0 CTF 2025 Writeup (1Zwasm)

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.

1.png

Terminal window
(gdb) set follow-fork-mode child
(gdb) r
Starting program: /home/noonchi/ctf/1Zwαsm
Catchpoint 1 (call to syscall memfd_create), 0x00007ffff7ffe3c7 in ?? ()
(gdb) c
Continuing.
...
[Attaching after process 108066 fork to child process 108619]
[New inferior 2 (process 108619)]
process 108619 is executing new program: /proc/108619/exe
Enter 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.

Terminal window
pgrep -f 1Zw | xargs -I{} sh -c 'echo "=== PID {} ===" && ls -la /proc/{}/fd/ 2>/dev/null'
=== PID 65619 ===
합계 0
dr-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/16
lrwx------ 1 noonchi noonchi 64 12월 20 15:39 1 -> /dev/pts/16
lrwx------ 1 noonchi noonchi 64 12월 20 15:39 2 -> /dev/pts/16
lrwx------ 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 Rust
    • cat /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:

Terminal window
wasm-objdump -h chall_wasm.bin
chall_wasm.bin: file format wasm 0x1
0000253: error: init expression must end with END opcode
000028b: error: unfinished section (expected end: 0x2e1)
000e8f2: error: ref.null type must be a reference type
module 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.

FunctionNameDescription
func[6]_startEntryPoint
func[11]chall::mainMain function
func[12]chall::crypto::apply_sbox_layerS-box substitution
func[23]sha1::compress::compressSHA1 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.

Terminal window
./chall
Usage:
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=VAR
Examples:
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.

2.png

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 that 0x55 -> 0x0b is the substitution

