type
Post
status
Published
date
May 20, 2026
slug
summary
本篇目标:从零编写你的第一个 Frida Hook 脚本。不只是学会「怎么写」,更要理解每一行代码背后发生了什么——这样当 Hook 不生效、App 崩溃、或者遇到混淆代码时,你才有能力独立排查。
tags
frida
Android
逆向工程
category
公众号
icon
password
网址
上次编辑时间
Jul 7, 2026 09:14 AM
comment
AI 总结
本篇目标:从零编写你的第一个 Frida Hook 脚本。不只是学会「怎么写」,更要理解每一行代码背后发生了什么——这样当 Hook 不生效、App 崩溃、或者遇到混淆代码时,你才有能力独立排查。
一、Frida 脚本的运行模型
第一篇讲过,Frida 会在目标 App 的进程内嵌入一个 JavaScript 引擎(frida-agent 中的 QuickJS)。你编写的 JS 脚本会被发送到这个引擎中执行。这意味着你的代码和 App 的代码运行在同一个进程里——你拥有和 App 自身完全相同的内存访问权限。
但这里有一个容易被忽视的细节:这个 JS 引擎运行在 frida-agent 创建的一个独立线程上,而 App 的 Java 代码运行在 ART 虚拟机管理的线程上。这两个世界是隔离的——JS 引擎本身并不是 ART VM 的一部分。如果你想在 JS 中访问 Java 世界的类和对象,就需要一个「桥梁」来连接这两个世界。
这个桥梁就是
Java.perform()。类比理解:想象一栋大楼里有两间办公室——左边是「JS 引擎办公室」,右边是「ART 虚拟机办公室」。两间办公室在同一栋楼(同一进程)里,共享水电(内存),但各自有独立的门禁系统。
Java.perform() 就是帮你办了一张右边办公室的临时门禁卡(JNIEnv 指针),有了它你才能自由进出 Java 世界。

二、Java.perform():连接 JS 世界和 Java 世界
2.1 它做了什么
当你调用
Java.perform(callback) 时,Frida 在底层执行了以下操作:第一步,通过 JNI(Java Native Interface)的
JavaVM->AttachCurrentThread() 将当前线程(也就是 JS 引擎所在的线程)附加到 ART 虚拟机。这一步完成后,当前线程就拥有了一个 JNIEnv 指针——后续所有 Java API 调用都要通过这个指针来完成。你可以把 JNIEnv 理解为一张「通行证」,没有它,你就无法从 Native 世界调用 Java 世界的任何方法。第二步,在 ART VM 的上下文中执行你传入的
callback 函数。在这个回调内部,你可以安全地使用 Java.use()、Java.choose() 等所有 Java 相关的 Frida API。第三步,
callback 执行完毕后,线程会保持附加状态(不会 detach)。这意味着 Hook 回调在任何时刻被触发时都能正常工作。2.2 为什么所有 Java 操作都必须在 Java.perform 内部
一个初学者经常犯的错误是在
Java.perform 外面直接使用 Java.use:原因现在你应该能理解了:
Java.use 内部需要调用 JNI 的 FindClass 来查找 Java 类,而 FindClass 需要 JNIEnv 指针,而 JNIEnv 只有在 Java.perform 完成线程附加后才可用。同样的道理,如果你使用
setTimeout 或 setInterval 来延迟执行代码,延迟回调中的 Java 操作也必须包裹在 Java.perform 中:常见疑问:「既然第一次
Java.perform 后线程就保持附加了,为什么 setTimeout 里还要再写一次?」好问题。实际上如果线程已经附加过,再次调用 Java.perform 的开销几乎为零——它内部会检测到线程已附加,直接执行 callback。但显式写上 Java.perform 是一个好习惯,因为你无法确定 Frida 内部是否会在某些场景下 detach 线程。防御性编程在逆向工具开发中尤其重要。
2.3 Java.perform 的执行时机
一个自然的问题是:
Java.perform 的 callback 什么时候执行?答案是「立刻执行」——它是同步的。当你的脚本被加载时,Java.perform 会立刻附加线程并执行 callback。如果此时 ART VM 还没有初始化完成(这在极早期的 Spawn 模式下可能发生),Java.perform 会等待 VM 就绪后再执行。把所有 Java Hook 代码放在
Java.perform 的回调函数里面就行了。 Frida 内部已经处理好了同步等待,你不需要手动管时机。2.4 Java.perform 与 Java.performNow 的区别
你可能在一些文档中看到过
Java.performNow(callback),它和 Java.perform 的区别在于:- Java.perform:如果 ART VM 尚未就绪,会等待直到 VM 初始化完成后再执行 callback
- Java.performNow:如果 ART VM 尚未就绪,会立刻抛出异常而不是等待
绝大多数场景下用
Java.perform 就够了——它会自己处理「VM 没就绪就等一下」这种边界情况,使用者不需要操心。那
Java.performNow 什么时候用?当你能 100% 确定 VM 已经就绪、并且这段代码会被高频调用时。这种场景下,与其每次调用都让 Java.perform 走一遍「检查 VM 状态 → 决定是否等待」的判断分支,不如直接用 Java.performNow 跳过这层判断。三、Java.use():获取 Java 类的引用
Java.use 是你在 Frida 中与 Java 世界交互的第一个入口。它的作用是根据类的完全限定名(Fully Qualified Name,如 com.example.app.LoginManager),返回一个 JavaScript Wrapper 对象,通过这个 Wrapper 你可以访问该类的所有方法和字段。3.1 它在底层做了什么
当你写
var LoginManager = Java.use("com.example.app.LoginManager") 时,Frida 在内部完成了以下步骤:首先,将 Java 风格的类名(用
. 分隔)转换为 JNI 风格的类名(用 / 分隔),即 com.example.app.LoginManager 变成 com/example/app/LoginManager。然后,调用 JNI 的
FindClass("com/example/app/LoginManager") 在 ART VM 中查找这个类。如果类已经被加载到内存中,FindClass 会返回对应的 jclass 引用;如果类尚未加载,ART 会通过当前的 ClassLoader 尝试加载它。接着,Frida 创建一个 JavaScript 代理对象(Wrapper),通过 JNI 反射(
GetMethodID、GetFieldID 等)获取该类的所有方法和字段信息,并将它们映射到 Wrapper 的属性上。这样你就可以用 LoginManager.login 来访问 login 方法,用 LoginManager.apiUrl 来访问 apiUrl 字段。性能提示:Java.use()的反射操作有一定开销(尤其是方法和字段数量多的大类)。它用于初始化时获取类引用——在Java.perform回调里调用一次并保存,不要放进热路径里反复调。
3.2 ClassNotFoundException 的常见原因
Java.use 失败并抛出 ClassNotFoundException,是 Frida 脚本开发中遇到频率最高的错误之一。理解它的每一种可能原因,能让你快速定位问题。
原因一:类名拼写错误。 这是最常见的原因,但也是最容易被忽视的。Java 类名是大小写敏感的,
loginManager 和 LoginManager 是不同的类。如果你从 jadx 中复制类名,确保没有多余的空格或不可见字符。原因二:类尚未被加载。 ART VM 采用「懒加载」策略——只有当代码第一次引用某个类时,才会加载它。如果你用 Spawn 模式启动 App,在 App 初始化的最早期,很多业务类可能还没有被加载。
Java.use 会尝试通过 ClassLoader 加载目标类,但如果该类所在的 DEX 文件还没有被壳解密(对于加固 App),或者该类所在的模块还没有被动态加载(对于插件化框架),加载就会失败。应对方法是使用延迟 Hook——通过定时重试等待类被加载:
进阶方案:除了定时重试,还可以 Hook 类加载入口,在目标类被加载的那一刻立刻执行 Hook。Android 8+ 上推荐 Hook
dalvik.system.BaseDexClassLoader.findClass(应用类加载实际经过这里,命中率比顶层 java.lang.ClassLoader.loadClass 更高,后者会被父 loader 委派吞掉),这种方式比盲目重试更精准。
原因三:类由非默认 ClassLoader 加载。 这是最棘手的情况。Android 的类加载机制允许不同的 ClassLoader 加载不同的类集合——加固壳(如梆梆、360加固、腾讯乐固等)通常会创建自己的
DexClassLoader 来加载解密后的 DEX,插件化框架(如 VirtualApk、RePlugin)也会为每个插件创建独立的 ClassLoader。Java.use 默认使用系统的 ClassLoader 查找类,如果目标类在另一个 ClassLoader 中,自然找不到。解决方案是枚举所有 ClassLoader,逐一尝试:
关于
Java.classFactory vs Java.ClassFactory 的大小写:这两个并存、用途不同,不是版本差异:
上面例子里枚举到目标 loader 后用的是 Java.ClassFactory.get(loader)——这是大写版本的标准用法。Java.classFactory.use(...) 与 Java.use(...) 等价。两者在 Frida 14.x 起一直并存,写代码时按"我要默认工厂还是要某个特定 loader 上的工厂"做选择即可。
原因四:混淆后类名不一致。 ProGuard 或 R8 混淆会将类名从
com.example.app.LoginManager 重命名为 a.b.c 之类的短名字。你在 jadx 中看到的类名是反编译器尝试还原后的名字,有时候与运行时的实际类名不同(特别是对于内部类和匿名类)。这种情况下,你需要通过枚举已加载的类来确认实际类名:性能警告:
Java.enumerateLoadedClasses 会遍历 ART VM 中所有已加载的类(通常有数千甚至上万个),执行时间可能达到几秒。不要在生产脚本中频繁调用它,仅在调试阶段用于定位类名。
四、implementation:替换方法的实现
一旦你通过
Java.use 拿到了类的 Wrapper 对象,就可以通过设置方法的 implementation 属性来替换方法的实现。这是 Frida Java Hook 的核心操作。
4.1 基本语法和语义
这段代码的含义是:每当 App 中的任何代码调用
LoginManager 实例的 login 方法时,真正被执行的不再是原始的 login 方法,而是你提供的这个 JavaScript 函数。在这个函数中,你可以查看参数(username 和 password)、调用原始方法(this.login(username, password))、查看返回值(result)——甚至可以修改参数后再传给原始方法,或者直接返回一个伪造的返回值。底层原理:设置
implementation 时,Frida 实际上修改了 ART VM 中该方法对应的 ArtMethod 结构体的 entry_point_from_quick_compiled_code_ 字段(第一篇有详细介绍),将其指向 Frida 的跳板函数(trampoline)。当 App 调用该方法时,执行流会先进入跳板函数,跳板函数将调用上下文传递给 JS 引擎,执行你的 implementation 回调,然后将返回值传回 Java 世界。
4.2 this 关键字的含义
在
implementation 回调中,this 是一个指向「被 Hook 方法所属的 Java 对象实例」的 Wrapper。对于实例方法(非 static),this 相当于 Java 中的 this 关键字;对于静态方法,this 指向类本身。this 的强大之处在于,你不仅可以通过它调用被 Hook 的方法,还可以调用该对象的任何其他方法或访问其字段:注意访问字段时用的是
.value 而不是直接访问。这是 Frida 的一个设计约定——字段名指向一个描述符对象(Field Descriptor),.value 才是字段的实际值。这个设计的原因是 Frida 需要区分「访问字段描述符」和「读取字段值」这两种操作。同样的,如果你要修改字段值:this.isVip.value = true;。易错点汇总:
把上面 5 条规则按"字段 vs 方法 × 描述符 vs 值/调用"两个维度排开,就是下面这张速查图——卡住时回来翻一眼即可:

4.3 不调用原始方法会发生什么
如果你在
implementation 中不调用原始方法:App 中调用
login 的代码会收到你返回的 true,就好像登录成功了一样,但实际上没有任何网络请求被发出。这在某些场景下很有用——比如你想跳过登录验证,或者想在 App 发起网络请求前拦截并取消它。但要小心:如果原始方法有副作用(Side Effect,比如初始化了某个全局状态、写入了数据库、启动了某个后台服务),跳过它可能会导致 App 后续的行为异常甚至崩溃。在绝大多数逆向分析场景中,你应该始终调用原始方法,只是在调用前后插入你的观察逻辑。
经验法则:只有在你完全理解原始方法的所有副作用、并且有明确理由跳过它时,才省略this.login()的调用。在分析阶段,永远调用原始方法——你的目标是「观察」,而不是「修改」App 的行为。
4.4 返回值类型必须匹配
implementation 的返回值类型必须与原始方法的返回类型一致。如果原始方法返回 boolean,你就必须返回一个布尔值;如果返回 String,你就必须返回一个字符串或 null;如果方法没有返回值(void),你不需要写 return 语句。如果你返回了错误类型的值,App 在接收到返回值后进行类型检查时就会崩溃——通常表现为一个
ClassCastException 或直接的进程退出(SIGABRT)。void 方法的特殊处理:对于返回类型为
void 的方法,你的 implementation 函数不需要(也不应该)有 return 语句。如果你不小心写了 return something,Frida 会忽略返回值,不会导致崩溃,但这不是好习惯。
五、处理方法重载(overload)
Java 允许同名方法有多个版本,只要它们的参数类型不同。这就是方法重载(Method Overloading)。在 Frida 中,如果一个方法有多个重载版本,直接设置
implementation 会报错——Frida 不知道你想 Hook 哪个版本。5.1 报错和原因
错误信息说得很清楚:
encode 方法有多个重载,你需要用 .overload() 来指定你想 Hook 的是哪一个。为什么 Frida 不能自动 Hook 所有重载? 因为不同重载的参数数量和类型可能完全不同,一个统一的
implementation 函数无法安全地处理所有情况。Frida 选择让你显式指定,避免因参数不匹配导致的隐性 Bug。
5.2 使用 overload 指定参数类型
.overload() 接收一个或多个字符串参数,每个字符串表示对应位置参数的 Java 类型:5.3 参数类型的表示方式
overload 中的类型字符串遵循 JNI 类型签名(Type Signature)的规则。这套规则初看有点奇怪,但它有严格的逻辑,值得花时间理解——本质上一个签名只有 3 段:**
[ × n(数组维度)+ L...;(对象类型)或 单字母(基本类型)**。下图把 String[][] 的签名 [[Ljava.lang.String; 拆成 3 段,每段对应一个独立角色:
对于基本类型(Primitive Types),直接使用 Java 的类型名作为字符串:
对于引用类型(对象),使用完全限定的类名(包名 + 类名):
对于数组类型,使用 JNI 的
[ 前缀表示法(最易踩的就这几条,其他形态查上图速查):只需记住这 4 条最高频的——其他维度组合按
[ × n + 单字母 / L...; 拼即可。5.4 不确定有哪些重载?打印出来看
一个非常实用的技巧——当你不确定一个方法有哪些重载版本时,可以通过
overloads 属性遍历它们:假设输出是:
现在你确切知道有三个重载,以及每个重载的参数类型是什么,写
.overload(...) 就有据可依了。快速调试技巧:如果你只是想快速确认某个类有哪些方法和重载,可以用这个一行命令在 frida CLI 中执行:
5.5 一次性 Hook 所有重载
有时候你不关心具体是哪个重载被调用了,你只想在任何一个版本被调用时都打印日志。这时可以遍历
overloads 数组,为每个重载都设置 implementation:apply vs 直接调用:
overload.apply(this, arguments) 和 this.encode(arg1, arg2) 的区别在于:后者要求你明确写出参数列表(数量必须匹配),而前者可以将不定数量的参数原样传递给原始方法。当你在 forEach 循环中为所有重载设置统一的 implementation 时,每个重载的参数数量可能不同(encode(String) 有1个参数,encode(String, String) 有2个参数),用 apply 就能优雅地处理这个差异。这是一种通用的 Hook 所有重载的「万能写法」。
六、调用堆栈:定位「是谁调用了这个方法」
拦截到一个方法的参数和返回值只是第一步。在实际逆向中,你经常需要回答一个更深层的问题:这个方法是从哪里被调用的?
比如你 Hook 了
javax.crypto.Cipher.doFinal,看到了加密操作。但 doFinal 可能被 App 的多个业务模块调用——登录、支付、签名校验等。你需要知道这次加密是哪个业务触发的,才能针对性地分析。调用堆栈(Stack Trace)就是答案。
6.1 打印 Java 调用堆栈
这段代码的原理值得深入解释:
Exception.$new() 创建了一个新的 Java Exception 对象。创建 Exception 时,JVM 会调用 Throwable.fillInStackTrace() 方法,自动记录当前线程的完整调用堆栈并存储在 Exception 对象的 stackTrace 字段中。然后 Log.getStackTraceString() 将这个堆栈信息格式化为可读的字符串。我们实际上并没有「抛出」这个异常——只是利用了 Exception 创建时自动捕获堆栈的特性。为什么不用
Thread.currentThread().getStackTrace()? 也可以用,但 Log.getStackTraceString(Exception.$new()) 的输出格式更友好(直接是缩进好的多行字符串),而且兼容性更好。如果你需要以编程方式逐帧分析堆栈(而非打印),可以使用 Thread.currentThread().getStackTrace(),它返回 StackTraceElement[] 数组。
输出的堆栈信息大致长这样:
从下往上读这个堆栈:用户点击了登录按钮(
LoginActivity.onLoginClick),触发了登录请求的构建(LoginApi.buildLoginRequest),其中调用了 AES 加密工具类(AesUtils.encrypt),最终执行了 Cipher.doFinal 完成实际加密。这样你不仅知道了加密参数,还知道了加密操作在业务逻辑中的上下文——这对理解 App 的数据流转至关重要。
6.2 堆栈的高级用法:区分不同的调用路径
在实际逆向中,同一个底层加密方法可能被多个业务调用。你可以用堆栈中的类名来判断当前是哪个业务路径,实现「分类日志」:
分析复杂 App 时,这种过滤能让你避开无关日志的淹没,只盯住关心的那条路径。
6.3 一个优化过的堆栈工具函数
原始的
Log.getStackTraceString 输出中包含很多系统框架的栈帧(android.view.View.performClick、android.os.Handler.dispatchMessage 之类),这些对逆向分析没有价值。你可以写一个过滤函数,只保留 App 自身代码的栈帧:使用方式:
console.log("调用链:\n" + getAppStackTrace(5));。输出会干净很多,只剩下 App 自身的代码路径。自定义过滤:你可以根据目标 App 的包名来进一步精确过滤。比如只保留以
com.example.app 开头的栈帧:
这样连第三方 SDK 的栈帧都会被过滤掉,只留下 App 核心业务代码的调用链。
总结
API / 技巧 | 作用 | 关键要点 |
Java.perform(callback) | 连接 JS 世界与 Java 世界 | 所有 Java 操作必须在其内部执行;底层通过 JNI AttachCurrentThread 获取 JNIEnv |
Java.use("className") | 获取 Java 类的引用 | 通过 JNI FindClass 查找类;ClassNotFoundException 有四种常见原因 |
method.implementation | 替换方法实现 | this 指向 Java 对象实例;返回值类型必须匹配;始终调用原始方法 |
.overload("type") | 指定重载版本 | 多重载必须指定;可遍历 overloads 查看所有版本;apply 实现万能转发 |
调用堆栈 | 定位调用来源 | Exception.$new() 捕获堆栈;按调用路径过滤日志;自定义工具函数 |
- Author:24th
- URL:https://24th.top/article/396e5b08-46db-80a9-a0f2-efd5938e4c3b
- Copyright:All articles in this blog, except for special statements, adopt BY-NC-SA agreement. Please indicate the source!









