Analyzing the LIAPP Game Protection: Anti-Debugging, Root/Emulator Detection, and an il2cpp Dump

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.

Analyzing the LIAPP Game Protection: Anti-Debugging, Root/Emulator Detection, and an il2cpp Dump

Package name: net.gameduo.tbd Disclosure: this writeup is for learning and exchange only.

Foreword

A quick run through an analysis of the LIAPP game-protection SDK.

First encounter with the anti-debugging

Opening the app directly trips a debuggable prompt:

Debuggable prompt

Injecting Frida surfaces a ng1ok-64.so prompt (a stock Frida agent would be frida-agent-64.so).

Hooking dlopen shows that before the crash only libdyzzwwc.so is loaded — so the anti-Frida logic clearly lives in that library.

Looking at libdyzzwwc.so’s .init_array, something looks off:

Odd .init_array

Pressing D in IDA to force re-parsing reveals there’s actually only one init function in static analysis — the detection logic is probably in there. I renamed sub_B8080 to init_array_func1.

Stepping into init_array_func1, some calls can’t be resolved by static analysis, like this:

When that happens, the only option is dynamic analysis.

Dynamically analyzing .init_array

Note: some functions below are renamed by me, not their original symbols.

Before stepping through, keep the goal in mind:

  1. Try to locate the detection logic.
  2. Get comfortable with the code (trust the code and it’ll help you back).
  3. Find the string-decryption routines / string-processing functions.

init_array_func1

Break on init_array_func1 and start stepping. First it checks whether v1 contains .sandbox — not entirely sure what this targets, maybe some sandbox environment?

My environment doesn’t take that branch, so moving on.

.sandbox check

Note: v1 comes from /proc/self/environ, and a while loop iterates over all of its elements:

Environment contents

Then it checks whether v1 contains com.lbe.parallel. Looking up that app, it’s described as “easily clone and run multiple accounts of the same app” — a multi-instance tool. So multi-instance tools are also banned.

Skipping some less important logic, at the end it calls a few functions. Let’s go through them one by one.

First, check_blackdex, which checks for blackdex — a well-known unpacking tool. Detection triggers kill_func:

check_blackdex

Next, check_something. A few 5C85F0A264 pointers in the middle point into app_process64 somewhere — ignore those. The interesting parts are do_something1 and do_something2:

check_something

do_something1 first opens /proc/self/maps — a classic detection point. The maps content is passed to check1:

do_something1

check1 looks for the following (partial list), including Frida, Xposed, etc. Since my modified frida-agent.so sits under /data/local, it gets flagged. When any of these strings appear in maps, it returns -1; otherwise 0 means clean.

check1 strings

check1 strings continued

Back in do_something1, there’s another layer. First it enumerates /proc/<pid>/task with scandir:

scandir over /proc/<pid>/task

Then iterates those thread directories, reading /proc/<tid>/comm:

Reading /proc/<tid>/comm

Then compares /proc/<tid>/comm against the following strings; a match means detection. Notice the familiar Frida signatures pool-frida, gum-js-loop, gbus, gamin:

Thread comm signatures

Thread comm signatures continued

Back in check_something, do_something2 opens /proc/<tid>/maps and calls check_maybe_io_redirect — arg0 is the FILE* from fopen, arg1 is "/proc/<tid>/maps".

Diving into check_maybe_io_redirect, it calls check_fd:

check_maybe_io_redirect

check_fd does the following:

  1. Builds a string like /proc/16875/fd/38 via sprintf, where 38 is the fopen return value.
  2. Calls readlink to resolve /proc/16875/fd/38 (yielding something like /proc/16875/maps) into buf1.
  3. Compares the passed-in proc_maps with buf1; normally they should be equal — both /proc/<pid>/maps.

check_fd

Note: cmp_func1 is strcmp-like, returning 0 only on equality.

So the gist is likely an IO-redirection check. I didn’t test further, so I’m not 100% sure.

Back in do_something2, there’s a large block of proc_maps checking/manipulation that seems unimportant:

do_something2

At the end it calls check_maybe_io_redirect again, then stores some info about base.apk:

base.apk info

That wraps up the important functions in init_array_func1.

init_array_func2

Static analysis only shows one .init_array function, but there are actually two. After init_array_func1 returns, single-step forward and you’ll hit init_array_func2 (or break in the linker).

It only calls one function; step in:

init_array_func2

First is a string-decryption routine whose output is /linker, then like_strcpy copies it to v24. Then it calls like_dlopen, which opens a new linker and does some initialization.

Step into like_dlopen. get_linker_startaddr gets the original linker’s start address (stored at *((_QWORD *)a1 + 3)), then openat + mmap maps the new linker into memory:

