Unpacking a Numeric-Brand Packer, Bypassing Frida Detection, and Repacking

About the author. I’m Colin Xiao — an independent reverse engineer, mostly working solo on contract jobs. I dig into compiled code of every kind, including but not limited to: Android and iOS apps, x86/x64/ARM native binaries, packers and obfuscators, DRM and license checks, network protocols and APIs, crypto and signature algorithms, malware, game binaries, and embedded/IoT firmware. I also handle large-scale data collection and vulnerability research. If you have any project in mind, I’d be glad to take it on — whatever it is, as long as it’s within my wheelhouse. Just reach out. You can reach me on X at @colinxiao_lab or on Telegram at https://t.me/ColinXiao666.

This research is published for educational and defensive purposes. The target sample is anonymized throughout.

Unpacking a Numeric-Brand Packer, Bypassing Frida Detection, and Repacking

Hi everyone. The app in question is a domestic planning app — feature-complete, nice-looking UI, and genuinely useful. I really like it. This took three evenings plus one daytime: packet capture analysis, unpacking, bypassing detection, hand-patching smali, and debugging. I’ve been doing development for a long time, but it’s been a while since I touched reverse engineering, so I was rusty.

The only problem: the membership is too expensive — a lifetime membership costs over 300 yuan!

For the company’s interests, I won’t provide the finished product or any app-specific info.

Nowadays apps big and small are all packed — even some gray-industry apps use these domestic packers! Extract here, dex2c there — can’t play around happily anymore.

Unpacking

Packer

And the libjiagu.so in the assets directory tells you it’s the numeric-brand packer, no doubt:

assets libjiagu.so

Unpacking with apktool, I found it’s the new numeric-brand protection from June.

For unpacking I used a FART-based unpacker; I won’t detail the process.

unpacker

In total 21 dex files were dumped. I loaded them one by one into jadx to check which were useful; two turned out to be all packer-related, leaving 19.

The dump is still relatively complete, though some parts are damaged — not a big deal.

dumped dex

Finding the signature & Frida hooking

Since I need the VIP feature, per convention I first searched for isVip and similar strings.

Many results came up — no problem. Excluding the app’s ad SDK and dependency libraries, I looked through them one by one for user-related ones, and found two relevant classes.

Wrote the hook directly:

hook

Just a partial screenshot:

hook partial

Then start Frida!

I first attached, and it crashed:

crash

Switching to strongR Frida (with some signatures stripped) didn’t change anything. I then tried:

  1. Changing the port, and spawn-mode start.
  2. Hooking strstr/strcmp in libc.so to erase the frida, gmain, gdbus strings from memory.
  3. Hooking/redirecting /proc/xxx/maps.
  4. Hooking libc.so’s exit.
  5. Hooking android.os.Process.killProcess.

Combined with the usual detection-bypass scripts, it still crashed. Something’s up.

Bypassing the Frida detection

Heads up: this Frida detection isn’t in the packer — it’s in the app’s .so. Also, the business-code hook has to run after a delay, otherwise the classloader hasn’t loaded the relevant classes yet.

Since it’s not in the Java layer, it’s in the native layer. Usually you hook android_dlopen_ext and watch which .so loads right before the exit:

function hook_dlopenAndExt() {
    Interceptor.attach(Module.findExportByName(null, "dlopen"), {
        onEnter: function (args) {
            var pathptr = args[0];
            if (pathptr !== undefined && pathptr != null) {
                var path = ptr(pathptr).readCString();
                //console.log("dlopen:", path);
                // if (path.indexOf("libart.so") >= 0) {
                //     // this.can_hook_libart = true;
                //     console.log("[dlopen:]", path);
                // }
                console.log("load " + path);
            }
        },
        onLeave: function (retval) {
            // if (this.can_hook_libart && !is_hook_libart) {
            //     dump_dex();
            //     is_hook_libart = true;
            // }
        }
    })

    Interceptor.attach(Module.findExportByName(null, "android_dlopen_ext"), {
        onEnter: function (args) {
            var pathptr = args[0];
            if (pathptr !== undefined && pathptr != null) {
                var path = ptr(pathptr).readCString();
                //console.log("android_dlopen_ext:", path);
                // if (path.indexOf("libart.so") >= 0) {
                //     // this.can_hook_libart = true;
                //     console.log("[android_dlopen_ext:]", path);
                // }
                console.log("load " + path);
            }
        },
        onLeave: function (retval) {
            // if (this.can_hook_libart && !is_hook_libart) {
            //     dump_dex();
            //     is_hook_libart = true;
            // }
        }
    });
}

