ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Frida Native层Hook实战:核心API与六大场景

Frida Native层Hook实战:核心API与六大场景

1. Native层才是Hook真正的主战场

但凡在移动端做过一段时间逆向或者安全评估的人,都会经历一个明显的分水岭:一开始用Frida hook Java层的方法,觉得整个世界都很美好,Java.perform一包,Java.use一挂,参数返回值随便改,成就感爆棚。然后你会遇到第一个加固过的样本,Java层的方法名全是a、b、c,逻辑被抽到 so 里,Java.use挂上去发现根本没有调用记录——因为真正的运算在 Native 层,Java 那层只是个壳。这时候你就得往下走,去碰Interceptor、Module、Memory、NativeFunction这一套东西。这篇内容就是把我这几年在 Native 层做 Hook 的整套操作整理出来,从环境对齐、API 拆解,到六个高频场景的完整脚本,再到对抗和排查。适合已经会用 Frida 挂 Java 方法、想往 Native 层深入的人;也适合做客户端安全评估、协议分析、性能定位的同行参考。所有内容都建立在获得明确授权的测试环境或自有样本上,这一点先讲清楚,后面的技术讨论才有意义。

1.1 Java层Hook的天花板到底在哪

Java 层 Hook 的本质是替换 ART 里的方法入口,它能看到的东西受限于 Java 语义。具体来说有四个明显短板。第一,混淆与抽取。方法名可以混淆,方法体可以被抽到 so 里由native方法承载,你在 Java 层看到的只是一个声明,没有实现,hook 上去只能拿到入参,拿不到内部逻辑。第二,看不到内存与寄存器。很多关键数据(密钥、签名中间量、校验结果)根本不经过 Java 对象,直接在 C 层的栈上或者寄存器里流转,Java 层的 hook 点根本触及不到。第三,性能敏感路径。加解密、哈希、图像处理这类热点通常在 so 里做,Java 层的调用频率可能是一次,实际运算在 C 层循环几万次。第四,反调试与检测。风控逻辑本身大概率在 Native 层,检测 Frida 的代码也基本写在 so 里,你不下沉到 Native 层,连对手在哪都不知道。

所以"下沉"不是一个可选项,而是必经之路。我个人的判断标准很简单:如果目标的关键逻辑在 so 里,并且 Java 层拿到的信息不足以支撑结论,那就必须走 Native 路线。反过来,如果只是看某个开关有没有打开、某个参数传了什么,Java 层就够用,别为了炫技去写一堆 Native 脚本,维护成本高得多。

1.2 Native层能拿到的四类关键信息

一旦进到 Native 层,你能拿到的东西维度完全不一样了,我习惯分成四类。

函数级调用信息:谁调了谁、传了什么指针、返回了什么。这是Interceptor.attach的领域,最常用,也最容易上手。

内存级数据:某个地址上的结构体长什么样、某个全局变量什么时候被写、某段缓冲区里是不是明文。这是Memory.read*和Memory.scan的领域,配合MemoryAccessMonitor还能做访问监控。

执行流:函数内部的指令级走向、分支有没有走到、循环了多少次。这是Stalker的领域,信息量最大但性能和稳定性代价也最高,属于"最后手段"。

符号与模块级信息:so 的加载基址、导出表、依赖关系、加载时机。这是Process.enumerateModules和 dlopen 拦截的领域,是所有 Hook 的前置条件——你连基址都算不对,后面全是空谈。

这四类信息的使用优先级,我的建议是:先用模块信息定位,再用函数级 Hook 拿主干,内存读写补充细节,Stalker 只在前面都解释不通的时候上。顺序反了会很痛苦,我见过有人一上来就 Stalker,结果目标卡死,连日志都出不来。

1.3 方案选型的现实考量

Hook 框架不止 Frida 一家,静态改包、Xposed、inline hook 库、内核态方案各有用武之地。我选 Frida 的理由很实际:脚本化、不需要重新打包、迭代速度快。改一版逻辑只要存个 js 文件重跑,不用重新签名安装,这对需要反复试错的场景是决定性优势。代价是它依赖注入、有运行时开销、特征相对明显,在强对抗环境下需要额外的隐藏处理,这部分第 5 章会展开。

