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就像在函数门口装了个摄像头,onEnteronLeave就是摄像头拍下的画面。

小提示: 如果函数是导出的,用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.readByteArrayMemory.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层注入一个代理,替换掉签名校验的逻辑。

具体思路是:

  1. 写一个DEX,里面包含一个类,它继承或代理了App中负责签名校验的类。
  2. 用Frida动态加载这个DEX。
  3. 在Frida脚本中,用Java.use替换掉原来的类。

当然,这只是一个思路。实际实现时,你需要根据App的具体代码来调整。但动态加载DEX给了你一个非常灵活的扩展能力,让你可以像写普通Android代码一样,去干预App的运行逻辑。

核心要点: Native层Hook、内存操作、动态加载DEX,这三者构成了Frida进阶的三板斧。Native Hook让你能深入底层,内存操作让你能直接修改数据,动态加载DEX让你能扩展Java层的能力。三者结合,几乎可以应对绝大多数逆向场景。

知识体系图

下面这张图,帮你梳理了本章的核心逻辑。你可以看到,Native层Hook是基础,内存操作是辅助,动态加载DEX是扩展。三者相辅相成。

Frida进阶:Native层Hook、内存操作、动态加载DEX Native层Hook 拦截so函数调用 导出/非导出函数 参数与返回值修改 内存操作 读写任意地址 模式扫描 修改全局变量 动态加载DEX DexClassLoader 注入Java代码 热修复/绕过校验 实战应用:绕过反调试、动态修改逻辑、注入自定义代码 三者结合,应对绝大多数逆向场景 图:Frida进阶三大核心能力及其关系

好了,这一章的内容就到这里。Native层Hook、内存操作、动态加载DEX,这三个技能你掌握了吗?别急,多练练,遇到实际问题时自然就能用上了。

公众号:蓝海资料掘金营,微信deep3321