移动端逆向和安全研究做到一定深度,Java 层的 Hook 迟早会不够用。签名算法、加密核心、协议打包、加固壳的解密逻辑,这些东西越来越多被下沉到 so 里,你用 Java 层那套反射、动态代理去抓,抓到的往往只是一层壳,真正的关键参数在进入 native 之前就已经算完了。这时候就得上 Frida 的 Native 层操作:定位模块基址、算偏移、拦截函数入口出口、读写内存、改返回值、追踪指令流。这套东西听起来门槛高,实际上核心概念就那么几个,难的是细节——地址算错一个字节就崩,参数类型对不上就读出垃圾数据,回调里多写一行日志整个进程就卡死。这篇内容我把 Native 层这套操作从环境准备到实战排错完整捋一遍,把我自己踩过的坑都摊开讲。适合已经会用 Frida 做 Java Hook、想往 so 层走一步的同行,也适合做自研应用调试、安全评估、so 行为分析的开发者参考。所有示例都基于自己写的测试 so 和授权测试环境,不对任何线上应用动手。
1. Native 层 Hook 到底在解决什么问题
1.1 Java 层 Hook 的天花板在哪里
做 Java 层 Hook 的时候,思路很直接:找到类、找到方法、attach 上去打印参数和返回值。这套流程之所以顺,是因为 Java 有完整的运行时元数据——类名、方法名、签名都在 Dex 里摆着,Frida 通过 ART 的反射机制能直接把方法对象捞出来。但问题在于,一旦逻辑进了 native,Java 层就彻底看不见了。你在nativeSign(byte[] data)这个方法上挂 Hook,只能看到传进去的字节数组和返回的字符串,中间那套运算过程完全是个黑盒。
更麻烦的是有些应用会把关键判断也放进 native,比如某个校验函数返回 boolean,Java 层只看结果,你改返回值倒是能过,但你不理解它校验了什么,换个场景就又卡住了。还有一种情况是加固后的应用,Dex 本身是加密的,运行时才解密加载,Java 层 Hook 需要等类加载完成,而类加载的时机、类名可能都被改过,抓起来很费劲。
Native 层的优势就在这里:不管上层怎么混淆,最终代码都要编译成机器指令落在 so 里,函数地址是确定的,调用约定是固定的。你只要能定位到那个地址,就能拦。这层抽象比 Java 层低得多,但也稳定得多。
1.2 Native 层能打开的三个突破口
我自己总结下来,Native Hook 主要用在三类场景。
第一类是算法逻辑还原。签名、加密、序列号生成这类逻辑放进 so 是常规做法,通过拦截函数入口看输入、出口看输出,配合多次调用采样,能快速推断出算法结构。如果逻辑复杂,还可以用 Stalker 追踪指令流,看它到底读了哪些内存、调了哪些子函数。
第二类是运行时状态观测。有些问题不是算法问题,是状态问题——比如某个全局标志位在什么时候被置位、某个结构体在什么阶段被填充。这种用 Memory 读写配合定时打印,比断点调试轻量得多,尤其在不能中断进程的场景下。
第三类是自研应用的功能验证。自己写的 so,单元测试覆盖不到的部分,用 Frida 挂上去跑真实调用链,看参数传递是否符合预期,比加一堆 printf 再重新编译快得多。这也是我最常用的场景,改一行 JS 就能验证一个假设,迭代速度完全不一样。
1.3 环境准备与版本对齐
环境这块,最容易出问题的是版本不一致。frida 客户端(Python 包或 CLI)和设备上跑的 frida-server 必须是同一个版本号,差一个小版本都可能连不上或者 attach 后立刻断开。我一般先pip show frida看客户端版本,然后去下载对应版本的 server 二进制。
架构也要对上。设备是 arm64 就推 arm64 的 server,32 位应用跑在 64 位系统上这种情况要注意,如果你的目标是 32 位进程,需要确保 server 能正常注入到 32 位进程里,通常 arm64 的 server 是兼容的,但个别老设备会有问题。
启动流程大致是这样:
# 推送 server 到设备 adb push frida-server-16.x.x-android-arm64 /data/local/tmp/fs adb shell chmod 755 /data/local/tmp/fs # 启动(需要 root) adb shell su -c "/data/local/tmp/fs &" # 端口映射,让本机能连上设备 adb forward tcp:27042 tcp:27042 # 验证 frida-ps -U提示:
frida-ps -U能列出进程就说明通了。如果报 "unable to connect",九成是 server 没起来或者版本不匹配,先adb shell ps | grep fs看进程在不在。
另外建议装个frida-tools,里面的frida-trace在做快速探测时非常好用,能自动跟踪一批导出函数的调用,虽然精度不如手写脚本,但用来摸清某个 so 被调了哪些函数,效率很高。
2. 三套核心武器与选型逻辑
2.1 Interceptor.attach:函数级拦截的主力
Interceptor.attach是使用频率最高的一个 API,作用是给指定地址的函数下钩子,在函数执行前和执行后分别回调。它的实现原理是在目标函数开头写入跳转指令,跳到 Frida 自己的 trampoline,执行完回调再跳回原函数继续跑。所以你不需要关心原始指令被覆盖的问题,Frida 帮你做了指令重定位。
基本写法:
const base = Module.findBaseAddress("libdemo.so"); if (base === null) { console.log("模块还没加载"); } else { const target = base.add(0x1a2b4); Interceptor.attach(target, { onEnter(args) { console.log("arg0 =", args[0]); console.log("arg1 =", args[1].toInt32()); }, onLeave(retval) { console.log("ret =", retval); } }); }这里有两个细节必须说清楚。第一,args数组里的元素是 NativePointer 类型,不是数字,做算术要显式转换,直接args[0] + 1会得到奇怪的结果。第二,args只覆盖整型和指针参数,浮点参数不会出现在 args 里,ARM64 下浮点参数走 v0-v7 寄存器,得从this.context里读。这个坑我在第一次 Hook 一个带 float 参数的函数时踩得很实,打印出来全是整数,还以为是数据错了。
onEnter里的this.context能拿到完整的寄存器上下文,ARM64 下是x0到x30、sp、pc,浮点寄存器是v0到v31。要读浮点参数:
onEnter(args) { const f0 = this.context.v0; console.log("float arg0 =", f0); }还有个进阶用法是this.returnAddress,能拿到调用者的返回地址,配合DebugSymbol.fromAddress可以打印调用栈,排查"到底是谁调了这个函数"特别好用。
2.2 内存读写与地址计算:错一个字节就崩
Native 层的地址计算是基本功,也是最容易出错的地方。核心公式就一个:绝对地址 = 模块基址 + 函数偏移。模块基址每次进程启动都不一样(ASLR),所以必须动态获取,不能写死。
Module.findBaseAddress("libxxx.so")返回的是模块加载的起始地址,偏移则是你在 IDA 或 Ghidra 里看到的函数地址减去 so 的基址(通常是 0,但有些 so 有非零的 image base,要减掉)。这里有个常见误区:很多人直接拿 IDA 里显示的地址当偏移用,结果算出来偏了一大截,就是因为没处理 image base。
更稳妥的做法是先用导出符号定位,能拿到符号就别算偏移:
const addr = Module.getExportByName("libdemo.so", "Java_com_demo_NativeHelper_sign");拿不到符号的时候,可以用Module.enumerateExports遍历一遍看有哪些可用的,或者Module.enumerateSymbols看调试符号(如果没 strip 的话)。实在没有,就只能靠偏移或者特征码扫描。
内存读写 API 主要这几个,记住类型对应关系就不会乱:
| API | 读取类型 | 常见用途 |
|---|---|---|
readUtf8String | 字符串 | 读 C 风格字符串 |
readPointer | 指针 | 读指针字段、结构体成员 |
readInt/readUInt | 32 位整数 | 读 int 类型参数、标志位 |
readLong/readULong | 64 位整数 | 读 long、时间戳 |
readByteArray | 字节数组 | 读 buffer、密钥数据 |
writeInt/writeUtf8String | 写入 | 改返回值、改字符串内容 |
readUtf8String有个坑:如果指针是 NULL 或者指向非法内存,会直接抛异常甚至崩溃。稳妥写法是先判空再读,或者包一层 try-catch。
2.3 Interceptor.replace 与 Stalker 的取舍
Interceptor.replace是把整个函数替换掉,原来的逻辑完全不执行。适合的场景是:你想完全接管某个函数的行为,比如让某个校验函数永远返回 true,或者用自己的 JS 实现替换掉原来的算法。
const orig = new NativeCallback(function (a, b) { return a + b; }, 'int', ['int', 'int']); Interceptor.replace(target, orig);注意replace之后原函数就没了,如果后续还想调用它,得在替换前用Interceptor.attach之外的方式保存原始地址,或者用Memory.patchCode之类的手段。我一般的做法是能用 attach 就不用 replace,因为 attach 保留了原始逻辑,出问题容易回退,replace 一旦写错整个流程就断了。
Stalker 是另一套东西,用来追踪指令执行流。它的粒度是代码块级别,能告诉你这段代码执行了哪些指令、访问了哪些内存。开销很大,一般只在定位某个短小的关键函数时用,比如你想知道一个加密函数内部调了哪些子函数:
Stalker.follow(Process.getCurrentThreadId(), { events: { call: true }, onCallSummary(summary) { for (const target in summary) { console.log("call ->", target); } } });跑完记得Stalker.unfollow(),不然目标线程会一直处于追踪状态,性能掉得厉害,严重的会直接卡死。
3. 从定位函数到跑通 Hook 的完整链路
3.1 静态分析配合:先看清楚再动手
我见过不少人上来就写 JS 试偏移,试一次崩一次,一天下来啥也没成。正确的顺序是先在静态分析工具里把目标看清楚:这个 so 导出了哪些函数、哪个函数看起来是关键逻辑、调用关系是怎样的。
用 IDA 或 Ghidra 打开 so,先看 Exports 列表。如果函数名是Java_开头的,说明是静态注册的 JNI 方法,名字里直接带包名和类名,非常好定位。如果导出表里只有寥寥几个JNI_OnLoad和几个看不出含义的符号,那大概率是动态注册,需要去JNI_OnLoad里找RegisterNatives的调用,从参数里翻出方法名和函数指针的对应关系。
静态看完之后,用 Frida 做验证:
// 列出所有导出,先摸清家底 Module.enumerateExports("libdemo.so").forEach(e => { if (e.name.indexOf("Java_") === 0 || e.name.indexOf("sign") >= 0) { console.log(e.name, e.address); } });这一步能省掉大量瞎猜。我自己习惯是先 enumerate 一遍,把可疑的函数地址打印出来,然后针对性地 attach,比盲试高效得多。
3.2 没有符号时怎么靠偏移定位
so 被 strip 过、或者关键函数是内部静态函数(没有导出),这时候只能靠偏移。流程是:在静态工具里找到函数起始地址,减去 so 的 image base,得到相对偏移,然后运行时用基址加偏移。
const base = Module.findBaseAddress("libdemo.so"); const offset = 0x8f4c; // 静态分析得到的相对偏移 const target = base.add(offset); console.log("target =", target, "base =", base);调试阶段一定要把算出来的地址打印出来,然后在静态工具里用 "跳转到地址" 功能核对一下,看落点是不是函数开头。如果落点是一条指令的中间,attach 上去大概率崩溃,因为 Frida 需要重定位被覆盖的指令,从半截开始就乱了。
偏移会随 so 版本变化,所以写脚本的时候最好把偏移做成配置项,同时加一层校验——比如读取目标地址的前几个字节,跟静态工具里看到的机器码比对,对不上就说明版本不对,直接报错比崩溃好排查。
特征码扫描是另一个思路,Memory.scan可以按字节模式搜索:
Memory.scan(base, 0x80000, "F0 4F BD A9 ?? ?? ?? ??", { onMatch(address, size) { console.log("candidate:", address); }, onComplete() { console.log("scan done"); } });模式里的??是通配符。选特征码的时候要挑函数里比较独特的一段机器码,别选那种到处都是的通用指令序列。
3.3 参数与返回值的处理细节
这是最容易出问题的地方。ARM64 下整型和指针参数走 x0-x7,超过 8 个参数就从栈上取,Frida 的args数组能覆盖前 8 个。返回值在 x0 里。
读字符串参数:
onEnter(args) { const strPtr = args[0]; if (!strPtr.isNull()) { console.log("str =", strPtr.readUtf8String()); } }读结构体参数要按字段偏移逐个读:
onEnter(args) { const p = args[0]; const field1 = p.readInt(); // 偏移 0 const field2 = p.add(4).readPointer(); // 偏移 4 const field3 = p.add(8).readUInt(); // 偏移 8 console.log(field1, field2, field3); }字符串数组要遍历,注意那里存的是指针数组,每个元素都是一个指针:
onEnter(args) { const arr = args[0]; for (let i = 0; i < 3; i++) { const p = arr.add(i * Process.pointerSize).readPointer(); if (!p.isNull()) console.log(`str[${i}] =`, p.readUtf8String()); } }Process.pointerSize在 64 位下是 8,32 位下是 4,写通用脚本的时候别硬编码。
改返回值更简单,在onLeave里直接赋值:
onLeave(retval) { console.log("原始返回:", retval.toInt32()); retval.replace(ptr(1)); // 强制返回 1 }retval.replace改的是 x0,如果返回类型是 bool,改完上层就能拿到 true。
3.4 用 NativeFunction 主动调用
有时候你不是想拦截,而是想主动调用 so 里的某个函数,比如自己构造输入去跑一遍算法。这时候用NativeFunction:
const funcPtr = Module.getExportByName("libdemo.so", "encrypt_data"); const encrypt = new NativeFunction(funcPtr, 'pointer', ['pointer', 'int']); const input = Memory.allocUtf8String("hello"); const output = encrypt(input, 5); console.log("output =", output.readCString());返回类型和参数类型必须写对,'pointer'、'int'、'uint64'、'float'、'double'、'void'这些都是常用类型。类型写错的后果是返回值解析错误,或者调用约定不对导致崩溃。有一次我把uint64写成了int,返回值高位全丢,排查了半小时才发现是类型问题。
调用完分配的内存不用手动释放,Memory.alloc分配的内存会随脚本生命周期管理。
4. 复杂场景:动态注册、多线程与反调试
4.1 动态注册的 Native 方法怎么定位
动态注册的方法在导出表里找不到Java_前缀,因为函数名是任意起的,只是在JNI_OnLoad里通过RegisterNatives把方法名和函数指针绑定了。定位思路是 HookRegisterNatives本身,把注册表打印出来。
RegisterNatives是 libart 里的函数,不同系统版本符号名可能有差异,稳妥做法是枚举符号找:
const syms = Module.enumerateSymbols("libart.so"); syms.forEach(s => { if (s.name.indexOf("RegisterNatives") >= 0 && s.name.indexOf("CheckJNI") < 0) { console.log(s.name, s.address); } });拿到地址后 attach 进去,第二个参数是JNINativeMethod数组,每个元素包含name(字符串指针)、signature(字符串指针)、fnPtr(函数指针),长度是 24 字节(64 位下):
Interceptor.attach(regNativesAddr, { onEnter(args) { const methods = args[1]; const count = args[2].toInt32(); for (let i = 0; i < count; i++) { const item = methods.add(i * 24); const name = item.readPointer().readCString(); const sig = item.add(8).readPointer().readCString(); const fn = item.add(16).readPointer(); console.log(`${name} ${sig} -> ${fn}`); } } });打印出来的结果就是完整的映射表,拿到 fnPtr 之后直接 attach 就行,不用再算偏移。这套方法我用了很多次,基本上一挂一个准。
4.2 多线程与回调阻塞的坑
Frida 的 JS 引擎是单线程的,所有 Hook 回调都在同一个 JS 线程里排队执行。这意味着你在回调里做的任何耗时操作,都会阻塞目标进程的线程。具体表现是:你在onEnter里打了一行console.log,如果这个函数每秒被调用几万次,日志量瞬间爆炸,进程直接卡到无响应。
我踩过的最典型的一次是 Hook 一个循环体内的函数,本来只想看看参数,结果挂上去之后应用直接白屏。原因是那个回调执行时间超过了函数本身的执行时间,形成雪崩。
避免这个问题的几条经验:
- 回调里只做最必要的操作,别写复杂的字符串拼接和格式化
- 高频函数用计数器采样,比如前 100 次打印,之后只统计次数
- 需要大量数据的话,把数据塞进数组,用
setInterval定期批量输出 - 绝对不要在回调里做同步的文件读写或者网络请求
采样写法:
let count = 0; Interceptor.attach(target, { onEnter(args) { count++; if (count <= 100) { console.log("call", count, args[0]); } } }); setInterval(() => { console.log("total calls:", count); }, 3000);另外要注意,Hook 回调执行时会短暂暂停目标线程,如果目标函数本身在多线程环境下被并发调用,暂停可能导致锁竞争问题,表现出来是偶发死锁。遇到这种情况,优先考虑把 Hook 点挪到锁外面,或者改用Memory.patchCode直接改指令。
4.3 反调试检测与稳定性处理
有些应用会检测调试环境,常见手段包括扫描/proc/self/maps里有没有可疑模块、检查线程名里有没有特征字符串、检测特定端口是否有监听。这些检测一旦命中,可能直接退出或者走假逻辑分支。
做安全评估的时候,需要先确认检测点在哪里,再决定怎么处理。定位方法是 Hook 文件读取相关的函数,看它读了哪些路径:
const fopen = Module.getExportByName(null, "fopen"); Interceptor.attach(fopen, { onEnter(args) { const path = args[0].readCString(); if (path && (path.indexOf("maps") >= 0 || path.indexOf("status") >= 0)) { console.log("open:", path, "from:", this.returnAddress); } } });打印调用者地址,再回到静态工具里看这段代码属于哪个函数,就能定位到检测逻辑的位置。
注意:这类操作只在自有应用或明确授权的测试环境里做。对第三方应用做运行时修改涉及合规问题,不在讨论范围内。
稳定性方面还有几个实操细节。Hook 时机很关键,Module.findBaseAddress返回 null 说明 so 还没加载,这时候要么用setTimeout轮询等待,要么 Hookdlopen在加载完成时触发。我一般写个轮询函数:
function waitForModule(name, cb) { const timer = setInterval(() => { const base = Module.findBaseAddress(name); if (base !== null) { clearInterval(timer); cb(base); } }, 100); }还有一点是 Hook 的清理。脚本 detach 之后 Hook 会自动移除,但如果脚本异常退出,某些情况下残留的 trampoline 可能导致后续调用异常。开发阶段频繁重载脚本的时候,建议每次 attach 前先Interceptor.detachAll(),避免重复 Hook 叠加。
5. 常见报错与排查速查表
5.1 典型错误对照
这些年攒下来的报错基本都在这张表里,遇到问题先对号入座:
| 现象 | 大概率原因 | 处理办法 |
|---|---|---|
unable to connect to remote frida-server | server 没启动或版本不匹配 | 检查进程、核对版本号 |
Module.findBaseAddress返回 null | 模块未加载或名字写错 | 轮询等待,enumerateModules核对名字 |
| attach 后进程立刻崩溃 | 偏移错误,落点不在函数首 | 打印地址,静态工具核对落点 |
expected a pointer | 传了 Number 而非 NativePointer | 用ptr()包装 |
| 读字符串得到乱码 | 指针类型不对或编码不是 UTF-8 | 换readCString、检查是否宽字符 |
| 打印参数全是整数 | 参数是浮点,走了 v 寄存器 | 从this.context.v0读 |
| 应用卡顿甚至无响应 | 回调里做了耗时操作 | 采样、批量输出、减少日志 |
onLeave没触发 | 函数内部抛异常或提前返回 | 检查是否有 longjmp 类逻辑 |
| 返回值修改无效 | 改错了寄存器或时机不对 | 确认返回类型,retval.replace用法 |
| 32 位 so Hook 失败 | thumb 模式地址需要置最低位 | 地址or 1 |
最后一条值得单独说。ARM32 下函数地址可能是奇数(thumb 模式标志位),Frida 有时需要你手动置位。表现是 attach 不报错但回调不触发,或者直接崩。处理办法是把地址最低位置 1,或者用Thumb相关的处理方式。
5.2 提升稳定性的几条硬经验
第一条,先小后大。不要一上来就 Hook 关键函数跑全流程,先用最简单的脚本验证地址对不对——attach 上去只打印一行 "hit",确认能触发,再逐步加逻辑。这样出问题的时候你知道是哪一步引入的。
第二条,保留原始调用。用 replace 的时候一定要在内部调用原函数(如果逻辑需要),保存原函数指针的方式是在 replace 之前用Module.getExportByName拿地址,然后用NativeFunction调用。忘了这茬会导致原本正常的流程断掉,表现出来是应用功能异常但没有任何报错。
第三条,日志要能关。开发阶段日志越多越好,跑到稳定阶段就该收起来,只保留关键节点。我习惯在脚本顶部放一个const DEBUG = false;,所有调试日志包一层判断,需要的时候改一行就行,不用到处删代码。
第四条,注意 64 位指针的高位。有些返回指针的高 32 位是垃圾数据,直接当地址用会崩,必要时用ptr(x).and(ptr("0xffffffff"))截断。这个坑在 Hook 返回结构体指针的函数时特别常见。
第五条,版本化你的偏移表。同一个 so 不同版本偏移完全不同,把偏移、特征码、校验用的机器码一起写进配置对象,脚本启动时先校验,不匹配直接抛错,比跑到一半崩溃好排查一百倍。
5.3 一个完整的排查实例
举个我自己遇到的例子。目标是一个自研的 so,里面有个字符串处理函数,Hook 上去之后偶尔崩,不是每次都崩。排查过程是这样的:
先确认地址没问题——打印出来的地址和静态工具里完全一致,落点也对。然后加上try-catch包住readUtf8String,发现崩溃点确实在读取操作上。再进一步打印指针值,发现某些调用传进来的指针是0x1这种明显非法的值。
回到静态代码里看,原来这个函数在某些分支下会把一个错误码当指针传进来,正常流程下不会走到,但测试用例触发了这个分支。加上判空和合法性检查之后就稳了:
onEnter(args) { const p = args[0]; // 合法性粗筛,排除明显非法的小地址 if (p.isNull() || p.toUInt32() < 0x1000) { console.log("invalid ptr:", p); return; } try { console.log(p.readUtf8String()); } catch (e) { console.log("read failed:", e.message); } }这个经验后来成了我写所有 Native Hook 脚本的标配——任何内存读取之前都做一次合法性检查,代价是一行判断,收益是脚本从偶尔崩变成完全不崩。
5.4 性能与可维护性的平衡
脚本写到后期,往往会膨胀到几百行,各种 Hook 交织在一起,改一处牵全身。我的处理方式是模块化:把通用的等待模块加载、地址校验、安全读取封装成函数,每个 Hook 单独一个函数,主流程里按需调用。
function safeReadString(p) { if (p.isNull() || p.toUInt32() < 0x1000) return "<invalid>"; try { return p.readUtf8String(); } catch (e) { return "<read-error>"; } } function hookEncrypt(base) { const target = base.add(0x4a10); Interceptor.attach(target, { onEnter(args) { console.log("[encrypt] in:", safeReadString(args[0])); } }); } waitForModule("libdemo.so", base => { hookEncrypt(base); console.log("hooks installed"); });这样结构清晰,加新 Hook 只是多一个函数,排查问题也能快速定位到具体哪个 Hook 出的岔子。
我个人在实际操作中的体会是,Native 层 Hook 的难点从来不在 API 本身,Interceptor.attach的用法看十分钟文档就能会。真正的门槛在地址计算的准确性、对调用约定的理解、对内存布局的判断,以及出事之后的排查思路。这些东西没有捷径,就是一次次崩、一次次对着静态代码核对、一次次把非法指针拦下来,慢慢积累出来的直觉。分享一个我常用的笨办法:每写一个新的 Hook 点,先在纸上画一遍它的参数布局和栈帧结构,画完再动手写代码,比直接敲键盘快得多,崩的次数也少得多。