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 模式