Evidence 2. Abnormal byte frequency distribution#

  • 0x38: 7193 occurrences (originally f32.store — far too many for this instruction)
  • 0x45: 4441 occurrences (originally i32.eqz)
  • 0x55: 1459 occurrences (originally i64.gt_u)
  • 0x0b: 227 occurrences (originally end — 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/maps
555576ff5000-555577016000 rw-p 00000000 00:00 0 [heap]
7cf924fec000-7cf9250fc000 rw-p 00000000 00:00 0
7cf925200000-7cf925228000 r--p 00000000 fc:00 2118957 /usr/lib/x86_64-linux-gnu/libc.so.6
7cf925228000-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.

3.png

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=32
0x00 i64.eqz 0x01 i64.sub 0x02 i64.load16_s
0x03 i64.gt_u 0x04 nop 0x05 i64.const
0x06 select 0x07 else 0x08 i64.load16_u
0x09 i64.ge_u 0x0a i64.load8_u 0x0b f64.lt
0x0c i32.gt_s 0x0d i32.lt_s 0x0e i32.load8_u
0x0f i32.rem_u 0x10 i64.store 0x11 br
0x12 f32.ne 0x13 i64.load8_s 0x14 i32.or
0x15 f32.lt 0x16 i32.add 0x17 i32.clz
0x18 i32.shr_u 0x1a f32.eq 0x1b i64.load32_u
0x1c global.get 0x1d i32.eqz 0x20 select_t
0x21 i32.div_s 0x22 i32.shr_s 0x23 i32.store8
0x24 i32.gt_u 0x25 i32.store 0x26 i64.clz
0x28 local.set 0x29 i64.le_s 0x2a f32.const
0x2b i64.le_u 0x2d f64.ge 0x2e if
0x2f i32.rotl 0x30 i32.eq 0x31 i64.load
0x33 i64.store8 0x34 f32.ge 0x35 i64.eq
0x36 global.set 0x37 i32.xor 0x38 local.get
0x39 drop 0x3a i64.gt_s 0x3d table_set
0x3e call 0x3f memory.size 0x40 f32.gt
0x41 i64.ne 0x42 local.tee 0x43 f64.ne
0x44 f64.store 0x45 i32.const 0x46 i32.ctz
0x47 i32.mul 0x48 i32.shl 0x49 return
0x4a f32.le 0x4c i32.load8_s 0x4d br_if
0x4e i32.popcnt 0x4f i32.load16_u 0x50 i32.sub
0x51 i32.lt_u 0x53 table_get 0x54 br_table
0x55 end 0x56 i64.lt_s 0x57 call_indirect
0x58 i64.lt_u 0x59 i32.load 0x5a i32.load16_s
0x5b block 0x5c unreachable 0x5d i64.mul
0x5e f64.le 0x5f i32.le_u 0x62 f64.const
0x63 f32.store 0x64 f32.load 0x65 f64.eq
0x66 i64.ctz 0x67 i32.ne 0x68 i64.add
0x69 i64.popcnt 0x6a i64.div_s 0x6b i32.ge_u
0x6d i32.rotr 0x6f i32.rem_s 0x70 memory.grow
0x71 i64.store16 0x72 i32.ge_s 0x73 i32.div_u
0x74 i64.store32 0x75 f64.load 0x76 i64.ge_s
0x77 i32.and 0x78 i64.load32_s 0x7a loop
0x7c i32.store16 0x7e f64.gt 0x7f i32.le_s
0x80 ref.null 0x81 fd 0x82 f32.convert_i32_s
0x84 i64.rem_s 0x85 i64.extend8_s 0x86 f64.ceil
0x87 i64.xor 0x88 f32.convert_i32_u 0x8a i64.trunc_f64_u
0x8e i64.extend_i32_u 0x90 f64.convert_i32_s 0x91 f32.neg
0x93 i64.trunc_f64_s 0x99 i64.shr_s 0x9b f32.convert_i64_u
0x9c f64.div 0x9e f32.copysign 0x9f f32.sub
0xa0 f64.floor 0xa3 f64.convert_i64_u 0xa5 f32.demote_f64
0xa7 i64.rotr 0xa8 f64.sub 0xaa i64.extend16_s
0xac f64.trunc 0xae i64.or 0xb1 i32.trunc_f32_u
0xb2 i64.shr_u 0xb3 i32.reinterpret_f32 0xb5 ref.is_null
0xb6 f64.min 0xb8 f64.add 0xb9 f64.neg
0xbb f32.div 0xbc i64.rotl 0xbd f64.copysign
0xbe f32.nearest 0xbf f64.reinterpret_i64 0xc1 ref.func
0xc2 f64.mul 0xc4 f64.convert_i64_s 0xc5 i32.extend16_s
0xc9 f32.trunc 0xcb f32.max 0xcc i64.extend_i32_s
0xcd f32.ceil 0xcf i64.trunc_f32_u 0xd2 f32.add
0xd3 f64.max 0xd4 f32.floor 0xd5 f32.abs
0xd7 f32.sqrt 0xd9 i32.trunc_f32_s 0xda i64.trunc_f32_s
0xdb i64.rem_u 0xdc i64.div_u 0xe0 f64.nearest
0xe1 f32.min 0xe2 i64.extend32_s 0xe3 f64.convert_i32_u
0xe4 i32.trunc_f64_s 0xe8 fc 0xec i64.shl
0xee f64.abs 0xf0 i32.extend8_s 0xf1 i64.reinterpret_f64
0xf4 f32.reinterpret_i32 0xf5 i32.trunc_f64_u 0xf8 f32.convert_i64_s
0xf9 f64.sqrt 0xfa i64.and 0xfb i32.wrap_i64
0xfc f64.promote_f32 0xfd f32.mul

Comparing 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#

  1. Running chall unpacks the custom toywasm into memory
  2. Taking a process core dump at that point also captures the toywasm opcode -> mnemonic string pointer table in memory
  3. Reversing the core binary allows us to find the opcode handler functions and mapping table
  4. (Bonus) By anchoring on the table base, you can automate extraction with IDA Python by iterating 0..255 and printing 0x?? mnemonic to produce opcode_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 Script
import argparse
import os
import 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 -> mnemonic
FC_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:

4.png

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.

5.png


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] * 16
for i, v in enumerate(SBOX):
INV_SBOX[v] = i
MASK32 = 0xFFFFFFFF
MASK64 = (1 << 64) - 1
C1 = (-0x4508329480000000) & MASK64
C2 = (-0x77fccd388a106529) & MASK64
C0 = 0x225d686a
M32 = 0x1e35a7bd
ROUND_CONSTS = [
0x6bdfe07b,
0xaf92329f,
0x32553697,
0x45b21761,
0xa45b1a4b,
0xff580251,
0xd3f278fc,
0x0945406a,
0x9826331a,
0x3d0b5dce,
0x52b238a8,
0x3c67752c,
0xeb51d479,
0xf3c6b787,
0x7f7d729b,
]
SHIFT = (C1 & -C1).bit_length() - 1
K1 = C1 >> SHIFT
MOD_K1 = 1 << (64 - SHIFT)
INV_K1 = pow(K1, -1, MOD_K1)
MOD31 = 1 << 31
INV_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

0 CTF 2025 Writeup (1Zwasm)
https://ma4the.github.io/en/posts/rev-1zwasm-0ctf2026/
Author
m@the
Published at
2026-01-05
License
CC BY-NC-SA 4.0