本篇目标:从零编写你的第一个 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 世界。
Frida 脚本运行模型:JS 引擎与 ART VM 的桥梁
二、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 需要 JNIEnv 指针,而该指针只在 Java.perform 完成线程附加后才可用
// Error: not allowed outside Java.perform
var MyClass = Java.use("com.example.MyClass");
Java.perform(function() {
// 正确:在 Java.perform 内部使用 Java.use
var MyClass = Java.use("com.example.MyClass");
});
原因现在你应该能理解了:Java.use 内部需要调用 JNI 的 FindClass 来查找 Java 类,而 FindClass 需要 JNIEnv 指针,而 JNIEnv 只有在 Java.perform 完成线程附加后才可用。
同样的道理,如果你使用 setTimeout 或 setInterval 来延迟执行代码,延迟回调中的 Java 操作也必须包裹在 Java.perform 中:
// 正确写法 - setTimeout 中也需要 Java.perform
setTimeout(function() {
// setTimeout 的回调运行在 JS 引擎线程上
// 此时需要重新确保线程已附加到 ART VM
Java.perform(function() {
var MyClass = Java.use("com.example.MyClass");
// ... 在这里安全地使用 Java API
});
}, 5000);
常见疑问:「既然第一次 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 初始化完成后再执行 callbackJava.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.use() 类查找流程与 ClassNotFoundException 排查原因一:类名拼写错误。 这是最常见的原因,但也是最容易被忽视的。Java 类名是大小写敏感的,loginManager 和 LoginManager 是不同的类。如果你从 jadx 中复制类名,确保没有多余的空格或不可见字符。
原因二:类尚未被加载。 ART VM 采用「懒加载」策略——只有当代码第一次引用某个类时,才会加载它。如果你用 Spawn 模式启动 App,在 App 初始化的最早期,很多业务类可能还没有被加载。Java.use 会尝试通过 ClassLoader 加载目标类,但如果该类所在的 DEX 文件还没有被壳解密(对于加固 App),或者该类所在的模块还没有被动态加载(对于插件化框架),加载就会失败。
应对方法是使用延迟 Hook——通过定时重试等待类被加载:
Java.perform(function() {
var retryCount = 0;
var maxRetries = 30; // 最多重试 30 次
var retryInterval = 1000; // 每次间隔 1 秒
functiontryHook() {
try {
// 尝试获取目标类的引用
var TargetClass = Java.use("com.example.app.TargetClass");
// 如果走到这里说明类已加载,可以执行 Hook
TargetClass.targetMethod.implementation = function() {
console.log("[*] targetMethod 被调用");
returnthis.targetMethod.apply(this, arguments);
};
console.log("[*] Hook 成功(第 " + (retryCount + 1) + " 次尝试)");
} catch(e) {
retryCount++;
if (retryCount < maxRetries) {
// 类还没加载,等待后重试
// 注意 setTimeout 回调中需要重新 Java.perform
setTimeout(function() {
Java.perform(function() { tryHook(); });
}, retryInterval);
} else {
console.log("[!] " + maxRetries + " 次重试后仍未找到目标类,放弃");
console.log("[!] 错误信息: " + e.message);
}
}
}
tryHook();
});
进阶方案:除了定时重试,还可以 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.perform(function() {
var targetClassName = "com.example.app.EncryptUtils";
// enumerateClassLoaders 会遍历 JVM 中所有已注册的 ClassLoader
Java.enumerateClassLoaders({
onMatch: function(loader) {
try {
// ClassFactory.get(loader) 创建一个绑定到指定 ClassLoader 的工厂
// factory.use() 行为和 Java.use() 一样,但通过指定的 ClassLoader 查找类
var factory = Java.ClassFactory.get(loader);
var clazz = factory.use(targetClassName);
console.log("[*] 找到目标类!ClassLoader: " + loader);
// 重要:后续所有 Hook 都必须通过这个 factory 来操作
// 因为目标类绑定在这个 ClassLoader 中
clazz.encrypt.implementation = function(data) {
console.log("[*] encrypt 被调用: " + data);
returnthis.encrypt(data);
};
} catch(e) {
// 这个 ClassLoader 中没有目标类,继续尝试下一个
// 这是正常情况,不需要打印错误
}
},
onComplete: function() {
console.log("[*] ClassLoader 枚举完成");
}
});
});
关于 Java.classFactory vs Java.ClassFactory 的大小写:这两个并存、用途不同,不是版本差异:
Java.classFactory(小写、单数)—— 当前默认工厂的实例,对应 App 的主 ClassLoader。Java.use("...") 内部使用的就是它。Java.ClassFactory(大写、首字母大写)—— 类本身,提供静态方法 Java.ClassFactory.get(loader),可以构造一个绑定到指定 ClassLoader 的新工厂实例。
上面例子里枚举到目标 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.perform(function() {
// 枚举所有已加载的类,搜索包含关键词的类名
Java.enumerateLoadedClasses({
onMatch: function(className) {
// 用关键词过滤,比如搜索包含 "Encrypt" 或 "Cipher" 的类
if (className.indexOf("Encrypt") !== -1 ||
className.indexOf("encrypt") !== -1) {
console.log("[*] 找到类: " + className);
}
},
onComplete: function() {
console.log("[*] 类枚举完成");
}
});
});
性能警告:Java.enumerateLoadedClasses 会遍历 ART VM 中所有已加载的类(通常有数千甚至上万个),执行时间可能达到几秒。不要在生产脚本中频繁调用它,仅在调试阶段用于定位类名。
四、implementation:替换方法的实现
一旦你通过 Java.use 拿到了类的 Wrapper 对象,就可以通过设置方法的 implementation 属性来替换方法的实现。这是 Frida Java Hook 的核心操作。
implementation 工作原理:方法调用拦截4.1 基本语法和语义
Java.perform(function() {
// 获取目标类的 Wrapper 对象
var LoginManager = Java.use("com.example.app.LoginManager");
// 替换 login 方法的实现
// 当 App 中任何代码调用 LoginManager.login() 时,会执行下面的函数
LoginManager.login.implementation = function(username, password) {
// --- 在这里你可以做三件事 ---
// 1. 查看参数:打印传入的用户名和密码
console.log("[*] 用户名: " + username);
console.log("[*] 密码: " + password);
// 2. 调用原始方法:通过 this 调用未被修改的原始 login 方法
// this 指向当前的 Java 对象实例
var result = this.login(username, password);
// 3. 查看或修改返回值
console.log("[*] 登录结果: " + result);
return result; // 将结果原样返回给调用者
};
});
这段代码的含义是:每当 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 的方法,还可以调用该对象的任何其他方法或访问其字段:
LoginManager.login.implementation = function(username, password) {
// 通过 this 调用该对象的其他方法
// 这就像在 Java 中写 this.getToken() 一样
var token = this.getToken();
var userId = this.getUserId();
console.log("[*] 当前 token: " + token);
console.log("[*] 当前 userId: " + userId);
// 通过 this 读取该对象的字段
// 注意:访问字段必须用 .value 属性
console.log("[*] isVip: " + this.isVip.value);
console.log("[*] apiUrl: " + this.apiUrl.value);
// 甚至可以修改字段值!
// this.isVip.value = true; // 将 isVip 改为 true
// 调用原始的 login 方法
returnthis.login(username, password);
};
注意访问字段时用的是 .value 而不是直接访问。这是 Frida 的一个设计约定——字段名指向一个描述符对象(Field Descriptor),.value 才是字段的实际值。这个设计的原因是 Frida 需要区分「访问字段描述符」和「读取字段值」这两种操作。同样的,如果你要修改字段值:this.isVip.value = true;。
易错点汇总:
this.fieldName → 获取字段描述符(不是值!)this.fieldName.value → 读取字段值this.fieldName.value = newValue → 写入字段值this.methodName → 获取方法描述符(用于设置 implementation 或检查 overloads)
把上面 5 条规则按"字段 vs 方法 × 描述符 vs 值/调用"两个维度排开,就是下面这张速查图——卡住时回来翻一眼即可:
Frida Java Wrapper 访问规则速查(字段 / 方法 × 描述符 / 值/调用)4.3 不调用原始方法会发生什么
如果你在 implementation 中不调用原始方法:
LoginManager.login.implementation = function(username, password) {
console.log("[*] 拦截了登录,但不执行原始方法");
// 直接返回 true,跳过真正的登录逻辑
// App 会认为登录成功,但实际上没有发送任何网络请求
returntrue;
};
App 中调用 login 的代码会收到你返回的 true,就好像登录成功了一样,但实际上没有任何网络请求被发出。这在某些场景下很有用——比如你想跳过登录验证,或者想在 App 发起网络请求前拦截并取消它。
但要小心:如果原始方法有副作用(Side Effect,比如初始化了某个全局状态、写入了数据库、启动了某个后台服务),跳过它可能会导致 App 后续的行为异常甚至崩溃。在绝大多数逆向分析场景中,你应该始终调用原始方法,只是在调用前后插入你的观察逻辑。
经验法则:只有在你完全理解原始方法的所有副作用、并且有明确理由跳过它时,才省略 this.login() 的调用。在分析阶段,永远调用原始方法——你的目标是「观察」,而不是「修改」App 的行为。
4.4 返回值类型必须匹配
implementation 的返回值类型必须与原始方法的返回类型一致。如果原始方法返回 boolean,你就必须返回一个布尔值;如果返回 String,你就必须返回一个字符串或 null;如果方法没有返回值(void),你不需要写 return 语句。
如果你返回了错误类型的值,App 在接收到返回值后进行类型检查时就会崩溃——通常表现为一个 ClassCastException 或直接的进程退出(SIGABRT)。
// 原始方法签名:public String encrypt(String input)
EncryptUtils.encrypt.implementation = function(input) {
var result = this.encrypt(input);
// 正确:返回 String 类型,与原始方法签名一致
return result;
// 错误:返回了 int 类型,会导致 ART VM 类型检查失败
// 表现为 App 闪退,logcat 中出现 ClassCastException
// return 123;
};
void 方法的特殊处理:对于返回类型为 void 的方法,你的 implementation 函数不需要(也不应该)有 return 语句。如果你不小心写了 return something,Frida 会忽略返回值,不会导致崩溃,但这不是好习惯。
五、处理方法重载(overload)
Java 允许同名方法有多个版本,只要它们的参数类型不同。这就是方法重载(Method Overloading)。在 Frida 中,如果一个方法有多个重载版本,直接设置 implementation 会报错——Frida 不知道你想 Hook 哪个版本。
5.1 报错和原因
// 假设 Utils.encode 有两个重载版本:
// public String encode(String input)
// public String encode(byte[] input)
// 直接设置 implementation 会报错,因为 Frida 无法确定你要 Hook 哪个版本
Utils.encode.implementation = function(input) { /* ... */ };
// 报错:Error: encode(): has more than one overload,
// use .overload(<signature>) to disambiguate
错误信息说得很清楚:encode 方法有多个重载,你需要用 .overload() 来指定你想 Hook 的是哪一个。
为什么 Frida 不能自动 Hook 所有重载? 因为不同重载的参数数量和类型可能完全不同,一个统一的 implementation 函数无法安全地处理所有情况。Frida 选择让你显式指定,避免因参数不匹配导致的隐性 Bug。
5.2 使用 overload 指定参数类型
.overload() 接收一个或多个字符串参数,每个字符串表示对应位置参数的 Java 类型:
// Hook encode(String) 这个版本
// overload 参数是 Java 类型的完全限定名
Utils.encode.overload("java.lang.String").implementation = function(str) {
console.log("[*] encode(String): " + str);
// 注意:this.encode(str) 调用的也是 String 版本(由 overload 上下文决定)
returnthis.encode(str);
};
// Hook encode(byte[]) 这个版本
// "[B" 是 byte[] 的 JNI 类型签名(详见 5.3 节)
Utils.encode.overload("[B").implementation = function(bytes) {
console.log("[*] encode(byte[]): " + bytes.length + " bytes");
returnthis.encode(bytes);
};
5.3 参数类型的表示方式
overload 中的类型字符串遵循 JNI 类型签名(Type Signature)的规则。这套规则初看有点奇怪,但它有严格的逻辑,值得花时间理解——本质上一个签名只有 3 段:**[ × n(数组维度)+ L...;(对象类型)或 单字母(基本类型)**。下图把 String[][] 的签名 [[Ljava.lang.String; 拆成 3 段,每段对应一个独立角色:
JNI 类型签名三段式拆解对于基本类型(Primitive Types),直接使用 Java 的类型名作为字符串:
int → "int"
long → "long"
boolean → "boolean"
byte → "byte"
char → "char"
float → "float"
double → "double"
short → "short"
对于引用类型(对象),使用完全限定的类名(包名 + 类名):
String → "java.lang.String"
Object → "java.lang.Object"
Context → "android.content.Context"
Bundle → "android.os.Bundle"
List → "java.util.List"
Map → "java.util.Map"
对于数组类型,使用 JNI 的 [ 前缀表示法(最易踩的就这几条,其他形态查上图速查):
byte[] → "[B" (加密接口最常见)
int[] → "[I"
long[] → "[J" (注意:J 表示 long,不是 L!)
String[] → "[Ljava.lang.String;" (L 开头、; 结尾表示对象类型)
只需记住这 4 条最高频的——其他维度组合按 [ × n + 单字母 / L...; 拼即可。
5.4 不确定有哪些重载?打印出来看
一个非常实用的技巧——当你不确定一个方法有哪些重载版本时,可以通过 overloads 属性遍历它们:
Java.perform(function() {
var Utils = Java.use("com.example.app.Utils");
console.log("[*] encode 方法的所有重载版本:");
// overloads 是一个数组,每个元素代表一个重载版本
Utils.encode.overloads.forEach(function(overload, index) {
// argumentTypes: 参数类型数组,每个元素有 name 和 className 属性
var params = overload.argumentTypes.map(function(t) {
return t.name; // 例如 "java.lang.String"、"[B"
});
// returnType: 返回类型,同样有 name 属性
var ret = overload.returnType.name;
console.log(" [" + index + "] " + ret + " encode(" + params.join(", ") + ")");
});
});
假设输出是:
[*] encode 方法的所有重载版本:
[0] java.lang.String encode(java.lang.String)
[1] java.lang.String encode([B)
[2] java.lang.String encode(java.lang.String, java.lang.String)
现在你确切知道有三个重载,以及每个重载的参数类型是什么,写 .overload(...) 就有据可依了。
快速调试技巧:如果你只是想快速确认某个类有哪些方法和重载,可以用这个一行命令在 frida CLI 中执行:
// 在 frida -U -n <app> 的交互式命令行中
Java.perform(function(){ Object.keys(Java.use("com.example.TargetClass").class.getDeclaredMethods()).forEach(function(m){ console.log(m) }) })
5.5 一次性 Hook 所有重载
有时候你不关心具体是哪个重载被调用了,你只想在任何一个版本被调用时都打印日志。这时可以遍历 overloads 数组,为每个重载都设置 implementation:
Java.perform(function() {
var Utils = Java.use("com.example.app.Utils");
// 遍历 encode 的所有重载版本,逐一设置 Hook
Utils.encode.overloads.forEach(function(overload) {
overload.implementation = function() {
// arguments 是 JS 的特殊对象,包含所有传入的参数
// 将参数逐一转换为可读字符串
var args = [];
for (var i = 0; i < arguments.length; i++) {
var arg = arguments[i];
if (arg === null) {
args.push("null");
} elseif (typeof arg === "object" && arg.toString) {
// Java 对象通过 toString() 转换
try { args.push(arg.toString()); }
catch(e) { args.push("[toString 失败]"); }
} else {
args.push(String(arg));
}
}
console.log("[*] encode(" + args.join(", ") + ")");
// 调用原始方法并返回结果
// overload.apply(this, arguments) 等同于 this.encode(arg1, arg2, ...)
// 但它能正确处理任意数量的参数,无需知道具体有几个参数
var result = overload.apply(this, arguments);
console.log("[*] => " + result);
return result;
};
});
console.log("[*] 已 Hook encode 的所有 " + Utils.encode.overloads.length + " 个重载");
});
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 调用堆栈
Java.perform(function() {
var Cipher = Java.use("javax.crypto.Cipher");
// Hook Cipher.doFinal(byte[]) - 这是 AES/DES 等对称加密的最终执行方法
Cipher.doFinal.overload("[B").implementation = function(data) {
console.log("[*] Cipher.doFinal 被调用");
// 获取调用堆栈的核心技巧:
// 创建一个 Exception 对象(不抛出!),利用其构造函数自动捕获当前调用栈
var Exception = Java.use("java.lang.Exception");
var Log = Java.use("android.util.Log");
// $new() 是 Frida 中调用 Java 构造函数的语法
var stackTrace = Log.getStackTraceString(Exception.$new());
console.log("[*] 调用堆栈:\n" + stackTrace);
// 别忘了调用原始方法并返回结果
returnthis.doFinal(data);
};
});
这段代码的原理值得深入解释:Exception.$new() 创建了一个新的 Java Exception 对象。创建 Exception 时,JVM 会调用 Throwable.fillInStackTrace() 方法,自动记录当前线程的完整调用堆栈并存储在 Exception 对象的 stackTrace 字段中。然后 Log.getStackTraceString() 将这个堆栈信息格式化为可读的字符串。我们实际上并没有「抛出」这个异常——只是利用了 Exception 创建时自动捕获堆栈的特性。
为什么不用 Thread.currentThread().getStackTrace()? 也可以用,但 Log.getStackTraceString(Exception.$new()) 的输出格式更友好(直接是缩进好的多行字符串),而且兼容性更好。如果你需要以编程方式逐帧分析堆栈(而非打印),可以使用 Thread.currentThread().getStackTrace(),它返回 StackTraceElement[] 数组。
输出的堆栈信息大致长这样:
[*] 调用堆栈:
java.lang.Exception
at javax.crypto.Cipher.doFinal(Cipher.java:...)
at com.example.app.crypto.AesUtils.encrypt(AesUtils.java:45)
at com.example.app.api.LoginApi.buildLoginRequest(LoginApi.java:32)
at com.example.app.ui.LoginActivity.onLoginClick(LoginActivity.java:78)
at android.view.View.performClick(View.java:...)
...
从下往上读这个堆栈:用户点击了登录按钮(LoginActivity.onLoginClick),触发了登录请求的构建(LoginApi.buildLoginRequest),其中调用了 AES 加密工具类(AesUtils.encrypt),最终执行了 Cipher.doFinal 完成实际加密。
这样你不仅知道了加密参数,还知道了加密操作在业务逻辑中的上下文——这对理解 App 的数据流转至关重要。
6.2 堆栈的高级用法:区分不同的调用路径
在实际逆向中,同一个底层加密方法可能被多个业务调用。你可以用堆栈中的类名来判断当前是哪个业务路径,实现「分类日志」:
Cipher.doFinal.overload("[B").implementation = function(data) {
// 获取堆栈字符串
var stackStr = Java.use("android.util.Log").getStackTraceString(
Java.use("java.lang.Exception").$new()
);
// 根据堆栈中出现的关键类名,判断当前的业务场景
// 这样就能在海量日志中快速定位你关心的那个调用
if (stackStr.indexOf("LoginApi") !== -1) {
console.log("[登录] 加密数据: " + bytesToString(data));
} elseif (stackStr.indexOf("PaymentApi") !== -1) {
console.log("[支付] 加密数据: " + bytesToString(data));
} elseif (stackStr.indexOf("SignUtils") !== -1) {
console.log("[签名] 签名数据: " + bytesToString(data));
} else {
// 未知的调用路径,打印完整堆栈以便进一步分析
console.log("[未知] 加密数据: " + bytesToString(data));
console.log(" 堆栈: " + stackStr.substring(0, 500));
}
returnthis.doFinal(data);
};
分析复杂 App 时,这种过滤能让你避开无关日志的淹没,只盯住关心的那条路径。
6.3 一个优化过的堆栈工具函数
原始的 Log.getStackTraceString 输出中包含很多系统框架的栈帧(android.view.View.performClick、android.os.Handler.dispatchMessage 之类),这些对逆向分析没有价值。你可以写一个过滤函数,只保留 App 自身代码的栈帧:
/**
* 获取过滤后的 App 调用堆栈
* @param {number}maxLines - 最多返回的栈帧数量,默认 6
* @returns {string}过滤后的堆栈字符串
*/
functiongetAppStackTrace(maxLines) {
maxLines = maxLines || 6;
try {
var Exception = Java.use("java.lang.Exception");
var Log = Java.use("android.util.Log");
var stack = Log.getStackTraceString(Exception.$new());
return stack.split("\n")
.filter(function(line) {
// 过滤规则:排除 Android 框架、Java 标准库、Frida 自身的栈帧
// 只保留 App 自身的业务代码
return line.indexOf("android.") === -1 &&
line.indexOf("java.lang.reflect") === -1 &&
line.indexOf("com.android.") === -1 &&
line.indexOf("dalvik.") === -1 &&
line.indexOf("frida") === -1 &&
line.trim().length > 0;
})
.slice(0, maxLines) // 限制输出行数,避免堆栈太长刷屏
.join("\n");
} catch(e) {
return"(获取堆栈失败: " + e.message + ")";
}
}
使用方式:console.log("调用链:\n" + getAppStackTrace(5));。输出会干净很多,只剩下 App 自身的代码路径。
自定义过滤:你可以根据目标 App 的包名来进一步精确过滤。比如只保留以 com.example.app 开头的栈帧:
.filter(function(line) { return line.indexOf("com.example.app") !== -1; })
这样连第三方 SDK 的栈帧都会被过滤掉,只留下 App 核心业务代码的调用链。
总结
| | |
|---|
Java.perform(callback) | | 所有 Java 操作必须在其内部执行;底层通过 JNI AttachCurrentThread 获取 JNIEnv |
Java.use("className") | | 通过 JNI FindClass 查找类;ClassNotFoundException 有四种常见原因 |
method.implementation | | this 指向 Java 对象实例;返回值类型必须匹配;始终调用原始方法 |
.overload("type") | | 多重载必须指定;可遍历 overloads 查看所有版本;apply 实现万能转发 |
| | Exception.$new() 捕获堆栈;按调用路径过滤日志;自定义工具函数 |