1. 这个“中文文档”到底在解决什么问题
第一次接触 Frida 的人,十有八九会被它的能力震住,然后立刻被它的文档劝退。官方文档是英文的,API 条目密密麻麻,示例代码散落在不同页面,Interceptor.attach和Java.perform的用法看懂了字面意思,真到动手写脚本的时候还是一脸懵。我当初就是这样,对着一个Module.findExportByName的返回值类型纠结了半天,因为文档里只写了“returns a NativePointer”,但没告诉你这个指针在什么情况下会是 null、null 的时候该怎么排查。
“Frida08-Javascript-中文文档”这个项目,本质上就是给 Frida 的 JavaScript API 做一套系统性的中文梳理。它不是简单的机器翻译,而是把官方 API 文档、社区实践、常见坑点揉在一起,用中文从业者能直接理解的方式重新组织。核心覆盖的是 Frida 的 JavaScript 绑定层——也就是你写.js脚本时能调用的那些对象和方法:Java、ObjC、Interceptor、Memory、Module、Process、Thread、NativePointer、NativeFunction等等。
这套文档能帮你做什么?最直接的一点:当你需要 hook 一个函数、读写一块内存、枚举某个模块的导出符号、或者在运行时动态调用一个 native 函数时,你能快速查到对应的 JS API 怎么写、参数是什么类型、返回值怎么处理、有哪些边界情况。它解决的是“知道 Frida 能干这事,但不知道具体代码怎么落笔”的问题。
适合谁来参考?安全研究方向的初学者、做移动端逆向分析的工程师、需要做运行时插桩调试的开发者,以及那些英文文档看得懂但看得慢、希望有个中文速查手册的老手。哪怕你 JS 基础一般,只要了解基本的语法和对象概念,配合文档里的示例就能上手。我见过不少做 Android 逆向的朋友,Java 层玩得很溜,一到 native 层就卡住,这套文档里关于Interceptor和NativePointer的部分对他们帮助最大。
需要说明的是,Frida 本身迭代很快,API 在不同大版本之间有增删改。这套中文文档的价值不在于“永久有效”,而在于它把 API 的设计意图和典型用法讲清楚了,你理解了意图,版本变了也能自己迁移。下面我就按实际使用的逻辑,把这套文档涉及的核心内容拆开讲。
2. 整体设计与思路拆解
2.1 为什么按“对象”而不是按“功能”来组织
官方文档的目录结构其实偏向按模块划分,但中文文档在组织上做了一个很关键的取舍:以 JS 侧的全局对象为主线。你打开 Frida 的 JS 运行时,能直接访问的东西就那么几个——Java、ObjC、Interceptor、Memory、Module、Process、Thread、Stalker、DebugSymbol、NativePointer、NativeFunction、NativeCallback。这套文档基本就是围绕这些对象逐个展开,每个对象下面列它的方法、属性、参数类型、返回值、典型示例。
这个设计的好处很实在。你在写脚本的时候,脑子里想的不是“我要做一个 hook 功能”,而是“我要用 Interceptor 去 attach 一个地址”。按对象组织,你的查找路径和编码时的思维路径是一致的。我试过按功能查文档,结果一个“拦截函数调用”的需求分散在 Interceptor、NativeFunction、Module 三个页面里,来回跳转很烦。按对象组织之后,Interceptor 页面里就把 attach、detach、replace、revert 讲全了,上下文是连贯的。
另一个考量是类型系统的统一。Frida 的 JS API 里大量涉及NativePointer这个类型,它既可以是函数地址,也可以是内存地址,还可以是模块基址。如果按功能组织,NativePointer的说明会被打散到各处;按对象组织,就可以单独给它一个完整章节,把add、sub、readU8、writeU8、readPointer、writePointer、isNull这些方法一次性讲透。读者建立一次认知,后面到处复用。
2.2 中文文档相比英文原文的取舍逻辑
做技术文档翻译,最怕的是“逐字翻译但读不懂”。Frida 官方文档里有些表述很精炼,比如Interceptor.attach(target, callbacks)的说明是“intercept calls to the function at target”,直译过来就是“拦截对 target 处函数的调用”。这句话没错,但对新手来说信息量不够——target 可以是哪些形式?callbacks 里 onEnter 和 onLeave 分别什么时候触发?args 是什么类型?这些才是真正要命的地方。
这套中文文档在取舍上做了几件事。第一,把参数类型和返回值类型显式标出来,不藏在正文里。比如Module.findExportByName(moduleName, exportName)返回NativePointer | null,这个| null非常关键,很多人不判空直接readU8就崩了。第二,给每个核心 API 配一个最小可运行示例,示例里把上下文补全,而不是只写一行调用。第三,把社区里反复出现的坑点单独拎出来,比如Java.perform里不能做耗时操作、Interceptor.attach的回调里不能直接调用会阻塞的 JS 函数、NativePointer做算术时要注意指针宽度。
这种取舍背后的判断标准是:读者打开文档是为了“把代码写对”,不是为了“了解 API 的哲学”。所以凡是影响代码正确性的细节,宁可多写;凡是纯概念性的背景,点到为止。
2.3 版本适配与兼容性说明的处理
Frida 的 API 变动不算小。早期版本里Module.findExportByName是同步的,后来某些场景下行为有调整;Java.perform的用法基本稳定,但Java.performNow是后来加的;Stalker的 API 在不同版本间也有演进。这套中文文档在处理版本问题时,采取的是“标注引入版本 + 说明行为差异”的方式,而不是只保留最新写法。
我个人的经验是,做逆向分析时你面对的 Frida 版本往往不由你决定——目标环境里装的是哪个版本,你就得用哪个版本的 API。所以文档里保留一些旧写法的说明是有价值的,至少让你在看到别人脚本里的老写法时不至于看不懂。文档里对每个可能受版本影响的 API,会注明“在较新版本中推荐用 X 替代 Y”,但不会把 Y 直接删掉。这种处理方式对实际工作者更友好。
2.4 示例代码的设计原则
文档里的示例代码有一个共同特点:短、可跑、有输出。比如讲Process.enumerateModules(),示例不是只写一行调用,而是配合forEach打印出每个模块的 name、base、size,让你跑一下就能看到当前进程加载了哪些模块。讲Memory.scan,示例给的是一个在指定范围内搜索特征字节序列的完整片段,包括onMatch和onComplete两个回调怎么配合。
这种设计的原因是,Frida 脚本的调试成本比普通 JS 高——你没法轻易打断点,很多时候只能靠console.log输出。所以示例代码必须自带“可观测性”,跑起来就能看到结果,读者才能确认自己理解对了。文档里凡是涉及异步回调的 API,示例都会把回调里的关键参数打印出来,这一点我觉得比官方文档做得更贴近实操。
3. 核心对象与 API 细节解析
3.1 NativePointer:一切地址操作的基石
NativePointer是 Frida JS API 里出现频率最高的类型,没有之一。你从Module.findExportByName拿到的返回值是它,从Interceptor回调里拿到的this.context.pc是它,Memory.alloc返回的也是它。理解不透这个类型,后面所有涉及地址的操作都会出问题。
它的核心方法分几类。算术类:add、sub、and、or、xor、shl、shr,注意这些方法返回的是新的NativePointer,不是原地修改。读写类:readU8、readU16、readU32、readU64、readS8一直到readS64,还有readPointer、readCString、readUtf8String、readByteArray。写入类对应writeU8等。判断类:isNull、equals、compare。转换类:toString、toInt32、toUInt32。
这里有个特别容易踩的坑:readU64返回的不是 JS 的 Number,而是一个UInt64对象,因为 JS 的 Number 是双精度浮点,无法精确表示 64 位整数。你要拿它的值,得用.toString()或者.toNumber()(后者在超出安全整数范围时会丢精度)。我当初写一个解析结构体的脚本,用readU64读了一个长度字段,然后直接拿去做比较,结果怎么都不对,后来才发现类型不匹配。
另一个坑是指针宽度。在 64 位进程里,NativePointer是 8 字节;在 32 位进程里是 4 字节。你写ptr.add(4)的时候,加的是 4 个字节,不是 4 个指针宽度。这个区别在做结构体偏移计算时特别重要。文档里对这一点有明确说明,并且建议在跨架构脚本里用Process.pointerSize来判断当前宽度,而不是硬编码。
// 典型用法:读取模块基址并计算偏移 var base = Module.findBaseAddress("libtarget.so"); if (base !== null) { var target = base.add(0x1234); console.log("target address: " + target); var value = target.readU32(); console.log("value at target: 0x" + value.toString(16)); } else { console.log("module not found"); }上面这段代码里,findBaseAddress可能返回 null,所以必须判空。这是文档里反复强调的一点——凡是返回NativePointer | null的 API,调用后第一件事就是判空。不判空直接add,在 null 上调用方法会抛异常,脚本直接挂掉。
3.2 Interceptor:函数拦截的核心机制
Interceptor是 Frida 做函数 hook 的主力。它的核心方法就四个:attach、detach、replace、revert。attach用于在函数调用前后插入回调,replace用于完全替换函数实现,detach和revert分别撤销前两者。
attach的签名是Interceptor.attach(target, callbacks),其中callbacks是一个对象,可以包含onEnter和onLeave两个函数。onEnter在目标函数体执行前被调用,参数是args,一个类数组对象,可以通过args[0]、args[1]访问入参。onLeave在目标函数返回后被调用,参数是retval,即返回值。
这里的关键细节是:onEnter里拿到的args是原始参数的“视图”,你可以读,但直接修改不一定生效,取决于参数传递方式(寄存器还是栈)。如果要修改参数,更可靠的做法是用replace或者用NativeFunction包装后手动调用。文档里对这一点有明确提醒,避免读者误以为改args[0]就能改入参。
onEnter里的this上下文包含context(CPU 寄存器快照)、returnAddress、depth等信息。this.context.pc是当前指令地址,this.context.sp是栈指针。这些在做底层分析时很有用,但要注意context里的寄存器值在onEnter和onLeave时是不同的,onLeave时函数已经执行完,寄存器可能已被修改。
// 拦截一个导出函数,打印入参和返回值 var addr = Module.findExportByName("libtarget.so", "target_func"); if (addr !== null) { Interceptor.attach(addr, { onEnter: function (args) { console.log("called with arg0 = " + args[0]); console.log("arg1 as int = " + args[1].toInt32()); }, onLeave: function (retval) { console.log("returned: " + retval); } }); }replace的用法不同,它接收一个NativeCallback或者一个 JS 函数作为替换实现。替换后原函数不再执行,你需要自己决定是否调用原函数(通过保存的原始地址用NativeFunction调用)。这个机制适合做“完全接管”的场景,比如绕过某个校验函数。文档里提醒,replace之后原函数的onEnter/onLeave不会再触发,两者不要混用。
还有一个实操要点:Interceptor.attach的回调执行在 Frida 的 JS 线程里,如果回调里做了耗时操作(比如大量字符串拼接、同步 IO),会阻塞目标线程。文档建议回调里尽量只做轻量记录,复杂处理放到send到宿主侧再做。这个坑我在早期脚本里踩过,一个onEnter里做了 JSON 序列化,结果目标应用明显卡顿。
3.3 Java 与 ObjC 桥接层:运行时反射的入口
Java对象是 Frida 在 Android 上做 Java 层 hook 的入口,ObjC对象对应 iOS 的 Objective-C 运行时。这两个桥接层的设计思路类似:提供类查找、方法枚举、实例创建、方法调用、hook 注入的能力。
Java.perform(fn)是最常用的入口,它确保回调在 Java 运行时已加载、且当前线程已附加到 JVM 的条件下执行。所有涉及 Java 类的操作都应该包在Java.perform里。文档里强调,Java.perform的回调里不要做阻塞操作,因为它在目标应用的线程上执行。
Java.use(className)返回一个包装后的类对象,你可以通过它访问静态方法、创建实例、访问字段。Java.choose(className, callbacks)用于枚举堆上已存在的实例,callbacks里包含onMatch和onComplete。Java.enumerateLoadedClasses用于枚举已加载的类,配合Java.enumerateLoadedClassesSync可以同步拿到列表。
方法 hook 的写法是clazz.methodName.implementation = function (...) { ... }。这里有个细节:重载方法需要用overload指定参数类型,比如clazz.method.overload('java.lang.String', 'int').implementation = ...。不指定 overload 时,Frida 会尝试匹配,但有歧义时会报错。文档里对重载的处理有专门说明,这是 Java 层 hook 最常见的卡点之一。
Java.perform(function () { var TargetClass = Java.use("com.example.TargetClass"); TargetClass.checkLicense.overload('java.lang.String').implementation = function (key) { console.log("checkLicense called with: " + key); var result = this.checkLicense(key); console.log("original result: " + result); return true; }; });上面这段代码展示了几个要点:overload指定参数类型、this.checkLicense(key)调用原实现、返回值可以篡改。文档里提醒,调用原实现时如果方法本身会抛异常,你的 hook 里也要处理,否则异常会穿透到调用方。
ObjC侧的用法类似但更动态。ObjC.classes可以访问已注册的类,ObjC.available判断运行时是否可用。方法 hook 用Interceptor.attach配合ObjC.implement或者直接替换method.implementation。iOS 上还要注意ObjC.choose的性能开销,枚举大量实例时可能很慢。
3.4 Memory 与 Module:内存与模块的底层操作
Memory对象提供内存分配、读写、扫描、保护属性修改的能力。Memory.alloc(size)分配一块内存,返回NativePointer;Memory.allocUtf8String(str)分配并写入 UTF-8 字符串;Memory.scan(address, size, pattern, callbacks)在指定范围内搜索字节模式,pattern支持通配符??。
Memory.scan的回调里,onMatch(address, size)在每次匹配时调用,onComplete()在扫描结束时调用。文档提醒,onMatch里如果要做耗时处理,考虑先收集地址再统一处理,避免阻塞扫描线程。另外Memory.scan是异步的,脚本结束前要确保扫描完成,否则可能拿不到结果。
Memory.protect(address, size, protection)用于修改内存页的保护属性,protection是'r--'、'rw-'、'r-x'这样的字符串。这个操作有风险,改错属性可能导致目标进程崩溃,文档里明确建议只在必要时使用,并且改完要恢复。
Module对象用于查询模块信息。Module.enumerateModules()返回模块列表,每个模块有name、base、size、path。Module.findBaseAddress(name)返回基址,Module.findExportByName(moduleName, exportName)返回导出符号地址。Module.enumerateExports(moduleName, callbacks)枚举导出符号,Module.enumerateImports枚举导入符号。
这里有个实用技巧:findExportByName的第一个参数传null时,会在所有模块里搜索该符号。这在你不确定符号属于哪个模块时很有用,但性能较差,文档建议明确模块名时优先传模块名。
// 枚举模块并打印基址 Process.enumerateModules().forEach(function (m) { console.log(m.name + " @ " + m.base + " size=" + m.size); }); // 在指定模块内搜索特征字节 var base = Module.findBaseAddress("libtarget.so"); if (base !== null) { Memory.scan(base, 0x10000, "48 8B ?? ?? 89", { onMatch: function (address, size) { console.log("pattern found at " + address); }, onComplete: function () { console.log("scan done"); } }); }3.5 Process、Thread 与 Stalker:运行时控制
Process对象提供进程级信息和控制。Process.id是当前进程 ID,Process.arch是架构字符串(如'arm64'、'x64'),Process.pointerSize是指针宽度(4 或 8)。Process.enumerateThreads()枚举线程,每个线程有id、state、context。Process.getCurrentThreadId()拿当前线程 ID。
Thread对象用于线程级操作,Thread.backtrace(context, backtracer)获取调用栈,backtracer可以是Backtracer.ACCURATE或Backtracer.FUZZY。Thread.sleep(seconds)让当前线程休眠,这个在需要等待某个条件时有用,但文档提醒不要在Interceptor回调里长时间 sleep。
Stalker是 Frida 的指令级追踪引擎,Stalker.follow(threadId, options)开始追踪指定线程,Stalker.unfollow(threadId)停止。options里可以配置events(如call、ret、exec)、onReceive回调等。Stalker 的开销很大,文档明确说只适合短时间、小范围的追踪,长时间开启会让目标进程慢到不可用。
Process.setExceptionHandler(handler)用于设置异常处理器,handler接收一个details对象,包含异常类型、地址、上下文。这个在分析崩溃或反调试逻辑时有用,但文档提醒异常处理器的行为受平台限制,不是所有异常都能捕获。
4. 实操过程与典型场景实现
4.1 环境准备与脚本加载方式
动手写脚本之前,得先把运行环境理清楚。Frida 的脚本加载方式主要有几种:命令行frida -U -f packageName -l script.js、frida-trace做快速追踪、Python 绑定frida.attach()后create_script加载、以及通过frida-server配合远程连接。这套中文文档在环境部分讲得比较克制,重点放在“脚本怎么写”而不是“环境怎么搭”,因为环境搭建的细节受目标平台影响很大。
我个人的习惯是,开发阶段用frida -U -f直接加载脚本,改一行存一次,命令行里立刻能看到console.log输出。调试稳定后再用 Python 绑定做自动化。文档里对console.log的输出格式有说明——它会把参数转成字符串打印,对象会被序列化,但NativePointer会打印成0x...形式,这个在调试时很方便。
脚本里如果要用到send和recv,需要注意send的数据会被序列化成 JSON,NativePointer不能直接序列化,要先.toString()。recv用于接收宿主侧发来的消息,配合recv(type, callback)使用。文档里给了一个双向通信的最小示例,展示了脚本侧和宿主侧怎么配合。
4.2 一个完整的 hook 脚本拆解
拿一个典型的场景来说:目标函数是一个 native 导出函数,接收两个 int 参数,返回一个 int,我们想记录每次调用的入参和返回值,并且在特定入参时修改返回值。
第一步,定位函数地址。用Module.findExportByName拿地址,判空。第二步,Interceptor.attach,在onEnter里读args[0]和args[1],用toInt32()转成 JS 数字。第三步,在onLeave里读retval,判断条件后修改retval的值。注意retval是NativePointer类型,修改时用retval.replace(ptr(0))或者直接赋值。
var target = Module.findExportByName("libtarget.so", "compute"); if (target === null) { console.log("compute not found"); } else { Interceptor.attach(target, { onEnter: function (args) { this.a = args[0].toInt32(); this.b = args[1].toInt32(); console.log("compute(" + this.a + ", " + this.b + ")"); }, onLeave: function (retval) { var original = retval.toInt32(); console.log("original return: " + original); if (this.a === 0 && this.b === 0) { retval.replace(ptr(1)); console.log("return value overridden to 1"); } } }); }这段代码里有个细节值得说:this.a和this.b是在onEnter里存到this上的,onLeave里通过this访问。Interceptor保证同一次调用的onEnter和onLeave共享同一个this上下文,所以这种传值是安全的。文档里专门强调了这一点,因为很多人担心并发调用时上下文会串,实际上 Frida 为每次调用维护了独立的上下文。
retval.replace(ptr(1))是修改返回值的标准写法。直接retval = ptr(1)不会生效,因为retval是回调参数,重新赋值只改了局部变量。必须用replace方法。这个坑文档里用加粗标出来了,我当初就是直接赋值,调了半天没效果。
4.3 Java 层 hook 的完整流程
Android 上的 Java 层 hook,流程稍微长一些。先Java.perform包住所有操作,然后Java.use拿到类,再定位方法,用overload指定签名,最后替换implementation。
Java.perform(function () { var Utils = Java.use("com.example.app.Utils"); var overloads = Utils.verifyToken.overloads; console.log("verifyToken has " + overloads.length + " overloads"); overloads.forEach(function (o) { console.log(" " + o.argumentTypes.join(", ")); }); Utils.verifyToken.overload('java.lang.String').implementation = function (token) { console.log("verifyToken input: " + token); var result = this.verifyToken(token); console.log("verifyToken result: " + result); return true; }; });这段代码先枚举了verifyToken的所有重载,打印参数类型,然后再针对String参数的那个重载做 hook。枚举重载这一步很关键,因为如果类里有多个同名方法,不指定overload直接写implementation会报“ambiguous”错误。文档里把这个排查步骤写进了示例,而不是只给最终代码,我觉得这个处理很好——它教的是方法,不是答案。
this.verifyToken(token)调用原实现时,如果原方法内部又调用了其他被 hook 的方法,会正常触发那些 hook,不会死循环(除非你自己 hook 了自己)。文档提醒,如果原方法会抛异常,你的 hook 里要 try-catch,否则异常会传播到调用方,可能导致应用崩溃。
4.4 内存扫描与特征定位的实操
做 native 层分析时,经常需要在内存里搜索特征字节序列。Memory.scan是主力工具,但用的时候有几个参数要算清楚。起始地址和大小决定了扫描范围,通常用模块基址加模块大小。特征模式用十六进制字节加??通配符表示。
var mod = Process.findModuleByName("libtarget.so"); if (mod !== null) { var pattern = "48 8D 05 ?? ?? ?? ?? 48 89 05"; Memory.scan(mod.base, mod.size, pattern, { onMatch: function (address, size) { console.log("match at " + address + " (offset " + address.sub(mod.base) + ")"); }, onComplete: function () { console.log("scan complete"); } }); }这里address.sub(mod.base)算出的是相对偏移,这个偏移在不同运行实例里是稳定的(只要模块版本一致),比绝对地址更有参考价值。文档里建议把匹配结果连同偏移一起记录,方便后续复现。
Memory.scan是异步的,如果脚本在扫描完成前就结束了,onComplete可能不触发。文档提醒在脚本末尾加一个保持存活的机制,比如setInterval空转,或者用recv等待宿主侧指令。这个细节在实际调试时很重要,我遇到过脚本跑完没输出,以为是没匹配到,其实是脚本提前退出了。
4.5 参数计算与类型转换的实操记录
Frida 脚本里大量涉及类型转换,这里把常见的转换场景整理一下。NativePointer转数字用toInt32()、toUInt32()、toString();数字转NativePointer用ptr(n);字符串转NativePointer用ptr("0x1234");NativePointer读字符串用readCString()或readUtf8String()。
| 场景 | 写法 | 注意事项 |
|---|---|---|
| 地址转 32 位整数 | p.toInt32() | 超出范围会截断 |
| 地址转字符串 | p.toString() | 输出0x前缀 |
| 数字转地址 | ptr(0x1234) | 大数用字符串形式 |
| 读 C 字符串 | p.readCString() | 遇\0截止 |
| 读 UTF-8 字符串 | p.readUtf8String(len) | 需指定长度 |
| 读指针 | p.readPointer() | 返回NativePointer |
| 写 32 位整数 | p.writeU32(v) | 注意字节序 |
字节序是个容易忽略的点。readU32默认按当前架构的字节序读,ARM 和 x86 都是小端,所以通常不用管。但如果读的是网络字节序的数据,就得手动转换,用ntohl之类的逻辑或者逐字节读再拼。文档里对字节序的说明不多,因为大多数场景下默认行为就是对的,但做协议解析时要注意。
5. 常见问题与排查技巧实录
5.1 脚本加载后没有任何输出
这是最高频的问题。原因通常有几个:脚本语法错误导致加载失败、Java.perform的回调没执行(Java 运行时还没准备好)、目标函数地址没找到导致attach没执行、或者console.log的输出被宿主侧吞了。
排查顺序建议这样:先在脚本最外层写一行console.log("script loaded"),确认脚本本身被加载了。如果这行没输出,说明加载环节有问题,检查语法和加载命令。如果有输出但后续没有,检查Java.perform是否执行——可以在回调第一行加日志。如果Java.perform没执行,可能是 hook 时机太早,改用Java.performNow或者延迟加载。如果地址没找到,把findExportByName的返回值打印出来确认。
提示:
frida -U -f启动时加--no-pause可以避免应用在启动时暂停,但有些应用需要在特定时机注入,这时候反而要保留暂停,等应用起来后再%resume。
5.2 Interceptor.attach 报错 “unable to intercept function”
这个错误通常意味着目标地址不是一个有效的函数入口,或者该地址的内存不可执行。常见原因:地址算错了(比如把数据地址当函数地址)、目标函数被内联优化掉了(编译器把函数体直接展开,没有独立入口)、或者地址位于只读数据段。
排查方法:先用Process.findModuleByAddress确认地址属于哪个模块,再用Module.enumerateExports确认该地址确实对应一个导出符号。如果是内联函数,Interceptor确实无能为力,得换思路,比如 hook 调用方或者用Stalker追踪。文档里对“哪些函数可以被 attach”有说明,核心是函数必须有独立的代码入口。
5.3 Java.use 找不到类
Java.use抛 “class not found” 时,先确认类名拼写和包名完整。然后确认类是否已加载——有些类是在运行时动态加载的,脚本执行时还没加载。可以用Java.enumerateLoadedClasses搜索类名片段,确认类是否存在。如果类确实没加载,用Java.perform配合延迟,或者 hook 类加载器。
另一个常见原因是混淆。类名被混淆后,你看到的源码类名和运行时类名不一致。这时候得用enumerateLoadedClasses把实际类名列出来,找特征匹配。文档里给了一个按包名前缀过滤的示例,很实用。
5.4 修改返回值不生效
前面提过,retval必须用replace修改。但还有一种情况:函数返回值是通过寄存器传递的,onLeave里改retval确实能改寄存器,但如果调用方在onLeave之后又覆盖了寄存器,修改就丢了。这种情况少见,但在某些优化过的代码里会出现。排查方法是确认onLeave确实执行了(加日志),以及修改后的值在调用方那里是否被读取。
Java 层的返回值修改相对可靠,因为implementation里return的值就是最终返回值。但要注意,如果原方法返回的是对象,你返回一个类型不匹配的值,可能在调用方触发ClassCastException。
5.5 脚本导致目标应用崩溃
崩溃的原因很多,常见的有:在onEnter里访问了无效的args索引(比如函数实际只接收 1 个参数,你读了args[1])、readU32读了一个无效地址、Memory.protect改了不该改的页、或者在回调里做了重入操作。
排查时先缩小范围:把 hook 逻辑简化到只打印地址,确认 attach 本身不崩;然后逐步加读取操作,定位到具体哪一行崩。文档里建议在onEnter里对args的访问加边界判断,虽然 Frida 不强制,但养成习惯能减少很多莫名其妙的崩溃。
| 问题现象 | 可能原因 | 排查动作 |
|---|---|---|
| 脚本无输出 | 语法错误/未加载 | 最外层加日志 |
| attach 报错 | 地址无效/内联 | 确认导出符号 |
| 类找不到 | 未加载/混淆 | 枚举已加载类 |
| 返回值不改 | 未用 replace | 检查赋值方式 |
| 应用崩溃 | 无效内存访问 | 逐步简化 hook |
| 回调不触发 | 时机太早 | 延迟或换注入点 |
5.6 性能问题的定位与缓解
Frida 脚本拖慢目标应用,通常是因为 hook 的回调太重。Interceptor的回调在目标线程上同步执行,回调里每多一行代码,目标函数就多一分延迟。如果 hook 的是高频调用的函数(比如渲染循环里的函数),哪怕只加一个console.log,都可能让帧率掉一半。
缓解办法:回调里只做最轻量的记录,比如把关键值存到一个数组里,定期批量send出去;或者用Memory.alloc分配一块缓冲区,回调里只写内存,脚本侧异步读取。文档里对性能优化的建议比较务实——先确认瓶颈在 hook 回调,再考虑减少回调里的操作,而不是一上来就上复杂方案。
Stalker的性能问题更严重,它是指令级追踪,开销可能是几十倍。文档明确说Stalker只适合短时间分析,比如追踪某个函数内部的执行流,追完立刻unfollow。长时间开着Stalker,目标应用基本没法正常用。
5.7 跨版本兼容的排查思路
同一个脚本在 A 环境能跑,在 B 环境报错,大概率是 Frida 版本差异。排查时先确认两边的 Frida 版本(脚本里可以用Frida.version打印),然后对照文档里标注的版本差异。常见的变动点:Module.findExportByName的参数顺序、Java.perform的可用性、Stalker的 options 字段、NativePointer某些方法的引入时间。
如果目标环境版本较旧,某些新 API 不可用,得用旧写法替代。文档里对每个 API 都尽量标注了“引入版本”和“替代方案”,这个信息在跨版本调试时很关键。我个人的做法是,脚本里对关键 API 做特性检测,比如if (typeof Module.findExportByName === 'function'),虽然 Frida 里不常见,但在不确定环境时能避免直接报错。
6. 文档使用与脚本编写的经验心得
这套中文文档我用了挺长时间,最大的体会是:它适合当“字典”用,不适合当“教程”从头读到尾。正确的用法是,你脑子里有一个明确的目标(比如“我要 hook 这个 Java 方法”),然后直接翻到Java章节,看implementation的写法和overload的用法,抄示例改改就能跑。从头读容易陷入 API 细节的海洋,读完还是不知道怎么组合。
另一个心得是关于示例代码的。文档里的示例都是最小化的,目的是展示 API 的用法,不是给你直接用的生产脚本。实际写脚本时,你得在示例基础上加错误处理、加日志、加边界判断。我见过有人直接把文档示例贴进项目里,结果目标函数地址找不到时脚本静默失败,排查了半天。示例是骨架,血肉得自己填。
还有一点,Frida 的 API 虽然多,但常用的就那么十几个。Interceptor.attach、Java.perform、Java.use、Module.findExportByName、NativePointer的读写方法、Memory.scan,这几个覆盖了八成以上的场景。文档里其他 API 可以先扫一眼知道存在,真用到时再细看。别想着一次把所有 API 背下来,用多了自然就熟了。
最后分享一个调试小技巧:脚本里善用console.log的格式化,NativePointer直接打印就是0x形式,数组和对象会被序列化,但NativePointer数组打印出来是一串0x...,可读性不错。如果要在宿主侧看结构化数据,用send({addr: ptr.toString(), value: n}),宿主侧收到的就是干净的 JSON。这个习惯能省很多调试时间。