rea-reverse

WASM to WAT: Inspect a WebAssembly Module in Your Browser

Written for REA 6.4.0Updated 9 min read
On this page

Short answer: a .wasm file is the binary form of a WebAssembly module, and WAT is the same module written as text. Converting one to the other loses nothing except formatting, so “WASM to WAT” is the first step whenever you need to know what a module does. Before you read thousands of lines of WAT, though, read the module’s structure: what it imports from its host, what it exports, how much memory it asks for and which compiler built it. That usually tells you where to look.

This site’s WASM to WAT tool does both in your browser without uploading the file. This guide walks through it on a small module I compiled myself from Rust, explains how to read the WAT it produces, and shows the matching wabt commands so you can check every result offline.

Who this is for: web and Rust/C++ developers debugging their own WebAssembly builds, security reviewers looking at a module a site they are authorized to test loads, and CTF players who get a .wasm challenge.

What you will learn:

  • How .wasm and .wat relate, and when WAT is worth reading
  • How to build a minimal module to practise on
  • How to read the import, export, memory and custom-section report
  • How to read WAT: signatures, locals and the stack machine
  • How to do the same with wasm2wat, wasm-objdump and wasm-decompile
  • What REA, the open-source project by morluto, supports here (in version 6.4.0: nothing, and why)

.wasm and .wat: same module, two encodings

The binary format starts with the four magic bytes 00 61 73 6d (\0asm) and a 4-byte version (01 00 00 00), followed by numbered sections: types, imports, functions, memory, globals, exports, code, data, and custom sections such as name and producers. Every section carries its own length, so a tool can walk the file without understanding what is in each section.

WAT (WebAssembly Text) is an S-expression syntax for exactly the same content. wasm2wat and wat2wasm from wabt convert between them. Read WAT when you need to know what a function does instruction by instruction. Stay with the structural report when the question is “what does this module talk to?”: the answer is in the import and export tables.

Step 1: Build a small module to practise on

Learning on a module whose source you have means you can check every claim. Here is the complete Rust source, with one host import and two exported functions:

#![no_std]

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

extern "C" {
    // Provided by the JavaScript host.
    fn log_value(v: i32);
}

/// Adler-32 style checksum over `len` bytes at `ptr` in linear memory.
#[no_mangle]
pub extern "C" fn checksum(ptr: *const u8, len: usize) -> u32 {
    let data = unsafe { core::slice::from_raw_parts(ptr, len) };
    let (mut a, mut b) = (1u32, 0u32);
    for &byte in data {
        a = (a + byte as u32) % 65521;
        b = (b + a) % 65521;
    }
    (b << 16) | a
}

#[no_mangle]
pub extern "C" fn add(x: i32, y: i32) -> i32 {
    let s = x.wrapping_add(y);
    unsafe { log_value(s) };
    s
}

Compile it with plain rustc (after rustup target add wasm32-unknown-unknown):

rustc --target wasm32-unknown-unknown --crate-type cdylib -O \
  -C strip=debuginfo -o checksum.wasm checksum.rs
xxd -l 8 checksum.wasm
# 00000000: 0061 736d 0100 0000                      .asm....

With rustc 1.95.0 the result is 606 bytes. #![no_std] keeps the standard library and its allocator out, so the module stays small enough to read in full.

Step 2: Read the structural report

Open the WASM to WAT tool and choose checksum.wasm. A small parser in a Web Worker reads the sections and builds a report. It does not execute the module and does not decode function bodies. These are the values it produced for this file.

Sections, with offsets and sizes in bytes:

Section Offset Size
type 10 11
import 23 17
function 42 3
memory 47 3
global 52 25
export 79 54
code 136 184
custom name 322 70
custom producers 394 61
custom target_features 458 148

Imports: one function, env.log_value with signature (i32) -> (). That is the extern "C" block. In a real module, the import list is the most informative table you have: it is everything the module can ask its host to do. A module that imports only math helpers cannot touch the network on its own. One that imports fetch-style glue or wasi_snapshot_preview1 functions can.

Exports: memory (memory 0), add and checksum (both (i32, i32) -> (i32)), and two globals, __data_end and __heap_base, that the Rust toolchain adds. The usize and pointer parameters became i32, because wasm32 has 32-bit pointers.

Memory and globals: one memory with an initial size of 16 pages. A WebAssembly page is 64 KiB, so that is 1 MiB. There are three globals, one of them mutable: the __stack_pointer initialized to 1048576, the top of that 1 MiB.

Code: two bodies, 180 bytes in total. The largest-functions list shows checksum at 163 bytes and add at 17, with names taken from the name section. The report counts 3 named functions, because the import is named too.

Producers and features: producers contains processed-by: rustc 1.95.0 (59807616e 2026-04-14), and target_features lists +bulk-memory, +bulk-memory-opt, +call-indirect-overlong, +multivalue, +mutable-globals, +nontrapping-fptoint, +reference-types and +sign-ext.

Toolchain: rust, with the evidence line producers processed-by: rustc 1.95.0 (59807616e 2026-04-14). Detection is a set of fingerprint rules in src/tools/wasm/parse.ts (producers entries, import module names such as wasi_snapshot_preview1 or gojs, export patterns such as wasm-bindgen’s __wbindgen_*). Each guess comes with the evidence it matched, so you can judge it yourself.

Release builds of real sites are often stripped. Without a name section, functions show up by index only, and producers may be gone too. The import and export tables are always there, because the host needs them to instantiate the module.

Step 3: Convert WASM to WAT and read it

Click Generate WAT. The page then loads wabt.js 1.0.39 (only on that click) and converts the module with every wabt feature enabled. It also uses the name section and generates names for everything else, so parameters appear as $p0, $p1 and types as $t0, $t1. The output for add and the start of checksum:

(module $checksum.wasm
  (type $t0 (func (param i32)))
  (type $t1 (func (param i32 i32) (result i32)))
  (import "env" "log_value" (func $log_value (type $t0)))
  (func $add (type $t1) (param $p0 i32) (param $p1 i32) (result i32)
    local.get $p1
    local.get $p0
    i32.add
    local.tee $p0
    call $log_value
    local.get $p0)
  (func $checksum (type $t1) (param $p0 i32) (param $p1 i32) (result i32)
    (local $l2 i32) (local $l3 i32) (local $l4 i32) (local $l5 i32)
    block $B0

The full text is 2,489 characters, well under the page’s 5-million-character display limit. The end of the module declares the memory, globals and exports:

  (memory $memory 16)
  (global $__stack_pointer (mut i32) (i32.const 1048576))
  (global $__data_end i32 (i32.const 1048576))
  (global $__heap_base i32 (i32.const 1048576))
  (export "memory" (memory $memory))
  (export "add" (func $add))
  (export "checksum" (func $checksum))

How to read it

Signatures first. (type $t1 (func (param i32 i32) (result i32))) is a function type, and (func $add (type $t1) ...) says add has that type. Imports come first in the function index space, so log_value is function 0, add is 1 and checksum is 2. Tools that show indexes instead of names (func[2]) count the same way.

WebAssembly is a stack machine. Instructions push to and pop from an operand stack. Read add line by line:

  1. local.get $p1 and local.get $p0 push y, then x.
  2. i32.add pops both and pushes the sum. The compiler swapped the operands, which is fine for addition.
  3. local.tee $p0 stores the sum into $p0 and leaves it on the stack.
  4. call $log_value pops it as the argument to the host import.
  5. local.get $p0 pushes the sum again. The value left at the end is the return value.

Locals are declared up front. (local $l2 i32) ... in checksum are the four extra locals the compiler needed. Their numbering continues after the parameters.

Control flow is structured. There are no arbitrary jumps. block, loop and if open labelled regions, and br / br_if jump to the end of a block or the start of a loop. A loop with br_if 0 at its end is a do ... while.

Constants are clues. The body of checksum contains i32.const 65521 six times (the optimizer unrolled the loop by two), plus i32.load8_u (read one byte from linear memory) inside a loop. 65521 is the Adler-32 modulus, so even without the source you could guess what this function computes. In unknown modules, look for constants like this, string offsets passed to imports, and calls to imports inside loops.

Step 4: The same with wabt on the command line

For big modules, scripts or CI, use wabt directly. The npm package wabt 1.0.39 (the same version as the page) ships these tools under node_modules/wabt/bin/, and the C++ release builds work the same way.

Section headers, the equivalent of the report’s section table:

wasm-objdump -h checksum.wasm
     Type start=0x0000000a end=0x00000015 (size=0x0000000b) count: 2
   Import start=0x00000017 end=0x00000028 (size=0x00000011) count: 1
 Function start=0x0000002a end=0x0000002d (size=0x00000003) count: 2
   Memory start=0x0000002f end=0x00000032 (size=0x00000003) count: 1
   Global start=0x00000034 end=0x0000004d (size=0x00000019) count: 3
   Export start=0x0000004f end=0x00000085 (size=0x00000036) count: 5
     Code start=0x00000088 end=0x00000140 (size=0x000000b8) count: 2
   Custom start=0x00000142 end=0x00000188 (size=0x00000046) "name"
   Custom start=0x0000018a end=0x000001c7 (size=0x0000003d) "producers"
   Custom start=0x000001ca end=0x0000025e (size=0x00000094) "target_features"

Both tools report where each section’s contents start, after its id byte and size field, so the numbers line up: type is offset 10 with size 11 on the page, and start=0x0a ... size=0x0b here.

wasm-objdump -x checksum.wasm prints the details: func[0] sig=0 <log_value> <- env.log_value, memory[0] pages: initial=16, the five exports and the code sizes (func[1] size=17 <add>, func[2] size=163 <checksum>). wasm-objdump -d disassembles with byte offsets, useful when a runtime error names an offset.

WAT text:

wasm2wat checksum.wasm -o checksum.wat                    # names from the name section only
wasm2wat --generate-names checksum.wasm -o checksum.wat   # also invent $p0, $t0, ... like the page
wasm2wat --enable-all module.wasm -o module.wat           # newer features such as GC or memory64

Without --generate-names, wasm2wat writes local.get 1 and (type 0) instead of $p1 and $t0. The instructions are identical.

If WAT is too low-level, wasm-decompile from the same package prints C-like pseudo-code. For our add it gives:

export function add(a:int, b:int):int { // func1
  a = b + a;
  log_value(a);
  return a;
}

It is a reading aid, not a decompiler back to Rust: types are WebAssembly types and control flow is shown as labels and goto. The browser tool does not include wasm-decompile.

Finding the .wasm a page loads

When you review a web app you are authorized to test, the module is usually fetched by a JavaScript loader. In the browser’s Network tab, filter by wasm or look for Content-Type: application/wasm, then save the response. Our guide to tracing website API calls covers capturing a HAR, and recovering JavaScript from bundles covers the loader side: wasm-bindgen and Emscripten glue code tells you what each import really does. Electron apps often ship .wasm files inside their ASAR archive; see Reverse Engineer an Electron App with REA.

What about REA?

REA 6.4.0 has no WebAssembly analysis. The published 6.4.0 package has no wasm command in dist/cliCommandNames.js and no wasm inspection tool. The places where WebAssembly appears in its dist/ are:

  • EVMole. REA’s EVM interface tool runs EVMole, which is itself compiled to WebAssembly (dist/evm/Evmole*). That is REA using wasm internally, not analyzing your module. See EVM bytecode function selectors.
  • Browser runtime capture. observe-web-execution lists WebAssembly among what stays outside the operation (dist/browser/execution/CdpExecutionCapture.js), and its source capture marks wasm scripts as excluded with the reason “WebAssembly bytecode is outside this JavaScript source operation.” (dist/browser/execution/CdpRuntimeSources.js).
  • Archive inventory. inspect-artifact lists a .wasm entry inside an archive as a generic resource file (classifyArtifactPath in dist/artifacts/inventory/ArtifactGraphConstruction.js). It does not parse the module.

So for now, use the browser tool and wabt. When a later REA release adds WebAssembly support, this guide will get a section on using it from an agent.

FAQ

Is the converter safe to use on a module I don’t trust? The module is parsed, never instantiated or executed. The analysis worker runs under connect-src 'none', so it cannot make network requests. Still, only examine modules you built, open-source modules, or modules you are authorized to review.

Does a successful conversion mean the module is valid? No. wabt reads the module and writes text but does not run its validator in the page, as the tool’s limitations section states. Run wasm-validate module.wasm if validity matters.

Why are there no function names? The name section was stripped. Functions are then shown by index (func 42, or $f42 with generated names). Imports and exports keep their names, which is where to start.

Can I open a Component Model .wasm? No. Only core modules with binary version 1 are supported. A component has a different layer value in its header and is rejected.

Can I turn WAT back into a working module? Yes, wat2wasm module.wat -o module.wasm assembles it, which is handy for experiments on your own builds. Add the matching --enable-* flags if the module uses newer features.

Next steps