68、Android逆向(四):Frida Hook Native层、So文件逆向
各位好,欢迎来到第68讲。
前面几章我们一直在Java层打转,说实话,那只是Android逆向的「新手村」。真正的硬骨头,藏在Native层——也就是那些.so文件里。我当年第一次面对一个加固过的App,Java层代码全被抽空,只剩几个native函数,当时真有点懵。后来啃透了Frida的Native Hook,才算真正入了门。
今天我们就来聊聊,怎么用Frida去Hook Native层,怎么逆向分析So文件。
为什么需要Hook Native层?
你想想看,很多核心逻辑——比如加密算法、协议组包、反调试——为什么都写在C/C++里?说白了,就是为了防你。Java层用JEB或者jadx一拖,代码几乎就是源码级别的。但So文件编译完是二进制指令,反编译出来是汇编,阅读难度直接拉满。
而且,很多App会在Native层做签名校验、环境检测。你如果在Java层Hook,还没等断点触发,程序已经检测到异常退出了。这时候,就得在Native层动手。
核心思路: Frida通过将JavaScript引擎注入目标进程,在Native层拦截函数调用。它不需要你懂汇编,只需要知道函数名和参数签名,就能像Hook Java方法一样Hook C函数。
Frida Hook Native的基础姿势
先看一个最简单的例子。假设So里有一个函数叫checkPassword,我们想看看它接收了什么参数,返回了什么结果。
// hook_native.js
Interceptor.attach(Module.findExportByName("libnative.so", "checkPassword"), {
onEnter: function(args) {
console.log("checkPassword called");
// 参数1是字符串指针,用 Memory.readCString 读取
console.log("arg0: " + Memory.readCString(args[0]));
// 参数2是int
console.log("arg1: " + args[1].toInt32());
},
onLeave: function(retval) {
console.log("checkPassword returns: " + retval);
}
});
运行命令:frida -U -f com.example.app -l hook_native.js --no-pause
嗯,这里要注意:Module.findExportByName只能找到导出的函数。如果函数被strip掉了,或者被混淆了,就得用另一种方式——按地址Hook。
小技巧: 我习惯先用Process.enumerateModules()打印所有加载的模块,确认So的基址。然后用Module.findBaseAddress("libnative.so")拿到基址,加上偏移量,就是目标函数的绝对地址。
So文件逆向的常规流程
光靠Frida盲Hook效率太低。我一般会先做静态分析,再用Frida动态验证。流程大概是这样的:
- 提取So文件:从APK里解压出lib/armeabi-v7a/下的.so文件。
- 静态分析:用IDA Pro或Ghidra打开,定位关键函数。
- 识别JNI函数:Java层的native方法对应So里的Java_com_xxx_xxx格式的函数名。
- 动态Hook验证:用Frida在关键函数上下断点,观察参数和返回值。
- 还原算法:结合静态反汇编和动态日志,还原加密或校验逻辑。
我曾经遇到一个App,它的So文件里所有函数名都被混淆成了类似sub_1234的形式。静态分析根本不知道哪个函数是干嘛的。后来我通过Frida Hook了JNI的RegisterNatives函数,动态打印出了Java层native方法与So函数的映射关系,才找到了入口。
Hook JNI函数:RegisterNatives
很多App为了安全,不会用默认的JNI命名规则,而是动态注册。这时候,RegisterNatives就是我们的突破口。
// hook_register.js
var RegisterNatives = Module.findExportByName("libart.so", "_ZN3art3JNI18RegisterNativesEP7_JNIEnvP7_jclassPK15JNINativeMethodi");
Interceptor.attach(RegisterNatives, {
onEnter: function(args) {
var methods = ptr(args[2]);
var methodCount = args[3].toInt32();
for (var i = 0; i < methodCount; i++) {
var methodName = Memory.readCString(methods.add(i * 12).readPointer());
var methodSig = Memory.readCString(methods.add(i * 12 + 4).readPointer());
var fnPtr = methods.add(i * 12 + 8).readPointer();
console.log("Register: " + methodName + " -> " + fnPtr);
}
}
});
这个脚本会打印出所有动态注册的native函数名和对应的So函数地址。有了地址,你就可以用Interceptor.attach直接Hook了。
注意: 不同Android版本的libart.so里,RegisterNatives的符号名可能不一样。我建议先用Module.enumerateExports("libart.so")搜一下,确认准确的符号名。
处理字符串与内存数据
Native层的数据不像Java层那么直观。字符串可能是C风格(以'\0'结尾),也可能是长度前缀。我常用的几个API:
| API | 用途 |
|---|---|
Memory.readCString(addr) |
读取以'\0'结尾的字符串 |
Memory.readByteArray(addr, length) |
读取指定长度的字节数组 |
hexdump(addr, {offset: 0, length: 64}) |
以十六进制和ASCII形式打印内存 |
ptr(addr).add(offset) |
指针运算,计算偏移后的地址 |
举个例子,如果函数返回一个结构体指针,里面包含多个字段,你可以这样读:
onLeave: function(retval) {
var data = Memory.readByteArray(retval, 16);
console.log(hexdump(data, {offset: 0, length: 16}));
// 假设前4字节是int,接着4字节是float
var firstInt = retval.readS32();
var secondFloat = retval.add(4).readFloat();
console.log("firstInt: " + firstInt + ", secondFloat: " + secondFloat);
}
实战:Hook一个加密函数
我记得有一次分析一个金融类App,它的登录密码加密是在Native层做的。静态分析发现So里有一个AES_encrypt函数,但密钥是动态生成的。我这样操作:
- 用Frida Hook
AES_encrypt的onEnter,打印出明文和密钥。 - 在onLeave打印出密文。
- 对比Java层传入的密码和So层收到的数据,发现App在Java层做了Base64编码。
- 最终还原了整个加密流程:Base64解码 -> AES加密 -> Hex输出。
如果没有Frida,这个过程可能需要用IDA动态调试,或者手动分析汇编,效率低得多。
SVG:Native Hook核心流程
下面这张图总结了我们刚才讲的整个流程,从Java层到Native层,再到So文件逆向的闭环。
避坑指南
最后分享几个我踩过的坑:
- So文件被加壳:有些App的So文件是加壳的,运行时才解密。这时候你直接
Module.findExportByName是找不到函数的。我一般先Hookdlopen或android_dlopen_ext,等So加载完成后再枚举导出函数。 - 函数被inline:如果目标函数被编译器内联了,你Hook不到。这时候可以退而求其次,Hook它的上层调用函数,或者Hook它调用的子函数。
- 多线程问题:Native层经常是多线程的。你的Hook回调里如果执行耗时操作,可能会卡死线程。我建议只在回调里打印日志,不要做复杂的计算或网络请求。
- Frida版本兼容性:不同版本的Frida对Native Hook的支持有差异。我个人习惯用最新稳定版,如果遇到崩溃,先检查Frida版本是否匹配Android版本。
好了,Native Hook的基础就讲到这里。说白了,Frida给了我们一把手术刀,但怎么切、切哪里,还是得靠静态分析来导航。下一讲我们会深入So文件的动态调试,用IDA配合Frida做更精细的逆向分析。
公众号:蓝海资料掘金营,微信deep3321