Saltmarch

PWN CF26 MEDIUM  ·  2026-09-29  ·  16-bit refcount overflow → UAF
CF26{st3p_4nd_r3p34t_t1ll_th3_pl4t3_g1v3s_tvz1b9tx5f6561d4}

Saltmarch is the imposition daemon on a label-press line: prepress uploads artwork, builds panels from it, steps-and-repeats them onto press sheets, and tracks dockets through the run. It speaks its own compact binary framing over TCP :1337 so the press-controller side stays small. We get the binary, its libc and loader, and a socket.

It is a textbook heap-object manager, and the bug is a textbook one in unusual clothing: a reference count stored in sixteen bits. Overflow it, and three careless free paths turn the wrap into a use-after-free.

Table of Contents

  1. Triage & Protections
  2. The Wire Framing
  3. The Object Model
  4. The Bug: Increment by R, Decrement by One
  5. Turning the Wrap into a UAF
  6. Leak, Reclaim, Redirect
  7. The Twist: the Flag Was Never a File
  8. Pitfalls
  9. Key Lessons

1 Triage & Protections

A stripped 35 KB x86-64 daemon; forks per connection, so each client gets its own copy of a pre-seeded object store. Protections are close to maxed:

MitigationStateConsequence
PIEONneed a leak for code addresses
Canary / NX / FORTIFYONno stack smash; bounded copies
CET (SHSTK + IBT)ONindirect calls must hit an endbr; no one-gadgets/mid-function ROP
RELROPartialGOT is writable — our control-flow target
libc2.39-0ubuntu8.8no __free_hook; tcache + safe-linking
Reading the protections Partial RELRO on glibc 2.39 points straight at a GOT overwrite (there is no __free_hook any more), and IBT means we must aim it at a function entry like system, never a one-gadget.

2 The Wire Framing

A fixed 12-byte header, then a payload capped at 0x1000 (the read buffer is exactly that size, so there is no overflow there):

off  size  field
0    4     magic   = 0x31504d53   // "SMP1", little-endian
4    1     version = 1
5    1     opcode
6    4     req_id / tag             // echoed in the reply
10   2     payload_len (<= 0x1000)
12   len   payload                  // reply opcode = req_opcode | 0x80

The opcode table is where the shape of the challenge lives. The one that matters first:

OpMeaningAuth
0x01HELLO — sets the auth flag, no passwordbypass
0x02–0x07create / read / write / delete marks (artwork)yes
0x10–0x15create panels, add placements, delete panelsyes
0x20step-and-repeat → build sheetthe bug
0x22delete sheetthe bug
0x31set docket payload (free(old)+malloc(new))overlay primitive

Every auth=yes handler is gated on a single flag — and opcode 0x01 sets it unconditionally, so one HELLO packet unlocks the whole API.

3 The Object Model

Four object arrays, each freshly seeded in the forked child. The mark is the interesting one:

// mark = malloc(0x30)
+0x00  u32 index
+0x04  u32 flags        // low 16 = type/meta,  HIGH 16 = refcount  (a `short` at +0x06, init 1)
+0x08  u16 w, +0x0a u16 h
+0x0c  u32 data_len
+0x10  char name[0x20]
+0x20  ptr vtable
+0x28  ptr data_ptr     // malloc(data_len), <= 0x1000

Panels hold placements ({mark_ptr, x, y, flags}); a sheet holds a copied array of raw mark pointers; dockets hold a payload buffer. The global marks[0x40] array is what we will leave dangling.

4 The Bug: Increment by R, Decrement by One

A mark's reference count is the short at mark+0x06. Two operations move it, and they disagree.

Step-and-repeat (0x20) — increments every copied placement

uVar5 = *(uint*)(mark+4);
*(uint*)(mark+4) = (uVar5 & 0xffff) | ((uVar5>>16)+1)*0x10000;  // 16-bit inc, 0xffff -> 0

It copies a panel's P placements R times into the new sheet (R·P entries) and bumps each pointed mark's count once per copy — so +R per mark. But delete-sheet (0x22) walks only the first P entries and decrements once per distinct mark. The net effect of one build-then-delete cycle:

refcount += (R − 1) every round-trip R is attacker-chosen up to 511. About 128 cheap cycles wrap the 16-bit counter modulo 65536, letting us steer a still-referenced mark's count down to exactly 1.

5 Turning the Wrap into a UAF

Wrapping the count is only half of it. Unlike delete-mark (0x07), the remove-placement (0x12), delete-panel (0x15) and delete-sheet (0x22) free paths free the mark object and its data buffer but never null the mark's slot in the global array. So at count 1, one more decrement frees the object while the slot still points at it:

wrap refcount → 1 → remove-placement → free(obj) + free(data), slot NOT nulled → read/write mark = UAF

get/read/write mark (0x03/0x04/0x05) now operate on freed memory, and the sheet's copied placement array still holds the stale pointer too. A clean use-after-free, read and write.

6 Leak, Reclaim, Redirect

A standard glibc-2.39 UAF chain, with one convenient shortcut on 2.39:

aLibc leak

Create a mark with a large (~0x500) data buffer so it lands in the unsorted bin on free. Wrap its refcount to 1, free it via remove-placement (slot dangles), then read mark pulls bytes through the still-stored data_ptr, now inside the freed unsorted chunk:

leak   = read_mark(idx, off=0, len=8)      # fd of the lone unsorted chunk
libc   = leak - 0x203b20                # leak == &main_arena.top on 2.39
heap   = read_mark(idx, off=0, len=8)      # reading through it again = a heap pointer, free
bArbitrary read/write

Reclaim the freed 0x30 object with a lone malloc — set-docket (0x31) does free(old)+malloc(len), a single-allocation overlay — so the docket payload lands on the victim and we control data_len (+0x0c) and data_ptr (+0x28). Point data_ptr at a GOT entry; write mark overwrites free@got with system (an endbr entry, IBT-safe). Re-calling set-docket repoints the fake in place (LIFO) for repeatable R/W.

7 The Twist: the Flag Was Never a File

You could now system("/bin/sh") — but its stdio is the socket and there is nothing on disk to cat. The launcher hides the flag in plain sight:

# entrypoint.sh
SM_UNIT_SERIAL="$FLAG"; export SM_UNIT_SERIAL
unset FLAG
exec saltmarch      # main() getenv("SM_UNIT_SERIAL"), bakes it into "serial: %s"
                    # at pie+0xa1a0, then unsetenv()s it

The serial string stays in the data segment and the fork inherits it. So with the arbitrary read and the PIE base, skip the shell entirely:

pie  = leak_pie()                    # scan heap for a barcode vtable, mask to page
flag = read_mark_at(pie + 0xa1a0, 0x180)  # the unit-info string
# -> "serial: CF26{st3p_4nd_r3p34t_t1ll_th3_pl4t3_g1v3s_...}"
CF26{st3p_4nd_r3p34t_t1ll_th3_pl4t3_g1v3s_tvz1b9tx5f6561d4}
Per-instance flag Because the flag is $FLAG baked in at startup, every fresh instance prints a different suffix — re-run the exploit per instance rather than reusing the captured string.

8 Pitfalls

9 Key Lessons

A narrow reference count plus asymmetric inc/dec is a use-after-free. Sixteen bits wrap, and "increment per copy, decrement per distinct" hands you the difference every round-trip. Refcounts belong in a width you cannot cheaply overflow — and every free path must null the owning slot.

Tools: Ghidra (full decompile), pwntools, gdb on the forked child, checksec.