get_linker_startaddr

Then a while loop walks the section header tables (confirmed by byte-comparing against the original linker file) and initializes based on s_type, storing results in a1 + x:

Section header walk

Section header walk continued

Section header walk continued

In short, like_dlopen is a miniaturized reimplementation of dlopen.

Back up a level: after like_dlopen, several like_dlsym calls resolve symbols into globals. g_solist, g_soinfo_get_realpath_func, and g_soinfo_get_soname will show up later. I won’t dig into like_dlsym’s implementation — it’s a pile of abstract arithmetic, but the upshot is it’s dlsym-like.

Finally it calls a cleanup function to unmap the linker it opened with open + mmap, then returns.

In short, init_array_func2 does a bunch of linker-related work — grabbing linker functions for use at later detection points.

String functions

From the .init_array analysis, a few string-related functions keep reappearing: like_strcpy, a1_contain_a2, cmp_func1, cmp_func2, etc.

like_strcpy is usually called right after a string-decryption routine, so it’s the closest thing to “the moment a string is decrypted.” Hook it.

But first solve the Frida detection. From the analysis above I know how it detects, so my plan was to hook fgets to scrub the /data/local signature. fgets also runs early in .init_array, so it can serve as a springboard to hook other .init_array-time functions (like like_strcpy).

function addr_in_so(addr){
    var process_Obj_Module_Arr = Process.enumerateModules();
    for(var i = 0; i < process_Obj_Module_Arr.length; i++) {
        if(addr>process_Obj_Module_Arr[i].base && addr<process_Obj_Module_Arr[i].base.add(process_Obj_Module_Arr[i].size)){
            console.log(addr.toString(16),"is in",process_Obj_Module_Arr[i].name,"offset: 0x"+(addr-process_Obj_Module_Arr[i].base).toString(16));
        }
    }
}

let hooked = false;
function hook_fgets() {
    Interceptor.attach(Module.findExportByName(null, "fgets"), {
        onEnter: function(args) {
            this.res = args[0];
        },
        onLeave: function() {
            let res = this.res.readCString();
            // 1. bypass anti-frida
            if (res.indexOf("/data/local/ng1ok/ng1ok_server/ng1ok-64.so") != -1) {
                Memory.writeUtf8String(this.res, " ");
                if(!hooked) {
                    hooked = true;
                    // 2. hook .init_array
                    start_hook();
                }
            }
        }
    })
}

function start_hook() {

    function hook_like_strcpy(base) {
        let count = 0;
        Interceptor.attach(base.add(0xE384), {
            onEnter: function(args) {
                console.log("a1: ", args[1].readCString());
            }
        })
    }

    let base = Module.findBaseAddress("libdyzzwwc.so");
    console.log("base: ", base);
    hook_like_strcpy(base);
}

function main() {
    hook_fgets();
}

setImmediate(main);

Result: Frida no longer gets Process terminated outright, but the dialog still pops up — meaning there’s another Frida detection somewhere.

Dialog still appears

hook_like_strcpy prints a lot. The bits I found most interesting:

// 1. looks like an integrity check
a1:  /libAdaptivePerformanceAndroid.so/libAdaptivePerformanceHint.so/libAdaptivePerformanceThermalHeadroom.so/libAndroidCpuUsage.so/libEncryptorP.so/libFirebaseCppAnalytics.so/libFirebaseCppApp-11_9_0.so/lib_burst_generated.so/libapminsighta.so/libapminsightb.so/libapplovin-native-crash-reporter.so/libbuffer_pg.so/libdyzzwwc.so/libfile_lock_pg.so/libil2cpp.so/libmain.so/libnative-googlesignin.so/libnms.so/libtobEmbedPagEncrypt.so/libunity.so
a1:  .
a1:  .
a1:  null:0:0:0:29256:0:d7db4753:5.1.1.139:null:10:10
a1:  null
a1:  5.1.1.139
a1:  d7db4753

// 2. root
a1:  /system/xbin/su
a1:  /system/bin/su
a1:  /sbin/su
a1:  /cache/su
a1:  /data/local/bin/su
a1:  /data/local/su
a1:  /data/local/xbin/su
a1:  /data/su
a1:  /system/bin/su
a1:  /system/xbin/bstk/su

// 3. magisk
a1:  /system/bin/magisk
a1:  /system/bin/magiskinit
a1:  /system/bin/magiskpolicy

// 4. android property?
a1:  /dev/__properties__/property_info

// 5. maybe frida?
a1:  ng1ok-64.so
a1:  FF130916
a1:  /proc/self/net/unix
a1:  7c3551fe3618

// 6. others
a1:  USB Connected
a1:  Alertdialog
a1:  debuggable

