A General Approach to Bypassing Frida Detection

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.

A General Approach to Bypassing Frida Detection

Foreword

Anti-detection is best approached from the exit path. When a detector sees an abnormal environment, it tries to kill the process one way or another. Those exits fall roughly into two buckets: libc-based and svc-based.

  • libc: exit, kill, _exit, and friends. You can neutralize these by calling FCAnd.anti.anti_debug(); directly (add any functions I missed).
  • svc: anything that goes through syscall.

At the libc level, Frida can hook the relevant functions and print a stack trace to locate the detector. svc can’t be hooked directly with Frida, so you need the general approach below.

The scripts in this article mostly come from: https://github.com/deathmemory/FridaContainer

Scanning for svc

People have shared ways to detect svc calls using Frida’s Stalker, and that’s one option — but Stalker’s stability has always been a problem. So here I use IDA Python + Frida instead.

The core idea: implement the “locate where a .so calls svc” step in IDA Python. Straight to the IDAPython code:

from idautils import *
from idc import *

import ida_idp
ida_idp.get_idp_name()
# the svc byte sequence to search for
# arm
# search_bytes = b"\x00\x00\x00\xef"
# arm64
search_bytes = b"\x01\x00\x00\xd4"
result = []
# iterate over the executable segments of the binary
seg = ida_segment.get_segm_by_name(".text")

# search for the byte sequence in each segment
for address in range(seg.start_ea, seg.end_ea):
    if get_bytes(address, len(search_bytes)) == search_bytes:
        print("Match found at address: 0x%x" % address)
        result.append(address)

print([hex(n) for n in result])

You can load this as python/android/idaSearchSvc.py in IDA and run it. It prints every svc call site in the .so, for example:

['0x4826c', '0x487bc', '0x48dc4', '0x496d4', '0x49880', '0x499d0', '0x4b200', '0x4bf40', '0x51578', '0x51598', '0x516fc', '0x51984', '0x519bc', '0x51a34', '0x51b24', '0x51b9c', '0x51e98']

Locating the detector with Frida

Hook all the svc_address_list entries above, or just call the FCAnd wrapper:

let svc_address_list = ['0x4826c', '0x487bc', '0x48dc4', '0x496d4', '0x49880', '0x499d0', '0x4b200', '0x4bf40', '0x51578', '0x51598', '0x516fc', '0x51984', '0x519bc', '0x51a34', '0x51b24', '0x51b9c', '0x51e98'];
FCAnd.watch_svc_address_list(mod.base, svc_address_list);

Then launch the target process with frida in spawn mode and every svc call’s stack trace gets printed. To hook in time, start hooking after the target .so loads:

FCAnd.whenSoLoad("libshell-super.com.wm.xxxxxx.so", (mod) => {
    let svc_address_list = ['0x4826c', '0x487bc', '0x48dc4', '0x496d4', '0x49880', '0x499d0', '0x4b200', '0x4bf40', '0x51578', '0x51598', '0x516fc', '0x51984', '0x519bc', '0x51a34', '0x51b24', '0x51b9c', '0x51e98'];
    FCAnd.watch_svc_address_list(mod.base, svc_address_list);
    // DMLog.i("dd", "so load base: " + mod.base);
});

The stack traces look like this:

......
[INFO][05/10/2024, 07:14:18 PM][PID:9502][Thread-134][9551][showNativeStacks]:  Backtrace:
  0x6e86545b10 libshell-super.com.xxxxxx.so!0x51b10
  0x6e86545580 libshell-super.com.xxxxxx.so!0x51580
  0x7187d9075c libc.so!_ZL15__pthread_startPv+0x44
  0x7187d30154 libc.so!__start_thread+0x44
[INFO][05/10/2024, 07:14:18 PM][PID:9502][Thread-135][9551][showNativeStacks]:  Backtrace:
  0x6e86545b10 libshell-super.com.xxxxxx.so!0x51b10
  0x6e86545580 libshell-super.com.xxxxxx.so!0x51580
  0x7187d9075c libc.so!_ZL15__pthread_startPv+0x44
  0x7187d30154 libc.so!__start_thread+0x44
[INFO][05/10/2024, 07:14:18 PM][PID:9502][Thread-136][9551][showNativeStacks]:  Backtrace:
  0x6e864ede0c
  0x6e86545580 libshell-super.com.xxxxxx.so!0x51580
  0x7187d9075c libc.so!_ZL15__pthread_startPv+0x44
  0x7187d30154 libc.so!__start_thread+0x44
[INFO][05/10/2024, 07:14:18 PM][PID:9502][Thread-137][9551][showNativeStacks]:  Backtrace:
  0x6e86545b10 libshell-super.com.xxxxxx.so!0x51b10
  0x6e86545580 libshell-super.com.xxxxxx.so!0x51580
  0x7187d9075c libc.so!_ZL15__pthread_startPv+0x44
  0x7187d30154 libc.so!__start_thread+0x44
......

Skim through and the most frequent offenders are basically 0x51b10 → 0x51580.

Next, open IDA at those addresses, look at the detection logic, and you can figure out what it checks and how to bypass it.

Detector disassembly

Say the detection function is sub_515C4. Step into it and see what it checks. I won’t go into the details here — just the conclusion. This function mainly checks:

  1. Thread names in /proc/self/task/%s/status
  2. readlink feature names in /proc/self/fd/%s
  3. Frida feature names in /proc/self/maps

Nothing exotic. But here’s the weird part: my Frida build was already feature-stripped, so all of these should have been bypassed — yet it still detected the environment and killed the process. I went back and re-confirmed this was definitely the right detection point. So which check in sub_515C4 actually fired?

Pinpointing the exact check

Now that sub_515C4 is confirmed and it only touches those few files, the question is what’s actually wrong. I first hooked snprint with Frida to filter its path concatenation, and found that what matters is /proc/self/task/%s/status. Its detection boils down to three feature strings:

  1. gum-js-loop
  2. gmain
  3. pool-frida

Detection strings

The first two are definitely replaced when compiling a feature-stripped Frida. The only suspect is pool-frida — but I couldn’t find that exact string in the Frida source or the generated binary. Not convinced, I turned to Frida’s Stalker to verify whether it really is the trigger.

Start following on sub_515C4’s onEnter, and unfollow on exit to minimize overhead and instability:

Interceptor.attach(base.add(0x515C4), {
    onEnter: function (args) {
        DMLog.i("hook_515C4", "onEnter");

        // FCAnd.showNativeStacks(this.context);
        function logX3(context: any) {
            console.log('x3:', context.x3.readInt());
        }

        Stalker.follow(this.tid, {
            events: {
                call: false,
                ret: false,
                exec: true,
                block: false,
                compile: false
            },
            transform: function (iterator: StalkerArm64Iterator) {
                var ins = iterator.next();
                if (ins == null) return;
                if (ins.address.sub(base) > ptr(0x00000000000515C4)
                    && ins.address.sub(base) < ptr(0x000000000005207C)) {
                    // if (ins.address.sub(base).compare(ptr(0x52074)) == 0) {
                    //     // console.log(`==== 51afc fd: `);
                    //     // iterator.putCallout(logX3);
                    //     DMLog.i("0x52074", "sleep");
                    //     Thread.sleep(9999);
                    // }
                    console.log(`${ins.address.sub(base)} ${ins}`);
                }
                iterator.keep();
                while (iterator.next() != null) iterator.keep();
            }
        });
    },
    onLeave: function (retval) {
        DMLog.i("hook_515C4", "onLeave1111======: " + retval);
        if (retval.toInt32() == 1) {
            DMLog.i("hook_515C4", "sleep x3: " + (this.context as Arm64CpuContext).x3);
            Thread.sleep(9999);
        }
        retval.replace(ptr(0));
        Stalker.unfollow(this.tid);
    }
})

The trace output:

Cross-reference with the key branch addresses from IDA. We already suspected the pool-frida check, so once the trace is out, you don’t need to read it line by line — just search whether the addresses involved in the pool-frida logic got printed.

