Frida学习笔记(十八):反调试与反检测对抗(下)·招商银行三套SDK实战"

admin 2026-10-20 05:29:07 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

cover_image

Frida学习笔记(十八):反调试与反检测对抗(下)· 招商银行三套 SDK 实战

原创

泡泡以安
泡泡以安

泡泡以安

2026年6月5日 19:37
浙江

在小说阅读器读本章

去阅读

上篇(第 17 篇)的网易云音乐易盾,最磨人的是「进程没死、agent 却被悄悄卸载」——它用 inline svc 绕过 libc 让你挂在 libc 上的 hook 集体失明、靠 SIGTRAP 自检照出插桩,还会直接 store 损坏 agent 内存、或在 MSA 构造期自爆。但它终究是反应式的:你不下钩,它未必动手。这篇换招商银行,台子更硬——它不止被动检测,还会主动反击:anti_thread 开机就起一条线程去干掉 frida 的 worker,dlopen wrapper 用 mprotect 废掉 agent。保护也不止一套,CmbShield、RootBeer、DEC 三套 SDK 并联,任意一套命中都弹窗。打法也从上篇的「nop / 静态补丁检测函数」升级到「环境伪装 / UI 兜底 / 粗暴 patch」之间的取舍。

标杆样本:cmb.pb(招商银行 13.2.0+,base.apk + split\_arm64 + split\_xxxhdpi),Pixel 6 Pro / Android 13 / Magisk root,frida-server 16.7.19,radare2 + jadx。目标:让招行自己判定环境 clean(环境伪装路线),而非挡掉 UI。

配套脚本:本篇产出的整套招商银行三套 SDK 绕过脚本已整理打包。关注本公众号后私信回复关键词「脚本」 即可获取,本系列与后续 Unidbg / SO 逆向 / ARM 汇编 等系列脚本会统一在此发放并持续更新。

一、招行的三套独立反检测体系

招商银行的反检测体系由 3 套独立 SDK 并联运行,任意一套命中都会弹窗:

SDK 文件 检测内容 触发 UI 覆盖
CmbShield libCmbShield.so(自研) frida 自检测 + maps 扫描 + anti-ptrace + dl_iterate_phdr 写信号文件 + Toast ✅
RootBeer dex(com.scottyab.rootbeer 改装)+ libtoolChecker.so 7 项 root 检测(包名 / 文件 / exec / Build / prop) AlertDialog「安全风险」 ✅
DEC 动态加载 dex(com.a.b.c.*,第三方 SDK) 周期性轮询设备环境检查 Activity「注入风险」 ✅

三套互不依赖,「注入风险」和「安全风险」是两个独立弹窗,来自不同 SDK,结果也不互通。就像一栋楼装了门磁、红外、摄像头三套报警,各报各的,你拆了一套,另两套照响。解决一套另一套照样弹,是招行最磨人的地方,也是这篇要一套套拆的原因。

招行三套独立反检测体系并联,CmbShield / RootBeer / DEC 各自检测、各自弹窗(Toast「检测到风险」/ AlertDialog「安全风险」/ Activity「注入风险」),互不共享结果,任意一套命中都弹。

三套 SDK 并联

1.1 包结构与入口

12 个 dex(multidex),无第三方加固壳(没有 libjiagu/libshell/libDexHelper)——所以本篇不像上篇那样有「解密 SO」的环节,难点全在检测逻辑本身。关键 native 库:

SO 大小 角色
libCmbShield.so 894 KB 主防护库——frida 检测 / 反 ptrace / maps 扫描
libCmbGuard.so 819 KB hook 检测专项
libtoolChecker.so 5.4 KB 开源 RootBeer 的 C 实现
libdobby.so — Dobby(自身保护性 hook)

入口是一个替身 Application:AndroidManifest 里 android:name=\"cmb.shield.AW\"、android:appComponentFactory=\"cmb.shield.AP\"(Android 9+ 最早期入口)。AW 通过反射加载真正的 com.pb.main.ShellApplication。所以注入必须用 spawn(-f)不能 attach——早期检测在 attachBaseContext 阶段就跑了。

二、CmbShield:native 反 frida 的四条路径

libCmbShield.so 的反 frida 由 4 条独立路径并联,任意一条命中都会「破坏 frida」或「标记 inject」。两条主动、两条被动,先看速查表:

路径 主动/被动 触发时机 对策
#1 anti\_thread 主动 run_secsdk 启动后持续 静态 patch run_secsdk 入口 → 整个调度器禁用
#2 kill\_inject 主动 每次 dlopen 静态 patch check_inject 永远返 0
#3 maps 扫描 被动 JNI_OnLoad 内 + 后续多次 hook fread 行级净化 maps
#4 dl\_iterate\_phdr 被动 同上(替代路径) hook dl_iterate_phdr,包装它的回调

得先分清这四条的性质:#1、#2 直接破坏 frida 的运行环境,不 patch 这两条,frida 根本活不到 Java.perform;#3、#4 则是给招行喂干净数据,让它的内部判定走 clean 分支。比上篇的网易易盾更主动一截:易盾是反应式的——你不插桩它未必动手,真检到了也多是损坏 agent 内存或在构造期自爆;招行是开机就拉一条 anti_thread 主动扫描进程内所有线程,逮着 frida worker 当场就打。

下图体现 CmbShield 反 frida 的四条路径,左栏主动攻击(#1 anti\_thread 栈耗尽攻击、#2 kill\_inject 用 mprotect 破坏 agent),右栏被动检测(#3 maps 扫描、#4 dl\_iterate\_phdr 枚举),各配对策。

CmbShield 四路径

2.1 路径 #1:anti\_thread\_of\_process\_debug(主动反击)

入口在 JNI_OnLoad 内:JNI_OnLoad (0x2d0e4) → fcn.0x2e550 → run_secsdk (fcn.0x404bc)。run_secsdk 通过字符串 RUN:protect_ptrace / RUN:protect_polling / RUN:env_check 定位:

void run_secsdk(arg1) {
    char flag = check_env();
    if (flag != 0) { log(\"RUN:env_check\"); fcn.560e4(arg1, flag); }
    if (config[0xb] == 0) {
        if (config[0xc] & 1) {
          log(\"RUN:protect_polling\"); fcn.56270();
        }  // 内部启 anti_thread
        else {
          log(\"RUN:protect_ptrace\"); fcn.562a8();
        }
    }
}

protect_polling(fcn.0x56270)内部启动 anti_thread_of_process_debug 工作线程,主动扫描进程内所有线程,发现可疑线程(frida-agent worker)就发起破坏性操作。logcat 实测:

E cmd: [SECSDK] [ANTIDEBUG][anti_thread_of_process_debug] pkgname=cmb.pb
E cmd: [SECSDK] [ANTIDEBUG][anti_thread_body] Start anti process[5260]'s thread debug running, tid[5290]
E cmd: JNI_OnLoad crash return
E AndroidRuntime: java.lang.StackOverflowError: stack size 8188KB
E AndroidRuntime:     at cmb.shield.H.load(H.java:43)

process[5260] 是 frida-server 主进程,tid[5290] 是 frida-agent 在目标进程内的 worker 线程。攻击手段推测是 pthread_kill / tgkill 给 worker 发信号,配合 signal handler 让其栈炸裂——主线程的 H.load() 出现 StackOverflowError 强烈暗示某种栈耗尽攻击。

对策:直接禁用整个 run_secsdk 调度器(两条指令):

0x404bc:  原 ff 03 01 d1 (sub sp, sp, 0x40)  →  patch 00 00 80 52 (mov w0, #0)
0x404c0:  原 fd 7b 03 a9 (stp x29,x30,[sp,0x30])  →  patch c0 03 5f d6 (ret)

等价于 int run_secsdk(){ return 0; }。caller 不依赖返回值也不期望栈状态变化,无副作用。为什么不只 patch protect_polling?因为 protect_ptrace 分支也会启动 ptrace 阻断,影响后续调试,整体 patch 一劳永逸。

2.2 路径 #2:kill\_inject(dlopen wrapper + mprotect 破坏 agent)

libCmbShield 通过 inline hook 替换了 libc 的 dlopen,所有 SO 加载都过 fcn.0x5f090:

void* dlopen_wrapper(const char *name, int mode) {
    if (name == NULL) goto original;
    if (strstr(name, \"/system/\") || strstr(name, \"/vendor/lib\")) goto original;
    if (/*命中关键字*/) {
        if (check_inject(name) == 1) {        // fcn.0x62158
            log(\"[SECSDK]-kill inject uid failed :%s\", name);
            kill_agent(0x40, 0xb6a2879f, 0xfff);   // fcn.0x2c840
        }
        if (find_hook_feature(name) == 1) {   // fcn.0x622d0
            log(\"[SECSDK]-kill inject find_hook_feature——kill:%s\", name);
            kill_agent(0x40, 0xb6a2879e, 0xfff);
        }
    }
original:
    return real_dlopen(name, mode);           // 0xe1a38 函数指针
}

kill_agent(fcn.0x2c840)的参数模式 (0x40, hash, 0xfff) 里 0xfff 是 4KB 页对齐掩码,推测是 mprotect((addr & ~0xfff), page, PROT_NONE)——mprotect 是改内存页权限的系统调用,把 frida-agent 那块代码页设成「禁止执行」,CPU 下一次执行到那里就当场撞墙(SIGSEGV)。实测现象印证:frida CLI 报 Process terminated,但 adb shell pidof cmb.pb 仍有 PID——是 frida-agent 被打掉了,目标进程还活着。这正是上篇第 10 章退出诊断表里「Process terminated 但 pidof 还在 → agent 被损坏、进程没事」那一行。

对策:patch 调用点,让两个 checker 永远返 0:

0x5f1bc:  bl check_inject       →  mov w0, #0  (00 00 80 52)
0x5f22c:  bl find_hook_feature  →  mov w0, #0

bl 换成 mov w0,#0 后,下一条 cmp w0,1; cset w8,ne; tbnz w8,0,跳过 永远走 tbnz,直接跳过 kill 分支。不 patch kill_agent 本身,是因为它可能被别的合法路径共用,patch 调用点更安全。

2.3 路径 #3:/proc/self/maps 字串扫描

fcn.0x56a2c(parse\_self\_maps):fopen(\"/proc/self/maps\",\"r\") + 逐 1024B fread + 行内匹配 frida / memfd:frida-agent。命中样本:

780508b000-7805aba000 r--p 00000000 00:01 2004  /memfd:frida-agent-64.so (deleted)
7805abb000-78067fb000 r-xp 00a2f000 00:01 2004  /memfd:frida-agent-64.so (deleted)
...

frida-server 通过 memfd 注入 agent,文件名硬编码含 frida,没法在 frida 端隐藏,只能在读取侧净化。

对策:不阻止 fread,只对 buf 做长度严格不变的行级替换:

// 原行: \"...2004    /memfd:frida-agent-64.so (deleted)\"
// 替换: \"...2004    [anon:cmbsdk_data]                \"  ← 长度一模一样

长度不变是关键:① sscanf 按字段位置解析,长度变了后续行错位;② fread 返回的字节数不能变,否则 caller 可能拿「字节数异常」当命中信号;③ \ 数量保持原样。如果 SO 改用 open + read(而非 fopen + fread),还要同时拦 read——生产脚本两条路径都覆盖作保险。

2.4 路径 #4:dl\_iterate\_phdr(绕过 maps 的二次检测)

第二条 frida 自检测,完全绕过 maps 文件。调用点在巨型函数 fcn.0xa5184(207 KB,3193 BBs)内的 0xb41ac:

int dl_iterate_phdr(int (*cb)(struct dl_phdr_info*, size_t, void*), void *data);
struct dl_phdr_info { ElfW(Addr) dlpi_addr; const char *dlpi_name; /* +8 */ ... };

dl_iterate_phdr 是 linker 提供的一个接口:linker 内部维护着一份「当前进程加载了哪些 SO」的清单,你调它,它就把清单逐条念给你听。它念的是内存里的清单、不读 /proc/self/maps 文件——所以上一节净化了 maps,这条路照样能发现 frida。具体来说,linker 对每个加载的 SO 调一次回调,把元信息传入;回调里检查 dlpi_name 是否含 frida,命中就把信息写回栈帧、返回 1 停止迭代。

对策:hook dl_iterate_phdr 的 onEnter,把 args[0] 换成我们自己的包装回调,对 frida 相关 SO 直接跳过:

const wrapperRefs = new Set();   // ★ 必须保留引用防 GC
Interceptor.attach(Module.findExportByName(null, \"dl_iterate_phdr\"), {
    onEnter(args) {
        const origCb = args[0];
        const wrap = new NativeCallback(function (info, size, data) {
            let name = '';
            try {
              name = info.add(8).readPointer().readCString();
            } catch (_) {}
            if (
              name && /frida|gum-js|linjector/.test(name)
            ) return 0;  // 跳过 → 相当于不存在
            return new NativeFunction(origCb, 'int', ['pointer','size_t','pointer'])(info, size, data);
        }, 'int', ['pointer','size_t','pointer']);
        wrapperRefs.add(wrap);   // NativeCallback 被 GC 回收 → 野指针 → 崩溃
        args[0] = wrap;
    }
});

这里有个坑:NativeCallback 一旦被 GC 回收,后续 linker 调用就是野指针、进程立即崩,所以得拿 Set 把它攥住。整套替换对招行是透明的——它的枚举逻辑跑得跟没被动过手脚一样,只是再也看不到 frida 的 SO 了。

2.5 时序关键:dlopen onLeave 是 patch 黄金窗口

libCmbShield.so 的 .init_array 只有 C++ 静态构造(fcn.0x27ef4,13 个 bl,全是编译器生成的 ctors,不做反检测)。真正的反检测在 JNI_OnLoad 内,由 ART runtime 在 dlopen 完成后才显式调用。所以 hook android_dlopen_ext 的 onLeave、拿到 libCmbShield.so 基址后立即做 4 处字节 patch,时机刚好早于 JNI_OnLoad。

下图是 dlopen 加载 libCmbShield.so 的时间轴——左段是 dlopen 内部(mmap / 重定位 / .init_array 静态构造,无害),中间 onLeave 是黄金窗口、打 4 处字节 patch(run_secsdk ×2 + check_inject / find_hook_feature),右段 JNI_OnLoad 及后续检测全部失活。onLeave 正好卡在「.init_array 已跑完」和「JNI_OnLoad 还没跑」之间,是唯一安全的注入点。

dlopen onLeave 黄金窗口时序:.init\_array 静态构造无害 → onLeave 打 4 处字节 patch → JNI\_OnLoad 及后续 maps/dl\_iterate\_phdr 检测全部失活

每条 patch 都先校验原 4 字节是否匹配预期,不匹配则 skip + WARN——保证 SO 版本变更时不会误 patch 别的指令。

2.6 命中之后:延迟反馈让弹窗栈「干净」

CmbShield 检测命中后不当场弹窗,而是把信号写进文件:native 通过 H.sha(key, path) 把信号文件路径塞进静态变量 Cts.A / Cts.B,等替身 Application cmb.shield.AW 跑到 hanDeRe() 时再 dRbf() 读这两个文件,存在就 Toast 弹「检测到风险」。这一步是故意的:你 hook Toast.makeText 抓调用栈,看到的是一条干干净净的 Java 栈,完全看不到 native 检测的影子。而且 dRbf 读完立刻 file.delete()、每次启动重写,外加 ShieldService 每 30 秒(最长 150 秒)再扫一遍 Cts.B——所以有时你以为绕过了,过半分钟又弹一个。对付这种「检测点和弹窗点解耦」的设计,硬看弹窗调用栈是死路,只能用第 4.1 节的真凶追踪法:从看得见的弹窗反查到信号文件,再反查到写信号的 native 命中点。

三、RootBeer:root 检测的 7 项与 2 个易漏点

CmbShield 那四条路径压下去,frida 总算能在招行进程里活下来。但主界面还会蹦一个「安全风险」弹窗——这是第二套,RootBeer,一套改装过的开源 root 检测。调用链——isRoot() 检出 root 后一路冒泡到「安全风险」弹窗;isRoot() 并联 RootBeer.isRooted()(7 子检测的 OR)与 checkRootOld()。5 项常规(包名 / 文件 / prop / Build.TAGS)第 16 篇已覆盖,2 项最易漏拦——checkSuExists 用数组形式 exec(new String[]{\"which\",\"su\"})、checkForRootNative 走 fopen 探测,朴素 hook 都会漏。全套伪装后 isRooted() 真实返 false(非 UI 兜底)。

RootBeer 调用链

7 个子检测大多是常规的(包名扫描、文件扫描、prop 检查、Build.TAGS),第 16 篇已覆盖通用绕过。这里只点两个最容易漏拦的:

3.1 易漏点一:checkSuExists 用数组形式

process = Runtime.getRuntime().exec(new String[]{\"which\", \"su\"});  // ★ 数组!

常规的 hook 只检查 cmds[0],而 cmds[0] = \"which\" 不在 su 黑名单——直接漏掉。正确做法:对整个 String[] 数组里任一元素是 su/busybox/magisk 都拦,Runtime.exec 的 4 个重载(String / String[] / 带 env 的两个)和 ProcessBuilder.start() 都要覆盖。

3.2 易漏点二:checkForRootNative 用 fopen 探测

libtoolChecker.so 的 Java_com_scottyab_rootbeer_RootBeerNative_checkForRoot 内部:

bool exists(const char *path) {
    FILE *f = fopen(path, \"r\");     // ← 关键:fopen,不是 stat/access!
    if (f) { fclose(f); return true; }
    return false;
}

常规的 native hook 只覆盖 stat / lstat / access / faccessat,会漏掉 fopen 这条路。正确做法:把 fopen 也加入伪装层,对 root 路径返 NULL。

应用全套伪装后,isRooted() 真实返回 false(不是 UI 兜底),Magisk 也不再弹「超级用户请求」——因为 Runtime.exec / native execve 都拦在源头,根本没真去申请 su。

四、DEC:动态加载 dex + 跨 ClassLoader hook

本以为搞定 RootBeer 就清净了,App 偏偏还弹「注入风险」——第三套 SDK 浮出水面。这一套是我整轮分析里最难揪出来的:类名混淆成 com.a.b.c.*,dex 还不在 apk 里。

4.1 真凶追踪法:从 UI 反查到检测源

我 hook 住 Instrumentation.execStartActivity,把弹窗 Activity 的启动栈抓了下来:

execStartActivity -> com.a.b.c.DECMsgDialog
  at DeviceEnvironmentCheck$MsgDialog.show
  at DeviceEnvironmentCheck.showMessage
  at DeviceEnvironmentCheck$CheckTask.onPostExecute

真凶定位为 com.a.b.c.DeviceEnvironmentCheck。这套「hook UI 显示点 → 抓 Java 调用栈 → 反查触发链 → 定位检测函数 → 找它的数据源」的流程,就是真凶追踪法。

下图从看得见的弹窗顺调用栈一路反查,经 getCheckResult(伪装拦截点)追到最底层数据源 envc.push 文件。

真凶追踪反查链

4.2 跨 ClassLoader 强制 hook

com.a.b.c.DeviceEnvironmentCheck 不在 base.apk 任何 dex 里(binary grep + dexdump -l 全 0),主 factory 下 Java.use 直接失败。ClassLoader 相当于类的户口本:Java.use 默认只翻主 App 那一本,DEC 这种运行时动态加载的类登记在另一本上,自然查不到。把所有 ClassLoader 枚举一遍,找到能 findClass 它的那本,拿它建个专用 factory 就行:

let loader = null;
Java.enumerateClassLoadersSync().forEach(l => {
    if (loader) return;
    try {
      if (l.findClass('com.a.b.c.DeviceEnvironmentCheck')) loader = l;
    } catch (_) {}
});
if (!loader) throw new Error('DEC loader 未找到——dex 可能还没被动态加载,确认用 spawn 启动且已过 attachBaseContext');
const factory = Java.ClassFactory.get(loader);
const DEC = factory.use('com.a.b.c.DeviceEnvironmentCheck');

动态加载 dex、插件化、热修复塞进来的类,主 Java.use 一概找不到,套路都是这一招:enumerateClassLoadersSync 枚举出登记它的那本户口本,再 ClassFactory.get 建一个专用 factory。

4.3 检测信号源:envc.push 文件

DEC 的 getCheckResult(key) 只做一件事:读 /data/user/0/cmb.pb/envc.push,解析后返回 0/1。在 getCheckResult 的 onEnter/onLeave 设全局标记 inCheckKey、让 native IO hook 只在标记非空时记录,精确捕获:

ENTER getCheckResult(\"frida\")
  IO open(\"/data/user/0/cmb.pb/envc.push\")
  IO fopen(\"/data/user/0/cmb.pb/envc.push\")
EXIT  getCheckResult(\"frida\") = 1

envc.push 是 16 字节 AES-128 密文(Base64 编码),每次启动随机化。它的写者绕开了整个 libc 写 API,普通 hook 一个都抓不到——这条线我追了很久也没追到底,下一节专门讲怎么对付这种对手。好在伪装根本不用解开它:直接从 Java 层最高点拦 getCheckResult 就行。

4.4 三层伪装

// Tier A(最简):getCheckResult 永远返回 0
DEC.getCheckResult.implementation = function (key) { return 0; };

// Tier B(保险):Support 黑名单全空
const Support = factory.use('com.a.b.c.DeviceEnvironmentCheck$Support');
Support.isSupport.implementation     = function (k) { return false; };
Support.isWarn.implementation        = function (k) { return false; };
Support.isWarnAndExit.implementation = function (k) { return false; };

// Tier C:阻止持久化命中事件
const CheckTask = factory.use('com.a.b.c.DeviceEnvironmentCheck$CheckTask');
CheckTask.saveInjectInfo.implementation = function () {};

A+B+C 三层挂上,主界面就再没弹过窗。这里插一段我交过的学费:一开始我盯上了 SP key c4ede765...(一个 boolean),笃定它是 risk flag,顺着它追了很久,最后才发现它是 AndroidX WindowEmbedding(平板分屏)的开关,由 EmbeddingHelper 写入,跟 risk 检测八竿子打不着。教训:看到可疑的持久化数据,先 trace 它的 writer,别凭值的形态去猜用途。

三套 SDK 到此全部摆平,弹窗清零。但 DEC 那个 envc.push 写者,我始终没用普通 hook 抓到——这正好引出最后一件武器:当 hook 彻底失灵时该怎么办。

五、当 hook 失灵时:Stalker 与指令级追踪

回到上一节没解开的谜:DEC 读的 envc.push,到底是谁写的?我把能想到的 libc 写 API 全 hook 了一遍,结果全部 0 命中:

类别 hook 了但 0 命中的 API
open 系列 open openat openat2 creat __open_2 fopen ...
write 系列 write fwrite fputs pwrite writev ...
重命名 / 截断 rename renameat truncate ftruncate

我又在 doInBackground 里逐次 stat 这个文件,时序证据很清楚:它在某个异步窗口里凭空出现,却没经手任何一个 libc 写 API。到这一步我心里基本有数了——写者多半用了 raw inline syscall(mov x8, #__NR_openat; svc #0)直接进内核,把整个 libc 函数层都绕了过去。 这里要理解 hook 为什么失灵:正常写文件是「走 libc 这个前台办手续」,hook 了 libc 函数就能在前台把它拦下;raw syscall 是用一条 svc 汇编指令绕过前台、直接闯进内核办事,你守在前台自然一个都逮不到。这种对手,普通 hook 彻底失灵,只能祭出指令级 trace。

5.1 Stalker:函数级调用链追踪

Frida 的 Stalker 能在指定内存范围追踪所有函数调用(第 24 篇专题)。用它定位「哪个函数走完之后发生了写盘 / 崩溃」:

function stalkerTraceModule(name) {
    var m = Process.getModuleByName(name);
    Stalker.follow(Process.getCurrentThreadId(), {
        events: { call: true },
        onReceive: function (events) {
            Stalker.parse(events).forEach(function (c) {
                var addr = ptr(c[1]);
                if (addr.compare(m.base) >= 0 && addr.compare(m.base.add(m.size)) < 0)
                    console.log(\"[call] \" + DebugSymbol.fromAddress(addr));
            });
        }
    });
}

Stalker 是函数级(每条 call/ret 都记),适合定位「哪个函数后崩」。但如果检测逻辑内联在大函数里、不走独立函数调用(招行 fcn.0xa5184 那种 207 KB 巨型函数就是典型),Stalker 会丢线索。

5.2 指令级追踪:Stalker 不够时还有三条路

要揪出那条 svc 藏在 207 KB 巨型函数的第几行,函数级的 Stalker 就不够了,得逐指令记录。三条路可走:

  • Frida Stalker 开 events: { exec: true }:Stalker 本身就能逐指令记录,代价是日志爆炸、目标函数太大时性能急剧下降。适合先用第 5.1 节把范围缩到某个中等函数,再开 exec 精修。
  • Unicorn / unidbg 代执行:把 SO 搬进模拟器,补好 JNI / syscall 环境后单独「代跑」JNI_OnLoad 或可疑函数,逐指令 trace,撞上 mov x8, #__NR_openat; svc #0 当场抓现行。离线、可重放,是定位 raw syscall 最稳的路子。
  • 静态兜底:把整个 SO 反汇编(objdump -d 或 radare2 pd),grep 出所有 svc 指令,再回溯附近引用 envc.push 字符串的代码——不依赖运行时,SO 不大时最省事。

说句实在的——招行这个 envc.push 写者,我到收工都没追到底。hook 全失灵、确认是 raw syscall 之后,指令级定位就留作了未来研究。但这不影响绕过:伪装根本不用解开 envc.push,第 4.4 节 Tier A 直接从 getCheckResult 一刀切就够了。能从上层切干净的,就别陷进底层考古——这正是分层覆盖的实战价值,也是这篇反复强调「在数据源处伪装 > 逐层硬刚」的原因。

六、方法论升级:环境伪装 vs UI 兜底 vs 粗暴 patch

招行这套,最有嚼头的不是哪个偏移,是绕过路线怎么挑。同一个检测,能从三个地方下手:

路线 性质 优劣
粗暴 patch(patch native 检测函数返 0) 让 App 不知道检测出了问题 服务器侧风控仍能从上报通道感知
UI 兜底(hook AlertDialog.show / startActivity) 用户看不见弹窗 App 内部仍判定 risk,后续登录 / 转账可能被拒 + 服务器风控
环境伪装(hook 检测 API 改输入数据) App 跑完所有检测,但读到的环境数据全干净 App 真的判定自己处于干净环境,最彻底

招行的 risk 事件即使本机绕过弹窗,仍会通过 Clc.scl 上报到服务器侧风控,账号可能被服务器标记。所以环境伪装 > UI 兜底:让 App 真实判定 clean,比挡 UI 显示更有价值、也避开服务器风控。绕过研究应同步拦截上报通道。

三条绕过路线在「检测 → 判定 → 弹窗 → 上报」流水线上的拦截位置:环境伪装拦在第 ① 环(最彻底),粗暴 patch(②)与 UI 兜底(③)都漏服务器风控。

三条绕过路线对比

6.1 环境伪装的最小完备 API 覆盖

层 关键 API(处理方式)
Native(libc/libdl) fopen/fread(maps 行级净化 + root 路径返 NULL)、stat/access(root 路径返 -1)、execve 系列(含 su 返 -1)、__system_property_get(敏感 prop 返伪装值)、dl_iterate_phdr(包装回调)
Java framework Runtime.exec 4 重载 + ProcessBuilder.start(含 su 抛 IOException,数组任一元素都查)、File.exists/canRead(root 路径返 false)、Build.TAGS(test-keys→release-keys)、PackageManager.getPackageInfo(root 包名抛 NameNotFoundException)
DEC(专用 ClassFactory) getCheckResult→0、Support.isSupport/isWarn/isWarnAndExit→false、CheckTask.saveInjectInfo→noop

6.2 六条可复用方法论

  1. 环境伪装 > UI 兜底:让 App 真实判定 clean,避开服务器侧风控。
  2. 分层覆盖:native(syscall/libc)→ JNI → Java framework → 业务类,任一层漏拦都可能命中。
  3. 真凶追踪法:hook UI 显示点 → 抓 Java 栈 → 反查触发链 → 定位检测函数 → 找它的 IO/API 数据源 → 在数据源处伪装。
  4. 黄金窗口 patch:android_dlopen_ext 的 onLeave 是 patch native SO 的最佳时机(.init_array 已跑完但 JNI_OnLoad 未跑)。
  5. 跨 ClassLoader hook:Java.enumerateClassLoadersSync + Java.ClassFactory.get(loader) 突破主 factory 的限制。
  6. IO 关联 trace:在 Java 方法 onEnter 设全局标记,让 native hook 把 IO 关联到具体方法(第 4.3 节定位 envc.push 就靠它)。

七、工具链兜底:去指纹 server / 自编译 / 静态 patch

脚本层有些检测面注定打不赢——比如内存扫 LIBFRIDA 字符串、单字节读 maps。这时换一把「没指纹」的 Frida 构建。

触发条件 必须换 server
加固 SO 内存扫 LIBFRIDA / gum-js-loop 字节序列 字符串无法运行时全部擦除
加固 SO 检测 D-Bus AUTH 协议握手 改端口治标,去掉 AUTH 字符串才治本
加固 SO 同时检测多个 libc/libart 函数的 Inline Hook 自编译 Frida 改 trampoline 模式
项目 仓库 推荐场景
strongR-frida-android github.com/hzzheyang/strongR-frida-android 默认首选(改名 + 改线程名 + D-Bus 关键字去除)
Florida github.com/Ylarod/Florida strongR 仍被检出时(patch 更激进 + 随机化)
VERSION=16.5.6   # 示例;实际选与本机 frida-tools 同号的 release(标杆环境用的是 16.7.19)
wget https://github.com/hzzheyang/strongR-frida-android/releases/download/${VERSION}/hluda-server-${VERSION}-android-arm64.xz
unxz hluda-server-${VERSION}-android-arm64.xz
adb push hluda-server-${VERSION}-android-arm64 /data/local/tmp/sysmon
adb shell \"su -c '/data/local/tmp/sysmon -l 0.0.0.0:38291 &'\"   # 改名 + 改端口
frida -H 127.0.0.1:38291 -F cmb.pb -l bypass.js

client 与 server 版本必须严格同号(精确到 minor;16.5.x 只能配 16.5.x,不能配 16.4.x)。

更彻底的纵深(面向未来更激进的检测升级):① 用 apk-repack 静态 patch libCmbShield.so 重打包,不依赖 frida 真正「fix」这个 SO;② Magisk Zygisk DenyList + frida-gadget 嵌入 APK,去掉 frida-server 进程;③ patch linker 让 maps / dl_iterate_phdr 都「自然」看不到 frida-agent,最彻底的 OS 级伪装。

八、分层防御决策表:拿到任意 App 怎么试

把上下两篇浓缩成一张实战决策表,下次拿到新样本按这个层级试:

级别 方案 成本 覆盖 适用场景
1 改名 + 改端口 + spawn 模式 1 分钟 60% 国内中小 App、未集成主流加固
2 改名 frida-server + signalProbe 过 SIGTRAP / 线程名自检 即时 75% libnesec 级信号 / 线程名自检(上篇第 3 节)
3 抢「干净窗口」attach + 晚挂分离检测面 30 分钟 85% 受同意门控、检测「同意后」才全量(上篇第 5、8 节)
4 r2 反查 exit + 12-NOP + 静态补丁检测函数返 0 1 小时 92% 网易易盾、inline svc + 两层击杀(上篇全篇)
5 静态 patch 调度器 + 环境伪装 + Stalker/指令级 trace + strongR 半天到一天 98% 招行三套 SDK、主动反击型加固(本篇)

下图为五级分层防御,成本从 L1 改名改端口(1 分钟 / 60%)递增到 L5 环境伪装 + Stalker/指令级 trace(半天到一天 / 98%),覆盖率随之爬升。

分层防御五级决策

8.1 先跑判定脚本

// quick_probe.js —— 5 分钟摸清加固级别
function probe() {
    Interceptor.attach(Module.findExportByName(null, \"android_dlopen_ext\"),
        { onLeave: function () { console.log(\"[dlopen onLeave]\"); } });
    Interceptor.attach(Module.findExportByName(\"libc.so\", \"pthread_create\"),
        { onEnter: function (a) { console.log(\"[pthread_create] start=\" + a[2]); } });
    Interceptor.attach(Module.findExportByName(\"libc.so\", \"clone\"),
        { onEnter: function (a) { if (a[3] != 0) console.log(\"[clone] tcb=\" + a[3]); } });
}
probe();

判定流程:① 只见 dlopen onLeave、pthread_create 始终不触发 → 检测多在构造期或走 inline svc(上篇 libnesec 第 3 节、MSA 第 4 节都是这型);② 看到 pthread_create 起后台线程轮询 → ptrace 自调试 / 轮询型(上篇 MSA,第 4 节);③ 没下钩就被「干净 terminated」、libc 那圈 hook 零命中 → inline svc 绕 libc 的成熟防护,先抢干净窗口再分离检测面(上篇第 5、8 节);④ frida Process terminated 但 App 还活着、logcat 有 anti_thread → 本篇第 2 节主动反击。

8.2 两条铁律

  1. 介入时机要选对:招行这种检测在 attachBaseContext 早期就跑的,必须 spawn(frida -U -f <包名>)抢在它前面——一旦 attach 加固检测早跑完了;但上篇网易那种受隐私同意门控、检测「同意后」才全量启动的,反而要在同意页 -N attach 先占位。先用第 8.1 节的 quick_probe 摸清是哪种。
  2. 从外到内绕:主动反击 / 同步检测 > Hook 完整性校验 > 异步检测线程。颠倒顺序则外层先把你杀掉,里层脚本来不及生效。
frida -U -f cmb.pb \\
    -l cmb_shield_patch.js \\   # 第 2 节 native patch(最先,保命)
    -l root_spoof.js \\         # 第 3 节 RootBeer 环境伪装
    -l dec_bypass.js \\         # 第 4 节 DEC 跨 ClassLoader
    -l business_hook.js \\      # 业务层(最后再 hook)
    --no-pause

九、总结

上下两篇用两个真实标杆,覆盖了「对抗 App 自我保护」的几乎全部档次:

  • 上篇(网易云音乐易盾):进程不死、agent 被悄悄卸载。SIGTRAP 自检 signalProbe、线程名扫描配 patched frida-server、inline svc 绕 libc、MSA 12-NOP(r2 反查 exit)、同意页 -N 抢干净窗口、六个单变量实验重建两层击杀模型(MSA 构造自爆 + agent 内存损坏)、慢层静态补丁、快层只钩非安全库、退出诊断表、能力排除法、跨设备敏感性。
  • 下篇(招商银行):主动反击 + 三套 SDK 并联。anti_thread 主动破坏 worker 线程、dlopen wrapper mprotect 杀 agent、dl_iterate_phdr 二次检测、RootBeer 两个易漏点、DEC 跨 ClassLoader、环境伪装方法论、Stalker 与指令级追踪兜底。

两篇底子是同一套:抓黄金窗口下钩、分清被动检测和主动反击、能伪装环境就别只挡 UI。难度和打法一路往上爬。拿到新样本,先用第 8.1 节的 quick_probe.js 摸清级别,再对号入座。

📦 获取本篇脚本

本篇产出的整套招商银行三套 SDK 绕过脚本已整理打包:

  1. 关注本公众号
  2. 私信回复关键词「脚本」

回复内含本系列与其它系列(Unidbg / SO 逆向 / ARM 汇编 ……)的脚本汇总,长期维护更新。


app-skill脱壳秒杀企业壳" 网络安全文章

app-skill脱壳秒杀企业壳"

本文介绍app-skill脱壳工具可秒杀企业壳包括360等,使用AI辅助和root手机通过USB连接电脑自动脱壳,原理为dump内存但会产生大量文件需AI修复,技术门槛因AI降低但需注意识别真正源代码文件
炸裂!这款BurpSuite插件让指纹识别效率提升1000%,实战渗透工程师直呼内行" 网络安全文章

炸裂!这款BurpSuite插件让指纹识别效率提升1000%,实战渗透工程师直呼内行"

本文介绍BurpFingerPrint,一款基于Java9+的BurpSuite被动指纹识别插件。它集成Ehole指纹库,支持OA系统、网络设备等资产识别,具备URL提取、指纹库热更新及Tomcat弱口令爆破功能。采用线程池并发处理与SQLite持久化存储,适用于攻防演练与授权渗透测试的信息收集阶段。文章末尾包含商业推广内容。
藏身东京写字楼里的秘密军事情报网络遭曝光" 网络安全文章

藏身东京写字楼里的秘密军事情报网络遭曝光"

本文披露俄罗斯格鲁乌以东京俄航办公室为掩护,通过商业物流渠道获取日本芯片等双用技术支援战争,导弹残骸印证了该供应链漏洞。日本右翼借此渲染安全危机,推动突破战后限制以强化情报与出口管控。这表明技术供应链已成大国隐秘博弈新战场,企业需警惕产品遭非法转用。
评论:0   参与:  0