另一个常见纠结是"要不要用 CModule 写 C 代码"。我的经验是:逻辑简单、只做数据搬运的,JS 完全够用;一旦涉及复杂结构体解析、大量循环、或者需要在onEnter里做重计算的,果断上 CModule,性能差距是数量级的。但 CModule 编译有延迟、调试困难,别一上来就用,先把逻辑用 JS 跑通,确认思路对了再下沉。

2. 动手之前:环境与时机的三个硬性前提

我统计过自己踩过的坑,大概六成以上不是脚本写错,而是环境没对齐或者时机不对。Native Hook 对环境的要求比 Java 层苛刻得多,因为地址是硬编码的,差一个版本,偏移全错,脚本跑起来不报错,就是静默失效,特别难查。所以这一章先讲前提,再讲代码。

2.1 版本、架构、进程三对齐

版本对齐指frida(Python 侧)和frida-server(设备侧)的主版本必须一致,次版本尽量一致。不一致的典型症状是连接成功但Interceptor.attach直接抛Error: unable to intercept function at 0x...; please file a bug,或者更隐蔽地挂上了但从不触发。用frida --version和frida-server --version各查一次,是最省事的排查动作。

架构对齐指 server 的二进制架构必须匹配设备 CPU。现在主流设备是arm64,老设备是arm,模拟器常见x86_64。放错架构的 server 根本起不来,会报Exec format error,这个还好查。

进程对齐指你要 hook 的 so 必须真的在那个进程里。听起来像废话,但Process.enumerateModules()打出来发现目标 so 不在列表里,是极高频的问题——要么进程选错了(多进程 App 常见),要么 so 还没加载(时机问题)。下面这段是我每次开工前必跑的自检脚本:

// 自检:确认目标 so 是否已加载,以及它的基址 function checkModule(name) { const m = Process.findModuleByName(name); if (m) { console.log(`[+] ${m.name} base=${m.base} size=${m.size} path=${m.path}`); return m; } else { console.log(`[-] ${name} 未加载,当前已加载模块数:${Process.enumerateModules().length}`); return null; } } // 打印所有 so,按大小排序,快速找可疑模块 Process.enumerateModules() .filter(m => m.path.endsWith('.so')) .sort((a, b) => b.size - a.size) .slice(0, 20) .forEach(m => console.log(`${m.name}\t${m.base}\t${m.size}`));

注意:Module.findExportByName在新版本里推荐换成Module.findGlobalExportByName或Module.getExportByName,后者找不到会直接抛异常而不是返回 null,这在调试时反而更好用——异常信息会明确告诉你哪个符号没找到。

2.2 frida-server 的部署与验证

部署这块,我的固定流程是四步:推文件、给权限、起服务、验连通。推文件注意/data/local/tmp是常用目录但并非所有环境都可写,遇到权限问题换到应用自己的可写目录也可以。起服务建议前台起,方便看日志,长期跑再考虑后台化。

# 1. 推送(假设本地已准备好对应架构的二进制) adb push frida-server /data/local/tmp/fs adb shell "chmod 755 /data/local/tmp/fs" # 2. 前台启动,观察是否报错 adb shell "/data/local/tmp/fs -l 0.0.0.0:1234" # 3. 另开终端验证 frida-ps -H 127.0.0.1:1234

端口改成非默认值是个很实用的习惯,原因在第 5 章讲特征检测时会提到。验证阶段如果frida-ps能列出进程列表,说明链路通了;如果列不出来,先查adb forward,再查 server 是否真的在跑(ps -A | grep fs)。

2.3 spawn 与 attach:时机决定成败

这是我认为 Native Hook 里最容易被低估的一环。attach是附加到已运行的进程,spawn是由 Frida 拉起进程并在早期暂停。区别在于:如果目标 so 是在Application初始化阶段加载的,你attach上去的时候它早加载完了,想 hook 它的初始化函数已经错过时机。这种场景必须用spawn。

# spawn 模式:Frida 负责拉起,脚本里用 setImmediate 抢跑 frida -U -f com.example.target -l hook.js --no-pause
// hook.js 里的配合写法 setImmediate(function () { // 这里的代码在进程启动后尽早执行 // 适合挂 dlopen 拦截、或直接 hook 已加载模块 hookDlopen(); });

我个人的经验判断:只要目标逻辑涉及"so 初始化"、"静态注册的 JNI"、"全局构造函数",一律用 spawn。只有纯粹观察运行期调用、不关心加载时机的,才用 attach 图省事。这个判断做对了,能省掉大量"为什么 hook 不触发"的排查时间。

