
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 版本、架构、进程三对齐版本对齐指fridaPython 侧和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到v7arm32 前 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是老 APIreadByteArray读原始字节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.replaceNativeFunction是我调别人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 A9stp 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加载完再Hookdlopen拦截这是 Native Hook 里最经典的时机问题。目标 so 是动态加载的你需要在它加载完成的瞬间挂 hook。做法是拦截加载函数在返回时再挂。不同系统版本加载路径不一样老的走dlopenAndroid 上常见的是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 内部实现地址而非 PLTProcess.getModuleByName抛异常so 尚未加载进程选错改用 dlopen 拦截确认 PID 对应主进程而非子进程readUtf8String崩溃参数不是有效字符串指针函数未执行完就读取检查参数类型需要结果时在 onLeave 读而非 onEnter目标进程卡死热点函数里打了日志Stalker 未 unfollow改为计数采样确保超时后 unfollow偏移计算出的地址内容是乱码偏移基准不对文件偏移 vs 虚拟地址用已知导出函数反推基准校准后再套用多进程 App hook 无效目标逻辑在另一个进程frida-ps -U确认 PID必要时对子进程单独 attachNativeFunction调用后进程退出目标函数有前置依赖未满足内部 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 脚本的时候所有地址和偏移都从这份文件里查而不是凭记忆填。这个习惯看起来笨但它把地址算错这一类问题的排查时间从几小时压到了几分钟是我这几年最有性价比的一个工作方式。