资讯详情 Frida Native Hook实战:模块定位、拦截与内存读写排错
📅 2026/11/5 2:46:52
1. 从一次原生崩溃说起Native 层 Hook 究竟卡在哪第一次在原生库上做 Hook 的时候我盯着一个进程刚启动就整个挂掉的日志看了将近两个小时。Java 层的脚本写得顺风顺水换成 native 层之后连最简单的函数替换都能把目标进程打成僵尸。后来才发现问题根本不在 Frida 的语法上而是我把 Java 层那套拿到方法名就能替换的思维惯性直接搬了过来——native 层没有虚拟机帮你做类型检查没有方法签名帮你兜底寄存器里放的是什么、指针指向哪里、这段内存还能不能读全得自己心里有数。这篇内容想聊的就是Frida 在 Native 层的操作体系从模块定位、符号解析到 Interceptor 的几种用法、寄存器与内存的正确读写再到内存扫描和崩溃排查。它适合已经能用 Frida 做基础 Java Hook、想往原生层走一步的开发者也适合做自研应用性能分析、算法验证、加固后自测的同学。所有示例都基于自己开发或已获授权的测试环境这一点先说在前面——工具本身是中性的用在哪里决定了它的性质。我见过太多人卡在同一个地方脚本能跑通一次换个版本就废或者 attach 上去之后日志正常一碰返回值就崩。这类问题几乎都不是 API 记错了而是对 native 层的运行时模型理解不够。下面按我实际踩坑的顺序拆开讲。2. 地基工程模块定位与符号解析的实操细节2.1 so 加载时机决定了你脚本的生死Native Hook 失败的第一大类原因是脚本执行的时候目标库压根还没被加载进内存。Module.findBaseAddress(libxxx.so)返回null后面所有基于这个基址的计算全部落空。Android 上原生库的加载通常发生在System.loadLibrary或者 Application 初始化阶段用frida -U -f 包名的 spawn 模式启动时脚本注入的时机往往早于库加载。处理方式有三种我按可靠性从高到低排第一种是轮询等待。简单粗暴但有效适合调试阶段function waitForModule(name, timeoutMs) { const start Date.now(); return new Promise((resolve, reject) { const timer setInterval(() { const m Process.findModuleByName(name); if (m) { clearInterval(timer); resolve(m); } else if (Date.now() - start (timeoutMs || 10000)) { clearInterval(timer); reject(new Error(module not found: name)); } }, 50); }); }第二种是挂android_dlopen_ext或者dlopen在库真正被加载的那一刻拿到回调。这种做法更精准但要注意两个函数本身也可能是通过 PLT 调用的得先确认符号存在。第三种是延迟注入用setTimeout硬等。我不推荐这个因为不同机型、不同冷热启动路径下加载耗时差异很大硬编码的等待时间要么不够要么浪费。2.2 基址、导出表与只有偏移没有名字的现实拿到模块对象之后第一步永远是确认基址。Process.findModuleByName(libxxx.so).base给的是一个NativePointer后续所有地址计算都应该写成base.add(0x1234)这种形式而不是直接把绝对地址写死。绝对地址在每次进程重启后都会变这是新手最容易犯的错。导出符号的枚举有两种常用姿势const mod Process.findModuleByName(libxxx.so); const exports mod.enumerateExports(); exports.filter(e e.type function).slice(0, 20).forEach(e { console.log(e.name, e.address.sub(mod.base)); });enumerateExports()拿到的是动态符号表里的导出项能覆盖大部分公开函数。但现实情况是很多关键函数是static的、被 strip 过的或者干脆就是内联展开的导出表里根本找不到。这时候有两条路。一条是Module.enumerateSymbols()它读的是.symtab对于没 strip 的库能看到局部符号另一条是用DebugSymbol.fromAddress()反查但它依赖调试信息发布版本基本没有。真正在实战里最常用的其实是第三种从字符串、日志、或者已知的导出函数出发顺着调用关系往下找偏移。比如某个导出函数里调用了目标函数就用Interceptor.attach挂在外层从context.lr或者指令反汇编里推算出内层地址。IDA 之类的静态工具在这里的价值就体现出来了——先在静态视图里确定偏移再在动态环境里用base.add(offset)定位。提示Module.enumerateExports()在部分加固过的库上会返回大量异常条目甚至直接抛错建议包一层 try/catch并且在遍历时加上数量上限避免脚本卡死。2.3 一个容易被忽略的细节库的重名与多实例有些应用会同时加载多个同名但不同路径的库Process.findModuleByName只会返回第一个匹配项。如果目标函数在第二个实例里你挂上去的 Hook 永远不会触发。稳妥的做法是遍历Process.enumerateModules()按path精确匹配const targets Process.enumerateModules().filter(m m.path.endsWith(/libtarget.so)); targets.forEach(m console.log(m.name, m.path, m.base));这个问题我在一次多进程架构的调试里踩得很实在——主进程和子进程各自加载了一份同名库脚本 attach 到主进程却一直在找子进程的地址日志打了半天全是null。3. 三种 Hook 手法的取舍Interceptor、Inline 与 GOT 改写3.1 Interceptor.replace 与 attach 的适用边界Interceptor.attach是最常用的方式它的语义是在函数入口和返回处插入回调原函数逻辑照常执行。适合做参数观测、返回值修改、耗时统计。const addr mod.base.add(0x1a2b0); Interceptor.attach(addr, { onEnter(args) { this.arg0 args[0]; console.log(arg0 , args[0], cstr , args[0].readCString()); }, onLeave(retval) { console.log(original ret , retval); retval.replace(ptr(1)); } });这里有个非常关键的点onLeave里的retval.replace()修改的是寄存器 x0ARM64或 r0ARM32它不会重新执行函数体只是改返回值。如果函数返回的是一个需要释放的堆指针你替换掉之后原指针就泄漏了这个责任得自己扛。Interceptor.replace的语义完全不同——它把整个函数替换掉原函数不再执行。必须配合NativeCallback使用并且回调的签名必须和原函数严格一致const orig new NativeCallback(function (a, b) { console.log(intercepted, a, b); return 0; }, int, [int, int]); Interceptor.replace(addr, orig);签名写错的后果是栈错乱。ARM64 上前 8 个整型参数走 x0-x7浮点走 v0-v7如果原函数接收的是float你按int声明读到的会是浮点寄存器的位模式算出来的值完全对不上。更糟的情况是参数个数不匹配函数返回时的栈平衡被破坏进程直接 SIGSEGV。3.2 Inline Hook 的指令级原理以及它为什么容易翻车Frida 的Interceptor在底层做的其实就是 inline hook把目标函数开头的若干条指令备份到一块可执行内存trampoline然后写入一条跳转指令指向 Frida 的处理入口执行完再跳回 trampoline 执行被搬走的原指令最后跳回原函数继续。在 ARM64 上写入的通常是几条指令组成的绝对跳转序列因为一条b指令只有 26 位偏移跳不远。ARM32 上则是ldr pc, [pc, #-4]配合一个字面量池。这些细节不需要手动实现但理解它有助于判断失败原因目标地址不在可执行段里写入会失败。函数太短开头几条指令被搬走之后剩下的不够放跳转桩Frida 会报错。目标函数被其他 Hook 框架先占了位置两边互相覆盖。还有一种情况特别隐蔽函数开头是 PC 相对寻址的指令比如adr或者adrpaddinline hook 把它搬到 trampoline 后相对偏移的计算基准变了结果算出来的地址是错的。这类函数在编译器优化后并不少见尤其是 PIC 代码。注意如果 attach 之后进程立刻崩溃先用frida-trace或者更简单的Interceptor.attach只打印不修改确认是挂的问题还是改的问题。这一步能省掉大量猜测。3.3 GOT 改写不碰函数体也能控制调用对于通过 PLT 调用的外部函数改写 GOT 表项是一种侵入性更小的方案。原理是 ELF 的.got段在动态链接时被填充为目标函数地址只要把对应表项改成自己的函数地址所有经由 PLT 的调用都会落到自己的实现上。定位 GOT 表项可以用Module.enumerateImports()const imports mod.enumerateImports(); const target imports.find(i i.name memcpy); if (target) { console.log(got slot:, target.slot, resolved:, target.address); Memory.protect(target.slot, Process.pointerSize, rw-); target.slot.writePointer(myFuncPtr); }这种方式的优点是只影响当前模块内部的调用不会污染全局符号缺点是如果函数在加载时就被内联优化掉了或者调用走的是-fno-plt生成的直接跳转改 GOT 完全无效。我在一个编译选项比较激进的库里试过导入表里明明有memcpy改完之后一点反应都没有——因为编译器把它换成了内联的 SIMD 指令序列。4. 寄存器与内存参数读写的正确姿势4.1 ARM64 与 ARM32 的调用约定差异Native 层的参数获取依赖调用约定这个必须记牢。ARM64 下整型和指针参数依次放在x0到x7浮点放在v0到v7超过 8 个的参数走栈。返回值放在x0如果是较大的结构体调用方会传入一个隐藏指针放在x8。ARM32 下则是r0到r3传参返回值在r0。在Interceptor.attach的onEnter里args数组已经按平台约定做了映射args[0]就是第一个参数。但如果你需要访问更靠后的参数或者修改寄存器就得直接用this.contextonEnter(args) { const self this.context.x0; // ARM64 const arg5 this.context.x5; this.ctx this.context; }这里有个坑this.context在onEnter里是可读写的但改动只在当前时刻生效函数执行过程中可能被覆盖。真正要影响函数行为要么在onEnter里改完立即生效比如改参数要么在onLeave里改返回值。4.2 Memory 读写、字符串与结构体的处理方法NativePointer提供了一整套读写方法readU8、readU16、readU32、readU64、readPointer、readCString、readUtf8String、readByteArray。这些名字很直白但类型选错会导致读到错位的值。一个常见场景是 C 的std::string它的内存布局在不同 STL 实现下不一样短字符串走 SSOSmall String Optimization内联存储长字符串走堆指针。直接readCString()读对象起始地址短字符串能读对长字符串读出来就是垃圾。稳妥的做法是先读前 8 个字节判断是否是堆指针再按布局解析。结构体读取我一般写个辅助函数按偏移逐字段读配合注释标清楚每个字段的类型和含义function dumpStruct(ptr, len) { const out {}; out.field_a ptr.add(0x00).readU32(); out.field_b ptr.add(0x08).readPointer(); out.field_c ptr.add(0x10).readU64(); console.log(JSON.stringify(out, (k, v) typeof v bigint ? v.toString(16) : v)); }偏移量从哪来静态分析工具里看结构体定义或者动态调试时用内存窗口对照。这一步没有捷径只能一点点试。4.3 指针生命周期与内存分配的注意点用Memory.alloc()分配的内存生命周期由脚本控制Frida 会在脚本卸载时回收。但如果把这个指针传给了目标进程长期持有脚本一卸载指针就悬空了后续访问必然崩溃。更隐蔽的是Memory.allocUtf8String()配合Interceptor.replace返回字符串的情况——回调返回的指针只在回调执行期间有效如果原函数期望的返回值会被调用方长期引用就会出现野指针。这种问题的表现是刚 Hook 完看着正常过几秒随机崩溃极难定位。我的做法是凡是要返回给目标进程长期使用的内存都用目标进程自己的分配器来分配。常见做法是找到malloc的地址用NativeFunction包装后调用const malloc new NativeFunction(Module.findExportByName(null, malloc), pointer, [size_t]); const str malloc(32); str.writeUtf8String(hello);代价是要自己管理释放不过至少不会因为脚本卸载而失效。5. 内存扫描与结构体还原没有符号表也能干活5.1 Memory.scan 的模式匹配技巧当目标函数完全没有符号、偏移也推不出来的时候Memory.scan是最后的抓手。它的基本用法是给一段内存范围和字节模式异步返回匹配地址const ranges Process.enumerateRanges(rw-); ranges.forEach(range { Memory.scan(range.base, range.size, 48 8B 05 ?? ?? ?? ??, { onMatch(address, size) { console.log(hit:, address); }, onComplete() {} }); });模式里的??是通配符用来跳过会变化的部分比如偏移量、立即数。写模式的时候要注意字节序ARM 是小端多字节立即数在内存里的顺序和反汇编里看到的相反。扫描范围要控制。全进程rw-段扫一遍在大型应用上可能几十秒都跑不完而且很容易触发内存访问异常。实用做法是先限定在目标模块的区间内再按需扩展到堆。5.2 从偏移猜测到结构体还原的推理过程还原一个未知结构体我通常按这个顺序推进先用导出函数或者字符串定位到一块可疑内存。用hexdump把前 256 字节打出来观察规律。找出连续的小整数可能是枚举或计数、连续的大值可能是指针、固定长度的字节序列可能是哈希或魔数。对疑似指针的值尝试readCString()或者readPointer()看能不能读出有意义的内容。构造输入改变某个字段观察哪些位置跟着变以此确认字段语义。console.log(hexdump(ptr, { offset: 0, length: 256, ansi: false }));这个过程很枯燥但每确认一个字段就是实打实的进展。我做过一个加密参数的结构还原前 0x20 字节是固定魔数0x20 处是一个长度字段0x28 处是指向密文的指针0x30 是密钥索引。这些全部是靠反复改输入、对照 hexdump 推出来的。提示hexdump的ansi: false选项在日志重定向到文件时很重要带颜色控制符的输出会让后续的文本处理变成灾难。6. 稳定性与排错让脚本从跑一次到长期可用6.1 崩溃定位的完整排查链路Hook 之后崩溃排查顺序我固定成这几步第一步确认崩在哪个线程、哪个地址。Frida 会打印 crash 报告重点看fault addr和backtrace。如果 backtrace 里出现了自己的 Hook 函数地址说明问题出在回调内部如果 fault addr 是 0x0 或者一个看起来像 ASCII 的值大概率是空指针或类型错误。第二步把 Hook 改成只读不改。去掉所有replace、所有write*、所有retval.replace只留console.log。如果这样不崩问题出在修改逻辑如果还崩问题出在 attach 本身或者时机。第三步逐个恢复修改点。二分法一次只加一个修改跑几分钟观察。第四步看是否与多线程有关。有些函数会被多个线程高频调用如果你的回调里有共享状态比如往一个数组里 push又没做同步就会出现数据竞争。更麻烦的是回调里调用了会阻塞的 API导致调用线程被卡住进而引发 ANR 或者超时。6.2 递归 Hook、时序与版本差异的坑Interceptor.attach挂在一个会被自己回调再次调用的函数上就会递归。典型例子是 Hookmalloc然后在回调里调用console.log——日志系统内部可能又调了malloc直接爆栈。规避方式是在回调入口加一个线程局部的重入标志const inHook {}; Interceptor.attach(mallocAddr, { onEnter(args) { const tid Process.getCurrentThreadId(); if (inHook[tid]) return; inHook[tid] true; // 安全操作 }, onLeave(retval) { inHook[Process.getCurrentThreadId()] false; } });时序问题也很常见。比如 Hook 了一个初始化函数但它在脚本注入之前就已经执行完了那Interceptor.attach永远不会触发。解决办法是改 spawn 模式启动或者在更早的时机注入。版本差异是最让人头疼的。同一个应用升级之后库的偏移、结构体布局、甚至函数实现都可能变。硬编码偏移的脚本必然失效。我的习惯是给每个偏移写一个版本判断或者干脆用特征码扫描来动态定位牺牲一点启动速度换取可维护性。7. 实战复盘一个校验函数的完整改造过程7.1 目标分析与断点验证拿一个自己写的测试 Demo 举例模拟项目非真实应用里面有个 native 方法负责校验输入串返回 0 表示通过、非 0 表示失败。目标是搞清楚它的判断逻辑。先枚举导出表找到名字里带check的函数拿到偏移。attach 上去打印参数Interceptor.attach(base.add(0x8f40), { onEnter(args) { console.log([check] arg0 , args[0].readCString()); console.log([check] arg1 len , args[1].toInt32()); }, onLeave(retval) { console.log([check] ret , retval.toInt32()); } });输入不同长度的字符串观察返回值变化。发现长度不是 16 时直接返回 -1说明第一层是长度校验。输入 16 位随机字符返回值是 -2说明还有内容校验。接着在函数内部下断点。用Memory.scan找到函数体里的一段特征码在匹配位置附近找cmp指令对应的机器码推断比较逻辑。这一步需要配合静态反汇编纯动态很难看清。7.2 改写方案与回归验证确认逻辑之后改写方案有两档一档是只改返回值在onLeave里retval.replace(ptr(0))最简单但有副作用——调用方可能还会用其他输出参数做二次判断另一档是替换整个函数用NativeCallback返回成功并填充必要的输出参数。我选了第二档因为原函数会往一个输出缓冲区写校验结果只改返回值会导致缓冲区内容不匹配上层逻辑还是会走失败分支。const fake new NativeCallback(function (input, len, outBuf) { if (outBuf.isNull()) return -1; outBuf.writeU32(1); return 0; }, int, [pointer, int, pointer]); Interceptor.replace(base.add(0x8f40), fake);改完之后必须做回归验证正常输入、非法输入、空输入、超长输入各跑一遍确认没有崩溃、没有内存泄漏、上层业务流程完整走通。这一步我一般会跑至少 20 次冷启动因为有些问题只在特定时序下出现。最后再分享一个小技巧给脚本加一个全局的try/catch包住所有回调并且在 catch 里用send()把错误信息回传给宿主端而不是直接console.log。这样在长时间运行、日志量巨大的场景下你能精准定位到是哪一次调用、哪一组参数触发了异常而不是在一堆正常输出里翻找。我靠这个习惯省下的 debug 时间大概比写过的脚本本身还多。