3. Frida Native层核心API拆解

这一章把常用的 API 按用途分组讲清楚,每个都说明"什么时候用、为什么这么用、有什么坑"。我不打算做成 API 手册的翻译,而是按实际使用顺序来组织,你照着往下读,基本就是一次完整的 Hook 流程。

3.1 Module与地址解析:所有Hook的起点

Native Hook 的第一性问题永远是"我要 hook 的函数在哪个地址"。地址有三种来源,各有适用场景。

导出符号:so 的动态符号表里有的,直接用名字查。这是最稳的方式,因为不依赖具体版本。

// 推荐写法:明确指定在哪个模块里找 const addr = Module.getExportByName('libtarget.so', 'native_sign'); console.log('native_sign @', addr);

基址加偏移:函数没导出,或者被 strip 了,只能靠 IDA 之类的工具在静态分析里找到偏移,运行时用基址加偏移算地址。这种方式的最大问题是版本敏感,so 一更新偏移就废了,所以脚本里一定要做偏移的注释和管理。

const base = Module.getBaseAddress('libtarget.so'); const target = base.add(0x1A2B4); // 偏移来自静态分析,务必标注 so 版本 console.log('target @', target);

模式匹配:既不导出、偏移又不稳定的时候,用一段特征字节在内存里搜索。成本高但鲁棒性好。

// 在模块范围内按特征字节搜索,返回所有命中 const m = Process.getModuleByName('libtarget.so'); const pattern = 'F0 4F BD A9 ?? ?? ?? ?? F4 4F 01 A9'; const hits = Memory.scanSync(m.base, m.size, pattern); hits.forEach(h => console.log('hit @', h.address));

提示:模式匹配的特征一定要选不含重定位项的片段。如果特征里含绝对地址跳转,不同加载基址下字节会变,扫描必然失败。选函数序言(prologue)通常最安全。

一个常被忽略的细节:Module.getBaseAddress返回的是 so 在内存中的加载起点,也就是 ELF 头的位置。但从静态分析拿到的偏移,有的是相对文件起始的,有的是相对某个段的,导出表里的是相对虚拟地址。这三者不一定相等,尤其是 so 里有多个 PT_LOAD 段的时候。我的做法是:用同一个函数既查导出表又算偏移,对比结果,比如拿一个已知导出的函数验证偏移计算方式对不对,确认之后再套到未导出的函数上。这一步花不了两分钟,能避免后面几个小时的困惑。

3.2 Interceptor:函数级Hook的主力

Interceptor.attach是所有 Native Hook 里使用频率最高的,它的模型是"替换函数入口的前几条指令为跳转,跳到 Frida 的 trampoline,再执行你提供的onEnter/onLeave"。

