$ cat writeup.md…
$ cat writeup.md…
ASIS CTF 2026
Task: a 9.4 MB obfuscated C source and a stripped RISC-V 64 binary validating a 28-byte flag. Solution: decode whitespace/comment steganography, invert a custom ARX hash for key chunks, reimplement the 32768-word array VM, and solve the final checker as a GF(2) linear system.
Organizers provided two files (no interactive service):
scouting_report.c— a 9.4 MB C source file, 345,236 linesbeyond.elf— a stripped RISC-V 64 (RV64) PIE Linux binary
English summary: beyond.elf accepts exactly one 28-byte command-line argument — the flag candidate — and prints a success message only for the correct value. scouting_report.c is "a C file that compiles, but that is not its only purpose": its layout (indentation widths, the shape of survey(w^w^...) call chains, comment lengths, blank-line rhythm) smuggles a 334,767-byte data container. Recovering the flag requires four cooperating stages:
The title is a hint at the finale: after the three visible stages, the VM is silently reset and re-run a fourth time.
The source consists of encoded lines of the form
survey(w^w^w^w^w);/*hhh*/
separated by blank lines. Every encoded line carries exactly one byte:
byte = indent | ((wcount - 1) << 3) | (comment_index << 6)
indent — number of leading spaces (0..7) → low 3 bits;wcount — number of w terms in the (w^w^...) chain (1..8) → middle 3 bits, stored as wcount - 1;comment_index — length of the trailing /*...*/ comment, one of {3, 7, 11, 15} → mapped to {0, 1, 2, 3} → high 2 bits.Blank lines are group separators and carry no data. Concatenating the decoded bytes yields a 334,767-byte stream:
| region | offset | size | content |
|---|---|---|---|
| header | 0 | 12 | magic 0xc47a19e3, version 1, param 32, body length 334739 |
| body | 12 | 334739 | ChaCha-encrypted VM program |
| trailer | 334751 | 16 | integrity data |
Reversing the RV64 binary (radare2; PIE, so all offsets below are file offsets) shows:
scouting_report.c, runs the Stage-1 decoder, and validates the container header;[0:7] (chunk 0). Decrypted plaintext starts with the magic "WPT1" and is a bytecode program containing three embedded stage blobs;| field | size | notes |
|---|---|---|
| magic | 4 | 0x9c214fb7 |
| stage | 1 | stage index 0/1/2 |
| zero | 1 | |
| header size | 2 | 40 (LE16) |
| array words | 4 | 32768 (u32) |
| payload length | 4 | L (u32) |
| array hash H | 8 | u64 |
| key material | 8 | u64 |
| tag | 8 | u64 — chunk-dependent authentication tag |
[0:7], [7:14], [14:21]) unlock the container body and stage blobs 0 and 1 — and blob 2's tag is what pins chunk 2. Chunk 3 (flag bytes [21:27], ending with the format's closing brace) is never used as a decryption key — it is checked computationally by the hidden 4th pass.The reversed helper at offset 0xcf8 is a custom ARX construction with SHA-256 IV constants (0x6a09e667, 0xbb67ae85 visible in the round setup). Per 32-bit message word m, with T chained across words:
T = rol32(W + m + 0x7fee831d, 7) ^ B
B = rol32((m + 0x7f4a7c15) ^ B, 13) + T
W += 0x9e3779b9
Each blob's tag is:
tag = helper_hash(blob[0:32] || blob[40:], domain) # hash field H skipped
domain = LE56(chunk_k) ^ stage_table[stage] ^ H ^ 0x9e3779b97f4a7c15
stage_table = [0x58670a15157776bc, 0x91d45908ea83412d, 0xe6e4a71330a9a77a]
The crucial weakness: the only unknown input is the 56-bit chunk, and it enters as a direct XOR (LE56 encoding). The 32-bit additions inside the hash mix only known values, so the whole tag equation is algebraically invertible per stage — evaluate the hash with domain = 0, XOR the known masks back out, and the remaining 56 bits are the chunk:
LE56(chunk_k) = tag ^ helper_hash(msg, 0) ^ stage_table[stage] ^ H ^ 0x9e3779b97f4a7c15
This yields exactly 7 bytes per stage:
| chunk | flag bytes | recovered value |
|---|---|---|
| 0 | 0..6 | <FLAG_FRAGMENT_REDACTED> (7 bytes; starts with the public ASIS{ prefix) |
| 1 | 7..13 | <FLAG_FRAGMENT_REDACTED> |
| 2 | 14..20 | <FLAG_FRAGMENT_REDACTED> |
| 3 | 21..27 | <FLAG_FRAGMENT_REDACTED> (recovered in Stage 5; ends with the closing brace) |
Practical extraction of the decrypted container and stage blobs was done from inside emulated runs: an LD_PRELOAD shim hooking free()/puts()/memcmp captured heap buffers as the binary parsed its structures, and a qemu-user gdbstub + gdb-multiarch harness (run in an arm64 container against the riscv64 binary with a -L sysroot) gave exact breakpoints on the parser and VM. Offsets of the stage blobs inside the decoded 334,767-byte container stream: stage0 @131,180, stage1 @215,524, stage2 @259,232; trailer @334,751.
The decrypted stage programs (WPT1 bytecode) prepare and drive a 32768-word u32 array VM:
(x, y, z);signed31(word >> 1), word & 1 = indirect bit → one extra value() dereference (note: the final check's d applies value() twice);d = V(x) - V(z), then dispatches on the decoded index of x:| decoded x | action |
|---|---|
| ≥ 0 | store: A[x] = d (subtract-and-store) |
| −2 | set input position: ipos = d (input byte read as V(-3) = argv[ipos]; V(-1) = 28) |
| −4 | emit byte to the output buffer |
| −5 | halt — success iff d == 1 |
| −7 | set stream read position (V(-8) = decrypted stream[pos64], V(-6) = stream length) |
pc = pc + 3 if d > 0 else y.After the three stages complete, the VM is reset and re-run on the mutated array — the hidden 4th pass whose only purpose is to validate the last candidate chunk (flag bytes 21..27).
The 4th-pass program (entry pc = 0, input array dumped as pass4_array.bin) works in two phases:
Extraction phase. For b = 0..6 the program sets ipos = 21 + b and reads the tail byte via V(-3); eight subtract-loops with mask constants (1, 2, 4, …, 128; cells ≈24573–24581) peel the bits LSB-first; bit k of byte b lands in cell 24582 + 8*b + k.
Constraint phase. 56 constraint blocks at fixed pcs. Each block:
a[24565] (per bit: clear a[24560]; a[24560] = -bit via double indirection; a[24565] -= a[24560]);24733 + 2i and compares it against the expected bit embedded in the program;a[24563]; a satisfied block writes nothing — this is exactly why naive instrumentation reported input-dependent verdict counts.Verdict. Success ⟺ no block was violated ⟺ a[24563] remains 0. The final test at word 24546 computes d = a[24563] - a[24555]; if d ≤ 0 it branches to word 24552 = (-5, 24552, 24558), where the static a[24558] = -1 gives the success halt d = V(-5) - V(24558) = 0 - (-1) = +1.
Block 55 is anomalous (aliased loop); its equation was skipped — any solution of the other 55 satisfies it automatically.
That leaves 55 consistent GF(2) equations over 42 unknowns (6 tail bytes × 7 bits; bit 7 of every byte is 0; the 7th tail byte is fixed to the format's closing brace, 0x7d). Gaussian elimination mod 2 gives rank 42 → a unique solution for the tail (value redacted here; it is the last 7 bytes of the flag in the frontmatter).
Constraint-set extraction was done by tracing the exact emulator (solve_pass4.py); differential runs with different input bytes (A/C/Z tests) confirmed the per-block selected cell sets are static and input-independent.
#!/usr/bin/env python3 """Stage 1: decode the steganographic container out of scouting_report.c.""" import re CLEN2IDX = {3: 0, 7: 1, 11: 2, 15: 3} def decode(path): out = bytearray() for raw in open(path, encoding="utf-8", errors="surrogateescape"): line = raw.rstrip("\n") if not line.strip(): # blank line = separator, no byte continue indent = len(line) - len(line.lstrip(" ")) m = re.search(r"/\*+\*/\s*$", line) # trailing /*comment*/ cidx = CLEN2IDX[m.end() - m.start() - 4] # comment length 3/7/11/15 -> 0..3 core = line[line.index("("):line.index(")")] wcount = core.count("w") # terms in the (w^w^...) chain out.append(indent | ((wcount - 1) << 3) | (cidx << 6)) return bytes(out) stream = decode("scouting_report.c") # 334767 bytes assert stream[:4] == (0xC47A19E3).to_bytes(4, "little") body = stream[12:12 + 334739] # ChaCha-encrypted WPT1 program trailer = stream[12 + 334739:]
Model of the reversed helper_hash (binary offset 0xcf8), validated against the binary. The property that matters: the domain term enters the tag as a pure XOR, so with the message fully known the chunk is a XOR away.
M = 0xFFFFFFFF K1, K2, K3 = 0x7FEE831D, 0x7F4A7C15, 0x9E3779B9 GOLDEN = 0x9E3779B97F4A7C15 STAGE_TABLE = [0x58670A15157776BC, 0x91D45908EA83412D, 0xE6E4A71330A9A77A] def rol32(v, n): return ((v << n) | (v >> (32 - n))) & M def helper_hash_model(msg_words, domain): """ARX construction @0xcf8 (SHA-256 IV constants); domain is XORed into the tag.""" A, B, W, T = 0x6A09E667, 0xBB67AE85, 0x6A09E667, 0 for m in msg_words: T = rol32((W + m + K1) & M, 7) ^ B B = (rol32(((m + K2) & M) ^ B, 13) + T) & M W = (W + K3) & M tag = finalize_to_u64(A, B, T) # reversed finalization return tag ^ domain def recover_chunk(blob, stage, tag, H): msg = words_of(blob[0:32] + blob[40:]) # header hash field skipped h0 = helper_hash_model(msg, 0) # evaluate with domain = 0 x56 = tag ^ h0 ^ STAGE_TABLE[stage] ^ H ^ GOLDEN return x56.to_bytes(7, "little") # chunk_k (LE56)
Decrypting each stage payload with its recovered chunk and carving by the blob header layout (magic 0x9c214fb7, array words 32768, payload length L) yields the three WPT1 stage programs (stage0.dec, stage1.dec, stage2.dec) and the decrypted container body (wrapper_stream.bin).
solve_pass4.py)def s31(w): w &= 0xFFFFFFFF return w - (1 << 32) if w >> 31 else w def V(w): if w & 1: # indirect bit: one extra value() deref return V(A[s31(w >> 1)]) i = s31(w >> 1) if i == -1: return 28 # flag length if i == -3: return argv_bytes[ipos] if i == -5: return 0 if i == -6: return len(stream) if i == -8: return stream[pos] return A[i] def run(prog, A): global pc, ipos, pos pc = 0 while True: x, y, z = prog[pc:pc + 3] d = V(x) - V(z) # the final checker applies value() twice here i = s31(x >> 1) if i >= 0: A[i] = d & 0xFFFFFFFF elif i == -2: ipos = d elif i == -4: out.append(d & 0xFF) elif i == -5: return d == 1 # success halt elif i == -7: pos = d pc = pc + 3 if d > 0 else y
Trace the 4th pass once; for each constraint block record which of the 56 bit-cells feed the parity and which expected bit it compares against. Differential runs with different inputs confirmed the selection masks are static.
def solve_gf2(eq, n): """eq: list of (mask, rhs_bit). Returns the solution as an int, or None.""" rows = [(mask << 1) | rhs for mask, rhs in eq] where, r = [-1] * n, 0 for col in range(n): piv = next((i for i in range(r, len(rows)) if (rows[i] >> col) & 1), None) if piv is None: continue rows[r], rows[piv] = rows[piv], rows[r] where[col] = r for i in range(len(rows)): if i != r and (rows[i] >> col) & 1: rows[i] ^= rows[r] r += 1 for row in rows[r:]: # consistency check if (row & 1) and (row >> 1) == 0: return None x = 0 for col in range(n): if where[col] != -1 and (rows[where[col]] & 1): x |= 1 << col return x x = solve_gf2(equations, 42) # rank 42 -> unique solution assert x is not None tail = bytes(((x >> (7 * b)) & 0x7F) for b in range(6)) + b"}" # bit7=0; closing brace fixed flag = chunk0 + chunk1 + chunk2 + tail # 4 x 7 bytes = 28
The recovered tail and assembled 28-byte flag are recorded in the frontmatter of this writeup (kept out of the body per redaction policy).
Local exact-emulator oracle (C re-implementation of the final pass, pass4core), printed as <violated blocks> <success halt>:
$ ./pass4core 'ASIS{REDACTED}'
0 1
Real binary under riscv64 emulation:
$ docker run --rm --platform linux/riscv64 -v "$PWD":/work -w /work/Beyond \
debian:stable-slim ./beyond.elf 'ASIS{REDACTED}'
The fourth wall opens.
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar