Writeup: Wasmbler - upCTF (Reverse)
Overview
The challenge presents a web page at http://46.225.117.62:30023 with an input field for the flag. Validation is performed entirely client-side using WebAssembly (WASM) via the exported check_flag function.
<script src="challenge.js"></script>
<script>
function go() {
const v = document.getElementById('fi').value.trim();
const ok = Module.ccall('check_flag', 'number', ['string'], [v]);
// ok = 1 → correct, 0 → wrong
}
</script>
Analysis
1. Download & Disassemble the WASM
curl -s http://46.225.117.62:30023/challenge.wasm -o challenge.wasm
wasm2wat challenge.wasm
The WASM file is 3584 bytes, compiled from C using Emscripten.
2. VM Architecture
Inside the WASM binary lies a custom bytecode VM with the following memory layout:
| Memory Address | Purpose |
|---|---|
65536 | Bytecode program (387 bytes) |
65924 | Function table template (13 entries) |
66000 | Working function table |
66052 | LCG PRNG seed (0x1337BEEF) |
66056 | Stack pointer |
66060 | Program counter (PC) |
66064 | Pointer to input string |
66080 | Stack data |
3. Instruction Set (13 operations)
| Func Index | Operation | Description |
|---|---|---|
| 1 | PUSH_IMM | Push immediate byte from bytecode stream |
| 2 | LOAD_INPUT | Pop index, push input[index] |
| 3 | ADD | Pop a, b → push (b + a) & 0xFF |
| 4 | SUB | Pop a, b → push (b - a) & 0xFF |
| 5 | XOR | Pop a, b → push b ^ a |
| 6 | OR | Pop a, b → push b | a |
| 7 | AND | Pop a, b → push b & a |
| 8 | SHL | Pop shift, val → push (val << shift) & 0xFF |
| 9 | SHR | Pop shift, val → push val >> shift |
| 10 | ROL | 8-bit rotate left |
| 11 | ROR | 8-bit rotate right |
| 12 | MOD | Pop a, b → push b % (a | 1) |
| 13 | EQ | Pop a, b → push a == b ? 1 : 0 |
4. Anti-Analysis: Function Table Shuffle
The key trick that makes this challenge interesting: after every single instruction, the function dispatch table is shuffled using a Fisher-Yates shuffle driven by an LCG PRNG.
seed = (seed * 1103515245 + 12345) & 0x7FFFFFFF
This means bytecode value 0x05 could map to a completely different operation at each execution step, depending on the current PRNG state. This makes static analysis without simulation impossible — you must faithfully replay the shuffle to know which operation each bytecode byte actually performs.
5. check_flag Flow (func 17)
1. Check strlen(input) == 38, return 0 if not
2. Copy function table template to working area
3. Set input pointer
4. Loop: while PC < 387:
a. Read bytecode[PC], increment PC
b. opcode = byte % 13
c. Lookup func_table[opcode] → func_idx
d. Execute operation based on func_idx
e. Shuffle function table (Fisher-Yates + LCG)
5. Return pop() → must be 1 for correct flag
6. Constraint Pattern
By tracing execution, a repeating pattern emerges for each flag character:
PUSH_IMM <index> ; character index
LOAD_INPUT ; load flag[index]
<transform ops> ; ROL/ROR/XOR/ADD/SUB/SHL/SHR/etc.
PUSH_IMM <expected> ; expected value after transformation
EQ ; compare
AND ; accumulate into running result
Some constraints involve 2-3 characters at once (e.g., flag[a] XOR flag[b] == constant), creating cross-character dependencies.
Solver
The solver uses symbolic execution with Z3 — running the VM symbolically where each flag[i] is a Z3 BitVec variable, then solving all constraints simultaneously.
from z3 import *
flag = [BitVec(f'f{i}', 8) for i in range(38)]
# Simulate VM symbolically, collecting constraints
# (see solve2.py for full implementation)
# Key operations handled symbolically:
# - PUSH_IMM: push concrete BitVecVal
# - LOAD_INPUT: push symbolic flag[index]
# - ADD/SUB/XOR/OR/AND/SHL/SHR/ROL/ROR/MOD: Z3 bitvector ops
# - EQ: If(a == b, 1, 0)
# - AND: accumulate results
solver = Solver()
solver.add(result == BitVecVal(1, 8))
# Format constraints
solver.add(flag[0] == ord('u'))
solver.add(flag[1] == ord('p'))
# ... etc
if solver.check() == sat:
m = solver.model()
print(''.join(chr(m[flag[i]].as_long()) for i in range(38)))
Verification
$ python3 solve2.py
Solving...
Flag: upCTF{n3rd_squ4d_4ss3mbl3_c0de_7f2b1d}
$ python3 verify.py # concrete VM execution
VM check result: 1 (PASS)
Tools
curl— download the WASM binarywabt(wasm2wat) — disassemble WASM to WAT text format- Python + Z3 — symbolic execution & constraint solving
Flag
upCTF{n3rd_squ4d_4ss3mbl3_c0de_7f2b1d}