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.
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:
| Mitigation | State | Consequence |
|---|---|---|
| PIE | ON | need a leak for code addresses |
| Canary / NX / FORTIFY | ON | no stack smash; bounded copies |
| CET (SHSTK + IBT) | ON | indirect calls must hit an endbr; no one-gadgets/mid-function ROP |
| RELRO | Partial | GOT is writable — our control-flow target |
| libc | 2.39-0ubuntu8.8 | no __free_hook; tcache + safe-linking |
__free_hook
any more), and IBT means we must aim it at a function entry like system, never
a one-gadget.
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:
| Op | Meaning | Auth |
|---|---|---|
0x01 | HELLO — sets the auth flag, no password | bypass |
0x02–0x07 | create / read / write / delete marks (artwork) | yes |
0x10–0x15 | create panels, add placements, delete panels | yes |
0x20 | step-and-repeat → build sheet | the bug |
0x22 | delete sheet | the bug |
0x31 | set 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.
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.
A mark's reference count is the short at mark+0x06. Two operations move
it, and they disagree.
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:
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.
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:
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.
A standard glibc-2.39 UAF chain, with one convenient shortcut on 2.39:
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
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.
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_...}"
$FLAG baked in at startup, every fresh instance prints a different
suffix — re-run the exploit per instance rather than reusing the captured string.
system).saltmarch 2.6.1)
matched the live service, so no time was lost second-guessing the protocol.&main_arena.top.
Reading through it yields a heap pointer directly — simpler than a second-chunk unsorted-bin leak.free()+malloc() setter is a single-allocation overlay primitive.
Pick its size to land on the victim; re-call it to repoint the fake in place for repeatable R/W.env → baked string
→ unsetenv: with an arbitrary read and PIE, read the data segment instead of chasing a shell.Tools: Ghidra (full decompile), pwntools, gdb on the forked child, checksec.