rea-reverse

WASM 转 WAT:在线或用 wabt 转换并查看 .wasm 文件

发布者 rea-reverse更新于 约 8 分钟读完
本页目录

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:

  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。

我们拿其他构建对照 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 规范。