Frida Hook Android手册
write by ppl on 2025/4
Frida的安装与配置
网上教程一大堆,略
连接 Android 设备
运行Frida服务端
bash
$> adb shell
su
cd /data/local/tmp
./frida-server
进行端口转发
bash
$> adb forward tcp:27042 tcp:27042
27042 用于与frida-server通信的默认端口号,之后的每个端口对应每个注入的进程。
检测Frida是否成功运行
bash
$> frida-ps -U
可以列出所有进程的PID,包名。
连接模式
- spawn 模式:启动新进程
bash
$> frida -U -f cn.binary.frida -l script.js
# 暂停进程
$> frida -U -f cn.binary.frida -l script.js --pause
- attach 模式:附加到运行中的进程
bash
# 附加到进程 pid
$> frida -U -p 1234 -l script.js
# 附加到进程名
$> frida -U -n com.example.demo -l script.js
# 附加到前台进程
$> frida -U -F -l script.js
常用参数
-U:连接到远程 USB 设备-p:指定进程 PID-n:指定进程包名-l:指定注入的 js 代码文件-f:启动新进程-F:附加到前台进程
注入方法
1. JS 脚本注入
使用-l命令参数,通过 frida 工具将写好的Js脚本注入到目标进程中。
2. Bash 命令行
不使用-l命令参数,则使用Frida命令会打开Frida的Bash 命令行逐行输入Js代码交互。
3. Python代码注入
Frida 提供了 Python 接口,可以使用 Frida 的 Python API 可以更方便地操作和动态注入脚本。
python
import frida
import sys
target = "<pid_or_process_name>"
js_code = """
var targetFunction = Module.findExportByName(null, 'target_function_name');
Interceptor.attach(targetFunction, {
onEnter: function(args) {
console.log('Intercepted function call!');
args[0] = ptr('0x12345678');
},
onLeave: function(retval) {
console.log('Function returned with value: ' + retval);
retval.replace(0xabcdef);
}
});
"""
def on_message(message, data):
print(message)
if __name__ == '__main__':
device = frida.get_usb_device()
session = device.attach(2595)
script = session.create_script(jscode)
script.on('message', on_message)
script.load()
sys.stdin.read()
ApkLab与APK的重打包
APKLab是一个基于VS Code的Android逆向工程扩展,继承了多个强大的工具 :
- apktool:反编译APK资源
- dex2jar:将DEX转换为JAR
- jadx:反编译Java字节码文件
- keytool:管理密钥和证书
集成了多个工具,可以方便的进行反编译、查看资源文件、修改代码、重新打包、签名等操作。
听说cursor可以用APKLab自动化逆向apk
Java层Hook
基本结构
Frida Hook Java脚本的执行是基于 Java.perform() 的,它确保 Java 环境在执行你的脚本时已经初始化。Java.use() 用来获取 Java 类的引用,这样可以对该类进行操作。implementation 是用来重写 Java 方法实现的关键字,可以在这里插入自己的代码,比如打印日志、修改参数、改变返回值等。
javascript
Java.perform(function () {
var myClass = Java.use('com.example.MyClass'); // 获取类的引用
// Hook doSomething 方法
myClass.doSomething.overload().implementation = function () {
console.log('doSomething 方法被调用');
// 可以修改返回值或进行其他操作
var result = this.doSomething(); // 调用原本的方法
console.log('原始返回值:' + result);
return result; // 或者返回修改后的值
};
});
在获取类的引用要写明类的路径,即 完整的包名+类名 (即使是Java自带的类)。它会返回一个 Java 类的代理对象,你可以通过它来访问类的字段、方法等。
获取入参、重载方法
可以使用 overload() 方法来钩取某个方法的特定重载版本。如果想获取函数的入参,可以通过 args 来访问。这些入参通常是一个数组,你可以根据方法签名提取出具体的值。
javascript
var TargetClass = Java.use("com.example.targetclass");
var method = TargetClass.methodName.overload('java.lang.String', 'int'); // 指定重载方法签名
method.implementation = function (str, num) {
console.log("Original args: str = " + str + ", num = " + num);
};
或者
javascript
var TargetClass = Java.use("com.example.targetclass");
TargetClass.methodName.overload('java.lang.String', 'int').implementation = function (str, num) {
console.log("Original args: str = " + str + ", num = " + num);
};
钩取某个方法的特定重载版本要通过参数类型的不同指明。 overload() 的参数数量应与Hook原函数的参数数量一致。参数类型:如果是基本类型直接是类型名的字符串,如果是非基本类型则为 包名+类名 的字符串。
如果参数是数组:基本类型数组,用左中括号接上基本类型的缩写;对象数组,用左中括号接上完整类名再接上分号
javascript
targetClass.methodName.overload('[I').implementation = function(arr) {
// 通过 Java Array API 访问数组的每个元素
let length = arr.length;
for (let i = 0; i < length; i++) {
console.log('元素 ' + i + ': ' + arr[i]);
}
};
| 基本类型 | 缩写 |
|---|---|
| boolean | Z |
| byte | B |
| char | C |
| double | D |
| float | F |
| int | I |
| long | J |
| short | S |
对象数组:例如 '[java.lang.String;'
this 和 super
this 代表的是当前类的实例,而 super 代表父类的实例。调用当前类的方法 ,通过 this 来调用当前类的实例方法或构造函数。调用父类的方法 ,通过 super 来调用父类的实例方法。
构造函数
$init 是构造函数的特殊表示, 是所有类构造函数的标识符。
javascript
var MyClass = Java.use('com.example.MyClass');
MyClass.$init.overload('java.lang.String').implementation = function(arg) {
console.log('Constructing MyClass with argument:', arg);
return this.$init(arg); // 调用构造函数
};
通常需要调用原构造函数。
获取类字段(属性)
可以使用 get 和 set 来访问 Java 类的字段。字段是通过 value 访问的。
javascript
var MyClass = Java.use('com.example.MyClass');
var fieldValue = MyClass.someField.value; // 获取字段值
console.log('Field value: ', fieldValue);
MyClass.someField.value = 42; // 修改字段值
console.log('Field value after modification: ', MyClass.someField.value);
数组实例
使用**Java.array()**创建一个 Java 数组实例。第一个参数为数组类型,第二个参数为数组内容。
javascript
var StringArray = Java.array('java.lang.String', ['a', 'b', 'c']);
题外话:Java传参的数组通常是bytes数组,将bytes转换为十六进制并输出:
javascript
function bytesToHex(bytes){
let hex = [];
for(let i=0; i < bytes.length;;i++){
let current = bytes[i] & 0xff;
let hexValue = current.toString(16);
if(hexValue.length == 1){
hexValue = "0" + hexValue;
}
hex.push(hexValue);
}
return hex.join(" ");
}
静态方法
静态方法是属于类本身的,因此你可以直接通过类来访问和 hook 静态方法调试。实例方法是属于对象实例的,因此在 hook 实例方法时,首先你需要通过 Java.use() 获取类的引用,当创建该类的实例时,或者在 hook 代码中通过实例方法进行调用。
被动hook写法上似乎没有区别。。。。。但在主动调用时,静态方法不需要实例化对象。
主动调用
以上都是被动调用,当原函数被调用时,hook函数才会发生作用。主动调用(或者说是直接调用)指的是在脚本中通过 Frida 代码主动触发目标程序的函数或方法。
主动调用实例化方法:
javascript
Java.perform(function () {
const MyClass = Java.use('com.example.MyClass'); // 获取目标类的引用
// 创建对象实例
const myObject = MyClass.$new(); // 使用 $new() 创建对象实例
// 主动调用实例方法
myObject.someInstanceMethod('Hello, Frida!');
// 修改实例方法的返回值
MyClass.someInstanceMethod.implementation = function(arg) {
console.log('Method called with argument:', arg);
return 'Modified return value'; // 修改返回值
};
});
主动调用静态方法:
javascript
Java.perform(function () {
const MyClass = Java.use('com.example.MyClass'); // 获取目标类的引用
// 主动调用静态方法
MyClass.staticMethod('Hello, static method!'); // 直接调用静态方法
MyClass.staticMethod.implementation = function(arg) {
console.log('Static method called with argument:', arg);
return 'Modified static return value'; // 修改返回值
};
});
遍历其他数据结构
Map
javascript
const mapField= this.stringMap.value; // 获取Map字段
if(mapField != null){
const keySet = mapField.keySet();
let iterator = keySet.iterator(); // 获取迭代器
while(iterator.hasNext()){
let key = iterator.next();
let value = mapField.get(key);;
console.log(" ["+ key + "=>" + value + "]");
}
}
之前做过的某题,使用Java.cast将this转换为CollectionTraversal类型,然后就是用.value获取不同数据结构对应的字段
javascript
Java.perform(function () {
var CollectionTraversal = Java.use("cn.binary.frida.CollectionTraversal");
CollectionTraversal.traverseCollections.implementation = function () {
console.log("[*] traverseCollections called");
// 获取当前实例的字段
var listField = this.stringList.value;
var mapField = this.stringMap.value;
var arrayField = this.stringArray.value;
// 遍历 List<String>
if (listField !== null) {
var size = listField.size();
console.log("[-] stringList:");
for (var i = 0; i < size; i++) {
var item = listField.get(i);
console.log(" [" + i + "]: " + item);
}
}
// 遍历 Map<String, String>
if (mapField !== null) {
console.log("[-] stringMap:");
try {
var keySet = mapField.keySet();
var iterator = keySet.iterator(); // 获取迭代器
while (iterator.hasNext()) {
var key = iterator.next(); // 获取下一个元素
var value = mapField.get(key); // 获取键对应的值
console.log(" [" + key + " => " + value + "]");
}
} catch (e) {
console.log(" Error in stringMap: " + e.message);
}
}
// 遍历 String[]
if (arrayField !== null) {
var arrayLength = arrayField.length;
console.log("[-] stringArray:");
for (var j = 0; j < arrayLength; j++) { // 遍历数组
var val = arrayField[j]; // 获取数组元素
console.log(" [" + j + "]: " + val); // 打印数组元素
}
}
// 调用原始函数
return this.traverseCollections.call(this);
};
});
调用栈打印
什么是调用栈?
调用栈是函数调用过程中形成的堆栈信息,记录了函数调用的顺序和位置。说人话就是看xx方法由谁调用,然后层层递进,直到最开始的调用者。在逆向工程中,了解是谁调用了某个函数非常重要,通过分析调用栈(CallStack),我们可以快速定位调用路径,从而更好地还原程序逻辑。
javascript
function print_callstack( {
const Log = Java.use("android.util.Log");
const Exception = Java.use("java.lang.Exception");
console.log(Log.getStackTraceString(Exception.$new()));
它的原理是主动调用Java代码,生成一个异常对象,然后打印异常对象的堆栈信息。
等价的Java代码:
java
android.util.Log().getStackTraceString(new java.lang.Exception())
可以在任何Hook函数中调用print_callstack()来输出谁调用了它。
Native层Hook
关于Native层是什么、加载方式就不细说了,这里主要讨论Frida Hook Native
Native模块遍历
Frida 提供了Process.enumerateModules方法列出所有模块。
javascript
Process.enumerateModules().forEach(function(module){
console.log("Module "+module.name+", Base Address:"+module.base.toString());
});
当so文件拥有符号时,可采用Moudle.findExportByName的方法查找地址
javascript
const func_addr = Module.findExportByName('libnative.so','target_function');
console.log(func);
没有符号时,函数地址 = 模块基地址 + 偏移量 (通常在IDA中将起始地址设为0,则函数在IDA中的地址即为偏移量)
javascript
const offset = 0x1234;
const module_base = Module.findBaseAddress('libnative.so','target_function');
const func_addr = module_base.add(offest);
Frida对指针变量存放进行了封装,实现了多种内存操作的辅助函数(这也是为什么上面代码不用加号运算符的原因),如可以使用NativePointer新建指针
javascript
const addr = new NativePointer('0x1234');
此外,使用NativeFunction类可以创建Native函数的引用
javascript
const func = new NativeFunction(addr,'int',['pointer','int']); // 地址、返回值、参数列表
hexdump可以打印内存。不过个人感觉用ida动调要方便些,
javascript
//读取128字节的内存内容
var data = Memory.readByteArray(addr,128);
console.log(hexdump(addr,{
offset : 0,
length : 128,
header : true,
anssi : true
}));
基本结构
Interceptor.attach 是 Frida 提供的一个功能强大的 API,用于hook目标函数。当目标函数被调用时,onEnter 和 onLeave 回调函数会被触发。
onEnter在目标函数被调用时执行,允许你访问传入的参数。onLeave在目标函数返回时执行,允许你访问返回值,并且可以修改返回值。
javascript
Interceptor.attach(targetAddress, {
onEnter: function(args) {
// 在这里处理函数调用前的逻辑
},
onLeave: function(retval) {
// 在这里处理函数返回时的逻辑
}
})
在 onEnter 回调中,我们可以访问函数的输入参数。这些参数通过 args 数组传递,每个参数是一个 NativePointer 对象。可以通过以下方式打印或修改这些参数:
args[index].toInt32():将参数转换为 32 位整数。args[index].toString():将参数转换为字符串。args[index].readUtf8String():如果参数是字符串指针,可以读取字符串内容。
在 onLeave 回调中,我们可以访问和修改函数的返回值。
retval.toInt32():获取返回值作为整数。retval.replace(value):修改返回值,value可以是任何合法的值或NativePointer对象。
例如这里hook了open函数,致使当程序试图打开包含"hack"字样的文件时,打开失败
javascript
var openFunc = Module.findExportByName(null, 'open');
console.log('[*] open: ' + openFunc);
Interceptor.attach(openFunc, {
onEnter: function(args) {
console.log('[*] open 被调用');
const filename_ptr = args[0];
const filename = Memory.readUtf8String(filename_ptr);
console.log('[*] filename: ' + filename);
if (filename.includes('hack')) {
console.log('[*] 禁止打开文件: ' + filename);
this.forbid = true;
} else {
console.log('[*] 允许打开文件: ' + filename);
this.forbid = false;
}
},
onLeave: function(retval) {
if (this.forbid) {
retval.replace(-1);
}
}
});
这里的this与hook java层的this不同。Java层的this指代Java 对象本身,而这里的this指代函数被hook时调用一次的过程,生命周期随目标函数调用的开始而开始,随调用结束而结束。因此可以利用js的动态性向this中添加属性,即this.forbid,使得forbid能够同时在两个回调函数中调用。(不嫌污染全局变量就全用var一样的)
文件重定向
javascript
var openFunc = Module.findExportByName(null, 'open');
console.log('[*] open: ' + openFunc);
Interceptor.attach(openFunc, {
onEnter: function(args) {
console.log('[*] open 被调用');
var filename_ptr = args[0];
var filename = Memory.readUtf8String(filename_ptr);
console.log('[*] filename: ' + filename);
if (filename.includes('/proc/self/status')) {
console.log('[*] 重定向到 /data/local/tmp/status.txt');
// 重新申请内存
var new_filename_ptr = Memory.allocUtf8String('/data/local/tmp/status.txt');
args[0] = new_filename_ptr;
this.new_filename_ptr = new_filename_ptr;
}
},
});
使用Memory.allocUtf8String申请内存写入字符串,替换arg[0]为新的内存地址
主动调用
javascript
var getLicense = Module.findExportByName("libfrida.so", '_Z10getLicenseiPKc');
console.log('[*] getLicense: ' + getLicense);
var getLicenseFunc = new NativeFunction(getLicense, 'pointer', ['int', 'pointer']);
for (var i = 0; i < 3; i++) {
var password_ptr = Memory.allocUtf8String("password");
var license = getLicenseFunc(i, password_ptr);
console.log('[*] license: ' + Memory.readUtf8String(license));
}
使用NativeFunction创建函数引用,方便用于主动调用。然后用 Memory.allocUtf8String分配内存,用于传递字符串参数,最后使用Memory.readUtf8String读取返回的字符串值。
NativeFunction创建函数引用
javascript
new NativeFunction(address,reyurnType,argTypes[,abi])
address函数地址,传入NativePointer类型;returnType返回值类型,传入string类型;argTypes参数类型,传入array;abi调用约定,通常默认
内存搜索
javascript
const pattern = "66 6c 61 67 7b 46 49 4e 44 5f 4d 45 5f"; // ASCII: flag{FIND_ME_
Process.enumerateRanges("--rw-").forEach(range => {
try {
Memory.scan(range.base, range.size, pattern, {
onMatch: function (address, size) {
const str = Memory.readUtf8String(address);
console.log("[*] Found flag:", str);
},
onError: function (reason) {
console.error("Memory scan error:", reason);
},
onComplete: function () {
}
});
} catch (error) {
console.error("Memory scan error:", error);
}
});
Process.enumerateRanges("--rw-")枚举当前进程中所有具备 读/写权限 (r 和 w)的内存段,不包括可执行(x)段。 flag 通常保存在数据段或堆中,而不是代码段。遍历这些内存范围,在每个内存段内扫描是否存在该 hex 字节模式。
onMatch:如果匹配到,就将该地址的数据当作 UTF-8 字符串读取,并打印出来。
onError:如果扫描出错,输出错误原因。
onComplete:扫描该段内存结束后的回调(这里为空)。
用try catch包裹整个扫描过程,防止因某段内存访问异常而中断整个流程。