@TOC

起因

自己在打MTCTF 2021 100mazes的比赛时,第一次接触到这种大量代码结构相似,却无法使用idapython自动化完成题目,无奈只能手动导出地图,一个一个的解密迷宫,这消耗了大量的时间,最终我在第十关放弃了作答并期待学习像这种结构大量相似的题该如何去做,最后在观看wjh的博客时,发现了他又用unicorn解决了此题目,我参照式的记录了此篇文章,鼓励自己学习更多的关于unicorn的知识。

什么是unicorn

Unicorn是一个轻量级, 多平台, 多架构的CPU模拟器框架,我们可以更好地关注CPU操作, 而忽略机器设备的差异.
缺点:无法模拟整个程序或系统, 也不支持系统调用. 你需要手动映射内存并写入数据进去, 随后你才能从指定地址开始模拟.
用处:使用unicorn可以在比赛中解决大量相似功能模块的题目,完成自动化操作

使用方法

导入模块

1
2
3
from unicorn import *
from unicorn.x86_const import *
uc = Uc(UC_ARCH_X86, UC_MODE_64)

寄存器读写

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
uc.reg_write(UC_X86_REG_RAX, 0x71f3029efd49d41d)
uc.reg_write(UC_X86_REG_RBX, 0xd87b45277f133ddb)
uc.reg_write(UC_X86_REG_RCX, 0xab40d1ffd8afc461)
uc.reg_write(UC_X86_REG_RDX, 0x919317b4a733f01)
uc.reg_write(UC_X86_REG_RSI, 0x4c24e753a17ea358)
uc.reg_write(UC_X86_REG_RDI, 0xe509a57d2571ce96)
uc.reg_write(UC_X86_REG_R8, 0xea5b108cc2b9ab1f)
uc.reg_write(UC_X86_REG_R9, 0x19ec097c8eb618c1)
uc.reg_write(UC_X86_REG_R10, 0xec45774f00c5f682)
uc.reg_write(UC_X86_REG_R11, 0xe17e9dbec8c074aa)
uc.reg_write(UC_X86_REG_R12, 0x80f86a8dc0f6d457)
uc.reg_write(UC_X86_REG_R13, 0x48288ca5671c5492)
uc.reg_write(UC_X86_REG_R14, 0x595f72f6e4017f6e)
uc.reg_write(UC_X86_REG_R15, 0x1efd97aea331cccc)
uc.reg_write(UC_X86_REG_RSP, ADDRESS + 0x200000)
uc.reg_write(UC_X86_REG_RIP, CODE + 0x88)
rax = mu.reg_read(UC_X86_REG_RAX)
rbx = mu.reg_read(UC_X86_REG_RBX)
rcx = mu.reg_read(UC_X86_REG_RCX)
rdx = mu.reg_read(UC_X86_REG_RDX)
rsi = mu.reg_read(UC_X86_REG_RSI)
rdi = mu.reg_read(UC_X86_REG_RDI)
r8 = mu.reg_read(UC_X86_REG_R8)
r9 = mu.reg_read(UC_X86_REG_R9)
r10 = mu.reg_read(UC_X86_REG_R10)
r11 = mu.reg_read(UC_X86_REG_R11)
r12 = mu.reg_read(UC_X86_REG_R12)
r13 = mu.reg_read(UC_X86_REG_R13)
r14 = mu.reg_read(UC_X86_REG_R14)
r15 = mu.reg_read(UC_X86_REG_R15)
rsp = mu.reg_read(UC_X86_REG_RSP)
rip = mu.reg_read(UC_X86_REG_RIP)

内存读写

1
2
uc.mem_write(CODE, CODE_DATA)
uc.mem_read(rbp, 8)

mem_write:第一个参数传递要写入的地址,第二个参数传递要写入的数据

mem_read:第一个参数传递要读取的地址,第二个参数传递要读取的长度

内存映射

1
uc.mem_map(ADDRESS, 2 * 1024 * 1024)

mem_map:第一个参数传递要映射的地址,第二个参数传递要映射的长度(按页对齐)。

如果要执行代码,那么必须需要先映射一块内存地址,然后再通过内存的读写把代码数据写入后执行。

hook

作用:由于并不是程序中的所有代码都可以成功模拟,所以需要对程序的内容进行一些 hook,通过这些 hook 来模拟一些代码的执行(例如 syscall 无法模拟,就需要使用回调的方式模拟 syscall 的执行),也正是通过这些 Hook 操作,使得我们程序的灵活性大大增强,可以实现各种各样的功能。
(hook的内容相对来说比较难理解,但同样的也是有很大的作用)

对每个块的回调

1
2
3
4
def hook_block(uc, address, size, user_data):
print(">>> Tracing basic block at 0x%x, block size = 0x%x" %(address, size))
uc.hook_add(UC_HOOK_BLOCK, hook_block)

