Frida Hook 脚本编写

基于 Frida 17.7.3,Android 逆向实战总结


学了快一周,但是本文还是由ai编写。

后面该学anti-frida了。

一、基本结构

所有脚本都必须包在 Java.perform 里,等 JVM 初始化完成后再执行:

1
2
3
4
Java.perform(function() {
// 所有 Hook 代码写在这里
console.log("[*] 脚本已加载");
});

二、找到目标类

1
2
3
4
Java.perform(function() {
// 用完整包名 + 类名
var MyClass = Java.use("com.example.app.ClassName");
});

三、Hook 普通方法

3.1 无参方法

1
2
3
4
5
6
7
8
9
10
Java.perform(function() {
var LoginActivity = Java.use("com.*******.*******.activity.LoginActivity");

LoginActivity.e.overload().implementation = function() {
var result = this.e(); // 调用原始方法
console.log("[e()] 返回值 = " + result);
return result; // 返回原始值
// return true; // 或篡改返回值
};
});

3.2 有参方法

1
2
3
4
5
6
7
8
9
10
11
12
Java.perform(function() {
var LoginActivity = Java.use("com.*******.*******.activity.LoginActivity");

// 参数类型写在 overload 里
LoginActivity.a.overload(
'java.lang.String',
'java.lang.String'
).implementation = function(phone, pwd) {
console.log("[a()] phone=" + phone + " pwd=" + pwd);
this.a(phone, pwd); // void 方法不需要 return
};
});

3.3 void 方法

1
2
3
4
// void 方法:调用原始方法,不需要接收返回值,不需要 return
方法名.overload().implementation = function() {
this.方法名(); // 直接调用,不用 var result =
};

四、处理重载方法

同一个方法名有多个参数版本时,必须用 .overload() 指定:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Java.perform(function() {
var Utility = Java.use("com.common.utils.Utility");

// 单参数版本
Utility.getValue.overload('java.lang.String').implementation = function(str) {
var result = this.getValue(str);
console.log("[getValue(1)] in=" + str + " out=" + result);
return result;
};

// 双参数版本
Utility.getValue.overload(
'java.lang.String',
'java.lang.String'
).implementation = function(str1, str2) {
var result = this.getValue(str1, str2);
console.log("[getValue(2)] in1=" + str1 + " in2=" + str2 + " out=" + result);
return result;
};
});

常用参数类型对照:

Java 类型 Frida 写法
String 'java.lang.String'
int 'int'
long 'long'
boolean 'boolean'
byte[] '[B'
Object 'java.lang.Object'

五、Hook 构造函数

构造函数用 $init 表示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Java.perform(function() {
var ApiClass = Java.use("com.*******.*******.retrofit.api.b");

// 无参构造函数
ApiClass.$init.implementation = function() {
console.log("[构造函数] api.b 被创建");

// 打印调用栈,看谁创建了它
console.log(Java.use("android.util.Log")
.getStackTraceString(
Java.use("java.lang.Exception").$new()
)
);

this.$init(); // 必须调用原始构造函数
};
});
1
2
3
4
5
6
7
8
9
10
11
Java.perform(function() {
var TimerClass = Java.use(
"com.*******.*******.personal.personinfo.utils.a"
);

// 有参构造函数
TimerClass.$init.overload('long', 'long').implementation = function(duration, interval) {
console.log("[Timer] duration=" + duration + " interval=" + interval);
this.$init(3000, interval); // 篡改参数:60秒改成3秒
};
});

六、读写字段

6.1 静态字段

1
2
3
4
5
6
7
8
9
Java.perform(function() {
var LoginActivity = Java.use("com.*******.*******.activity.LoginActivity");

// 读
console.log(LoginActivity.isLoginPage.value);

// 写
LoginActivity.isLoginPage.value = true;
});

6.2 实例字段(在 Hook 回调里)

1
2
3
4
5
6
7
8
9
10
Java.perform(function() {
var LoginActivity = Java.use("com.*******.*******.activity.LoginActivity");

LoginActivity.k.overload().implementation = function() {
// this 就是当前实例
console.log("手机号 = " + this.l.value); // 读实例字段
this.l.value = "13900000000"; // 写实例字段
this.k();
};
});

