rea-reverse

用 REA 6.7 + WABT 检查 WebAssembly 模块:导入导出对比实测

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

结论先行: REA(morluto 的开源项目)从 6.5.0 起就有两个 WebAssembly 命令,本文在 6.7.0 上实测。rea inspect-wasm-artifact 调用 WABT 1.0.42 检查本地 .wasm 文件,把段表、带类型的导入导出清单和完整 WAT 打包成一条 Evidence;rea compare-wasm-interfaces 拿两条这样的 Evidence 做对比,列出哪些导入导出新增了、删掉了、签名变了。REA 不执行模块,也不替你装 WABT:你得自己给它一个 WABT 1.0.42 的目录,版本不对直接拒绝。

如果只是想看一眼模块里有什么,本站的 WASM 转 WAT 工具 和配套教程 WASM 转 WAT:在浏览器里查看 WebAssembly 模块 更省事,WAT 的读法那篇也讲过了,这里不重复。本文只讲 REA 多出来的部分:一份带哈希、事后可以复核的记录;两个构建之间的接口 diff;以及 agent 通过 MCP 调用时,怎么避免把 15 MB 的 WAT 一股脑塞进上下文。

测试环境: macOS 27.0.1(arm64),Node.js 22.22.3,npm 上的 rea-agents 6.7.0,WABT 1.0.42(上游发布的 macOS arm64 包),binaryen version_133,rustc 1.95.0,AssemblyScript 0.28.20,Apple clang 21.0.0。下面的模块除了 sql.js 1.13.0(MIT 协议,用来当体量大一点的真实样本)都是我们自己编译的。请只分析自己构建的、开源的或已获授权的模块,动手前先读一下 REA 的 DISCLAIMER.md。

安装 REA 和 WABT 1.0.42

npm install [email protected]
curl -LO https://github.com/WebAssembly/wabt/releases/download/1.0.42/wabt-1.0.42-macos-arm64.tar.gz
shasum -a 256 wabt-1.0.42-macos-arm64.tar.gz
# 3f654779b436c628db3ca4323c51538815f5fbc887a6d5f3bb1c18d36281e240
tar xzf wabt-1.0.42-macos-arm64.tar.gz
export REA_WABT_BIN_DIRECTORY="$PWD/wabt-1.0.42/bin"

这个校验和与 npm 包里 third_party/wabt/README.md 记录的一致,那份说明写得很直白:REA “does not bundle, patch, install or download WABT executables”。我们查的时候 Homebrew 的 wabt 也是 1.0.42;npm 上的 wabt 包就不是了。拿 [email protected] 的目录喂给 REA,得到的是:

code: capability_unavailable
message: Unsupported wasm-validate version banner; expected 1.0.42. Supply WABT 1.0.42 via absolute REA_WABT_BIN_DIRECTORY; REA does not install tools.

写成相对路径(比如 ../wabt-1.0.42/bin)也不行,报错是 REA_WABT_BIN_DIRECTORY must be an absolute tool directory.。另外,6.7.0 的 rea doctor 和 rea capabilities 都不检查 WABT,第一次跑 inspect-wasm-artifact 才算真正验证了环境。

还有个小坑:WABT 1.0.42 的发布包里有 wasm2wat、wasm-objdump、wasm-validate 等 11 个工具,偏偏没有 wasm-decompile。下文用到 wasm-decompile 的地方,用的是 npm [email protected] 里的版本。

准备一个练手模块

第一个样本是 Rust 的 cdylib,一个宿主导入、三个导出。src/lib.rs 的核心部分如下,alloc 和 dealloc 就是常见的基于 Vec 的内存分配辅助函数:

extern "C" {
    fn report(bytes: u32); // provided by the host
}

