Frida进阶:Native层Hook、内存操作、动态加载DEX
好,咱们继续深入。前面几章我们把Frida在Java层的玩法摸了个透,但真正的硬仗,往往在Native层。我个人的经验是,很多App的核心逻辑——比如加密算法、协议校验、甚至反调试——都藏在so文件里。你光Hook Java层,根本碰不到它的命门。
这一章,我们就来啃这块硬骨头。我会带你看看怎么用Frida在Native层做Hook,怎么直接操作内存,以及怎么动态加载DEX文件。嗯,这些技巧在实战中非常有用,尤其是当你面对一个加固得严严实实的App时。
Native层Hook:从Java到C的跨越
说白了,Native层Hook就是拦截so文件里的函数调用。Frida在这方面很强大,它内置了强大的代码跟踪引擎,可以让你在C/C++函数执行前后插入自己的逻辑。
我刚开始做Native Hook时,总觉得这玩意儿很玄乎。后来发现,其实核心就两步:找到函数地址,然后替换它。Frida帮我们封装好了这些细节。
1. 基础Native Hook示例
假设我们有一个so文件叫libnative.so,里面有个函数int add(int a, int b)。我们想Hook它,看看传入的参数和返回值。
// Frida脚本
Interceptor.attach(Module.findExportByName("libnative.so", "add"), {
onEnter: function(args) {
console.log("add called, a = " + args[0] + ", b = " + args[1]);
},
onLeave: function(retval) {
console.log("add returned: " + retval);
}
});
你看,代码很简洁。Module.findExportByName帮我们找到了函数的地址。然后Interceptor.attach就像在函数门口装了个摄像头,onEnter和onLeave就是摄像头拍下的画面。
findExportByName很方便。但很多so文件会隐藏导出符号。这时候,你需要用Module.findBaseAddress拿到so基址,然后加上函数在内存中的偏移量。偏移量怎么找?用IDA Pro或者Ghidra反编译一下so文件就知道了。
2. 处理非导出函数
我在项目中遇到过一种情况:目标函数根本没有导出,符号表里找不到。怎么办?
嗯,这时候就得用硬编码地址了。你先用IDA找到函数的偏移量,比如是0x1234,然后这样写:
var base = Module.findBaseAddress("libnative.so");
var funcAddr = base.add(0x1234);
Interceptor.attach(funcAddr, {
onEnter: function(args) {
console.log("Hooked non-exported function!");
// 这里可以打印参数
},
onLeave: function(retval) {
console.log("Return value: " + retval);
}
});
这里有个坑:地址一定要算对。我曾经因为IDA里显示的地址是虚拟地址,而Frida用的是相对偏移,结果Hook了半天没反应。后来才发现,IDA里显示的地址是加载到固定基址后的地址,而Frida的add方法是在基址上做加法。所以,你只需要用IDA里看到的偏移量就行,不用管基址是多少。
内存操作:读写与扫描
Native层Hook只是第一步。很多时候,我们需要直接操作内存。比如,你想修改某个全局变量的值,或者扫描内存中特定的字符串。
Frida提供了Memory对象,让你可以像操作指针一样读写内存。我个人习惯用Memory.readByteArray和Memory.writeByteArray来读写大块数据,用Memory.readUtf8String来读字符串。
1. 读取与修改内存
假设我们找到了一个关键变量的地址0x12345678,它是一个整数,我们想把它改成999。
var addr = ptr("0x12345678");
console.log("Before: " + Memory.readInt(addr));
Memory.writeInt(addr, 999);
console.log("After: " + Memory.readInt(addr));
你看,就这么简单。但要注意,写内存前一定要确认地址是可写的。如果地址在代码段(.text),你写进去会直接崩溃。我刚开始学的时候,就因为这个原因把手机搞重启了好几次。
2. 内存扫描
有时候,我们不知道变量的具体地址,只知道它包含某个特征字符串。比如,App里有个加密密钥,我们想找到它在内存中的位置。
Frida的Memory.scan方法可以帮我们做这件事。它类似于CE(Cheat Engine)的内存扫描功能。
var pattern = "48 65 6C 6C 6F"; // "Hello"的十六进制
Memory.scan(ptr("0x10000000"), 0x100000, pattern, {
onMatch: function(address, size) {
console.log("Found pattern at: " + address);
console.log("Content: " + Memory.readUtf8String(address));
},
onComplete: function() {
console.log("Scan complete.");
}
});
这个例子扫描了从0x10000000开始的1MB内存,寻找十六进制模式48 65 6C 6C 6F。找到后,我们打印了地址和字符串内容。
你想想看,这个技巧在逆向分析时有多好用?比如,你想找某个硬编码的URL、密钥或者标志位,直接扫内存就行了。
动态加载DEX:热修复与动态注入
最后,我们聊聊动态加载DEX。这个技术说白了,就是在App运行时,把一个外部的DEX文件加载到它的Dalvik/ART虚拟机里。这在热修复、插件化开发中很常见。但在安全测试中,我们用它来注入自己的代码,实现更灵活的控制。
Frida本身已经很强大了,但有时候,你需要在Java层执行一些复杂的逻辑,或者调用一些Frida不方便直接调用的API。这时候,动态加载DEX就派上用场了。
1. 加载DEX文件
假设你有一个DEX文件,里面有一个类com.example.MyInjector,它有一个静态方法public static void doSomething(Context ctx)。你想在目标App里调用它。
// 读取DEX文件内容
var dexBytes = FileAccess.readAllBytes("/data/local/tmp/inject.dex");
// 动态加载DEX
var clazz = Java.use("dalvik.system.DexClassLoader");
var dexLoader = clazz.$new(
"/data/local/tmp/inject.dex", // DEX路径
"/data/local/tmp/opt", // 优化缓存目录
null, // 父加载器
Java.use("java.lang.ClassLoader").getSystemClassLoader()
);
// 加载目标类
var injectorClass = dexLoader.loadClass("com.example.MyInjector");
injectorClass.doSomething(Java.use("android.app.ActivityThread").currentApplication());
这段代码做了几件事:
- 用
DexClassLoader加载了外部的DEX文件。 - 通过
loadClass拿到了我们想要的类。 - 调用了它的静态方法,并传入了当前应用的Context。
嗯,这里要注意,优化缓存目录一定要存在且可写。我一般用/data/local/tmp/opt,记得先mkdir一下。
2. 实战场景:绕过签名校验
我在项目中遇到过一种情况:App在启动时检查自己的签名,如果发现签名不对,就闪退。这种校验通常写在Native层,很难直接Hook。但我们可以用动态加载DEX的方式,在Java层注入一个代理,替换掉签名校验的逻辑。
具体思路是:
- 写一个DEX,里面包含一个类,它继承或代理了App中负责签名校验的类。
- 用Frida动态加载这个DEX。
- 在Frida脚本中,用
Java.use替换掉原来的类。
当然,这只是一个思路。实际实现时,你需要根据App的具体代码来调整。但动态加载DEX给了你一个非常灵活的扩展能力,让你可以像写普通Android代码一样,去干预App的运行逻辑。
知识体系图
下面这张图,帮你梳理了本章的核心逻辑。你可以看到,Native层Hook是基础,内存操作是辅助,动态加载DEX是扩展。三者相辅相成。
好了,这一章的内容就到这里。Native层Hook、内存操作、动态加载DEX,这三个技能你掌握了吗?别急,多练练,遇到实际问题时自然就能用上了。