rea-reverse

WASM 转 WAT:在浏览器里查看 WebAssembly 模块的导入、导出和函数

基于 REA 6.4.0更新于 约 8 分钟读完
本页目录

结论先行: .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:

  1. local.get $p1 和 local.get $p0 依次压入 y 和 x。
  2. i32.add 弹出两个值,压入它们的和。编译器调换了操作数顺序,加法无所谓。
  3. local.tee $p0 把和存进 $p0,同时留在栈上。
  4. call $log_value 把它弹出来,作为参数传给宿主导入的函数。
  5. 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-* 参数。

下一步