$ cat writeup.md…
$ cat writeup.md…
ASIS CTF Quals 2026
Task: reverse a stripped static-PIE ELF with a six-lane `e`/`s` stdin protocol, fingerprints, and a final decrypt gate. Solution: recover the protocol internals, replay captured compare buffers, then emulate `c255c0` with the correct helper behavior to decrypt `flag.enc`.
No separate organizer description was preserved in the local task files.
We are given a stripped static-PIE x86-64 ELF that reads commands from stdin in the form e <lane> <hex> and s <lane> <hex>. The binary maintains six lane states, emits long fingerprint values after successful e transitions, and only reaches the final flag logic after a later multi-lane verification step.
e takes 160 input bytes. Internally the binary derives a 160-byte target buffer on the stack and compares the user data against it.e probe plus a hook at the compare site is enough to capture that target buffer. Resubmitting the captured bytes produces a genuine e success and prints the lane fingerprint.e transitions on the same lane satisfy the marker-length gate that enables s.s takes 176 input bytes. After the state checks and anti-replay check, the acceptance condition is derive(X, lane)[160:176] == 0.s solutions: for each lane, XORing the same 16-byte delta into blocks 5 and 6 preserves the zero-check, giving a 128-bit affine family of accepted records.s inputs themselves.The final outer verifier hashes the six stored 176-byte lane records and compares the resulting 16-byte value against:
10fe0df1471d48b5226d8b3b9e3559f3
fast_5f.py reproduced that outer hash exactly for our reconstructed records and showed that our valid record set lands on a different stable value:
9ff5c6459a95c5f7346e70377b304785
So we did not finish the intended last mile. We did not recover the exact natural s-record set that makes the original binary print the flag on its own, and we did not solve the constrained final preimage problem.
The unresolved outer compare did not block flag recovery because the final decrypt logic had already been reversed. Once the behavior of the helper routines was modeled accurately, the decrypt routine c255c0 could be emulated directly on the authentic challenge data.
e inputs, capture the hidden 160-byte compare buffer, and replay it to obtain real e successes and fingerprints.s and prove that success means derive(X, lane)[160:176] == 0 together with the lane gates and anti-replay check.s inputs from the recovered solution family and confirm that the program stores those exact 176-byte records for the final stage.fast_5f.py, confirm that it depends on the stored s records rather than only the printed fingerprints, and verify the mismatch against the expected constant.c2b3d0 as the vector-grow helper,0x40,c255c0 on the dumped lane records and flag.enc,The exact local artifact used for final extraction was recover_flag.py:
from emu import Emu, BASE, HEAP from unicorn import UC_HOOK_CODE from unicorn.x86_const import * import struct, re TARGET40 = bytes.fromhex('10fe0df1471d48b5226d8b3b9e3559f3') records = b''.join(open(f'lane_rec_{i}.bin','rb').read() for i in range(6)) flag_enc = open('dist/Atarude/flag.enc','rb').read() REC = HEAP + 0x50000 OUT = HEAP + 0x60000 FL = HEAP + 0x70000 e = Emu() e.uc.reg_write(UC_X86_REG_FS_BASE, HEAP + 0x1000) e.uc.mem_write(REC, records) e.uc.mem_write(FL, flag_enc) def grow(uc, a, s, ud): rsp = uc.reg_read(UC_X86_REG_RSP) ret = struct.unpack('<Q', uc.mem_read(rsp, 8))[0] vec = uc.reg_read(UC_X86_REG_RDI) oldcap = struct.unpack('<Q', uc.mem_read(vec, 8))[0] oldptr = struct.unpack('<Q', uc.mem_read(vec + 8, 8))[0] newcap = max(oldcap * 2, 4) newsize = newcap * 16 newptr = e.heap_ptr e.heap_ptr += (newsize + 0x40) & ~0xf if oldcap: data = bytes(uc.mem_read(oldptr, oldcap * 16)) uc.mem_write(newptr, data + bytes(newsize - len(data))) else: uc.mem_write(newptr, bytes(newsize)) uc.mem_write(vec, struct.pack('<Q', newcap)) uc.mem_write(vec + 8, struct.pack('<Q', newptr)) uc.reg_write(UC_X86_REG_RIP, ret) uc.reg_write(UC_X86_REG_RSP, rsp + 8) def patch40(uc, a, s, ud): rsp = uc.reg_read(UC_X86_REG_RSP) uc.mem_write(rsp + 0x40, TARGET40) e.uc.hook_add(UC_HOOK_CODE, grow, begin=BASE + 0xc2b3d0, end=BASE + 0xc2b3d0) e.uc.hook_add(UC_HOOK_CODE, patch40, begin=BASE + 0xc25a53, end=BASE + 0xc25a53) e.call(0xc255c0, rdi=OUT, rsi=REC, rdx=len(records) // 16, rcx=FL, r8=len(flag_enc), count=400_000_000_000) ptr = int.from_bytes(e.uc.mem_read(OUT + 8, 8), 'little') ln = int.from_bytes(e.uc.mem_read(OUT + 16, 8), 'little') data = bytes(e.uc.mem_read(ptr, ln)) print(data.decode('latin1', 'replace')) m = re.search(rb'ASIS\{[^}]*\}', data) print('FLAG:', m.group(0).decode() if m else None)
$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar