Skip to main content

August 9, 2026

WebAssembly Reversing for CTF: Reading WAT and Recovering Flags from .wasm

Find the .wasm a page loads, disassemble it to WAT with wabt, read the stack machine, pull constants out of the data section, and debug it live in DevTools.

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 loadedHow to get it
A .wasm URL fetched at loadDevTools Network tab, filter by wasm, then Save. Or curl the URL directly
Base64 embedded in the JavaScriptSearch the bundle for WebAssembly.instantiate and decode the array it is handed
Assembled at runtime from partsSet a breakpoint on the instantiate call and dump the buffer argument
Already parsed, no file leftDevTools Sources panel lists every instantiated module under wasm://
# Straightforward case
curl -sO https://target.example/main.wasm
file main.wasm
xxd 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 bundle
grep -oE "[A-Za-z0-9+/]{200,}={0,2}" bundle.js | while read -r b; do
echo "$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 && break
done
file found.wasm
Tip: The browser-side hunt is the same skill as any other bundle archaeology. If the loader is minified or obfuscated to the point that you cannot find the instantiate call, the JavaScript deobfuscation guide covers untangling it, and web recon covers finding the asset in the first place.

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 time
wasm2wat main.wasm -o main.wat
 
# Text back to binary, after you edit it
wat2wasm main.wat -o patched.wasm
 
# A higher-level, C-like rendering. Often more readable than WAT
wasm-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.

ToolPackageUse it for
wasm2watwabtThe canonical text disassembly
wasm-decompilewabtC-like output when the stack machine is tiring
wasm-objdumpwabtSections, imports, exports, data segments
wasm-disbinaryenA second opinion when wabt output looks odd
wasm2cwabtProducing C you can compile, instrument, and run natively
Ghidrawith a WASM loader extensionLarge 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 a
local.get $b ;; push b
i32.add ;; pop two, push a+b
i32.const 42 ;; push 42
i32.eq ;; pop two, push 1 if equal else 0
)

The instruction vocabulary you need is small. This table is most of it:

InstructionMeaning
local.get / local.setRead or write a function local (parameters are locals 0 upward)
global.get / global.setModule-level variables. The stack pointer is usually global 0
i32.const NPush a literal. Where XOR keys and lengths live
i32.load / i32.storeRead or write linear memory at an address popped from the stack
i32.load8_uLoad one byte, zero extended. The signature of a string loop
i32.xor / i32.add / i32.andArithmetic. XOR in a character loop means obfuscation
i32.eq / i32.ne / i32.lt_sComparisons. The _s and _u suffixes are signed and unsigned
br_if / block / loopStructured control flow. There are no arbitrary jumps
call $fDirect 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 $L
local.get $i
i32.const 1024 ;; base address of the ciphertext in linear memory
i32.add
i32.load8_u ;; read ciphertext[i]
i32.const 42 ;; the XOR key, sitting right there as a literal
i32.xor
;; ... compare against the user's input byte ...
local.get $i
i32.const 1
i32.add
local.tee $i
i32.const 32 ;; the flag length
i32.lt_u
br_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 contents
wasm-objdump -x main.wasm | sed -n '/Data\[/,$p'
 
# Or read it as text in the WAT
wasm2wat 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.

Key insight: This directness is why WASM reversing is fast. In a native binary, connecting a load instruction to the bytes it reads means resolving a virtual address through section headers and possibly a relocation table. In WASM the constant in the instruction is the index into the array. The whole layer of indirection simply is not there.

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 pico
wasm-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 section
print(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.

Tip: The escalation across those four is worth naming, because it repeats everywhere in reverse engineering: plaintext, then single-byte obfuscation, then multi-byte obfuscation, then enough layers that reading them costs more than observing the result. The correct response to layer four is not a better decoder. It is a debugger.

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.

TaskWhere
See every instantiated moduleSources panel, wasm:// tree
Break on an instructionClick the line number in the WAT view
Inspect locals and the operand stackScope pane, while paused
Read linear memoryMemory inspector on the Module memory object
Call an export by handConsole, 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 text
new 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.dcmp
less 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 write
gcc main.c wasm-rt-impl.c driver.c -I/path/to/wabt/wasm2c -lm -o native_module
Note: Emscripten output has tells worth recognising, because they tell you what to skip: imports named 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

ChallengeWhat it addsDifficulty
Some Assembly Required 1The flag survives into the data section as a plain stringMedium
Some Assembly Required 2Single-byte XOR, with the key visible as an i32.constMedium
Some Assembly Required 3Five-byte repeating key, stored beside the ciphertextHard
Some Assembly Required 4Layered obfuscation. Stop reading, start debuggingHard
TurboFlanV8 JIT exploitation. A different discipline entirelyHard
Kit EngineJIT compiler bug chained into code executionHard
Download HorsepowerV8 heap exploitationHard

Quick reference

# Identify
xxd module.wasm | head -1 # expect 0061 736d 0100 0000
 
# Always start here
wasm-objdump -x module.wasm | head -60
 
# Free wins before any real work
strings module.wasm | grep -i pico
wasm-objdump -x module.wasm | sed -n '/Data\[/,$p'
 
# Read it
wasm2wat module.wasm -o module.wat
wasm-decompile module.wasm -o module.dcmp # more readable for big modules
 
# Patch and rebuild
wat2wasm module.wat -o patched.wasm
 
# Take it native
wasm2c 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, and wasm2c, 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.

Run it in the browser

Tools on this site that do the work described above. No install, nothing uploaded: they run entirely in your browser.

Try it on these picoCTF challenges

Walkthroughs that put this technique to work, grouped by event.

Keep reading

Guides that build on the same ideas, plus the roadmap this topic sits under.