6.3 不知道字段名时,枚举所有字段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Java.perform(function() {
Java.choose("com.*******.*******.activity.LoginActivity", {
onMatch: function(instance) {
var fields = instance.class.getDeclaredFields();
fields.forEach(function(field) {
field.setAccessible(true);
try {
console.log(" " + field.getName() + " = " + field.get(instance));
} catch(e) {}
});
},
onComplete: function() {}
});
});

七、堆扫描:Java.choose

主动从堆内存里找到某个类的所有实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
Java.perform(function() {
Java.choose("com.*******.*******.activity.LoginActivity", {
onMatch: function(instance) {
// 过滤已销毁的实例
try {
if (instance.isFinishing()) return;
} catch(e) { return; }

console.log("[找到实例] " + instance);

// 操作 UI 必须切换到主线程
Java.scheduleOnMainThread(function() {
var editText = instance.mobile.value;
var String = Java.use("java.lang.String");

editText.setText.overload(
'java.lang.CharSequence',
'android.widget.TextView$BufferType'
).call(
editText,
String.$new("13900000000"),
Java.use("android.widget.TextView$BufferType").EDITABLE.value
);
});
},
onComplete: function() {
console.log("[扫描完成]");
}
});
});

注意事项:

1
2
3
4
5
6
7
8
1. JADX 显示的字段名 ≠ 运行时真实字段名(混淆导致)
→ 用 getDeclaredFields() 枚举真实名字

2. 操作 UI 必须在主线程
→ Java.scheduleOnMainThread()

3. 堆里可能有已销毁的旧实例
→ 用 isFinishing() 过滤

八、Native Hook

8.1 Java Hook 和 Native Hook 的区别

1
2
3
4
5
6
7
Java Hook                          Native Hook
────────────────────────────────────────────────────────
操作 ART 方法表 直接修改机器码
需要知道类名和方法名 需要知道函数内存地址
参数是 Java 对象,直接可读 参数是原始指针,需要手动解析
只能 Hook Java 方法 可以 Hook 任意 Native 函数
Java.perform 里写 直接写在外层,不需要 Java.perform

8.2 找到 Native 函数地址

1
2
3
4
5
6
7
8
9
10
// Frida 17.x 正确写法
var funcAddr = Process.findModuleByName("libfoo.so")
.findExportByName("函数名");
console.log("[地址] " + funcAddr);

// 隐式注册的 JNI 函数,函数名遵循命名规则
var barAddr = Process.findModuleByName("libfoo.so")
.findExportByName(
"Java_sg_vantagepoint_uncrackable2_CodeCheck_bar"
);

8.3 Interceptor.attach:插桩

原函数还会执行,在前后插入观察点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Interceptor.attach(barAddr, {
onEnter: function(args) {
// 函数执行前
// JNI 函数固定前两个参数是 JNIEnv* 和 jobject
console.log("[bar] onEnter");
console.log(" args[0] = " + args[0]); // JNIEnv*
console.log(" args[1] = " + args[1]); // jobject
console.log(" args[2] = " + args[2]); // 实际参数

// 保存参数供 onLeave 使用
this.byteArray = args[2];
},
onLeave: function(retval) {
// 函数执行后
console.log("[bar] retval = " + retval);

// 篡改返回值:用 retval.replace(),不能直接赋值
retval.replace(1); // 强制返回 1 = true
}
});

8.4 Interceptor.replace:完全替换

原函数完全不执行,用新函数取代:

1
2
3
4
5
6
7
8
9
Interceptor.replace(barAddr, new NativeCallback(
function(env, thiz, input) {
// 原函数逻辑完全跳过
console.log("[bar] replace,直接返回 true");
return 1;
},
'int', // 返回值类型
['pointer', 'pointer', 'pointer'] // 参数类型列表
));

8.5 attach 和 replace 对比

1
2
3
4
5
6
7
8
Interceptor.attach                 Interceptor.replace
──────────────────────────────────────────────────────────
原函数还会执行 原函数完全不执行
在原函数前后插入代码 完全替换原函数
有 onEnter 和 onLeave 两个时机 只有一个新函数
retval.replace(1) 篡改返回值 直接 return 1
不需要声明参数类型 必须用 NativeCallback 声明类型
适合:观察、监控、篡改返回值 适合:完全绕过某个函数

8.6 NativeCallback 类型表

1
new NativeCallback(JS函数, 返回值类型, [参数类型列表])
类型 说明
'void' 无返回值
'int' 32位整数,bool 也用这个
'pointer' 指针,JNIEnv* jobject jarray 都是指针
'long' 64位整数
'float' 浮点数

JNI 函数参数规律:

1
2
3
4
5
6
前两个参数固定:
args[0] → JNIEnv* → 'pointer'
args[1] → jobject → 'pointer'
后面的参数看具体函数签名:
jbyteArray / jstring / jobject → 'pointer'
jint / jboolean → 'int'

8.7 从 Native 层读取字节数组内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Interceptor.attach(barAddr, {
onEnter: function(args) {
this.byteArray = args[2];
},
onLeave: function(retval) {
var byteArray = this.byteArray; // 先保存到局部变量!

Java.perform(function() {
var env = Java.vm.getEnv();
var ptr = env.getByteArrayElements(byteArray, null);
var len = env.getArrayLength(byteArray);
var bytes = ptr.readByteArray(len);
var result = "";
new Uint8Array(bytes).forEach(function(b) {
result += String.fromCharCode(b);
});
console.log("[native] 读取内容 = " + result);
});
}
});
/***
注意:onLeave 里的 Java.perform 回调中 this 上下文会变,必须先
var byteArray = this.byteArray 保存到局部变量再使用。
***/

8.8 Java 层和 Native 层篡改返回值的区别

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Java 层:直接 return
方法名.implementation = function() {
return true;
}

// Native 层 attach:用 retval.replace()
onLeave: function(retval) {
retval.replace(1);
}

// Native 层 replace:直接 return
new NativeCallback(function() {
return 1;
}, 'int', [...])

九、自动追踪 RegisterNatives

9.1 为什么需要追踪 RegisterNatives

1
2
3
4
5
6
7
8
隐式注册(可见):
导出表直接有 Java_包名_类名_方法名
→ findExportByName 直接找到

显式注册(不可见):
env->RegisterNatives(clazz, methods, count)
→ 导出表里没有任何 Java_ 开头的函数
→ 必须 Hook RegisterNatives 才能知道映射关系

9.2 Android 12+ 追踪方式(从 libart.so 内部符号)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
var libart = Process.findModuleByName("libart.so");

// 搜索 RegisterNative 相关符号
var matches = libart.enumerateExports().filter(function(exp) {
return exp.name.indexOf("RegisterNative") !== -1
&& exp.name.indexOf("Callback") === -1
&& exp.name.indexOf("Free") === -1
&& exp.name.indexOf("Allocation") === -1;
});
matches.forEach(function(exp) {
console.log("[候选] " + exp.name + " → " + exp.address);
});

// Android 12 通常是这个符号
var registerNative = libart.findExportByName(
"_ZN3art11ClassLinker14RegisterNativeEPNS_6ThreadEPNS_9ArtMethodEPKv"
);

Interceptor.attach(registerNative, {
onEnter: function(args) {
// args[0] = ClassLinker* this
// args[1] = Thread*
// args[2] = ArtMethod*
// args[3] = Native 函数指针
try {
var fnPtr = args[3];
var module = Process.findModuleByAddress(fnPtr);
if (!module) return;

// 过滤系统库,只看目标 App 的 so
var skipList = [
"libart", "libc", "libandroid",
"libmonochrome", "libhoudini",
"libopenjdk", "libframework", "libwebview"
];
var skip = skipList.some(function(s) {
return module.name.indexOf(s) !== -1;
});
if (skip) return;

var offset = fnPtr.sub(module.base);
console.log("[RegisterNative]"
+ " 模块=" + module.name
+ " 偏移=0x" + offset.toString(16)
+ " 绝对地址=" + fnPtr);
} catch(e) {}
}
});

9.3 拿到偏移之后的标准操作

1
2
3
4
5
6
7
8
9
Frida 输出:模块=libtarget.so 偏移=0x1234

后续步骤:
1. adb pull 把 so 拉到本地
adb pull /data/app/com.xxx-xxx/lib/arm64/libtarget.so

2. IDA 打开,按 G 输入 0x1234 跳转

3. 结合 Java 层方法名分析函数逻辑

9.4 模块偏移 vs 绝对地址

1
2
绝对地址:每次启动都不一样(ASLR)→ 不能复用
偏移: 相对 so 基址的固定距离 → IDA 里直接用

十、内存读写与扫描

10.1 Memory.scan 扫描特征码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 扫描某个模块内存,搜索字节特征
var module = Process.findModuleByName("libtarget.so");

// 字符串转 pattern 工具函数
function strToPattern(str) {
return str.split('').map(function(c) {
return ("0" + c.charCodeAt(0).toString(16)).slice(-2);
}).join(' ');
}

// 搜索明文字符串
Memory.scan(module.base, module.size,
strToPattern("target_string"),
{
onMatch: function(address, size) {
console.log("[找到] 地址=" + address
+ " 偏移=0x" + address.sub(module.base).toString(16));
console.log(" 内容=" + address.readUtf8String());
},
onError: function(reason) {},
onComplete: function() {
console.log("[扫描完成]");
}
}
);

// 搜索算法特征码(例:AES S-Box 开头)
Memory.scan(module.base, module.size,
"63 7c 77 7b f2 6b 6f c5",
{
onMatch: function(address) {
console.log("[AES S-Box] " + address);
},
onError: function() {},
onComplete: function() {}
}
);

10.2 Memory.scan 能找到 vs 找不到

1
2
3
4
5
6
7
8
9
能找到:
明文字符串存在 .rodata 段
算法魔数(AES S-Box、MD5 初始值等)
固定的函数序言字节

找不到:
运行时 strcpy 到栈上的字符串
加密存储的字符串
编译器优化成立即数的常量

10.3 读取内存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Frida 17.x 正确写法:ptr.readByteArray(len)
var bytes = new Uint8Array(ptr("地址").readByteArray(256));

// 打印为十六进制+ASCII
var hex = "", ascii = "", result = "";
for (var i = 0; i < bytes.length; i++) {
hex += ("0" + bytes[i].toString(16)).slice(-2) + " ";
ascii += (bytes[i] >= 32 && bytes[i] < 127)
? String.fromCharCode(bytes[i]) : ".";
if ((i + 1) % 16 === 0) {
result += hex + "| " + ascii + "\n";
hex = ""; ascii = "";
}
}
console.log(result);

10.4 动态找某模块的内存段

1
2
3
4
5
6
7
8
9
10
// 不要硬编码地址(ASLR 每次都变)
// 动态找目标模块的特定权限段
var ranges = Process.enumerateRanges('r--').filter(function(r) {
return r.file && r.file.path.indexOf("libtarget") !== -1;
});
ranges.forEach(function(range) {
console.log("基址=" + range.base
+ " 大小=" + range.size
+ " 权限=" + range.protection);
});

10.5 Memory.alloc 申请内存

1
2
3
4
5
6
7
8
9
10
11
12
13
// 申请普通内存(存数据)
var buf = Memory.alloc(256);
buf.writeUtf8String("fake_data");

// 申请可执行内存(注入代码)
var codeBuf = Memory.alloc(64);
Memory.protect(codeBuf, 64, 'rwx');
codeBuf.writeByteArray([0x90, 0x90, 0xC3]); // nop nop ret

// 让函数返回我们控制的字符串
var fakeStr = Memory.alloc(64);
fakeStr.writeUtf8String("fake_token");
retval.replace(fakeStr);

10.6 多线程上下文问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//错误:回调嵌套里 this 上下文会变
Interceptor.attach(addr, {
onEnter: function(args) {
this.data = args[0];
},
onLeave: function(retval) {
Java.perform(function() {
console.log(this.data); // undefined!
});
}
});

//正确:先保存到局部变量,用闭包捕获
Interceptor.attach(addr, {
onEnter: function(args) {
this.data = args[0];
},
onLeave: function(retval) {
var data = this.data; // 保存到局部变量
Java.perform(function() {
console.log(data); // 闭包捕获,正常使用
});
}
});

同样的规则适用于:

1
2
3
4
Java.perform 内部的回调
Java.scheduleOnMainThread 的回调
setTimeout / setInterval 的回调
任何异步嵌套场景

十一、vtable Hook

11.1 vtable 是什么

1
2
3
4
5
6
7
8
9
C++ 每个有虚函数的类,编译器自动生成一张函数指针表:

vtable for Animal:
[0] → Animal::speak() 地址
[1] → Animal::move() 地址

每个对象的内存布局:
[0~7] → vptr(指向 vtable)
[8~n] → 成员变量

11.2 vtable Hook vs Interceptor.attach

1
2
3
4
5
6
Interceptor.attach          vtable Hook(正确姿势)
────────────────────────────────────────────────────
修改函数入口机器码 给单个对象造一张假 vtable
影响所有对该函数的调用 只影响这一个对象实例
无法区分调用来源 天然隔离,其他实例不受影响
适合:监控所有调用 适合:精准控制特定对象

11.3 vtable Hook(造假 vtable)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// 注意:直接修改 vtable 的槽 = 影响所有实例(和 attach 效果一样)
// 正确做法:给目标对象单独造一张假 vtable

Java.choose("com.example.SomeClass", {
onMatch: function(instance) {
var objPtr = instance.handle;

// 读取原始 vptr
var originalVptr = objPtr.readPointer();

// 申请新内存作为假 vtable,复制原始内容
var slotCount = 10;
var tableSize = slotCount * Process.pointerSize;
var fakeVtable = Memory.alloc(tableSize);
Memory.protect(fakeVtable, tableSize, 'rwx');

// 完整复制原始 vtable
for (var i = 0; i < slotCount; i++) {
var originalSlot = originalVptr
.add(i * Process.pointerSize).readPointer();
fakeVtable.add(i * Process.pointerSize)
.writePointer(originalSlot);
}

// 只替换假 vtable 里的目标槽(slot 0 为例)
var originalFn = originalVptr.readPointer();
var hookFn = new NativeCallback(function(thiz) {
console.log("[只有这个对象触发]");
var fn = new NativeFunction(originalFn, 'void', ['pointer']);
fn(thiz);
}, 'void', ['pointer']);

fakeVtable.writePointer(hookFn);

// 把这个对象的 vptr 指向假 vtable
Memory.protect(objPtr, Process.pointerSize, 'rw-');
objPtr.writePointer(fakeVtable);

console.log("[vtable Hook 已安装] 只影响此对象");
},
onComplete: function() {}
});

11.4 vtable Hook 使用场景

1
2
3
4
5
6
同一个类有多个实例,只想 Hook 其中一个:
serverChecker → 假 vtable(Hook)
localChecker → 原始 vtable(不动)

分析 C++ 框架内部的对象行为:
找到特定功能对象 → 只监控它的虚函数调用

十二、通用 Hook 模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Java.perform(function() {
var TargetClass = Java.use("完整包名.类名");

// 模板:有参有返回值
TargetClass.方法名.overload('参数类型').implementation = function(arg) {
// 1. 打印入参
console.log("[方法名] 入参 = " + arg);

// 2. 调用原始方法
var result = this.方法名(arg);

// 3. 打印返回值
console.log("[方法名] 返回值 = " + result);

// 4. 返回原始值或篡改
return result;
};

// 模板:void 方法
TargetClass.方法名.overload().implementation = function() {
console.log("[方法名] 被调用");
this.方法名(); // 调用原始,不需要 return
};

console.log("[*] Hook 已安装");
});

十三、Frida 启动命令

1
2
3
4
5
6
7
8
# 查找目标进程 PID
frida-ps -Uai | findstr 关键词

# attach 模式(App 已运行,推荐)
frida -U -p <PID> -l 脚本.js

# spawn 模式(Frida 启动 App)
frida -U -f com.xxx.xxx -l 脚本.js

注意:

  • 如果spawn 会导致 React Native 线程崩溃,则用attach模式
  • attach 前确保 App 已完全启动

十四、常见错误处理

错误信息 原因 解决方法
has more than one overload 方法有多个重载 .overload('参数类型')
argument types do not match 参数类型不匹配 枚举方法查看正确的重载
cannot read property of undefined 字段名不对 getDeclaredFields() 枚举真实字段名
You cannot start a load for a destroyed activity 扫描到已销毁的实例 isFinishing() 检查
Process crashed: Bad access spawn 模式 RN 线程冲突 改用 attach 模式