Introduction
A web challenge that ships a .wasm file tends to stall people who are perfectly comfortable with both halves of the problem. Web people open DevTools, see a binary blob instead of readable JavaScript, and stop. Reverse engineering people know how to read a disassembly but expect x86 registers and find a stack machine with no registers at all.
The good news is that WebAssembly is one of the friendliest reverse engineering targets that exists. It is a public, deliberately simple specification with an official text format, so the disassembly is not a best-effort reconstruction the way an x86 decompilation is: WAT is an exact, lossless rendering of the binary, defined by the same spec. There is no stripping, no obfuscated calling convention, and no ambiguity about where a function starts.
WebAssembly is a compilation target, not a security boundary. The sandbox isolates the module from your machine. It does nothing to hide the module from you.
That last sentence is the whole framing. WASM sandboxing is real and it protects the host from the module. It offers precisely zero protection to the secrets inside the module, which sit in a data section that anyone can dump. This guide covers finding the module, turning it into text, reading that text, pulling constants out of memory, and stepping through it in a debugger when the static route runs out.
Finding the module
Before anything else you need the file. A page can load WASM in several ways, and only the first is obvious.
| How it is loaded | How to get it |
|---|---|
| A .wasm URL fetched at load | DevTools Network tab, filter by wasm, then Save. Or curl the URL directly |
| Base64 embedded in the JavaScript | Search the bundle for WebAssembly.instantiate and decode the array it is handed |
| Assembled at runtime from parts | Set a breakpoint on the instantiate call and dump the buffer argument |
| Already parsed, no file left | DevTools Sources panel lists every instantiated module under wasm:// |
# Straightforward casecurl -sO https://target.example/main.wasmfile main.wasmxxd main.wasm | head -2
Every WebAssembly binary starts with the same eight bytes: the magic number 00 61 73 6D, which reads as \0asm, followed by a four-byte version, currently 01 00 00 00. That signature is how you recognise a WASM module embedded in something else, and it is worth adding to your mental magic-byte table alongside the ones in file carving.
# Decode a module that was embedded as base64 inside a JS bundlegrep -oE "[A-Za-z0-9+/]{200,}={0,2}" bundle.js | while read -r b; doecho "$b" | base64 -d 2>/dev/null > cand.bin# compare the magic as hex: grep cannot match a pattern containing a NUL byte[ "$(head -c 4 cand.bin | xxd -p)" = "0061736d" ] && mv cand.bin found.wasm && breakdonefile found.wasm
The toolchain
One toolkit does almost everything. wabt is the official binary toolkit and ships the three commands you will actually use.
sudo apt install wabt # or: brew install wabt# Binary to text. This is the one you run every timewasm2wat main.wasm -o main.wat# Text back to binary, after you edit itwat2wasm main.wat -o patched.wasm# A higher-level, C-like rendering. Often more readable than WATwasm-decompile main.wasm -o main.dcmp# Structural overview: what does this module import, export, and contain?wasm-objdump -x main.wasm | head -60
Start with wasm-objdump -x every time, before reading a single instruction. It prints the section table, the imports, the exports, and the function signatures, which together tell you what the module is for and where to begin. An export named check_flag saves you from reading forty other functions.
| Tool | Package | Use it for |
|---|---|---|
| wasm2wat | wabt | The canonical text disassembly |
| wasm-decompile | wabt | C-like output when the stack machine is tiring |
| wasm-objdump | wabt | Sections, imports, exports, data segments |
| wasm-dis | binaryen | A second opinion when wabt output looks odd |
| wasm2c | wabt | Producing C you can compile, instrument, and run natively |
| Ghidra | with a WASM loader extension | Large modules where you want a graph view and cross-references |
Reading WAT
WAT is a stack machine in S-expression clothing. There are no registers. Instructions pop their operands off an implicit stack and push their results back. Once that clicks, the format is genuinely easy, because there is nowhere for state to hide.
(func $add_then_compare (param $a i32) (param $b i32) (result i32)local.get $a ;; push alocal.get $b ;; push bi32.add ;; pop two, push a+bi32.const 42 ;; push 42i32.eq ;; pop two, push 1 if equal else 0)
The instruction vocabulary you need is small. This table is most of it:
| Instruction | Meaning |
|---|---|
| local.get / local.set | Read or write a function local (parameters are locals 0 upward) |
| global.get / global.set | Module-level variables. The stack pointer is usually global 0 |
| i32.const N | Push a literal. Where XOR keys and lengths live |
| i32.load / i32.store | Read or write linear memory at an address popped from the stack |
| i32.load8_u | Load one byte, zero extended. The signature of a string loop |
| i32.xor / i32.add / i32.and | Arithmetic. XOR in a character loop means obfuscation |
| i32.eq / i32.ne / i32.lt_s | Comparisons. The _s and _u suffixes are signed and unsigned |
| br_if / block / loop | Structured control flow. There are no arbitrary jumps |
| call $f | Direct call. Arguments come off the stack in order |
Structured control flow is the feature that makes WASM nicer than x86 to read. There is nojmp to an arbitrary address, so every loop and branch is explicitly nested and correctly matched by construction. Decompiler output for WASM does not suffer the spaghetti reconstruction problem that native binaries produce.
A typical flag-check loop looks like this once you know the vocabulary:
(loop $Llocal.get $ii32.const 1024 ;; base address of the ciphertext in linear memoryi32.addi32.load8_u ;; read ciphertext[i]i32.const 42 ;; the XOR key, sitting right there as a literali32.xor;; ... compare against the user's input byte ...local.get $ii32.const 1i32.addlocal.tee $ii32.const 32 ;; the flag lengthi32.lt_ubr_if $L)
Linear memory and the data section
A WASM module has one flat array of bytes called linear memory. Every string, buffer, and heap allocation lives inside it, addressed by plain integer offsets starting at zero. There are no pointers to anywhere else, because there is nowhere else.
Initial contents come from data segments declared in the module, each with a target offset and a byte blob. This is where string literals from the original source end up, and it is the first place to look for anything interesting.
# Dump the data section: offsets and contentswasm-objdump -x main.wasm | sed -n '/Data\[/,$p'# Or read it as text in the WATwasm2wat main.wasm | grep -n '(data'
The output looks like this, and it is directly readable:
(data (i32.const 1024) "\3a\21\36\2b\1f\4c...")(data (i32.const 1088) "Enter the password: \00")(data (i32.const 1120) "Correct!\00Wrong.\00")
Cross-referencing is now trivial arithmetic. If the loop you read earlier starts at i32.const 1024 and the data section places a blob at offset 1024, that blob is the data the loop consumes. There is no relocation, no ASLR, and no indirection to work through: the address in the code is the offset in the segment.
Where flags actually hide
The picoCTF Some Assembly Required series is a four-step ladder through exactly the escalation you should expect, and it is the best available illustration of how this category gets harder.
Some Assembly Required 1 hides nothing at all. The string constant from the original source survives into the data section byte for byte, so strings finds it without any WASM knowledge whatsoever. That is the lesson stated as plainly as a challenge can state it: compiling to WASM is not obfuscation.
strings main.wasm | grep -i picowasm-objdump -x main.wasm | grep -i pico
Some Assembly Required 2 adds a single-byte XOR. Now strings returns nothing, but the ciphertext and the key are both in the module, sitting a few lines apart: the bytes in the data section, the key as an i32.const in the loop. Static analysis reveals both at once.
data = bytes.fromhex('3a21362b1f4c...') # from the data sectionprint(bytes(b ^ 0x2a for b in data)) # key from the i32.const in the loop
Some Assembly Required 3 upgrades to a five-byte repeating key. This changes nothing structurally, because a repeating-key XOR with a known key length is just N independent single-byte problems. When the key is stored beside the ciphertext, you do not even need frequency analysis:
key = bytes.fromhex('1122334455')ct = bytes.fromhex('...')print(bytes(c ^ key[i % len(key)] for i, c in enumerate(ct)))
Some Assembly Required 4 stacks several transformation layers, and this is where the static approach stops being worth the effort. Not because it is impossible, but because it is unnecessary: the comparison still has to happen, and at that moment both operands are in memory.
More generally: XOR for CTF covers the recovery techniques when the key is not conveniently stored next to the ciphertext, and custom cipher reversing covers inverting a homemade transformation with more moving parts.
Debugging it live
Chrome DevTools debugs WebAssembly directly, and it is dramatically better than most people expect. Instantiated modules appear in the Sources panel under a wasm:// origin, rendered as WAT with clickable line numbers. You can set breakpoints on individual instructions, step, and inspect the stack, the locals, the globals, and the whole of linear memory.
| Task | Where |
|---|---|
| See every instantiated module | Sources panel, wasm:// tree |
| Break on an instruction | Click the line number in the WAT view |
| Inspect locals and the operand stack | Scope pane, while paused |
| Read linear memory | Memory inspector on the Module memory object |
| Call an export by hand | Console, via the instance exports object |
The highest-value move is the same one that beats hard crackmes: break at the comparison and read the other operand. Whatever chain of transformations produced the expected value, it has finished by then and the result is a plain byte array in linear memory.
// In the DevTools console, once you have a handle on the instance:const mem = new Uint8Array(instance.exports.memory.buffer);// Dump a region as textnew TextDecoder().decode(mem.slice(1024, 1024 + 64));// Dump it as hex when it is not printable[...mem.slice(1024, 1088)].map(b => b.toString(16).padStart(2, '0')).join(' ');// Search all of memory for the flag prefix(() => {const s = new TextDecoder('latin1').decode(mem);const i = s.indexOf('picoCTF{');return i < 0 ? 'not present' : s.slice(i, s.indexOf('}', i) + 1);})();
That last snippet is worth keeping. Scanning the entire linear memory for the flag prefix after the check has run resolves a surprising number of challenges without reading any WAT, and it is exactly the solution path Some Assembly Required 4 is built to reward. For driving the browser reproducibly rather than by hand, see the Burp Suite guide for the intercepting-proxy half of the workflow.
When WAT is too low level
A module compiled from a real C or Rust program with Emscripten can run to tens of thousands of instructions, most of it runtime and standard library. Reading that as WAT is not a good use of time. Two escapes:
wasm-decompile renders the module as C-like pseudocode with named locals and real expressions rather than stack operations. For any module bigger than a few hundred lines it should be your default view:
wasm-decompile main.wasm -o main.dcmpless main.dcmp
wasm2c goes further and emits compilable C. That is powerful for a different reason: once it is C, you can compile it natively, add printf statements, run it under a sanitizer, or point a fuzzer at it. Turning a browser artifact into an ordinary local binary puts your whole native toolchain back on the table.
wasm2c main.wasm -o main.c# main.c is a library, not a program: it needs wabt's runtime and a driver you writegcc main.c wasm-rt-impl.c driver.c -I/path/to/wabt/wasm2c -lm -o native_module
env.emscripten_*, a large __stack_pointer global, and exports such as _malloc and _free. Anything reachable only from those is runtime plumbing. Find the export whose name matches what the page actually does and work outward from there.The other side: JIT exploitation
There is a second, much harder way WebAssembly appears in CTF: not as the thing you reverse, but as the thing you attack the engine with. Browser JavaScript engines compile both JS and WASM to native code at runtime, and a bug in that compiler is a path to native code execution.
TurboFlan, Kit Engine, and Download Horsepower are that category: V8 and JIT compiler exploitation, where the goal is to corrupt the engine's assumptions and turn them into an arbitrary read and write primitive. WASM plays a supporting role there because, in the engine versions those challenges target, a WASM instance holds readable-writable-executable pages, which makes it a convenient place to drop shellcode once you have a memory primitive. Current V8 enforces write-xor-execute on JIT and WASM code, so the trick is a period technique rather than a standing one.
They are genuinely hard and share almost nothing with the reversing in this guide beyond the file format. The prerequisites live in heap exploitation and shellcode. Mentioning them here is mostly to keep the two topics from being confused: if a challenge asks you to read a module, this guide applies; if it hands you a patched V8 build, you are in a different discipline that happens to use the same three letters.
picoCTF challenges
| Challenge | What it adds | Difficulty |
|---|---|---|
| Some Assembly Required 1 | The flag survives into the data section as a plain string | Medium |
| Some Assembly Required 2 | Single-byte XOR, with the key visible as an i32.const | Medium |
| Some Assembly Required 3 | Five-byte repeating key, stored beside the ciphertext | Hard |
| Some Assembly Required 4 | Layered obfuscation. Stop reading, start debugging | Hard |
| TurboFlan | V8 JIT exploitation. A different discipline entirely | Hard |
| Kit Engine | JIT compiler bug chained into code execution | Hard |
| Download Horsepower | V8 heap exploitation | Hard |
Quick reference
# Identifyxxd module.wasm | head -1 # expect 0061 736d 0100 0000# Always start herewasm-objdump -x module.wasm | head -60# Free wins before any real workstrings module.wasm | grep -i picowasm-objdump -x module.wasm | sed -n '/Data\[/,$p'# Read itwasm2wat module.wasm -o module.watwasm-decompile module.wasm -o module.dcmp # more readable for big modules# Patch and rebuildwat2wasm module.wat -o patched.wasm# Take it nativewasm2c module.wasm -o module.c# In DevTools: Sources -> wasm:// -> click a line to break// new Uint8Array(instance.exports.memory.buffer)
Related reading: JavaScript deobfuscation for the loader, XOR for CTF for the obfuscation layer, web recon for finding the asset, and the reverse engineering roadmap for where this sits among the other targets.
Sources and further reading
WebAssembly is fully specified in public, which is exactly why it reverses so well.
- The WebAssembly Core Specification for the binary format, the text format, and the instruction semantics, with MDN as the readable introduction to the same material.
- wabt for
wasm2wat,wasm-decompile,wasm-objdump, andwasm2c, and Binaryen for a second disassembler when wabt output looks wrong. - Debugging WebAssembly in Chrome DevTools for breakpoints, the Scope pane, and the memory inspector.
- Emscripten for the runtime conventions that explain which exports are program logic and which are plumbing you can safely skip.