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

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

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.

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.

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:

Just a partial screenshot:

Then start Frida!
I first attached, and it crashed:

Switching to strongR Frida (with some signatures stripped) didn’t change anything. I then tried:
- Changing the port, and spawn-mode start.
- Hooking
strstr/strcmpin libc.so to erase thefrida,gmain,gdbusstrings from memory. - Hooking/redirecting
/proc/xxx/maps. - Hooking libc.so’s
exit. - 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;
// }
}
});
}

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

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:

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:

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:

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:

- Use MT Manager to replace the original dex with these 21 dex.
- Delete the four numeric-packager
.sofiles in theassetsfolder (thelibjiagu.soetc.). - Change
com.stub.StubAppin AndroidManifest.xml to the app’s real entrycom.xxxxxx.
This app is huge — the AndroidManifest alone is nearly 5000 lines! (Changing smali later was painful.)
Repacking

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:

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

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