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:

  1. UPDATE_PACKAGES_WITHOUT_USER_ACTION
  2. QUERY_ALL_PACKAGES
  3. FOREGROUND_SERVICE
  4. REQUEST_INSTALL_PACKAGES
  5. HIDE_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