基于 unicorn 的单个函数模拟执行和 fuzzer 实现
1. Why
在日常的二进制逆向分析中,我们常会碰到很多复杂的二进制文件,纯静态分析是一件很耗时且耗体力的活,而且不易追踪数据流的变动,所以我们常常需要借助动态调试来定位问题,分析函数功能。
对于 x86 架构的,让程序跑起来并不是什么难事,需要考虑的仅是 32bits / 64 bits,PE 文件格式和 ELF 文件格式等问题。通过搭建 Windows / Linux 操作系统虚拟机,我们可以很方便的对目标二进制文件进行调试。那么 arm 架构呢,mips 架构呢,还有可能碰到的系统环境不是常见的 Windows / Linux。所以这里有第一个需求:如何让不同架构的二进制程序跑起来。
如果手头上有相应的设备,那么当然更好,如果没有那只能模拟了。这里有几种思路:
- qemu,qemu 有full-system 模式和 user 模式。User 模式下可设定好 lib目录实现不同架构的动态编译的二进制文件模拟执行并且可使用gdb 调试。
- miasm,一款 python 实现的模拟器,可汇编,反汇编,模拟执行,甚至符号执行。
- unicorn,基于 qemu 但大幅改进的模拟器,仅模拟 CPU,可定制性强
当然,有时我们不仅仅满足于程序能执行起来,还希望能够进行一些 fuzzing 操作。喂一些数据给函数,看看函数执行后,数据所在的内存区域发生了怎样的改变,程序的流程又走向了哪里。
再考虑一种场景,ARM, AArch64, M68K, Mips, Sparc,X86…这么多架构的汇编语言,如果在分析时碰到不熟悉的架构的汇编语言,每条指令都去查手册,实在太繁琐和低效了,如果能让一小块指令运行起来,查看寄存器和内存的变化,这样岂不是可以大大降低分析难度。
进而我们考虑一些攻防对抗的操作,例如调试 shellcode ,执行一些加过混淆指令和复杂算法的函数。是否可以通过模拟执行的方式来快速和便利得解决问题。
所以需求和目的很明确:
- 1. 脱离设备调试,让不同架构的目标二进制程序运行起来
- 2. 可以进行一些 fuzzing 操作
- 3. 可以模拟小段指令或单个函数
为什么选择 unicorn-engine。对 Qemu 深度定制较复杂;miasm 指令转换不准确及开发复杂。相比较而言,unicorn-engine 对用户很友好了。
2. 需要考虑的问题及设计思路
需要明确的是,unicorn-engine 所实现的仅仅是对 CPU 的模拟,也就是说,它的输入只能是各种 CPU 的二进制指令序列。
对于一个可执行文件来说,会有很多函数调用的跳转指令,如果在指令模拟的时候跳转的位置及相应的指令块没有被加载到模拟器中,那模拟时肯定是会有异常的。
同理,全局的数据段也需要进行相应的处理,否则会出现数据变量引用地址错误的异常。这样的模拟是低效的和不完善的。
还有个很大的问题,就是动态编译的可执行文件,里面包含了大量的符号信息,有时会调用一些系统库函数。那么对于这些不同架构的系统调用和库函数的处理就是一个难点了。
其次我们需要考虑的是数据的变异的问题。函数的输入可能是一个数值,也可能是一个指向存储数据的某块内存的指针。
2.1 unicorn-engine 模拟步骤
结合具体的 C 代码解释下 unicorn-engine的模拟步骤:
1. 初始化模拟器
uc_engine *uc;
uc_open(UC_ARCH_X86, UC_MODE_32, &uc);
这里初始化了一个32bit 的 x86 架构的模拟器
2. 映射模拟器的虚拟地址
#define CODEADDR 0x10000
#define STACKADDR 0x20000
uc_mem_map(uc, CODEADDR, 2 * 1024 * 1024, UC_PROT_ALL);
uc_mem_map(uc, STACKADDR, 2 * 1024 * 1024, UC_PROT_ALL);
这里映射了代码区和栈区的内存空间
3. 把二进制数据写入模拟器的虚拟地址
#define X86_CODE32 "\x31\xc9\xf7\xe1\x51\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\xb0\x0b\xcd\x80"
uc_mem_write(uc, CODEADDR, X86_CODE32,sizeof(X86_CODE32) - 1);
4. 初始化模拟器的寄存器
*addr = STACKADDR;
uc_reg_write(uc, UC_X86_REG_ESP, addr);
5. 设置hook 回调函数
uc_hook_add(uc, &trace1, UC_HOOK_CODE, hook_code, NULL, CODEADDR,CODEADDR + sizeof(X86_CODE32));
hook_code 就是一个回调函数,由用户自己实现
6. 执行模拟
uc_emu_start(uc, CODEADDR, CODEADDR + sizeof(X86_CODE32), 0, 0);
以上就是 unicorn-engine 模拟执行的大致步骤。Unicorn-engine 提供了对 code block ,insr,insn等强大且多样的指令级hook,极大便利了我们的操作。
2.2 设计思路
设计思路大致分为六个模块:初始化配置;可执行文件解析;模拟执行;hook操作;数据及参数变异;crash处理。如下图 2-1 所示:
图2-1 程序流程图
3. 具体实现
开发环境:
- OS:Windows 10 wsl
- 调试:gdb + gef
- Radamsa
- 开发语言:C + Python
- GCC额外编译选项:gcc-lunicorn -lLIEF -lpython2.7 -I /usr/include/python2.7/
3.1 初始化模块
初始化模块主要负责解析配置文件。配置文件采用 ini 文件格式,使用 Python 可以很方便解析。基本的配置设置如下图 3-1 。
基本的设置包括文件名;可执行文件的类型;映射的地址和大小;需要执行的指令的在文件中的偏移及大小等等。
图 3-1 基本配置
当然还可以进行参数和数据区的数据设定,指定哪个参数或数据块需要变异,还可以设定初始化的寄存器,如下图3-2。
图 3-2 数据块,参数和寄存器设置
3.2 可执行文件解析模块
目前主流的可执行文件无非是 PE / ELF / Mach-O 等等。当然也可以直接传入直接一段指令序列的二进制文件。那么可执行文件解析模块需要解析什么呢。
LOAD 段。没错,为了实现函数调用(不包括库函数和系统调用)和全局数据的引用问题,这里解析可执行文件的所有可加载段,把代码段和数据段的内容都映射到模拟器中。以 ELF 为例,部分代码如下图 3-3 所示:
图 3-3 映射 LOAD 段
一步一步根据可执行文件的格式手工解析可执行文件是件体力活,所以这里使用了 LIEF这个工具。这个工具提供了各种可执行文件的解析 API 和 各种语言的 bindings ,因此可以很方便地得到可执行文件的特性来进行相应的操作。
3.3 模拟执行模块
这个模块就是根据循环次数 count 调用 unicorn-engine 模拟执行指令。Unicorn-engine 提供了一个表示异常的枚举类型,uc_err。其值如图 3-4 所示:
图 3-4 uc_err 枚举值
通过对这些 err 的捕获和处理,我们可以知晓指令流运行时的状态和对 unicorn-engine 进行扩展打造一个强大的分析工具。
图 3-5 模拟指令流程
如上图 3-5 模拟指令流程所示,通过函数指针和 callback 解决不同架构的寄存器;参数和数据的初始化和 hook 的操作。
3.4 Hook 处理模块
Unicorn-engine 的 Hook callback 机制极大的方便了我们对指令的调试,我们可以通过hook 机制查看当前的寄存器状态和栈区的数据等。一个调试用的 hook 函数如下图 3-6 所示:
图 3-6 调试用的 hook 函数
不仅如此,hook 机制还为我们解决库函数,系统调用,中断等提供了便利和思路。如下图 3-7 所示,unicorn-engine 提供了丰富的 hook 类型。
图 3-7 unicorn-engine提供的 hook type
所以对于系统调用和中断的处理,我们可以通过注册 UC_HOOK_INTR 类型的hook callback 捕获系统调用和中断。
这个时候就需要模拟操作系统的中断操作和系统调用的操作。手动实现?这绝对是件耗时耗力且不讨好的做法。因此,这里采用一种异构转化的方法,简单来说,就是实现一个转化器。
- 1. 获取进行系统调用前的各种环境参数
- 2. 开发环境调用相关系统调用
- 3. 获取开发环境系统调用的环境结果写入目标架构的 CPU 环境
比如目标环境是 arm 架构的,开发环境是 x64 。在转化时获取模拟器此时的 CPU 环境和内存状态,转化为 x64 进行系统调用的 CPU 环境和 内存状态。接着在 x64 调用相关的系统调用,获取结果,写入模拟器的环境。
同理,库函数的调用也可以这样来解决。
3.5 数据变异模块
数据变异的操作,无非就是对参数和内存块的数据进行变异。这里直接调用 radamsa 工具生成,然后稍微适配下数据格式即可。
3.6 Crash 处理模块
Crash 的处理很简单,就是捕获指令运行时的异常,然后写入到一个 Crash 文件。
4. Conclusion & TODO
当然,在实际开发实现的过程中会遇到很多问题。Unicorn-engine 只是封装实现了 CPU 指令的模拟,期间稍有不慎就会陷入各种奇奇怪怪的问题中,需要不断跟踪和调试。
异构转化的思路似乎很美好,但是毕竟不同架构的体质差别还是很大的,在实现的过程中还是蛮多磕磕碰碰的。
5. Reference
l https://gitlab.com/akihe/radamsa
l https://github.com/unicorn-engine/unicorn
l https://github.com/lief-project/LIEF