Reversing WebAssembly: a stack machine in the browser tab
Reading .wasm as text, finding the exported function that checks your flag, following the linear memory that holds the strings, and why the JavaScript glue is usually where the answer is.
WebAssembly challenges look exotic and are structurally simpler than native reversing. Wasm is a typed stack machine with no registers, no calling-convention ambiguity, and a single flat memory. There is no ASLR, no stripping of the section structure, and the module tells you its own imports and exports by name.
What makes them fiddly is that a .wasm module is almost never the whole program. It is one half of a pair, and the JavaScript that loads it does the string handling, the DOM work, and often the comparison you care about. Read both.
Getting to something readable
# The .wat text format: the same module, as s-expressions.
wasm2wat chal.wasm -o chal.wat
# A C-like reconstruction. Much easier to read, occasionally wrong.
wasm-decompile chal.wasm -o chal.dcmp
# What does the module import and export?
wasm-objdump -x chal.wasm | head -60wasm-objdump -x first: the import and export tables are a summary of the module's entire interface with the outside world, and that is where you start.Ghidra and IDA both read wasm now, and Ghidra's decompiler output is often better than wasm-decompile on anything non-trivial. For a small challenge module the text format is enough.
The five things to look at, in order
1. Exports
Exported functions are what JavaScript can call, which means one of them is the entry point for whatever the page does with your input. check, validate, _check_flag, main, or an Emscripten-mangled _Z5checkPKc are the names to look for. If the module exports exactly one interesting function, the challenge just told you where to start.
2. Imports
Imports are what the module needs *from* JavaScript. A module importing env.printf or a WASI fd_write writes output; one importing a custom env.compare has handed the comparison back to JavaScript, and the answer is in the JS file rather than the wasm.
3. Data segments
Wasm has one linear memory, and its initial contents come from data segments in the module. All static strings live there. Dump them - it is exactly strings on a native binary, and it works just as often.
(data (i32.const 1024) "Correct!\00Nope\00fl4g{\00")
;; And in the code, a reference to that offset:
i32.const 1024
call $putsi32.const of an address into memory. Cross-referencing an offset from the data dump back to the i32.const that uses it is the wasm equivalent of following an xref.4. The check itself
Flag checks in wasm take the same four shapes as anywhere else: a direct comparison against a stored string, a loop transforming input and comparing, a per-character constraint set, and a hash. The flag-check shapes section of reading a binary applies unchanged - only the instruction names differ.
;; A per-character loop, in .wat. Read it as: load input[i], xor with a
;; constant, compare against expected[i].
loop $L
local.get $i
i32.load8_u ;; input[i]
i32.const 42
i32.xor
local.get $i
i32.const 2048
i32.add
i32.load8_u ;; expected[i]
i32.ne
br_if $fail
...
endBecause the transform is usually reversible and the expected bytes are in a data segment, you rarely need to run anything. Pull the constant, pull the expected array, invert.
5. The JavaScript glue
Emscripten and wasm-bindgen both generate a substantial JavaScript file that marshals strings into and out of linear memory. That file frequently contains the interesting logic, and it is not obfuscated by default. Read it before you read a single wasm instruction - it is the cheapest thing in the whole exercise.
Just run it
Wasm's biggest advantage over native reversing: the runtime is in your browser and in Node, and you can call any exported function directly with arguments of your choosing.
const fs = require("fs");
const bytes = fs.readFileSync("chal.wasm");
const { instance } = await WebAssembly.instantiate(bytes, {
env: { /* stub whatever it imports */ }
});
const { memory, check, malloc } = instance.exports;
// Strings live in linear memory, so write the bytes and pass the offset.
function write(str) {
const ptr = malloc ? malloc(str.length + 1) : 1024;
new Uint8Array(memory.buffer, ptr).set([...Buffer.from(str), 0]);
return ptr;
}
// Now brute-force one character at a time, if the check is per-character.
for (const c of "abcdefghijklmnopqrstuvwxyz0123456789{}_") {
console.log(c, check(write("fl4g{" + c)));
}Two complications worth expecting
- A stripped name section. Function names are optional metadata. Without them everything is
$func42and you navigate by signature, call graph and data references - which is normal native reversing, just with a friendlier instruction set. - Wasm as an obfuscation layer. Some challenges compile an interpreter to wasm and run a bytecode program inside it. Recognise the shape - a big dispatch loop over an array from a data segment - and reverse the inner bytecode rather than the wasm. This is a virtual machine, and the approach is the same as any other VM-based obfuscation.
- Rust and Go modules are large. A Rust wasm module contains formatting and panic machinery that dwarfs the actual logic. Ignore anything reachable only from a panic path; the challenge code is a small island in it.
- WASI modules run standalone. If the imports are all
wasi_snapshot_preview1, run it withwasmtimeorwasmerand treat it as an ordinary command-line binary, including under a debugger.
The order for a wasm challenge
- Read the JavaScript glue first. It is unobfuscated and it may contain the whole answer.
wasm-objdump -xfor imports and exports. Exports name the entry point.- Dump the data segments. Look for the flag, the expected bytes, and the success and failure strings.
- Find the exported check function and read it in
.wator a decompiler. - Cross-reference every
i32.constthat looks like an address back to the data dump. - If the logic resists reading, instantiate the module in Node and call the function directly.
- If it is per-character or returns a prefix length, brute-force rather than reverse.
The through-line is that wasm gives you far more than a native binary does - names, types, a clean module structure, and a runtime you can drive from a script. The right instinct is to exploit that rather than to treat it as an unfamiliar architecture.