15、Unicorn模拟执行:Unicorn引擎基础、模拟执行SO函数、脱壳中的调用
Unicorn 这个东西,说白了就是一个轻量级的 CPU 模拟器。它能让你在 PC 上直接跑 ARM、x86 这些架构的二进制代码,不需要手机,不需要真机。我最早接触它是在做某加固脱壳的时候,那时候壳在内存里动态解密,断点一打就崩溃,后来发现 Unicorn 可以完美绕过这些坑。
嗯,这一章我们就来聊聊 Unicorn 怎么用,怎么用它模拟执行 SO 文件里的函数,以及它在脱壳场景下到底能帮我们做什么。
Unicorn 引擎基础
Unicorn 的核心概念其实不多,你只要记住几个关键点:
- 架构支持:ARM、ARM64、x86、x86-64、MIPS 等。做 Android 逆向,我们主要用 ARM 和 ARM64。
- 内存管理:Unicorn 模拟了一个独立的内存空间,你需要手动映射内存、写入代码和数据。
- Hook 机制:可以拦截代码执行、内存读写、中断等事件。这是脱壳中最强大的功能。
- 上下文控制:你可以随时读写寄存器、内存,甚至修改执行流程。
我个人习惯把 Unicorn 理解成一个「沙盒 CPU」。你给它一段二进制指令,它就能模拟执行,并且你可以全程监控它的一举一动。
核心要点:Unicorn 不依赖操作系统,它只模拟 CPU 指令。所以那些依赖系统调用的函数,你需要自己处理。
模拟执行 SO 函数
我们来看一个实际例子。假设你有一个 SO 文件,里面有个函数叫 check_password,你想看看它到底怎么校验的。直接跑在手机上太麻烦,用 Unicorn 模拟执行就快多了。
基本步骤是这样的:
- 加载 SO 文件到内存(用
mmap或直接读文件)。 - 解析 ELF 头,找到目标函数的偏移地址。
- 在 Unicorn 中映射内存,把 SO 的代码段、数据段写进去。
- 设置栈指针(SP)、程序计数器(PC)等寄存器。
- 开始模拟执行,并注册 Hook 来监控执行过程。
下面是一个简化版的代码示例:
from unicorn import *
from unicorn.arm_const import *
# 初始化 ARM 架构的 Unicorn 实例
mu = Uc(UC_ARCH_ARM, UC_MODE_ARM)
# 映射内存:代码段 + 栈
BASE_ADDR = 0x10000000
STACK_ADDR = 0x20000000
STACK_SIZE = 0x100000
mu.mem_map(BASE_ADDR, 0x100000)
mu.mem_map(STACK_ADDR, STACK_SIZE)
# 写入 SO 的代码段(假设已经加载到 code 变量中)
mu.mem_write(BASE_ADDR, code)
# 设置寄存器
mu.reg_write(UC_ARM_REG_SP, STACK_ADDR + STACK_SIZE - 0x100)
mu.reg_write(UC_ARM_REG_PC, BASE_ADDR + func_offset)
# 开始执行
mu.emu_start(BASE_ADDR + func_offset, BASE_ADDR + func_offset + 0x100)
# 读取返回值(ARM 下通常存在 R0)
result = mu.reg_read(UC_ARM_REG_R0)
print(f"函数返回值: {hex(result)}")
你看,核心代码就这么几行。但实际项目中,坑往往在细节里。比如:
- SO 文件可能有重定位,你需要手动修复。
- 函数内部可能调用了其他 SO 的函数,你需要 Hook 掉或者自己实现。
- 有些加固壳会检测是否运行在模拟器中,Unicorn 默认没有这些特征,但你需要留意。
我的经验:我曾经遇到一个函数,执行到一半就崩了。后来发现是因为它访问了一个未映射的内存地址。解决办法是在 Hook 里拦截内存访问异常,动态映射缺失的内存页。
脱壳中的调用
脱壳场景下,Unicorn 的价值就体现出来了。很多加固壳会在运行时动态解密代码,然后跳转到解密后的地址执行。如果你用调试器,壳很容易检测到并退出。但用 Unicorn,你可以完全控制执行环境。
具体怎么用?我总结了几种常见模式:
| 场景 | Unicorn 的作用 | 注意事项 |
|---|---|---|
| 壳动态解密代码段 | 模拟执行解密逻辑,捕获解密后的内存 | 需要 Hook 内存写操作,记录写入的数据 |
| 壳通过 JNI 调用 Java 层 | Hook 掉 JNI 调用,自己模拟返回 | 需要了解 JNI 的调用约定 |
| 壳检测调试器 | Unicorn 默认不触发这些检测 | 但有些壳会检测时间差,需要手动处理 |
| 壳使用混淆或花指令 | Unicorn 可以一步步跟踪执行路径 | 配合 Hook 记录指令序列,方便后续分析 |
举个例子。有一次我分析某加固,它在 JNI_OnLoad 里做了大量解密工作。我直接用 Unicorn 加载 SO,然后 Hook 了所有内存写操作。解密完成后,我把内存 dump 出来,直接得到了原始的 DEX 文件。整个过程不到 5 秒。
注意:Unicorn 模拟执行时,如果遇到未实现的指令(比如某些浮点运算或系统调用),会抛出异常。你需要提前注册好对应的 Hook,或者跳过这些指令。
知识体系结构图
下面这张图概括了 Unicorn 在脱壳中的核心流程:
避坑指南
最后,分享几个我踩过的坑:
- 内存对齐问题:ARM 架构下,很多指令要求内存地址对齐。映射内存时,起始地址最好按页对齐(0x1000 的倍数)。
- Thumb 模式切换:如果 SO 是 Thumb 指令集,PC 寄存器的值需要设置最低位为 1。我刚开始不知道,折腾了半天。
- 系统调用模拟:有些函数会调用
svc指令触发系统调用。你需要 Hook 这个指令,自己模拟返回结果。比如mmap、open等。 - 多线程问题:Unicorn 默认是单线程的。如果 SO 内部创建了线程,你需要自己管理。我一般直接 Hook 掉线程创建函数。
一个小技巧:在 Hook 里打印当前 PC 和指令,可以帮你快速定位执行路径。配合 Capstone 反汇编引擎,效果更佳。
好了,Unicorn 的基础用法和脱壳场景就聊到这里。这东西上手不难,但真正用好需要多练。下次遇到加固壳,不妨试试用 Unicorn 跑一下,你会发现新世界的。