14、Native层逆向(三):Unicorn引擎模拟执行、Frida对Native函数的Hook

好,咱们继续聊Native层逆向。前两章我们把ELF文件结构和ARM汇编基础过了一遍,算是把地基打牢了。这一章,我们来点真刀真枪的——怎么让Native代码“跑起来”,以及怎么“拦截”它。

我个人觉得,Native逆向最头疼的地方就是:代码是死的,你得让它动起来才能看出门道。静态分析看半天,不如动态跑一次。所以这一章,我重点讲两个工具:Unicorn引擎和Frida。一个负责模拟执行,一个负责动态Hook。两者配合,基本能覆盖90%的Native层分析场景。

14.1 Unicorn引擎:让Native代码“活”过来

Unicorn是什么?说白了,它是一个轻量级的CPU模拟器。它能模拟ARM、ARM64、x86等多种架构的指令执行。你不需要真机,不需要root,甚至不需要一个完整的操作系统,就能跑一段Native代码。

我最早接触Unicorn,是在分析一个混淆得很厉害的so文件时。静态分析完全看不懂,IDA里全是花指令。后来我试着把关键函数抠出来,用Unicorn模拟执行,结果一下子就看清了它的逻辑。嗯,那感觉就像在黑屋子里突然开了灯。

14.1.1 Unicorn的核心概念

Unicorn的使用其实不复杂,核心就几个概念:

  • 引擎(Engine):负责初始化和执行代码
  • 内存(Memory):你需要手动映射内存区域,包括代码段、栈、堆
  • 寄存器(Register):可以读写CPU寄存器,比如R0-R12、SP、PC等
  • 钩子(Hook):可以在指令执行、内存访问等时机插入回调函数

你想想看,这就像搭了一个微型计算机。你给它代码,给它内存,告诉它从哪里开始执行,然后它就能一步步跑下去。

14.1.2 一个简单的Unicorn示例

咱们直接看代码。假设有一段ARM32的汇编代码,功能很简单:把两个数相加。

// 汇编代码(ARM32)
// R0 = 1, R1 = 2
// ADD R2, R0, R1
// BX LR

unsigned char code[] = {
    0x01, 0x00, 0x80, 0xE3,  // MOV R0, #1
    0x02, 0x10, 0x81, 0xE3,  // MOV R1, #2
    0x02, 0x20, 0x80, 0xE0,  // ADD R2, R0, R1
    0x1E, 0xFF, 0x2F, 0xE1   // BX LR
};

用Unicorn模拟执行这段代码:

from unicorn import *
from unicorn.arm_const import *

# 初始化ARM32引擎
mu = Uc(UC_ARCH_ARM, UC_MODE_ARM)

# 映射内存:代码段 + 栈
ADDRESS = 0x10000
mu.mem_map(ADDRESS, 2 * 1024 * 1024)  # 2MB
mu.mem_write(ADDRESS, bytes(code))

# 设置栈指针
mu.reg_write(UC_ARM_REG_SP, ADDRESS + 0x1000)

# 开始执行
mu.emu_start(ADDRESS, ADDRESS + len(code))

# 读取结果
r2 = mu.reg_read(UC_ARM_REG_R2)
print(f"R2 = {r2}")  # 输出:R2 = 3

看到了吗?就这么几行代码,我们模拟执行了一段ARM指令,并且拿到了结果。R2的值是3,完全正确。

我的小技巧: 在实际逆向中,你往往需要从so文件中提取一段函数代码。可以用IDA或Ghidra把函数字节码导出来,然后喂给Unicorn。记得要处理好函数的输入参数(通过寄存器传递)和返回值。

14.1.3 Unicorn在逆向中的典型场景

我个人习惯把Unicorn用在以下几个地方:

  • 脱壳:很多壳会在运行时解密代码,你可以用Unicorn模拟执行解密过程,然后dump出解密后的代码
  • 分析混淆算法:遇到控制流平坦化、指令替换等混淆,静态分析很痛苦,但模拟执行可以绕过这些
  • 模拟系统调用:有些Native代码会调用系统API,你可以用Hook来模拟这些调用,返回你想要的值

我曾经遇到过一个so文件,它会在执行前先校验环境。如果检测到是模拟器或调试器,就直接退出。我用Unicorn模拟执行时,通过Hook拦截了它的检测函数,直接返回假值,顺利绕过了校验。

注意: Unicorn模拟的是CPU指令,不是完整的操作系统。如果代码里调用了系统调用(比如open、read),你需要自己实现这些调用的逻辑。否则程序会崩溃或卡住。

14.2 Frida对Native函数的Hook

如果说Unicorn是“让代码跑起来”,那Frida就是“让代码听你的话”。Frida是一个动态插桩工具,它可以在运行时修改函数的行为。对于Native层,Frida可以Hook so文件中的导出函数,甚至是内部函数。

