Bypassing a Korean Game's Anti-Debugging, Layer by Layer
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.
Bypassing a Korean Game’s Anti-Debugging, Layer by Layer
Sample:
Y29tLndlbWFkZS5uaWdodGNyb3dz(base64)
Preface
A bit of a tangent: I’ve been job hunting lately. One company wanted to do “NP” work, which I declined outright. Another gave me an interview problem: analyze a cheat targeting one of their games and implement a working cheat feature. I eagerly downloaded the game, opened the APK’s lib directory, and the moment I saw libtprt.so and libtersafe2.so I knew what I was dealing with.
As everyone knows, that’s Tencent’s MTP. I admit my skill level isn’t enough to crack that, but I still steeled myself and dug in — mainly wanting to analyze its CRC detection. I found several CRC logic blocks, but none were the primary one, until I finally saw what looked like a VM. Maybe the core detection logic lives inside the VM? I’ll see if I get another chance to analyze it later.
After that small detour through libtprt.so, my spirit was broken, so I decided to find something easier to play with. A friend happened to share a sample recently — this one. It’s a UE5 game; the main goal is to look at its detection logic.
Frida crash analysis
After injecting Frida, the app crashes within about a second. Print the loaded SOs, and only one libdxbase.so belongs to the app itself — clearly the detection logic is inside it.
Pull libdxbase.so into IDA; no errors, so the SO is probably not packed.
Then, as usual, look at init_array first — nothing major, but decrypt1 is obviously a string-decryption function. Note it down.
Hook RegisterNatives; four functions are dynamically registered. Hook each to see which one gets called.
Note: I’ll call the d function reg_func_d, and so on.
[RegisterNatives] java_class: com.xshield.da name: d sig: (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IIIIIIII)Ljava/lang/String; fnPtr: 0x7137359258 fnOffset: 0x7137359258 libdxbase.so!0x18258 callee: 0x713736543c libdxbase.so!JNI_OnLoad+0x15e0
[RegisterNatives] java_class: com.xshield.da name: o sig: (II)I fnPtr: 0x7137348228 fnOffset: 0x7137348228 libdxbase.so!0x7228 callee: 0x713736543c libdxbase.so!JNI_OnLoad+0x15e0
[RegisterNatives] java_class: com.xshield.da name: p sig: (II)Ljava/lang/String; fnPtr: 0x7137347e78 fnOffset: 0x7137347e78 libdxbase.so!0x6e78 callee: 0x713736543c libdxbase.so!JNI_OnLoad+0x15e0
[RegisterNatives] java_class: com.xshield.da name: q sig: (Landroid/content/Context;ILjava/lang/String;)Ljava/lang/String; fnPtr: 0x7137360d60 fnOffset: 0x7137360d60 libdxbase.so!0x1fd60 callee: 0x713736543c libdxbase.so!JNI_OnLoad+0x15e0
The result: reg_func_d gets called, but only Enter with no Leave — so the detection logic is probably inside reg_func_d.
reg_func_d is nearly 2000 lines. Too lazy to analyze it statically piece by piece; just dynamic-debug to see where it crashes.
The fake time diff
The crash is at exit_func(0xFFFFFFFE), and before calling exit_func it does some time-diff operations, deciding based on the time diff whether to reach the exit_func part of the logic.

I assumed the above was just a normal time-diff anti-debugging check, but after hooking exit_func with Frida and printing the call stack, it’s the same place — i.e., Frida hooking still reaches the above location, then calls exit_func and crashes.
Digging into that block of logic: once execution reaches that location, it will inevitably go to exit_func. (Reason: sub_8250 returns a fixed value, and the time diff is always greater than v202.)
[exit_func] call in:
7bb25dab70 is in libdxbase.so offset: 0x1ab70
7c1f940354 is in libart.so offset: 0x140354
7c1f936470 is in libart.so offset: 0x136470
The weird time diff
Searching upward from that location for an “escape route”, I see goto LABEL_215. If I can get the execution flow into any of the goto LABEL_215 logic, I can avoid reaching that dead end.
Try the red-boxed goto LABEL_215. Condition 1 is *(_DWORD *)(import_data + 9972) == 0. Try to satisfy that first.

Cross-reference where (_DWORD *)(import_data + 9972) gets assigned; from the analysis, v197 is a time diff, but what two things the time diff is between isn’t directly visible in the pseudocode.

Only from the assembly view can I see it: some_timestamp above is assigned from lstat’s buf (x1) + 0x68. (x0 is /sbin.)
And [buf + 0x68] only has a value after lstat is called. But buf’s structure is struct stat, whose size seems smaller than 0x68, so buf[0x68] normally wouldn’t belong to struct stat. My guess is it’s due to memory alignment or something similar.

From the memory layout you can see buf + 0x68 is the last field of struct stat (the last three fields of struct stat are all timestamps), representing the directory’s “time of last status change”.

Convert 0x67517B0D:
It matches the date shown by ls -l on /sbin.
Using the same method, find the other value of the time diff: 0x676631AB.

It matches the date shown by ls -l on /system/lib.
What’s the point of computing the time diff between these two times?
Here’s an ordinary Magisk-environment Xiaomi phone — you can see the two dates differ by a lot.
sbin’s date is closer because there’s a shamiko file in it; presumably enabling/disabling the shamiko module refreshes its date.
Meanwhile /system/lib’s date is extremely old.
Summary: this part computes the time diff between /system/lib and /sbin “time of last status change” — this time diff feels like it’s detecting Magisk or similar.
Continuing down, it checks whether the time diff is greater than 0xF4240 sec; if so, condition 1 can’t be satisfied.
0xF4240 sec → 277 hrs 46 min 40 sec. In a normal phone environment the time diff shouldn’t exceed this.

Bypass script: hook lstat directly.
function hook_lstat() {
let fake_time = Date.now();
Interceptor.attach(Module.findExportByName(null, "lstat"), {
onEnter: function(args) {
this.name = args[0].readCString();
this.statbuf = args[1];
},
onLeave: function(retval) {
if (this.name == "/system/lib") {
this.statbuf.add(0x68).writeU64(fake_time++);
console.log("bypass lstat");
}
if (this.name == "/sbin") {
this.statbuf.add(0x68).writeU64(fake_time++);
console.log("bypass lstat");
}
}
})
}
After bypassing this time-diff check, the Magisk-environment Xiaomi phone still exits — probably because condition 2, check1_res == 9 || !check1_res, isn’t satisfied.

check1 analysis (root detection)
When check1 returns 9 or 0, condition 2 is satisfied. Let’s see what check1 checks.
Root check 1: popen("which su").

Root check 2: gets a bunch of paths where su might exist, then calls check_exist_in_different_way to check whether the given path exists.

check_exist_in_different_way creates a pthread_func2_check_path_exist thread to handle it.

It uses the following methods to check whether a passed path exists:
openat, syscall(__NR_openat), scandir, lstat, stat, access, readlink.

Note: the checked paths are roughly these:
[decrypt1] 0x3a304 /system/bin/su
[decrypt1] 0x3a2f4 /system/xbin/su
[decrypt1] 0x3a313 /system/bin/.ext/.su
[decrypt1] 0x3a328 /system/xbin/.tmpsu
[decrypt1] 0x3a33c /vendor/bin/su
[decrypt1] 0x3a34b /sbin/su
[decrypt1] 0x3a354 /system/xbin/nosu
[decrypt1] 0x3a366 /system/bin/nosu
[decrypt1] 0x3a377 /system/xbin/su_bk
[decrypt1] 0x3a38a /system/bin/su_bk
[decrypt1] 0x3a39c /system/xbin/xsu
[decrypt1] 0x3a3ad /system/xbin/suu
[decrypt1] 0x3a3be /system/xbin/bstk/su
[decrypt1] 0x3a3d3 /system/RootTools/su
[decrypt1] 0x3a3e8 /data/data/bin/su
[decrypt1] 0x3a3fa /data/data/in/su
[decrypt1] 0x3a40b /data/data/n/bstk/su
[decrypt1] 0x3a420 /data/data/xbin/su
[decrypt1] 0x3a433 /res/su
[decrypt1] 0x3a43b /data/local/bin/su
[decrypt1] 0x3a44e /data/local/su
[decrypt1] 0x3a45d /data/local/xbin/su
[decrypt1] 0x3a471 /system/su
[decrypt1] 0x3a47c /data/su
[decrypt1] 0x3a485 /su/bin/su
[decrypt1] 0x3a490 /su/bin/sush
[decrypt1] 0x3a49d /system/bin/failsafe/su
[decrypt1] 0x3a4b5 /system/sbin/su
[decrypt1] 0x3a4c5 /system/sd/xbin/su
[decrypt1] 0x3a4d8 /system/xbin/noxsu
[decrypt1] 0x3a4eb /magisk/.core/bin/su
[decrypt1] 0x3a500 /sbin/.magisk
[decrypt1] 0x3a50e /sbin/.core
[decrypt1] 0x3b0c3 /system/usr/we-need-root/su
[decrypt1] 0x3b0df /cache/su
[decrypt1] 0x3b0e9 /dev/su
Root check 3: check whether the fingerprint contains user-debug, eng/, or Custom Phone.

The corresponding bypass script:
function hook_popen() {
Interceptor.attach(Module.findExportByName(null, "popen"), {
onEnter: function(args) {
if (args[0].readCString().indexOf(" su") != -1) {
console.log("[popen] which su -> which xx");
Memory.writeUtf8String(args[0], "which xx");
}
}
})
}
// hook after dlopen libdxbase.so
function hook_pthread_func2() {
Interceptor.attach(base.add(0x983C), {
onEnter: function(args) {
let check_path = args[0].readPointer().readCString();
if (check_path.indexOf("/su") != -1) {
Memory.writeUtf8String(args[0].readPointer(), check_path.replace("/su", "/XX"));
// console.log(`[pthread_func2]: ${check_path} -> ${args[0].readPointer().readCString()}`);
}
if (check_path.indexOf("magisk") != -1) {
Memory.writeUtf8String(args[0].readPointer(), check_path.replace("magisk", "3Ag1sk"));
// console.log(`[pthread_func2]: ${check_path} -> ${args[0].readPointer().readCString()}`);
}
if (check_path.indexOf("/sbin") != -1) {
Memory.writeUtf8String(args[0].readPointer(), check_path.replace("/sbin", "/ABCD"));
// console.log(`[pthread_func2]: ${check_path} -> ${args[0].readPointer().readCString()}`);
}
// console.log("[pthread_func2] check: ", check_path);
this.a0 = args[0];
}
})
}
After bypassing all of the above, Frida no longer crashes, but the screen still shows ROOTED.
And j.rjshqqeirnhhbc.mq is actually Magisk’s randomized package name — meaning Magisk is being detected.

Magisk detection analysis
After bypassing the Frida crash, hook decrypt1 to save a relatively complete decrypted string log to help the analysis, called decrypt_str.log.
function hook_decrypt1() {
Interceptor.attach(base.add(0x5E84),{
onEnter(args){
this.a3 = args[3];
this.len = args[4].toInt32();
this.offset = this.a3.sub(base);
},
onLeave(retval){
let dec_str = this.a3.readCString(this.len);
console.log("[decrypt1] ", ptr(this.offset), dec_str);
}
})
}
Back to the Java layer
Hook those four dynamically-registered functions again; besides calling reg_func_d once, it keeps calling reg_func_p and reg_func_q. I tried analyzing the latter two directly, but found nothing.
Change tack: hook some Java-layer exit functions.
function printStack(){
console.log(Java.use("android.util.Log").getStackTraceString(Java.use("java.lang.Exception").$new()))
}
function hook_leave_java() {
Java.perform(() => {
let System = Java.use("java.lang.System");
System.exit.implementation = function() {
console.log("exit....")
printStack()
}
let Process = Java.use("android.os.Process");
Process.killProcess.implementation = function() {
console.log("killProcess....")
printStack()
}
})
}
System.exit is triggered, and the call stack is as follows — called by the com.xshield.x.run class.
exit....
java.lang.Exception
at java.lang.System.exit(Native Method)
at com.xshield.x.run(dd.java:186)
at java.lang.Thread.run(Thread.java:919)
The Java layer is obfuscated. Opening it in JEB can strip some simple obfuscation by default (like string obfuscation), which helps analysis.
Following upward from com.xshield.x.run to call_exit_thread_xref2, I see the "Scanrisk" string and thought it was the relevant logic, but after hooking, v2 == 0, so no "Scanrisk" branch is ever taken.

call_exit_thread_xref2 → u.call_exit_thread_xref1 → exit.

Try forcing call_exit_thread_xref2 to always return 1, skipping the original logic.
The result: the detection UI no longer shows, but after a while it still exits, calling the native-layer exit_func.
[exit_func] call in:
7da42486a4 is in libdxbase.so offset: 0x86a4
7e96fb7894 is in libc.so offset: 0xe6894
7e96f55b70 is in libc.so offset: 0x84b70
So call_exit_thread_xref2 only builds the detection UI; the real detection is elsewhere.
Print the call stack at the call_exit_thread_xref2 moment and keep tracing upward:
java.lang.Exception
at com.xshield.da.IIiIiiIIII(Native Method)
at com.xshield.da.IIiiiIIiIi(da.java:49)
at com.xshield.k.run(da.java:103)
at android.os.Handler.handleCallback(Handler.java:883)
at android.os.Handler.dispatchMessage(Handler.java:100)
at android.os.Looper.loop(Looper.java:224)
at android.app.ActivityThread.main(ActivityThread.java:7520)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:539)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:950)
com.xshield.k.run is as follows. When detected it takes the else branch; try making it take the if branch.
Same result: the detection UI no longer shows, but after a while it still exits. So I still have to go from the native layer.
Note: da.detectInfo is a name I manually renamed.

Globally search detectInfo (don’t just press x for cross-references — not accurate enough); find that it’s the return value of some reg_func_q call.
Above it is a while loop that calls da.q (i.e., reg_func_q) according to specific logic.

Hook da.q, and indeed a certain result is detectInfo.
Also visible: a certain reg_func_q call passes a bunch of package names as arguments, including j.rjshqqeirnhhbc.mq.
So the Magisk detection logic is likely: the Java layer collects installed-app package names, paths, etc. → calls da.q(ctx, 8, installed_app_info) to check → finds certain characteristics of j.rjshqqeirnhhbc.mq → determines it’s Magisk.

Based on the guess, try blanking out j.rjshqqeirnhhbc.mq in da.q’s argument so it doesn’t check j.rjshqqeirnhhbc.mq.
Result: the app no longer shows the detection UI and doesn’t auto-exit — the Magisk detection is successfully bypassed.
function hook_reg_func_q() {
let da = Java.use("com.xshield.da");
da["q"].implementation = function (context, i, str) {
// ;j.rjshqqeirnhhbc.mq;/data/app/j.rjshqqeirnhhbc.mq-YbP-hQjkQs0g9MZDz9dD0w==/base.apk;10205;tcp
str = str.replace(";j.rjshqqeirnhhbc.mq;/data/app/j.rjshqqeirnhhbc.mq-YbP-hQjkQs0g9MZDz9dD0w==/base.apk;10205;tcp", "")
console.log(`da.q is called: i=${i}, str=${str}`);
let result = this["q"](context, i, str);
console.log(`da.q result=${result}`);
return result;
};
}
This confirms the detection logic is indeed in da.q(ctx, 8, installed_app_info) (specifically when args[1] is 8, the above detection runs).
Native layer: reg_func_q case 8
Back to the native reg_func_q to analyze the concrete detection implementation, dynamic-debugging the case-8 situation.

First it writes the incoming installed_app_info into cfdd35cd.dex.

Its contents are:

Finally it creates a reg_func_q_pthread1 thread — the real detection is inside.

reg_func_q_pthread1 analysis
For some reason, dynamic debugging could never break inside reg_func_q_pthread1, so I had to analyze via hooks plus decrypt_str.log (mainly relying on these two to determine the execution flow).
Open XXX/base.apk → fd reverse-lookup (IO-redirection detection) → parse the .apk structure → get its AndroidManifest.xml.

Check whether AndroidManifest.xml contains the following permissions:
UPDATE_PACKAGES_WITHOUT_USER_ACTIONQUERY_ALL_PACKAGESFOREGROUND_SERVICEREQUEST_INSTALL_PACKAGESHIDE_OVERLAY_WINDOWS

And stock Magisk happens to contain all of the above permissions.

Does judging solely by permissions cause false positives? Yes. While searching for related material I found a bunch of users complaining on some forum about being falsely flagged — though that was 2021; by 2025 the issue should have improved a lot.
You can see it added some whitelists to avoid falsely killing normal apps that have the above permissions.

Bypass script: hook openat, redirect /data/app/j.rjshqqeirnhhbc.mq-YbP-hQjkQs0g9MZDz9dD0w==/base.apk to another normal apk.
Note: redirecting this way won’t be caught by the fd reverse-lookup above; the alternative Interceptor.replace would be.
function hook_openat() {
Interceptor.attach(Module.findExportByName(null, "openat"), {
onEnter: function(args) {
let path = args[1].readCString();
if (path.indexOf("/data/app/j.rjshqqeirnhhbc") != -1 && path.indexOf("base.apk") != -1) {
Memory.writeUtf8String(args[1], "/data/local/tmp/base.apk");
console.log("[openat] bypass: ", path, args[1].readCString());
}
// Thread.backtrace(this.context, Backtracer.FUZZY).map(addr_in_so);
}
})
}
The above script bypasses the Magisk detection, but the odd thing is: after hook_openat, even if the next run has no hook_openat, it still no longer pops the detection UI and doesn’t exit.
Even the execution flow changes — you have to reinstall the game to return to “normal”. Feels like a bug in the protection? Not sure.
Debuggable detection analysis
Both ways of giving the app debuggable permission — modifying AOSP or modifying AndroidManifest.xml — are detected.
Last time analyzing LIAPP there was a similar check; I didn’t figure it out then, so let’s look again.

Note: during analysis I found 0xB11C looks like a detection handler; I’ll call it mb_detect_handler.
Hook mb_detect_handler, print the call stack when the argument contains AndroidManifest.xml.
The related logic is at 0xb5bc.
[mb_detect_handler] 0x852 0x10000000 AndroidManifest.xml
LR: 0x7bb250b5bc
Function: 0xb5bc
moduleName: libdxbase.so
LR: 0x7ca37b6730
Function: _ZL15__pthread_startPv+0x28
moduleName: libc.so
LR: 0x7ca3757008
Function: __start_thread+0x44
moduleName: libc.so
Call 0xb5bc detected_APKMODE. Tracing upward, whether the detected_APKMODE thread is created is decided by g_APKMODE_flag1 and g_APKMODE_flag2.
Pressing x didn’t show where g_APKMODE_flag1 and g_APKMODE_flag2 are assigned. I tried Frida’s memory breakpoint, but with little effect.

Switched to a self-implemented Frida memory breakpoint from another article, and it hit successfully:
// usage: readwritebreak(base.add(0x3D700), 4, 1)
hit : 0x7bb253e700 pc pointer : 0x7bb2519f0c
{"name":"libdxbase.so","base":"0x7bb2501000","size":258048,"path":"/data/app/com.wemade.nightcrows-3nZhg8hrtpfvU8YQT2BvZw==/lib/arm64/libdxbase.so"}
pc - - > libdxbase.so -> 0x18f0c
readwritebreak exit
Go look at libdxbase.so!0x18f0c (it’s inside reg_func_d).
It seems import_data + 9984 is g_APKMODE_flag1 (confirmed by dynamic debugging), and its value comes from a19.

Hook reg_func_d, print at enter and leave respectively — it indeed only has a value at leave, i.e., g_APKMODE_flag is assigned inside reg_func_d.

And a19 is actually reg_func_d’s args[18] (the third-to-last argument).

Look at how the Java layer passes it — it’s the ApplicationInfo’s flags property.

Hook the Java-layer reg_func_d and strip the FLAG_DEBUGGABLE flag.
Result: the game finally stops showing the APKMOD detection UI, successfully bypassing its debuggable detection.
function hook_reg_func_d() {
let da = Java.use("com.xshield.da");
da["d"].implementation = function (str, str2, str3, str4, str5, str6, str7, str8, str9, str10, str11, i9, i10, i11, i12, i13, i14, i15, i16) {
const FLAG_DEBUGGABLE = 0x2;
i14 &= (~FLAG_DEBUGGABLE);
console.log(`da.d is called: str=${str}, str2=${str2}, str3=${str3}, str4=${str4}, str5=${str5}, str6=${str6}, str7=${str7}, str8=${str8}, str9=${str9}, str10=${str10}, str11=${str11}, i9=${i9}, i10=${i10}, i11=${i11}, i12=${i12}, i13=${i13}, i14=${i14}, i15=${i15}, i16=${i16}`);
let result = this["d"](str, str2, str3, str4, str5, str6, str7, str8, str9, str10, str11, i9, i10, i11, i12, i13, i14, i15, i16);
console.log(`da.d result=${result}`);
return result;
};
}
The complete bypass script
function hook_dlopen(soName) {
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(soName) >= 0) {
this.is_can_hook = true;
}
}
},
onLeave: function (retval) {
if (this.is_can_hook) {
console.log("hook start...");
hook_func(soName)
}
}
}
);
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(soName) >= 0) {
this.is_can_hook = true;
}
}
},
onLeave: function (retval) {
if (this.is_can_hook) {
console.log("hook start...");
hook_func(soName)
}
}
}
);
}
function hook_func(soName) {
var base = Module.findBaseAddress(soName);
function hook_pthread_func2() {
Interceptor.attach(base.add(0x983C), {
onEnter: function(args) {
// bypass root check2
let check_path = args[0].readPointer().readCString();
if (check_path.indexOf("/su") != -1) {
Memory.writeUtf8String(args[0].readPointer(), check_path.replace("/su", "/XX"));
}
if (check_path.indexOf("magisk") != -1) {
Memory.writeUtf8String(args[0].readPointer(), check_path.replace("magisk", "3Ag1sk"));
}
if (check_path.indexOf("/sbin") != -1) {
Memory.writeUtf8String(args[0].readPointer(), check_path.replace("/sbin", "/ABCD"));
}
}
})
}
function hook_openat() {
Interceptor.attach(Module.findExportByName(null, "openat"), {
onEnter: function(args) {
let path = args[1].readCString();
// bypass Magisk check
if (path.indexOf("/data/app/j.rjshqqeirnhhbc") != -1 && path.indexOf("base.apk") != -1) {
Memory.writeUtf8String(args[1], "/data/local/tmp/base.apk");
console.log("[openat] bypass: ", path, args[1].readCString());
}
}
})
}
hook_pthread_func2();
hook_openat();
}
function hook_lstat() {
let fake_time = Date.now();
// bypass frida crash
Interceptor.attach(Module.findExportByName(null, "lstat"), {
onEnter: function(args) {
this.name = args[0].readCString();
this.statbuf = args[1];
},
onLeave: function(retval) {
if (this.name == "/system/lib") {
this.statbuf.add(0x68).writeU64(fake_time++);
console.log("bypass lstat");
}
if (this.name == "/sbin") {
this.statbuf.add(0x68).writeU64(fake_time++);
console.log("bypass lstat");
}
}
})
}
function hook_popen() {
Interceptor.attach(Module.findExportByName(null, "popen"), {
onEnter: function(args) {
if (args[0].readCString().indexOf(" su") != -1) {
// bypass root check1
console.log("[popen] which su -> which xx");
Memory.writeUtf8String(args[0], "which xx");
}
}
})
}
function printStack(){
console.log(Java.use("android.util.Log").getStackTraceString(Java.use("java.lang.Exception").$new()))
}
function hook_java() {
Java.perform(() => {
function hook_reg_func_d() {
let da = Java.use("com.xshield.da");
da["d"].implementation = function (str, str2, str3, str4, str5, str6, str7, str8, str9, str10, str11, i9, i10, i11, i12, i13, i14, i15, i16) {
// bypass debuggable
const FLAG_DEBUGGABLE = 0x2;
i14 &= (~FLAG_DEBUGGABLE);
console.log(`da.d is called: str=${str}, str2=${str2}, str3=${str3}, str4=${str4}, str5=${str5}, str6=${str6}, str7=${str7}, str8=${str8}, str9=${str9}, str10=${str10}, str11=${str11}, i9=${i9}, i10=${i10}, i11=${i11}, i12=${i12}, i13=${i13}, i14=${i14}, i15=${i15}, i16=${i16}`);
let result = this["d"](str, str2, str3, str4, str5, str6, str7, str8, str9, str10, str11, i9, i10, i11, i12, i13, i14, i15, i16);
console.log(`da.d result=${result}`);
return result;
};
}
hook_reg_func_d();
})
}
function main() {
hook_dlopen("libdxbase.so");
hook_lstat();
hook_popen();
hook_java();
}
setImmediate(main)
Closing thoughts
Overall this protection is similar to the LIAPP I analyzed before — not hard, just tedious.
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