WASM 转 WAT:在浏览器里查看 WebAssembly 模块的导入、导出和函数
本页目录
结论先行: .wasm 是 WebAssembly 模块的二进制形式,WAT 是同一个模块的文本写法。两者互转除了排版什么都不丢,所以想弄清一个模块在做什么,第一步就是“WASM 转 WAT”。不过在啃几千行 WAT 之前,最好先看模块的结构:它从宿主环境导入了什么、导出了什么、要多少内存、是哪个编译器生成的。看完这些,通常就知道该从哪里下手了。
本站的 WASM 转 WAT 工具 在浏览器里把这两件事都做了,文件不会上传。本文用一个我自己从 Rust 编译出来的小模块走一遍流程,讲清楚工具生成的 WAT 怎么读,再给出对应的 wabt 命令,方便你离线核对每一个结果。
适合谁读: 调试自家 WebAssembly 构建的前端和 Rust/C++ 开发者;在授权范围内检查某个网站加载了什么模块的安全测试人员;拿到 .wasm 题目的 CTF 选手。
读完你会知道:
.wasm和.wat是什么关系,什么时候值得读 WAT- 怎么编译一个最小模块来练手
- 怎么看导入、导出、内存和自定义段的结构报告
- 怎么读 WAT:函数签名、局部变量和栈式指令
- 怎么用
wasm2wat、wasm-objdump、wasm-decompile做同样的事 - REA(morluto 的开源项目)在这件事上支持到什么程度(6.4.0 版:不支持,以及原因)
.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)。识别逻辑是 src/tools/wasm/parse.ts 里的一组特征规则(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。
找到页面加载的 .wasm
在授权范围内检查一个 Web 应用时,模块一般由一段 JavaScript 加载器下载。在浏览器 Network 面板里按 wasm 过滤,或者找 Content-Type: application/wasm 的响应,保存下来即可。抓 HAR 的方法见 追踪网站 API 调用;加载器那一侧见 从 bundle 还原 JavaScript 源码,wasm-bindgen 和 Emscripten 生成的胶水代码能告诉你每个导入函数实际做了什么。Electron 应用经常把 .wasm 放在 ASAR 包里,见 用 REA 逆向分析 Electron 应用。
REA 呢?
REA 6.4.0 不支持分析 WebAssembly。 发布到 npm 的 6.4.0 包里,dist/cliCommandNames.js 没有任何 wasm 命令,也没有 wasm 检查工具。dist/ 里出现 WebAssembly 的地方只有这几处:
- EVMole。 REA 的 EVM 接口工具调用 EVMole,而 EVMole 本身是编译成 WebAssembly 的(
dist/evm/Evmole*)。这是 REA 内部在用 wasm,不是在分析你的模块。见 EVM 字节码函数选择器。 - 浏览器运行时采集。
observe-web-execution在限制说明里写明 WebAssembly 不在采集范围内(dist/browser/execution/CdpExecutionCapture.js);源码采集遇到 wasm 脚本会标为 excluded,理由是 “WebAssembly bytecode is outside this JavaScript source operation.”(dist/browser/execution/CdpRuntimeSources.js)。 - 归档清单。
inspect-artifact会把归档里的.wasm条目列成普通的resource文件(dist/artifacts/inventory/ArtifactGraphConstruction.js里的classifyArtifactPath),不解析模块内容。
所以目前请用浏览器工具和 wabt。等 REA 后续版本加上 WebAssembly 支持,本文会补一节“在 agent 里用 REA 分析”。
常见问题
拿来历不明的模块用这个转换器安全吗?
模块只会被解析,不会被实例化或执行。分析用的 Worker 运行在 connect-src 'none' 策略下,发不出任何网络请求。即便如此,也只检查你自己构建的、开源的,或者你有权审查的模块。
能转换成功,是不是说明模块合法?
不是。工具页的限制说明里写了:页面里 wabt 只负责读模块、写文本,不运行校验器。如果合法性很重要,执行 wasm-validate module.wasm。
为什么没有函数名?
name 段被裁掉了。这时函数只按编号显示(func 42,自动起名后是 $f42)。导入和导出的名字始终保留,从它们入手。
能打开 Component Model 的 .wasm 吗?
不能。只支持二进制版本 1 的核心模块。组件的文件头里 layer 字段不同,会被拒绝。
WAT 能再变回可用的模块吗?
可以,wat2wasm module.wat -o module.wasm 就能汇编回去,适合拿自己的构建做实验。模块用了新特性的话,加上对应的 --enable-* 参数。
下一步
- 检查你自己的模块:WASM 转 WAT 工具
- 读模块旁边的 JavaScript 加载器:从 bundle 还原 JavaScript 源码
- 抓取页面加载的资源:追踪网站 API 调用
- 参考资料:WebAssembly 规范、wabt、REA 的 DISCLAIMER.md