Challenges: https://github.com/sajjadium/ctf-archives/tree/main/ctfs/0CTF/2025/rev/1Zwasm
1. Challenge Overview
Upon opening the challenge binary, I found it was packed with UPX, and the standard unpacking tool didn’t work.
I tried attaching GDB, but debugging didn’t work either. After using set follow-fork-mode child to trace execution, I discovered that the memfd_create() syscall was being invoked.

(gdb) set follow-fork-mode child(gdb) rStarting program: /home/noonchi/ctf/1Zwαsm
Catchpoint 1 (call to syscall memfd_create), 0x00007ffff7ffe3c7 in ?? ()(gdb) cContinuing.
...
[Attaching after process 108066 fork to child process 108619][New inferior 2 (process 108619)]process 108619 is executing new program: /proc/108619/exeEnter the flag:hey
:([Inferior 2 (process 108619) exited normally]2. Unpacking and Dumping the Binary
The UPX packer was creating two files at runtime via the memfd_create() syscall, so I dumped them while the process was running.
pgrep -f 1Zw | xargs -I{} sh -c 'echo "=== PID {} ===" && ls -la /proc/{}/fd/ 2>/dev/null'=== PID 65619 ===합계 0dr-x------ 2 noonchi noonchi 5 12월 20 15:39 .dr-xr-xr-x 9 noonchi noonchi 0 12월 20 15:30 ..lrwx------ 1 noonchi noonchi 64 12월 20 15:39 0 -> /dev/pts/16lrwx------ 1 noonchi noonchi 64 12월 20 15:39 1 -> /dev/pts/16lrwx------ 1 noonchi noonchi 64 12월 20 15:39 2 -> /dev/pts/16lrwx------ 1 noonchi noonchi 64 12월 20 15:39 3 -> '/memfd:chall (deleted)'lrwx------ 1 noonchi noonchi 64 12월 20 15:39 4 -> '/memfd:chall_wasm (deleted)'Two files were loaded in memory:
- File 1.
chall: toywasm runtime (WASM interpreter)cat /proc/65619/fd/3 > chall.bin
- File 2.
chall_wasm: A WASM module written in Rustcat /proc/65619/fd/4 > chall_wasm.bin
Opening chall in a decompiler shows it as UPX-packed, while chall_wasm is recognized as a WASM binary but the code appears corrupted.
3. Analyzing the WASM Binary
Inspecting the chall_wasm binary with wasm-objdump reveals that several opcodes including END are corrupted:
wasm-objdump -h chall_wasm.bin
chall_wasm.bin: file format wasm 0x10000253: error: init expression must end with END opcode000028b: error: unfinished section (expected end: 0x2e1)000e8f2: error: ref.null type must be a reference typemodule name: <chall-7a8478e4c9508fe0.wasm>
Sections:
Type start=0x0000000b end=0x0000008c (size=0x00000081) count: 18 Import start=0x0000008f end=0x00000146 (size=0x000000b7) count: 5 Function start=0x00000149 end=0x00000237 (size=0x000000ee) count: 236 Table start=0x00000239 end=0x0000023e (size=0x00000005) count: 1 Memory start=0x00000240 end=0x00000243 (size=0x00000003) count: 1 Global start=0x00000245 end=0x00000253 (size=0x0000000e) count: 2 Export start=0x00000255 end=0x00000276 (size=0x00000021) count: 3 Elem start=0x00000278 end=0x000002e1 (size=0x00000069) count: 1 Code start=0x000002e5 end=0x0000e253 (size=0x0000df6e) count: 236 Data start=0x0000e256 end=0x00011490 (size=0x0000323a) count: 2 Custom start=0x00011493 end=0x00011537 (size=0x000000a4) "target_features" Custom start=0x0001153a end=0x000153f7 (size=0x00003ebd) "name" Custom start=0x000153fa end=0x000154ba (size=0x000000c0) "producers"Based on the function symbols extracted via wasm-objdump, the overall flow appears to be “input char[i] -> apply_sbox_layer(???) -> SHA1 -> compare with target hash[i]”, and we need to figure out the exact logic to reverse the computation.
| Function | Name | Description |
|---|---|---|
| func[6] | _start | EntryPoint |
| func[11] | chall::main | Main function |
| func[12] | chall::crypto::apply_sbox_layer | S-box substitution |
| func[23] | sha1::compress::compress | SHA1 compression |
| .. | etc… | … |
Additionally, static data was extracted from the data segment:
- Flag format:
0ops{...} - Target hash table: 40 hashes
- S-box table: 16 bytes
0e 04 0d 01 02 0f 0b 08 03 0a 06 0c 05 09 00 07
4. Identifying the toywasm Runtime
Running the dumped chall binary revealed that toywasm is the underlying runtime.
./challUsage: toywasm [OPTIONS] [--] <MODULE> [WASI-ARGS...]Options: --allow-unresolved-functions --disable-jump-table --disable-localtype-cellidx --disable-resulttype-cellidx --invoke FUNCTION[ FUNCTION_ARGS...] --load MODULE_PATH --max-frames NUMBER_OF_FRAMES --max-memory MEMORY_LIMIT_IN_BYTES --max-stack-cells NUMBER_OF_CELLS --repl --repl-prompt STRING --print-build-options --print-stats --timeout TIMEOUT_MS --version --wasi --wasi-dir HOST_DIR[::GUEST_DIR] --wasi-env NAME=VARExamples: Run a wasi module toywasm --wasi module Load a module and invoke its function toywasm --load module --invoke "func arg1 arg2"Digging into the original toywasm open-source code, I found the parsing logic that interprets opcodes via a table and dispatches to individual instruction handlers.

toywasm expr.c — opcode dispatch table
At this point, I began to suspect that this challenge is a VM-like problem with custom opcode definitions.
5. The Custom Opcode Hypothesis
Evidence 1. All 236 functions end with 0x55
- After parsing the Code section according to the WASM format and checking the last byte of each function body, all of them end with
0x55 - Since standard WASM requires functions to end with
0x0b(END), I suspected that0x55 -> 0x0bis the substitution
Evidence 2. Abnormal byte frequency distribution
0x38: 7193 occurrences (originallyf32.store— far too many for this instruction)0x45: 4441 occurrences (originallyi32.eqz)0x55: 1459 occurrences (originallyi64.gt_u)0x0b: 227 occurrences (originallyend— far too few!)
The clear takeaway here is that the custom interpreter is correctly interpreting and executing the bytecode in the custom WASM binary.
In other words, disassembling the scrambled opcodes against the standard opcode table naturally produces incorrect mnemonics. If we find the dispatcher function, recover the mapping table, and patch accordingly, standard decompilers should be able to analyze the binary properly.
With this hypothesis in hand, I set out to dump the unpacked toywasm and trace the actual opcode handlers being used.
6. Extracting the Opcode Mapping via Core Dump
After running ./chall --wasi ./chall_wasm, I checked the process memory map:
cat /proc/16222/maps555576ff5000-555577016000 rw-p 00000000 00:00 0 [heap]7cf924fec000-7cf9250fc000 rw-p 00000000 00:00 07cf925200000-7cf925228000 r--p 00000000 fc:00 2118957 /usr/lib/x86_64-linux-gnu/libc.so.67cf925228000-7cf9253b0000 r-xp 00028000 fc:00 2118957 /usr/lib/x86_64-linux-gnu/libc.so.6...7cf925572000-7cf9255b3000 r-xs 00000000 00:01 6031 /memfd:upx (deleted)...As mentioned earlier, chall is UPX-packed, so we need to dump it at runtime. Taking a full dump with gcore -o core <PID> yields roughly this structure:
┌─────────────────────────────────────────────────────────────┐│ core.??? (2.2MB) - Process memory snapshot││ Contents:│ ├── libc.so.6 (~1.5MB) - System library│ ├── ld-linux.so (~200KB) - Dynamic linker│ ├── libm.so (~500KB) - Math library│ ├── memfd:upx (266KB) <- Unpacked toywasm│ ├── Heap, Stack│ └── WASM memory└─────────────────────────────────────────────────────────────┘Analyzing the binary to locate the opcode dispatcher function.

Since the base address changes with every core dump, you can find it by searching for strings like i64.eqz and following xrefs upward. (In fact, since the goal is to find the expr.c handler, searching for strings like "Unimplemented instructions" from the original expr.c source code makes it even easier.)
The extracted opcode mapping is as follows:
64 collapsed lines
# Full Opcode Mapping Table# base=0x7cf099725fe0 stride=320x00 i64.eqz 0x01 i64.sub 0x02 i64.load16_s0x03 i64.gt_u 0x04 nop 0x05 i64.const0x06 select 0x07 else 0x08 i64.load16_u0x09 i64.ge_u 0x0a i64.load8_u 0x0b f64.lt0x0c i32.gt_s 0x0d i32.lt_s 0x0e i32.load8_u0x0f i32.rem_u 0x10 i64.store 0x11 br0x12 f32.ne 0x13 i64.load8_s 0x14 i32.or0x15 f32.lt 0x16 i32.add 0x17 i32.clz0x18 i32.shr_u 0x1a f32.eq 0x1b i64.load32_u0x1c global.get 0x1d i32.eqz 0x20 select_t0x21 i32.div_s 0x22 i32.shr_s 0x23 i32.store80x24 i32.gt_u 0x25 i32.store 0x26 i64.clz0x28 local.set 0x29 i64.le_s 0x2a f32.const0x2b i64.le_u 0x2d f64.ge 0x2e if0x2f i32.rotl 0x30 i32.eq 0x31 i64.load0x33 i64.store8 0x34 f32.ge 0x35 i64.eq0x36 global.set 0x37 i32.xor 0x38 local.get0x39 drop 0x3a i64.gt_s 0x3d table_set0x3e call 0x3f memory.size 0x40 f32.gt0x41 i64.ne 0x42 local.tee 0x43 f64.ne0x44 f64.store 0x45 i32.const 0x46 i32.ctz0x47 i32.mul 0x48 i32.shl 0x49 return0x4a f32.le 0x4c i32.load8_s 0x4d br_if0x4e i32.popcnt 0x4f i32.load16_u 0x50 i32.sub0x51 i32.lt_u 0x53 table_get 0x54 br_table0x55 end 0x56 i64.lt_s 0x57 call_indirect0x58 i64.lt_u 0x59 i32.load 0x5a i32.load16_s0x5b block 0x5c unreachable 0x5d i64.mul0x5e f64.le 0x5f i32.le_u 0x62 f64.const0x63 f32.store 0x64 f32.load 0x65 f64.eq0x66 i64.ctz 0x67 i32.ne 0x68 i64.add0x69 i64.popcnt 0x6a i64.div_s 0x6b i32.ge_u0x6d i32.rotr 0x6f i32.rem_s 0x70 memory.grow0x71 i64.store16 0x72 i32.ge_s 0x73 i32.div_u0x74 i64.store32 0x75 f64.load 0x76 i64.ge_s0x77 i32.and 0x78 i64.load32_s 0x7a loop0x7c i32.store16 0x7e f64.gt 0x7f i32.le_s0x80 ref.null 0x81 fd 0x82 f32.convert_i32_s0x84 i64.rem_s 0x85 i64.extend8_s 0x86 f64.ceil0x87 i64.xor 0x88 f32.convert_i32_u 0x8a i64.trunc_f64_u0x8e i64.extend_i32_u 0x90 f64.convert_i32_s 0x91 f32.neg0x93 i64.trunc_f64_s 0x99 i64.shr_s 0x9b f32.convert_i64_u0x9c f64.div 0x9e f32.copysign 0x9f f32.sub0xa0 f64.floor 0xa3 f64.convert_i64_u 0xa5 f32.demote_f640xa7 i64.rotr 0xa8 f64.sub 0xaa i64.extend16_s0xac f64.trunc 0xae i64.or 0xb1 i32.trunc_f32_u0xb2 i64.shr_u 0xb3 i32.reinterpret_f32 0xb5 ref.is_null0xb6 f64.min 0xb8 f64.add 0xb9 f64.neg0xbb f32.div 0xbc i64.rotl 0xbd f64.copysign0xbe f32.nearest 0xbf f64.reinterpret_i64 0xc1 ref.func0xc2 f64.mul 0xc4 f64.convert_i64_s 0xc5 i32.extend16_s0xc9 f32.trunc 0xcb f32.max 0xcc i64.extend_i32_s0xcd f32.ceil 0xcf i64.trunc_f32_u 0xd2 f32.add0xd3 f64.max 0xd4 f32.floor 0xd5 f32.abs0xd7 f32.sqrt 0xd9 i32.trunc_f32_s 0xda i64.trunc_f32_s0xdb i64.rem_u 0xdc i64.div_u 0xe0 f64.nearest0xe1 f32.min 0xe2 i64.extend32_s 0xe3 f64.convert_i32_u0xe4 i32.trunc_f64_s 0xe8 fc 0xec i64.shl0xee f64.abs 0xf0 i32.extend8_s 0xf1 i64.reinterpret_f640xf4 f32.reinterpret_i32 0xf5 i32.trunc_f64_u 0xf8 f32.convert_i64_s0xf9 f64.sqrt 0xfa i64.and 0xfb i32.wrap_i640xfc f64.promote_f32 0xfd f32.mulComparing this with the actual WASM standard table, we can see that in standard WASM, end is always 0x0B, but here end is mapped to 0x55. Conversely, i64.sub is 0x7D in the standard table, but in the extracted table 0x01 maps to i64.sub.
Summary
- Running
challunpacks the custom toywasm into memory - Taking a process core dump at that point also captures the toywasm opcode -> mnemonic string pointer table in memory
- Reversing the core binary allows us to find the opcode handler functions and mapping table
- (Bonus) By anchoring on the table base, you can automate extraction with IDA Python by iterating 0..255 and printing
0x?? mnemonicto produceopcode_dump.txt
7. Opcode Patching and Decompilation
After patching the WASM binary according to the recovered mapping, here is the code that parses the WASM format and substitutes only the opcode positions:
362 collapsed lines
# Opcode Patch Scriptimport argparseimport osimport re
def uleb(d: bytes, o: int) -> tuple[int, int]: r = 0 s = 0 i = 0 while True: b = d[o + i] r |= (b & 0x7F) << s i += 1 if b < 0x80: return r, i s += 7 if i > 16: raise ValueError("uleb too long")
def read_uleb_bytes(d: bytes, o: int) -> tuple[bytes, int]: i = 0 while True: b = d[o + i] i += 1 if b < 0x80: return d[o : o + i], i if i > 16: raise ValueError("uleb bytes too long")
def read_sleb_bytes(d: bytes, o: int) -> tuple[bytes, int]: i = 0 while True: b = d[o + i] i += 1 if b < 0x80: return d[o : o + i], i if i > 16: raise ValueError("sleb bytes too long")
def encode_uleb(n: int) -> bytes: out = bytearray() while True: b = n & 0x7F n >>= 7 if n: out.append(b | 0x80) else: out.append(b) return bytes(out)
def parse_sections(wasm: bytes) -> list[tuple[int, int, int]]: if wasm[:4] != b"\0asm": raise ValueError("not wasm") o = 8 secs: list[tuple[int, int, int]] = [] while o < len(wasm): sid = wasm[o] o += 1 sz, n = uleb(wasm, o) o += n secs.append((sid, o, o + sz)) o += sz return secs
def sec_range(secs: list[tuple[int, int, int]], sid: int) -> tuple[int, int] | None: for s, a, b in secs: if s == sid: return a, b return None
def load_opdump(path: str) -> dict[int, str]: enc2name: dict[int, str] = {} with open(path, "r", encoding="utf-8", errors="ignore") as f: for line in f: line = line.strip() if not line or line.startswith("#"): continue k, name = line.split(None, 1) enc2name[int(k, 16)] = name.strip() return enc2name
_RE_INSN = re.compile(r'INSTRUCTION\(\s*(0x[0-9a-fA-F]+)\s*,\s*"([^"]+)"')
def load_std_maps(repo_root: str) -> tuple[dict[str, int], dict[str, int]]: def parse_hdr(p: str) -> dict[str, int]: m: dict[str, int] = {} with open(p, "r", encoding="utf-8", errors="ignore") as f: for ln in f: mo = _RE_INSN.search(ln) if mo: m[mo.group(2)] = int(mo.group(1), 0) return m
base_p = os.path.join(repo_root, "toywasm", "lib", "insn_list_base.h") fc_p = os.path.join(repo_root, "toywasm", "lib", "insn_list_fc.h") if not (os.path.exists(base_p) and os.path.exists(fc_p)): raise SystemExit("need toywasm headers: toywasm/lib/insn_list_base.h + insn_list_fc.h")
std = parse_hdr(base_p) std_fc = parse_hdr(fc_p) if "table.grew" in std_fc: std_fc["table.grow"] = std_fc.pop("table.grew") return std, std_fc
# encoded 0xfc subopcode -> mnemonicFC_ENC2NAME: dict[int, str] = { 7: "i64.trunc_sat_f32_u", 39: "memory.copy", 85: "memory.fill",}
def rewrite_init_expr(buf: bytes, enc2name: dict[int, str], std: dict[str, int]) -> tuple[bytes, int]: out = bytearray() i = 0 while i < len(buf): op = buf[i] i += 1 name = enc2name.get(op) if not name: raise ValueError(f"unknown encoded opcode 0x{op:02x} in init-expr") if name in ("fc", "fd"): raise ValueError("prefix in init-expr not supported")
out.append(std[name]) if name in ("i32.const", "i64.const"): raw, n = read_sleb_bytes(buf, i) i += n out += raw elif name == "f32.const": out += buf[i : i + 4] i += 4 elif name == "f64.const": out += buf[i : i + 8] i += 8 elif name == "global.get": raw, n = read_uleb_bytes(buf, i) i += n out += raw elif name == "ref.null": out.append(buf[i]) i += 1 elif name == "ref.func": raw, n = read_uleb_bytes(buf, i) i += n out += raw
if name == "end": return bytes(out), i raise ValueError("init-expr didn't end with end")
def rewrite_code(code: bytes, enc2name: dict[int, str], std: dict[str, int], std_fc: dict[str, int]) -> bytes: out = bytearray() i = 0 while i < len(code): op = code[i] i += 1 name = enc2name.get(op) if not name: raise ValueError(f"unknown encoded opcode 0x{op:02x} at +0x{i-1:x}")
if name == "fd": raise ValueError("0xfd(SIMD) not supported")
if name == "fc": enc_sub, _ = uleb(code, i) _raw, n = read_uleb_bytes(code, i) i += n sub_name = FC_ENC2NAME.get(enc_sub) if not sub_name: raise ValueError(f"unknown encoded 0xfc subopcode {enc_sub}") std_sub = std_fc.get(sub_name) if std_sub is None: raise ValueError(f"unknown std 0xfc mnemonic {sub_name!r}")
out.append(0xFC) out += encode_uleb(std_sub)
# fc immediates if 0x00 <= std_sub <= 0x07: pass elif sub_name == "memory.init": raw, n = read_uleb_bytes(code, i); i += n; out += raw raw, n = read_uleb_bytes(code, i); i += n; out += raw elif sub_name == "data.drop": raw, n = read_uleb_bytes(code, i); i += n; out += raw elif sub_name == "memory.copy": raw, n = read_uleb_bytes(code, i); i += n; out += raw raw, n = read_uleb_bytes(code, i); i += n; out += raw elif sub_name == "memory.fill": raw, n = read_uleb_bytes(code, i); i += n; out += raw elif sub_name == "table.init": raw, n = read_uleb_bytes(code, i); i += n; out += raw raw, n = read_uleb_bytes(code, i); i += n; out += raw elif sub_name == "elem.drop": raw, n = read_uleb_bytes(code, i); i += n; out += raw elif sub_name == "table.copy": raw, n = read_uleb_bytes(code, i); i += n; out += raw raw, n = read_uleb_bytes(code, i); i += n; out += raw elif sub_name in ("table.grow", "table.size", "table.fill"): raw, n = read_uleb_bytes(code, i); i += n; out += raw else: raise ValueError(f"unsupported 0xfc mnemonic {sub_name!r}") continue
out.append(std[name])
# immediates: length-only if name in ("block", "loop", "if"): b = code[i] if b in {0x40, 0x7F, 0x7E, 0x7D, 0x7C, 0x7B, 0x70, 0x6F}: out.append(b); i += 1 else: raw, n = read_sleb_bytes(code, i); i += n; out += raw elif name in ("br", "br_if", "call", "local.get", "local.set", "local.tee", "global.get", "global.set", "table_get", "table_set"): raw, n = read_uleb_bytes(code, i); i += n; out += raw elif name == "call_indirect": raw, n = read_uleb_bytes(code, i); i += n; out += raw out.append(code[i]); i += 1 elif name == "br_table": raw_cnt, n = read_uleb_bytes(code, i) cnt, _ = uleb(code, i) i += n out += raw_cnt for _ in range(cnt + 1): raw, n = read_uleb_bytes(code, i); i += n; out += raw elif name == "select_t": raw_cnt, n = read_uleb_bytes(code, i) cnt, _ = uleb(code, i) i += n out += raw_cnt out += code[i : i + cnt] i += cnt elif ".load" in name or ".store" in name: raw, n = read_uleb_bytes(code, i); i += n; out += raw raw, n = read_uleb_bytes(code, i); i += n; out += raw elif name in ("memory.size", "memory.grow"): out.append(code[i]); i += 1 elif name in ("i32.const", "i64.const"): raw, n = read_sleb_bytes(code, i); i += n; out += raw elif name == "f32.const": out += code[i : i + 4]; i += 4 elif name == "f64.const": out += code[i : i + 8]; i += 8 elif name == "ref.null": out.append(code[i]); i += 1 elif name == "ref.func": raw, n = read_uleb_bytes(code, i); i += n; out += raw
return bytes(out)
def rewrite_module(wasm: bytes, enc2name: dict[int, str], std: dict[str, int], std_fc: dict[str, int]) -> bytes: secs = parse_sections(wasm) out = bytearray(wasm)
# globals (6) gr = sec_range(secs, 6) if gr: gs, ge = gr p = gs gcount, n = uleb(out, p); p += n for _ in range(gcount): p += 2 rw, used = rewrite_init_expr(bytes(out[p:ge]), enc2name, std) out[p : p + used] = rw p += used
# elem (9) - active segments (flags 0/2) only er = sec_range(secs, 9) if er: es, ee = er p = es segcnt, n = uleb(out, p); p += n for _ in range(segcnt): flags, n = uleb(out, p); p += n if flags not in (0, 2): raise ValueError(f"unsupported elem flags {flags}") if flags == 2: _, n = uleb(out, p); p += n rw, used = rewrite_init_expr(bytes(out[p:ee]), enc2name, std) out[p : p + used] = rw p += used cnt, n = uleb(out, p); p += n for _j in range(cnt): _, n = uleb(out, p); p += n
# data (11) dr = sec_range(secs, 11) if dr: ds, de = dr p = ds segcnt, n = uleb(out, p); p += n for _ in range(segcnt): flags, n = uleb(out, p); p += n if flags & 0x02: _, n = uleb(out, p); p += n if (flags & 0x01) == 0: rw, used = rewrite_init_expr(bytes(out[p:de]), enc2name, std) out[p : p + used] = rw p += used sz, n = uleb(out, p); p += n p += sz
# code (10) cr = sec_range(secs, 10) if not cr: raise ValueError("no code section") cs, _ce = cr p = cs fcnt, n = uleb(out, p); p += n for fi in range(fcnt): body_size, nsz = uleb(out, p); p += nsz bs = p be = p + body_size body = bytes(out[bs:be]) q = 0 lgc, nn = uleb(body, q); q += nn for _ in range(lgc): _, nn = uleb(body, q); q += nn q += 1 rw = rewrite_code(body[q:], enc2name, std, std_fc) if len(rw) != len(body) - q: raise ValueError(f"code size changed at func {fi}; need section rebuild") out[bs + q : be] = rw p = be
return bytes(out)
def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--wasm", required=True) ap.add_argument("--opdump", required=True) ap.add_argument("--out", required=True) args = ap.parse_args()
repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) std, std_fc = load_std_maps(repo_root) enc2name = load_opdump(args.opdump) wasm = open(args.wasm, "rb").read() out = rewrite_module(wasm, enc2name, std, std_fc) open(args.out, "wb").write(out) print("[ok]", args.out, "size", len(out)) return 0
if __name__ == "__main__": raise SystemExit(main())After installing the Ghidra Wasm plugin and opening the patched binary:

