Frida学习笔记(十八):反调试与反检测对抗(下)· 招商银行三套 SDK 实战
原创
泡泡以安
泡泡以安
泡泡以安
2026年6月5日 19:37
浙江
在小说阅读器读本章
去阅读
上篇(第 17 篇)的网易云音乐易盾,最磨人的是「进程没死、agent 却被悄悄卸载」——它用 inline svc 绕过 libc 让你挂在 libc 上的 hook 集体失明、靠 SIGTRAP 自检照出插桩,还会直接 store 损坏 agent 内存、或在 MSA 构造期自爆。但它终究是反应式的:你不下钩,它未必动手。这篇换招商银行,台子更硬——它不止被动检测,还会主动反击:
anti_thread开机就起一条线程去干掉 frida 的 worker,dlopenwrapper 用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或 radare2pd),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 六条可复用方法论
- 环境伪装 > UI 兜底:让 App 真实判定 clean,避开服务器侧风控。
- 分层覆盖:native(syscall/libc)→ JNI → Java framework → 业务类,任一层漏拦都可能命中。
- 真凶追踪法:hook UI 显示点 → 抓 Java 栈 → 反查触发链 → 定位检测函数 → 找它的 IO/API 数据源 → 在数据源处伪装。
- 黄金窗口 patch:
android_dlopen_ext的onLeave是 patch native SO 的最佳时机(.init_array已跑完但JNI_OnLoad未跑)。 - 跨 ClassLoader hook:
Java.enumerateClassLoadersSync+Java.ClassFactory.get(loader)突破主 factory 的限制。 - 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 两条铁律
- 介入时机要选对:招行这种检测在
attachBaseContext早期就跑的,必须 spawn(frida -U -f <包名>)抢在它前面——一旦 attach 加固检测早跑完了;但上篇网易那种受隐私同意门控、检测「同意后」才全量启动的,反而要在同意页-Nattach 先占位。先用第 8.1 节的quick_probe摸清是哪种。 - 从外到内绕:主动反击 / 同步检测 > 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 线程、dlopenwrappermprotect杀 agent、dl_iterate_phdr二次检测、RootBeer 两个易漏点、DEC 跨 ClassLoader、环境伪装方法论、Stalker 与指令级追踪兜底。
两篇底子是同一套:抓黄金窗口下钩、分清被动检测和主动反击、能伪装环境就别只挡 UI。难度和打法一路往上爬。拿到新样本,先用第 8.1 节的 quick_probe.js 摸清级别,再对号入座。
📦 获取本篇脚本
本篇产出的整套招商银行三套 SDK 绕过脚本已整理打包:
- 关注本公众号
- 私信回复关键词「脚本」
回复内含本系列与其它系列(Unidbg / SO 逆向 / ARM 汇编 ……)的脚本汇总,长期维护更新。
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。










评论