Featured image of post 绕过某邦企业壳 Root/Frida 检测

绕过某邦企业壳 Root/Frida 检测

记录一次针对某邦企业级加固(Bybit App)的 Frida 反调试绕过实战。从 pthread_create 崩溃到 clone 系统调用分析,最终通过内存 Patch 实现绕过。

本文基于 xiaoeeyu 的文章 做了一次复现,主要记录我自己的分析过程和实践结果。

0x00 环境

  • 设备: Xiaomi MI 8(Android 10,已 Root)
  • 工具: Frida 17.3.2
  • App 版本: v5.12.0

0x01 查壳与威胁建模

使用 ApkCheckPack 对目标 APK 进行全面扫描,结果如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
APK检测工具 - 扫描配置:
- 检测类型: ROOT(true) 模拟器(true) 反调试(true) 代理(true) SDK(true) ...
...
- 加固特征扫描结果
    - Soname  梆梆安全(定制版) -> lib/arm64-v8a/libDexHelper.so
    - Soname  梆梆安全(企业版) -> lib/arm64-v8a/libDexHelper.so
    
- 安全检测特征扫描结果
    - ROOT检测特征: 检测 su 文件 (/sbin/su, /system/xbin/su...), Magisk, SuperSU 等
    - 模拟器检测特征: 检测 qemu_pipe, goldfish 内核, 默认 IMEI 等
    - 反调试检测特征: 检测 TracerPid, ro.debuggable, Frida-gadget, XposedBridge 等

分析结论

  1. 加固厂商:确认为 梆梆安全 (BangBang) 企业定制版。核心逻辑在 libDexHelper.so 中。
  2. 防御机制
    • Root 检测:覆盖了文件路径、包名和属性检测。这也是需要 Shamiko 的主要原因。
    • 环境检测:包含模拟器和反调试检测。
    • 第三方 SDK:集成了腾讯云、阿里实人认证等 SDK,这些 SDK 本身也可能带有一定的安全校验,增加逆向干扰。

0x02 初步分析

打开应用后,很明显出现了 Root 检测提示。

采用隐藏 Magisk + Shamiko 的方式来绕过 Root 检测:隐藏 Magisk,刷入 Shamiko 模块,并在重启后配置排除列表。

处理完成后,打开 App,Root 检测提示消失。 但我并没有遇到原文作者提到的“frida-server 运行即闪退”情况,不管点击注册还是登录都未触发闪退。

并且我使用的确实是默认端口。

考虑到可能是 App 版本差异,我没有继续深究这个现象,先尝试简单 Hook 检测逻辑。

可以看到它会检测 Frida 的核心组件(frida-agent)并阻止注入。

0x03 Hook pthread_create

Hook 思路和原理分析

pthread_create 是 Android/Linux 开发中最常用的创建线程的 API。在加固或者反调试的场景中,壳通常会创建一些可以在后台偷偷运行的线程,即“检测线程”。

1
2
3
4
int pthread_create(pthread_t *thread,
                  const pthread_attr_t *attr,
                  void *(*start_routine)(void *),
                  void *arg);
  • thread: 返回创建的线程 ID。
  • attr: 线程属性(可以为 NULL)。
  • start_routine: 线程执行函数。
  • arg: 传入线程函数的参数。

这些线程的任务通常包括:

  1. 轮询文件系统:检查 /proc/self/maps 是否有 Frida 相关 so,或者 /sys/class/ 下是否有痕迹。
  2. 端口扫描:检查本地 27042 (frida-server 默认端口)。
  3. 完整性校验:检查特定函数(如 open, read)的头部字节是否被修改(Inline Hook 检测)。

因此,逆向分析的第一步通常是先记录线程入口地址。如果某个线程入口位于壳的 SO(如 libDexHelper.so)范围内,这个线程就值得重点关注。

第一次尝试

根据上述思路,我们首先尝试 Hook libc.sopthread_create 函数,以此来定位检测线程的入口。

1
2
3
4
5
6
7
8
var pth = Module.findExportByName("libc.so", "pthread_create");
Interceptor.attach(pth, {
    onEnter: function(args) {
        var entry = args[2]; // 线程函数入口
        var module = Process.findModuleByAddress(entry);
        if (module) console.log("Thread from: " + module.name);
    }
});

结果Process terminated App 启动即崩溃 (Crash)。

企业级加固壳(如梆梆)常会检查 pthread_create 前几个字节是否被改写。如果检测到 Inline Hook,通常会触发崩溃或退出。因此,直接 Hook 这类敏感 libc 函数风险较高。

0x04 Hook clone

既然 pthread_create 被检测,那我们向下深入一层。在 Linux/Android 中,线程创建最终是通过 clone 系统调用实现的。

pthread_create 的调用流程

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
pthread_create()

分配线程控制块TCB)、栈空间等

设置调度策略/属性可选

调用 clone()

内核创建 task_struct共享 mmfsfilessighand 

新线程执行 start_routine(arg)

策略:Hook libc.so 中的 clone 函数。

过程:

  1. 定位调用栈:首先 Hook clone 并打印堆栈,发现 clone 是被 libc.so 中的 pthread_create 调用的。

  2. 静态分析 libc:将手机中的 /system/lib64/libc.so 拉取出来,使用 IDA 反编译 pthread_create 函数。

  3. 寻找赋值点: 在 pthread_create 的伪代码中,我们可以看到在调用 clone 之前,有一行关键的代码:

    1
    2
    3
    4
    
      *(_QWORD *)(v28 + 96) = v51;
      *(_QWORD *)(v28 + 104) = v52;
      *(_DWORD *)(v28 + 20) = getpid(inited);
      if ( (unsigned int)clone(__pthread_start, v17, 4001536, v28, v28 + 16, v21 + 8, v28 + 16) == -1 )
    

    结论pthread_create 会把线程入口函数写入结构体的 +96 偏移,并把该结构体指针传给 clone。因此在 Hook clone 时,读取 args[3] 指针的 +96 偏移,即可拿到真实线程入口。

注意:

在第一次反编译手机的 libc.so 时,我看到偏移量是 48 (0x30) 而不是 96。 这是因为我分析的是 32位 (ARM) 的库 (/system/lib/libc.so)。 而目标 App 运行在 64位 (ARM64) 环境下,所以需要分析 /system/lib64/libc.so,才能得到对应的 96 (0x60) 偏移。

验证脚本 (trace_clone.js)

为了增强脚本的兼容性(应对部分 Frida 版本在 Spawn 早期 Module.findExportByName 找不到符号的问题),我们在实际脚本中增加了一个 get_export 的封装。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/* trace_clone.js */
console.log("[*] 开始 Hook clone 以定位检测线程...");

// 兼容性封装:在不同 Frida 版本中查找导出符号
function get_export(name) {
    if (Module.findGlobalExportByName) return Module.findGlobalExportByName(name);
    if (Module.getGlobalExportByName) return Module.getGlobalExportByName(name);
    if (Module.findExportByName) return Module.findExportByName(null, name);
    try {
        var libc = Process.getModuleByName("libc.so");
        if (libc) return libc.findExportByName(name);
    } catch(e) {}
    return null;
}

var clone = get_export("clone");
if (clone) {
    Interceptor.attach(clone, {
        onEnter: function(args) {
            try {
                // clone 的参数 args[3] 是 pthread_internal_t 指针
                // 在 Android 中,偏移 96 处存储了线程入口函数 (start_routine)
                var struct_ptr = args[3];
                if (!struct_ptr.isNull()) {
                    var start_routine = struct_ptr.add(96).readPointer();
                    
                    // 检查入口函数是否属于加固壳的 SO (libDexHelper.so)
                    var module = Process.findModuleByAddress(start_routine);
                    if (module && module.name.indexOf("libDexHelper") !== -1) {
                         var offset = start_routine.sub(module.base);
                         console.log("[+] 发现可疑线程 | 模块: " + module.name + " | 基址: " + module.base + " | 入口: " + start_routine + " | 偏移: " + offset);
                    }
                }
            } catch(e) {}
        }
    });
} else {
    console.log("[-] 未找到 clone 函数");
}

结果: 成功输出了检测线程的偏移地址,随后 App 依然崩溃(因为我们只是观测,没阻止检测)。

1
2
3
4
5
6
[MI 8::com.bybit.app ]-> [+] 发现可疑线程 | 模块: libDexHelper.so | 基址: 0x77a4306000 | 入口: 0x77a435a814 | 偏移: 0x54814
[+] 发现可疑线程 | 模块: libDexHelper.so | 基址: 0x77a4306000 | 入口: 0x77a4357304 | 偏移: 0x51304
[+] 发现可疑线程 | 模块: libDexHelper.so | 基址: 0x77a4306000 | 入口: 0x77a4362034 | 偏移: 0x5c034
[+] 发现可疑线程 | 模块: libDexHelper.so | 基址: 0x77a4306000 | 入口: 0x77a436256c | 偏移: 0x5c56c
[+] 发现可疑线程 | 模块: libDexHelper.so | 基址: 0x77a4306000 | 入口: 0x77a43688c8 | 偏移: 0x628c8
Process terminated

我们捕获到了以下关键偏移 (基于 libDexHelper.so):

  • 0x54814
  • 0x51304
  • 0x5C034
  • 0x5C56C
  • 0x628C8

0x05 Hook dlopen

我们需要在 libDexHelper.so 加载后、检测线程启动前尽快 Hook android_dlopen_ext 并完成 Patch。

  1. Patch 策略:将上述 5 个偏移处的函数代码,修改为直接返回 (RET)。
  2. 指令选择
    • RET (Return): 0xC0 03 5F D6 (ARM64)

完整脚本 (hook_dlopen.js)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
var OFFSETS = [
    0x54814, 0x51304, 0x5C034, 0x5C56C, 0x628C8
];

function patch_ret(base, offset) {
    var target = base.add(offset);
    try {
        Memory.patchCode(target, 4, function(code) {
           // ARM64: RET (直接返回) -> C0 03 5F D6
           code.writeByteArray([0xC0, 0x03, 0x5F, 0xD6]);
        });
        console.log("[+] 已Patch检测线程: " + target + " (Offset: " + offset.toString(16) + ")");
    } catch (e) {
        console.log("[-] Patch失败: " + e);
    }
}

// 兼容性封装:在不同 Frida 版本中查找导出符号
function get_export(name) {
    if (Module.findGlobalExportByName) return Module.findGlobalExportByName(name);
    if (Module.getGlobalExportByName) return Module.getGlobalExportByName(name);
    if (Module.findExportByName) return Module.findExportByName(null, name);
    try {
        var libc = Process.getModuleByName("libc.so");
        if (libc) return libc.findExportByName(name);
    } catch(e) {}
    return null;
}

var dlopen = get_export("android_dlopen_ext");
if (dlopen) {
    var is_patched = false;
    Interceptor.attach(dlopen, {
        onLeave: function(retval) {
            if (is_patched) return;
            var mod = Process.findModuleByName("libDexHelper.so");
            if (mod) {
                is_patched = true;
                
                console.log("[*] 捕获 libDexHelper.so 加载 | 基址: " + mod.base);
                OFFSETS.forEach(function(off) {
                    patch_ret(mod.base, off);
                });
            }
        }
    });
} else {
    console.log("[-] 未找到 android_dlopen_ext");
}

效果验证

运行命令:frida -U -f com.bybit.app -l hook_dlopen.js 终端输出(成功示例):

App 成功进入主界面,无闪退,Frida 连接稳定。


0x06 复盘总结

6.1 查壳

  • 特征识别: 使用 ApkCheckPack 这类工具可以快速确认加固特征。对梆梆企业壳来说,libDexHelper.so 是比较明确的线索。
  • Root 检测对抗: 传统的 su 改名方式效果有限。实际测试里,Magisk + Shamiko 这类按应用隔离 Root 痕迹的方案更稳定。

6.2 从 pthread_createclone

  • API 调用链: Android 中的线程创建并非一步到位的,而是一个层层递进的过程: Java Thread -> Native pthread_create (NDK/Libc) -> clone syscall (Kernel Interface) -> Kernel

  • 为什么 Hook pthread_create 失败? 壳在初始化阶段可能会校验 pthread_create 的指令头,或监控该函数是否被 Hook。直接附加到这个点位,容易触发崩溃。

  • 为什么 clone 有效? clone 位于更底层,直接与内核交互。很多壳对它的防护没有上层 API 那么激进,因此是一个可行的观测点。

  • 源码分析: 通过反编译手机中的 libc.so (64位),找到了 pthread_create 调用 clone 的部分:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    
    // 伪代码片段 (IDA 64-bit libc.so)
    // v51: start_routine (用户线程函数)
    // v52: user_arg (用户参数)
    // v28: pthread_internal_t* (线程控制块指针)
    
    *(_QWORD *)(v28 + 96) = v51;   // [关键] 将真实线程入口存入 +96
    *(_QWORD *)(v28 + 104) = v52;  // [关键] 将参数存入 +104
    *(_DWORD *)(v28 + 20) = getpid(inited);
    
    // 调用 clone
    // 参数1: __pthread_start (系统包装函数)
    // 参数3: flags
    // 参数4: v28 (arg,即它的 pthread_internal_t 指针)
    if ( (unsigned int)clone(__pthread_start, v17, 4001536, v28, v28 + 16, v21 + 8, v28 + 16) == -1 )
    

    两个关键发现

    1. Wrapper 机制clone 的第一个参数不是业务线程函数,而是系统的 __pthread_start,用于初始化线程环境(TLS、Stack Guard 等)。
    2. Arg 传递clone 的第4个参数 (v28/arg) 实际上是一个指向 pthread_internal_t 的指针。系统把 start_routine 藏在了这个结构体的 +96 偏移处。

6.3 动态库加载

  • android_dlopen_ext vs dlopen: 在 Android 高版本中,系统加载 SO 往往直接通过 android_dlopen_ext。Hook 这个函数可以让我们拦截到系统加载任何 SO 的动作。
  • 执行顺序:
    1. 检测到 libDexHelper.so 正在加载。
    2. 此时 SO 的基址 (base) 已经确定。
    3. onLeave 阶段通常可以拿到稳定基址并尽快完成 Patch(是否早于具体检测逻辑,取决于壳的实现时机)。
    4. 通过 Memory.patchCode 修改目标函数入口,减少后续检测线程执行风险。

6.4 内存修补 (Patching)

  • NOP (0x1F2003D5):空指令,CPU 会继续执行下一条。如果后续仍有检测调用,NOP 本身不能阻断流程。
  • RET (0xC0035FD6):让函数立即返回。用于这种场景时,通常比 NOP 更直接。

(引用:本文思路参考 xiaoeeyu - 绕过某邦企业壳root、frida检测)

前途似海,来日方长。


<