【Android安全-Frida的使用】此文章归类为:Android安全。
Frida 是动态插桩框架,可在运行时注入脚本(JavaScript/Python)到目标进程,用于 hook 函数、改参数/返回值、追踪调用等,常用于逆向、安全测试和自动化。
前提条件:Python3.7+
# 安装 Frida 工具和 Python 绑定 pip install frida-tools # 或指定版本(建议与手机端 frida-server 版本一致) pip install frida-tools==17.3.2 pip install frida==17.3.2
下载地址:Frida Releases
解压得到 frida-server(或 frida-server-16.x.x-android-arm64)
注意:运行 Frida 服务器需要设备具备 root 权限,真机必须先完成 root,模拟器默认已具备权限,无需额外
adb push frida-server-17.3.2-android-arm64 /data/local/tmp/frida-server adb shell cd /data/local/tmp ./data/local/tmp/frida-server-17.3.2-android-arm64
Frida的使用
| 命令 | 说明 |
|---|---|
frida-ps | 列出本机进程 |
frida-ps -U | 列出 USB 连接的 Android 设备上的进程 |
frida-ps -H 127.0.0.1:27042 | 列出指定 frida-server 上的进程 |
frida-ps -U -a | 仅显示应用(包名) |
frida-ps -U -a -i | 应用 + 进程 PID |
注意:以下所有操作,需要确保真机或模拟器中的 frida-server 已正常运行。
# 附加到已运行进程(包名) frida -U -n com.example.app -l script.js # 附加到 PID frida -U -p 12345 -l script.js # 生成模式:启动 app 并立即附加(常用) frida -U -f com.app.package -l script.js -o out.log
| 参数 | 含义 |
|---|---|
-U | 使用 USB 设备 |
-n <name> | 按进程名/包名附加 |
-p <pid> | 按 PID 附加 |
-f <package> | 生成(spawn)并附加 |
-l <file> | 加载js脚本 |
-o file | 输出日志到文件 |
其中Java.perform(fn)最常用
| API | 执行线程 | 是否等待 Java 就绪 | 典型用途 |
|---|---|---|---|
| Java.perform(fn) | Frida 线程,但在 JVM 上下文 | ✅ 自动等待 | 常规 Hook、调用 Java 方法 |
| Java.performNow(fn) | 当前线程,同步 | ❌ 需已就绪 | 立即执行、快速取状态 |
| Java.scheduleOnMainThread(fn) | 主线程(UI 线程) | 依赖当前 JVM 状态 | 必须在主线程做的 UI/系统调用 |
setTimeout
作用:在指定毫秒数之后执行一次回调,不阻塞后面的脚本
Java.perform(function() {
setTimeout(function() {
var MainActivity = Java.use("com.example.MainActivity");
MainActivity.onCreate.implementation = function(bundle) {
console.log("onCreate hooked");
this.onCreate(bundle);
};
}, 5000); // 5 秒后再 Hook,避免类未加载
});Java.perform(function() {
setTimeout(function() {
v const targetClass = Java.use("com.cr.test.CFoo");
console.log("成功获取目标类:", targetClass.$className); // 打印类名验证
// 步骤4:Hook目标类中的foo方法(需匹配原方法参数列表)
// 原方法参数:char ch, float f, long l, double d, String str, byte b, int n
targetClass.foo.implementation = function(ch, f, l, d, str, b, n) {
// 步骤5.1:获取并打印被Hook方法的参数(用于调试参数传递)
console.log("\n=== 被Hook方法调用,参数如下 ===");
console.log("char参数:", ch);
console.log("float参数:", f);
console.log("long参数:", l);
console.log("double参数:", d);
console.log("String参数:", str);
console.log("byte参数:", b);
console.log("int参数:", n);
// 步骤5.2:调用原方法(可传递原始参数或自定义参数)
// 此处演示传递自定义参数调用原方法
console.log("\n=== 调用原方法(自定义参数) ===");
const originalResult = this.foo('b', 3.15, 9999, 99.9, "9999", 99);
console.log("原方法返回值:", originalResult);
// 步骤5.3:修改返回值(将原方法返回值替换为自定义值100)
console.log("\n=== 修改返回值为100 ===");
return 100;
};
}, 5000); // 5 秒后再 Hook,避免类未加载
});// 进入Java虚拟机上下文(必须通过Java.perform包裹Java层操作)
Java.perform(() => {
try {
// 获取目标类的引用(com.kr.test.CFoo为待Hook的类名)
const targetClass = Java.use("com.kr.test.CFoo");
console.log("成功获取目标类:", targetClass.$className); // 打印类名验证
// ===================== HOOK 多参数重载方法 =====================
// 通过overload明确指定参数列表
targetClass.foo.overload('char', 'float', 'long', 'double', 'java.lang.String', 'byte', 'int').implementation = function(ch, f, l, d, str, b, n){
// 打印被Hook方法的参数
console.log("\n=== 被Hook方法(多参数重载)调用,参数如下 ===");
console.log("char参数:", ch);
console.log("float参数:", f);
console.log("long参数:", l);
console.log("double参数:", d);
console.log("String参数:", str);
console.log("byte参数:", b);
console.log("int参数:", n);
// 调用原方法(传递自定义参数)
console.log("\n=== 调用原方法(自定义参数) ===");
const originalResult = this.foo('b', 3.15, 9999, 99.9, "9999", 99);
console.log("原方法返回值:", originalResult);
// 修改返回值为100
console.log("\n=== 修改返回值为100 ===");
return 100;
};
// ===================== HOOK 单int参数重载方法 =====================
targetClass.foo.overload('int').implementation = function(n){
console.log("foo(int) 重载方法被调用,参数:", n);
// 调用原方法(传递自定义参数20)
this.foo(20);
// 修改返回值为0
return 0;
}
console.log("foo的两个重载方法均Hook成功,等待调用...");
} catch (err) {
console.error("Hook过程出错:", err.message);
}
});// 进入Java虚拟机上下文(必须通过Java.perform包裹Java层操作)
Java.perform(function() {
try {
// 获取目标类的引用
const targetClass = Java.use("com.kr.test.CFoo");
console.log("成功获取目标类:", targetClass.$className);
// Hook 无参构造函数
targetClass.$init.overload().implementation = function() {
console.log("CFoo 构造");
// 调用原构造函数(必须加,否则会崩溃)
return this.$init();
};
} catch (err) {
console.error("Hook出错:", err.message);
}
});// 进入Java虚拟机上下文(必须通过Java.perform包裹Java层操作)
Java.perform(() => {
try {
// 获取目标类的引用(com.kr.test.CFoo为待Hook的类名)
const targetClass = Java.use("com.kr.test.CFoo");
console.log("成功获取目标类:", targetClass.$className); // 打印类名验证
// Hook int数组参数的 foo 方法
targetClass.foo.overload('[I').implementation = function(a) {
console.log("foo 来了", a);
// 调用原方法,传入自定义数组
this.foo([1, 2, 3, 4, 5]);
};
console.log("foo 方法Hook成功,等待调用...");
} catch (err) {
console.error("Hook过程出错:", err.message);
}
});基础调用
Java.perform(function () {
// 1. 获取目标类
var TargetClass = Java.use("com.example.app.TargetClass");
// 2. 直接调用静态方法 (假设方法名为 staticMethod)
var result = TargetClass.staticMethod();
console.log("静态方法返回: " + result);
// 3. 传入参数调用
TargetClass.staticMethodWithArgs("Hello", 123);
});处理方法重载
如果静态方法有多个重载版本,必须使用 .overload() 指定参数签名,然后再用 .call() 或直接加括号调用。
Java.perform(function () {
var TargetClass = Java.use("com.example.app.TargetClass");
// 调用参数为 (String, int) 的重载版本
// 写法 A:直接传参
TargetClass.myStaticFunc.overload('java.lang.String','int')("测试", 100);
// 写法 B:使用 call,第一个参数必须是类对象本身
TargetClass.myStaticFunc.overload('java.lang.String','int').call(TargetClass, "测试", 100);
});如果你只需要调用一个工具类的实例方法,且该类允许随意实例化,可以直接 new 一个。
Java.perform(function () {
var TargetClass = Java.use("com.example.app.TargetClass");
// 1. 调用无参构造函数创建实例
var instance1 = TargetClass.$new();
instance1.instanceMethod(); // 调用实例方法
// 2. 调用有参构造函数创建实例
var instance2 = TargetClass.$new("参数1", 666);
instance2.instanceMethodWithArgs("Test");
// 3. 遇到构造函数重载
var instance3 = TargetClass.$init.overload('java.lang.String').call(TargetClass, "Hello");
});很多时候我们需要操作当前正在运行的单例或特定界面(如 MainActivity),此时不能 new,必须从内存中找。
Java.perform(function () {
// 寻找内存中所有的 com.example.app.MainActivity 实例
Java.choose("com.example.app.MainActivity", {
// 每找到一个实例,就会回调一次 onMatch
onMatch: function (instance) {
console.log("找到实例: " + instance);
// 主动调用该实例的方法
var ret = instance.getUserInfo();
console.log("UserInfo: " + ret);
// 也可以修改实例的成员变量
instance.mUserName.value = "Frida_Hooked";
},
// 搜索完毕后回调
onComplete: function () {
console.log("内存搜索完毕");
}
});
});应用运行并在内存中加载了 DEX 后,我们可以遍历内存中所有的 ClassLoader,挨个尝试去寻找目标类,找到后将 Frida 的默认加载器替换为它。
原理:无论它是怎么加载的(文件加载、内存加载),只要加载进虚拟机了,必定存在一个与之绑定的 ClassLoader。
Java.perform(function () {
// 遍历内存中所有的 ClassLoader
Java.enumerateClassLoaders({
onMatch: function (loader) {
try {
// 尝试用这个 loader 去找目标类
var targetClass = loader.loadClass("com.dynamic.TargetClass");
if (targetClass) {
console.log("[+] 找到了目标 ClassLoader: " + loader);
// 【核心步骤】将 Frida 的默认 ClassLoader 切换为当前找到的
Java.classFactory.loader = loader;
// 现在可以正常使用 Java.use 去 Hook 或主动调用了
var TargetClass = Java.use("com.dynamic.TargetClass");
// 1. Hook
TargetClass.targetMethod.implementation = function () {
console.log("Hook 成功!");
return this.targetMethod();
};
// 2. 主动调用 (如果是静态方法)
TargetClass.staticMethod();
}
} catch (e) {
// 这个 loader 里没有这个类,忽略报错继续找下一个
console.log("[-] 没找到了目标 ClassLoader" );
}
},
onComplete: function () {
console.log("[*] ClassLoader 遍历结束");
}
});
});如果不知道目标 DEX 什么时候加载,或者上面的方法执行太早(DEX 还没加载进内存),你可以直接 Hook 最底层的 loadClass 方法,守株待兔。
原理:所有动态加载的类,最终都要经过 ClassLoader.loadClass(className) 进行类加载。
Java.perform(function () {
var ClassLoader = Java.use("java.lang.ClassLoader");
// Hook loadClass 方法
ClassLoader.loadClass.overload('java.lang.String').implementation = function (className) {
// 先让它原原本本地加载类
var result = this.loadClass(className);
// 如果加载的是我们需要的目标类
if (className === "com.dynamic.TargetClass") {
console.log("[+] 拦截到目标类正在被加载: " + className);
// 此时的 this 就是加载这个 DEX 的 ClassLoader
Java.classFactory.loader = this;
// 拿到上下文后,立刻进行 Hook
var TargetClass = Java.use(className);
TargetClass.secretFunc.implementation = function () {
console.log("动态类方法被调用");
return this.secretFunc();
}
}
return result;
};
});有些高级加壳或者动态加载(如从网络下载字节数组直接加载),它们不会将 DEX 落地到本地存储,而是直接在内存中加载。
原理:Android 8.0 之后引入了 InMemoryDexClassLoader,专门用于加载内存中的 ByteBuffer DEX。可以 Hook 它的构造函数来拦截。
Java.perform(function () {
// Hook ByteBuffer 版本的 Dex 加载器
var InMemoryDexClassLoader = Java.use("dalvik.system.InMemoryDexClassLoader");
InMemoryDexClassLoader.$init.overload('java.nio.ByteBuffer', 'java.lang.ClassLoader').implementation = function (buffer, parentLoader) {
console.log("[+] 拦截到内存 DEX 加载!");
// 先完成原始初始化
var result = this.$init(buffer, parentLoader);
// 此时 this 就是用于加载这个内存 DEX 的 ClassLoader
Java.classFactory.loader = this;
try {
var TargetClass = Java.use("com.dynamic.InMemoryTarget");
TargetClass.doSomething(); // 主动调用
} catch(e) {
console.log("未找到目标类");
}
return result;
};
}); 如果你不关心类是怎么加载的,且确信APP已经在运行过程中创建过该类的实例,你可以直接用 Java.choose 扫内存。
原理:每个对象都带有自己所属类的引用,类上带有 ClassLoader 的引用。一旦 Java.choose 成功找到实例,Frida 内部会自动帮你搞定 ClassLoader 上下文。
Java.perform(function () {
// 注意:如果在默认 ClassLoader 中找不到该类,这里可能会直接报错 Class Not Found。
// 但如果你之前用别的手段(比如拿到某个通用接口对象)拿到了实例:
// 假设你不知道动态类的名字,但知道它实现了某个系统接口,比如 Runnable
Java.choose("java.lang.Runnable", {
onMatch: function (instance) {
var className = instance.getClass().getName();
// 发现这个 Runnable 是动态加载的 DEX 里的某个类
if (className === "com.dynamic.MyRunnable") {
console.log("[+] 找到动态类实例!");
// 获取加载该实例的 ClassLoader 并切换
Java.classFactory.loader = instance.getClass().getClassLoader();
// 此时可以去主动调用了
var DynamicClass = Java.use("com.dynamic.MyRunnable");
DynamicClass.staticMethod(); // 调静态方法
instance.instanceMethod(); // 直接调实例方法
}
},
onComplete: function () {}
});
});要 Hook 一个 C++ 函数,首先必须拿到它在内存中的绝对地址。根据函数是否被导出,有两种找法:
C++ 编译后会对函数名进行“粉碎”(如 int add(int, int) 会变成 _Z3addii)。你需要在 IDA 的 Exports 窗口中找到这个奇怪的名字
// 1. 通过导出名直接获取地址
var funcName = "_ZN7MyClass3addEii"; // IDA 中看到的粉碎后的名字
var funcPtr = Module.findExportByName("libtarget.so", funcName);
if (funcPtr) {
console.log("找到函数地址: " + funcPtr);
}sub_18EE0 的函数。此时需要通过 模块基址 + IDA偏移 来定位// 1. 获取 so 模块的基址
var baseAddr = Module.findBaseAddress("libtarget.so");
// 2. 加上 IDA 中看到的偏移量 (例如 0x18EE0)
// 注意:如果是 32位 ARM 且为 Thumb 指令集,偏移量必须 +1 (即 0x18EE1)
var offset = 0x18EE0;
var funcPtr = baseAddr.add(offset);
console.log("计算出目标函数地址: " + funcPtr);拿到指针后,使用 Interceptor.attach 进行拦截,可以在执行前读写参数,执行后读写返回值。
Interceptor.attach(funcPtr, {
// 每次函数被调用前执行
onEnter: function (args) {
console.log("进入函数!");
// args 数组存放了参数指针,args[0] 是第一个参数,依次类推
console.log("参数 1 (int): " + args[0].toInt32());
// 读取字符串参数 (假设 arg[1] 是 char*)
console.log("参数 2 (char*): " + args[1].readUtf8String());
// 修改参数:把第一个参数强制改成 999
args[0] = ptr(999);
},
// 函数执行完即将返回时执行
onLeave: function (retval) {
// retval 是返回值的指针
console.log("原始返回值: " + retval.toInt32());
// 修改返回值:强制返回 1
retval.replace(1);
}
});在 C++ 中,类的非静态成员函数在编译后,第一个参数永远是隐含的 this 指针(指向当前对象的内存地址)。
Interceptor.attach(funcPtr, {
onEnter: function (args) {
// args[0] 是 this 指针!
var thisPtr = args[0];
console.log("当前对象的 this 指针: " + thisPtr);
// args[1] 才是源代码里的第一个参数
var age = args[1].toInt32();
console.log("传入的 age: " + age);
// 进阶:通过 this 指针 + 偏移,可以读取或修改对象的成员变量
// 假设 age 存在 this+0x10 的位置
var memberAge = thisPtr.add(0x10).readInt();
console.log("对象当前的 age 属性: " + memberAge);
}
});如果想自己主动去调某个 C++ 函数,需要使用 NativeFunction。你必须自己根据 IDA 中的定义,告诉 Frida 这个函数的返回值和参数类型。
语法:new NativeFunction(地址, '返回值类型', ['参数1类型', '参数2类型', ...])
假设我们要调用的函数原型为:int calculate(char* str, int type)
var baseAddr = Module.findBaseAddress("libtarget.so");
var funcPtr = baseAddr.add(0x1234);
// 定义 NativeFunction
var calculateFunc = new NativeFunction(funcPtr, 'int', ['pointer', 'int']);
// 准备参数(如果是字符串,需要在内存中先开辟空间)
var strPtr = Memory.allocUtf8String("hello frida");
var typeArg = 1;
// 主动调用!
var result = calculateFunc(strPtr, typeArg);
console.log("主动调用结果: " + result);假设原型:int MyClass::getValue(int a) 由于需要 this 指针,你必须先在内存中找到一个合法的 MyClass 对象实例。
// 1. 定义实例方法,第一个参数类型必须声明为 pointer (代表 this)
var getValueFunc = new NativeFunction(funcPtr, 'int', ['pointer', 'int']);
// 2. 假设你在别的 Hook 里,或者通过搜内存拿到了一个 MyClass 对象的指针
var myObjPtr = ptr("0x7abc1234");
// 3. 主动调用时,强行把对象指针作为第一个参数传进去
var res = getValueFunc(myObjPtr, 100);如果你不想原函数执行,想完全用自己的逻辑顶替它,可以使用 Interceptor.replace。
var funcPtr = Module.findBaseAddress("libtarget.so").add(0x18EE0);
// 创建一个替代函数
var replacementFunc = new NativeCallback(function (arg1, arg2) {
console.log("原函数被劫持了!参数: " + arg1 + ", " + arg2);
// 你可以自己算一个结果返回,原函数的逻辑完全不会跑
return 1024;
}, 'int', ['pointer', 'int']);
// 替换掉原函数
Interceptor.replace(funcPtr, replacementFunc);
// 如果后面想恢复原函数:
// Interceptor.revert(funcPtr); 在 Frida 中,NativePointer 是最核心的基础数据类型,它完全等价于 C/C++ 中的指针(如 void*、char*、int*)。它代表的是一个内存地址。
在拦截 Native 函数时,args[0]、retval,以及 Module.findBaseAddress 的返回值,全都是 NativePointer 对象。
你可以随时使用内置函数 ptr() 将一个字符串或数字转换成内存地址指针:
// 1. 从十六进制字符串创建(最常用)
var p1 = ptr("0x12345678");
// 2. 从数字创建
var p2 = ptr(123456);
// 3. 空指针 (NULL)
var pNull = ptr("0x0");
var pNull2 = NULL; // Frida 内置常量在逆向中,最常见的操作就是 基址 + 偏移,或者访问结构体/数组里的元素。注意:Frida 的指针运算不支持直接写 + 或 -,必须调用方法
var base = ptr("0x10000000");
// 1. 加法 (add) -> 比如计算函数绝对地址、访问结构体成员
var funcPtr = base.add(0x1234);
var structMember = base.add(8); // 访问偏移为 8 的成员
// 2. 减法 (sub)
var prevPtr = base.sub(0x10);
// 3. 按位与 (and) -> 常用于地址对齐计算
var alignedPtr = base.and(ptr("0xFFFFFFF0"));拿到指针后,我们最关心的是“这个地址里面存了什么”。Frida 提供了丰富的 .readXxx() 方法。
var p = args[0]; // 假设这是拦截到的某个参数指针 // 1. 读基本数值 (按照 C 语言类型长度) var num_8 = p.readU8(); // 读 1 个字节 (unsigned char) var num_16 = p.readU16(); // 读 2 个字节 (unsigned short) var num_32 = p.readInt(); // 读 4 个字节 (int) var num_64 = p.readS64(); // 读 8 个字节 (long long) - 注意返回的是 Int64 对象 var fNum = p.readFloat(); // 读浮点数 (float) // 2. 读字符串 (极其常用) // 如果 p 是 char* var str1 = p.readUtf8String(); // 自动读到 \0 结尾 var str2 = p.readUtf8String(10); // 强制只读 10 个字节 var str3 = p.readCString(); // 读 ANSI C 字符串 // 3. 读指针 (解引用,相当于 C 里的 *p) // 如果 p 是一个指针的指针 (比如 void** 或结构体里的指针成员) var nextPtr = p.readPointer(); // 4. 读字节数组 (常用于 Dump 内存或看加密前后的密文) // 读取从该地址开始的 16 个字节的 ArrayBuffer var buffer = p.readByteArray(16);
可以直接修改内存中的值。
var p = args[0];
// 1. 写数值
p.writeInt(999); // 把这个地址的 4 个字节改成 999
p.writeFloat(3.14);
// 2. 写字符串
// 注意:原地址必须有足够的空间容纳新字符串,否则会内存溢出崩溃!
p.writeUtf8String("Hacked by Frida");
// 3. 写指针
p.writePointer(ptr("0x88888888"));
// 4. 写字节数组
// 比如写入一串特定的 Hex 字节
p.writeByteArray([0x2F, 0x3A, 0x4B, 0x5C]);var p = ptr("0x1234abcd");
// 1. 判断是否为空指针
if (p.isNull()) {
console.log("这是一个空指针!");
}
// 2. 判断两个指针是否相等
if (p.equals(ptr("0x1234abcd"))) {
// true
}
// 3. 转换成 JavaScript 可计算的普通数字 (注意:64位地址转换可能丢失精度)
var num = p.toInt32(); // 转成 32位数字
// 4. 格式化输出 (常用于打印日志)
console.log(p.toString()); // 输出: "0x1234abcd"
console.log(p.toString(10)); // 输出十进制字符串: "305441741"假设 C++ 中有这样一个结构体作为参数传给了某个函数:
struct User {
int id; // 偏移 0 (占4字节)
char* name; // 偏移 4 (32位占4字节, 64位占8字节)
float score; // 偏移 8 (32位) / 偏移 12 (64位)
};在 Frida 中如何通过 NativePointer 解析它(以 64 位为例):
Interceptor.attach(funcPtr, {
onEnter: function (args) {
// args[0] 是指向 User 结构体的 NativePointer (User* struct_ptr)
var structPtr = args[0];
if (structPtr.isNull())
return;
// 1. 读 int id; (偏移 0)
var id = structPtr.readInt();
// 2. 读 char* name; (在 64 位系统下,指针占 8 字节,所以偏移是 8)
// 第一步先读出 name 字段里存的那个指针地址
var namePtr = structPtr.add(8).readPointer();
// 第二步从那个地址读出真实的字符串
var nameStr = namePtr.readUtf8String();
// 3. 读 float score; (偏移是 8(name) + 8(指针长度) = 16)
var score = structPtr.add(16).readFloat();
console.log("解析结构体: ID=" + id + ", Name=" + nameStr + ", Score=" + score);
// 4. 修改结构体的值:把分数改成 100.0
structPtr.add(16).writeFloat(100.0);
}
});作用:将指定内存地址的数据,以标准十六进制的形式格式化输出
语法:
hexdump(target, options)
target:要读取的目标,通常是一个 NativePointer(内存地址),或者是 ArrayBuffer / Uint8Array 等。options(可选):一个字典对象,用于配置输出的格式(偏移、长度、颜色等)。
基础 用法:
Interceptor.attach(funcPtr, {
onEnter: function (args) {
// args[0] 是一个 NativePointer
// 默认情况下,它会从该地址往后读取 256 字节并打印
console.log("参数 1 的内存数据:");
console.log(hexdump(args[0]));
}
});输出效果:
0 1 2 3 4 5 6 7 8 9 A B C D E F 0123456789ABCDEF 0x7ff52010 48 65 6c 6c 6f 20 46 72 69 64 61 00 00 00 00 00 Hello Frida..... 0x7ff52020 01 00 00 00 05 00 00 00 ff ff ff ff 00 00 00 00 ................ ...
自定义读取长度和偏移:
Interceptor.attach(funcPtr, {
onEnter: function (args) {
var bufferPtr = args[0];
console.log(hexdump(bufferPtr, {
offset: 0, // 从指针位置偏移 0 字节开始读 (默认是0)
length: 16, // 只读取 16 个字节!(非常重要,防崩溃)
header: true, // 是否显示顶部的 0 1 2 3... 刻度 (默认true)
ansi: true // 是否在控制台使用彩色高亮输出 (默认false)
}));
}
});Java 层打印堆栈的本质是利用 Java 虚拟机自带的异常机制(Exception)或线程工具(Thread)来获取当前的调用链。
这是最常用的方式。通过构造一个异常对象,并用 Android 系统的 Log 工具类将其堆栈转化为字符串。
Java.perform(function () {
var TargetClass = Java.use("com.example.app.TargetClass");
var Log = Java.use("android.util.Log");
var Exception = Java.use("java.lang.Exception");
TargetClass.targetMethod.implementation = function () {
console.log("[+] targetMethod 被调用!");
// 核心代码:创建一个异常对象,提取其堆栈字符串
var stackStr = Log.getStackTraceString(Exception.$new());
console.log(stackStr);
return this.targetMethod();
};
});如果你不想引入 android.util.Log(比如在一些非 Android 的纯 Java 环境,或者系统类被魔改的情况),可以直接利用当前线程的堆栈数组。
Java.perform(function () {
var TargetClass = Java.use("com.example.app.TargetClass");
var Thread = Java.use("java.lang.Thread");
TargetClass.targetMethod.implementation = function () {
console.log("[+] targetMethod 被调用!");
// 获取当前线程的堆栈元素数组 (StackTraceElement[])
var stackElements = Thread.currentThread().getStackTrace();
// 遍历打印 (索引 0-2 通常是 VM 和 Thread 内部的方法,真正的调用栈从 3 或 4 开始)
for (var i = 2; i < stackElements.length; i++) {
console.log(" " + stackElements[i].toString());
}
return this.targetMethod();
};
});Java.perform(function () {
var TargetClass = Java.use("com.example.app.TargetClass");
var Throwable = Java.use("java.lang.Throwable");
TargetClass.targetMethod.implementation = function () {
// 直接打印到 Logcat (System.err)
Throwable.$new("Frida Stack Trace").printStackTrace();
return this.targetMethod();
};
});var funcPtr = Module.findExportByName("libtarget.so", "target_func");
Interceptor.attach(funcPtr, {
onEnter: function (args) {
console.log("[+] 进入 Native 函数");
// 1. 获取当前上下文的调用地址数组 (返回 NativePointer[])
// FUZZY 模式能在没有严格栈帧时(如被混淆或优化过)尽量猜出调用栈,但可能不准;
// ACCURATE 模式只依靠栈帧寄存器,准确但可能较短。
var bt = Thread.backtrace(this.context, Backtracer.ACCURATE);
// 2. 遍历地址并解析符号
for (var i = 0; i < bt.length; i++) {
var address = bt[i];
// 解析地址对应的符号(SO名字、函数名、偏移量)
var symbol = DebugSymbol.fromAddress(address);
console.log(" #" + i + " " + symbol.toString());
}
}
});
//一句代码快捷打印 (极简语法糖)
Interceptor.attach(funcPtr, {
onEnter: function (args) {
console.log("Native 堆栈:\n" +
Thread.backtrace(this.context, Backtracer.ACCURATE)
.map(DebugSymbol.fromAddress).join("\n") + "\n"
);
}
});在很多开启了混淆(如 OLLVM)或者由于编译器优化(如 -O3 省略了 Frame Pointer)的 SO 文件中,Backtracer.ACCURATE 往往只能打印出一层(就是当前函数),调用链会断掉。 这时候必须切换到 FUZZY 模式,让 Frida 去暴力扫描栈内存猜测调用链:
Interceptor.attach(funcPtr, {
onEnter: function (args) {
// 使用 Backtracer.FUZZY 替代 ACCURATE
var bt = Thread.backtrace(this.context, Backtracer.FUZZY);
console.log(bt.map(DebugSymbol.fromAddress).join("\n"));
}
});call (函数调用)、ret (函数返回)、exec (执行了一个基本块)。踪某个线程执行了哪些代码。通常会结合 Interceptor,在目标函数进入时开启 Stalker,函数返回时关闭 Stalker。
var funcPtr = Module.findExportByName("libtarget.so", "target_func");
Interceptor.attach(funcPtr, {
onEnter: function (args) {
console.log("[*] 进入目标函数,开启 Stalker...");
// 获取当前线程 ID
this.tid = Process.getCurrentThreadId();
// 开启 Stalker
Stalker.follow(this.tid, {
// events 对象决定你想监听哪些事件
events: {
call: true, // 监听函数调用 (BL/BLR等)
ret: false, // 监听函数返回 (RET)
exec: false, // 监听每一条执行的指令 (极其海量,极易卡死)
block: false, // 监听基本块的执行
compile: false // 监听 Stalker 编译新代码块的事件
},
// 当事件发生时,会回调 onReceive 接收数据
onReceive: function (events) {
// events 是一个 ArrayBuffer,包含了所有收集到的事件数据
// 必须使用 Stalker.parse() 将其解析为数组
var parsedEvents = Stalker.parse(events);
for (var i = 0; i < parsedEvents.length; i++) {
var event = parsedEvents[i];
var type = event[0]; // 事件类型,如 'call'
if (type === 'call') {
var location = event[1]; // 发生 call 指令的地址
var target = event[2]; // call 跳转的目标地址
// 为了可读性,尝试把地址转成函数名或偏移
var modTarget = Process.findModuleByAddress(target);
if (modTarget && modTarget.name === "libtarget.so") {
// 只打印我们关心的 SO 内部的调用
var offset = target.sub(modTarget.base);
console.log(" [Call] 跳转到: libtarget.so + " + offset);
}
}
}
}
});
},
onLeave: function (retval) {
console.log("[*] 目标函数执行完毕,关闭 Stalker");
// 记得关闭 Stalker,否则会造成巨大的性能开销和内存泄漏
Stalker.unfollow(this.tid);
}
});如果你不仅想“看”它执行了什么,还想在它执行某条特定指令时停下来运行你的 JS 代码,或者动态修改某条汇编指令,就要用到 transform。
var base = Module.findBaseAddress("libtarget.so");
Interceptor.attach(base.add(0x1234), {
onEnter: function(args) {
var tid = Process.getCurrentThreadId();
Stalker.follow(tid, {
// transform 回调:每次 Stalker 遇到一个新的基本块时被调用
transform: function (iterator) {
var instruction = iterator.next();
// 遍历这个基本块里的所有汇编指令
while (instruction !== null) {
// 1. 获取当前指令的地址、助记符、操作数
var addr = instruction.address;
var mnemonic = instruction.mnemonic;
var opStr = instruction.opStr;
// 如果这条指令在我们的目标 SO 里
if (Process.findModuleByAddress(addr)?.name === "libtarget.so") {
// 场景 A: 在特定指令前插桩 (Callout)
// 比如走到这句汇编时,打印寄存器的值
if (addr.equals(base.add(0x1250))) {
// iterator.putCallout 会在生成的 JIT 代码里插入一段跳回 JS 的代码
iterator.putCallout(function (context) {
console.log("[Callout] 走到 0x1250 了!");
console.log("当前 X0 寄存器: " + context.x0);
});
}
// 场景 B: 动态修改指令 (比如把一个 CMP 语句给 NOP 掉)
if (addr.equals(base.add(0x1260))) {
console.log("把 0x1260 的指令丢弃,不写进 JIT 内存 (相当于 NOP)");
// 如果我们不调用 iterator.keep(),这条指令就被删掉了
instruction = iterator.next();
continue;
}
}
// 核心:把当前指令保留下来写入 JIT 内存
iterator.keep();
// 继续看下一条指令
instruction = iterator.next();
}
}
});
},
onLeave: function() {
Stalker.unfollow(Process.getCurrentThreadId());
}
});onReceive 或 Callout 里使用了纯 JavaScript,由于 JS 引擎与 Native 频繁切换,会导致 App 很卡甚至直接 ANR。// 1. 用 C 语言写一段处理 Stalker 事件的代码
var ccode =
`#include <gum/gumstalker.h>
#include <stdio.h>
// 这个函数将会在 C 层直接处理 call 事件,速度极快!
void process_events (const GumEvent * event, GumCpuContext * cpu_context, gpointer user_data) {
if (event->type == GUM_CALL) {
gpointer location = event->call.location;
gpointer target = event->call.target;
// 我们可以在这里用 C 语言直接做过滤,比如只打印特定地址范围的
// printf("CModule Call: %p -> %p\\n", location, target);
}
}`;
// 2. 编译 CModule
var cmod = new CModule(ccode);
Interceptor.attach(funcPtr, {
onEnter: function (args) {
Stalker.follow(Process.getCurrentThreadId(), {
events: { call: true },
// 把 JS 的 onReceive 替换为 CModule 里的 C 函数指针
onEvent: cmod.process_events
});
},
onLeave: function () {
Stalker.unfollow(Process.getCurrentThreadId());
}
});更多【Android安全-Frida的使用】相关视频教程:www.yxfzedu.com