rea-reverse

WASM to WAT: Convert and Inspect a .wasm File Online or with wabt

Published by rea-reverseUpdated 9 min read
On this page

To convert WASM to WAT, run wasm2wat module.wasm -o module.wat from wabt, or open the file in the WASM to WAT tool, which converts it in your browser without uploading it. 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.

Below, both steps are run on a small module I compiled myself from Rust, with the matching wabt commands so you can check every result offline.

WASM to WAT: Inspect a WebAssembly Module in Your BrowserSilent walkthrough with English captions.

.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 uses fingerprint rules (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.

Two traps we hit when checking wasm-decompile against the WAT of other builds:

  • Memory offsets print as array indexes. A Rust CRC-32 module loads its table with i32.load offset=1049012; wasm-decompile shows the same load as 0[262253]:int. 262,253 × 4 = 1,049,012: the offset is divided by the access size. Multiply it back before comparing with the WAT or a crash offset.
  • Unsigned compares can read as signed. A C function that rejects len < 0 || len > 3072 compiled (clang, -O2) to one unsigned compare, i32.const 3072 / i32.gt_u. wasm-decompile printed if (a > 3072) goto B_a; for a parameter typed a:int, which reads as if a negative length would get through. It would not. When the two disagree on a bounds check, go with the WAT.

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 HAR file guide 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.

Diffing the interfaces of two builds

To see what changed between two versions of a module’s imports and exports, REA, a third-party open-source reverse-engineering toolkit, has inspect-wasm-artifact and compare-wasm-interfaces (REA 6.7.0). It brings no WABT of its own: you supply WABT 1.0.42 through an absolute REA_WABT_BIN_DIRECTORY. Without it, our run stopped with capability_unavailable: “REA_WABT_BIN_DIRECTORY must be an absolute tool directory. Supply WABT 1.0.42 via absolute REA_WABT_BIN_DIRECTORY; REA does not install tools.”

compare-wasm-interfaces takes one JSON argument holding both complete Evidence records and a mandatory scope. In our test, v2 of a CRC-32 module added a seed: u32 parameter to crc32, renamed dealloc to free_buf and added abi_version():

rea inspect-wasm-artifact v1/crcmod.wasm --format json > v1.json
rea inspect-wasm-artifact v2/crcmod.wasm --format json > v2.json
node -e 'const fs=require("fs");fs.writeFileSync("cmp.json",JSON.stringify({
  left: JSON.parse(fs.readFileSync("v1.json")),
  right: JSON.parse(fs.readFileSync("v2.json")), scope: "both"}))'
rea compare-wasm-interfaces cmp.json --format json

Trimmed to one line per entry:

summary: added 2, removed 1, changed 1, unchanged 5, unknown 0   coverage: complete-within-inputs
export crc32        changed    (i32, i32) -> i32   =>  (i32, i32, i32) -> i32
export dealloc      removed    (i32, i32) -> ()
export free_buf     added      (i32, i32) -> ()
export abi_version  added      () -> i32
import env.report   unchanged  (i32) -> ()

A rename shows up as a removal plus an addition, and seed: u32 appears as i32 because WebAssembly has no unsigned types, so a signedness change in the source is invisible here. The input must be the exact Evidence: editing a record, or saving the default toon output instead of --format json, makes the command fail. Running wasm-opt -Oz --strip-debug --strip-producers (binaryen 133) on v2 changed function names in the WAT but left the comparison at unchanged: 8, because it matches on name, kind and signature. If the .wasm sits inside a zip, extract it first: REA’s archive inventory lists it as a generic resource entry, not as a module.

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. 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.

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.

For the JavaScript loader next to the module, see recover JavaScript source from bundles. Reference: the WebAssembly specification.