Hook 是一种运行时拦截技术。程序已经启动,代码已经在执行,但我们仍然可以在函数调用前后插入自己的逻辑,读取参数、修改参数、替换返回值,甚至直接调用某个原本不会被触发的方法。
在 Android 安全分析、逆向调试和自动化测试中,Hook 常用来解决这些问题:
- 观察关键函数调用链,例如登录、加密、签名、网络请求。
- 捕获运行时数据,例如明文参数、密钥、Token、校验结果。
- 修改函数行为,例如在实验环境中验证 Root 检测、证书校验、环境检测逻辑。
- 主动调用隐藏函数,例如触发某个未暴露的 Java 方法或 Native 函数。
所有操作都应该限定在授权 App、靶场或自有环境中。Frida 的能力很强,越靠近运行时底层,越需要明确测试边界。
1. Frida 是什么
Android 生态里常见的 Hook 方案主要有 Xposed 和 Frida。两者都能改变运行时行为,但适用方式不同。
| 框架 | 工作方式 | 优点 | 代价 | 适合场景 |
|---|---|---|---|---|
| Xposed | 修改系统层框架,模块长期生效 | 持久化能力强,适合系统级增强 | 通常需要刷机、Root 或虚拟环境,变更后可能需要重启 | 系统定制、长期模块开发 |
| Frida | 动态注入脚本到目标进程 | 注入快、脚本灵活、无需重启 App,支持 Java 和 Native | 需要运行 frida-server 或使用 Frida Gadget,版本要匹配 | 逆向分析、安全测试、快速验证逻辑 |
Frida 的常见工作模式是 Client/Server(客户端/服务端):
- PC 端运行 Frida 客户端,用 Python、命令行工具或 JavaScript 脚本控制目标。
- Android 端运行
frida-server,接收 PC 端指令,并把脚本注入到目标进程。 - 脚本进入目标 App 后,可以访问 Java 虚拟机,也可以操作 Native 内存。
flowchart LR
A[PC: frida / frida-tools] --> B[ADB 端口转发]
B --> C[Android: frida-server]
C --> D[目标 App 进程]
D --> E[Java VM]
D --> F[Native SO / libc]
E --> D
F --> D
这套架构决定了安装时必须处理两件事:PC 端 Frida 版本、Android 端 frida-server 版本。两端版本不一致时,经常会出现连接失败、脚本无法注入、命令执行异常等问题。
2. Frida 环境搭建
2.1 PC 端安装
PC 端需要 Python 环境。Frida 核心库和命令行工具可以直接用 pip 安装:
pip install frida
pip install frida-tools
如果需要固定版本,例如使用 16.7.14:
pip install frida==16.7.14
pip install frida-tools
查看当前 Frida 版本:
frida --version
这个版本号后面要和 Android 端 frida-server 保持一致。
2.2 确认 Android 设备架构
连接手机并开启 USB 调试后,用 Android Debug Bridge(ADB,Android 调试桥)查看 CPU 的 Application Binary Interface(ABI,应用二进制接口):
adb shell getprop ro.product.cpu.abi
常见输出与下载版本对应关系如下:
| 输出 | 对应 frida-server |
|---|---|
arm64-v8a | android-arm64,目前主流真机 |
armeabi-v7a | android-arm,老设备或 32 位环境 |
x86 | android-x86,部分模拟器 |
x86_64 | android-x86_64,部分 64 位模拟器 |
例如 PC 端 Frida 是 16.7.14,手机是 arm64-v8a,就应该下载:
frida-server-16.7.14-android-arm64.xz
解压后得到可执行文件:
frida-server-16.7.14-android-arm64
2.3 推送并运行 frida-server
把服务端推送到手机的临时目录:
adb push frida-server-16.7.14-android-arm64 /data/local/tmp/
进入手机 Shell,并切换到 Root 权限:
adb shell
su
赋予执行权限并启动:
cd /data/local/tmp
chmod +x frida-server-16.7.14-android-arm64
./frida-server-16.7.14-android-arm64 &
& 表示后台运行,终端断开时不至于立刻阻塞当前 Shell。
为了减少输入长度,也可以改名:
mv frida-server-16.7.14-android-arm64 fs
chmod +x fs
./fs &
简单字符串检测可能会扫描进程名、文件名或默认端口,把文件名改短只能避开很粗糙的检测,不能当作完整对抗方案。
2.4 设置端口转发
Frida 默认使用 27042 和 27043 端口。USB 连接时,PC 端需要把本地端口转发到设备端:
adb forward tcp:27042 tcp:27042
adb forward tcp:27043 tcp:27043
如果默认端口被占用,可以让 frida-server 监听自定义端口:
./fs -l 0.0.0.0:8888 &
PC 端建立对应转发:
adb forward tcp:8888 tcp:8888
连接时指定 Host:
frida -H 127.0.0.1:8888 -l hook.js -f com.example.target
3. 常用 Frida 命令
环境是否正常,先用设备枚举和进程枚举来验证。
| 命令 | 作用 |
|---|---|
frida-ls-devices | 列出当前 Frida 可识别的设备 |
frida-ps -U | 通过 USB 列出 Android 设备上正在运行的进程 |
frida-ps -Uai | 列出设备上已安装应用,包含包名和应用名 |
frida-ps -D <device_id> | 多设备连接时指定设备 |
frida -U -l hook.js -f <package> | Spawn 模式启动并注入 |
frida -U -l hook.js <process_name_or_pid> | Attach 模式附加到运行中进程 |
frida -H <ip>:<port> -l hook.js -f <package> | 远程或自定义端口连接 |
Frida 注入主要有两种方式。
| 模式 | 命令特征 | 目标标识 | 进程状态 | 适合场景 |
|---|---|---|---|---|
| Spawn | 使用 -f | 包名 | Frida 启动 App 后注入 | Hook Application.onCreate()、早期检测、Native 初始化 |
| Attach | 不使用 -f | 进程名或 PID | App 已经运行 | 中途观察业务逻辑、降低启动阶段干扰 |
Spawn 示例:
frida -U -l hook.js -f com.example.target
Attach 示例:
frida -U -l hook.js "Target App Name"
查包名:
frida-ps -Uai
查运行中的进程名或 PID:
# Linux / macOS
frida-ps -U | grep "example"
# Windows
frida-ps -U | findstr "example"
4. Java 层 Hook 基础
Android App 的 Java/Kotlin 代码最终运行在 Android Runtime(ART)上。Frida 提供了 Java API,可以在脚本里获取类、替换方法实现、访问字段、创建对象、枚举堆实例。
Java 层 Hook 的基本骨架是:
setImmediate(function () {
Java.perform(function () {
var TargetClass = Java.use("com.example.demo.MainActivity");
TargetClass.targetMethod.implementation = function (arg1, arg2) {
console.log("[*] arg1=" + arg1 + ", arg2=" + arg2);
var newArg1 = "patched";
var result = this.targetMethod(newArg1, arg2);
console.log("[*] original result=" + result);
return result;
};
});
});
核心 API 可以按用途理解:
| API / 属性 | 作用 |
|---|---|
Java.perform(fn) | 把当前 Frida 脚本线程附加到 Java Virtual Machine(VM,虚拟机),Java Hook 逻辑通常写在这里 |
Java.use(className) | 获取目标 Java 类的包装对象 |
method.implementation | 替换目标方法实现 |
this.method(...) | 在 Hook 函数内调用原始方法 |
Class.field.value | 读取或修改字段值 |
Class.$new(...) | 调用构造方法创建对象 |
Java.choose(className, callbacks) | 在堆内存中搜索已存在的对象实例 |
method.overload(...) | 指定重载方法的参数签名 |
4.1 执行时机:setImmediate 与 setTimeout
setImmediate 会在当前 JavaScript 事件循环结束后尽快执行,适合大多数 Java Hook:
setImmediate(function () {
Java.perform(function () {
console.log("[*] script loaded");
});
});
如果脚本注入过早,目标类尚未加载,可能出现 ClassNotFoundException。这时可以延迟执行:
setTimeout(function () {
Java.perform(function () {
console.log("[*] run after 1000 ms");
});
}, 1000);
Spawn 模式下尤其容易遇到时机问题,因为脚本可能早于 Activity 创建完成。
5. Hook 普通实例方法
普通实例方法属于某个对象。Hook 时,implementation 内部的 this 指向当前对象实例。
假设目标代码如下:
// com.ad2001.frida0x1.MainActivity
void check(int i, int i2) {
if ((i * 2) + 4 == i2) {
Toast.makeText(getApplicationContext(), "Yey you guessed it right", 1).show();
} else {
Toast.makeText(getApplicationContext(), "Try again", 1).show();
}
}
校验条件是:
(i * 2) + 4 == i2
可以在进入 check 时把参数改成满足条件的值:
setImmediate(function () {
Java.perform(function () {
let MainActivity = Java.use("com.ad2001.frida0x1.MainActivity");
MainActivity["check"].implementation = function (i, i2) {
console.log(`[*] original args: i=${i}, i2=${i2}`);
i = 0;
i2 = 4;
console.log(`[*] patched args: i=${i}, i2=${i2}`);
this["check"](i, i2);
};
});
});
方括号写法 this["check"](...) 比 this.check(...) 更稳,混淆后的方法名可能包含 $ 等特殊字符,点号写法容易出问题。
6. 调用静态方法
静态方法属于类本身,不依赖对象实例。
假设目标类里有一个静态方法,但 App 自己没有调用它:
public class MainActivity extends AppCompatActivity {
static TextView t1;
public static void get_flag(int a) {
if (a == 4919) {
t1.setText("FLAG is here...");
}
}
}
如果只是写 implementation,方法不被调用,Hook 就不会触发。此时应当主动调用:
setImmediate(function () {
Java.perform(function () {
let MainActivity = Java.use("com.ad2001.frida0x2.MainActivity");
console.log("[*] invoke get_flag(4919)");
MainActivity["get_flag"](4919);
});
});
判断使用 Hook 还是主动调用,可以看目标函数是否会自然执行:
| 场景 | 做法 |
|---|---|
| App 会调用目标方法 | 用 implementation 拦截并修改参数或返回值 |
| App 不会调用目标方法 | 直接通过类或实例主动调用 |
| 静态方法 | Class.method(...) |
| 实例方法 | 需要对象实例,可用 $new() 或 Java.choose() |
7. 修改静态字段
字段不是方法,不能用 implementation 替换。Frida 访问字段值要通过 .value。
目标逻辑:
public void onClick(View v) {
if (Checker.code == 512) {
Toast.makeText(getApplicationContext(), "YOU WON!!!", 1).show();
} else {
Toast.makeText(getApplicationContext(), "TRY AGAIN", 1).show();
}
}
public class Checker {
static int code = 0;
}
只需要在点击前把 Checker.code 改成 512:
setImmediate(function () {
Java.perform(function () {
let Checker = Java.use("com.ad2001.frida0x3.Checker");
console.log("[*] original code=" + Checker.code.value);
Checker.code.value = 512;
console.log("[*] patched code=" + Checker.code.value);
});
});
直接打印 Checker.code 得到的是 Frida 字段包装对象,不是字段值;读取真实数据必须使用 Checker.code.value。
8. 创建对象并调用非静态方法
有些目标方法是实例方法,但 App 当前流程没有创建该类对象。没有现成对象时,可以用 $new() 主动构造。
目标代码:
public class Check {
public String get_flag(int a) {
if (a == 1337) {
return "FLAG{...}";
}
return "";
}
}
Frida 脚本:
setImmediate(function () {
Java.perform(function () {
let CheckClass = Java.use("com.ad2001.frida0x4.Check");
let checkInstance = CheckClass.$new();
let flag = checkInstance.get_flag(1337);
console.log("[*] result=" + flag);
});
});
$new() 等价于 Java 里的 new Check()。如果构造方法有参数,就把参数传进去:
let obj = CheckClass.$new(1, "abc");
9. 获取当前内存中的实例
Android 的 Activity、Service、Application 等对象由系统管理,不应该随便 $new()。自己创建的 Activity 不代表当前屏幕上的 Activity,也不能控制当前 UI。
这种情况要用 Java.choose() 从堆里找已有实例。
目标代码:
public class MainActivity extends AppCompatActivity {
public void flag(int code) {
if (code == 1337) {
// update UI and show flag
}
}
}
脚本:
setTimeout(function () {
Java.perform(function () {
Java.choose("com.ad2001.frida0x5.MainActivity", {
onMatch: function (instance) {
console.log("[*] found instance: " + instance);
instance.flag(1337);
return "stop";
},
onComplete: function () {
console.log("[*] search complete");
}
});
});
}, 1000);
Java.choose() 的工作流程如下:
flowchart TD
A[开始堆扫描] --> B{发现目标类实例?}
B -- 是 --> C[调用 onMatch(instance)]
C --> D{是否 return stop?}
D -- 是 --> E[停止扫描]
D -- 否 --> B
B -- 否 --> F[继续遍历堆]
F --> B
E --> G[调用 onComplete]
B -- 扫描结束 --> G
return "stop" 很适合 Activity 或单例对象,找到第一个就停止,减少扫描开销。
10. 调用带对象参数的方法
实际业务里,方法参数常常是自定义对象,而不是简单的 int 或 String。调用这类方法时,要构造参数对象并设置字段。
目标代码:
public class MainActivity extends AppCompatActivity {
public void get_flag(Checker A) {
if (1234 == A.num1 && 4321 == A.num2) {
// success
}
}
}
public class Checker {
int num1;
int num2;
}
调用者是当前 MainActivity 实例,参数是 Checker 对象。脚本需要完成三步:
- 找到
MainActivity实例。 - 创建
Checker对象。 - 设置
num1和num2后调用get_flag()。
setTimeout(function () {
Java.perform(function () {
Java.choose("com.ad2001.frida0x6.MainActivity", {
onMatch: function (instance) {
let CheckerClass = Java.use("com.ad2001.frida0x6.Checker");
let checkerObj = CheckerClass.$new();
checkerObj.num1.value = 1234;
checkerObj.num2.value = 4321;
instance["get_flag"](checkerObj);
return "stop";
},
onComplete: function () {
console.log("[*] search complete");
}
});
});
}, 1000);
字段赋值同样要使用 .value。
11. Hook 构造方法
Java 构造方法在 Frida 中对应 $init。
目标代码:
public class Checker {
int num1;
int num2;
public Checker(int a, int b) {
this.num1 = a;
this.num2 = b;
}
}
public class MainActivity extends AppCompatActivity {
public void onCreate(Bundle savedInstanceState) {
Checker ch = new Checker(123, 321);
flag(ch);
}
public void flag(Checker A) {
if (A.num1 > 512 && A.num2 > 512) {
// success
}
}
}
Checker 初始化为 (123, 321),不满足条件。可以在对象创建源头修改参数:
setImmediate(function () {
Java.perform(function () {
let Checker = Java.use("com.ad2001.frida0x7.Checker");
Checker["$init"].implementation = function (a, b) {
console.log(`[*] Checker.$init: a=${a}, b=${b}`);
a = 999;
b = 999;
console.log(`[*] patched: a=${a}, b=${b}`);
this["$init"](a, b);
};
});
});
如果构造方法存在重载,必须指定参数签名:
Checker["$init"].overload("int", "int").implementation = function (a, b) {
return this["$init"](999, 999);
};
也可以不改构造方法,而是在 flag() 执行前替换对象:
setImmediate(function () {
Java.perform(function () {
let MainActivity = Java.use("com.ad2001.frida0x7.MainActivity");
let Checker = Java.use("com.ad2001.frida0x7.Checker");
MainActivity["flag"].implementation = function (A) {
let newChecker = Checker.$new(999, 999);
this["flag"](newChecker);
};
});
});
两种方案的影响范围不同:
| 方案 | 影响范围 | 适合场景 |
|---|---|---|
Hook flag() 参数 | 只影响该校验方法 | 不希望改变其他 Checker 创建逻辑 |
Hook $init 构造方法 | 所有 new Checker(...) 都会受影响 | 目标类只服务于当前校验,或需要从源头改数据 |
12. Hook 重载方法
Java 支持方法重载,同名方法可以有不同参数列表:
private void check(String str) {
Toast.makeText(this, "String: " + str, 0).show();
}
private void check(int i) {
Toast.makeText(this, "Int: " + i, 0).show();
}
Frida 如果只写:
Challenge4Activity["check"].implementation = function (arg) {};
会因为不知道目标是哪一个重载版本而报歧义错误。正确做法是用 .overload() 指定签名:
setImmediate(function () {
Java.perform(function () {
let Activity = Java.use("com.xiusi.fridastudy.Challenge4Activity");
Activity["check"].overload("java.lang.String").implementation = function (str) {
console.log("[*] check(String): " + str);
this["check"]("patched string");
};
Activity["check"].overload("int").implementation = function (i) {
console.log("[*] check(int): " + i);
this["check"](99999);
};
});
});
签名书写规则:
| Java 类型 | Frida overload 写法 |
|---|---|
int | "int" |
boolean | "boolean" |
float | "float" |
java.lang.String | "java.lang.String" |
android.os.Bundle | "android.os.Bundle" |
byte[] | "[B" |
String[] | "[Ljava.lang.String;" |
| 自定义类 | 完整包名,例如 "com.example.Checker" |
不确定签名时,可以先故意不写或写错 .overload()。Frida 的报错信息通常会列出所有可用重载签名,把正确签名复制回来即可。
13. Native 层 Hook 基础
Native 层指 C/C++ 编译出的 Shared Object(SO,共享库),文件一般是 .so。Java 层通过 Java Native Interface(JNI,Java 本地接口)调用 Native 函数。
Native Hook 面对的是函数地址、指针、寄存器和内存。Frida 主要用这些 API:
| API | 作用 |
|---|---|
Module.findExportByName(so, name) | 根据导出符号查函数地址,找不到返回 null |
Module.getExportByName(so, name) | 根据导出符号查函数地址,找不到抛异常 |
Module.findBaseAddress(so) | 获取 SO 加载基址 |
Module.enumerateExports(so, callbacks) | 枚举导出符号 |
Module.enumerateImports(so, callbacks) | 枚举导入符号 |
Interceptor.attach(address, callbacks) | 拦截 Native 函数 |
NativeFunction(address, ret, args) | 把 Native 地址包装成可调用函数 |
Memory.read* / write* | 读写内存 |
hexdump(ptr, options) | 打印内存块 |
Native Hook 的典型流程:
flowchart TD
A[确认目标 SO 已加载] --> B{目标函数是否有导出符号?}
B -- 有 --> C[Module.findExportByName]
B -- 无 --> D[Module.findBaseAddress + offset]
C --> E[得到函数绝对地址]
D --> E
E --> F[Interceptor.attach 或 NativeFunction]
F --> G[读取参数 / 修改返回值 / 主动调用]
13.1 Interceptor.attach 基本模板
以 strcmp 为例:
function hookNative() {
var funcPtr = Module.findExportByName("libc.so", "strcmp");
console.log("[*] strcmp address: " + funcPtr);
if (!funcPtr) {
console.log("[-] strcmp not found");
return;
}
Interceptor.attach(funcPtr, {
onEnter: function (args) {
var s1 = Memory.readUtf8String(args[0]);
var s2 = Memory.readUtf8String(args[1]);
console.log("[*] strcmp");
console.log(" s1=" + s1);
console.log(" s2=" + s2);
},
onLeave: function (retval) {
console.log("[*] retval=" + retval.toInt32());
}
});
}
onEnter 在函数执行前触发,适合读取或修改参数。onLeave 在函数返回前触发,适合读取或替换返回值。
13.2 处理 SO 加载时机
App 自带 SO 通常不是进程启动时立刻加载,而是在 System.loadLibrary() 执行时加载。SO 没加载前,找不到内部函数地址。
可以 Hook java.lang.Runtime.loadLibrary0,等目标库加载后再执行 Native Hook:
setImmediate(function () {
Java.perform(function () {
var Runtime = Java.use("java.lang.Runtime");
Runtime.loadLibrary0
.overload("java.lang.Class", "java.lang.String")
.implementation = function (loader, libname) {
var ret = this.loadLibrary0(loader, libname);
console.log("[*] loaded library: " + libname);
if (libname.indexOf("targetlib") >= 0) {
hookNative();
}
return ret;
};
});
});
部分 Android 版本或 Frida 版本中,loadLibrary0 签名可能不同。遇到 overload 错误时,打印可用重载签名再调整。
14. Native 内存读写
Native 参数通常是指针,args[0]、args[1] 得到的是 NativePointer,不是 JavaScript 字符串或数字。需要用 Memory API 读取指针指向的数据。
| API | 用途 | 示例 |
|---|---|---|
Memory.readUtf8String(ptr) | 读取 char * 字符串 | Memory.readUtf8String(args[0]) |
Memory.writeUtf8String(ptr, str) | 写入字符串 | Memory.writeUtf8String(args[0], "abc") |
Memory.readInt(ptr) | 读取 int * 指向的整数 | Memory.readInt(args[1]) |
Memory.writeInt(ptr, value) | 写入整数 | Memory.writeInt(args[1], 1337) |
Memory.readByteArray(ptr, len) | 读取二进制数据 | Memory.readByteArray(args[2], 16) |
hexdump(ptr, options) | 十六进制打印内存 | hexdump(args[0], { length: 64 }) |
综合示例:
Interceptor.attach(targetAddr, {
onEnter: function (args) {
var strArg = Memory.readUtf8String(args[0]);
var count = Memory.readInt(args[1]);
console.log(`[*] str=${strArg}, count=${count}`);
Memory.writeInt(args[1], 9999);
console.log(hexdump(args[0], {
offset: 0,
length: 32,
header: true,
ansi: true
}));
}
});
写内存时要确认目标缓冲区大小。如果原缓冲区只有 4 字节,却写入更长字符串,很容易覆盖相邻内存导致崩溃。
15. Native 辅助枚举脚本
逆向初期经常不知道目标 SO 名称、导出符号或导入函数。先枚举,再定位。
15.1 枚举已加载模块
setImmediate(function () {
console.log("[*] modules:");
Process.enumerateModules({
onMatch: function (module) {
console.log(
module.name.padEnd(40) +
module.base.toString().padEnd(20) +
module.size
);
},
onComplete: function () {
console.log("[*] done");
}
});
});
15.2 枚举指定 SO 的导出函数
setImmediate(function () {
var targetSo = "libtarget.so";
var module = Process.findModuleByName(targetSo);
if (!module) {
console.log("[-] module not found: " + targetSo);
return;
}
Module.enumerateExports(targetSo, {
onMatch: function (exp) {
if (exp.name.indexOf("flag") >= 0) {
console.log(exp.name + " " + exp.address + " " + exp.type);
}
},
onComplete: function () {
console.log("[*] exports done");
}
});
});
15.3 枚举指定 SO 的导入函数
setImmediate(function () {
var targetSo = "libtarget.so";
var module = Process.findModuleByName(targetSo);
if (!module) {
console.log("[-] module not found: " + targetSo);
return;
}
Module.enumerateImports(targetSo, {
onMatch: function (imp) {
console.log(
imp.name + " " +
imp.address + " " +
imp.type + " from " +
imp.module
);
},
onComplete: function () {
console.log("[*] imports done");
}
});
});
16. Hook 有符号函数
有符号函数指能在导出表找到名字的函数,常见两类:
- 系统库函数,例如
libc.so里的open、read、strcmp。 - JNI 导出函数,例如
Java_com_example_MainActivity_checkFlag。
目标 Java 代码:
public class MainActivity extends AppCompatActivity {
public native int cmpstr(String str);
static {
System.loadLibrary("frida0x8");
}
public void onClick(View v) {
int res = cmpstr(input);
if (res == 1) {
// success
}
}
}
Native 层内部大致逻辑:
// 伪代码
for (i = 0; i < len; i++) {
s2[i] = encrypted[i] - 1;
}
int v = strcmp(input, s2);
return v == 0;
既然最终调用了 strcmp(input, s2),就可以 Hook strcmp,观察两个比较参数。系统中 strcmp 调用很多,必须过滤日志,否则会刷屏。
function hookNative() {
var strcmpPtr = Module.findExportByName("libc.so", "strcmp");
if (!strcmpPtr) {
console.log("[-] strcmp not found");
return;
}
Interceptor.attach(strcmpPtr, {
onEnter: function (args) {
var s1 = Memory.readUtf8String(args[0]);
var s2 = Memory.readUtf8String(args[1]);
if (s1 && s1.indexOf("666") >= 0) {
console.log("[+] strcmp hit");
console.log(" input=" + s1);
console.log(" candidate=" + s2);
}
}
});
}
setImmediate(function () {
Java.perform(function () {
var Runtime = Java.use("java.lang.Runtime");
Runtime.loadLibrary0
.overload("java.lang.Class", "java.lang.String")
.implementation = function (loader, libname) {
var ret = this.loadLibrary0(loader, libname);
if (libname.indexOf("frida0x8") >= 0) {
hookNative();
}
return ret;
};
});
});
这里约定在输入框输入 666,只打印包含该特征的调用,能显著降低噪声。
17. 修改 Native 返回值
有些 Native 函数没有可控参数,只能改返回值。
Java 层:
public class MainActivity extends AppCompatActivity {
public native int check_flag();
static {
System.loadLibrary("a0x9");
}
public void onClick(View v) {
if (check_flag() == 1337) {
// success
} else {
// fail
}
}
}
Native 伪代码:
long Java_com_ad2001_a0x9_MainActivity_check_1flag() {
return 1;
}
目标要求 1337,实际返回 1。在 onLeave 里使用 retval.replace():
function hookNative() {
var funcName = "Java_com_ad2001_a0x9_MainActivity_check_1flag";
var checkPtr = Module.findExportByName("liba0x9.so", funcName);
if (!checkPtr) {
console.log("[-] function not found");
return;
}
Interceptor.attach(checkPtr, {
onEnter: function (args) {},
onLeave: function (retval) {
console.log("[*] original retval=" + retval.toInt32());
retval.replace(1337);
console.log("[+] retval patched to 1337");
}
});
}
setImmediate(function () {
Java.perform(function () {
var Runtime = Java.use("java.lang.Runtime");
Runtime.loadLibrary0
.overload("java.lang.Class", "java.lang.String")
.implementation = function (loader, libname) {
var ret = this.loadLibrary0(loader, libname);
if (libname.indexOf("a0x9") >= 0) {
hookNative();
}
return ret;
};
});
});
JNI 命名有转义规则,例如 Java 方法名里的 _ 在 JNI 符号里可能变成 _1。如果 findExportByName() 找不到函数,先用 Module.enumerateExports() 打印真实导出名。
18. 主动调用 Native 有符号函数
有些 Native 函数包含关键逻辑,但 Java 层没有声明,也不会主动调用。只要能拿到函数地址并知道参数类型,就可以用 NativeFunction 包装后直接调用。
假设导出表里发现可疑函数:
_Z8get_flagii
这是 C++ Name Mangling(名称修饰)后的符号名:
_Z表示 C++ 修饰符号。8get_flag表示函数名get_flag长度为 8。ii表示两个int参数。
Native 伪代码:
long get_flag(int a, int b) {
if (a + b == 3) {
__android_log_print(3, "FLAG", "Decrypted Flag: %s", decrypted_flag);
}
return 0;
}
脚本:
function invokeNative() {
var funcPtr = Module.findExportByName("libfrida0xa.so", "_Z8get_flagii");
if (!funcPtr) {
console.log("[-] get_flag not found");
return;
}
console.log("[*] get_flag address=" + funcPtr);
var getFlag = new NativeFunction(funcPtr, "long", ["int", "int"]);
getFlag(1, 2);
console.log("[*] invoked get_flag(1, 2)");
}
setTimeout(function () {
Java.perform(function () {
invokeNative();
});
}, 1000);
NativeFunction 原型:
new NativeFunction(address, returnType, argTypes[, abi])
常见类型:
| C/C++ 类型 | Frida 类型 |
|---|---|
void | "void" |
| 指针 | "pointer" |
int | "int" |
unsigned int | "uint" |
long | "long" |
unsigned long | "ulong" |
char | "char" |
unsigned char | "uchar" |
float | "float" |
double | "double" |
如果函数内部把结果写到 Logcat,需要用 adb logcat 或 Android Studio 的日志面板查看输出。
19. Hook 无符号函数:基址 + 偏移
生产环境中的 SO 经常被 Strip,符号表被移除后,函数名可能只剩 sub_151C0 这类地址标识,无法用 findExportByName() 定位。
这时使用公式:
函数运行时绝对地址 = SO 运行时基址 + IDA/Ghidra 中的函数偏移
例如在 IDA Pro 或 Ghidra 里看到目标函数偏移是:
0x1DD60
脚本:
function invokeByOffset() {
var moduleName = "libfrida0xa.so";
var baseAddr = Module.findBaseAddress(moduleName);
if (!baseAddr) {
console.log("[-] module not found: " + moduleName);
return;
}
console.log("[*] base=" + baseAddr);
var offset = 0x1dd60;
var targetAddr = baseAddr.add(offset);
if (Process.arch === "arm") {
// 32 位 ARM 且目标函数是 Thumb 指令时才需要 +1
// targetAddr = targetAddr.add(1);
}
console.log("[*] target=" + targetAddr);
var fn = new NativeFunction(targetAddr, "long", ["int", "int"]);
fn(1, 2);
}
setTimeout(function () {
Java.perform(function () {
invokeByOffset();
});
}, 1000);
32 位 ARM 上有一个常见坑:Thumb 指令地址最低位需要置 1。如果目标函数是 Thumb 模式,地址要 +1。ARM64 不需要处理。
20. 修改汇编指令:Code Patching
Hook 是在函数入口或出口插入逻辑。遇到这些情况,直接 Patch 指令更合适:
- 函数内部有反调试或死循环。
- 关键判断位于函数中间,改返回值太晚。
- 要跳过某条条件跳转,例如
B.NE、CBZ、CBNZ。 - 目标函数没有稳定入口,或入口处逻辑太复杂。
代码段通常是只读可执行(r-x),写入前要改内存页权限:
var pageSize = Process.pageSize;
var pageStart = targetAddr.and(ptr(pageSize - 1).not());
Memory.protect(pageStart, pageSize, "rwx");
ARM64 写 NOP 的基本模板:
var writer = new Arm64Writer(targetAddr);
try {
writer.putNop();
writer.flush();
} finally {
writer.dispose();
}
关键 API:
| API | 作用 |
|---|---|
Memory.protect(ptr, size, prot) | 修改内存权限,ptr 必须页对齐 |
Arm64Writer | ARM64 指令写入器 |
X86Writer | x86 指令写入器 |
writer.putNop() | 写入空指令 |
writer.putBImm(addr) | 写入跳转指令 |
writer.flush() | 把缓冲区指令写入内存并刷新指令缓存 |
writer.dispose() | 释放 Writer 资源 |
一个典型场景:Native 函数内部存在条件跳转,阻止程序进入成功分支。
汇编片段:
MOV W8, #0xDEADBEEF
STUR W8, [X29,#var_24]
LDUR W8, [X29,#var_24]
SUBS W8, W8, #0x539
B.NE loc_1532C
逻辑含义:
0x539是十进制1337。0xDEADBEEF - 1337不为 0。B.NE条件成立,程序跳到失败分支。- 如果把
B.NE改成NOP,程序会继续向下执行成功路径。
Patch 脚本:
function patchCode() {
var moduleName = "libfrida0xb.so";
var baseAddr = Module.findBaseAddress(moduleName);
if (!baseAddr) {
console.log("[-] module not found");
return;
}
var offset = 0x15248;
var targetAddr = baseAddr.add(offset);
console.log("[*] patch address=" + targetAddr);
var pageSize = Process.pageSize;
var pageStart = targetAddr.and(ptr(pageSize - 1).not());
Memory.protect(pageStart, pageSize, "rwx");
var writer = new Arm64Writer(targetAddr);
try {
writer.putNop();
writer.flush();
console.log("[+] patch success");
} catch (e) {
console.log("[-] patch failed: " + e);
} finally {
writer.dispose();
}
}
setTimeout(function () {
Java.perform(function () {
patchCode();
});
}, 1000);
Patch 指令前必须确认架构、偏移和指令长度。ARM64 每条指令固定 4 字节,写一个 NOP 刚好覆盖一条指令;x86/x64 指令长度不固定,误覆盖半条指令会直接崩溃。
21. 常见问题与排查
| 问题 | 常见原因 | 处理方式 |
|---|---|---|
frida-ps -U 连接失败 | frida-server 未启动、端口未转发、USB 调试异常 | 检查 adb devices、重启 frida-server、执行 adb forward |
| 版本不匹配 | PC 端 frida 与手机端 frida-server 版本不同 | 两端统一版本 |
ClassNotFoundException | 注入太早,类还没加载 | 用 setTimeout 延迟,或 Hook 类加载点 |
| overload 歧义 | 方法或构造方法存在多个签名 | 使用 .overload(...) 指定参数类型 |
field 打印不是值 | 直接打印了字段包装对象 | 使用 .value 读写字段 |
Java.choose 找不到实例 | 实例尚未创建或已经销毁 | 延迟执行,确认页面已打开 |
| Native 函数找不到 | SO 未加载、符号名错误、库被 Strip | 监听 loadLibrary0,枚举 exports,或使用基址 + 偏移 |
Hook strcmp 日志爆炸 | 系统调用频率太高 | 加特征字符串或调用栈过滤 |
| Patch 后崩溃 | 偏移错误、架构不匹配、权限未改、覆盖指令长度错误 | 校验基址、偏移、指令集和页权限 |
22. 一套实用分析路径
面对一个 Android 目标,Frida 分析可以按这条路径推进:
flowchart TD
A[确认目标行为] --> B[反编译 Java 层]
B --> C{关键逻辑在 Java?}
C -- 是 --> D[Java.use 定位类和方法]
D --> E{方法会被调用?}
E -- 是 --> F[implementation 修改参数或返回值]
E -- 否 --> G[$new 或 Java.choose 主动调用]
C -- 否 --> H[定位 SO 与 JNI 方法]
H --> I{函数有符号?}
I -- 是 --> J[findExportByName]
I -- 否 --> K[base + offset]
J --> L[Interceptor / NativeFunction]
K --> L
L --> M{入口出口 Hook 够用?}
M -- 是 --> N[读取参数或替换返回值]
M -- 否 --> O[Code Patch 修改指令]
Java 层优先看类、方法、字段和生命周期;Native 层优先看 SO 加载、导出符号、JNI 映射和字符串引用。Hook 不是孤立技巧,它依赖静态分析给出的类名、方法名、函数偏移和参数类型。静态分析负责找到位置,Frida 负责在运行时验证和控制。