对每行代码的回调

1
2
3
4
5
6
7
def hook_code64(uc, address, size, user_data):
print(">>> Tracing instruction at 0x%x, instruction size = 0x%x" %(address, size))
rip = uc.reg_read(UC_X86_REG_RIP)
print(">>> RIP is 0x%x" %rip);

uc.hook_add(UC_HOOK_CODE, hook_code64, None, ADDRESS, ADDRESS+20)

无效内存访问回调

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def hook_mem_invalid(uc, access, address, size, value, user_data):
if access == UC_MEM_WRITE_UNMAPPED:
print(">>> Missing memory is being WRITE at 0x%x, data size = %u, data value = 0x%x" \
%(address, size, value))
# map this memory in with 2MB in size
uc.mem_map(0xaaaa0000, 2 * 1024*1024)
# return True to indicate we want to continue emulation
return True
else:
# return False to indicate we want to stop emulation
return False

mu.hook_add(UC_HOOK_MEM_READ_UNMAPPED | UC_HOOK_MEM_WRITE_UNMAPPED, hook_mem_invalid)

内存访问回调

1
2
3
4
5
6
7
8
9
10
def hook_mem_access(uc, access, address, size, value, user_data):
if access == UC_MEM_WRITE:
print(">>> Memory is being WRITE at 0x%x, data size = %u, data value = 0x%x" \
%(address, size, value))
else: # READ
print(">>> Memory is being READ at 0x%x, data size = %u" \
%(address, size))

uc.hook_add(UC_HOOK_MEM_WRITE, hook_mem_access)
uc.hook_add(UC_HOOK_MEM_READ, hook_mem_access)

针对每个指针的回调

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
def hook_syscall(uc, user_data):
arg_regs = [UC_X86_REG_RDI, UC_X86_REG_RSI, UC_X86_REG_RDX, UC_X86_REG_R10, UC_X86_REG_R8, UC_X86_REG_R9]
rax = uc.reg_read(UC_X86_REG_RAX)
if rax in SYSCALL_MAP.keys():
ff, n = SYSCALL_MAP[rax]
args = []
while n > 0:
args.append(uc.reg_read(arg_regs.pop(0)))
n -= 1
try:
ret = ff(uc, *args) & 0xffffffffffffffff
except Exception as e:
uc.emu_stop()
return
else:
ret = 0xffffffffffffffff
uc.reg_write(UC_X86_REG_RAX, ret)

SYSCALL_MAP = {
0: (sys_read, 3),
1: (sys_write, 3),
60: (sys_exit, 1),
}

uc.hook_add(UC_HOOK_INSN, hook_syscall, None, 1, 0, UC_X86_INS_SYSCALL)

开始和结束

1
2
uc.emu_start(CODE, CODE + 0x3000 - 1)
uc.emu_stop()

emu_start:来执行模拟,第一个参数填写模拟的开始地址,第二个参数填写模拟的结束地址

emu_stop:用来结束模拟

例题:MTCTF2021 100mazes

main

请添加图片描述

a maze

可以观察到这里在主函数上有一百个迷宫,我们随便打开其中的一个进行分析
在这里插入图片描述在这里插入图片描述

观察到栈上的数据是这样的,我们可以直接根据 rbp 来寻址提取各个数据的内容,然后再进行解密操作
在这里插入图片描述并且注意到这里有一个 getchar 函数,由于我们没有装载 libc,所以这个函数是无法被 unicorn 所模拟的,我们就可以以这里为当前迷宫读取结束的位置,hook 所有执行到这个函数的代码,并且数据进行解析,并且在执行完这个函数后执行 retn 到上一层的函数。

代码

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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
from hashlib import md5
from pwn import *
from unicorn import *
from unicorn.x86_const import *

BASE = 0
CODE = BASE + 0x0
CODE_SIZE = 0x100000
STACK = 0x7F00000000
STACK_SIZE = 0x100000
FS = 0x7FF0000000
FS_SIZE = 0x100000
CODE_DATA = ""

dx = [0, 0, -1, 1]
dy = [-1, 1, 0, 0]
str_map = "WSAD"
ans = ""
map_data = []
ans_map = {}
all_input = ""


def dfs(t, f_x, f_y, x, y):
global map_data, str_map, ans_map, ans, dx, dy
if t == 15:
# print('ok')
return True
for i in range(4):
nx = x + dx[i]
ny = y + dy[i]
if 0 <= nx < 25 and 0 <= ny < 25 and not (f_x == nx and f_y == ny) and chr(map_data[ny * 25 + nx]) == '.':
if dfs(t + 1, x, y, nx, ny):
ans = str_map[i] + ans
return True
return False