Another anti-Frida

Note: hooking check1 to force it to return 0 is enough to fully bypass — but I was curious how the other Frida detection works, so I kept going.

Same like_strcpy hook, but print the call stack when ng1ok-64.so shows up:

function hook_like_strcpy(base) {
    let count = 0;
    Interceptor.attach(base.add(0xE384), {
        onEnter: function(args) {
            if(args[1].readCString().indexOf("ng1ok-64.so") != -1) {
                console.log("a1: ", args[1].readCString());
                Thread.backtrace(this.context, Backtracer.FUZZY).map(addr_in_so);
            }
        }
    })
}

The call stacks:

a1:  /data/local/ng1ok/ng1ok_server/ng1ok-64.so
7a0dd2b20c is in libdyzzwwc.so offset: 0x1720c
7a0dd37864 is in libdyzzwwc.so offset: 0x23864
7a7bbd1974 is in libart.so offset: 0x5ab974
7a211ff25c is in ng1ok-64.so offset: 0x8e925c
7a211ff25c is in ng1ok-64.so offset: 0x8e925c
7a2120830c is in ng1ok-64.so offset: 0x8f230c
7a0dd38624 is in libdyzzwwc.so offset: 0x24624
7a2120851c is in ng1ok-64.so offset: 0x8f251c
7a0dd3819c is in libdyzzwwc.so offset: 0x2419c
7a0dd3819c is in libdyzzwwc.so offset: 0x2419c
7afe1af0a4 is in libdl.so offset: 0x10a4
7a0dd380e0 is in libdyzzwwc.so offset: 0x240e0
7a0dd66310 is in libdyzzwwc.so offset: 0x52310
7a7b766354 is in libart.so offset: 0x140354
a1:  ng1ok-64.so
7a0dd37a9c is in libdyzzwwc.so offset: 0x23a9c
7a0dd38624 is in libdyzzwwc.so offset: 0x24624
7a2120851c is in ng1ok-64.so offset: 0x8f251c
7a0dd3819c is in libdyzzwwc.so offset: 0x2419c
7a0dd3819c is in libdyzzwwc.so offset: 0x2419c
7afe1af0a4 is in libdl.so offset: 0x10a4
7a0dd380e0 is in libdyzzwwc.so offset: 0x240e0
7a0dd66310 is in libdyzzwwc.so offset: 0x52310
7a7b766354 is in libart.so offset: 0x140354
7afcf0da18 is in libc.so offset: 0xdea18
7a0dd2c0f0 is in libdyzzwwc.so offset: 0x180f0
7a0dd50ee0 is in libdyzzwwc.so offset: 0x3cee0
7afcebe0ac is in libc.so offset: 0x8f0ac
5c85f0a570 is in app_process64 offset: 0x5570
a1:  ng1ok-64.so
7a0dd37d1c is in libdyzzwwc.so offset: 0x23d1c
7a0dd38624 is in libdyzzwwc.so offset: 0x24624
7a2120851c is in ng1ok-64.so offset: 0x8f251c
7a0dd3819c is in libdyzzwwc.so offset: 0x2419c
7a0dd3819c is in libdyzzwwc.so offset: 0x2419c
7afe1af0a4 is in libdl.so offset: 0x10a4
7a0dd380e0 is in libdyzzwwc.so offset: 0x240e0
7a0dd66310 is in libdyzzwwc.so offset: 0x52310
7a7b766354 is in libart.so offset: 0x140354
7afcf0da18 is in libc.so offset: 0xdea18
7a0dd2c0f0 is in libdyzzwwc.so offset: 0x180f0
7a0dd386cc is in libdyzzwwc.so offset: 0x246cc
7afcf12730 is in libc.so offset: 0xe3730
a1:  ng1ok-64.so
7a0dd633e4 is in libdyzzwwc.so offset: 0x4f3e4
7a7b766354 is in libart.so offset: 0x140354
a1:  /data/local/ng1ok/ng1ok_server/ng1ok-64.so
7a0dd36328 is in libdyzzwwc.so offset: 0x22328
7a0dd388d0 is in libdyzzwwc.so offset: 0x248d0
a1:  /data/local/ng1ok/ng1ok_server/ng1ok-64.so
7a0dd633e4 is in libdyzzwwc.so offset: 0x4f3e4
7a7b766354 is in libart.so offset: 0x140354

Lots of different places — at first I thought there were many separate Frida detections and planned to replace them one by one:

