FridaHook系列写到第三篇,前两篇把环境搭建和Java层hook讲透了,这回专门啃Native层。很多人用Frida做Java层hook很顺手,一碰到so层就卡住:函数地址去哪儿找?地址明明找到了,hook却不生效?改完返回值App秒崩又是为什么?这些问题绕来绕去,核心其实是同一个主题——函数寻址。Native层没有类名、方法名给你按图索骥,你面对的是一个被地址随机化机制搅乱过的内存空间,必须先建立一套可靠的寻址方法,hook才能落地,调优才有基础。这篇就围绕函数寻址与实战调优两个重点展开,写给已经会用Frida基础API、但想深入so层做插桩调试和性能分析的读者。读完之后,你会对Native层“找不到、定不准、跑不起”这三个老大难有一套完整的应对思路。
1. 为什么Native层Hook要先解决函数寻址
1.1 Java层Hook与Native层Hook的本质差异
Java层hook太舒服了,舒服到很多人第一次转向Native层时被落差暴击。Java层你只需要拿到类名和方法名,比如某类某个方法,运行时Art虚拟机帮你解析,你只要把名字写对就能挂上,相当于导航直接报路名,你照着走就行。
Native层完全不是这个逻辑。一个so文件被装载进内存后,里面一个函数就是一段机器码,以地址的形式存在。你需要告诉Frida的是“我要在第几号街的第几个门牌号挂一个监听器”,而不是“我要找哪个名字的函数”。更麻烦的是,这个门牌号不是固定的。
现代操作系统几乎都开了ASLR(地址空间布局随机化),每次进程启动时,so模块被映射进内存的基址都会变。同一个函数,这次运行在0x12345678,下次可能就在0x9ABCDEF0。那是不是说地址没法用了?不是,基址虽然变,函数在模块内部的相对偏移是不变的。这就像一栋楼每次被搬到不同位置,但“三楼靠左第二个房间”这个说法始终有效。所以Native寻址的核心就变成了:拿到本次运行时so的基址,加上从文件里算好的固定偏移。
另一个让寻址头疼的因素是符号缺失。编译so时如果加了strip之类的选项,导出符号表被剥离,函数只剩地址没有名字,你在文件里能看到一堆指令但不知道该从哪下手。这两件事叠加在一起,就是Native层寻址难的两个根源:地址动态变化、函数无声无息。
1.2 寻址前必做的三件事
不要上来就写代码,先花几分钟做确认,能省掉后面大量的无效操作。
第一件事,确认目标so的加载时机。你的脚本是注入到进程之后才跑的,如果目标so在启动早期才被加载,而你注入得太晚,模块已经在了,没问题;如果so是运行到某个功能模块时才动态加载的,而你一上来就找,大概率找不到。先用Process.enumerateModules()扫一遍,确认模块是否已经存在于内存:
const mods = Process.enumerateModules(); const targetMod = mods.find(m => m.name === "libtarget.so"); if (targetMod) { console.log("module found at", targetMod.base, "size", targetMod.size); } else { console.log("module not loaded yet"); }第二件事,拿到文件层面的静态信息。用反汇编分析工具打开目标so,找到你关心的函数,记录三个信息:函数的文件偏移(加载到内存后要比对的vaddr)、相邻指令的特征字节、以及它是不是导出函数。这些是从二进制文件里读出来的,只要so版本不变,偏移和字节序列就不会变,可以放心长期使用。
第三件事,确认目标进程的运行架构。安卓真机基本上是ARM64,模拟器可能是x86或x86_64,老设备还可能是ARMv7的Thumb模式。架构不同,指令长度、寄存器编号、hook点的选择逻辑完全不同。用Process.arch一行就能看到:
console.log("arch:", Process.arch); // arm64 / arm / x64 / ia32这三件事做完,心里就有底了,再进入寻址环节。
2. 三种最常用的Native函数寻址方案
2.1 模块基址加静态偏移:最直接也最容易出错
这是最基础、用得最多的一种方式,一句话版本就是:函数地址 = 模块基址 + 固定偏移。
拿到基址用Module.findBaseAddress,这个API接受so文件名,返回本次运行时的真实基址。然后你要找的函数在文件里的偏移(一般用vaddr,也就是加载进内存后相对基址的虚拟地址)从工具里读出来,两者相加即可:
const base = Module.findBaseAddress("libtarget.so"); if (!base) { console.log("module not loaded, check timing"); return; } const target = base.add(0x2A3C0); // 0x2A3C0 是目标函数在反汇编工具中查到的vaddr偏移 console.log("target address:", target);这里有个常见误区我必须强调:很多人直接把工具右下角状态栏里看到的“file offset”拿来和基址相加。这个值通常和vaddr不一致,因为ELF文件里段的文件偏移和内存映射偏移之间存在页对齐的关系。正确的做法是,在工具的函数列表中直接看目标函数的vaddr值,或者在“反汇编视图”里看函数地址那一列的数值。两者混淆会出现地址偏差,hook静默失效还很难排查。
还有,千万不要把绝对地址写死在脚本里。我也犯过这种错误,第一次在某场景下打印出0x7F4A1234,觉得很稳定,第二天重启进程再跑就完全找不到目标,因为ASLR已经把这个地址换掉了。所以任何时候都走“基址加偏移”的路径,不要偷懒直接填绝对地址。
拿到地址别急着挂hook,先验证。最轻量的验证是看目标地址附近的字节是不是和文件里的一致:
console.log(hexdump(target, { length: 16 }));把输出和反汇编工具里的原始指令对比,字节能对上,说明地址计算没问题,再进行下一步。这一步虽然不起眼,但能拦住至少一半的“hook不生效”问题。
2.2 导出符号直接命中:省事但依赖编译选项
如果目标函数还在导出表里,事情就简单多了。JNI函数是默认导出的,比如Java_开头的那一串,格式一般是Java_包名_类名_方法名,包名里的点变成下划线。直接让Frida帮你找:
const fn = Module.findExportByName("libtarget.so", "Java_com_example_NativeBridge_run"); console.log("export found at:", fn);如果记不全函数名,或者想知道这个so到底导出了哪些函数,用Module.enumerateExports全量打印再过滤:
const exports = Module.enumerateExports("libtarget.so"); exports.filter(e => e.name.indexOf("run") !== -1) .forEach(e => console.log(e.name, e.address));这张导出表里能看到所有动态导出符号,基本对应ELF的.dynsym段。这儿还有一个细节值得注意:Module.enumerateSymbols()能拿到更多符号信息,因为它还解析了.debug相关的符号段,可能在开发版本里有,发布版本被strip之后就没了。所以完整的策略是:先查导出表,查不到再试enumerateSymbols,都没有就回到偏移法或特征码法。
写JNI函数名的时候要小心,包名下划线规则在遇到特殊字符时会转义,比如下划线本身会变成_1,导致名字看起来很奇怪。万一你在导出表里看到了一个名字长得不太对劲,不要怀疑工具,直接复制打印出来的原名去匹配,别手抖重敲。
2.3 特征码扫描:对付非导出函数的唯一出路
最头疼的情况是目标函数是static的、又被strip掉了,导出表里什么都没有,这个时候只能靠特征码。特征码的思路很简单:函数编译成机器码之后,字节序列是确定的,只要so版本不变,这段字节就一直存在。你只需要把这段特征字节告诉Frida,全模块内存里扫一遍,就能定位到函数地址。
Frida提供了Memory.scanSync,用起来很直接:
const base = Module.findBaseAddress("libtarget.so"); const mod = Process.getModuleByName("libtarget.so"); const matches = Memory.scanSync(base, mod.size, "F0 20 02 8B ?? ?? ?? ?? 18 00 00 94"); matches.forEach(m => console.log("match at:", m.address));pattern里的??是通配符,表示这个字节不参与匹配。为什么要用通配符?因为很多指令里包含地址相关的立即数,这些数在函数内部每次编译可能变化,但指令的“形状”不变,所以把那些易变字节模糊掉,只匹配稳定的部分。
特征字节从哪儿来?在反汇编工具里定位到目标函数,切到十六进制视图,把函数开头的一段指令复制出来。我个人的经验是:不要只取函数开头6个字节,因为有些保护框架会动态patch函数开头几条指令,一旦被patch,你的特征码就废了。更稳妥的做法是从函数中部取一段不太可能被修改的稳定指令,长度8到12个字节足够,太长了容易因为编译选项差异匹配不上。
扫描结果可能不止一个。内存里存在多个相同特征字节序列是常有的事,尤其是循环展开或者内联函数比较多的时候。碰到这种情况,把每个命中点的前后字节都hexdump出来人工确认一下,选择符合上下文语义的那一个。比如某个命中点后面紧跟着一个BL跳转指令,而目标函数正是要调用子函数的,这个命中点基本就是目标。
3. Hook落地与参数处理的实战要点
3.1 Interceptor.attach的参数读取与改写
地址确定了,接下来就是写hook。最常用的API是Interceptor.attach,它可以在目标地址的入口和返回点各挂一个回调:
Interceptor.attach(target, { onEnter(args) { console.log("called, arg0:", args[0], "arg1:", args[1]); }, onLeave(retval) { console.log("return value:", retval); } });参数顺序对应系统调用约定。在ARM64平台下,前8个参数依次走x0到x7寄存器,args[0]就是x0,args[1]就是x1,以此类推。超过8个的参数才走栈上,Frida的args在栈参数场景下也能直接拿到,但因为栈布局依赖调用约定,用的时候要格外小心,最好先用很小参数长度的函数练手。
读取不同类型的参数需要不同的API。如果参数是C字符串,直接用Memory.readCString(args[0]);如果是指针,先确保它确实是个有效指针再读取,否则一读就崩:
onEnter(args) { if (!args[0].isNull()) { console.log("str:", Memory.readCString(args[0])); } }改返回值是很多人学hook的初心所在。在onLeave里直接对retval做操作:
onLeave(retval) { retval.replace(ptr(0)); // 把返回值强制改成NULL }注意retval是个包装对象,直接retval = ptr(0)不生效,必须调用replace方法。这个细节我见无数人踩过,连我自己一开始也写错过,赋值语句写完没效果,还以为hook失败了。
3.2 指令对齐、Thumb模式和hook点选择
hook Native函数,本质是在目标地址处做inline hook,把原来执行的指令搬走,替换成跳转指令。这里涉及一个容易忽略的概念:指令对齐。
ARM64架构下所有指令都是4字节长度,指令地址必须4字节对齐。ARMv7下有ARM模式和Thumb模式两种指令集,ARM模式也是4字节对齐,Thumb模式是指令2字节或4字节混合,对齐单位是2字节。如果你把hook点选在了指令中间,或者在你hook的地址附近存在PC相对寻址指令,原指令被搬走后再执行就会得到错误的结果,表现为进程莫名其妙的随机崩溃。
这不是你写几行JavaScript能看到的,但理解它对你的排查思路很有帮助。实际使用中,我建议优先把hook点选在函数入口的稳定区域,比如入口后第一条BL指令之后的几行。那种地址很偏僻、前后字节凌乱的地方,哪怕特征码匹配上了也不要直接用,宁可在偏移基础上往前或往后挪几个指令,找一个更“居中”的地方挂载。
另外,某些应用会把so里关键函数的开头拿来存放加密壳的初始化逻辑,也就是说函数真正业务代码之前多了一层前置逻辑。这种情况下你hook入口能碰到,但可能拿到的参数都还是壳的初始值,不是真实业务参数。处理办法是先在反汇编工具里看入口区域和真实代码区之间有没有跳转指令或加载逻辑,找到“真正进入业务逻辑”的指令边界再hook。
3.3 用NativeFunction主动验证寻址结果
寻址和hook都写完了,但有一个高效验证方法值得单独拿出来讲:主动调用目标函数。
Frida允许通过NativeFunction包装一个内存地址,像调用普通JavaScript函数一样去调用Native函数:
const fn = new NativeFunction(target, "void", ["pointer", "pointer"]); const buf = Memory.allocUtf8String("hello"); fn(ptr(0), buf);第一个参数是地址,第二个参数是返回值类型,第三个参数是参数类型数组。如果目标函数签名正确,调用就会正常执行;如果签名完全不对,比如本来返回值是void你写成pointer,或者参数个数少了,进程很可能直接崩溃。
这个方法在调试阶段特别有用。我先用NativeFunction主动调一次,确认地址和签名都对,再回头写Interceptor的逻辑。如果主动调用正常但Interceptor不触发,问题一定出在“这个函数在真实路径上没有被调用”,而不是hook代码本身。这个先后判断顺序能省下大量的无效debug时间。
JNI函数用主动调用会麻烦一点,因为你得自己构造JNIEnv和jobject参数。如果你只是验证地址对不对,可以不用主动调用,而是先挂一个最简单的attach,然后触发一次App里的真实操作,看onEnter是否打印。只要能触发,地址就是对的。
4. 性能调优:从能跑到稳跑
4.1 高频函数的插桩策略
很多人第一次做Native层性能分析时,会挂一个日志输出型hook,结果App瞬间卡到没法操作,甚至会ANR。原因很简单:你hook的是一个每秒被调用几千次的函数,而且每次回调里都做了send或者console.log,主线程的消息队列直接被日志淹没了。
Frida的send是把数据从注入的Agent传到宿主端,中间有跨进程通信的开销。一次两次不算什么,频繁调用就是性能炸弹。应对策略是把“每次事件一条日志”改成“累积统计,定时上报”。用一个计数器在onEnter里累加,然后另起一个定时器,每秒钟查一次计数,有增量就批量上报,然后清零:
let hitCount = 0; Interceptor.attach(target, { onEnter() { hitCount++; } }); setInterval(() => { if (hitCount > 0) { send({ type: "hitStat", count: hitCount }); hitCount = 0; } }, 1000);这样一万次调用变成一条日志,渲染压力瞬间没了。如果你需要的是每次调用的参数快照,也尽量用批量数组缓存起来,攒够50条或者100条再一次性send出去,而不是逐条发。
另一个更“宏观”的策略是:提升hook层次。如果你真正关心的是某个业务函数被调用的路径,与其hook最底层那个每秒被调一万次的基础函数,不如hook上层业务入口,在钩子函数里用Thread.backtrace抓一下调用栈,确认路径。代价是信息粒度变粗,但性能开销小了一个量级,大多数场景下完全够用。
4.2 批量Hook的可靠性设计
真实项目里很少只hook一个函数,往往是一口气hook十几个甚至几十个。批量hook最怕一件事:第二个函数挂了,导致后面所有hook没执行到,或者整个脚本直接报错退出。
解决思路很简单,逐个hook、逐个体检。每个hook包一层try-catch,失败就把错误记录下来,不让它中断后续流程:
const hookList = [ { name: "registerUser", offset: 0x1234 }, { name: "sendMessage", offset: 0x5678 }, { name: "logout", offset: 0x9ABC } ]; hookList.forEach(item => { try { const addr = base.add(item.offset); Interceptor.attach(addr, { onEnter() { console.log("hit:", item.name); } }); } catch (e) { console.log("hook failed:", item.name, e); } });同一份脚本不要重复attach同一个地址。Frida对同一个地址重复attach,第二次的行为表现不稳定,不会报错但回调可能不触发,或者只触发最后一次。稳妥做法是维护一个全局的Set,记录已挂载地址,hook之前先查一下:
const hookedAddresses = new Set(); // ... if (!hookedAddresses.has(addr)) { Interceptor.attach(addr, ...); hookedAddresses.add(addr); }批量hook还有一个时机问题。如果目标so是在业务运行过程中才加载的,你就得等它加载完再寻址。一个通用做法是轮询等待,我写过很多次这个helper:
function waitForModule(name, timeoutMs) { return new Promise((resolve) => { const start = Date.now(); const timer = setInterval(() => { const mod = Process.findModuleByName(name); if (mod) { clearInterval(timer); resolve(mod); } else if (Date.now() - start > timeoutMs) { clearInterval(timer); resolve(null); } }, 50); }); }先await这个Promise再继续,很大程度上避免“模块没加载所以hook了个寂寞”的情况。
4.3 内存与生命周期管理
Native层hook做得多了会发现,真正拖垮分析的往往不是hook本身,而是Agent内部对内存的不自觉消耗。一个典型场景:你在onEnter里读取一个C字符串并保存进JavaScript数组,准备离线分析,但这个数组只增不减,进程跑几分钟后内存就暴涨。
解决办法有两个思路:一是及时消费,也就是上面说的定时批量send出去,send完之后清空数组;二是限制缓存大小,超过阈值就丢弃旧数据并标记警报。永远不要觉得“反正是分析工具,内存多点无所谓”。
还有Interceptor的生命周期。脚本结束时,挂载过的hook不会自动卸载干净,目标进程的行为可能被残留的trampoline影响,导致你后面做对照实验时数据失真。分析结束就主动detach:
const interceptor = Interceptor.attach(target, { ... }); // 一段时间后 interceptor.detach();如果你写的Agent要给没有Java运行时的进程用,最好先做一次环境判断。用Java.available确认存在JVM再调用Java相关API,不然在纯native调试目标上会直接抛异常:
if (Java.available) { Java.perform(() => { ... }); }整体来看,Native层调优不是一个技巧,而是一套工程习惯。每次只验证一个变量,日志量控制在可读范围,及时清理不再使用的资源。能做到这三点,再复杂的so层分析都不会变成泥潭。
5. 常见问题排查实录
5.1 Hook不触发的四类典型原因
hook写完了,日志一行不打印,很多人第一反应是“Frida是不是坏了”。其实绝大多数情况是下面四种原因之一,按概率排个序:
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| hook无日志,模块也找不到 | so尚未加载 | 用轮询等待so加载后再hook |
| hook无日志,模块已加载 | 偏移计算错误 | 打印base和offset,在目标地址hexdump对比 |
| hook无日志,地址也正确 | 多进程环境,目标逻辑在另一个进程 | enumerateProcesses确认注入进程是否为业务进程 |
| hook无日志,单进程也没错 | 目标函数确实没被调用 | 往上再hook一层父函数,看调用链走到哪一步断了 |
多进程这一项最隐蔽。很多App的逻辑在子进程里执行,Frida默认attach的是主进程,你在主进程里找so、找地址全都正常,但业务根本不在这里跑。排查手段是Process.enumerateProcesses(),把匹配进程名的pid找出来,再重新注入。
还有一个常被忽略的原因是spawn模式和attach模式的差异。attach模式注入时目标App已经在运行,如果是启动早期的函数调用,你已经错过了;spawn模式是从进程启动开始注入,能抓到最早的调用轨迹。做Native层分析时,能用spawn就尽量用spawn,能少踩很多时序坑。
5.2 崩溃定位三板斧
hook之后App崩了,不要慌,按三个步骤来。
第一斧,缩小范围。把脚本里所有hook注释掉,只留一个出问题的hook点,重新跑。如果崩溃消失,说明就是这个hook点的问题;如果还有崩溃,那可能是Agent本身的问题,先检查是不是用了不安全的API。
第二斧,打印现场。在onEnter里不要只print,用Thread.backtrace打印当前调用栈,看看崩溃时是从哪条路径进来的。再配合hexdump打印函数附近的关键内存,确认你读取的指针确实是预期的结构体:
Interceptor.attach(target, { onEnter(args) { console.log(Thread.backtrace(this.context, Backtracer.Accurate) .map(DebugSymbol.fromAddress).join("\n")); } });第三斧,检查hook点位置。如果崩溃发生在hook点附近且呈现“随机、不可复现”的特征,基本可以判断是inline hook导致了PC相对寻址错乱。换一个hook点,比如往函数中部挪几条指令,或者改用函数入口后方的稳定区域,往往立竿见影。
多年的排查经验告诉我一个残酷的事实:大部分崩溃不是hook本身引起的,而是你在onEnter里用错了参数。参数本来是个整数,你当成指针去readCString,直接段错误。所以读任何指针之前先做两个判断:这个参数真的是指针吗?指针非空吗?就这两步,能挡住80%的崩溃。
5.3 调优阶段最容易被忽略的细节
当hook能跑、稳定了以后,进入调优阶段。这时候真正体现功力的不是hook写得多华丽,而是你能不能让数据说话。
先给日志分级。我在大项目中习惯用一个全局verbose开关,默认关闭。分析时打开verbose,看最细节的日志;跑性能对比时关掉verbose,只留汇总统计。这么做的好处是切换成本低,不用反复改代码。
再做前后对比时要有耐心,同一个场景跑三次取中位数。Native层的性能受CPU频率、缓存状态影响很大,跑一次的结果可能是噪声。我自己会记录一张简单表格:
| 场景 | 原生耗时 | 插桩后耗时 | 额外开销 |
|---|---|---|---|
| 场景A:普通业务函数 | 2.1ms | 2.4ms | 约14% |
| 场景B:高频小函数 | 0.8ms | 1.6ms | 100% |
这种数据才是调优的依据。上一章讲的定时批量上报、计数统计、提升hook层次,都是围绕“降低额外开销”在做的调整。
最后强烈建议:分析任务结束后,把测试用的所有hook都清理干净,保留一份最小验证脚本,方便下次启动时快速检查环境是不是正常。别嫌麻烦,这个习惯让我少交了很多“上个脚本把当前环境搞乱了”的学费。
说一个我自己的习惯收尾:每确定一个候选地址,先打印三样东西——模块基址、目标偏移、目标地址附近的16字节hexdump,三样确认无误再写hook逻辑。这套流程帮我砍掉了一大半无效调试时间。Native层探索到这步还有不少可以深挖的地方,比如JNI函数的参数还原、主动调用时如何构造复杂结构体,真展开讲又是一篇的篇幅,后续有机会再聊。