#[no_mangle]
pub unsafe extern "C" fn crc32(ptr: *const u8, len: usize) -> u32 {
    let data = core::slice::from_raw_parts(ptr, len);
    let t = table(); // 256-entry CRC table, built on first use
    let mut c = 0xFFFF_FFFFu32;
    for &b in data {
        c = t[((c ^ b as u32) & 0xFF) as usize] ^ (c >> 8);
    }
    report(len as u32);
    !c
}
cargo build --release --target wasm32-unknown-unknown   # opt-level = "s"
cp target/wasm32-unknown-unknown/release/crcmod.wasm v1/

因为用了 std(Vec 要用),分配器和 panic 相关代码都被带了进来:产物 17,633 字节,定义了 55 个函数,而源码里我们只写了 3 个。

第一次检查

npx rea inspect-wasm-artifact v1/crcmod.wasm \
  --filter-output normalized_result.validation,normalized_result.sections
normalized_result:
  validation: valid
  sections[13]{kind,start,end,bytes,description}:
    Type,10,72,62,"count: 10"
    Import,74,88,14,"count: 1"
    Function,90,146,56,"count: 55"
    ...
    Code,277,12641,12364,"count: 55"
    Data,12644,13086,442,"count: 1"
    Custom,13089,17403,4314,"\"name\""
    Custom,17405,17482,77,"\"producers\""
    Custom,17485,17633,148,"\"target_features\""

首次运行 3.3 秒,之后稳定在 1.1~1.3 秒。REA 内部的流程是:把文件复制到私有目录,对每个 WABT 可执行文件算哈希,确认三个工具的 --version 输出都恰好是 1.0.42,然后依次运行 wasm-validate --enable-all、wasm-objdump -h -x、wasm2wat --enable-all。三条命令行和二进制哈希都记录在 tool_profile 里。

--filter-output 一定要会用。这个 17 KB 的模块,完整记录是 197,087 字节的 JSON,--token-count 算出来 46,511 个 token,大头是 WAT。真正有用的是 interface 条目:

- direction: import
  module: env
  name: report
  signature: { kind: func, params[1]: i32, results: [] }
  wat_form: "(import \"env\" \"report\" (func $report (type 3)))"
- direction: export
  name: crc32
  signature: { kind: func, params[2]: i32,i32, results[1]: i32 }
  wat_form: "(export \"crc32\" (func $crc32))"

(已删减:完整输出里还有 memory,minimum: "17" 页,以及 alloc、dealloc、__data_end、__heap_base。)

我们拿 WABT 直接跑了一遍做对照。段表和 wasm-objdump -h 逐字节一致,这在意料之中,因为 REA 本来就是解析它的输出。记录里 WAT 的 SHA-256 是 622ac13b…8339,自己跑 wasm2wat --enable-all 得到的文件哈希完全相同。也就是说,REA 并没有自己再解码一遍,它只是原样记下 WABT 的结论。以后要引用这份结果时,这恰恰是它的价值。

读 crc32,顺便说一个 wasm-decompile 的坑

WAT 里查表的部分很好认:

i32.const 0
i32.load offset=1049012      ;; already initialised?
...
i32.const -306674912         ;; 0xEDB88320, the CRC-32 polynomial
i32.xor

同样的 load,wasm-decompile 写成 0[262253]:int 和 (d << 2)[262252]:int。这两个数不是地址:262,253 × 4 = 1,049,012。反编译器把偏移量除以访问宽度,当成数组下标显示。拿它的输出去对 WAT 或崩溃偏移时,记得乘回来。

对比两个构建

v2 我们模拟了现实项目里最常见的“手滑改 ABI”:crc32 加了一个 seed: u32 参数,dealloc 改名成 free_buf,又新增了一个 abi_version() 导出。新构建 17,671 字节。

compare-wasm-interfaces 只接受一个参数:内联 JSON 或一个文件,里面放左右两条完整 Evidence,外加必填的 scope:

npx rea inspect-wasm-artifact v1/crcmod.wasm --format json > v1.json
npx 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"}))'
npx rea compare-wasm-interfaces cmp.json --format json