function hook_like_strcpy(base) {
    let count = 0;
    Interceptor.attach(base.add(0xE384), {
        onEnter: function(args) {
            if(args[1].readCString().indexOf("ng1ok-64.so") != -1) {
                if(count++ == 0) {
                    Memory.writeUtf8String(args[1], "tteesstt");
                }
                console.log("a1: ", args[1].readCString());
                Thread.backtrace(this.context, Backtracer.FUZZY).map(addr_in_so);
            }
        }
    })
}

After replacing the first one, only a single call stack remains, and the app’s detection prompt changes from ng1ok-64.so to debuggable:

a1:  tteesstt
7a0ee2c20c is in libdyzzwwc.so offset: 0x1720c
7a0ee38864 is in libdyzzwwc.so offset: 0x23864
7a7bbd1974 is in libart.so offset: 0x5ab974
7a211ff25c is in ng1ok-64.so (deleted) offset: 0x8e925c
7a211ff25c is in ng1ok-64.so (deleted) offset: 0x8e925c
7a2120830c is in ng1ok-64.so (deleted) offset: 0x8f230c
7a0ee39624 is in libdyzzwwc.so offset: 0x24624
7a2120851c is in ng1ok-64.so (deleted) offset: 0x8f251c
7a0ee3919c is in libdyzzwwc.so offset: 0x2419c
7a0ee3919c is in libdyzzwwc.so offset: 0x2419c
7afe1af0a4 is in libdl.so offset: 0x10a4
7a0ee390e0 is in libdyzzwwc.so offset: 0x240e0
7a0ee67310 is in libdyzzwwc.so offset: 0x52310
7a7b766354 is in libart.so offset: 0x140354

Notice libdyzzwwc.so offset: 0x240e0 calls libdl.so offset: 0x10a4. Step in — it’s dl_iterate_phdr, which iterates over the loaded shared libraries, invoking a callback for each.

Single-step (F7) to follow into callback_func. It leads into linker64’s dl__Z18do_dl_iterate_phdrPFiP12dl_phdr_infomPvES1, which calls the callback:

dl_iterate_phdr in linker64

Step into callback_func. For some reason F5’s decompilation doesn’t match the assembly (probably a cascade from IDA mis-analyzing some functions), so continue in the disassembly view:

callback_func disassembly

x0 is /system/bin/linker64, x1 is libdl.so, cmp_func1 is strcmp-like (returns 0 only on equality).

x0 is /system/bin/linker64, x1 is /data/app, cmp_func2 is also strcmp-like.

x0 is /system/bin/linker64, x1 is the package name; true only when x0 contains x1, otherwise it goes to check_smaps.

Following a bit, x0 is the shared library name passed to callback_func by dl_iterate_phdr. The callback filters out libdl.so, anything starting with /data/app/, and anything containing the package name, then runs check_smaps on the rest.

check_smaps calls check1.

Recall check1 — it has a block like this, which is why my modified frida-agent-<arch>.so under /data/local gets flagged:

check1 /data/local detection

Let’s force check1 to return 0 and see if it bypasses:

function hook_check1(base) {
    // 1643C
    Interceptor.attach(base.add(0x1643C), {
        onEnter: function(args) {
        },
        onLeave: function(retval) {
            if(retval.toInt32() != 0) {
                console.log(`bypass check1 (before retval: ${retval})`)
                retval.replace(0);

            }
        }
    })
}

Success — no more ng1ok-64.so, it now shows debuggable:

debggable prompt

Summary: besides the fopen("/proc/<pid>/maps") + fgets combo, it also uses dl_iterate_phdr to emulate iterating /proc/<pid>/maps. That’s why my initial fgets hook didn’t fully bypass.

The debuggable detection

This check probably fires because my phone is a custom AOSP build where I defaulted every app to debuggable. To confirm, I made the app’s debuggable flag toggleable:

Toggleable debuggable

With debuggable off, the game launches normally:

Game launches with debuggable off

But turning debuggable off kills dynamic debugging, which is a problem. I tried to locate the specific detection to bypass it surgically, but couldn’t find it. The final solution was to patch the function that crashes the app — I’ll get to which one later.

Detection functions outside .init_array

While debugging .init_array I set breakpoints on some of its callees. Once, after .init_array finished, F9 continued and landed somewhere unexpected. Walking up the stack led to another huge detection function I named after_initarray_check2.

At first I didn’t chase who called after_initarray_check2, then realized it’s obviously a native function called from the Java layer. Loading the app into jadx and searching dyzzwwc — there’s exactly one native function, clearly it. The Java layer is obfuscated, but we don’t need to analyze it now.

Every upper-level call stack for the various detections ends up at after_initarray_check2, so here’s a quick note on its origin.

N thread-detection functions

Debugging after_initarray_check2, IDA gets increasingly laggy, jumps around erratically, crashes often, and freezes. At first I chalked it up to IDA’s usual dynamic-debugging jank (it genuinely can be slow…), but something felt off — until I spotted pthread_create and it clicked: after_initarray_check2 is spawning a bunch of threads.