const fn = Module.getExportByName('libtarget.so', 'native_sign'); Interceptor.attach(fn, { onEnter(args) { // args 是参数数组,元素是 NativePointer // 基本类型参数可以直接读值 this.param1 = args[0].toInt32(); // 第一个 int 参数 this.param2 = args[1]; // 指针类参数先存下来 // 读 C 字符串 const s = args[1].readUtf8String(); console.log(`[enter] a=${this.param1} s=${s}`); }, onLeave(retval) { // retval 是返回值,同样是 NativePointer console.log(`[leave] ret=${retval.toInt32()}`); // 可以直接改返回值,但要注意类型宽度 // retval.replace(ptr(1)); } });

几个必须记住的要点。this是 onEnter 和 onLeave 之间的共享上下文,用来传递中间值,这是标准做法。参数类型必须自己知道,Frida 不会帮你推断,把int当指针读会直接崩。arg1这种老式写法已废弃,统一用args[n]。修改返回值要用replace,直接赋值是无效的。

关于 arm64 和 arm32 的差异,我吃过一次亏值得说一下。arm64 前 8 个整型参数走x0到x7寄存器,浮点走v0到v7;arm32 前 4 个走r0到r3,浮点走s0到s15,且 64 位参数要占两个寄存器。当你需要拿超出参数列表的寄存器值(比如 hook 函数中间的某条指令,想看某个临时寄存器),就得用this.context。

Interceptor.attach(targetAddr, { onEnter() { // arm64:直接读上下文寄存器 console.log('x0=', this.context.x0, 'x1=', this.context.x1); console.log('pc=', this.context.pc, 'lr=', this.context.lr); } });

3.3 Memory与NativeFunction:读数据与主动调用

Hook 是被动观察,很多时候你还需要主动读内存、调函数。这两块是Memory和NativeFunction。

Memory家族的方法有明确的语义区分,混用会出问题:readUtf8String读到\0停,适合 C 字符串;readCString是老 API;readByteArray读原始字节;readPointer读指针;readS32/readU64这类读定长数值。写操作必须用write*系列,不能直接对NativePointer赋属性。

// 读一个典型的 C 结构体(假设已知布局) function dumpStruct(addr) { const len = addr.add(0x00).readU32(); // uint32 len const flags = addr.add(0x04).readU8(); // uint8 flags const bufPtr = addr.add(0x08).readPointer(); // char* buf console.log(`len=${len} flags=${flags} buf=${bufPtr.readUtf8String(len)}`); }

NativeFunction用来主动调用任意函数,最典型的用途是"解密函数在 so 里,我不重写算法,直接调它"。这是 Native Hook 相比 Java Hook 的一个巨大优势——Java 层你没法直接调一个非公开方法,Native 层只要地址知道,签名对上,就能调。

// 声明函数签名:返回类型、参数类型列表 const aesDecrypt = new NativeFunction( Module.getExportByName('libcrypto_custom.so', 'do_aes_decrypt'), 'pointer', // 返回 NativePointer ['pointer', 'int', 'pointer'] // (buf, len, keyPtr) ); const input = Memory.allocUtf8String('ciphertext...'); const out = Memory.alloc(64); const key = Memory.alloc(16); key.writeByteArray([0x01,0x02 /* ... */]); // 实际值按需填 aesDecrypt(input, 16, key); console.log(hexdump(out, { length: 32 }));

注意:NativeFunction调用的目标函数如果内部有abort、assert,或者依赖某些全局状态未初始化,会让整个目标进程崩溃。第一次调之前,先用静态分析确认它没有明显副作用,比盲目调用安全得多。

3.4 NativeCallback与Interceptor.replace

NativeFunction是"我调别人",NativeCallback是"别人调我",配合Interceptor.replace可以把整个函数替换成你写的 JS 实现。这个能力最强,风险也最大。

// 场景:把校验函数整体替换成恒返回 true const checkFn = Module.getExportByName('libtarget.so', 'integrity_check'); const checkImpl = new NativeCallback(function (ctxPtr) { console.log('[replace] integrity_check 被调用,已拦截'); return 1; // 返回 true }, 'int', ['pointer']); Interceptor.replace(checkFn, checkImpl);

用replace有两个硬性约束要记住。第一,你全权负责原函数的行为,replace之后原逻辑不再执行,如果原函数有必须产生的副作用(比如填充输出缓冲区),你漏了就会引发后续逻辑异常,症状往往离得很远,极难定位。第二,参数和返回值的类型必须精确,尤其是结构体返回值(arm64 上用s0/x8返回大结构体)的情况,类型写错会直接访存越界。我的原则是:能用attach加改返回值解决的,绝不用replace。只有原函数逻辑本身必须跳过(比如它会直接退出进程、或有强反调试),才上replace。

4. 六个高频场景的完整实现

这一章是实操部分,每个场景给完整可跑的脚本骨架,并解释为什么这么写。场景的选择基于我这几年实际遇到频率的排序。

4.1 场景一:Hook导出函数,打印参数与返回值

最基础也最常用。难点不在attach,在参数类型的正确解读。

function hookExported(modName, funcName) { const addr = Module.getExportByName(modName, funcName); Interceptor.attach(addr, { onEnter(args) { this.t0 = Date.now(); // 假设签名是 (const char* input, int mode, unsigned char* out) this.mode = args[1].toInt32(); this.out = args[2]; const input = args[0]; console.log(`[${funcName}] input="${input.readUtf8String()}" mode=${this.mode}`); }, onLeave(retval) { const cost = Date.now() - this.t0; console.log(`[${funcName}] ret=${retval.toInt32()} cost=${cost}ms`); // 如果返回值表示成功,读输出缓冲区 if (retval.toInt32() === 0) { console.log(`[${funcName}] out=${hexdump(this.out, { length: 32 })}`); } } }); }

这段的关键在this.out的保存。args[2]的值本身是个指针,存在this上,onLeave时再读,就能拿到函数写入的结果。如果不存,onLeave里就访问不到了。这是最常被遗漏的一步。

4.2 场景二:Hook未导出函数,基址加偏移

未导出函数的定位,我总结了一个相对可靠的流程。

const BASE = Module.getBaseAddress('libtarget.so'); // 偏移来自 IDA 静态分析:函数 sub_1A2B4 的起始 const OFF = 0x1A2B4; const addr = BASE.add(OFF); // arm64 上建议先校验目标地址的开头是否符合预期 console.log('target head:', hexdump(addr, { length: 16 })); // 预期示例:F0 4F BD A9(stp x16, x30, [sp, #-0x30]!) // 对得上再挂 Interceptor.attach(addr, { onEnter(args) { console.log(`[sub_1A2B4] x0=${args[0]} x1=${args[1]}`); } });

校验这一步别省。我遇到过 so 更新后偏移错位,Interceptor.attach挂到了函数中间,目标直接崩溃,日志里啥都没有,排查了两小时才发现是偏移不对。现在我的脚本里,凡是用偏移的,都强制先 dump 开头 16 字节做视觉确认,或者写几个字节的断言,对不上就console.error然后return。

4.3 场景三:等so加载完再Hook,dlopen拦截

这是 Native Hook 里最经典的时机问题。目标 so 是动态加载的,你需要在它加载完成的瞬间挂 hook。做法是拦截加载函数,在返回时再挂。

不同系统版本加载路径不一样:老的走dlopen,Android 上常见的是android_dlopen_ext,高版本还有走 linker 内部do_dlopen的。

function hookDlopen(targetSoName, callback) { const hooks = [ { name: 'android_dlopen_ext', mod: 'libdl.so' }, { name: 'dlopen', mod: 'libdl.so' }, ]; hooks.forEach(({ name, mod }) => { let addr; try { addr = Module.getExportByName(mod, name); } catch (e) { return; // 该符号不存在,跳过 } Interceptor.attach(addr, { onEnter(args) { // 第一个参数是 so 路径字符串 try { this.path = args[0].readUtf8String(); } catch (e) { this.path = null; } }, onLeave(retval) { if (this.path && this.path.indexOf(targetSoName) !== -1) { console.log(`[dlopen] ${this.path} loaded, ret=${retval}`); // 关键:so 刚加载完,此时算基址一定成功 setImmediate(() => callback()); } } }); }); } // 使用 hookDlopen('libtarget.so', () => { const base = Module.getBaseAddress('libtarget.so'); console.log('[+] 拿到基址,开始挂业务 hook:', base); // 在这里 attach 真正的目标函数 });

这里有两个细节值得说。第一,onLeave里用setImmediate而不是立即执行回调。原因是某些环境下,dlopen返回时模块虽然已映射,但符号表可能还没完全就绪,延后一个 tick 更稳。第二,try/catch包住readUtf8String是必要的,因为dlopen的参数在某些调用路径下可能是null(比如通过文件描述符加载时第一个参数不一定是路径),不加保护直接崩。

提示:如果目标 so 是你用spawn模式启动的,用hookDlopen就成;如果是attach上去的且 so 已加载,hookDlopen不会触发,此时直接Process.getModuleByName拿基址。所以脚本里最好两种都兼容,先查是否已加载,没有再挂 dlopen。

4.4 场景四:Hook JNI RegisterNatives,拿动态注册表

Java 层的native方法有两种注册方式:静态命名(Java_com_example_Foo_bar)和动态注册(RegisterNatives)。加了壳或者做过混淆的 App,基本都走动态注册,你在 so 导出表里根本找不到Java_开头的符号。这时候最有效的手段是 hookRegisterNatives本身,把注册表完整截下来。

function hookRegisterNatives() { // 注意:RegisterNatives 是 JNIEnv 的成员函数,不导出符号 // 做法是 hook 它的实际实现,通常通过扫描或已知偏移 // 更稳的做法:hook 所有 Java_ 前缀导出符号的同时,用以下方案 // 方案:hook JNIEnv 结构体中 RegisterNatives 的实际地址 // 这里给的是通用思路,具体偏移依 ART 版本而定 const JNIEnv_RegisterNatives_OFF = 0xcomparison; // 需按实际版本查表 // 实操中更常用的是 hook 调用方的符号: // 很多加固方案会暴露一个统一的注册函数 } // 更实用的通用方案:hook 每个 so 的 JNI_OnLoad Process.enumerateModules() .filter(m => m.path.endsWith('.so')) .forEach(m => { const onLoad = Module.findExportByName(m.name, 'JNI_OnLoad'); if (!onLoad) return; Interceptor.attach(onLoad, { onEnter(args) { console.log(`[JNI_OnLoad] ${m.name} vm=${args[0]}`); this.mod = m.name; }, onLeave(retval) { console.log(`[JNI_OnLoad] ${this.mod} version=${retval.toInt32()}`); } }); });

关于RegisterNatives的 hook,我得说实话:它的实现不在导出表里,需要按 ART 版本查JNIEnv结构体的偏移,或者用符号扫描在libart.so里找。这个过程版本敏感度极高,不同系统版本偏移都不一样,写死了很难跨设备跑。我的实际做法是准备一份版本到偏移的映射表,脚本启动时读Process.getModuleByName('libart.so')的相关信息判断版本,再取对应偏移。

// 版本到偏移的映射(示例结构,具体值需按实际环境测定) const JNIENV_OFFSETS = { 'android12': 0x1A2B3C, 'android13': 0x1A2B3D, 'android14': 0x1A2B3E, };

这套东西维护起来不轻松,但一旦建好,抓动态注册表的效率是最高的——一次就能拿到"哪个 Java 方法 → 对应哪个 so 里的哪个地址"的完整映射,比一个个去猜符号名靠谱得多。

4.5 场景五:内存扫描定位关键数据

有些数据你知道它的形态(比如某段 Base64、某个固定前缀的字符串、某个特征字节序列),但不知道它在哪。这时候用Memory.scan。

// 在指定模块范围内扫描字符串模式 const m = Process.getModuleByName('libtarget.so'); const pattern = '41 42 43 44'; // ASCII "ABCD" const results = Memory.scanSync(m.base, m.size, pattern); console.log(`命中 ${results.length} 处`); results.slice(0, 10).forEach(r => { console.log(r.address, hexdump(r.address.sub(16), { length: 48 })); });

扫描有几个实操要点。范围要收窄:全进程扫(0x0到0xffffffffffff)在大内存应用上会跑很久甚至超时,优先限定在目标模块的范围。模式要选好:??是通配符,用它能容忍变化的字段,但通配太多会误命中暴增。注意分页:跨页扫描偶尔会报错,用try/catch包住整个扫描调用。

还有一种情况是扫描后持续监控,即找到地址后想知道谁在读写它。这时用MemoryAccessMonitor或者对地址做Interceptor.attach都不太合适(前者开销大、后者要求地址是函数)。比较实用的折中是:在可疑地址附近 dump 一大块,然后在关键函数 hook 里比对这块内存有没有变化,间接推断写入时机。

4.6 场景六:Stalker追踪执行流,谨慎使用

Stalker能记录函数执行的每一条指令,信息密度极高,但代价也极大——目标会明显变慢,某些情况下直接卡死或者触发看门狗。我的使用原则是:只在前面所有手段都解释不通的时候用,且一定要限定范围、限定时间。

// 只追踪某个函数一次调用,且只收集 call 事件 const target = Module.getExportByName('libtarget.so', 'suspect_fn'); Interceptor.attach(target, { onEnter() { if (this.traced) return; this.traced = true; const tid = Process.getCurrentThreadId(); Stalker.follow(tid, { events: { call: true, ret: false, exec: false, block: false }, onCallSummary(summary) { // summary 是 { 目标地址: 调用次数 } 的映射 Object.keys(summary).forEach(k => { const addr = ptr(k); const mod = Process.findModuleByAddress(addr); console.log(`call -> ${addr} (${mod ? mod.name : '?'}) x${summary[k]}`); }); } }); // 关键:限制追踪时长,避免目标卡死 setTimeout(() => { Stalker.unfollow(tid); Stalker.flush(); console.log('[stalker] stopped'); }, 2000); } });

onCallSummary比onReceive好用得多,因为它是聚合后的结果,直接告诉你"这次执行里调了哪些函数、各调了几次",不用自己解析事件流。配合Process.findModuleByAddress把地址翻译成模块名,一眼就能看出执行路径走到了哪个 so,对于判断"某个分支到底有没有走到"特别有效。

5. 对抗与稳定性:让脚本在真实环境里跑得住

脚本在 Demo 上跑通和在真实样本上跑通,中间隔着的往往就是这一章的内容。真实环境有检测、有多线程、有性能压力,随便一个都能让你的脚本静默失效。

5.1 常见的检测点与应对思路

我不打算写成"绕过手册",而是从排查视角讲,因为知道对方在检测什么,你才能判断问题出在哪。

第一类是进程与端口层面。默认端口、注入产生的额外线程、/proc/self/maps里的异常映射,都是经典检测点。应对上,端口换非默认值、注入方式按需选择,是最基础的。

第二类是字符串与符号层面。so 名字、内存里的特定字符串,可能被特征匹配。这类检测的特点是会误伤正常应用,所以一般不激进。

第三类是时序层面。Hook 引入的开销会让某个操作的耗时异常,检测方对比预期耗时就能发现。这个最难处理,我的经验是尽量缩小 hook 范围,用Interceptor.attach而不是replace,能不加日志就不加日志——console.log本身的开销在热点函数上非常可观。

// 反面教材:热点函数里每次调用都打日志 Interceptor.attach(hotFn, { onEnter(args) { console.log('called', args[0]); } // 一秒几万次,直接把目标拖死 }); // 正确做法:计数 + 采样 let cnt = 0; Interceptor.attach(hotFn, { onEnter(args) { cnt++; if (cnt % 10000 === 0) console.log('called x', cnt); } });

这个坑我踩过不止一次。在加解密的热点函数上挂了带console.log的 hook,目标直接从流畅变成卡死,一开始还以为是 hook 挂错了位置,查了很久才发现是日志本身的开销。现在我的习惯是:任何可能高频调用的 hook,一律先只计数,确认频率之后再决定要不要输出详细日志。

5.2 多线程与JS执行线程的坑

Frida 的 JS 运行时是单线程的,所有 hook 回调都在这一个线程上串行执行。这意味着两件事:第一,回调里做耗时操作会阻塞其他所有 hook 回调,包括你用来观察的其他 hook;第二,在回调里调用某些会等待锁的 API,可能和目标线程形成死锁。

// 危险写法:在 hook 回调里做重活 Interceptor.attach(fn, { onEnter(args) { const big = parseComplexStructure(args[0]); // 耗时几十毫秒 console.log(big); } }); // 更稳的写法:只采集原始数据,延后处理 const queue = []; Interceptor.attach(fn, { onEnter(args) { queue.push({ addr: args[0], t: Date.now() }); // 只记录 } }); setInterval(() => { const batch = queue.splice(0, queue.length); batch.forEach(item => { /* 在这里做解析和输出 */ }); }, 1000);

死锁这块,最典型的场景是在 hook 回调里调用Java.perform或者访问 ART 的对象,而这些操作需要 ART 的锁,如果恰好目标线程持锁在等你,就死锁了。我的处理方式是:Native hook 回调里只碰指针和内存,绝不在里面做 Java 相关操作;需要跨层交互的,通过队列传递到另一个执行上下文处理。

5.3 性能与稳定性的三条底线

第一条:hook 点越少越好。每多挂一个点,就多一份开销和一份崩溃可能。我见过有人把所有导出函数全挂一遍做"全量监控",结果目标跑不到十秒就崩了。正确的做法是先用静态分析缩小范围,只挂真正需要的几个。

第二条:永远考虑异常路径。readUtf8String遇到非法指针会抛异常,NativeFunction调用可能崩进程。所有非确定性的操作都要try/catch,而且 catch 里不要再做可能抛异常的事。

Interceptor.attach(fn, { onEnter(args) { try { this.s = args[0].readUtf8String(); } catch (e) { this.s = '<unreadable>'; } } });

第三条:脚本要能优雅退出。Interceptor.detachAll()、Stalker.unfollow()这些要配上,尤其在用setTimeout做限时追踪的时候,超时后一定要 unfollow,否则目标会一直处于被追踪状态,越来越慢。

6. 问题排查速查表

前面几章的方法和心得,落到实际排查时往往没有那么多时间慢慢分析。这一章整理成表,遇到问题直接对号入座。

6.1 症状、原因、处理对照表

症状最可能的原因处理动作
unable to intercept function at 0x...Frida 版本与 server 不一致;目标地址不是函数入口查两侧版本;dump 目标地址前 16 字节确认是序言
attach成功但 onEnter 从不触发函数没被调用;hook 的是导出表的副本而实际调用走了内部地址确认业务路径确实走到;尝试 hook 内部实现地址而非 PLT
Process.getModuleByName抛异常so 尚未加载;进程选错改用 dlopen 拦截;确认 PID 对应主进程而非子进程
readUtf8String崩溃参数不是有效字符串指针;函数未执行完就读取检查参数类型;需要结果时在 onLeave 读而非 onEnter
目标进程卡死热点函数里打了日志;Stalker 未 unfollow改为计数采样;确保超时后 unfollow
偏移计算出的地址内容是乱码偏移基准不对(文件偏移 vs 虚拟地址)用已知导出函数反推基准,校准后再套用
多进程 App hook 无效目标逻辑在另一个进程frida-ps -U确认 PID,必要时对子进程单独 attach
NativeFunction调用后进程退出目标函数有前置依赖未满足;内部 assert静态分析确认依赖;先在小范围试调,观察副作用
脚本跑一段时间后越来越慢hook 回调里累积了内存未释放;队列无限增长限制队列长度;定期清理已处理数据

6.2 我踩过的几个典型坑

坑一:把 PLT 当成了真实函数。早期我习惯用Module.getExportByName直接挂,有一次挂了某个 so 的导出函数,onEnter一直触发,但args的内容完全不对。后来才想明白,getExportByName返回的是 PLT 桩,真正的实现可能在另一个模块里。判断方法很简单:看返回地址所属的模块,Process.findModuleByAddress一查就清楚了。

坑二:在onEnter里读了还没写入的缓冲区。输出型参数在函数执行前是未初始化的,我急着在onEnter里hexdump输出缓冲区,结果全是随机数据,还以为算法搞错了。正确做法是把指针存到this上,onLeave里再读。这个错误看起来很低级,但我在不同项目里重复犯过三次以上,因为调试的时候总是下意识想在入口就把所有信息打出来。

坑三:忽略了 so 的多个实例。某些情况下同一个 so 会被加载多次(不同命名空间或者不同路径),getModuleByName只返回第一个。如果 hook 没生效,可以试着用Process.enumerateModules()过滤出所有匹配的实例,分别挂一遍。

Process.enumerateModules() .filter(m => m.name === 'libtarget.so') .forEach(m => { console.log('instance:', m.path, m.base); // 对每个实例分别 attach });

坑四:以为console.log打印指针就够了。console.log(ptr)打印出来的是0x...,但如果你要看它指向的内存,得显式hexdump或者readUtf8String。更要命的是某些对象打印出来是[object Object],看着像有值实际是空的。我的习惯是统一写一个小的fmt辅助函数,指针、缓冲区、结构体都走它,输出格式固定,方便对比。

function fmt(p, len = 32) { if (!p || p.isNull()) return '<null>'; try { return hexdump(p, { length: len, ansi: false }); } catch (e) { return `<unreadable: ${e.message}>`; } }

坑五:跨设备直接复制脚本。同一个 so 在 arm64 和 arm 设备上,偏移、寄存器约定、结构体对齐都可能不同。我在一台设备上调好的脚本,换到另一台直接跑,偏移全错。现在的习惯是脚本里所有偏移都按架构分组,注释清楚来源,跨设备时先跑自检脚本对齐。

这几个坑的共同点是:问题都不在 Frida 本身,而在对目标的理解不够。所以我现在遇到 hook 不生效,第一反应不是改脚本,而是回到静态分析,确认目标函数的符号、调用路径、参数类型,确认清楚了再动脚本,效率反而高。

最后分享一个我在实际工作中养成的习惯:每做一个新目标,先花二十分钟写一个"侦察脚本",把进程的模块列表、目标 so 的基址、导出函数清单、JNI 加载情况全打一遍,存成文件。后面写 hook 脚本的时候,所有地址和偏移都从这份文件里查,而不是凭记忆填。这个习惯看起来笨,但它把"地址算错"这一类问题的排查时间从几小时压到了几分钟,是我这几年最有性价比的一个工作方式。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进