def hook_code(uc, address, size, user_data):
global map_data, str_map, ans_map, ans, all_input
# print('>>> Tracing instruction at 0x%x, instruction size = 0x%x' % (address, size))
assert isinstance(uc, Uc)
code = uc.mem_read(address, 4)
if code == b"\x48\x0F\xC7\xF0":
uc.reg_write(UC_X86_REG_RIP, address + 4) #遇见rdrand rax直接跳过

if address == 0x640: #遇见printf ret
rsp = uc.reg_read(UC_X86_REG_RSP)
retn_addr = u64(uc.mem_read(rsp, 8))
uc.reg_write(UC_X86_REG_RIP, retn_addr)
elif address == 0x650: #遇见getchar 读取迷宫
rbp = uc.reg_read(UC_X86_REG_RBP)
maze_data = uc.mem_read(rbp - 0xC6A, 0x625) #迷宫数据
step_data = uc.mem_read(rbp - 0x9F9, 4).decode() #方向数据
xor_data = uc.mem_read(rbp - 0x9D0, 0x9C4) #异或数据
lr_val = u32(uc.mem_read(rbp - 0x9F4, 4)) #起点x
ur_val = u32(uc.mem_read(rbp - 0x9F0, 4)) #起点y

maze_data = list(maze_data) #异或
for i in range(0, 0x9C4, 4):
maze_data[i // 4] ^= u32(xor_data[i: i + 4])

for i in range(25): #合成最终的迷宫
line_data = ""
for j in range(25):
line_data += chr(maze_data[i * 25 + j])
# print(line_data)

map_data = maze_data
str_map = step_data
ans = ""
assert dfs(0, -1, -1, lr_val, ur_val) #深搜
# print(ans)
all_input += ans

# leave;ret
rbp = uc.reg_read(UC_X86_REG_RBP)
new_rbp = u64(uc.mem_read(rbp, 8))
retn_addr = u64(uc.mem_read(rbp + 8, 8))
uc.reg_write(UC_X86_REG_RBP, new_rbp)
uc.reg_write(UC_X86_REG_RSP, rbp + 0x18)
uc.reg_write(UC_X86_REG_RIP, retn_addr)


def init(uc):
uc.mem_map(CODE, CODE_SIZE, UC_PROT_ALL)
uc.mem_map(STACK, STACK_SIZE, UC_PROT_ALL)

uc.mem_write(CODE, CODE_DATA)
uc.reg_write(UC_X86_REG_RSP, STACK + 0x1000)

uc.hook_add(UC_HOOK_CODE, hook_code) #hook


if __name__ == '__main__':
with open('C:\\Users\\86178\\Desktop\\100mazes', "rb") as f:
CODE_DATA = f.read()
uc = Uc(UC_ARCH_X86, UC_MODE_64)
main_addr = 0x00000000000A6AA8 #主函数的起始地址
main_end = 0x00000000000A7344@ #主函数的末地址
init(uc)
try:
uc.emu_start(CODE + main_addr, CODE + main_end)
except Exception as e:
print(e)

print(all_input)
d = md5(all_input.encode()).hexdigest() #md5
print("flag{" + d[0:8] + "-" + d[8:12] + "-" + d[12:16] + "-" + d[16:20] + "-" + d[20:32] + "}")

可以结合上述的描述来尝试理解一下代码,我这里分析几处比较重要的地方

首先是,程序中除了代码空间还存在栈空间,所以我们除了需要映射代码空间,还需要映射一块足够大的栈空间,并且赋值给 RSP 寄存器适当的位置(最好选择一个中间位置)。

接下来我是用一个 hook 钩子来对每一行指令进行检查(在实际使用的时候都最好先挂上这样的一个钩子便于确认程序在哪里出现了异常),并且为了偷懒,直接在这个钩子中对地址进行判断来做相应的操作。

  1. 对 rdrand rax 这个指令直接跳过,因为似乎 unicorn 无法正确的识别这个指令,并且这个指令对程序流程并没有实际上的影响,所以这里我直接跳过。
    在这里插入图片描述
  1. 对 printf 函数直接跳过,但是由于 plt@printf 的实现问题,我这里需要手动模拟 retn 操作,这再次体现了 unicorn 的灵活性。

  2. 对于遇到 getchar 函数的时候,执行读取迷宫数据操作,读取之后进行 dfs 寻找答案,并组合输出,由于题目要求的是对最后的所有输入取 md5 作为 flag,所以这里还保存了所有的输入。这里我尝试手动模拟了 leave;ret 来返回到主函数

参考链接

http://blog.wjhwjhn.com/archives/288/
https://bbs.pediy.com/thread-224315.htm
https://github.com/unicorn-engine/unicorn/tree/master/bindings/python