Frida的原理其实不复杂:它通过ptrace或者注入的方式,把JavaScript引擎(V8或Duktape)注入到目标进程中。然后你写的JS代码就可以在目标进程里执行,拦截函数调用、修改参数、篡改返回值。

14.2.1 Hook Native函数的基本写法

咱们直接上代码。假设目标so文件叫libnative.so,里面有一个导出函数Java_com_example_app_MainActivity_stringFromJNI。我们想Hook它,看看它返回了什么。

// Frida JavaScript代码
Interceptor.attach(Module.findExportByName("libnative.so", 
    "Java_com_example_app_MainActivity_stringFromJNI"), {
    onEnter: function(args) {
        console.log("stringFromJNI called!");
        console.log("JNIEnv: " + args[0]);
        console.log("thiz: " + args[1]);
    },
    onLeave: function(retval) {
        console.log("Original return value: " + retval);
        // 修改返回值
        retval.replace(ptr(0x12345678));
    }
});

这段代码做了三件事:

  1. 找到libnative.so中的目标函数地址
  2. 在函数进入时打印参数
  3. 在函数返回时打印原始返回值,并把它替换成一个固定值

你想想看,这有多强大。你可以随意修改函数的输入输出,甚至跳过函数体直接返回。

14.2.2 Hook非导出函数

很多时候,我们要Hook的函数并不是导出的。比如一些内部辅助函数。这时候怎么办?

方法一:通过偏移量计算地址。先用IDA找到函数的偏移量,然后加上so的基址。

var base = Module.findBaseAddress("libnative.so");
var func_addr = base.add(0x1234);  // 假设偏移是0x1234
Interceptor.attach(func_addr, {
    onEnter: function(args) {
        console.log("Internal function called");
    }
});

方法二:通过特征码搜索。如果so被加固或动态加载,偏移量可能变化。这时候可以用Memory.scan来搜索特征码。

var pattern = "01 02 03 04 ?? 06 07 08";  // 问号表示通配
Memory.scan(Module.findBaseAddress("libnative.so"), 
    0x100000, pattern, {
    onMatch: function(address, size) {
        console.log("Found at: " + address);
        Interceptor.attach(address, {
            onEnter: function(args) {
                // ...
            }
        });
    },
    onComplete: function() {
        console.log("Scan complete");
    }
});
避坑指南: 我曾经在Hook一个非导出函数时,直接用了IDA里的偏移量,结果怎么都Hook不上。后来发现是因为so被加载时地址对齐了,偏移量变了。正确的做法是用Module.findBaseAddress获取基址,再加上偏移量。或者直接用Module.getExportByName,如果函数是导出的。

14.2.3 Frida与Unicorn的配合

这两个工具不是互斥的,而是互补的。我经常这样用:

  • 先用Frida在真机上动态分析,看看函数的调用时机、参数特征
  • 然后把关键函数抠出来,用Unicorn模拟执行,深入分析算法细节
  • 最后再用Frida验证修改后的逻辑是否正确

举个例子。有一次我分析一个支付相关的so,它有一个签名校验函数。我用Frida Hook了它,发现它接收三个参数,返回一个字符串。但算法太复杂,静态看不太懂。于是我把这个函数的字节码导出来,用Unicorn模拟执行,一步步跟踪寄存器的变化,终于搞清楚了它的加密流程。

14.3 本章知识体系

下面这张图,是我自己总结的Native层动态分析的知识体系。你可以把它当作一个路线图。

Native层动态分析知识体系 Unicorn引擎(模拟执行) Frida(动态插桩) CPU指令模拟 内存映射管理 寄存器读写 Hook回调 导出函数Hook 非导出函数Hook 参数/返回值修改 特征码搜索 典型应用场景 脱壳与代码dump 混淆算法还原 签名校验绕过

这张图把Unicorn和Frida的核心能力以及应用场景串在了一起。你可以看到,两者各有侧重,但最终都指向同一个目标:让Native代码变得可观测、可控制。

14.4 实战中的一些体会

最后,我想分享几点实战中的体会:

  • 不要迷信工具:Unicorn和Frida都很强大,但它们不是万能的。遇到反调试、反Hook机制时,还是得靠经验和耐心。
  • 日志是最好的朋友:无论是Unicorn的Hook回调,还是Frida的console.log,多打日志能帮你快速定位问题。
  • 先理解,再动手:我见过很多人一上来就Hook所有函数,结果被海量日志淹没了。先静态分析一下,找到关键函数,再有针对性地动态分析,效率会高很多。

好了,这一章就到这里。Unicorn和Frida这两个工具,值得你花时间去熟练掌握。它们就像瑞士军刀,在Native逆向的战场上,能帮你解决大部分问题。


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