dlopen trace

That locates it to libmxxxdesc.so.

Then hook pthread_create to try to find the detection thread created by libmxxxdesc.so:

pthread_create

But it got stuck there — I couldn’t find any thread-creation call from that .so.

The part below borrows from a Kanxue writeup on bypassing a major video app’s Frida detection.

Putting the .so into IDA, I also found no imported symbol for creating threads:

IDA imports

So I tried hooking dlsym at an earlier stage, to see whether pthread_create’s address is obtained via dlsym before being called.

Sure enough, it does call the thread-creation function — just not directly; it gets the address via dlsym and calls that:

dlsym

Next, return a fake thread-creation function address to fool the target .so (still the idea and code from that video-app bypass writeup):

function create_fake_pthread_create() {
    const fake_pthread_create = Memory.alloc(4096)
    Memory.protect(fake_pthread_create, 4096, "rwx")
    Memory.patchCode(fake_pthread_create, 4096, code => {
        const cw = new Arm64Writer(code, { pc: ptr(fake_pthread_create) })
        cw.putRet()
    })
    return fake_pthread_create
}
 
function hook_dlsym() {
    var count = 0
    console.log("=== HOOKING dlsym ===")
    var interceptor = Interceptor.attach(Module.findExportByName(null, "dlsym"),
        {
            onEnter: function (args) {
                const name = ptr(args[1]).readCString()
                console.log("[dlsym]", name)
                if (name == "pthread_create") {
                    count++
                }
            },
            onLeave: function(retval) {
                if (count == 1) {
                    retval.replace(fake_pthread_create)
                }
                else if (count == 2) {
                    retval.replace(fake_pthread_create)
                    // after 2 replacements, stop hooking dlsym
                    interceptor.detach()
                }
            }
        }
    )
    return Interceptor
}
 
function hook_dlopen() {
    var interceptor = Interceptor.attach(Module.findExportByName(null, "android_dlopen_ext"),
        {
            onEnter: function (args) {
                var pathptr = args[0];
                if (pathptr !== undefined && pathptr != null) {
                    var path = ptr(pathptr).readCString();
                    console.log("[LOAD]", path)
                    if (path.indexOf("libmxxxxxec.so") > -1) {
                        hook_dlsym()
                    }
                }
            }
        }
    )
    return interceptor
}
 
// create a fake pthread_create
var fake_pthread_create = create_fake_pthread_create()
var dlopen_interceptor = hook_dlopen()

Detection bypassed:

bypassed

Fixing the dex

Hooking the key functions did produce the paid-VIP effect, but some screens still had VIP-style display issues.

I dumped each dex into jadx one by one, removed the useless ones, and found I could drop two dex files that are pure numeric-brand-packer signatures:

dex cleanup

  1. Use MT Manager to replace the original dex with these 21 dex.
  2. Delete the four numeric-packager .so files in the assets folder (the libjiagu.so etc.).
  3. Change com.stub.StubApp in AndroidManifest.xml to the app’s real entry com.xxxxxx.

This app is huge — the AndroidManifest alone is nearly 5000 lines! (Changing smali later was painful.)

Repacking

repack

Then repack and compile, sign with jarsigner — all in one go. Install, and… crash! Lovely!

At first I thought there might be signature verification, so I searched jadx for packagemanager-related code — but nothing fit. Finally I realized it’s residual packer signatures left from unpacking:

Just like this effect, 1000+ of them:

residual signatures

invoke-static {p0, p1, p2}, Lcom/stub/StubApp;->interface24(Landroid/app/Activity;[Ljava/lang/String;I)V

You can match-and-replace these with regex. It’s tedious — I spent over half an hour replacing all sorts of them. Nasty!

Here I used a one-click-regex tool as a shortcut (though it was still slow). These two below can generically replace some, but there are still lots and lots of stragglers:

invoke-static/range \{.* \.\. .*\}, Lcom/stub/StubApp;->getOrigApplicationContext\(Landroid/content/Context;\)Landroid/content/Context;\s+move-result-object .*
invoke-super {p0, p1}, Landroid/app/Activity;->onCreate(Landroid/os/Bundle;)V

Actually I got lucky with this app — the onCreate function wasn’t extracted, so it was cooperative!

After finishing all the replacements, and confirming no stubapp / stub/stub numeric-packer signatures remain, I repacked and signed again, and it opened. Testing, I found two sub-pages crash on open, but the pages I need all work (this app has 62 pages in total, so those two don’t matter).

Patching smali

Full disclosure: I don’t know smali (everything below is learn-as-you-go, so it’ll look like crap — that’s normal).

This step has no real technical depth (for a small fry like me and a simple app like this); it’s mostly patience and care. I used MT Manager to edit — convenient indeed — but changing smali is painful: you have to manipulate registers, you can’t verify until you repack and install, and one slip causes a crash. As mentioned, there are two relevant classes. One has get/set methods, easy to handle — for a getter, just:

const/4 v1, 0x1

then return or assign it:

public class Uxxxxfo implements Serializable {
    public List<AdX> adxList;
    public boolean axxxxeVip;
    public String alxxxcon;
    public String axxxge;
    public CheckFreeVipInfo cxxxnfo;
    public boolean evxxp;
    public int exxxxay;
    public String id;
    public boolean isPoxxxp;
    public boolean isxxxit;
    public boolean isVip;
......
......

The other bean is all public fields with no get/set methods, and it’s referenced in a lot of places. I can’t assign in the constructor because it gets overwritten later. I thought of doing it via packet capture and packet modification — I tested that on a rooted device with Xposed and it worked — but I want it to run without root or Xposed, so that approach won’t do.

I had to patch every place it’s used, e.g.:

Lcom/dxxxx/lxxxxon/model/Uxxxxfo;->id:Ljava/lang/String;

    check-cast v2, Ljava/lang/CharSequence;

    invoke-static {v2}, Landroid/text/TextUtils;->isEmpty(Ljava/lang/CharSequence;)Z

    move-result v2

    const-string v8, "mContext"

    if-nez v2, :cond_218

    iget-boolean v2, v1, Lcom/xxx/xxxxx/model/Usxxxxxfo;->vstate:Z

    if-nez v2, :cond_7f

    goto/16 :goto_218

I need vstate to always be true, so I changed it to:

    const/4 v2, 0x1  # store constant 1 (true) into register v2
    iput-boolean v2, v1, Lcom/xxx/xxxxx/model/Usxxxxxfo;->vstate:Z
    iget-boolean v2, v1, Lcom/xxx/xxxxx/model/Usxxxxxfo;->vstate:Z
    if-nez v2, :cond_7f

The finished product

result

Afterword

I used to capture with Charles + Postern — in a friend’s words, “go over sockets, closer to the bottom layer, and you get more upper-layer traffic.”

Now I’ve switched to Reqable for capture. First time using it; quite convenient, and it also needs root — no way around that.

About cert installation: Android 7+ requires manually remounting and moving the cert into /system/etc/security/cacerts. I tried several times and Reqable still didn’t recognize the cert as installed, even though 64xxxk.0 was already in the system cert dir. Later I tried removing the Charles cert, twice, and after a reboot it worked! As for no-network or other issues preventing cert installation, those are recorded on my blog.

Conclusion

Dare to try, and success is possible.

Code

Some of the detection-bypass code used in this writeup:

function loadGson() {

    Java.openClassFile("/data/local/tmp/xiaosheng-dex-tool.dex").load();
    var js = Java.use("com.xiaosheng.tool.json.Gson");
    var gson = js.$new();
    return gson;

}

function hook_dlopen_ext() {
    Interceptor.attach(Module.findExportByName(null, "android_dlopen_ext"),
        {
            onEnter: function (args) {
                var pathptr = args[0];
                if (pathptr !== undefined && pathptr != null) {
                    var path = ptr(pathptr).readCString();
                    console.log("load " + path);
                }
            }
        }
    );
}

function hook_dlopenAndExt() {
    Interceptor.attach(Module.findExportByName(null, "dlopen"), {
        onEnter: function (args) {
            var pathptr = args[0];
            if (pathptr !== undefined && pathptr != null) {
                var path = ptr(pathptr).readCString();
                //console.log("dlopen:", path);
                // if (path.indexOf("libart.so") >= 0) {
                //     // this.can_hook_libart = true;
                //     console.log("[dlopen:]", path);
                // }
                console.log("load " + path);
            }
        },
        onLeave: function (retval) {
            // if (this.can_hook_libart && !is_hook_libart) {
            //     dump_dex();
            //     is_hook_libart = true;
            // }
        }
    })

    Interceptor.attach(Module.findExportByName(null, "android_dlopen_ext"), {
        onEnter: function (args) {
            var pathptr = args[0];
            if (pathptr !== undefined && pathptr != null) {
                var path = ptr(pathptr).readCString();
                //console.log("android_dlopen_ext:", path);
                // if (path.indexOf("libart.so") >= 0) {
                //     // this.can_hook_libart = true;
                //     console.log("[android_dlopen_ext:]", path);
                // }
                console.log("load " + path);
            }
        },
        onLeave: function (retval) {
            // if (this.can_hook_libart && !is_hook_libart) {
            //     dump_dex();
            //     is_hook_libart = true;
            // }
        }
    });
}

function hook_open() {
    var pth = Module.findExportByName(null, "open");
    Interceptor.attach(ptr(pth), {
        onEnter: function (args) {
            this.filename = args[0];
            console.log("", this.filename.readCString())
            if (this.filename.readCString().indexOf(".so") != -1) {
                args[0] = ptr(0)
            }
        }, onLeave: function (retval) {
            return retval;
        }
    })
}

function hookProcess() {
    var process = Java.use("android.os.Process");
    process.killProcess.implementation = function (pid) {
        console.log("kill process:" + pid)
    }
}

function hookExit() {
    var ByPassTracerPid = function () {
        var fgetsPtr = Module.findExportByName("libc.so", "fgets");
        var fgets = new NativeFunction(fgetsPtr, 'pointer', ['pointer', 'int', 'pointer']);
        Interceptor.replace(fgetsPtr, new NativeCallback(function (buffer, size, fp) {
            var retval = fgets(buffer, size, fp);
            var bufstr = Memory.readUtf8String(buffer);
            if (bufstr.indexOf("TracerPid:") > -1) {
                Memory.writeUtf8String(buffer, "TracerPid:\t0");
                console.log("tracerpid replaced: " + Memory.readUtf8String(buffer));
            }
            return retval;
        }, 'pointer', ['pointer', 'int', 'pointer']));
    };



}
function hook_Pthreadfunc() {
    var pthread_creat_addr = Module.findExportByName("libc.so", "pthread_create")
    Interceptor.attach(pthread_creat_addr, {
        onEnter(args) {
            console.log("call pthread_create...")
            let func_addr = args[2]
            console.log("The thread function address is " + func_addr)
            try {
                console.log('pthread_create called from:\n'
                    + Thread.backtrace(this.context, Backtracer.ACCURATE)
                        .map(DebugSymbol.fromAddress)
                        .join('\n')
                    + '\n');
            } catch (e) {

            }
        }
    })
}
function hookBaseExit() {
    function main() {
        const openPtr = Module.getExportByName('libc.so', 'open');
        const open = new NativeFunction(openPtr, 'int', ['pointer', 'int']);
        var readPtr = Module.findExportByName("libc.so", "read");
        var read = new NativeFunction(readPtr, 'int', ['int', 'pointer', "int"]);
        // var fakePath = "/sdcard/app/maps/maps";
        var fakePath = "/data/local/tmp/fakeMap";
        var file = new File(fakePath, "w");
        var buffer = Memory.alloc(512);
        Interceptor.replace(openPtr, new NativeCallback(function (pathnameptr, flag) {
            var pathname = Memory.readUtf8String(pathnameptr);
            var realFd = open(pathnameptr, flag);
            if (pathname.indexOf("maps") != 0) {
                while (parseInt(read(realFd, buffer, 512)) !== 0) {
                    var oneLine = Memory.readCString(buffer);
                    if (oneLine.indexOf("tmp") === -1) {
                        file.write(oneLine);
                    }
                }
                var filename = Memory.allocUtf8String(fakePath);
                return open(filename, flag);
            }
            var fd = open(pathnameptr, flag);
            return fd;
        }, 'int', ['pointer', 'int']));
    }
    setImmediate(main)
}

function replace_str() {
    var pt_strstr = Module.findExportByName("libc.so", 'strstr');
    var pt_strcmp = Module.findExportByName("libc.so", 'strcmp');

    Interceptor.attach(pt_strstr, {
        onEnter: function (args) {
            var str1 = args[0].readCString();
            var str2 = args[1].readCString();
            if (str2.indexOf("tmp") !== -1 ||
                str2.indexOf("frida") !== -1 ||
                str2.indexOf("gum-js-loop") !== -1 ||
                str2.indexOf("gmain") !== -1 ||
                str2.indexOf("gdbus") !== -1 ||
                str2.indexOf("pool-frida") !== -1 ||
                str2.indexOf("linjector") !== -1) {
                // console.log("strcmp-->", str1, str2);
                this.hook = true;
            }
        }, onLeave: function (retval) {
            if (this.hook) {
                retval.replace(0);
            }
        }
    });

    Interceptor.attach(pt_strcmp, {
        onEnter: function (args) {
            var str1 = args[0].readCString();
            var str2 = args[1].readCString();
            if (str2.indexOf("tmp") !== -1 ||
                str2.indexOf("frida") !== -1 ||
                str2.indexOf("gum-js-loop") !== -1 ||
                str2.indexOf("gmain") !== -1 ||
                str2.indexOf("gdbus") !== -1 ||
                str2.indexOf("pool-frida") !== -1 ||
                str2.indexOf("linjector") !== -1) {
                // console.log("strcmp-->", str1, str2);
                this.hook = true;
            }
        }, onLeave: function (retval) {
            if (this.hook) {
                retval.replace(0);
            }
        }
    })

}
// define a function anti_maps to prevent matching of specific strings, avoiding detection of sensitive content like "Frida" or "REJECT"
function anti_maps() {
    // find the address of the strstr function in libc.so; strstr locates the first occurrence of a substring in a string
    var pt_strstr = Module.findExportByName("libc.so", 'strstr');
    // find the address of the strcmp function in libc.so; strcmp compares two strings
    var pt_strcmp = Module.findExportByName("libc.so", 'strcmp');
    // attach to strstr with Interceptor to intercept and modify its behavior
    Interceptor.attach(pt_strstr, {
        // callback before strstr is called
        onEnter: function (args) {
            // read strstr's first argument (source string) and second argument (substring to find)
            var str1 = args[0].readCString();
            var str2 = args[1].readCString();
            // if the substring contains "REJECT" or "frida", set the hook flag to true
            if (str2.indexOf("REJECT") !== -1 || str2.indexOf("frida") !== -1) {
                this.hook = true;
            }
        },
        // callback after strstr is called
        onLeave: function (retval) {
            // if the hook flag was set, replace strstr's result with 0 (not found) to hide the sensitive info
            if (this.hook) {
                retval.replace(0);
            }
        }
    });

    // similar handling for strcmp, to prevent detecting sensitive info via string comparison
    Interceptor.attach(pt_strcmp, {
        onEnter: function (args) {
            var str1 = args[0].readCString();
            var str2 = args[1].readCString();
            if (str2.indexOf("REJECT") !== -1 || str2.indexOf("frida") !== -1) {
                this.hook = true;
            }
        },
        onLeave: function (retval) {
            if (this.hook) {
                // strcmp returns 0 when the strings are equal; replace with 0 here too to avoid a successful match
                retval.replace(0);
            }
        }
    });
}

const STD_STRING_SIZE = 3 * Process.pointerSize;
class StdString {
    constructor() {
        this.handle = Memory.alloc(STD_STRING_SIZE);
    }

    dispose() {
        const [data, isTiny] = this._getData();
        if (!isTiny) {
            Java.api.$delete(data);
        }
    }

    disposeToString() {
        const result = this.toString();
        this.dispose();
        return result;
    }

    toString() {
        const [data] = this._getData();
        return data.readUtf8String();
    }

    _getData() {
        const str = this.handle;
        const isTiny = (str.readU8() & 1) === 0;
        const data = isTiny ? str.add(1) : str.add(2 * Process.pointerSize).readPointer();
        return [data, isTiny];
    }
}

function prettyMethod(method_id, withSignature) {
    const result = new StdString();
    Java.api['art::ArtMethod::PrettyMethod'](result, method_id, withSignature ? 1 : 0);
    return result.disposeToString();
}


function hook_libc_exit() {
    var exit = Module.findExportByName("libc.so", "exit");
    console.log("native:" + exit);

    Interceptor.attach(exit, {
        onEnter: function (args) {
            try {
                console.log(Thread.backtrace(this.context, Backtracer.FUZZY).map(DebugSymbol.fromAddress).join("\n"));
            } catch (e) {
                console.log(e)
            }
        },
        onLeave: function (retval) {
            //send("gifcore so result value: "+retval);
        }
    });
}

function anti_exit() {
    const exit_ptr = Module.findExportByName(null, '_exit');
    // DMLog.i('anti_exit', "exit_ptr : " + exit_ptr);
    console.log("anti_kill, kill_ptr:" + exit_ptr)
    if (null == exit_ptr) {
        return;
    }
    Interceptor.replace(exit_ptr, new NativeCallback(function (code) {
        if (null == this) {
            return 0;
        }
        // var lr = FCCommon.getLR(this.context);
        // DMLog.i('exit debug', 'entry, lr: ' + lr);
        console.log("kill debug entry,lr")
        return 0;
    }, 'int', ['int', 'int']));
}

function anti_kill() {
    const kill_ptr = Module.findExportByName(null, 'kill');
    // DMLog.i('anti_kill', "kill_ptr : " + kill_ptr);
    console.log("anti_kill, kill_ptr:" + kill_ptr)

    if (null == kill_ptr) {
        return;
    }
    Interceptor.replace(kill_ptr, new NativeCallback(function (ptid, code) {
        if (null == this) {
            return 0;
        }
        // var lr = FCCommon.getLR(this.context);
        // DMLog.i('kill debug', 'entry, lr: ' + lr);
        console.log("kill debug entry,lr")
        // FCAnd.showNativeStacks(this.context);
        return 0;
    }, 'int', ['int', 'int']));
}

// I had reference issues with the FCCommon library, so I commented that code out

Colin Xiao — I take on contract reverse-engineering work, and writeups like this are how I show what I can do. If you need help with anything in this space — including but not limited to the areas I cover above — I’m available. No project is too big or too small — if it’s reverse engineering and I can do it, I’ll take it. DM me on X (@colinxiao_lab) or Telegram (@ColinXiao666).

Get in touch