对比耗时 0.4 秒,结果整理成每条一行:

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 根本没有无符号整数类型,所以源码里把有符号改成无符号,这里是看不出来的。工具自己的 limitations 里也写了:“Exact declared interface equality is not runtime/ABI compatibility”。

输入必须是原封不动的 Evidence。我们试过改掉其中一条的 artifact.bytes,也试过为了瘦身删掉 wat 字段,结果都一样:退出码 1,报 Evidence semantic identifier does not match its record。evidence_id 是按内容算出来的,所以 CLI 和 MCP 给 v1 构建算出的 ID 完全相同(ev_30646050…)。把默认的 toon 格式输出存下来再喂进去,则报 expected: object。结论:保存时一律用 --format json,并且不要加 --filter-output。

优化器有没有动接口?

用 binaryen 133 跑 wasm-opt -Oz --strip-debug --strip-producers,v2 从 17,671 字节缩到 11,553 字节,函数从 56 个变成 33 个,耗时 0.04 秒。拿优化前后两条记录对比,结果只有 unchanged: 8。去掉 name 段之后,wat_form 从 (func $crc32) 变成了 (func 3),但对比是按名称、种类和签名来的,所以不受影响。发版前检查要的正是这个效果。

关联 JS 胶水代码

--glue_paths 指定要扫描的 JavaScript 文件,REA 会找出其中以 .wasm 结尾的字符串字面量;--candidate_paths 指定这些字面量可能对应的本地模块。AssemblyScript 编译器正好能生成一份真实的加载器:

npx asc assembly/index.ts --outFile build/release.wasm --bindings esm -O3 --noAssert
npx rea inspect-wasm-artifact build/release.wasm --glue_paths build/release.js --format json

REA 在 release.js 第 67 行第 11 列找到了 new URL("release.wasm", import.meta.url),标为 local-path-candidate。顺带一提,这个 9,285 字节的模块即使加了 --noAssert 还是导入了 env.abort,string 参数在接口里则是 i32 指针。

为了看全几种关联状态,我们又手写了一个四行的加载器。目标是 v1,候选是 v2 和上面的 AssemblyScript 模块:

3 "/static/release.wasm"                         basename-candidates  [as/build/release.wasm]
4 "https://cdn.example.com/build/crcmod.wasm?v=3" basename-candidates  [v1/crcmod.wasm, v2/crcmod.wasm]
5 "./v2/crcmod.wasm"                              local-path-candidate [v2/crcmod.wasm]
6 "crcmod.wasm"   (from base + "crcmod.wasm")     basename-candidates  [v1/crcmod.wasm, v2/crcmod.wasm]

第 6 行说明这个扫描有多“字面”:源码是 base + "crcmod.wasm",base = "./v1/",但 REA 不求值表达式,两个构建都按文件名匹配上了。所以它只能告诉你该去哪儿看,不能证明运行时加载的是哪个文件。

我们踩到的坑: 第一次写的是 --candidate_paths v2/crcmod.wasm as/build/release.wasm。命令没报错,但 parameters.candidate_paths 里只有第一个路径,第 3 行也成了 unresolved。第二个值被悄悄丢掉了,多出来的位置参数也一样被无视。正确写法是每个值重复一次参数(--candidate_paths a.wasm --candidate_paths b.wasm)。用逗号隔开会被当成一个路径,报 ENOENT。

C 模块,以及丢失的符号信息

Apple 自带的 clang 21 有 wasm32 后端,却没有 WebAssembly 链接器。clang --target=wasm32 -nostdlib ... 编译完就卡在链接,报 clang: error: unable to execute command: posix_spawn failed: No such file or directory。好在 rustup 自带了一个,于是先 -c 编译,再手动链接:

clang --target=wasm32 -O2 -nostdlib -c b64.c -o b64.o
~/.rustup/toolchains/stable-aarch64-apple-darwin/lib/rustlib/aarch64-apple-darwin/bin/gcc-ld/wasm-ld \
  --no-entry --allow-undefined -o b64.wasm b64.o      # LLD 22.1.2

