$ cat writeup.md…
$ cat writeup.md…
asisctf2026
Task: a custom QEMU-based architecture boots a ROM that validates a 44-byte flag through a bespoke 16-bit VM transform. Solution: recover the ISA from the emulator, lift the 10-round algorithm, and invert the full target state instead of abusing the lossy final checksum.
No separate organizer description was preserved in the local task files.
The challenge ships two artifacts: challenge.rom and qemu-asisarch. The goal is to understand the custom architecture well enough to recover the scoreboard-valid flag, not just any locally accepted collision.
The task title already suggests a custom architecture challenge, and the provided emulator confirms it. The important starting point was the ROM loader inside qemu-asisarch.
The emulator main validates the ROM header before execution:
AARQ0x020x10000rom[0x20:] seeded with 0x31415926That immediately shows this is not a normal firmware blob for a known CPU. The ROM payload is copied into a 64 KiB guest memory region and execution begins at guest PC 0x0000.
The recovered machine state layout was:
0x100100x100120x100180x0000So the architecture is a compact 16-bit VM implemented inside a stripped x86-64 QEMU binary.
Instructions are stored in encrypted 4-byte form. The emulator decrypts and dispatches them with three key tables:
.rodata+0x140 / VA 0x2140.rodata+0x160 / VA 0x2160.data.rel.roThe permutation rows are:
[0,1,2,3] [2,0,3,1] [3,2,1,0] [1,3,0,2]
Recovering the decode path was enough to rebuild the ISA used by the ROM.
From the emulator and ROM disassembly, the useful instructions were:
mov, add, sub, xor, and, rol with immediate or register sourcejmp, jz, jnzcall, retin, outsbox_rThis was sufficient to lift the checker into normal Python.
The ROM input routine reads bytes until newline or EOF into 0xc000.., then appends a null byte. The main checker requires exactly 0x2c bytes, so the flag body is treated as 44 bytes total, or 22 little-endian 16-bit words.
That little-endian word interpretation is the key semantic clue behind the intended flag.
After lifting the verifier, the checker becomes a 10-round transform over x[0..21].
Each round does:
x[i] = sbox16(x[i]) ^ K[r][i]+ 0x5a5a mod 2^16x[i] ^= G(x[(i+1)%22]) ^ rol16(G(x[(i+2)%22]), r+1)where:
G(v) = v ^ rol16(v, 5) ^ rol16(v, 11)
The whole verifier is therefore a 10-round SPN-like word transform, not a simple direct comparison.
The crucial trap is the final check near 0x7874..0x7be8. It is not a strict word-by-word comparison. Instead it only checks:
sum((x[i] ^ T[i]) for i in range(22)) & 0xffff == 0
This means many different 44-byte inputs can be locally accepted. One such false positive was:
ASIS{AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'e<}
It passed the emulator but was not the intended scoreboard flag. That disproved the “accepted locally means correct remotely” assumption and forced a pivot from collision-finding to true inversion.
qemu-asisarchThe first step was to reverse the custom instruction decryption and sparse opcode dispatch from the emulator. That gave a workable Python model of the VM and a ROM disassembly.
Once the handlers were understood, the large straight-line checker in challenge.rom was translated into high-level operations on 22 little-endian words. The lifted model reproduced both the round function and the final lossy checksum.
Because the last comparison is only a 16-bit sum, brute forcing a checksum collision was easy but wrong for the real flag. The correct approach is to recover the target state T that would satisfy a strict compare before the checksum collapses information.
For each round, inversion is done in reverse order:
Running that process from the final target array T back through all 10 rounds produces the original 44 input bytes.
The recovered byte string is valid ASCII and matches the expected event format. It is the intended flag, unlike the earlier checksum collision.
The core solver is short once the lifted constants are known:
#!/usr/bin/env python3 from typing import List MASK = 0xffff N = 22 ROUNDS = 10 SBOX = bytes.fromhex( "fc9e7b6ece09975b5fbf78bb79e664ea" "998d48d94d690da4ef5acb818bb04a43" "ded3d1a80ebce0d8b501b6fe3b8c8625" "1b574c7dd70a82ab0b071f0013b98e17" "a65dc0ca8fae6c833cf32ddf4489eee1" "7e033985e3d42c40d687ac934ff93f31" "c24516b724b336307c912fadcd275c80" "aacf2e4753f8559f506675ff26dc6712" "8896102ad27419064b119bb432d5f1a9" "d01ebeaf9805ede946769c4e5820357a" "c4683a9277a7a22233a01cfdecf45e56" "0241b8db650f18216f90b170386b23eb" "51733de5c76acc28f571f0dd3e14c39a" "fb95e76254a3b229e46d9d84f2e815a5" "2bc51d4961e2fabdc91a0c428a72347f" "086394a1f7f637c15952c604c8ba60da" ) INV = [0] * 256 for i, b in enumerate(SBOX): INV[b] = i def rol16(x: int, n: int) -> int: n &= 15 return ((x << n) | (x >> (16 - n))) & MASK def g(v: int) -> int: return v ^ rol16(v, 5) ^ rol16(v, 11) def inv_sbox16(v: int) -> int: return (INV[(v >> 8) & 0xff] << 8) | INV[v & 0xff] def invert_stage_c(x: List[int], r: int) -> List[int]: x = x[:] for i in range(N - 1, -1, -1): x[i] ^= g(x[(i + 1) % N]) ^ rol16(g(x[(i + 2) % N]), r + 1) return x def invert_stage_b(x: List[int]) -> List[int]: x = x[:] prev_last = (x[0] - x[21] - 0x5a5a) & MASK for i in range(N - 1, 0, -1): x[i] = (x[i] - x[i - 1] - 0x5a5a) & MASK x[0] = prev_last return x def invert_round(x: List[int], r: int, K: List[List[int]]) -> List[int]: x = invert_stage_c(x, r) x = invert_stage_b(x) return [inv_sbox16(v ^ K[r][i]) for i, v in enumerate(x)] def words_to_bytes(x: List[int]) -> bytes: out = bytearray() for w in x: out += w.to_bytes(2, "little") return bytes(out) # K and T are extracted from the lifted ROM. K = ... T = ... x = T[:] for r in range(ROUNDS - 1, -1, -1): x = invert_round(x, r, K) print(words_to_bytes(x).decode())
The actual local solve script and disassembly used during the solve are in the task directory.
Verification was performed with the real emulator under amd64 Docker. The exact recovered flag was piped into qemu-asisarch, and the output included:
[+] Access Granted! Flag verified.
The command form was:
docker run --rm --platform linux/amd64 -i -v "$PWD/ASIS-Arch:/work" -w /work ubuntu:24.04 bash -lc "chmod +x ./qemu-asisarch && printf \"ASIS{REDACTED}\\n\" | ./qemu-asisarch -M asisboard -kernel challenge.rom -nographic"
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar