WASM 转 WAT:在线或用 wabt 转换并查看 .wasm 文件
本页目录
WASM 转 WAT,用 wabt 的 wasm2wat module.wasm -o module.wat,或者把文件拖进本站的 WASM 转 WAT 工具,在浏览器里直接转换,文件不会上传。.wasm 是 WebAssembly 模块的二进制形式,WAT 是同一个模块的文本写法。两者互转除了排版什么都不丢,所以想弄清一个模块在做什么,第一步就是“WASM 转 WAT”。不过在啃几千行 WAT 之前,最好先看模块的结构:它从宿主环境导入了什么、导出了什么、要多少内存、是哪个编译器生成的。看完这些,通常就知道该从哪里下手了。
下面用一个我自己从 Rust 编译出来的小模块把这两步走一遍,并给出对应的 wabt 命令,方便你离线核对每一个结果。
.wasm 和 .wat:同一个模块,两种编码
二进制格式以四个魔数字节 00 61 73 6d(\0asm)和 4 字节版本号(01 00 00 00)开头,后面是一个个带编号的段:类型、导入、函数、内存、全局变量、导出、代码、数据,以及 name、producers 这类自定义段。每个段都自带长度,工具不必理解段里的内容,也能从头走到尾。
WAT(WebAssembly Text)用 S 表达式表示完全相同的内容。wabt 里的 wasm2wat 和 wat2wasm 负责两者互转。如果你要逐条指令弄清某个函数做了什么,就读 WAT;如果问题是“这个模块和外界打什么交道”,看结构报告就够了,答案在导入表和导出表里。
第 1 步:编译一个练手用的小模块
用手里有源码的模块来学,每个结论都能核对。下面是完整的 Rust 源码,一个宿主导入,两个导出函数:
#![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
}
先执行 rustup target add wasm32-unknown-unknown,再直接用 rustc 编译:
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....
用 rustc 1.95.0 编出来是 606 字节。#![no_std] 把标准库和内存分配器挡在外面,模块小到可以从头读到尾。
第 2 步:看结构报告
打开 WASM 转 WAT 工具,选择 checksum.wasm。Web Worker 里的一个小解析器读取各个段,生成报告。它不执行模块,也不解码函数体。下面是它对这个文件给出的结果。
段列表(偏移和大小单位都是字节):
| 段 | 偏移 | 大小 |
|---|---|---|
| type | 10 | 11 |
| import | 23 | 17 |
| function | 42 | 3 |
| memory | 47 | 3 |
| global | 52 | 25 |
| export | 79 | 54 |
| code | 136 | 184 |
自定义段 name |
322 | 70 |
自定义段 producers |
394 | 61 |
自定义段 target_features |
458 | 148 |
导入: 只有一个函数,env.log_value,签名 (i32) -> (),就是源码里的 extern "C" 块。分析真实模块时,导入表往往是信息量最大的一张表:模块能请宿主做的事全在里面。只导入数学函数的模块,自己没法碰网络;导入了 fetch 一类胶水函数或 wasi_snapshot_preview1 函数的,就有可能。
导出: memory(内存 0)、add 和 checksum(都是 (i32, i32) -> (i32)),以及 Rust 工具链自动加上的两个全局变量 __data_end 和 __heap_base。usize 和指针参数都变成了 i32,因为 wasm32 的指针是 32 位的。
内存和全局变量: 一块内存,初始 16 页。WebAssembly 一页是 64 KiB,所以是 1 MiB。全局变量有三个,其中一个可变:__stack_pointer,初始值 1048576,正好是这 1 MiB 的顶端。
代码: 两个函数体,共 180 字节。“最大函数”列表里 checksum 163 字节、add 17 字节,名字取自 name 段。报告统计到 3 个有名字的函数,因为导入的函数也有名字。
producers 和特性: producers 里是 processed-by: rustc 1.95.0 (59807616e 2026-04-14);target_features 列出了 +bulk-memory、+bulk-memory-opt、+call-indirect-overlong、+multivalue、+mutable-globals、+nontrapping-fptoint、+reference-types 和 +sign-ext。
工具链: rust,证据是 producers processed-by: rustc 1.95.0 (59807616e 2026-04-14)。识别靠一组特征规则(producers 条目、wasi_snapshot_preview1 或 gojs 这类导入模块名、wasm-bindgen 的 __wbindgen_* 这类导出名)。每个判断都附带它命中的证据,你可以自己判断靠不靠谱。
真实网站的 release 构建经常被裁剪过。没有 name 段时,函数只能按编号显示,producers 也可能没了。导入表和导出表则一定在,因为宿主要靠它们实例化模块。
第 3 步:WASM 转 WAT,然后读懂它
点 Generate WAT。这时页面才会加载 wabt.js 1.0.39,开启 wabt 支持的全部特性来转换。它会用上 name 段,并给其余没名字的东西自动起名,所以参数显示成 $p0、$p1,类型显示成 $t0、$t1。下面是 add 和 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
全文 2489 个字符,离页面 500 万字符的显示上限还远。模块末尾声明了内存、全局变量和导出:
(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))
怎么读
先看签名。 (type $t1 (func (param i32 i32) (result i32))) 定义了一个函数类型,(func $add (type $t1) ...) 表示 add 是这个类型。在函数编号里导入排在最前面,所以 log_value 是 0 号,add 是 1 号,checksum 是 2 号。只显示编号(func[2])的工具也是这么数的。
WebAssembly 是栈式虚拟机。 指令从操作数栈上压入、弹出数据。逐行读 add:
local.get $p1和local.get $p0依次压入y和x。i32.add弹出两个值,压入它们的和。编译器调换了操作数顺序,加法无所谓。local.tee $p0把和存进$p0,同时留在栈上。call $log_value把它弹出来,作为参数传给宿主导入的函数。local.get $p0再把和压回栈上。函数结束时留在栈上的值就是返回值。
局部变量集中在开头声明。 checksum 里的 (local $l2 i32) ... 是编译器额外需要的四个局部变量,编号接在参数后面。
控制流是结构化的。 没有随意跳转。block、loop、if 各自开一个带标签的区域,br / br_if 跳到 block 的末尾或 loop 的开头。结尾带 br_if 0 的 loop 就是一个 do ... while。
常量是线索。 checksum 的函数体里出现了 6 次 i32.const 65521(优化器把循环展开了一倍),循环里还有 i32.load8_u(从线性内存读一个字节)。65521 是 Adler-32 的模数,所以就算没有源码,也能猜出这个函数在算什么。分析陌生模块时,多留意这类常量、传给导入函数的字符串偏移,以及循环里对导入函数的调用。
第 4 步:在命令行里用 wabt 做同样的事
模块大、要写脚本或者放进 CI,就直接用 wabt。npm 上的 wabt 1.0.39(和页面用的版本相同)在 node_modules/wabt/bin/ 下带了这些命令;C++ 版的发行包用法一样。
段头信息,对应报告里的段列表:
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"
两边报告的都是段内容的起点(跳过段 id 字节和长度字段之后),所以数字能直接对上:页面上 type 段偏移 10、大小 11,这里是 start=0x0a ... size=0x0b。
wasm-objdump -x checksum.wasm 输出细节:func[0] sig=0 <log_value> <- env.log_value、memory[0] pages: initial=16、五个导出,以及代码大小(func[1] size=17 <add>、func[2] size=163 <checksum>)。wasm-objdump -d 带字节偏移反汇编,运行时报错给出偏移时很好用。
转成 WAT 文本:
wasm2wat checksum.wasm -o checksum.wat # 只用 name 段里的名字
wasm2wat --generate-names checksum.wasm -o checksum.wat # 和页面一样,自动起 $p0、$t0 这类名字
wasm2wat --enable-all module.wasm -o module.wat # 模块用了 GC、memory64 等新特性时
不加 --generate-names 时,wasm2wat 输出的是 local.get 1 和 (type 0),而不是 $p1 和 $t0,指令本身完全一样。
如果嫌 WAT 太底层,同一个包里的 wasm-decompile 会输出类 C 的伪代码。对 add 的输出是:
export function add(a:int, b:int):int { // func1
a = b + a;
log_value(a);
return a;
}
它只是帮你读代码,不能反编译回 Rust:类型还是 WebAssembly 的类型,控制流用标签和 goto 表示。浏览器工具里没有集成 wasm-decompile。
我们拿其他构建对照 WAT 检查 wasm-decompile 时,踩过两个坑:
- 内存偏移会显示成数组下标。 一个 Rust 写的 CRC-32 模块用
i32.load offset=1049012读表,wasm-decompile把同一个 load 写成0[262253]:int。262,253 × 4 = 1,049,012:偏移量被除以了访问宽度。拿它去对 WAT 或崩溃偏移之前,记得乘回来。 - 无符号比较可能看起来像有符号。 一个 C 函数拒绝
len < 0 || len > 3072,clang-O2把它编译成一次无符号比较:i32.const 3072加i32.gt_u。wasm-decompile对类型为a:int的参数输出if (a > 3072) goto B_a;,看起来负数长度能混过去,实际上不能。两者在边界检查上对不上时,以 WAT 为准。
找到页面加载的 .wasm
在授权范围内检查一个 Web 应用时,模块一般由一段 JavaScript 加载器下载。在浏览器 Network 面板里按 wasm 过滤,或者找 Content-Type: application/wasm 的响应,保存下来即可。抓 HAR 的方法见 HAR 文件分析指南;加载器那一侧见 从 bundle 还原 JavaScript 源码,wasm-bindgen 和 Emscripten 生成的胶水代码能告诉你每个导入函数实际做了什么。Electron 应用经常把 .wasm 放在 ASAR 包里,见 用 REA 逆向分析 Electron 应用。
对比两个构建的接口
想看两个版本的模块在导入、导出上改了什么,可以用 REA(第三方开源逆向工具集)6.7.0 的 inspect-wasm-artifact 和 compare-wasm-interfaces。REA 不自带 WABT,需要你自己准备 WABT 1.0.42,并通过绝对路径的 REA_WABT_BIN_DIRECTORY 指定。不设这个变量时,我们的测试直接返回 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 只接受一个 JSON 参数,里面放左右两条完整 Evidence,外加必填的 scope。我们的测试里,CRC-32 模块的 v2 给 crc32 加了 seed: u32 参数,把 dealloc 改名为 free_buf,又新增了 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
整理成每条一行:
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) -> ()
改名会显示成“删一个、加一个”;seed: u32 显示为 i32,因为 WebAssembly 没有无符号类型,源码里的符号变化在这里看不出来。输入必须是原封不动的 Evidence:改动记录内容,或者存成默认的 toon 格式而不是 --format json,命令都会失败。对 v2 跑 wasm-opt -Oz --strip-debug --strip-producers(binaryen 133)后,WAT 里的函数名变了,但对比结果仍是 unchanged: 8,因为它按名称、类型和签名匹配。如果 .wasm 在 zip 里,先解出来:REA 的归档清单只把它当普通 resource 条目,不当模块分析。
常见问题
拿来历不明的模块用这个转换器安全吗?
模块只会被解析,不会被实例化或执行。分析用的 Worker 运行在 connect-src 'none' 策略下,发不出任何网络请求。只检查你自己构建的、开源的,或者你有权审查的模块。
能转换成功,是不是说明模块合法?
不是。工具页的限制说明里写了:页面里 wabt 只负责读模块、写文本,不运行校验器。如果合法性很重要,执行 wasm-validate module.wasm。
能打开 Component Model 的 .wasm 吗?
不能。只支持二进制版本 1 的核心模块。组件的文件头里 layer 字段不同,会被拒绝。
WAT 能再变回可用的模块吗?
可以,wat2wasm module.wat -o module.wasm 就能汇编回去,适合拿自己的构建做实验。模块用了新特性的话,加上对应的 --enable-* 参数。
模块旁边的 JavaScript 加载器怎么读,见 从 bundle 还原 JavaScript 源码。参考:WebAssembly 规范。