Unicorn简介

Unicorn是一个轻量级,多平台,多架构的CPU模拟器框架,基于qemu开发,它可以代替CPU模拟代码的执行,就比如说我们需要调试某个程序,常见的调试器需要配置可执行文件需要的环境并且还需要考虑一些恶意代码导致的安全问题,但是通过Unicorn我们就可以单纯的模拟代码的执行(甚至可以指定从某个地址开始执行)而不需要考虑这些问题。
它的亮点有:

  • 支持多种架构: Arm, Arm64 (Armv8), M68K, Mips, Sparc, & X86 (include X86_64).
  • 对Windows和nix系统(已确认包含Mac OSX, Linux, BSD & Solaris)的原生支持
  • 具有平台独立且简洁易于使用的API
  • 使用JIT编译技术, 性能表现优异

技术原理

虚拟内存

Unicorn采用虚拟内存机制,使得虚拟CPU的内存与真实CPU的内存隔离。使用时,需要手动映射内存并写入数据,随后才能从指定地址开始模拟。

Unicorn 使用如下API来操作内存:

1
2
3
uc_mem_map
uc_mem_read
uc_mem_write

使用uc_mem_map映射内存的时候,address 与 size 都需要与0x1000对齐,也就是0x1000的整数倍,否则会报UC_ERR_ARG 异常。

Hook机制

Unicorn的Hook机制为编程控制虚拟CPU提供了便利。

Unicorn 支持多种不同类型的Hook,大致可以分为指令执行类、内存访问类和异常处理类。

调用hook_add函数可添加一个Hook。Unicorn的Hook是链式的,而不是传统Hook的覆盖式,也就是说,可以同时添加多个同类型的Hook,Unicorn会依次调用每一个handler。

实战演示

我们将以hxp CTF 2017上的逆向工程题目Fibonacci(译注:斐波那契)为例,基于Python上的Unicorn引擎开发相关调试程序,通过反汇编、动态调试等技术,逐步模拟程序运行,并通过Hook函数来优化程序,最终得到Flag。

反汇编二进制文件

打开虚拟机,运行二进制文件,可以注意到这个程序计算和输出Flag非常的慢。Flag的下一个字节计算的越来越慢。

这就意味着有必要优化程序来获取Flag(在合理的时间内)。
在IDA Pro的帮助下,我们得到了像C语言一样的伪代码(如下)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
__int64 __fastcall main(int a1, char **a2, char **a3)
{
int *v3; // rbp
int v4; // ebx
__int64 v5; // r8
char v6; // r9
__int64 v7; // r8
char v8; // cl
int v10[7]; // [rsp+Ch] [rbp-1Ch] BYREF

v3 = &dword_4007E1;
v4 = 0;
setbuf(stdout, 0LL);
printf("The flag is: ");
while ( 1 )
{
LODWORD(v5) = 0;
do
{
v10[0] = 0;
fibonacci(v4 + v5, v10);
v8 = v7;
v5 = v7 + 1;
}
while ( v5 != 8 );
v4 += 8;
if ( (unsigned __int8)(v10[0] << v8) == v6 )
break;
v3 = (int *)((char *)v3 + 1);
_IO_putc(v6 ^ (unsigned __int8)(LOBYTE(v10[0]) << v8), stdout);
}
_IO_putc('\n', stdout);
return 0LL;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
void __fastcall fibonacci(int a1, _DWORD *v10)
{
int v3; // eax
int v4; // er12
int v5; // eax
unsigned int v6; // edx
unsigned int v7; // esi
unsigned int v8; // eax

if ( a1 )
{
if ( a1 == 1 )
{
fibonacci(0, v10);
v6 = v8 - ((v8 >> 1) & 0x55555555);
}
else
{
fibonacci(a1 - 2, v10);
v4 = v3;
fibonacci(a1 - 1, v10);
v6 = v4 + v5 - (((unsigned int)(v4 + v5) >> 1) & 0x55555555);
}
v7 = ((v6 >> 2) & 0x33333333) + (v6 & 0x33333333) + ((((v6 >> 2) & 0x33333333) + (v6 & 0x33333333)) >> 4);
*v10 ^= ((BYTE1(v7) & 0xF) + (v7 & 0xF) + (unsigned __int8)((((v7 >> 8) & 0xF0F0F) + (v7 & 0xF0F0F0F)) >> 16)) & 1;
}
else
{
*v10 ^= 1u;
}
}

观察反编译代码,我们可以看到main函数调用了fibonacci函数若干次,并且fibonacci函数是个递归函数。当所求项数越来越大,那么程序运行必然也越来越慢,且时间呈指数级上升。

用Unicorn模拟执行

在开始优化之前,我们先用Unicorn引擎模拟一个正常的程序,不进行优化,成功之后,再开始优化。

  1. 创建一个Python的脚本,然后将二进制文件放到同一目录下。
  2. 调用相关模块。
1
2
from unicorn import * # 加载主要的二进制模块和一些Unicorn中的一些基本常量
from unicorn.x86_const import * # 加载一些特定的x86和x64的常量
  1. 为x86-64架构初始化Unicorn引擎。
1
mu = Uc (UC_ARCH_X86, UC_MODE_64)

Uc函数需要以下参数:
第一个参数:架构类型。这些常量以UC_ATCH_为前缀;
第二个参数:架构细节说明。这些常量以UC_MODE_为前缀。

  1. 初始化内存。针对这个二进制文件来说,我们需要将代码写入到某个地方,并且分配一些栈空间。
    二进制文件的基址是0x400000。栈的话不妨从地址0x0开始,大小为1024*1024字节(即1M)。
    可以使用mem_map函数来映射内存。
1
2
3
4
5
BASE = 0x400000
STACK_ADDR = 0x0
STACK_SIZE = 1024*1024
mu.mem_map(BASE, 1024*1024)
mu.mem_map(STACK_ADDR, STACK_SIZE)
  1. 加载二进制文件到基址,并将RSP寄存器指向申请的栈空间底部。
1
2
mu.mem_write(BASE, read("./fibonacci"))
mu.reg_write(UC_X86_REG_RSP, STACK_ADDR + STACK_SIZE - 1)

需要注意的是,内存中栈通常由高地址向低地址增长。栈的底部位于较高的地址,而栈的顶部位于较低的地址。RSP寄存器初始化时应指向高地址。

  1. 模拟执行代码。

观察main函数地址,其开始于0x00000000004004E0,结尾于0x0000000000400582。实际上我们不需要retn这个函数,所以我们可以在0x0000000000400575处结尾。保留retn的另一个原因是为接下来优化Hook函数做准备。

1
mu.emu_start(0x00000000004004E00x0000000000400575)

目前为止的脚本(solve1.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from unicorn import *  # 加载主要的二进制模块和一些Unicorn中的一些基本常量
from unicorn.x86_const import * # 加载一些特定的x86和x64的常量
from pwn import * # 这里主要用到 read 函数来读取文件

mu = Uc(UC_ARCH_X86, UC_MODE_64) # 第一个参数:架构类型,第二个参数:架构细节说明,为x86-64架构初始化一下Unicorn引擎

BASE_ADDR = 0x400000 # 基址
STACK_ADDR = 0x0 # 栈地址
STACK_SIZE = 1024 * 1024 # 栈空间

mu.mem_map(BASE_ADDR, 1024 * 1024)
mu.mem_map(STACK_ADDR, STACK_SIZE) # mem_map函数来映射内存

mu.mem_write(BASE_ADDR, read("./fibonacci")) # read仅仅返回文件中的内容,加载二进制文件到基址上

mu.reg_write(UC_X86_REG_RSP, STACK_ADDR + STACK_SIZE - 1) # 初始化RSP寄存器指向栈底(倒着的)

mu.emu_start(0x4004E0, 0x400575) # 启动模拟执行,起始从main函数开始,遇到while循坏外面的IO_putc结束

  1. 执行脚本。这个时候会发现报错:

从错误信息“Invalid memory read”我们可以知道脚本进行了一个无效的内存读取。为了探究是哪条指令执行过程中出现了这样的错误,我们可以在mu.emu_start之前加入一个Hook。

1
2
3
def hook_code(mu, address, size, user_data): 
print('>>> Tracing instruction at 0x%x, instruction size = 0x%x'%(address, size))
mu.hook_add(UC_HOOK_CODE, hook_code)

我们自定的函数hook_code在执行每一条代码模拟的时候都将被调用。参数如下:

  • Uc实例句柄
  • 指令的地址
  • 执行的长度
  • 用户自定义数据(我们可以在hook_add的可选参数中传递这个值)
  1. 再次运行脚本。脚本报错:

这表明,脚本执行以下的指令的时候发生了错误:

通过IDA我们可以发现,这条指令从地址0x601038处读取内存(如下图)。

这是.bss区段所在,然而我们并没有来分配这个区段,解决方案是跳过所有出现问题的指令。
可以通过修改RIP寄存器的方式来跳过这些指令。

1
mu.reg_write(UC_X86_REG_RIP, address+size)

添加指令黑名单,修改Hook函数。

1
2
3
4
5
instructions_skip_list=[]
def hook_code(mu, address, size, user_data): 
print('>>> Tracing instruction at 0x%x, instruction size = 0x%x'%(address, size))
if address in instructions_skip_list:
mu.reg_write(UC_X86_REG_RIP, address+size)

所有的指令黑名单如下:

1
instructions_skip_list = [0x00000000004004EF0x00000000004004F60x00000000004005020x000000000040054F]

当找出所有需要跳过的指令之后,为了方便查看Flag的输出,我们可以注释print跟踪语句。

  1. 再次运行脚本,脚本正常运行,但未输出原有Flag。

通过对原二进制文件的Linux远程动态调试可知,Unicorn模拟运行时其原有的call __IO_putc指令无法正常运行(跳转至未分配区段)。

然而,在执行这条指令之前程序已经将需要输出的字符存入RDI寄存器。于是这里我们就可以直接读取RDI寄存器,然后输出,跳过模拟执行这部分代码。进一步修改Hook函数如下。

1
2
3
4
5
6
7
8
9
instructions_skip_list = [0x00000000004004EF0x00000000004004F60x00000000004005020x000000000040054F]
def hook_code(mu, address, size, user_data): 
# print('>>> Tracing instruction at 0x%x, instruction size = 0x%x' %(address, size))
if address in instructions_skip_list:
mu.reg_write(UC_X86_REG_RIP, address+size)
elif address == 0x400560:
C = mu.reg_read(UC_X86_REG_RDI)
print(chr(c))
mu.reg_write(UC_X86_REG_RIP, address+size)
  1. 再次运行脚本,脚本正常运行,输出原有Flag,但速度依然很慢。

目前为止的脚本(solve2.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
from unicorn import *  # 加载主要的二进制模块和一些Unicorn中的一些基本常量
from unicorn.x86_const import * # 加载一些特定的x86和x64的常量
from pwn import * # 这里主要用到 read 函数来读取文件

mu = Uc(UC_ARCH_X86, UC_MODE_64) # 第一个参数:架构类型,第二个参数:架构细节说明,为x86-64架构初始化一下Unicorn引擎
BASE_ADDR = 0x400000 # 基址
STACK_ADDR = 0x0 # 栈地址
STACK_SIZE = 1024 * 1024 # 栈空间
mu.mem_map(BASE_ADDR, 1024 * 1024)
mu.mem_map(STACK_ADDR, STACK_SIZE) # mem_map函数来映射内存
mu.mem_write(BASE_ADDR, read("./fibonacci")) # read仅仅返回文件中的内容,加载二进制文件到基址上
mu.reg_write(UC_X86_REG_RSP, STACK_ADDR + STACK_SIZE - 1) # 初始化RSP寄存器指向栈底(倒着的)

Inst_skip_list = [0x4004EF, 0x4004F6, 0x400502, 0x40054F] # 需要跳过的黑名单


def hook_code(mu, address, size, user_data): # 在启动之前写一个hook函数来输出我们需要的信息
# print('>>> Tracing instruction at 0x%x, instruction size = 0x%x' % (address, size))
if address in Inst_skip_list: # 遇到了就跳过避免报错
mu.reg_write(UC_X86_REG_RIP, address + size)
elif address == 0x400560: # 如果遇到了输出函数就打印输出的内容
flag = mu.reg_read(UC_X86_REG_RDI)
print((chr(flag)), end="")
mu.reg_write(UC_X86_REG_RIP, address + size)


mu.hook_add(UC_HOOK_CODE, hook_code) # 添加hook
mu.emu_start(0x4004E0, 0x400575) # 启动模拟执行

优化Hook函数

根据之前的分析,fibonacci函数是一个递归函数,而且在该程序中反复调用,当所求项数越来越大,那么程序运行必然也越来越慢,且时间呈指数级上升。

在ACM编程中,如果一个问题具有多数重叠子问题,为减少重复计算,我们只需对每一个子问题求解一次,然后将其不同阶段的不同状态保存在一个二维数组中即可,这便是动态规划(Dynamic Programming,DP)。如果我们对fibonacci函数采用动态规划的思想,那么就可以大大提升数列的求解效率。

我们可以看到fibonacci这个函数有两个参数。

第一个参数在进入的时候存在EDI寄存器中,第二个参数是一个指针,用RSI寄存器储存,同时返回值也有两个,第一个存在RAX寄存器中,第二个就是指针存在RSI寄存器中。

我们考虑用一个dp字典来存储这两个参数。在fibonacci入口时,检查参数对应的值是否已经被dp记录。

  • 如果是,直接返回这个key-value就行,只需将返回值写入到RAX中,同时设置RIP为RET指令的值,退出这个函数。这里需要注意,不能在fibonacci函数内直接跳转到retn,因为这条指令已经被Hook了,所以我们跳转到main中的retn。否则会报错如下。
  • 如果dp中没有出现参数和对应的值,将参数添加到dp中。

当退出函数的时候,保存返回值。可以从我们的栈结构中读取参数和返回值。

最终脚本(solve3.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
from unicorn import *  # 加载主要的二进制模块和一些Unicorn中的一些基本常量
from unicorn.x86_const import * # 加载一些特定的x86和x64的常量
from pwn import * # 这里主要用到 read 函数来读取文件,同时用到 u32 和 p32 两个函数,u32将4字节的string转换为integer,以小端序表示这个数据,p32与u32相反

mu = Uc(UC_ARCH_X86, UC_MODE_64) # 第一个参数:架构类型,第二个参数:架构细节说明,为x86-64架构初始化一下Unicorn引擎
BASE_ADDR = 0x400000 # 基址
STACK_ADDR = 0x0 # 栈地址
STACK_SIZE = 1024 * 1024 # 栈空间

mu.mem_map(BASE_ADDR, 1024 * 1024)
mu.mem_map(STACK_ADDR, STACK_SIZE) # mem_map函数来映射内存
mu.mem_write(BASE_ADDR, read("./fibonacci")) # read仅仅返回文件中的内容,加载二进制文件到基址上
mu.reg_write(UC_X86_REG_RSP, STACK_ADDR + STACK_SIZE - 1) # 初始化RSP寄存器指向栈底(倒着的)

Inst_skip_list = [0x4004EF, 0x4004F6, 0x400502, 0x40054F]

stack = []
dp = {}
FIBONACCI_ENTRY = 0x400670
FIBONACCI_END = [0x4006F1, 0x400709]


def hook_code(mu, address, size, user_data): # 在启动之前写一个hook函数来输出我们需要的信息
# print('>>> Tracing instruction at 0x%x, instruction size = 0x%x' % (address, size))
if address in Inst_skip_list: # 遇到了就跳过避免报错
mu.reg_write(UC_X86_REG_RIP, address + size)
elif address == 0x400560: # 如果遇到了输出函数就打印输出的内容
flag = mu.reg_read(UC_X86_REG_RDI)
print((chr(flag)), end="")
mu.reg_write(UC_X86_REG_RIP, address + size)
elif address == FIBONACCI_ENTRY: # 遇到fibonacci函数的入口
# 把参数存储下来
arg0 = mu.reg_read(UC_X86_REG_RDI)
rsi = mu.reg_read(UC_X86_REG_RSI) # 用rsi存储地址,这里是间接寻址,(rsi)是数据
arg1 = u32(mu.mem_read(rsi, 4))
# 判断参数是否被访问过,如果被访问过就把dp数组中的第一个参数赋给RAX寄存器,第二个参数的数据写入rsi指向的地址中
if (arg0, arg1) in dp:
(ret_rax, ret_ref) = dp[(arg0, arg1)]
mu.reg_write(UC_X86_REG_RAX, ret_rax)
mu.mem_write(rsi, p32(ret_ref))
mu.reg_write(UC_X86_REG_RIP, 0x400582) # 由于此时fibonacci这个函数还在被hook中,所以不能跳转到该函数本身的ret指令
else:
stack.append((arg0, arg1, rsi)) # 不在dp数组中就入栈
elif address in FIBONACCI_END: # 遇到fibonacci函数的出口,从栈顶拿出数据建立dp数组的映射
(arg0, arg1, rsi) = stack.pop()
ret_rax = mu.reg_read(UC_X86_REG_RAX)
ret_ref = u32(mu.mem_read(rsi, 4))
dp[(arg0, arg1)] = (ret_rax, ret_ref)


mu.hook_add(UC_HOOK_CODE, hook_code) # 添加hook
mu.emu_start(0x4004E0, 0x400575) # 启动模拟执行

运行如下:

写在最后

在学校的天璇战队搞逆向也有大半年了,是时候进行反思,我在这个领域究竟学到了些什么。

在我看来,逆向工程是一门怀疑的艺术。虽然各个程序都只由最基本的汇编指令构成,每条指令都只执行最基本的功能,但量变促成质变,逻辑升华成算法,而算法的逆向不仅需要丰富的逆向经验,还需要极强的耐心。

“三分逆向七分猜”,这句话表达了逆向工程的本质。在逆向过程中,我们经常需要根据有限的线索进行推断和猜测。尽管有时这种推测是不确定的,但它们可以引导我们朝着正确的方向前进(虽然有时候要走的弯路一步都不会少)。

幸好前人为我们试了错,发明了Unicorn引擎——一个功能强大的二进制分析工具。结果表明,Unicorn引擎在逆向工程中具有很高的准确性和可靠性。它能够准确地模拟各种处理器指令的执行,包括复杂的控制流和数据流操作。同时,Unicorn引擎还提供了丰富的API和工具,使我们能够对二进制代码进行深入的分析和修改,帮助我们理解程序的内部结构和逻辑。

对于上面的fibonacci函数,我们本可以通过某种语言重构代码。然而,重构代码的过程并不容易,可能会引入一些BUG和错误。盯着代码找错误可并不好玩。通过Unicorn引擎来解决这个任务可以跳过重构代码的过程,避免上面提到的问题。

逆向工程真正的价值在于通过观察、分析和推测,逐渐揭示出程序的内部工作原理。在调试的过程中,你会感受到怀疑与猜测带给你的驱动力以及不确定性变为确定性所带来的成就感。对于程序本身而言,逆向工程更像是一场解剖手术,从细微处入手,于变化点破局。到最后你会感觉,程序本身也是有生命的。