产物 844 字节,导入 host.trace,导出 input_ptr、output_ptr 和 b64_encode(i32) -> i32。源码里的判断是 len < 0 || len > 3072,clang 把两个条件合成了一次无符号比较,WAT 里是 i32.const 3072 加 i32.gt_u。wasm-decompile 却输出 export function b64_encode(a:int):int,接着是 if (a > 3072) goto B_a;,看起来负数长度能混过去,实际上不能。反编译结果和 WAT 在边界检查上对不上时,以 WAT 为准。

大一点的模块:sql.js

sql.js 1.13.0 npm 包里的 sql-wasm.wasm 有 659,806 字节,1,883 个函数,38 个导入,53 个导出。Emscripten 把接口名都压缩了:所有导入都来自模块 "a",名字是 "a"、"b" 这种,导出则是 "M"、"N"、"O"……binaryen 的 wasm-opt --metrics 统计的三个数字和 WABT 完全一致。不过第一次跑就报了 memory.fill operations require bulk memory [--enable-bulk-memory-opt],原因是这个模块没有 target_features 段。给 wasm-opt 加上 -all 就好,道理和 REA 给 WABT 加 --enable-all 一样。

REA 处理它用了 1.6 秒。CLI 输出的记录有 15.6 MB,光 WAT 就有 14,817,970 个字符(288,167 行),--token-count 报 1,997,058 个 token。胶水扫描在压缩过的 sql-wasm.js 第 157 行找到两处 "sql-wasm.wasm",都对应到了本地文件。

通过 MCP 做同样的事

MCP 服务器(rea --mcp)提供 inspect_wasm_artifact 和 compare_wasm_interfaces,分页查看走 inspect_analysis_view。对 sql.js 直接调 inspect_wasm_artifact 会失败:

code: resource_constraint
message: The operation completed, but its complete MCP response exceeds the stdio response budget. The analysis result was not truncated. ...

正确顺序是先要摘要,再按页读:

{"name": "inspect_wasm_artifact",
 "arguments": {"path": "/abs/sql-wasm.wasm", "detail": "summary"}}

{"name": "inspect_analysis_view",
 "arguments": {"source": {"kind": "retained-evidence", "evidence_id": "<normalized_result.parent_evidence_id>"},
               "view": {"kind": "wasm", "facet": "wat", "offset": 0, "limit": 20000}}}

摘要 1.1 秒返回,约 11 KB,里面有校验结果、各项计数、WAT 的 SHA-256 和 code_units: 14817970,但不含 WAT 正文。读一页 20,000 个码元的 WAT 用了 17 毫秒。按这个页大小,整份 WAT 要 741 页,所以实际用法是让 agent 读 imports、exports 这两个 facet,WAT 则在本地用 WABT 搜。compare_wasm_interfaces 在同一个会话里可以两边都传 {"kind": "retained-evidence", "evidence_id": ...}。我们用 scope: "exports" 对比两个 Rust 构建,结果和 CLI 一致。

实测确认的限制

  • 超过 8 MiB 的文件在调用 WABT 之前就会被拒(Selected artifact exceeds 8388608 bytes);符号链接也会被拒(Expected a regular file without a symlink)。扩展名无所谓,改名成 crcmod.bin 照样通过校验。
  • 字节有问题时,返回的是带 WABT 原始报错的部分记录,不会瞎猜。比如截断的文件会得到 0000115: error: invalid section size: extends past end。
  • 每次调用 WABT 限时 30 秒,输出上限 32 MiB。sql.js 用掉了大约 15 MB。
  • 只对比核心模块,不支持组件(component)。全程不执行、不联网(runtime_execution: not-performed)。
  • 我们只测了 macOS arm64。包内说明里 WABT 的验证也是在这个平台上做的,其他平台需要自己确认。

接下来可以看