本篇目标:系统掌握 Frida 提供的全部 Java 层 API。第三、四篇教会了你用 Java.use + implementation 做被动拦截(别人调用时你看一眼),但 Frida 的能力远不止于此——你可以主动调用 App 的任意方法、创建 Java 对象、注册全新的 Java 类、在多个 ClassLoader 之间切换、处理 Java 与 JavaScript 之间的类型转换。这些能力组合起来,才构成了完整的"在别人的进程里执行你的代码"的工具箱。
一、Java 桥接 API 全景图
在写具体代码之前,先建立一个全局视图。Frida 的 Java 命名空间下大约有 20 多个 API,按功能可以分为六组:
Frida Java API 全景图 | | |
|---|
| 环境控制 | Java.available | 当前进程是否有 Java 运行时(ART/Dalvik) |
| Java.androidVersion | |
| Java.perform(fn) | |
| Java.performNow(fn) | |
| 类操作 | Java.use(className) | |
| Java.cast(handle, klass) | 将一个 Java 对象转换为指定类型的 Wrapper |
| Java.array(type, values) | |
| Java.registerClass(spec) | |
| 堆搜索 | Java.choose(className, callbacks) | |
| Java.retain(obj) | 防止 Java 对象被 GC 回收(创建全局引用) |
| 类加载器 | Java.enumerateLoadedClasses(callbacks) | |
| Java.enumerateLoadedClassesSync() | |
| Java.enumerateClassLoaders(callbacks) | |
| Java.enumerateClassLoadersSync() | |
| Java.classFactory | 当前使用的 ClassFactory(绑定到特定 ClassLoader) |
| 调度 | Java.scheduleOnMainThread(fn) | |
| Java.isMainThread() | |
| 高级 | Java.openClassFile(filePath) | |
| Java.deoptimizeEverything() | 强制 ART 关闭所有 JIT 编译,回退到解释执行 |
| Java.deoptimizeBootImage() | |
| Java.vm | 底层 JavaVM 对象,可直接调用 JNI 函数 |
第03-04篇已经深入讲过 Java.perform、Java.use(基础用法)、Java.choose(基础用法)、Java.enumerateLoadedClasses。本篇不重复这些基础,而是聚焦于:主动调用、ClassLoader 切换、Java.cast / Java.array / Java.registerClass 这些进阶能力,以及贯穿整个逆向工作流的类型转换和堆栈打印技巧。
二、主动调用:不只是旁观,而是亲自动手
2.1 被动 Hook vs 主动调用
前两篇教的都是「被动拦截」——你设置一个 Hook,然后等 App 自己触发那个方法,你才能看到参数和返回值。这就像在公路上装了一个摄像头:车来了你能看到,车不来你就只能等。
主动调用则完全不同——你可以直接从 Frida 脚本中调用 App 的任意 Java 方法,不需要等 App 自己触发。
下图把两种模式画在同一时间轴上:被动模式是 App 在主线程触发 → Frida 在 JS 线程被动接到回调(红色数据流方向 App→Frida);主动模式是 App 闲置不动 → Frida 在 JS 线程主动发起 JNI 调用(绿色数据流方向 Frida→App)。同样要拿一个 token,被动模式可能要等用户点击登录按钮才能触发,主动模式两秒就能拿到。
被动 Hook vs 主动调用 时序对比这在实际逆向中极其有用。几个典型场景:
场景一:验证你的理解。 你通过 Hook 观察到 EncryptUtils.encrypt("hello", "key123") 返回了 "a1b2c3"。为了确认你理解了参数格式,你想主动调用一次:传入 "test" 和 "key123",看看输出是什么。如果输出符合你的预期(比如 AES-CBC 的特征),说明你的理解正确。
场景二:提取动态数据。 很多 App 有一个 getToken() 或 getDeviceId() 方法,返回运行时动态生成的值。你不需要等 App 自己调用这个方法——直接主动调用,立刻拿到结果。
场景三:触发隐藏功能。 有些 App 内部有调试方法或管理员功能,正常使用流程中不会被触发。通过主动调用,你可以直接执行这些方法。
2.2 调用静态方法
静态方法是最简单的主动调用——因为不需要对象实例,直接通过类 Wrapper 调用即可。
// active_call_static.js
// 主动调用 App 的静态方法
Java.perform(function() {
// 假设 App 有一个工具类,提供静态的加密方法
var EncryptUtils = Java.use("com.example.app.crypto.EncryptUtils");
// 直接调用静态方法,就像在 Java 中调用 EncryptUtils.md5("hello") 一样
var hash = EncryptUtils.md5("hello");
console.log("[主动调用] md5('hello') = " + hash);
// 调用有多个参数的静态方法
var encrypted = EncryptUtils.aesEncrypt("plaintext", "secretkey");
console.log("[主动调用] aesEncrypt result = " + encrypted);
// 如果方法有重载,需要用 overload 指定参数类型
var result = EncryptUtils.encrypt
.overload("java.lang.String", "int")
.call(EncryptUtils, "data", 1);
console.log("[主动调用] encrypt('data', 1) = " + result);
});
这里有一个容易忽略的细节:**.call(EncryptUtils, ...) 中第一个参数是类 Wrapper 本身**。对于静态方法,这个参数其实没有实际作用(静态方法不依赖实例),但 Frida 的 API 设计要求你传入它。
与 Hook 回调中调用的区别:在 implementation 回调中,你用 this.method(args) 来调用原始方法,这里的 this 是被 Hook 的那个具体对象实例。而在主动调用中,你用 ClassName.method(args) 直接调用,这是在你选择的任意时刻发起调用,与 App 的执行流完全独立。
2.3 调用实例方法:先找到对象
实例方法需要一个对象实例才能调用。问题是:对象在 App 的堆内存中,你怎么拿到它?
有三种方式:
方式一:用 Java.choose 搜索堆上的活跃实例。
这是最常用的方式。Java.choose 遍历堆上所有指定类型的对象,每找到一个就通过 onMatch 回调交给你。
// active_call_instance.js
// 通过 Java.choose 找到实例,然后主动调用实例方法
Java.perform(function() {
Java.choose("com.example.app.auth.UserSession", {
onMatch: function(instance) {
// instance 就是堆上找到的 UserSession 对象
// 现在可以调用它的任意实例方法
var token = instance.getToken();
var userId = instance.getUserId();
var isVip = instance.isVipUser();
console.log("[UserSession 实例]");
console.log(" Token: " + token);
console.log(" UserID: " + userId);
console.log(" VIP: " + isVip);
// 甚至可以修改对象状态
// instance.setVipUser(true); // 把自己变成 VIP(慎用)
},
onComplete: function() {
console.log("[*] 堆搜索完成");
}
});
});
方式二:在 Hook 回调中保存 this 引用。
如果你已经 Hook 了某个方法,可以在回调中把 this(当前对象)保存到全局变量中,后续就可以随时调用它的方法。
// active_call_saved_ref.js
// 在 Hook 回调中保存对象引用,稍后主动调用
var savedSession = null;
Java.perform(function() {
var UserSession = Java.use("com.example.app.auth.UserSession");
UserSession.getToken.implementation = function() {
var token = this.getToken();
console.log("[Hook] getToken: " + token);
// 保存这个对象的引用到全局变量
// Java.retain 防止被 GC 回收(重要!)
savedSession = Java.retain(this);
return token;
};
});
// 稍后可以通过 savedSession 主动调用(比如在 RPC 中)
// savedSession.refreshToken();
// savedSession.getUserProfile();
Java.retain 的重要性:当 implementation 回调结束后,Frida 会释放回调中 Java 对象的局部引用(JNI Local Reference)。如果你把 this 存到全局变量但不调用 Java.retain,后续使用时可能遇到"stale reference"错误——因为对象的 JNI 引用已经失效了。Java.retain 的底层是调用 JNI 的 NewGlobalRef,将局部引用升级为全局引用,防止被 GC 回收。当你不再需要这个引用时,可以对 wrapper 调用 obj.$dispose() 主动释放底层 JNI 引用;脚本卸载时 Frida 也会自动清理,所以实际逆向中很少有人显式 dispose。
方式三:通过 $new() 自己创建一个新对象。
Java.perform(function() {
var StringBuilder = Java.use("java.lang.StringBuilder");
// $new() 相当于 Java 中的 new StringBuilder("Hello")
var sb = StringBuilder.$new("Hello");
sb.append(" ");
sb.append("World");
console.log(sb.toString()); // 输出: Hello World
});
2.4 $new():在 Frida 中创建 Java 对象
$new() 是 Java.use 返回的 Wrapper 上的特殊方法,用于创建该类的新实例。它等同于 Java 中的 new ClassName(args)。
Java.perform(function() {
// 创建基础类型的包装对象
var Integer = Java.use("java.lang.Integer");
var num = Integer.$new(42);
console.log("Integer: " + num.intValue()); // 42
// 创建字符串(虽然 Frida 会自动转换,但有时你需要显式创建)
var JavaString = Java.use("java.lang.String");
var str = JavaString.$new("Hello from Frida");
// 创建 ArrayList 并添加元素
var ArrayList = Java.use("java.util.ArrayList");
var list = ArrayList.$new();
list.add("item1");
list.add("item2");
list.add("item3");
console.log("List size: " + list.size()); // 3
console.log("List[0]: " + list.get(0)); // item1
console.log("List: " + list.toString()); // [item1, item2, item3]
// 创建 HashMap
var HashMap = Java.use("java.util.HashMap");
var map = HashMap.$new();
map.put("key1", "value1");
map.put("key2", "value2");
console.log("Map: " + map.toString());
// 创建 File 对象(不会真的创建文件,只是一个路径引用)
var File = Java.use("java.io.File");
var file = File.$new("/data/data/com.example.app/shared_prefs/config.xml");
console.log("File exists: " + file.exists());
console.log("File path: " + file.getAbsolutePath());
});
$new 同样支持 overload,当构造函数有多个重载版本时:
Java.perform(function() {
var File = Java.use("java.io.File");
// File(String pathname)
var f1 = File.$new("/sdcard/test.txt");
// File(String parent, String child)
var f2 = File.$new("/sdcard", "test.txt");
// 如果自动匹配失败,手动指定重载
var f3 = File.$new
.overload("java.lang.String", "java.lang.String")
.call(File, "/sdcard", "test.txt");
});
**init 再辨析**:第04篇提过这个区别,这里从 JNI 角度加深理解。`$new()底层调用的是 JNI 的NewObject(env, clazz, methodID, args),它会:①分配内存 → ②调用构造函数 → ③返回对象引用。而 new 是从外部创建新对象,$init` Hook 是拦截内部的对象创建过程。
2.5 主动调用的线程安全问题
主动调用 Java 方法时,你的代码运行在 Frida 的 JS 引擎线程上。而 App 的业务逻辑可能运行在主线程或其他工作线程上。如果你调用的方法不是线程安全的(比如它访问了一个只在主线程初始化的 Handler,或者它操作了 UI 元素),可能会遇到 crash。
对于必须在主线程执行的操作,使用 Java.scheduleOnMainThread:
Java.perform(function() {
// 这段代码会在 Android 主线程(UI 线程)上执行
Java.scheduleOnMainThread(function() {
// 安全地调用需要主线程上下文的方法
var activity = Java.use("android.app.ActivityThread")
.currentApplication()
.getApplicationContext();
// 弹一个 Toast(必须在主线程)
var Toast = Java.use("android.widget.Toast");
var toastObj = Toast.makeText(
activity,
Java.use("java.lang.String").$new("Hello from Frida!"),
Toast.LENGTH_LONG.value
);
toastObj.show();
console.log("[*] Toast 已显示");
});
});
**Java.isMainThread()**:在执行代码前可以先检查当前是否在主线程上。在 Hook 回调中,你的代码运行在调用被 Hook 方法的那个线程上——如果 App 在主线程调用了被 Hook 的方法,你的回调也在主线程;如果 App 在后台线程调用,你的回调也在后台线程。而在 Java.perform 的直接回调中(非 Hook 回调),你运行在 Frida 的 JS 引擎线程上。
三、ClassLoader 切换:解决「类找不到」的终极方案
3.1 为什么 Java.use 会找不到类
第03篇讲过 ClassNotFoundException 的常见原因。其中有一种情况特别棘手:类确实存在于 App 中,但默认的 ClassLoader 找不到它。
这在以下场景中经常发生:
加固/加壳 App:壳会创建自定义的 ClassLoader 来加载解密后的 DEX 文件。App 的业务类通过这个自定义 ClassLoader 加载,而 Frida 默认使用的是系统 ClassLoader,自然找不到这些类。
插件化/热修复框架:如 VirtualApk、RePlugin、Atlas、Tinker 等,每个插件模块都有独立的 ClassLoader。你要 Hook 的类可能在某个插件的 ClassLoader 中。
多 DEX App:虽然 Android 5.0+ 的 ART 原生支持 MultiDex,但某些场景下(如动态加载的 DEX)仍可能需要切换 ClassLoader。
下面这张图把"为什么默认找不到"画清楚——核心原因是 Java 的双亲委派只向上找父,不横向找兄弟:当目标类被 DexClassLoader(插件/加固)加载时,Frida 默认绑定的 PathClassLoader 调用 loadClass 一路向上委派到 BootClassLoader 都找不到,最后回落自查也不行,于是抛 ClassNotFoundException——而真正持有目标类的 DexClassLoader 是 PathClassLoader 的兄弟,根本不在委派路径上。
ClassLoader 双亲委派与 loadClass 委派链理解了这张图,下面三节(枚举 → 切换 → 多工厂并存)就是顺理成章的解决步骤。
3.2 Java.enumerateClassLoaders:找到所有 ClassLoader
// enumerate_classloaders.js
// 枚举所有活跃的 ClassLoader,找到能加载目标类的那个
Java.perform(function() {
var targetClassName = "com.example.app.core.SecretManager";
console.log("[*] 开始枚举 ClassLoader...\n");
Java.enumerateClassLoaders({
onMatch: function(loader) {
try {
// 尝试用这个 ClassLoader 加载目标类
// loader.loadClass 就是 Java 的 ClassLoader.loadClass 方法
var klass = loader.loadClass(targetClassName);
console.log("[FOUND] " + targetClassName);
console.log(" ClassLoader: " + loader);
console.log(" ClassLoader 类型: " + loader.$className);
// 打印这个 ClassLoader 的父级链
var parent = loader.getParent();
var depth = 1;
while (parent !== null) {
console.log(" " + " ".repeat(depth) + "└ parent: " + parent.$className);
parent = parent.getParent();
depth++;
}
} catch (e) {
// 这个 ClassLoader 找不到目标类,跳过
// 不打印错误信息,因为大多数 ClassLoader 都会失败
}
},
onComplete: function() {
console.log("\n[*] ClassLoader 枚举完成");
}
});
});
3.3 切换 ClassLoader:Java.classFactory
找到正确的 ClassLoader 后,需要告诉 Frida 使用它。方法是设置 Java.classFactory.loader:
// switch_classloader.js
// 切换 ClassLoader 后 Hook 加固 App 中的类
Java.perform(function() {
var targetClassName = "com.example.app.core.SecretManager";
Java.enumerateClassLoaders({
onMatch: function(loader) {
try {
loader.loadClass(targetClassName);
console.log("[*] 找到 ClassLoader: " + loader);
// 关键步骤:切换 Frida 的 ClassFactory 使用这个 ClassLoader
Java.classFactory.loader = loader;
// 现在 Java.use 会通过新的 ClassLoader 查找类
var SecretManager = Java.use(targetClassName);
console.log("[*] 成功获取 " + targetClassName);
// 正常 Hook
SecretManager.getSecret.implementation = function() {
var secret = this.getSecret();
console.log("[SecretManager] secret: " + secret);
return secret;
};
console.log("[*] Hook 成功!");
} catch (e) {
// 不是这个 ClassLoader,继续找
}
},
onComplete: function() {}
});
});
重要提醒:切换 Java.classFactory.loader 会影响后续所有的 Java.use 调用。如果你需要同时操作不同 ClassLoader 中的类,有两种做法:一是切换 → use → Hook → 切回,二是创建独立的 ClassFactory(下面讲)。
3.4 多 ClassLoader 并存:创建独立的 ClassFactory
当你需要同时操作来自不同 ClassLoader 的类时(比如同时 Hook 宿主 App 和插件模块的方法),频繁切换 Java.classFactory.loader 容易出错。更优雅的做法是创建独立的 ClassFactory:
// multi_classloader.js
// 同时操作多个 ClassLoader 中的类
Java.perform(function() {
var targetClassA = "com.example.app.host.HostManager"; // 宿主类
var targetClassB = "com.example.plugin.pay.PayProcessor"; // 插件类
var factoryA = null; // 宿主的 ClassFactory
var factoryB = null; // 插件的 ClassFactory
Java.enumerateClassLoaders({
onMatch: function(loader) {
try {
loader.loadClass(targetClassA);
// 为这个 ClassLoader 创建独立的 ClassFactory
factoryA = Java.ClassFactory.get(loader);
console.log("[*] 宿主 ClassLoader: " + loader.$className);
} catch(e) {}
try {
loader.loadClass(targetClassB);
factoryB = Java.ClassFactory.get(loader);
console.log("[*] 插件 ClassLoader: " + loader.$className);
} catch(e) {}
},
onComplete: function() {
if (factoryA) {
// 通过 factoryA 操作宿主类
var HostManager = factoryA.use(targetClassA);
HostManager.init.implementation = function() {
console.log("[Host] init 被调用");
returnthis.init();
};
}
if (factoryB) {
// 通过 factoryB 操作插件类
var PayProcessor = factoryB.use(targetClassB);
PayProcessor.pay.implementation = function(amount) {
console.log("[Plugin] pay: " + amount);
returnthis.pay(amount);
};
}
}
});
});
Java.ClassFactory.get(loader) 返回一个绑定到指定 ClassLoader 的 ClassFactory 对象。它拥有和 Java 命名空间类似的 API(.use()、.choose() 等),但所有类查找操作都通过指定的 ClassLoader 进行。
ClassLoader 切换与多 ClassFactory 工作模式3.5 实战模式:通用的 ClassLoader 搜索 + Hook 模板
在实际逆向中,你经常需要"先找到 ClassLoader,再 Hook"。下面是一个可复用的模板:
// classloader_hook_template.js
// 通用模板:自动搜索 ClassLoader 并 Hook 目标类
functionhookWithClassLoader(targetClass, hookSetup) {
Java.perform(function() {
// 先尝试默认 ClassLoader
try {
var klass = Java.use(targetClass);
console.log("[*] 默认 ClassLoader 即可找到 " + targetClass);
hookSetup(klass);
return;
} catch(e) {
console.log("[*] 默认 ClassLoader 找不到,开始搜索...");
}
// 遍历所有 ClassLoader,找到第一个能加载目标类的就停下
var done = false;
Java.enumerateClassLoaders({
onMatch: function(loader) {
if (done) return'stop';
try {
loader.loadClass(targetClass);
Java.classFactory.loader = loader;
var klass = Java.use(targetClass);
console.log("[*] 通过 " + loader.$className + " 找到 " + targetClass);
hookSetup(klass);
done = true;
return'stop';
} catch(e) {}
},
onComplete: function() {
if (!done) console.log("[!] 所有 ClassLoader 中均未找到 " + targetClass);
}
});
});
}
// 使用方式
hookWithClassLoader("com.example.app.core.CryptoEngine", function(CryptoEngine) {
CryptoEngine.encrypt.implementation = function(data) {
console.log("[CryptoEngine] encrypt: " + data);
returnthis.encrypt(data);
};
});
四、Java.cast:类型转换的瑞士军刀
4.1 为什么需要 cast
在 Java 中,一个对象可以被声明为它的父类或接口类型。比如一个 ArrayList 对象可以被声明为 List 类型,一个 OkHttpClient$Builder 可以被作为 Object 传递。
当你在 Frida 中通过 Hook 拿到一个参数时,它的 Wrapper 类型是方法签名中声明的类型,而不是对象的实际类型。如果方法签名是 void process(Object obj),你拿到的 args[0] 是 Object 类型的 Wrapper——你只能调用 Object 上定义的方法(toString()、hashCode() 等),无法直接调用 obj 真实类型上的方法。
Java.cast 解决这个问题:它把一个 Java 对象的 Wrapper 转换为指定类型的 Wrapper,让你可以调用目标类型上的所有方法。
4.2 基本用法
Java.perform(function() {
var SomeClass = Java.use("com.example.app.SomeClass");
// 假设这个方法接收 Object 类型的参数,但实际传入的是 JSONObject
SomeClass.process.overload("java.lang.Object").implementation = function(obj) {
// obj 的 Wrapper 类型是 Object,只能调用 toString()
console.log("[process] obj.toString(): " + obj.toString());
// 先检查实际类型
console.log("[process] 实际类型: " + obj.$className);
// 输出: org.json.JSONObject
// cast 为实际类型
var JSONObject = Java.use("org.json.JSONObject");
var jsonObj = Java.cast(obj, JSONObject);
// 现在可以调用 JSONObject 的方法了
console.log("[process] keys: " + jsonObj.keys());
console.log("[process] username: " + jsonObj.getString("username"));
console.log("[process] password: " + jsonObj.getString("password"));
returnthis.process(obj);
};
});
**obj.className属性,返回对象的实际运行时类名(等同于 Java 的obj.getClass().getName()`)。这在 cast 之前非常有用——先看看实际类型是什么,再决定 cast 成什么。
4.3 cast 到接口类型
Java 中的接口很常见。比如 Interceptor 接口在 OkHttp 中用于拦截网络请求。如果你通过 Java.choose 找到了一个实现了 Interceptor 接口的对象,但它的具体类名被混淆了(比如是 a.b.c),你可以 cast 为接口类型来调用接口方法:
Java.perform(function() {
// 枚举所有实现了 okhttp3.Interceptor 接口的对象
Java.choose("okhttp3.Interceptor", {
onMatch: function(instance) {
// instance 是某个具体实现类的对象,但类名可能被混淆
console.log("找到 Interceptor: " + instance.$className);
// cast 为 Interceptor 接口类型(虽然 choose 已经返回了正确的 Wrapper,
// 但在某些场景下你可能需要显式 cast 到特定接口)
var Interceptor = Java.use("okhttp3.Interceptor");
var interceptor = Java.cast(instance, Interceptor);
// 调用接口方法
// (Interceptor 只有一个 intercept(Chain) 方法,这里只是展示 cast 用法)
console.log(" 类名: " + interceptor.$className);
},
onComplete: function() {}
});
});
4.4 cast 在加密分析中的经典应用
在 Hook Cipher.init 时,密钥参数类型是 java.security.Key 接口。你需要 cast 才能获取密钥的具体信息:
Java.perform(function() {
var Cipher = Java.use("javax.crypto.Cipher");
var SecretKeySpec = Java.use("javax.crypto.spec.SecretKeySpec");
var IvParameterSpec = Java.use("javax.crypto.spec.IvParameterSpec");
// init(int opmode, Key key, AlgorithmParameterSpec params)
Cipher.init.overload("int", "java.security.Key", "java.security.spec.AlgorithmParameterSpec")
.implementation = function(opmode, key, params) {
var mode = opmode === 1 ? "ENCRYPT" : "DECRYPT";
console.log("\n[Cipher.init] 模式: " + mode);
// key 的声明类型是 java.security.Key(接口)
// 实际类型通常是 SecretKeySpec
console.log("[Cipher.init] Key 实际类型: " + key.$className);
if (key.$className === "javax.crypto.spec.SecretKeySpec") {
// cast 为 SecretKeySpec 以访问 getEncoded() 方法
var keySpec = Java.cast(key, SecretKeySpec);
var keyBytes = keySpec.getEncoded();
console.log("[Cipher.init] 密钥(hex): " + bytesToHex(keyBytes));
console.log("[Cipher.init] 密钥算法: " + keySpec.getAlgorithm());
}
// params 的声明类型是 AlgorithmParameterSpec(接口)
// 实际类型通常是 IvParameterSpec
if (params !== null && params.$className === "javax.crypto.spec.IvParameterSpec") {
var ivSpec = Java.cast(params, IvParameterSpec);
var ivBytes = ivSpec.getIV();
console.log("[Cipher.init] IV(hex): " + bytesToHex(ivBytes));
}
returnthis.init(opmode, key, params);
};
});
functionbytesToHex(bytes) {
if (!bytes) return"null";
var hex = [];
for (var i = 0; i < bytes.length; i++) {
var b = ((bytes[i] || 0) & 0xff).toString(16);
hex.push(b.length === 1 ? "0" + b : b);
}
return hex.join("");
}
五、Java.array:创建 Java 数组
5.1 为什么需要专门的数组 API
JavaScript 的数组([1, 2, 3])和 Java 的数组(int[]、byte[]、String[])是完全不同的东西。Java 数组有固定的元素类型和固定的长度,是 JVM 中的一等公民对象。当你需要主动调用一个接受 Java 数组参数的方法时,不能直接传 JS 数组——你需要用 Java.array 创建一个真正的 Java 数组。
5.2 基本语法
Java.perform(function() {
// 创建基本类型数组
var intArray = Java.array("int", [1, 2, 3, 4, 5]);
var byteArray = Java.array("byte", [0x48, 0x65, 0x6c, 0x6c, 0x6f]); // "Hello"
var longArray = Java.array("long", [100, 200, 300]);
var floatArray = Java.array("float", [1.0, 2.5, 3.14]);
var boolArray = Java.array("boolean", [true, false, true]);
// 创建对象类型数组
var stringArray = Java.array("java.lang.String", ["Hello", "World"]);
// 读取数组元素
console.log("intArray[0] = " + intArray[0]); // 1
console.log("intArray.length = " + intArray.length); // 5
// 遍历数组
for (var i = 0; i < byteArray.length; i++) {
console.log("byte[" + i + "] = 0x" + (byteArray[i] & 0xff).toString(16));
}
});
类型标识符规则:
| | |
|---|
byte[] | "byte" | Java.array("byte", [0x01, 0x02]) |
int[] | "int" | Java.array("int", [1, 2, 3]) |
long[] | "long" | Java.array("long", [100, 200]) |
float[] | "float" | Java.array("float", [1.0, 2.0]) |
double[] | "double" | Java.array("double", [1.0, 2.0]) |
boolean[] | "boolean" | Java.array("boolean", [true]) |
char[] | "char" | Java.array("char", [65, 66]) |
short[] | "short" | Java.array("short", [1, 2]) |
String[] | "java.lang.String" | Java.array("java.lang.String", ["a"]) |
Object[] | "java.lang.Object" | Java.array("java.lang.Object", [...]) |
5.3 实战:主动调用接受 byte[] 参数的加密方法
// active_call_encrypt.js
// 主动调用 App 的 AES 加密方法,传入自定义的 byte[] 参数
Java.perform(function() {
var AesUtils = Java.use("com.example.app.crypto.AesUtils");
// 准备参数:16 字节的密钥和 16 字节的 IV
var key = Java.array("byte", [
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10
]);
var iv = Java.array("byte", [
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
]);
// 要加密的明文
var plaintext = Java.array("byte", [
0x48, 0x65, 0x6c, 0x6c, 0x6f// "Hello"
]);
// 主动调用加密方法
// 假设方法签名: public static byte[] encrypt(byte[] data, byte[] key, byte[] iv)
var encrypted = AesUtils.encrypt(plaintext, key, iv);
console.log("[主动调用] 加密结果: " + bytesToHex(encrypted));
});
byte 类型的坑:Java 的 byte 是有符号的(-128 到 127),而 JavaScript 的数字没有这个限制。当你在 Java.array("byte", [...]) 中传入大于 127 的值(如 0xff),Frida 会自动处理符号转换。但在读取 Java byte[] 时,返回的值可能是负数——要转成无符号值需要 & 0xff。这是一个非常常见的坑:
var b = byteArray[0]; // 可能是 -1(Java 的有符号 byte)
var unsigned = b & 0xff; // 255(无符号值)
var hex = unsigned.toString(16); // "ff"
六、Java.registerClass:在运行时注册全新的 Java 类
6.1 什么时候需要创建新类
这是一个高级功能,在大多数常规 Hook 场景中用不到——但有一类问题除了它没有别的解法:当 App 接受一个接口/抽象类参数,而你想注入自己的实现时。
最典型的就是替换回调。比如 App 有这样一个调用:
apiClient.request(url, params, callback); // callback 是 ResponseCallback 接口
你想拦截 callback.onSuccess(data) 时拿到的 data。直接 Hook ResponseCallback.onSuccess 看似可行,但 ResponseCallback 是接口——Frida 的 Java.use("...ResponseCallback") 只能拿到接口 Wrapper,没法 Hook 接口方法本身(要 Hook 实现类才有意义)。而具体实现类可能是匿名内部类(ApiClient$1),名字混淆后还可能每次构建都变。
Java.registerClass 解法是:自己注册一个实现 ResponseCallback 接口的代理类,然后 Hook apiClient.request(...) 把第三个参数换成代理实例。代理在 onSuccess 里既能打印 data,又能转发给原 callback——下面 6.3 节就是这个场景的完整代码。
除了"替换回调",registerClass 还会出现在两个相对小众的场景:① 注册一个继承自系统抽象类(如 BaseAdapter、BroadcastReceiver)的"占位实现",配合 $new 用来满足某个 API 的形参要求;② 配合 superClass 字段做"加一层 wrapper" 拦截父类受保护方法。这两种用法占比不高,看完 6.3 后举一反三即可,本篇不专门展开。
6.2 基本语法
// register_class.js
// 注册一个新的 Java 类
Java.perform(function() {
// 注册一个实现了 Runnable 接口的新类
var MyRunnable = Java.registerClass({
name: "com.frida.MyRunnable", // 新类的全限定名
// 实现的接口列表
implements: [Java.use("java.lang.Runnable")],
// 方法实现
methods: {
// 实现 Runnable.run() 方法
run: function() {
console.log("[MyRunnable] run() 被调用了!");
}
}
});
// 创建新类的实例
var runnable = MyRunnable.$new();
// 传给需要 Runnable 的地方
var Thread = Java.use("java.lang.Thread");
var thread = Thread.$new(runnable);
thread.start();
});
6.3 实战:替换网络回调拦截响应
// replace_callback.js
// 用自注册的类替换网络请求的回调,拦截响应数据
Java.perform(function() {
// 假设 App 有这样一个回调接口:
// public interface ResponseCallback {
// void onSuccess(String data);
// void onError(int code, String message);
// }
var ResponseCallback = Java.use("com.example.app.net.ResponseCallback");
// 保存原始回调引用
var originalCallback = null;
// 注册一个代理回调类
var ProxyCallback = Java.registerClass({
name: "com.frida.ProxyCallback",
implements: [ResponseCallback],
fields: {
// 可以声明字段(可选)
"original": "java.lang.Object"
},
methods: {
onSuccess: function(data) {
// 拦截成功回调,打印响应数据
console.log("[ProxyCallback] onSuccess: " + data);
// 转发给原始回调
if (originalCallback) {
originalCallback.onSuccess(data);
}
},
onError: function(code, message) {
console.log("[ProxyCallback] onError: " + code + " - " + message);
if (originalCallback) {
originalCallback.onError(code, message);
}
}
}
});
// Hook 发送请求的方法,将回调替换为我们的代理
var ApiClient = Java.use("com.example.app.net.ApiClient");
ApiClient.request.implementation = function(url, params, callback) {
console.log("[ApiClient] request: " + url);
// 保存原始回调——必须 Java.retain,否则当 App 在异步线程
// 真正回调时,这里的 JNI 局部引用早已失效(参见 2.3 节关于
// Java.retain 的说明)。不 retain 会偶发 "stale local
// reference" 崩溃,特别在网络请求耗时较长时几乎必现。
originalCallback = Java.retain(callback);
// 创建代理回调并替换
var proxy = ProxyCallback.$new();
returnthis.request(url, params, proxy);
};
});
七、类型转换工具箱:Java ↔ JavaScript 的桥梁
在 Frida 逆向中,你会不断在 Java 类型和 JavaScript 类型之间来回转换。这些转换操作虽然简单,但如果不熟练,会在每个脚本中反复耗费时间。本节整理一个完整的工具箱。
7.1 Frida 的自动类型转换
Frida 在 Java 和 JavaScript 之间提供了一些自动转换:
| | |
|---|
String | | |
int | | |
long | | |
boolean | | |
float | | |
double | | |
byte[] | | |
其他对象 | | |
long 精度陷阱:JavaScript 的 Number 类型是 64 位 IEEE 754 浮点数,安全整数范围是 ±2^53。而 Java 的 long 是 64 位有符号整数,范围是 ±2^63。当一个 Java long 值超过 2^53(约 9007 万亿)时,传到 JavaScript 中会丢失精度。在处理时间戳(毫秒级)时通常没问题,但处理数据库 ID 或加密用的大整数时要注意。对于需要精确的场景,用 .toString() 将 long 转为字符串传输。
7.2 byte[] 处理:逆向中最高频的转换
Android 逆向中,你和 byte[] 打交道的频率极高——加密的输入/输出、密钥、IV、哈希值、签名、证书……全都是 byte[]。
// ====== byte[] → 十六进制字符串 ======
functionbytesToHex(bytes) {
if (bytes === null || bytes === undefined) return"null";
var hex = [];
for (var i = 0; i < bytes.length; i++) {
// & 0xff 将有符号 byte 转为无符号
var b = (bytes[i] & 0xff).toString(16);
hex.push(b.length === 1 ? "0" + b : b);
}
return hex.join("");
}
// ====== byte[] → UTF-8 字符串 ======
functionbytesToUtf8(bytes) {
if (bytes === null || bytes === undefined) return"null";
// 方法一:用 Java 的 String 构造函数(最可靠)
var JavaString = Java.use("java.lang.String");
return JavaString.$new(bytes, "UTF-8");
}
// ====== byte[] → Base64 字符串 ======
functionbytesToBase64(bytes) {
if (bytes === null || bytes === undefined) return"null";
var Base64 = Java.use("android.util.Base64");
// Base64.NO_WRAP = 2,不添加换行符
return Base64.encodeToString(bytes, 2);
}
// ====== 十六进制字符串 → byte[](Java array)======
functionhexToBytes(hexStr) {
var bytes = [];
for (var i = 0; i < hexStr.length; i += 2) {
bytes.push(parseInt(hexStr.substr(i, 2), 16));
}
return Java.array("byte", bytes);
}
// ====== UTF-8 字符串 → byte[] ======
functionutf8ToBytes(str) {
var JavaString = Java.use("java.lang.String");
return JavaString.$new(str).getBytes("UTF-8");
}
// ====== Base64 字符串 → byte[] ======
functionbase64ToBytes(b64Str) {
var Base64 = Java.use("android.util.Base64");
return Base64.decode(b64Str, 0);
}
7.3 智能打印 byte[]:自动判断是文本还是二进制
在实际 Hook 中,你经常需要打印 byte[] 的内容,但不知道它是可读文本还是二进制数据。下面这个函数会自动判断:
// smart_print_bytes.js
// 智能打印 byte[]:如果内容是可读文本则显示文本,否则显示十六进制
functionsmartPrintBytes(bytes, maxLen) {
if (bytes === null || bytes === undefined) return"null";
if (bytes.length === 0) return"(empty)";
maxLen = maxLen || 256; // 默认最多显示 256 字节
// 统计可打印 ASCII 字符的比例
var printableCount = 0;
var checkLen = Math.min(bytes.length, maxLen);
for (var i = 0; i < checkLen; i++) {
var b = bytes[i] & 0xff;
// 可打印 ASCII 范围:0x20-0x7E(空格到波浪号)
// 加上常见的控制字符:\t(0x09), \n(0x0A), \r(0x0D)
if ((b >= 0x20 && b <= 0x7e) || b === 0x09 || b === 0x0a || b === 0x0d) {
printableCount++;
}
}
var printableRatio = printableCount / checkLen;
var truncated = bytes.length > maxLen;
var suffix = truncated ? "...(" + bytes.length + " bytes total)" : "";
if (printableRatio > 0.85) {
// 大部分是可打印字符,按 UTF-8 显示
try {
var JavaString = Java.use("java.lang.String");
var text = JavaString.$new(bytes, "UTF-8").toString();
if (truncated) text = text.substring(0, maxLen);
return'"' + text + '"' + suffix + " | (UTF8)";
} catch(e) {
// UTF-8 解码失败,fallback 到十六进制
}
}
// 二进制数据,显示十六进制
var hex = bytesToHex(bytes).substring(0, maxLen * 2);
return hex + suffix + " | (HEX)";
}
这个函数在第14篇(算法自吐脚本)中会被大量使用——它让加密监控的输出一目了然:文本明文直接显示文本,二进制密文显示十六进制。
7.4 Java 集合类型的遍历
除了 byte[],你还经常遇到 Java 的集合类型。以下是常见集合的遍历方式:
// collection_traversal.js
// 遍历 Java 集合类型
Java.perform(function() {
// ====== 遍历 List(ArrayList / LinkedList 等)======
functionprintList(list) {
if (list === null) { console.log("null"); return; }
console.log("List (size=" + list.size() + "):");
for (var i = 0; i < list.size(); i++) {
console.log(" [" + i + "] " + list.get(i));
}
}
// ====== 遍历 Map(HashMap / TreeMap / LinkedHashMap 等)======
functionprintMap(map) {
if (map === null) { console.log("null"); return; }
console.log("Map (size=" + map.size() + "):");
// 通过 entrySet 遍历
varSet = Java.use("java.util.Set");
var Iterator = Java.use("java.util.Iterator");
var MapEntry = Java.use("java.util.Map$Entry");
var entrySet = map.entrySet();
var iterator = entrySet.iterator();
while (iterator.hasNext()) {
var entry = Java.cast(iterator.next(), MapEntry);
console.log(" " + entry.getKey() + " => " + entry.getValue());
}
}
// ====== 遍历 Set(HashSet / TreeSet 等)======
functionprintSet(set) {
if (set === null) { console.log("null"); return; }
console.log("Set (size=" + set.size() + "):");
var iterator = set.iterator();
while (iterator.hasNext()) {
console.log(" " + iterator.next());
}
}
// ====== 遍历 Bundle(Android 特有,Activity 间传递数据)======
functionprintBundle(bundle) {
if (bundle === null) { console.log("null"); return; }
console.log("Bundle:");
var keys = bundle.keySet();
var iterator = keys.iterator();
while (iterator.hasNext()) {
var key = iterator.next().toString();
var value = bundle.get(key);
console.log(" " + key + " => " + (value !== null ? value.toString() : "null"));
}
}
// ====== JSON 对象/数组遍历 ======
functionprintJSONObject(jsonObj) {
if (jsonObj === null) { console.log("null"); return; }
// 最简单的方式:直接 toString(2) 格式化输出
console.log(jsonObj.toString(2));
}
functionprintJSONArray(jsonArr) {
if (jsonArr === null) { console.log("null"); return; }
console.log("JSONArray (length=" + jsonArr.length() + "):");
for (var i = 0; i < jsonArr.length(); i++) {
console.log(" [" + i + "] " + jsonArr.get(i));
}
}
});
性能提醒:遍历大型集合(如上千个元素的 List 或 Map)时要注意,每次 .get(i) 或 .next() 都涉及一次 JNI 调用,开销不小。在 Hook 高频方法(如网络拦截器每次请求都触发)时,建议只在确实需要时才遍历,或者加一个条件过滤。
八、堆栈打印:定位「谁调用了这个方法」
8.1 为什么堆栈如此重要
当你 Hook 到一个加密方法被调用了,看到了密钥和明文——接下来最重要的问题是:谁调用了它? 是登录模块?支付模块?还是设备指纹采集?
堆栈(Stack Trace)给你答案。它展示了从当前方法一路向上的完整调用链,比如:
at com.example.crypto.AesUtils.encrypt(AesUtils.java:42)
at com.example.api.AuthService.login(AuthService.java:128)
at com.example.ui.LoginActivity.onLoginClick(LoginActivity.java:56)
一看就知道是「登录页面点击登录按钮 → 调用认证服务 → 执行 AES 加密」。
8.2 方式一:Exception + Log(最常用)
这是 Frida Java 层堆栈打印最标准、最可靠的方式:
// 获取 Java 层调用堆栈
functiongetJavaStack() {
var Exception = Java.use("java.lang.Exception");
var Log = Java.use("android.util.Log");
// 创建一个 Exception 对象(不需要 throw),它在创建时就捕获了当前堆栈
var exception = Exception.$new();
// getStackTraceString 将堆栈格式化为可读字符串
var stackTrace = Log.getStackTraceString(exception);
return stackTrace;
}
原理:在 Java 中,new Exception() 的构造函数会调用 fillInStackTrace(),记录当前线程的完整调用栈。Log.getStackTraceString() 将这个堆栈信息格式化为人类可读的字符串。
为什么不直接用 exception.getStackTrace()? 你当然可以——它返回一个 StackTraceElement[] 数组,每个元素包含类名、方法名、文件名、行号。但 Log.getStackTraceString 会帮你格式化成整齐的多行字符串,在控制台输出中更容易阅读。
8.3 方式二:Thread.currentThread().getStackTrace()
// 通过当前线程获取堆栈
functiongetJavaStackViaThread() {
var Thread = Java.use("java.lang.Thread");
var stackElements = Thread.currentThread().getStackTrace();
var lines = [];
for (var i = 0; i < stackElements.length; i++) {
var elem = stackElements[i];
lines.push(" at " + elem.getClassName() + "." +
elem.getMethodName() + "(" +
elem.getFileName() + ":" +
elem.getLineNumber() + ")");
}
return lines.join("\n");
}
这种方式的优势是可以编程式地访问堆栈的每一层(类名、方法名、行号),方便做过滤和条件判断。
8.4 方式三:Throwable(最精简)
functiongetJavaStackShort() {
var Throwable = Java.use("java.lang.Throwable");
return Throwable.$new().getStackTrace()
.map(function(elem) {
return elem.toString();
})
.join("\n");
}
8.5 过滤堆栈:只看有用的部分
原始堆栈通常很长,包含大量框架代码(dalvik.system、java.lang.reflect、com.android.internal 等)。在实际使用中,你通常只关心 App 自己的代码。
// filtered_stack.js
// 过滤堆栈,只保留 App 业务代码
functiongetAppStack(maxLines, packageFilter) {
maxLines = maxLines || 8;
packageFilter = packageFilter || null; // 如 "com.example.app"
try {
var Exception = Java.use("java.lang.Exception");
var Log = Java.use("android.util.Log");
var stack = Log.getStackTraceString(Exception.$new());
var lines = stack.split("\n");
var filtered = [];
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (!line.startsWith("at ")) continue;
// 跳过 Frida 自身的调用帧
if (line.indexOf("frida") !== -1) continue;
// 跳过 Java 标准库和 Android 框架
if (line.indexOf("java.lang.reflect") !== -1) continue;
if (line.indexOf("dalvik.system") !== -1) continue;
if (line.indexOf("com.android.internal") !== -1) continue;
// 如果指定了包名过滤,只保留匹配的
if (packageFilter && line.indexOf(packageFilter) === -1) continue;
filtered.push(" " + line);
if (filtered.length >= maxLines) break;
}
return filtered.join("\n");
} catch(e) {
return"(堆栈获取失败: " + e.message + ")";
}
}
// 使用示例
// 打印最多 5 层,只看 com.example 包下的调用
// console.log(getAppStack(5, "com.example"));
8.6 在 Hook 中使用堆栈的最佳实践
// stack_best_practice.js
// 在加密 Hook 中使用堆栈追踪
Java.perform(function() {
var Cipher = Java.use("javax.crypto.Cipher");
Cipher.doFinal.overload("[B").implementation = function(input) {
var result = this.doFinal(input);
console.log("\n========== [Cipher.doFinal] ==========");
console.log("算法: " + this.getAlgorithm());
console.log("输入: " + smartPrintBytes(input, 64));
console.log("输出: " + smartPrintBytes(result, 64));
console.log("调用链:");
console.log(getAppStack(5));
console.log("=======================================\n");
return result;
};
});
性能注意:创建 Exception 对象并获取堆栈是有开销的(需要遍历当前线程的整个调用栈帧)。如果被 Hook 的方法每秒调用几百次(比如 Socket.write),每次都打印堆栈会导致明显的性能下降。建议在高频 Hook 中,只在满足特定条件时才打印堆栈(比如密钥长度为 16 字节时,或者输入包含特定关键字时)。
九、Java.choose 高级用法
9.1 回顾:Java.choose 的基本工作原理
第04篇介绍了 Java.choose 的基础用法。这里补充它的底层原理和高级技巧。
Java.choose 的底层实现依赖于 ART VM 的堆遍历接口。在 Android 的 ART 运行时中,所有 Java 对象都分配在受管理的堆(Managed Heap)上。ART 提供了遍历堆上所有存活对象的能力(类似于 GC 标记阶段的遍历)。Java.choose 利用这个能力,遍历堆上的每一个对象,检查它是否是指定类型的实例(包括子类实例),如果是就通过 onMatch 回调交给你。
这意味着 Java.choose 的性能开销与堆上的总对象数量成正比,而不是与目标类型的实例数量成正比。如果堆上有 100 万个对象,即使你要找的类型只有 1 个实例,它也要遍历全部 100 万个对象。
Java.choose 堆遍历原理图里那个"扫描线穿过整个堆"的视觉对应的就是 ART 的堆遍历——每个对象都做一次 instanceof 判断,没有按类型建立索引可以走捷径。这也直接解释了下面 9.2 的两条优化策略:既然遍历不可省,那就尽早终止 + 不要重复触发。
9.2 性能优化策略
策略一:尽早终止遍历。 如果你知道目标类型是单例(堆上只有一个实例),找到后立刻终止枚举即可。Java.choose 的 onMatch 回调返回字符串 'stop' 会让 Frida 停止后续遍历——这是官方支持的早终止机制:
Java.choose("com.example.app.AppConfig", {
onMatch: function(instance) {
console.log("[AppConfig] server: " + instance.getServerUrl());
console.log("[AppConfig] debug: " + instance.isDebugMode());
return'stop'; // 找到第一个就停止遍历
},
onComplete: function() {}
});
同样的早终止约定也适用于 Java.enumerateLoadedClasses 和 Java.enumerateClassLoaders 的 onMatch 回调。
策略二:避免在 Hook 回调中使用 Java.choose。 Hook 回调可能被频繁触发(每次方法调用都会触发),如果每次都执行一次堆遍历,性能影响会很大。更好的做法是在初始化时用 Java.choose 找到对象并保存引用,后续直接使用保存的引用。
// 不好的做法:每次 Hook 触发都做堆遍历
SomeClass.someMethod.implementation = function() {
Java.choose("com.example.TokenManager", { // 每次调用都遍历堆,性能差
onMatch: function(tm) { /* ... */ },
onComplete: function() {}
});
returnthis.someMethod();
};
// 好的做法:初始化时找到引用,后续直接使用
var tokenManager = null;
Java.choose("com.example.TokenManager", {
onMatch: function(tm) {
tokenManager = Java.retain(tm); // 保存引用
},
onComplete: function() {}
});
SomeClass.someMethod.implementation = function() {
if (tokenManager) {
console.log("token: " + tokenManager.getToken()); // 直接使用
}
returnthis.someMethod();
};
9.3 搜索子类实例
Java.choose 会匹配指定类型及其所有子类的实例。这在搜索接口实现时很有用:
// 搜索所有 Activity 实例(包括 MainActivity、LoginActivity 等所有子类)
Java.choose("android.app.Activity", {
onMatch: function(activity) {
console.log("活跃 Activity: " + activity.$className);
// 输出可能包括:
// com.example.app.ui.MainActivity
// com.example.app.ui.LoginActivity
// com.example.app.ui.SettingsActivity
},
onComplete: function() {}
});
9.4 结合 cast 和 choose
搜索到的对象可能是某个子类的实例,但 onMatch 中的 Wrapper 类型是你传给 Java.choose 的那个类型。如果你需要调用子类特有的方法,需要先 cast:
Java.choose("android.app.Activity", {
onMatch: function(activity) {
if (activity.$className === "com.example.app.ui.LoginActivity") {
// activity 的 Wrapper 是 Activity 类型
// cast 为 LoginActivity 才能调用 getUsername() 等子类方法
var LoginActivity = Java.use("com.example.app.ui.LoginActivity");
var loginAct = Java.cast(activity, LoginActivity);
console.log("[LoginActivity]");
console.log(" username field: " + loginAct.mUsername.value);
console.log(" password field: " + loginAct.mPassword.value);
}
},
onComplete: function() {}
});
十、Java.enumerateLoadedClasses:在代码海洋中搜索
10.1 枚举所有已加载的类
// enumerate_classes.js
// 枚举所有已加载的类,搜索包含特定关键词的类名
Java.perform(function() {
var keyword = "encrypt"; // 搜索关键词(不区分大小写)
var matches = [];
Java.enumerateLoadedClasses({
onMatch: function(className) {
if (className.toLowerCase().indexOf(keyword.toLowerCase()) !== -1) {
matches.push(className);
}
},
onComplete: function() {
console.log("[*] 包含 '" + keyword + "' 的类 (" + matches.length + " 个):\n");
matches.sort();
for (var i = 0; i < matches.length; i++) {
console.log(" " + matches[i]);
}
}
});
});
同步版本更简洁(但会阻塞直到枚举完成):
Java.perform(function() {
var classes = Java.enumerateLoadedClassesSync();
console.log("[*] 共加载了 " + classes.length + " 个类");
// 搜索加密相关的类
var cryptoClasses = classes.filter(function(name) {
return name.toLowerCase().indexOf("crypto") !== -1 ||
name.toLowerCase().indexOf("cipher") !== -1 ||
name.toLowerCase().indexOf("encrypt") !== -1;
});
console.log("[*] 加密相关类 (" + cryptoClasses.length + " 个):");
cryptoClasses.forEach(function(name) {
console.log(" " + name);
});
});
10.2 搜索技巧
在实际逆向中,搜索类的常见目的和对应的关键词:
| |
|---|
| encrypt, decrypt, cipher, crypto, aes, rsa, sign, hmac |
| http, okhttp, retrofit, request, response, url, api |
| login, auth, token, session, password, credential |
| database, sqlite, room, dao, cursor |
| root, detect, safety, integrity, check |
| webview, bridge, javascript, jsbridge |
混淆代码的搜索策略:如果 App 经过了 ProGuard/R8 混淆,类名可能变成了 a.b.c、a.b.d 这样无意义的名字。此时不能靠类名搜索,需要换一种思路:先用 jadx 的交叉引用功能从已知的 Android Framework 类(如 javax.crypto.Cipher——这个类名不会被混淆)出发,顺着调用链找到 App 的业务类。
十一、Java.deoptimizeEverything:对付 Hook 失效的杀手锏
11.1 ART 的 JIT 编译与 Hook 冲突
Android ART 运行时使用 JIT(Just-In-Time)编译器将热点 Java 方法编译为 Native 机器码。被 JIT 编译后的方法直接以 Native 代码执行,绕过了解释器。
Frida 的 Java 方法 Hook 是通过修改方法的 ART 内部数据结构(ArtMethod 的入口点)来实现的。但如果一个方法已经被 JIT 编译为 Native 代码,调用时可能走的是 JIT 编译后的路径,绕过了 Frida 修改的入口点——导致 Hook 设置成功了,但不触发回调。
ART JIT 编译与 Frida Hook 入口点冲突图里那个"两个 entry_point 字段"是关键:ArtMethod 同时持有 entry_point_from_interpreter(解释器入口)和 entry_point_from_quick_compiled_code(JIT/AOT 入口)。Frida 早期版本只改前者,方法被 JIT 后 ART 会走后者——Hook 就静默失效。Frida 16.x 已经改为同时覆盖两个入口点,所以现代版本遇到此问题的概率比 12-14 时代低很多;但在 Hook 框架类(boot image)或第三方加固自定义编译流程时仍可能命中。
11.2 使用 deoptimize
Java.perform(function() {
// 强制关闭所有 JIT 编译,回退到解释执行
// 这样 Frida 的 Hook 一定会被触发
Java.deoptimizeEverything();
console.log("[*] JIT 已关闭,所有方法回退到解释执行");
// 现在设置的 Hook 一定生效
var TargetClass = Java.use("com.example.app.TargetClass");
TargetClass.criticalMethod.implementation = function() {
console.log("[*] criticalMethod 被调用");
returnthis.criticalMethod();
};
});
性能代价:deoptimizeEverything 会显著降低 App 的运行速度(所有 Java 代码都以解释器模式运行,性能通常下降 5-10 倍)。在生产环境的 App 中,你可能会注意到明显的卡顿。但对于逆向分析来说,正确性远比性能重要——如果 Hook 不触发,分析就无法进行。建议的做法是:先不加 deoptimizeEverything,如果发现 Hook 不触发,再加上它。
**Java.deoptimizeBootImage()**:比 deoptimizeEverything 更精准。它只对 boot image 中的方法(Android Framework 的核心类,如 Activity、Cipher、PackageManager 等)关闭 JIT,对 App 自身的代码不影响。当你的 Hook 目标是 Framework 类时,用这个方法对性能影响更小。
十二、实战:通用类方法枚举器
把本篇学到的 API 综合起来,写一个实用工具:输入一个类名,自动列出它的所有方法签名、字段信息,并支持一键 Hook 所有方法。
// class_inspector.js
// 通用类方法枚举器 - 输入类名,自动列出所有方法和字段
// 用法: frida -U -f com.example.app -l class_inspector.js --no-pause
// ====== 配置 ======
var TARGET_CLASS = "com.example.app.crypto.EncryptUtils"; // 修改为你要分析的类名
var HOOK_ALL = false; // 设为 true 则自动 Hook 所有方法并打印参数
var MAX_ARG_LENGTH = 200; // 参数打印的最大长度
// ====== 辅助函数 ======
functionbytesToHex(bytes) {
if (bytes === null || bytes === undefined) return"null";
var hex = [];
for (var i = 0; i < bytes.length; i++) {
var b = (bytes[i] & 0xff).toString(16);
hex.push(b.length === 1 ? "0" + b : b);
}
return hex.join("");
}
functionformatArgValue(arg) {
if (arg === null || arg === undefined) return"null";
try {
// 尝试检测 byte[] 类型
if (arg.length !== undefined && typeof arg.length === "number" && arg.$className === undefined) {
// 可能是基本类型数组
if (arg.length > 0 && arg.length < 1024) {
try {
var hex = bytesToHex(arg);
if (hex.length > MAX_ARG_LENGTH) hex = hex.substring(0, MAX_ARG_LENGTH) + "...";
return"[byte[" + arg.length + "]] " + hex;
} catch(e) {}
}
return"[array, length=" + arg.length + "]";
}
var str = arg.toString();
if (str.length > MAX_ARG_LENGTH) {
str = str.substring(0, MAX_ARG_LENGTH) + "...(" + str.length + " chars)";
}
return str;
} catch(e) {
return"(无法转为字符串: " + e.message + ")";
}
}
functiongetAppStack(maxLines) {
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(l) {
var t = l.trim();
return t.startsWith("at ") &&
t.indexOf("frida") === -1 &&
t.indexOf("java.lang.reflect") === -1;
})
.slice(0, maxLines || 5)
.map(function(l) { return" " + l.trim(); })
.join("\n");
} catch(e) { return""; }
}
// ====== ClassLoader 搜索 + 枚举 ======
functioninspectClass(className) {
// 尝试获取类引用(支持自动搜索 ClassLoader)
var klass = null;
try {
klass = Java.use(className);
} catch(e) {
console.log("[!] 默认 ClassLoader 找不到 " + className + ",搜索其他 ClassLoader...");
Java.enumerateClassLoaders({
onMatch: function(loader) {
if (klass) return;
try {
loader.loadClass(className);
Java.classFactory.loader = loader;
klass = Java.use(className);
console.log("[*] 通过 " + loader.$className + " 找到");
} catch(e) {}
},
onComplete: function() {}
});
}
if (!klass) {
console.log("[!] 无法找到类: " + className);
return;
}
// ====== 获取类信息 ======
var javaClass = klass.class;
console.log("\n╔══════════════════════════════════════════════════════════════╗");
console.log("║ Class Inspector ║");
console.log("╚══════════════════════════════════════════════════════════════╝");
console.log("\n[类名] " + className);
// 父类
try {
var superClass = javaClass.getSuperclass();
if (superClass) {
console.log("[父类] " + superClass.getName());
}
} catch(e) {}
// 实现的接口
try {
var interfaces = javaClass.getInterfaces();
if (interfaces.length > 0) {
console.log("[接口]");
for (var i = 0; i < interfaces.length; i++) {
console.log(" - " + interfaces[i].getName());
}
}
} catch(e) {}
// ====== 枚举字段 ======
try {
var fields = javaClass.getDeclaredFields();
if (fields.length > 0) {
console.log("\n[字段] (" + fields.length + " 个)");
for (var i = 0; i < fields.length; i++) {
var field = fields[i];
var modifiers = Java.use("java.lang.reflect.Modifier")
.toString(field.getModifiers());
console.log(" " + modifiers + " " +
field.getType().getName() + " " +
field.getName());
}
}
} catch(e) {}
// ====== 枚举方法 ======
try {
var methods = javaClass.getDeclaredMethods();
console.log("\n[方法] (" + methods.length + " 个)");
for (var i = 0; i < methods.length; i++) {
var method = methods[i];
var modifiers = Java.use("java.lang.reflect.Modifier")
.toString(method.getModifiers());
var returnType = method.getReturnType().getName();
var methodName = method.getName();
// 获取参数类型列表
var paramTypes = method.getParameterTypes();
var paramList = [];
for (var j = 0; j < paramTypes.length; j++) {
paramList.push(paramTypes[j].getName());
}
console.log(" " + modifiers + " " + returnType + " " +
methodName + "(" + paramList.join(", ") + ")");
// 如果 HOOK_ALL 开启,自动 Hook 这个方法
if (HOOK_ALL) {
hookMethod(klass, methodName, paramList);
}
}
} catch(e) {
console.log("[!] 枚举方法失败: " + e.message);
}
// ====== 枚举构造函数 ======
try {
var constructors = javaClass.getDeclaredConstructors();
if (constructors.length > 0) {
console.log("\n[构造函数] (" + constructors.length + " 个)");
for (var i = 0; i < constructors.length; i++) {
var ctor = constructors[i];
var paramTypes = ctor.getParameterTypes();
var paramList = [];
for (var j = 0; j < paramTypes.length; j++) {
paramList.push(paramTypes[j].getName());
}
console.log(" " + className + "(" + paramList.join(", ") + ")");
}
}
} catch(e) {}
console.log("\n[完成] 类检查结束\n");
}
// JVM 内部签名 → Frida overload 期望的可读类型名
// "[B" → "[B" (基本类型数组保持 JNI 形式)
// "[I" → "[I"
// "[Ljava.lang.String;" → "[Ljava.lang.String;"
// "java.lang.String" → "java.lang.String"
// 其实 Frida overload 同时接受 "[B" 和 "byte[]"、"java.lang.String[]"
// 两种写法;但 Java 反射 getName() 在二维数组、嵌套数组上返回的是 JNI 形式,
// 这里统一规范化为 Frida 更稳妥的写法。
functionjvmSigToFridaName(name) {
if (!name) return name;
// 基本类型数组:[B / [I / [J / [F / [D / [S / [C / [Z
var primMap = { 'B': 'byte', 'I': 'int', 'J': 'long', 'F': 'float',
'D': 'double', 'S': 'short', 'C': 'char', 'Z': 'boolean' };
var depth = 0;
while (name.charAt(depth) === '[') depth++;
if (depth === 0) return name; // 不是数组
var elem = name.substring(depth);
var elemName;
if (elem.length === 1 && primMap[elem]) {
elemName = primMap[elem];
} elseif (elem.charAt(0) === 'L' && elem.charAt(elem.length - 1) === ';') {
elemName = elem.substring(1, elem.length - 1);
} else {
return name; // 解析不出,原样返回
}
var suffix = '';
for (var i = 0; i < depth; i++) suffix += '[]';
return elemName + suffix;
}
functionhookMethod(klass, methodName, paramTypes) {
try {
var overloadArgs = paramTypes.map(jvmSigToFridaName);
var method = klass[methodName];
if (!method) return;
var overloaded = paramTypes.length > 0
? method.overload.apply(method, overloadArgs)
: method.overload();
overloaded.implementation = function() {
var args = [].slice.call(arguments);
var argStr = args.map(function(a) { return formatArgValue(a); }).join(", ");
console.log("\n>>> " + methodName + "(" + argStr + ")");
var result = this[methodName].apply(this, args);
console.log("<<< " + methodName + " => " + formatArgValue(result));
console.log(" 调用链:\n" + getAppStack(3));
return result;
};
} catch(e) {
// Hook 失败(可能是抽象方法、接口方法等),静默跳过
}
}
// ====== 入口 ======
Java.perform(function() {
inspectClass(TARGET_CLASS);
});
运行效果示例:
╔══════════════════════════════════════════════════════════════╗
║ Class Inspector ║
╚══════════════════════════════════════════════════════════════╝
[类名] com.example.app.crypto.EncryptUtils
[父类] java.lang.Object
[接口]
- (无)
[字段] (3 个)
private static final java.lang.String SECRET_KEY
private static final java.lang.String IV
private static boolean initialized
[方法] (5 个)
public static java.lang.String encrypt(java.lang.String)
public static java.lang.String decrypt(java.lang.String)
public static byte[] encryptBytes(byte[], byte[])
private static void init()
public static java.lang.String md5(java.lang.String)
[构造函数] (1 个)
com.example.app.crypto.EncryptUtils()
[完成] 类检查结束
将 HOOK_ALL 设为 true,可以自动 Hook 所有方法并打印参数和返回值——这在快速分析一个未知类时非常高效。
Class Inspector 工作流程
十三、工具集与速查
本节做两件事:① 把第七、第八两节散落的工具函数汇总成一个完整可拷贝的工具集;② 给出 13 条核心 API 的单行速查,配合工具集即可覆盖日常 Frida Java 层逆向 80% 的场景。
13.1 工具集索引
| | |
|---|
bytesToHex(bytes) | | |
bytesToUtf8(bytes) | | |
bytesToBase64(bytes) | | |
hexToBytes(hex) | | |
utf8ToBytes(s) | | |
base64ToBytes(b64) | | |
smartPrintBytes(bytes, maxLen) | | |
getJavaStack() | | |
getAppStack(maxLines, pkg) | | |
printList(list) / printMap(map) / printSet(set) / printBundle(b) | | |
13.2 完整代码(拷贝即用)
把下面这段直接粘到你 Frida 脚本的最前面,所有工具函数立即可用。后续加密专题、JNI 追踪、签名校验相关代码块都假定脚本里已包含它。
// ============================================================
// frida-java-utils · Frida Java 层逆向通用工具集
// 用法:把整段拷到脚本最前面即可
// ============================================================
// ====== byte[] / 字符串相互转换 ======
functionbytesToHex(bytes) {
if (bytes === null || bytes === undefined) return"null";
var hex = [];
for (var i = 0; i < bytes.length; i++) {
// & 0xff 将有符号 byte 转为无符号
var b = (bytes[i] & 0xff).toString(16);
hex.push(b.length === 1 ? "0" + b : b);
}
return hex.join("");
}
functionbytesToUtf8(bytes) {
if (bytes === null || bytes === undefined) return"null";
return Java.use("java.lang.String").$new(bytes, "UTF-8");
}
functionbytesToBase64(bytes) {
if (bytes === null || bytes === undefined) return"null";
// Base64.NO_WRAP = 2,不添加换行符
return Java.use("android.util.Base64").encodeToString(bytes, 2);
}
functionhexToBytes(hexStr) {
var bytes = [];
for (var i = 0; i < hexStr.length; i += 2) {
bytes.push(parseInt(hexStr.substr(i, 2), 16));
}
return Java.array("byte", bytes);
}
functionutf8ToBytes(str) {
return Java.use("java.lang.String").$new(str).getBytes("UTF-8");
}
functionbase64ToBytes(b64Str) {
return Java.use("android.util.Base64").decode(b64Str, 0);
}
// ====== 智能打印 byte[]:自动判断文本/二进制 ======
functionsmartPrintBytes(bytes, maxLen) {
if (bytes === null || bytes === undefined) return"null";
if (bytes.length === 0) return"(empty)";
maxLen = maxLen || 256;
var printableCount = 0;
var checkLen = Math.min(bytes.length, maxLen);
for (var i = 0; i < checkLen; i++) {
var b = bytes[i] & 0xff;
// 可打印 ASCII:0x20-0x7E;常见控制字符:\t \n \r
if ((b >= 0x20 && b <= 0x7e) || b === 0x09 || b === 0x0a || b === 0x0d) {
printableCount++;
}
}
var ratio = printableCount / checkLen;
var truncated = bytes.length > maxLen;
var suffix = truncated ? "...(" + bytes.length + " bytes total)" : "";
if (ratio > 0.85) {
try {
var text = Java.use("java.lang.String").$new(bytes, "UTF-8").toString();
if (truncated) text = text.substring(0, maxLen);
return'"' + text + '"' + suffix + " | (UTF8)";
} catch (e) { /* fallback to hex */ }
}
var hex = bytesToHex(bytes).substring(0, maxLen * 2);
return hex + suffix + " | (HEX)";
}
// ====== Java 调用栈打印 ======
functiongetJavaStack() {
var Exception = Java.use("java.lang.Exception");
var Log = Java.use("android.util.Log");
return Log.getStackTraceString(Exception.$new());
}
// 过滤后的 App 业务调用栈:跳过反射/dalvik/internal/frida 帧
functiongetAppStack(maxLines, packageFilter) {
maxLines = maxLines || 8;
packageFilter = packageFilter || null;
try {
var Exception = Java.use("java.lang.Exception");
var Log = Java.use("android.util.Log");
var stack = Log.getStackTraceString(Exception.$new());
var lines = stack.split("\n");
var filtered = [];
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (!line.startsWith("at ")) continue;
if (line.indexOf("frida") !== -1) continue;
if (line.indexOf("java.lang.reflect") !== -1) continue;
if (line.indexOf("dalvik.system") !== -1) continue;
if (line.indexOf("com.android.internal") !== -1) continue;
if (packageFilter && line.indexOf(packageFilter) === -1) continue;
filtered.push(" " + line);
if (filtered.length >= maxLines) break;
}
return filtered.join("\n");
} catch (e) {
return"(堆栈获取失败: " + e.message + ")";
}
}
// ====== Java 集合遍历 ======
functionprintList(list) {
if (list === null) { console.log("null"); return; }
console.log("List (size=" + list.size() + "):");
for (var i = 0; i < list.size(); i++) {
console.log(" [" + i + "] " + list.get(i));
}
}
functionprintMap(map) {
if (map === null) { console.log("null"); return; }
console.log("Map (size=" + map.size() + "):");
var MapEntry = Java.use("java.util.Map$Entry");
var iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
var entry = Java.cast(iterator.next(), MapEntry);
console.log(" " + entry.getKey() + " => " + entry.getValue());
}
}
functionprintSet(set) {
if (set === null) { console.log("null"); return; }
console.log("Set (size=" + set.size() + "):");
var iterator = set.iterator();
while (iterator.hasNext()) {
console.log(" " + iterator.next());
}
}
functionprintBundle(bundle) {
if (bundle === null) { console.log("null"); return; }
console.log("Bundle:");
var iterator = bundle.keySet().iterator();
while (iterator.hasNext()) {
var key = iterator.next().toString();
var value = bundle.get(key);
console.log(" " + key + " => " + (value !== null ? value.toString() : "null"));
}
}
13.3 核心 API 单行速查(13 条)
// 1. 环境检查
console.log(Java.available, Java.androidVersion);
// 2. 获取应用 Context
var ctx = Java.use("android.app.ActivityThread")
.currentApplication().getApplicationContext();
// 3. 主动调用静态方法
Java.use("com.example.Utils").encrypt("test");
// 4. 搜索堆上实例(早终止:return 'stop')
Java.choose("com.example.TokenManager", {
onMatch: function(inst) { console.log(inst.getToken()); return'stop'; },
onComplete: function() {}
});
// 5. 创建 Java 对象
Java.use("java.io.File").$new("/sdcard/test.txt");
// 6. 创建 Java 数组
Java.array("byte", [0x01, 0x02, 0x03]);
// 7. 类型转换
var jsonObj = Java.cast(obj, Java.use("org.json.JSONObject"));
// 8. 切换 ClassLoader(详见 3.3 节模板)
Java.classFactory.loader = targetLoader;
// 9. 搜索类名
Java.enumerateLoadedClassesSync()
.filter(function(c) { return c.indexOf("Encrypt") !== -1; });
// 10. 打印调用栈(完整版见 13.2 节的 getAppStack)
console.log(getAppStack(5, "com.example"));
// 11. 主线程执行
Java.scheduleOnMainThread(function() { /* UI 操作放这里 */ });
// 12. Hook 不触发时的最后手段
Java.deoptimizeBootImage(); // 只对 framework 类;优于 deoptimizeEverything
// Java.deoptimizeEverything(); // 全局降速 3-5×,万不得已才用
// 13. 保存 Hook 回调中的引用,防止 stale local reference
var saved = Java.retain(this); // 详见 2.3 节
总结
**写脚本前先想清楚"被动等还是主动取"**。同一个 App 数据,被动 Hook 等触发可能要等半小时,主动调用 Java.choose + 实例方法两秒就拿到。新手写脚本经常默认走 Hook 路线,结果跑十分钟才发现没人触发——选错了路。
80% 的"类找不到"都是 ClassLoader 问题,不是 API 用错。看到 ClassNotFoundException 不要先怀疑包名,直接套 3.5 节的 hookWithClassLoader 模板搜一遍。加固/插件化 App 几乎必中,常规 App 走默认分支也无副作用。
Java.cast 是接口/抽象类参数的开锁工具。形参声明类型只代表"承诺什么",实际类型才是"是什么"——前者决定能调什么方法,后者决定能拿到什么数据。加密分析里几乎每次必用,记住 Key → SecretKeySpec、AlgorithmParameterSpec → IvParameterSpec/GCMParameterSpec 这两组就够。
registerClass 是接口替换的唯一干净解法。当目标是个匿名内部类回调、且实现类名混淆到无法稳定定位时,与其反复定位,不如自己注册一个代理插进去。
工具函数集中维护,别在每篇脚本里重写。本篇把 bytesToHex、smartPrintBytes、getAppStack 等十余个高频函数汇成一个可拷贝的工具集,后续直接引用即可。