......
0x302666770 cbz x0, #0x71d69bb788
0x302666774 ldaxr x21, [x0]
0x302666780 stlxr w8, x22, [x0]
0x302666780 stlxr w8, x22, [x0]
0x302666774 ldaxr x21, [x0]
0x302666788 ldp x20, x19, [sp, #0x20]
0x30266484c add x0, x19, #0x18
0x3026c8d20 adrp x16, #0x71d6a22000
0x3026c51a8 movi v0.2d, #0000000000000000
0x3026c51b8 strh wzr, [x0]
0x302664860 b #0x71d69b9868
0x302664868 mov x0, x19
0x51a20 cbz x0, #0x6ed43a7014
0x51a24 mov x27, x0
0x51a38 mov x0, x27
0x4440 adrp x16, #0x6ed43c6000
0x302664878 stp x29, x30, [sp, #-0x30]!
0x30266488c add x19, x0, #0x18
0x3026c8740 adrp x16, #0x71d6a22000
0x3026c5238 stp x29, x30, [sp, #-0x10]!
0x3026c5250 and x8, x8, #0x2000
0x3026c5264 stxrh w10, w9, [x0]
0x3026c5264 stxrh w10, w9, [x0]
0x3026c5258 ldaxrh w10, [x0]
0x3026c526c mov w0, wzr
0x30266489c ldr x8, [x20, #8]
0x3026648cc add x21, x20, #0x40
0x3026af860 mov x8, #0x3d
0x3026af874 ret
0x3026648e4 cmn w0, #1
0x302664900 cmp w0, #1
0x302664908 mov w8, w0
0x3026648a8 ldrh w9, [x21, #0x10]
0x302664918 mov x0, x19
0x3026c8780 adrp x16, #0x71d6a22000
0x3026c5740 stp x29, x30, [sp, #-0x30]!
0x3026c5764 orr w9, w8, #2
0x3026c5764 orr w9, w8, #2
0x3026c5774 cmp w10, w9
0x3026c586c mov w0, wzr
0x302664920 mov x0, x21
0x51a40 cbz x0, #0x6ed43a700c
0x51a44 movi v0.2d, #0000000000000000
0x51ac0 add x9, x9, #1
0x51ab0 ldrb w10, [x8]
0x51acc b #0x6ed43a6a38
0x3026c5250 and x8, x8, #0x2000
0x3026648a4 ldr x21, [x20, #0x10]
0x51ad0 adrp x9, #0x6ed43af000
0x51aec add x9, x9, #1
0x51adc ldrb w10, [x8]
0x51af8 b #0x6ed43a6a38
0x51afc adrp x2, #0x6ed43af000
0x4020 adrp x16, #0x6ed43c6000
0x3026bee88 stp x29, x30, [sp, #-0x20]!
0x3026bef00 add x10, sp, #0x118
0x3026a03d0 stp x29, x30, [sp, #-0x60]!
0x3026c8550 adrp x16, #0x71d6a22000
0x302661ff0 mrs x8, tpidr_el0
0x3026a040c movi v2.2d, #0000000000000000
0x3026a047c ldr w9, [x8, #0x30]
0x3026a0498 mov w9, #-1
0x3026a048c ldr x8, [x19, #0x18]
0x3026a04cc mov w8, #0x1a
0x3026a05cc add x9, x22, #0x18
0x3026a0688 cmp w8, #0x25
0x3026a0690 add x9, x9, #1
0x3026a069c add x10, x28, x9
0x3026a06a4 subs x9, x10, x28
0x3026a06ac sub w11, w20, w26
0x3026a06b8 ldr x23, [sp, #0x90]
0x3026a06e4 mov w15, wzr
0x3026a0720 adrp x11, #0x71d698d000
0x3026a0e4c mov w21, w15
0x3026a0e58 ldr x8, [sp, #0x1a0]
0x3026a1340 ldr x8, [sp, #0x50]
0x3026a134c ldr x10, [sp, #0x50]
0x3026a1360 ldr x9, [sp, #0x20]
0x3026a14e4 ldr x8, [x8]
0x3026a1548 str w27, [sp, #0xb4]
0x3026a1568 mov x0, x25
0x3026c8580 adrp x16, #0x71d6a22000
0x30265e870 and x4, x0, #0xfff
0x30265e880 ldp x2, x3, [x0]
0x30265e8a4 csel x4, x4, x5, lo
0x3026a1570 mov x26, x0
0x3026a157c str x20, [sp, #0xc0]
0x3026a1cbc ldrb w9, [sp, #0x1fc]
0x3026a1cfc ldr w8, [sp, #0xcc]
0x3026a1e14 mov w21, w24
0x3026a1e94 ldrb w8, [sp, #0x1ad]
0x3026a1f08 cmp w20, #0x80
0x3026a2094 cmp w27, #1
0x3026a2008 ldr w22, [sp, #0xb4]
0x3026a2010 ldr x8, [sp, #0x1c0]
0x3026a2074 ldr w26, [sp, #0xac]
0x3026a29ec ldr w21, [sp, #0xcc]
0x3026a2af0 cmp w21, w24
0x3026a2b08 ldr x9, [sp, #0x1c0]
0x3026a2b14 add x1, sp, #0x1b0
0x3026ab9b8 stp x29, x30, [sp, #-0x60]!
0x3026ab9dc ldr w8, [x0, #0x10]
0x3026ab9ec ldr x9, [x19, #0x18]
0x3026aba1c ldr x9, [x20]
0x3026aba2c tbnz w8, #0, #0x71d6a00c04
0x3026aba30 mov w25, #0x4200
0x3026aba8c cbnz x21, #0x71d6a00aa0
0x3026abaa0 ldp w23, w8, [x19, #0xc]
0x3026abb14 tbnz w8, #9, #0x71d6a00a38
0x3026aba38 cmp x21, w23, sxtw
0x3026c8500 adrp x16, #0x71d6a22000
0x30265e000 sub x5, x0, x1
0x30265de90 prfm pldl1keep, [x1]
0x30265dee0 cmp x2, #8
0x30265dee8 ldr x6, [x1]
0x3026aba54 ldr w8, [x19, #0xc]
0x3026aba90 ldr x21, [x26, #8]
0x3026aba9c ldur x22, [x26, #-0x10]
0x30265df00 tbz w2, #2, #0x71d69b2f18
0x30265df04 ldr w6, [x1]
0x3026abbfc mov w0, wzr
0x3026abd50 ldp x20, x19, [sp, #0x50]
0x3026a2b20 mov w14, #0x10
0x3026a2b30 str wzr, [sp, #0x1b8]
0x3026a067c mov x9, xzr
0x3026a06d8 b #0x71d69f7d58
0x3026a2d58 cbz x9, #0x71d69f7d74
0x3026a2d5c add x1, sp, #0x1b0
0x3026a2d68 str xzr, [sp, #0x1c0]
0x3026a2d74 str wzr, [sp, #0x1b8]
0x3026c8850 adrp x16, #0x71d6a22000
0x30264fb08 stp x29, x30, [sp, #-0x10]!
0x30264fb44 cbnz x8, #0x71d69a4b50
0x30264fb48 ldp x29, x30, [sp], #0x10
0x302658580 mov x1, x0
0x3026585a0 sub sp, sp, #0x40
0x3026585d0 adrp x8, #0x71d6a22000
0x3026585dc cbnz x22, #0x71d69ad608
0x3026585e0 b #0x71d69ad6a4
0x3026586a4 ldp x20, x19, [sp, #0x30]
0x3026a2d90 ldr x0, [sp, #0xb8]
0x3026a2d9c ldr x0, [sp, #0x1a0]
0x3026a2dbc ldr x8, [x25, #0x28]
0x3026a2dcc mov w0, w22
0x3026bef9c ldr x8, [sp, #0x118]
0x3026befb4 add sp, sp, #0x230
0x51b10 mov w8, #0x38
0x51b2c add x8, sp, #0x370
0x51b60 strb wzr, [x8], #1
0x51b6c strb wzr, [x8], #1
0x51b78 strb wzr, [x8], #1
0x51b54 strb wzr, [x8], #1
0x51b84 mov x10, xzr
0x51ba8 ldrb w8, [sp, #0x270]
0x51bb4 strb w8, [x23, x10]
0x51b8c mov w8, #0x3f
0x51bc4 ldr x8, [sp, #0x20]
0x51bd0 ldurb w10, [x8, #-5]
0x51bdc cbnz w10, #0x6ed43a6bcc
0x51bcc add x8, x8, #1
0x51be0 b #0x6ed43a6c60
0x51c60 ldr x8, [sp, #0x18]
0x51c6c ldurb w10, [x8, #-2]
0x51c78 cbnz w10, #0x6ed43a6c68
0x51c68 add x8, x8, #1
0x51c7c b #0x6ed43a6cb4
0x51cb4 ldr x8, [sp, #0x10]
0x51cc0 ldurb w10, [x8, #-4]
0x51ccc cbnz w10, #0x6ed43a6cbc
0x51cbc add x8, x8, #1
0x51cd0 b #0x6ed43a6a2c
0x51a2c mov w8, #0x39
0x51be4 ldurb w10, [x8, #-4]
0x51c80 ldurb w10, [x8, #-1]
0x51cd4 ldurb w10, [x8, #-3]
0x51ce0 ldurb w10, [x8, #-2]
0x51cec ldurb w10, [x8, #-1]
0x51cf8 ldrb w10, [x8]
0x51d04 ldrb w10, [x8, #1]
0x51d10 ldrb w10, [x8, #2]
0x51d1c ldrb w10, [x8, #3]
0x51d28 ldrb w10, [x8, #4]
0x51d34 ldrb w10, [x8, #5]
0x51d40 b #0x6ed43a7070
0x52070 ldr x9, [sp, #0x38]
0x52040 ldr x8, [x9, #0x28]
0x52050 add sp, sp, #0x580
0x305eb050c ldr x17, #0x71da205518
0x30476a14c sub sp, sp, #0x10
[INFO][05/07/2024, 07:52:08 PM][PID:18959][Thread-17][19001][hook_515C4]: onLeave1111======: 0x1

Now I have the function’s execution path. The first key point: 0x51afc.

Key branch 0x51afc

This confirms it goes through the status-related check.

The second key point: 0x51D34.

Key branch 0x51D34

This confirms it’s checking pool-frida. That’s the one. But the string wasn’t found in the source or the generated binary earlier — which means the string must be concatenated at runtime.

In the end it turned out to be pool-%s — concatenated into pool-frida.

By the way, the pool-frida thread only exists at process startup as a temporary thread; once the Frida environment is ready, the thread exits.

Now that we know the problem, the fix is trivial: replace that string in our Frida anti-detection script.

Back to happily hooking away. :)

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