pthread_create

Hooking pthread_create confirms it — N threads, and counting the distinct callback functions gives 11.

I’ll analyze what these threads actually detect later. For now the goal is bypass, so Frida can hook the app (and solve the debuggable detection) without a crash.

so_name libdyzzwwc.so offset 0x2d4c4 path /data/app/net.gameduo.tbd-BHl5bEYewEh0AB6f_qEItw==/lib/arm64/libdyzzwwc.so parg2 0x7a0dd444c4
so_name libdyzzwwc.so offset 0x11368 path /data/app/net.gameduo.tbd-BHl5bEYewEh0AB6f_qEItw==/lib/arm64/libdyzzwwc.so parg2 0x7a0dd28368
so_name libdyzzwwc.so offset 0x25fa8 path /data/app/net.gameduo.tbd-BHl5bEYewEh0AB6f_qEItw==/lib/arm64/libdyzzwwc.so parg2 0x7a0dd3cfa8
so_name libdyzzwwc.so offset 0x44834 path /data/app/net.gameduo.tbd-BHl5bEYewEh0AB6f_qEItw==/lib/arm64/libdyzzwwc.so parg2 0x7a0dd5b834
so_name libdyzzwwc.so offset 0x15f30 path /data/app/net.gameduo.tbd-BHl5bEYewEh0AB6f_qEItw==/lib/arm64/libdyzzwwc.so parg2 0x7a0dd2cf30
so_name libdyzzwwc.so offset 0x45a68 path /data/app/net.gameduo.tbd-BHl5bEYewEh0AB6f_qEItw==/lib/arm64/libdyzzwwc.so parg2 0x7a0dd5ca68
so_name libdyzzwwc.so offset 0xf2d0 path /data/app/net.gameduo.tbd-BHl5bEYewEh0AB6f_qEItw==/lib/arm64/libdyzzwwc.so parg2 0x7a0dd262d0
so_name libdyzzwwc.so offset 0x332a4 path /data/app/net.gameduo.tbd-BHl5bEYewEh0AB6f_qEItw==/lib/arm64/libdyzzwwc.so parg2 0x7a0dd4a2a4
so_name libdyzzwwc.so offset 0x112e4 path /data/app/net.gameduo.tbd-BHl5bEYewEh0AB6f_qEItw==/lib/arm64/libdyzzwwc.so parg2 0x7a0dd282e4
so_name libdyzzwwc.so offset 0xf2d0 path /data/app/net.gameduo.tbd-BHl5bEYewEh0AB6f_qEItw==/lib/arm64/libdyzzwwc.so parg2 0x7a0dd262d0
so_name libdyzzwwc.so offset 0xf2d0 path /data/app/net.gameduo.tbd-BHl5bEYewEh0AB6f_qEItw==/lib/arm64/libdyzzwwc.so parg2 0x7a0dd262d0
so_name libdyzzwwc.so offset 0xf2d0 path /data/app/net.gameduo.tbd-BHl5bEYewEh0AB6f_qEItw==/lib/arm64/libdyzzwwc.so parg2 0x7a0dd262d0
so_name libdyzzwwc.so offset 0xf2d0 path /data/app/net.gameduo.tbd-BHl5bEYewEh0AB6f_qEItw==/lib/arm64/libdyzzwwc.so parg2 0x7a0dd262d0
… (the 0xf2d0 entry repeats many times; 11 distinct thread callbacks total)
so_name libdyzzwwc.so offset 0x39e18 path /data/app/net.gameduo.tbd-BHl5bEYewEh0AB6f_qEItw==/lib/arm64/libdyzzwwc.so parg2 0x7a0dd50e18
so_name libdyzzwwc.so offset 0xf2d0 path /data/app/net.gameduo.tbd-BHl5bEYewEh0AB6f_qEItw==/lib/arm64/libdyzzwwc.so parg2 0x7a0dd262d0
… (more 0xf2d0 repeats)
so_name libdyzzwwc.so offset 0x2466c path /data/app/net.gameduo.tbd-BHl5bEYewEh0AB6f_qEItw==/lib/arm64/libdyzzwwc.so parg2 0x7a0dd3b66c
so_name libdyzzwwc.so offset 0x112e4 path /data/app/net.gameduo.tbd-BHl5bEYewEh0AB6f_qEItw==/lib/arm64/libdyzzwwc.so parg2 0x7a0dd282e4
so_name libdyzzwwc.so offset 0x112e4 path /data/app/net.gameduo.tbd-BHl5bEYewEh0AB6f_qEItw==/lib/arm64/libdyzzwwc.so parg2 0x7a0dd282e4