The code is visible!
The apply_sbox_layer function logic — which I was most curious about — turned out to be splitting a 64-bit value into 4-bit nibbles, substituting each through the S-box table (DAT_ram_00100abc), and reassembling them back into a 64-bit value.

8. Solve Script
import hashlib
HASHES = [ "4462fc5ac9dda45e1f956bfa9159c148906ea2eb", "50afeeeba0e2674462f21f8286bb8fa20f91e882", "271d1c2edfb9d5c6506be73c695958b035a2fbb4", "82ef770e9de4f8ad66071b95e69cc80998cc6154", "5a6778089ec6ecf9abc84348af33e761e30f0368", "c839f2e7c4701924709bc0b9fa339cb7d361102e", "e93f555173093854f0588ae859de9caf686213d4", "3e0bd1fb0cc18b23941f3a015a0826071f9526a6", "1df3f80cd3b0c34360f9e7c56378fba862d05e0b", "6c144bf262c8e54719cd1e056ca6ede306161182", "983b5bc62c686e987e252de3548cd17ce2f0c998", "64e63929dc2c52e93a26ea72c6c5d00db839d2c4", "afb7023a362d94e0bfdb33e2a8dced45ae87aa0b", "c5b02ef1d8f7a31950ab219e6258355fd81a218e", "58511d70859435ea1e4a4a64ebab7aff01e8ccad", "2f58d45d2c6cdf5421d41af2b08778abf0e3d525", "deeb5ed18ce5d39b0f0be9a7f9ccd9bdf8221753", "8bae2558d08c0bc17e80192fbc398436e6934d55", "b6a03cd5819d84f9babf9e80291b08c6901cfa5d", "2d91a20a20fcaeb0ae60b5189b810bdf8481b1d7", "5a930b172e356b7e4d9e3e0560863ca7794a87d8", "72f676227f09ea59b4c176786b43d5638709e594", "4acf5137e8896d3de00cb2fe97cbbbd5c00ae443", "b6f116748b1a85fd0514666b220f3acd73c05863", "a5522b2b6e176d64dc1d064264d43704e31b7325", "1ea1c9dea1b08dc9cb3834d66e4ac48c28e3cf30", "f5b378b0e1eed620d0522de40427a59ecb1d1b06", "b6d3b5ebc1b4ed96d33702e18796846d4a07996c", "c4e45262d2f681f87ca7624dab7589df0daec44e", "fd1a1247655fccb5ccf1689f99789a30e69440fa", "4a7008477bfd6c0bb38b44cbfe1f578230f09c34", "2eebddd42c73f4df038bcedf24d2a9fda3e24b96", "416a237b97883bf34bcbf14f9b84bc8111d369dd", "814daf9e0d4106a254fe39a4019338377db99300", "c70544086b038f41391a84815e6a3cb5c8e7405a", "f36ef8a4827781edca736f121fab55d61ad2a902", "e737b3e6a9d2f5d36c930e0f825813ae1f86e2ee", "2789186ff38370bb23b45775f9bcf8b3bfb72ddc", "001c5fcd727e9e8a1af79b5ef966771b5b5008ef", "d685f409ea233df5eba3658a4547ddd78485d2fa",]
SBOX = [0x0e, 0x04, 0x0d, 0x01, 0x02, 0x0f, 0x0b, 0x08, 0x03, 0x0a, 0x06, 0x0c, 0x05, 0x09, 0x00, 0x07]INV_SBOX = [0] * 16for i, v in enumerate(SBOX): INV_SBOX[v] = i
MASK32 = 0xFFFFFFFFMASK64 = (1 << 64) - 1
C1 = (-0x4508329480000000) & MASK64C2 = (-0x77fccd388a106529) & MASK64C0 = 0x225d686aM32 = 0x1e35a7bdROUND_CONSTS = [ 0x6bdfe07b, 0xaf92329f, 0x32553697, 0x45b21761, 0xa45b1a4b, 0xff580251, 0xd3f278fc, 0x0945406a, 0x9826331a, 0x3d0b5dce, 0x52b238a8, 0x3c67752c, 0xeb51d479, 0xf3c6b787, 0x7f7d729b,]
SHIFT = (C1 & -C1).bit_length() - 1K1 = C1 >> SHIFTMOD_K1 = 1 << (64 - SHIFT)INV_K1 = pow(K1, -1, MOD_K1)MOD31 = 1 << 31INV_C2_31 = pow(C2 % MOD31, -1, MOD31)
def g(u8): return (SBOX[u8 >> 4] << 17) | (SBOX[u8 & 0xF] << 13)
def f32(u): return ((u * M32) ^ g(u & 0xFF)) & MASK32
def inv_sbox_layer(x): out = 0 for i in range(16): out |= INV_SBOX[(x >> (4 * i)) & 0xF] << (4 * i) return out
def bswap64(x): return int.from_bytes(x.to_bytes(8, "little"), "big")
def inv_mix(p1, u): t0 = ((p1 >> SHIFT) * INV_K1) % MOD_K1 a = (t0 * C2) & MASK64 k = (u - (a >> 33)) & (MOD31 - 1) k = (k * INV_C2_31) % MOD31 return (t0 + (k << (64 - SHIFT))) & MASK64
def recover_words(): table = {hashlib.sha1(v.to_bytes(2, "little")).hexdigest(): v for v in range(65536)} return [table[h] for h in HASHES]
def recover_block(out64): b = out64.to_bytes(8, "little") u = b[7] | (b[6] << 8) | (b[5] << 16) | ((b[4] & 0x7F) << 24) p1 = ((b[4] & 0x80) << 24) | (b[3] << 32) | (b[2] << 40) | (b[1] << 48) | (b[0] << 56)
t = inv_mix(p1, u) v = inv_sbox_layer(t) A = v & MASK32 B = (v >> 32) & MASK32
A ^= f32(B ^ ROUND_CONSTS[-1]) for i in range(len(ROUND_CONSTS) - 2, -1, -1): if i % 2 == 0: A ^= f32(B ^ ROUND_CONSTS[i]) else: B ^= f32(A ^ ROUND_CONSTS[i])
H = B ^ f32(A ^ C0) p1 = (A & 0x80000000) | (H << 32) u = A & 0x7FFFFFFF y = inv_mix(p1, u)
x = bswap64(inv_sbox_layer(y)) return x.to_bytes(8, "little")
def main(): w = recover_words() data = b"".join( recover_block(w[i] | (w[i + 1] << 16) | (w[i + 2] << 32) | (w[i + 3] << 48)) for i in range(0, len(w), 4) ) print(data.rstrip(b"\x00").decode())
if __name__ == "__main__": main()Flag
0ops{𝞃ĥ|ß_ǐ$_Ʀ³αIıу_Ą_Ślmp1ȩ_Ŵǟ5ɱ_cHªIIεnɠ𝛆()}
9. Conclusion
Once the code is exposed, even the most complex logic can potentially be solved by an LLM in one shot. What made this challenge’s design great was the addition of a forensic element — requiring solvers to patch scrambled opcodes and restore the binary before any analysis could begin.
Someone in the CTF server did comment that LLMs handled the solving part well, but I believe this was by no means a one-shot solvable challenge.
I’d love to dig deeper, but time is limited, so I’ll leave it at this. Until next time! BYE