Bypassing everything

The app spawns all those threads, and the detection logic lives inside them, so I tried patching out all the threads. Result: the Alert Dialog stops popping up, but the app crashes partway through the loading progress bar.

Hook the kill_func found during debugging, to see if it fires, and print its call stack:

function hook_exit(base) {
    // 0x10CFC
    Interceptor.attach(base.add(0x10CFC), {
        onEnter: function() {
            console.log("call kill func");
            Thread.backtrace(this.context, Backtracer.FUZZY).map(addr_in_so);
        }
    })
}

It does fire:

call kill func
7a0dc27fa0 is in libdyzzwwc.so offset: 0x10fa0
7a0dc65738 is in libdyzzwwc.so offset: 0x4e738
7a7b766354 is in libart.so offset: 0x140354
7a7b75d5bc is in libart.so offset: 0x1375bc
7a7b76bfb0 is in libart.so offset: 0x145fb0
7a7b90cc94 is in libart.so offset: 0x2e6c94
7a7b908cb4 is in libart.so offset: 0x2e2cb4
7a7b908b70 is in libart.so offset: 0x2e2b70
7a7bbd7150 is in libart.so offset: 0x5b1150
7a7b757c98 is in libart.so offset: 0x131c98
7a7bbd4564 is in libart.so offset: 0x5ae564
7a7b757998 is in libart.so offset: 0x131998
7a7bbd4564 is in libart.so offset: 0x5ae564
7a7bbd4788 is in libart.so offset: 0x5ae788
7a2111fcb0 is in ng1ok-64.so offset: 0x7eecb0
7a7b757998 is in libart.so offset: 0x131998

It turns out pthread_func9 (my name for the 9th detection thread) failed to spawn:

pthread_func9

Let pthread_func9 spawn cleanly:

// in the pthread_create hook, let pthread_func9 through
if(offset == 0x112E4) { // 0x112E4: offset of pthread_func9
    console.log("pass pthread_func9");
    return pthread_create(parg0, parg1, parg2, parg3)
}

Now the game loads, but crashes again after a while:

call kill func
7a0dc142dc is in libdyzzwwc.so offset: 0x112dc
7a0dc14368 is in libdyzzwwc.so offset: 0x11368
7afcf12730 is in libc.so offset: 0xe3730
7afceb3008 is in libc.so offset: 0x84008

So the detection threads are coupled — touch one and the rest react.

The general bypass is to go after kill_func directly (guessing every crash funnels through it). Patch kill_func to always return 0, and both the app and Frida stop crashing:

function patch_exit(base) {
    // 0x10CFC
    Interceptor.replace(base.add(0x10CFC), new NativeCallback(() => {
        console.log("call kill func");
        return 0;
    }, "int", []))
}

That clears the two anti-debugging checks I hit. But I’m also curious what the other threads do, so let’s analyze a few of them (not all).

Prep for debugging the threads

  1. Debug with Frida + IDA (rather than adb shell am start -D -n XXX). This lets you:

    • choose which threads actually spawn, so you can analyze them one at a time;
    • hook kill_func to prevent crashes.
    function hook_pthread() {
    
        var pthread_create_addr = Module.findExportByName(null, 'pthread_create');
        console.log("pthread_create_addr,", pthread_create_addr);
    
        var pthread_create = new NativeFunction(pthread_create_addr, "int", ["pointer", "pointer", "pointer", "pointer"]);
    
        Interceptor.replace(pthread_create_addr, new NativeCallback(function (parg0, parg1, parg2, parg3) {
            var so_name = Process.findModuleByAddress(parg2).name;
            var so_path = Process.findModuleByAddress(parg2).path;
            var so_base = Module.getBaseAddress(so_name);
            var offset = parg2 - so_base;
            // if(so_name.indexOf("libdyzzwwc.so") != -1)
            //     console.log("so_name", so_name, "offset", ptr(offset), "path", so_path, "parg2", parg2);
            var PC = 0;
            if ((so_name.indexOf("libdyzzwwc.so") > -1)) {
                // console.log("find thread func offset", so_name, offset);
    
                // if(offset == 0x112E4) {  // maybe the Alert Dialog
                //     console.log("ignore to patch: pthread_func9");
                //     return pthread_create(parg0, parg1, parg2, parg3)
                // }
    
                // if(offset == 0x332A4) {
                //     console.log("ignore to patch: pthread_func8_check_app_debuggable");
                //     return pthread_create(parg0, parg1, parg2, parg3)
                // }
    
                // if(offset == 0x2D4C4) {  // nothing
                //     console.log("ignore to patch: pthread_func1");
                //     return pthread_create(parg0, parg1, parg2, parg3)
                // }
    
                // if(offset == 0x112A0) {  // nothing
                //     console.log("ignore to patch: pthread_func2");
                //     return pthread_create(parg0, parg1, parg2, parg3)
                // }
    
                // if(offset == 0x25FA8) {  // nothing
                //     console.log("ignore to patch: pthread_func3");
                //     return pthread_create(parg0, parg1, parg2, parg3)
                // }
    
                // if(offset == 0x44834) {  // nothing
                //     console.log("ignore to patch: pthread_func4");
                //     return pthread_create(parg0, parg1, parg2, parg3)
                // }
    
                // if(offset == 0x15F30) {  // nothing
                //     console.log("ignore to patch: pthread_func5");
                //     return pthread_create(parg0, parg1, parg2, parg3)
                // }
    
                // if(offset == 0x45A68) {  // detect: net.gameduo.tbd.apk
                //     console.log("ignore to patch: pthread_func6");
                //     return pthread_create(parg0, parg1, parg2, parg3)
                // }
    
                // if(offset == 0xF2D0) {  // nothing
                //     console.log("ignore to patch: pthread_func7");
                //     return pthread_create(parg0, parg1, parg2, parg3)
                // }
    
                // if(offset == 0x39E18) {  // shamiko??
                //     // prerequisites: pthread_func6, pthread_func7, pthread_func9
                //     console.log("ignore to patch: pthread_func10");
                //     return pthread_create(parg0, parg1, parg2, parg3)
                // }
    
                if(offset == 0x2466C) {  // nothing
                    console.log("ignore to patch: pthread_func11");
                    return pthread_create(parg0, parg1, parg2, parg3)
                }
    
    
            } else {
                PC = pthread_create(parg0, parg1, parg2, parg3);
                // console.log("ordinary sequence", PC)
            }
            return PC;
        }, "int", ["pointer", "pointer", "pointer", "pointer"]))
    
    }
  2. Patch nanosleep — here it causes freezes during debugging (probably triggered by another anti-debugging routine). Force it to return 0:

    function hook_nanosleep() {
        Interceptor.replace(Module.findExportByName(null, "nanosleep"), new NativeCallback(() => {
            return 0;
        }, "int", ["pointer", "pointer"]))
    }
  3. Best to set breakpoints on both the relevant pthread_create and the thread callback — a breakpoint only in the callback may fail.

  4. After breaking on pthread_create, suspend the other threads to reduce interference. IDAPython: suspend all other threads in one shot.

    import idc;
    def suspend_other_thread():
        current_thread = idc.get_current_thread()
        thread_count = idc.get_thread_qty()
        for i in range(0, thread_count):
            other_thread = idc.getn_thread(i)
            if other_thread != current_thread:
                idc.suspend_thread(other_thread)
    suspend_other_thread()

pthread_func6

Function offset: 0x45A68

pthread_func6 starts by calling check_fingerprint3 for the first part of its detection. check_fingerprint3 calls check_su to check the usual su paths:

check_fingerprint3 / check_su

check_su builds the candidate su paths (e.g. /system/xbin/su) and passes each to check_su_path_exist:

check_su paths

check_su_path_exist (better called check_path_exist, since it isn’t only for su) creates pthread_func7 to do the checking — details in the next section:

check_su_path_exist

Back in check_fingerprint3. It checks whether ro.build.fingerprint contains userdebug, and compares ro.product.model against Custom Phone:

fingerprint checks

Checks Magisk signatures, then gets the environment variables and iterates over all the paths, passing them to trav_dir_and_check_su:

env path traversal

trav_dir_and_check_su uses scandir to walk a directory and check whether it contains a su file:

trav_dir_and_check_su

It even goes after /sdcard/Download/boot.img?

More Magisk signatures:

/system/bin/magisk
/system/bin/magiskinit
/system/bin/magiskpolicy
/system/bin/resetprop

And finally some more su checks:

more su checks

Back in pthread_func6 after check_fingerprint3, there’s one notably suspicious spot: it loops over the solist (from g_solist), calling soinfo_get_realpath (actually g_soinfo_get_realpath_func) and soinfo_get_soname (actually g_soinfo_get_soname) to get each library’s realpath and soname, then checks for Zygisk signatures:

solist / zygisk check

Summary: pthread_func6 is fundamentally a root detector.

pthread_func7

Function offset: 0xF2D0

pthread_func7 tries to open/access the passed-in path through various means; succeeding means detection. APIs used: fopen, openat, scandir, lstat, stat, access, readlink.

pthread_func7

pthread_func7 continued

pthread_func10

Function offset: 0x39E18

It first calls sub_7869BC4880; dynamically I couldn’t tell what it does, but static analysis of its decrypted strings suggests emulator detection:

sub_7869BC4880 strings

Back in pthread_func10, it calls check_BlueStacks_emu, which specifically detects the BlueStacks emulator. It checks the following (passed as arguments to check_su_path_exist):

"com.bluestacks.bstfolder"
"/data/data/com.bluestacks.home"
"/data/data/com.bluestacks.launcher"
"/sdcard/Android/data/com.bluestacks.home"
"/system/bin/bstfolderd"
"/system/bin/bstfolder"
"/system/bin/bstsyncfs"
"/sys/module/bstsensor"
"/sys/module/bstpgaipc"
"/system/xbin/bstk/su"
"/system/xbin/bstk"

Then it checks some more signatures I don’t recognize, followed by Nox:

"/system/bin/nox-vbox-sf"
"/data/data/com.bignox.appcenter"

Then LDPlayer:

"/system/app/LDAppStore/LDAppStore.apk"

Then KoPlayer:

KoPlayer detection

Then some virtual-machine signatures:

"/system/bin/androidVM-vbox-sf"
"/system/bin/androidVM-prop"
"/sys/module/vboxpcismv"
"/sys/module/nemusf"
"/system/bin/genybaseband"
"/sys/module/vboxsf"

Then checks whether the Android fingerprint contains any of:

"Android SDK built"
"sdk_gphone"
"Android/sdk_"
"Android/vbox86"
"google/sdk_"

Then calls check_android_prop to check some Android properties:

check_android_prop

The properties it reads:

"Dapexd.status"
"vm.cleaner.status"
"Xsystem.slide-out.enabled"
"sys.powerboot.adbd"
"init.svc.vmcd"
"nit.svc.sh_boot"
"siq.display.config"
"ro.boottime.apexd"
"ro.com.cph.cloud_app_engine"  (cloud-phone signature)
"ro.global.scene"

Summary: pthread_func10 is fundamentally an emulator/VM detector. It probably also checks shamiko, but dynamically I couldn’t reach that branch, so I didn’t analyze it.

il2cpp dump

With the anti-debugging bypassed, we finally get to the “main act” — which turns out to be the easiest part.

This game is the classic Unity + il2cpp. Its libil2cpp.so isn’t encrypted, but global-metadata.dat clearly is. Searching libil2cpp.so for "global-metadata.dat" lands on its loader, sub_A6D2B0.

The Frida dump script:

function dump_bin(name, addr, size) {
    var file_path = "/data/data/net.gameduo.tbd" + "/" + name + ".bin";
    console.log("dump path: ", file_path);
    var file_handle = new File(file_path, "wb");
    if (file_handle && file_handle != null) {
        Memory.protect(ptr(addr), size, 'rwx');
        var libso_buffer = ptr(addr).readByteArray(size);
        file_handle.write(libso_buffer);
        file_handle.flush();
        file_handle.close();
        console.log("[dump]:", file_path);
    }
}
function get_size(addr) {
    const metadataHeader = addr;
    let fileOffset = 0x10C;
    let lastCount = 0;
    let lastOffset = 0;
    while (true) {
        lastCount = Memory.readInt(ptr(metadataHeader).add(fileOffset));
        if (lastCount !== 0) {
            lastOffset = Memory.readInt(ptr(metadataHeader).add(fileOffset-4));
            console.log("fileOffset : ", ptr(fileOffset))
            break;
        }
        fileOffset -= 8;
        if(fileOffset <= 0)
        {
            console.log("get size failed!");
            break;
        }
    }
    return lastOffset + lastCount;
}
function dump_gm(base) {
    Interceptor.attach(base.add(0xA6D2B0),{
        onEnter(args){
            console.log("[libil2cpp] arg0: ", args[0].readCString());
        },
        onLeave(retval){
            dump_bin("global-meta", retval, get_size(retval));
        }
    })
}

The dumped global-metadata works directly in Il2cppDumper. Finally, I hooked a random function and successfully modified the experience required for leveling:

Hooked function for XP

Conclusion

A shallow pass over the LIAPP protection. It clearly pours most of its effort into anti-debugging. Unlike conventional protectors, it has no single fixed string-decryption function, so you can’t one-shot discover all the suspicious spots. Fortunately, a handful of fixed string-processing functions follow the various decryption routines, which made the work much easier. There’s probably also some risk-control logic, but I didn’t reach it, so I won’t speculate.

What baffles me is how weak its protection of the global-metadata file is — effectively dump-and-go.

I spent a few days on this. If the goal were purely cracking, one or two days would’ve sufficed; the time sink was understanding how the detection works. Overall, a solid learning experience.

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