JS 逆向中的 WebAssembly(WASM)特征识别与逆向
逆向遇到 WebAssembly(WASM)时,第一选择是把 .wasm 当成一个编译好的 .so/.dll,在 Node 里加载并黑盒调用它的导出函数,而不是逐行把字节码翻译回 JS。WASM 是一种独立的二进制字节码格式(Binary Bytecode Format),由 C/C++/Rust 等语言编译而来,浏览器原生执行,加载快、体积小,也因此成了把签名、加密、风控逻辑藏起来的常见手段——抖音 a_bogus/bdms 这类把算法塞进 WASM 甚至字节码虚拟机(Bytecode VM)的方案,目的就是对抗"扣代码"。 什么时候该黑盒调用而非硬扣
官方文档:
- WebAssembly 规范主站:https://webassembly.org/
- MDN WebAssembly JavaScript 接口:https://developer.mozilla.org/en-US/docs/WebAssembly
- Emscripten 工具链文档:https://emscripten.org/
- WABT(WebAssembly Binary Toolkit):https://github.com/WebAssembly/wabt
适用版本:WebAssembly Core Spec 3.0(含异常处理 Tag、bulk-memory)、Emscripten 6.x、WABT/Binaryen/wasm-tools 当前发行版。
核实日期:2026-06-07。
概述
逆向遇到 WebAssembly(WASM)时,第一选择是把 .wasm 当成一个编译好的 .so/.dll,在 Node 里加载并黑盒调用它的导出函数,而不是逐行把字节码翻译回 JS。WASM 是一种独立的二进制字节码格式(Binary Bytecode Format),由 C/C++/Rust 等语言编译而来,浏览器原生执行,加载快、体积小,也因此成了把签名、加密、风控逻辑藏起来的常见手段——抖音 a_bogus/bdms 这类把算法塞进 WASM 甚至字节码虚拟机(Bytecode VM)的方案,目的就是对抗"扣代码"。
什么时候该黑盒调用而非硬扣算法:当 WASM 内部经过编译器优化、自定义 VM 或混淆,逐行还原成本极高且极易出错时。只要能在受控环境里复现"相同输入产生相同输出",就完全满足签名/加密需求,无需理解算法内部。反汇编一个 VM 型 WASM 只会看到解释器循环,看不到算法本身,这正是它的反逆向设计——把整个 VM 连同字节码一起当黑盒调用,才是正解。
什么时候才需要真正读懂逻辑:当目标是判别用了哪种已知算法(MD5/AES/SM3 等)、需要在纯算法层面复现、或要修改逻辑时。此时通过魔法常量(Magic Constant)和导出函数名快速判定算法族,再决定是否值得手翻。本文按"识别 → 工具 → 调试 → 黑盒复现 → 算法还原"的顺序展开,目标读者是有 JS 逆向基础但没系统接触过 WASM 的工程师。
WASM 二进制结构
模块头(Module Preamble)
每个 .wasm 文件以 8 字节固定头开始,这是最强也最简单的识别特征。
| 偏移 | 字节 | 含义 |
|---|---|---|
0x00 |
0x00 0x61 0x73 0x6D |
魔数(Magic Number),即 ASCII \0asm(\0、a、s、m) |
0x04 |
0x01 0x00 0x00 0x00 |
版本(Version),小端 u32 = 1(自 MVP 至今仍为 1) |
判别要点:任何文件前 8 字节是 00 61 73 6D 01 00 00 00,100% 是 WASM 模块。0x61 0x73 0x6D 三个字节正好是 asm 的 ASCII。十六进制速记:\0asm = 00 61 73 6d,后跟版本 01 00 00 00。
Section 编码格式
紧跟模块头之后是一系列段(Section),每个段的编码格式如下:
section ::= section_id : byte # 1 字节段 ID
size : u32 # LEB128 变长,段内容字节数
content : ... # size 字节
size 用 LEB128(无符号变长整数编码,Little Endian Base 128)表示,这是 WASM 二进制里随处可见的整数编码方式——读 wat 或 hex 时遇到的多字节整数大多是 LEB128。
Section 速查表
WebAssembly 3.0 规范定义的段 ID 如下。除自定义段(ID 0)外,每个标准段至多出现一次,且必须按规范规定的固定顺序排列——该顺序并非简单的 ID 升序(Tag 段紧跟 Memory 段、DataCount 段位于 Code 段之前,见表后规范顺序);自定义段可插在任意两个标准段之间,且可多次出现。
| ID | 段名称 | 作用 | 逆向关注点 |
|---|---|---|---|
| 0 | Custom(自定义) | 调试信息/第三方扩展,被 WASM 语义忽略;可出现在任意位置、可多次 | name 子段含函数名/局部变量名(调试符号);sourceMappingURL 子段指向 source map;逆向第一目标 |
| 1 | Type(类型) | 函数签名(递归类型)声明列表 | 看导出函数的参数/返回值类型 |
| 2 | Import(导入) | 从宿主(JS)导入 function/table/memory/global | 看模块依赖 JS 提供哪些环境函数,是补垫片的线索 |
| 3 | Function(函数) | 函数到类型索引的映射(仅声明,函数体在 Code 段) | 与 Code 段配合阅读 |
| 4 | Table(表) | 表定义,通常存函数引用,用于间接调用 | 间接调用 / dynCall 相关 |
| 5 | Memory(内存) | 线性内存(Linear Memory)定义,初始/最大页数,1 页 = 64 KiB | 内存大小,构造 importObject 时参考 |
| 6 | Global(全局) | 全局变量定义 | 常量、状态 |
| 7 | Export(导出) | 导出 function/table/memory/global 给 JS | 逆向核心:导出名如 encrypt/sign/_md5 直接暴露逻辑 |
| 8 | Start(启动) | 可选的启动函数,实例化后自动执行 | 初始化逻辑 |
| 9 | Element(元素) | 表初始化数据段 | 填充函数表 |
| 10 | Code(代码) | 函数体(局部变量 + 指令字节码) | 算法逻辑所在,反编译主战场 |
| 11 | Data(数据) | 初始化线性内存的字节 | 藏 S-Box / 魔法常量 / 字符串的地方 |
| 12 | DataCount(数据计数) | 可选 u32,data 段数量;启用 bulk-memory 时出现,必须在 Code 段之前(单遍验证需要) | bulk memory 特征 |
| 13 | Tag(标签) | 异常处理 tag 定义(exception handling 提案,已并入 3.0) | 较新模块才有 |
规范要求的固定段顺序(注意不是 ID 升序):Type(1) → Import(2) → Function(3) → Table(4) → Memory(5) → Tag(13) → Global(6) → Export(7) → Start(8) → Element(9) → DataCount(12) → Code(10) → Data(11)。Tag 段插在 Memory 与 Global 之间,DataCount 段排在 Code 段之前(单遍验证需要先知道 data 段数量)。
说明:很多教学材料只列 0–11 共 12 类段(custom 到 datacount)。规范当前实为 0–13 共 14 个段 ID,其中 Tag(13) 是异常处理扩展段,较新工具链产物才出现。Function 段(声明)与 Code 段(函数体)拆分是为了支持并行编译。
在网页中识别用到了 WASM
判断一个页面是否用了 WASM,从网络层、JS API、内联编码三个维度入手,任一命中即可确认。
网络层特征
| 特征 | 表现 | 说明 |
|---|---|---|
| 资源请求 | DevTools Network 出现 .wasm 后缀请求(如 app.wasm),有时无后缀 |
用 Type 列或文件名过滤 |
| MIME 类型 | 响应头 Content-Type: application/wasm |
instantiateStreaming/compileStreaming 强制要求服务器返回该 MIME,否则流式编译报错,是强信号 |
| 响应体头部 | 二进制响应以 00 61 73 6D 01 00 00 00 开头 |
直接抓包看前 8 字节 |
JS API 调用特征
页面 JS 中出现以下符号即说明用到了 WASM,全文搜索这些关键字定位入口。
// app.js —— WebAssembly 命名空间下的各类符号
// 编译类
WebAssembly.compile(bytes);
WebAssembly.compileStreaming(fetch("x.wasm"));
WebAssembly.validate(bytes); // 返回 true 即合法 WASM
// 实例化类(最常见)
WebAssembly.instantiate(bytesOrModule, importObject);
WebAssembly.instantiateStreaming(fetch("x.wasm"), importObject);
// 构造器 / 命名空间
new WebAssembly.Module(bytes);
new WebAssembly.Instance(module, importObject);
new WebAssembly.Memory({ initial: 256, maximum: 32768 });
new WebAssembly.Table({ initial: 8, element: "anyfunc" });
new WebAssembly.Global({ value: "i32", mutable: true }, 0);
// 错误类型(出现在 catch / 报错栈里)
WebAssembly.CompileError; // 解码 / 校验阶段
WebAssembly.LinkError; // 实例化链接阶段
WebAssembly.RuntimeError; // 运行期 trap
WebAssembly 是命名空间对象(类似 Math/Intl),不是构造器:
// 正确:调用命名空间上的方法
const { instance } = await WebAssembly.instantiate(bytes, importObject);
// 错误:WebAssembly 不是构造器,不能 new
const w = new WebAssembly(); // 错误:WebAssembly 是命名空间对象,不可实例化
最典型的加载链(流式)与兼容写法(手动 fetch → arrayBuffer → instantiate,老代码常见):
// 流式:要求服务器返回 application/wasm
WebAssembly.instantiateStreaming(fetch("simple.wasm"), importObject)
.then(obj => obj.instance.exports.exported_func());
// 兼容写法:先取 ArrayBuffer 再实例化(适配旧浏览器 / MIME 不对的服务器)
fetch("module.wasm")
.then(r => r.arrayBuffer())
.then(bytes => WebAssembly.instantiate(bytes, importObject))
.then(results => {
const exports = results.instance.exports; // 访问导出
});
Base64 内联 WASM 的识别
为减少请求或反爬,WASM 常被 base64 内联进 JS。关键判别常量:base64 字符串以 AGFzbQ 开头,正是 00 61 73 6D(\0asm)的 base64 编码;完整头 00 61 73 6D 01 00 00 00 的 base64 是 AGFzbQEAAAA。在 JS 里全文搜 AGFzbQ 能直接定位内联 WASM。
// glue.js —— 内联 base64 WASM 的典型解码流程
// 特征:一段超长 base64 字符串,以 AGFzbQ 开头
var wasmBinary = "AGFzbQEAAAA..."; // "AGFzbQ" = "\0asm" 的 base64
var binary = atob(wasmBinary);
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
WebAssembly.instantiate(bytes, importObject);
也可能是 data:application/wasm;base64,AGFzbQ... 形式的 data URI,或十六进制数组 [0,97,115,109,1,0,0,0,...](开头是十进制的 0 97 115 109)。
其他线索
| 线索 | 含义 |
|---|---|
Module 全局对象 |
Emscripten 默认配置对象 |
.js 胶水文件 + 同名 .wasm 成对出现 |
如 a.out.js + a.out.wasm、xxx.js + xxx.wasm |
Sources 面板出现 wasm:// 协议虚拟文件 |
DevTools 把二进制反汇编为 wat 直接展示 |
Emscripten 产物特征识别
Emscripten 是 C/C++ 编译到 WASM 的主流工具链,生成胶水 JS(Glue Code) + .wasm 两部分。胶水 JS 里有一批高度固定、硬编码的运行时符号,看到它们基本可断定"这是 Emscripten 编译的 WASM"。这是实战中最常遇到的形态,也是本文重点。

图解读:调试器里出现 HEAPU8/HEAP32 等视图、以及 t.ccall("cmd_clientDecrypt", "number", [...]) 这种调用,就是 Emscripten 编译 WASM 的铁证,cmd_clientDecrypt 是 WASM 导出的解密函数。
HEAP 类型化数组视图(最强铁证)
Emscripten 把 WASM 线性内存(一块 ArrayBuffer)用多种类型化数组(TypedArray)视图暴露,命名极其固定。这 8 个名字是硬编码的,手写或别的工具链不会恰好用这套命名,全文搜 HEAPU8 命中即 Emscripten。
| 视图名 | 类型 | 元素宽度 | 说明 |
|---|---|---|---|
HEAP8 |
Int8Array |
1 字节 | 8 位有符号 |
HEAPU8 |
Uint8Array |
1 字节 | 8 位无符号,操作字节最常用 |
HEAP16 |
Int16Array |
2 字节 | 16 位有符号 |
HEAPU16 |
Uint16Array |
2 字节 | 16 位无符号 |
HEAP32 |
Int32Array |
4 字节 | 32 位有符号 |
HEAPU32 |
Uint32Array |
4 字节 | 32 位无符号 |
HEAPF32 |
Float32Array |
4 字节 | 32 位浮点 |
HEAPF64 |
Float64Array |
8 字节 | 64 位浮点 |
逆向用途:读写 WASM 内存就靠它们。读地址 ptr 处一个字节 = HEAPU8[ptr];读 32 位整数 = HEAP32[ptr >> 2](>> 2 是因为 Int32Array 索引以 4 字节为单位)。
内存与栈管理符号
| 符号 | 签名 | 说明 |
|---|---|---|
_malloc |
_malloc(size) -> ptr |
在 WASM 堆分配内存,返回字节偏移(指针) |
_free |
_free(ptr) |
释放 |
stackAlloc |
stackAlloc(size) -> ptr |
在 WASM 栈上分配临时缓冲 |
stackSave |
stackSave() -> sp |
保存栈指针 |
stackRestore |
stackRestore(sp) |
回退栈指针,一次性释放 stackAlloc 的分配 |
下划线前缀 _ 代表对应的 C 函数(C 符号 malloc 编译为 JS 端 _malloc)。新版 Emscripten 默认不再导出 _malloc/_free,需 -sEXPORTED_FUNCTIONS=['_malloc','_free'];但 stackAlloc/stackSave/stackRestore 几乎总在胶水里。
字符串转换辅助函数(铁证)
JS 字符串与 WASM 内存(UTF-8)互转的工具,命名固定。
| 函数 | 签名 | 说明 |
|---|---|---|
UTF8ToString |
UTF8ToString(ptr, maxBytesToRead?, ignoreNul?) |
从堆里读 NUL 结尾的 UTF8 串转 JS String |
UTF8ArrayToString |
UTF8ArrayToString(heap, ptr) |
从指定 heap 数组读 UTF8 串 |
stringToUTF8 |
stringToUTF8(str, outPtr, maxBytesToWrite) |
JS String 写入堆(含 NUL);返回实际写入字节数(不含 NUL),可据此推进指针,常与 lengthBytesUTF8 配合 |
stringToNewUTF8 |
stringToNewUTF8(str) -> ptr |
分配并写入,返回指针需 _free |
lengthBytesUTF8 |
lengthBytesUTF8(str) -> number |
计算 UTF8 字节数(不含 NUL,分配前用) |
逆向用途:当 C 函数参数/返回是字符串,必经这些函数。Hook UTF8ToString 能拿到 WASM 返回的明文字符串;Hook stringToUTF8 能截获传入 WASM 的明文(如密码、待加密原文)。
调用桥接 ccall / cwrap
// ccall: 调一次 C 函数
// 参数依次为:函数名、返回类型、参数类型数组、实参数组
Module.ccall("func_name", "number", ["string", "number"], ["hello", 42]);
// cwrap: 返回一个可复用的 JS 包装函数
var func = Module.cwrap("func_name", "number", ["string", "number"]);
func("hello", 42);
ccall/cwrap 需通过 -sEXPORTED_RUNTIME_METHODS=['ccall','cwrap'] 导出后才能在 Module. 上访问。逆向时它们是"JS 调 WASM"的入口,Hook Module.ccall 的第一个参数能枚举被调用的所有 C 函数名。
WASM 导出对象 / asm 别名(关键演变)
历史上 Emscripten 把 WASM 实例的 exports 命名为 asm(沿用 asm.js 时代命名),自 Emscripten 3.1.43(2023 年,issue #19792)起改名为 wasmExports。
// 老版本:exports 别名为 asm
var asm = output.instance.exports;
asm["_main"]();
asm["functionName"];
// 新版本(Emscripten 3.1.43+):改名 wasmExports
var wasmExports = wasmInstance.exports;
wasmExports["functionName"];
判别:胶水里出现 var asm = 或 wasmExports =,且后续大量 asm["..."] / wasmExports["..."] 形式的下标访问。这是真正的 WASM 函数表,所有导出函数都挂在这里。
动态调用与构造器
| 符号 | 说明 |
|---|---|
dynCall_<sig> |
通过函数表做间接调用,<sig> 是签名串:vi=void(i32)、iii=i32(i32,i32) 等 |
dynCall('vi', funcPtr, [arg]) |
通用动态调用入口 |
__wasm_call_ctors |
调用全局构造器(C++ 全局对象初始化),常用 createExportWrapper("__wasm_call_ctors") 包装 |
dynCall_xxx 和 __wasm_call_ctors 都是 Emscripten 专有命名。
Emscripten 铁证清单
任意命中 ≥2 项基本可确认是 Emscripten 产物:
[ ] HEAP8 / HEAPU8 / HEAP16 / HEAPU16 / HEAP32 / HEAPU32 / HEAPF32 / HEAPF64
[ ] var asm = ... 或 wasmExports = ... 且大量 asm["xxx"] / wasmExports["xxx"]
[ ] _malloc / _free / stackAlloc / stackSave / stackRestore
[ ] ccall / cwrap
[ ] UTF8ToString / UTF8ArrayToString / stringToUTF8 / lengthBytesUTF8
[ ] dynCall_ 开头的函数 / __wasm_call_ctors
[ ] Module 全局对象 + EXPORTED_RUNTIME_METHODS / EXPORTED_FUNCTIONS 痕迹
[ ] 成对的 xxx.js(胶水) + xxx.wasm
[ ] 字符串 "memory grew"、"wasm" 或 emscripten 报错文案
这些符号是 Emscripten 运行时模板写死的,不属于 WASM 标准 API(标准只有 WebAssembly.*)。它们的存在说明 .wasm 不是手写/Rust wasm-bindgen/AssemblyScript 产物,而是 Emscripten(C/C++)产物。
区分其他工具链
| 工具链 | 旁证符号 |
|---|---|
| Emscripten(C/C++) | HEAP* 视图、ccall/cwrap、_malloc/UTF8ToString |
| Rust + wasm-bindgen | 胶水里有 wasm_bindgen、__wbindgen_ 前缀、__wbg_ 前缀函数 |
| AssemblyScript | 导出常含 __new/__pin/__collect/__rtti_base,无 HEAP* 命名 |
| Go(TinyGo/标准 Go) | 有 go.importObject、runtime.wasmExit、syscall/js |
asm.js 与 WASM 的区分
asm.js 是 WASM 的前身(一种 JS 严格子集),WASM 标准化后取代了它。Emscripten 早年用 asm 命名 WASM exports 就是这段历史的遗留。实战区分:
| 维度 | asm.js | WebAssembly |
|---|---|---|
| 本质 | JavaScript 严格子集(文本 JS 源码) | 独立的二进制字节码格式 |
| 标志 | 函数体顶部 "use asm"; 指令 |
文件头 \0asm 魔数 |
| 体积 | 冗长,按 JS 解析慢 | 紧凑,解析快 |
| 现状 | 已被取代,仅作旧浏览器 fallback | 主流 |
看到 .wasm 文件 / \0asm 头 / WebAssembly.* API 就是 WASM;看到 JS 函数体里有 "use asm"; 加大量 x = x | 0(强制整型)、+x(强制浮点)就是 asm.js。注意 asm.js 也用 HEAP32 命名(与 Emscripten 同源),区别在于 asm.js 是纯文本 JS、有 "use asm"、无 .wasm 文件。
WASM 逆向工具链
核心目标:把不可读的 .wasm 二进制转为可读 .wat(文本格式)→ 类 C 伪代码 → 在动态调试器里跑起来。
WABT(WebAssembly Binary Toolkit)
官方仓库 WebAssembly/wabt。定位是 1:1 高保真往返,不做优化、不改指令,最贴合规范,是看 wat、看段、反汇编、剥符号的首选。
# 安装
brew install wabt # macOS / Linux (Homebrew)
sudo apt install wabt # Debian / Ubuntu
# Windows / 通用:GitHub Releases 下载预编译二进制
| 工具 | 用途 | 典型命令 |
|---|---|---|
wasm2wat |
二进制转文本(.wat),逆向第一步 | wasm2wat app.wasm -o app.wat |
wat2wasm |
文本转二进制(改完 wat 后回编验证) | wat2wasm app.wat -o app.wasm |
wasm-decompile |
反编译为类 C 伪代码,可读性远高于 wat | wasm-decompile app.wasm -o app.dcmp |
wasm-objdump |
看段/反汇编/十六进制,类 UNIX objdump | wasm-objdump -x app.wasm |
wasm-strip |
删除自定义段(如 name 段)减小体积 | wasm-strip app.wasm |
wasm-validate |
校验二进制是否合法 | wasm-validate app.wasm |
wasm-interp |
栈式解释器,直接跑 wasm | wasm-interp app.wasm --run-all-exports |
wasm2c |
wasm 转 C 源码 + 头文件 | wasm2c app.wasm -o app.c |
wasm-objdump 选项表(逆向最常用):
| 选项 | 长选项 | 含义 |
|---|---|---|
-x |
--details |
显示各段详情(imports/exports/functions/globals/memory 等结构) |
-d |
--disassemble |
反汇编函数体 |
-h |
--headers |
打印段头(每段的偏移和大小) |
-s |
--full-contents |
打印原始段内容(十六进制),找 Data 段常量用 |
-j |
--section=SECTION |
只看某一段 |
-r |
--reloc |
反汇编时内联显示重定位 |
| 无 | --section-offsets |
用段偏移而非文件偏移 |
常用组合:wasm-objdump -x app.wasm 先看全貌结构,再 -d 反汇编关键函数。wasm-decompile 的伪代码(V8 团队出品)比 wat 更接近源码语义,是无源码场景下最快的读懂逻辑手段。
Binaryen
官方仓库 WebAssembly/binaryen。优化器,逆向价值在 wasm2js(转回 JS 调试)和 wasm-dis(反汇编)。
npm install binaryen # Node 版命令行工具
brew install binaryen # macOS
| 工具 | 用途 | 典型命令 |
|---|---|---|
wasm-opt |
wasm 转 wasm 优化器,跑各种 pass | wasm-opt input.wasm -O3 -o out.wasm |
wasm2js |
wasm 转 JS,便于在 Node 里跑/调试/打断点 | wasm2js app.wasm -o app.mjs |
wasm-dis |
二进制转文本(经 Binaryen IR,命名更可读) | wasm-dis input.wasm -o output.wat |
wasm-as |
文本转二进制(汇编) | wasm-as in.wat -o out.wasm |
wasm-merge |
合并多个 wasm,自动连接 import 与 export | wasm-merge a.wasm a b.wasm b -o m.wasm |
注意:wasm-dis 与 WABT 的 wasm2wat 区别——前者经过 IR 不保证 1:1 往返,但命名更可读;要高保真往返用 WABT。
bytecodealliance/wasm-tools(Rust)
官方仓库 bytecodealliance/wasm-tools。Rust 写的低层 wasm 操控 CLI + 库,对 Component Model / WIT 支持最好,独有符号反修饰能力。
cargo install --locked wasm-tools # 源码编译
cargo binstall wasm-tools # 预编译产物
| 子命令 | 用途 |
|---|---|
print |
二进制转文本(约等于 wasm2wat) |
parse |
文本转二进制(约等于 wat2wasm) |
dump |
打印二进制格式的详细调试信息(按字节注解) |
objdump |
打印段头调试信息 |
demangle |
反修饰 name 段里的 Rust/C++ 符号名(WABT 无此能力) |
strip |
删除自定义段 |
validate |
校验 wasm |
addr2line |
用 DWARF 把 wasm 偏移翻译成 文件:行号 |
component |
操作组件(new/wit/embed/unbundle) |
metadata |
查看/添加 name 与 producer 元数据 |
官方示例管道:
# 先反修饰符号名,再剥自定义段,最后看段头
wasm-tools demangle foo.wasm | wasm-tools strip | wasm-tools objdump
运行时(动态分析)
| 运行时 | 安装 | 黑盒调用命令 |
|---|---|---|
| wasmtime | cargo install wasmtime-cli / curl https://wasmtime.dev/install.sh -sSf | bash |
wasmtime run --invoke main app.wasm arg1 arg2 |
| wasmer | cargo install wasmer-cli / curl https://get.wasmer.io -sSfL | sh |
wasmer run ./app.wasm |
两者默认安全沙箱(secure by default),不给文件/网络权限,需显式授权(wasmtime 用 --dir,wasmer 用 --dir/--net/--enable-all)。wasmtime explore foo.wasm 生成 HTML 并排展示 wat 与机器码,看编译映射很有用。
反编译器 / 专业分析器
| 工具 | 类型 | 说明 |
|---|---|---|
| JEB Decompiler(PNF Software) | 商业 | 业界最佳 WASM 反编译,输出类 C 代码,JEB 5.36 已支持 WebAssembly 3.0;有在线 demo |
| Ghidra WASM 插件 | 开源 | nneonneo/ghidra-wasm-plugin,反汇编 + 反编译 + 交叉引用,可脚本化 |
wasm-decompile |
开源 | WABT 自带,伪代码反编译,无需大型框架,最轻量 |
选型表
| 需求 | 推荐工具 |
|---|---|
| 看二进制对应的 wat | wasm2wat(高保真)/ wasm-dis(可读)/ wasm-tools print / Chrome DevTools |
| 看类 C 伪代码(快速理解逻辑) | wasm-decompile(轻量) |
| 最强反编译(无源码逆向) | JEB(商业)/ Ghidra WASM 插件(免费) |
| 看段结构 / imports / exports | wasm-objdump -x / wasm-tools objdump |
| 反汇编函数体 | wasm-objdump -d |
| 看原始字节(hex) | wasm-objdump -s / wasm-tools dump |
| 反修饰 Rust/C++ 符号名 | wasm-tools demangle |
| 把 wasm 转成 JS 来调试 | wasm2js |
| 直接运行 / 黑盒探测导出函数 | wasmtime run --invoke / wasmer run / wasm-interp |
| 源码级断点调试(带 DWARF) | Chrome DevTools(-g 编译) |
| 偏移转源码行号 | wasm-tools addr2line |
实战推荐顺序:wasm-decompile(懂逻辑)→ wasm2wat / wasm-objdump -x -d(看细节)→ 需要时上 JEB/Ghidra 做深度反编译 → 动态阶段用 Chrome DevTools(带 -g)或 wasmtime --invoke 验证。
动态调试
Chrome DevTools 调试 wat 与查看线性内存
页面加载 .wasm 时,即便没有调试信息,Sources 面板也会把二进制即时反汇编为 WAT 文本展示,整个模块合并显示在单个文件里,使用 wasm:// 前缀标识。
| 操作 | 方法 |
|---|---|
| 下断点 | 在反汇编视图里点代码左侧的地址列(offset 列,形如 0x0042),刷新页面触发 |
| 单步 | 命中后用 Step over / into / out 逐条 wasm 指令执行 |
| 看调用栈 | 右侧 Debugger 面板的 Call Stack;无 DWARF 时帧名为 $funcN 或 name 段名字 |
| 看作用域 | Scope 区展示 Local(局部变量)、Global(全局)、Stack(操作数栈)、Module(含 memory) |
查看线性内存用 Memory Inspector(菜单 More Tools 打开,或在 Scope 里右键 WebAssembly.Memory/ArrayBuffer 选 Reveal in Memory Inspector)。它把内存按 Int8/Uint8、Int16/Uint16、Int32/Uint32、Pointer 32/64-bit、Float 32/64-bit 多类型解释,工具栏可切大小端(WASM 线性内存是小端),地址输入框输入 hex 地址(如 0x00000008)跳转。
DWARF 与 source map
若编译时带 -g,Emscripten 会在 wasm 内嵌入 DWARF 调试信息。
# 内嵌 DWARF,可在 DevTools 调原始 C/C++ 源码
emcc -g source.cc -o app.html
# 分离调试信息(减小线上体积)
emcc -g source.cc -o app.html -gseparate-dwarf=debug.wasm -s SEPARATE_DWARF_URL=path/URL
# 生成轻量 source map(把 wat 行映回源码行)
emcc -gsource-map source.cc -o app.html
安装 Chrome 扩展 C/C++ DevTools Support (DWARF)(扩展 ID pdcpmagijalfljmkmjngeonclgbbannb),Chrome 114+ 无需开实验开关即可直接调原始 C/C++ 源码、源码行下断点、悬停看变量。
JS 层 Hook importObject 与 instantiate
抓取实例、exports、memory 的核心 Hook,必须在目标 wasm 加载之前注入(油猴 @run-at document-start,或扩展 content script "run_at": "document_start" 注入主世界)。
// hook.js —— 注入到页面主世界,必须早于 wasm 加载
(function () {
const origInstantiate = WebAssembly.instantiate;
const origInstantiateStreaming = WebAssembly.instantiateStreaming;
window.__wasm = { instances: [], memory: null, exports: null };
function captureResult(result) {
// result 可能是 WebAssembly.Instance,或 { module, instance }
const instance = result.instance || result;
if (instance && instance.exports) {
window.__wasm.exports = instance.exports;
window.__wasm.instances.push(instance);
if (instance.exports.memory instanceof WebAssembly.Memory) {
window.__wasm.memory = instance.exports.memory;
}
console.log("[wasm-hook] exports:", Object.keys(instance.exports));
}
return result;
}
// 包裹 importObject,拦截 env 里所有导入函数并打印入参/返回
function wrapImportObject(importObject) {
if (!importObject) return importObject;
const out = {};
for (const ns of Object.keys(importObject)) { // 通常是 "env"、"wasi_snapshot_preview1"
const mod = importObject[ns];
out[ns] = {};
for (const name of Object.keys(mod)) {
const val = mod[name];
if (typeof val === "function") {
out[ns][name] = function (...args) {
console.log(`[import] ${ns}.${name}(`, args, ")");
const ret = val.apply(this, args);
return ret;
};
} else {
out[ns][name] = val; // Memory/Table/Global 原样传递
}
}
}
return out;
}
WebAssembly.instantiate = function (bufferOrModule, importObject, ...rest) {
return origInstantiate
.call(this, bufferOrModule, wrapImportObject(importObject), ...rest)
.then(captureResult);
};
// 注意:streaming 走 Response 流,无法改字节,只能包 importObject 和抓 exports
WebAssembly.instantiateStreaming = function (source, importObject, ...rest) {
return origInstantiateStreaming
.call(this, source, wrapImportObject(importObject), ...rest)
.then(captureResult);
};
})();
读 WASM 内存里的 C 字符串(注意每次重建视图,见常见陷阱):
// 从 window.__wasm.memory 读以 NUL 结尾的字符串
function wasmReadCString(ptr, maxLen = 4096) {
// memory.buffer 是 ArrayBuffer;grow 后旧 buffer 会 detach,所以每次重新取
const u8 = new Uint8Array(window.__wasm.memory.buffer);
let end = ptr;
while (end < ptr + maxLen && u8[end] !== 0) end++;
return new TextDecoder("utf-8").decode(u8.subarray(ptr, end));
}
实战:抽取 wasm 与在 Node 黑盒调用
从网站抽取 .wasm
方法 A 最简单:F12 → Network 面板,过滤 wasm,找到目标请求右键 Save as,落盘为 sign.wasm。
方法 B 最可靠(适用于运行时解密/拼接的 wasm):在页面最早期 Hook 编译入口,从字节落盘。关键是 instantiateStreaming 走 Response 流,必须 clone() 后才能既 dump 又给 WASM 用(流只能消费一次)。
// dump-wasm.js —— @run-at document-start 注入,截获进入编译的字节并下载
(function () {
function dump(bytes, tag) {
const buf = bytes instanceof ArrayBuffer ? bytes
: ArrayBuffer.isView(bytes) ? bytes.buffer : null;
if (!buf) return;
const a = document.createElement("a");
a.href = URL.createObjectURL(new Blob([buf], { type: "application/wasm" }));
a.download = tag + "_" + buf.byteLength + ".wasm";
a.click();
}
const _inst = WebAssembly.instantiate;
WebAssembly.instantiate = function (bytes, ...rest) {
if (!(bytes instanceof WebAssembly.Module)) dump(bytes, "instantiate");
return _inst.call(this, bytes, ...rest);
};
const _instS = WebAssembly.instantiateStreaming;
if (_instS) {
WebAssembly.instantiateStreaming = async function (src, ...rest) {
const resp = await src;
resp.clone().arrayBuffer().then(b => dump(b, "stream")); // 必须 clone
return _instS.call(this, resp, ...rest);
};
}
})();
落盘后先看头 4 字节是否为 00 61 73 6d(\0asm)确认是裸 wasm,是则可直接 wasm2wat。
判断 wasm 形态决定复现路径
| 形态 | 特征 | 复现方式 |
|---|---|---|
| Emscripten 模块 | 同目录有胶水 JS(Module/onRuntimeInitialized/ccall/_malloc);import 段大量 env.emscripten_* |
直接复用胶水 JS,最省事 |
| 裸 SIDE_MODULE / 手写 wasm | import 少而干净(仅 env.memory、几个 env.__*);无胶水 JS |
手工补 import 垫片 |
| 字节码 VM(抖音 bdms 风格) | 导出极少,内部是解释器循环,算法是被解释执行的字节码数据 | 仍黑盒调用导出,别反推字节码语义 |
用 wasm-objdump -x sign.wasm 看 import/export 段:(import "env" "...") 列出必须在 importObject 里补齐的垫片,(export "...") 列出能调用的函数。
Node 手工加载裸 wasm + import 垫片
WebAssembly.instantiate(bufferSource, importObject) 返回 Promise 解析为 { module, instance };importObject 每个声明 import 都必须有对应属性,否则抛 LinkError——报错会精确告诉你缺哪个,这是补垫片的导航。new WebAssembly.Memory({initial, maximum}) 单位是页(1 页 = 64 KiB)。
// load_wasm.js (Node 18+,WebAssembly 是全局对象)
const fs = require("fs");
async function main() {
const bytes = fs.readFileSync("./sign.wasm");
// initial/maximum 看 wasm2wat 里的 (memory ...) / (table ...)
const memory = new WebAssembly.Memory({ initial: 256, maximum: 32768 });
const table = new WebAssembly.Table({ initial: 8, element: "anyfunc" });
const importObject = buildImports(memory, table);
const { instance } = await WebAssembly.instantiate(bytes, importObject);
console.log("exports:", Object.keys(instance.exports));
}
// 按 LinkError 报缺什么就补什么,不要一次性全填
function buildImports(memory, table) {
let HEAPU8 = new Uint8Array(memory.buffer);
const refresh = () => { HEAPU8 = new Uint8Array(memory.buffer); };
const env = {
memory, table, __indirect_function_table: table,
__memory_base: 0, __table_base: 0,
__stack_pointer: new WebAssembly.Global({ value: "i32", mutable: true }, 5 * 1024 * 1024),
abort: (w) => { throw new Error("wasm abort: " + w); },
__assert_fail: () => { throw new Error("assert_fail"); },
// 内存拷贝类用 copyWithin 实现;旧版符号名 emscripten_memcpy_big,
// Emscripten 3.1.43+(含当前 6.x)改名为 emscripten_memcpy_js,两个都补上最稳妥
emscripten_memcpy_big: (dest, src, num) => { refresh(); HEAPU8.copyWithin(dest, src, src + num); return dest; },
emscripten_memcpy_js: (dest, src, num) => { refresh(); HEAPU8.copyWithin(dest, src, src + num); return dest; },
emscripten_resize_heap: () => false,
// 时间/随机务必给确定值,便于复现
emscripten_get_now: () => Date.now(),
emscripten_random: () => Math.random(),
// 无关 syscall 直接返回 0
setTempRet0: () => {}, getTempRet0: () => 0,
__syscall_openat: () => -1,
};
return {
env,
wasi_snapshot_preview1: { fd_write: () => 0, fd_close: () => 0, proc_exit: (c) => { throw new Error("proc_exit " + c); } },
"GOT.mem": {}, "GOT.func": {}, global: { NaN: NaN, Infinity: Infinity },
};
}
main().catch(console.error);
补垫片的实战节奏:先只给 memory/table/abort 跑,看 LinkError 报缺哪个 env.xxx,回去补一个再跑,循环到不报错为止。绝大多数缺失要么是内存拷贝(用 copyWithin),要么是无关 syscall(返回 0 / throw)。
Emscripten 模块复现:直接用胶水 JS
如果抽 .wasm 时同时拿到胶水 JS,不要自己补 import——直接复用它生成的运行时。需要三件套:locateFile(告诉运行时去哪找 .wasm)、onRuntimeInitialized(实例化完成回调,必须等它触发后才能调导出)、Module 预置对象。
// run_emscripten.js —— 老式(非 MODULARIZE)胶水 JS
const path = require("path");
// 在 require 胶水 JS 之前预置全局 Module 配置
global.Module = {
locateFile: (file) => path.join(__dirname, file), // 让它找到本地 sign.wasm
onRuntimeInitialized: () => {
callSign(); // 在回调里调用,保证已就绪
},
};
require("./glue.js"); // 执行胶水 JS,它读 Module、实例化 wasm、触发回调
调用导出函数的完整骨架
典型 C 签名 char* sign(const char* input):入参是字符串指针、返回结果字符串指针。流程:_malloc 写入参 → 调用 → UTF8ToString 读返回 → _free。
function callSign() {
// 方式 1:cwrap(推荐,包一次反复用),'string' 入参自动 malloc+写堆+调用+free
const sign = Module.cwrap("sign", "string", ["string"]);
console.log(sign("hello_payload"));
// 方式 2:ccall(一次性调用)
const out = Module.ccall("sign", "string", ["string"], ["hello_payload"]);
console.log(out);
// 方式 3:手动管内存(传二进制 / 自己解析返回指针时)
const input = "hello_payload";
const nBytes = Module.lengthBytesUTF8(input) + 1; // +1 给 \0
const inPtr = Module._malloc(nBytes); // 在 wasm 堆上分配
Module.stringToUTF8(input, inPtr, nBytes); // 写入 UTF8(带终止符)
const retPtr = Module._sign(inPtr); // 调底层导出(带下划线),返回 char* 指针
const result = Module.UTF8ToString(retPtr); // 从指针读字符串(按 \0 截断)
console.log("sign result:", result);
Module._free(inPtr);
// 返回指针是否要 free 取决于 C 端:内部 malloc 的要 _free(retPtr),静态缓冲区则不要
}
二进制入参/出参不用 'string',用 HEAPU8.set 写、HEAPU8.slice 读:
function callBinary(inputBytes /* Uint8Array */) {
const inPtr = Module._malloc(inputBytes.length);
Module.HEAPU8.set(inputBytes, inPtr); // 写入参
const outLen = 32;
const outPtr = Module._malloc(outLen);
Module._hash(inPtr, inputBytes.length, outPtr); // void hash(in, len, out)
// grow 后视图会失效,读之前重取 Module.HEAPU8
const out = Module.HEAPU8.slice(outPtr, outPtr + outLen); // 拷出,脱离 wasm 堆
Module._free(inPtr);
Module._free(outPtr);
return out;
}
wasm2js 转 JS 后在 Node 调试
把不可读的二进制转成可断点的 JS,每条 wasm 指令变成具名 JS 语句,比 wat 易读、易插桩。
wasm2js secret.wasm -o secret.mjs --debug # 转换(输入可为 .wasm 或 .wat)
node --inspect-brk run.mjs # 首行暂停,等 attach 后再继续
# 打开 chrome://inspect -> Open dedicated DevTools for Node -> Sources 里给函数下断点
局限:浮点/trap 语义在纯 JS 下是模拟的(--pedantic 可逼近但不完全等价),大模块转出体积大、性能低,仅用于分析而非生产。
WASM 内加密算法的判别与还原
两条路,强烈优先黑盒调用:不管它内部是 MD5/AES/自研,只要能"喂相同输入得相同输出"就够了。验证方法:在浏览器对同一输入观察真实输出,与 Node 复现结果逐字节对比,一致即收工。抖音 a_bogus/bdms 推荐走这条。
实在要看算法时才反编译。加密算法即便编译进 WASM,其魔法常量/S-Box 通常原样躺在 Data 段(ID 11)或字节码立即数里,可直接搜出来。先 wasm2wat module.wasm -o module.wat 或 wasm-objdump -s 转 hex,再搜常量。
哈希算法初始向量/常量
字节序两头都搜(大端原值 + 小端反转,如 67452301 / 01234567)。
| 算法 | 标志常量 | 说明 |
|---|---|---|
| MD5 | 0x67452301、0xefcdab89、0x98badcfe、0x10325476 |
4 个初始 IV(A/B/C/D),0x67452301 是最经典指纹 |
| SHA-1 | 前 4 个同 MD5,多出第 5 个 0xC3D2E1F0 |
0xC3D2E1F0 是区分 SHA-1 与 MD5 的关键 |
| SHA-256 | 0x6a09e667、0xbb67ae85 等 8 个 IV;常量表 K 以 0x428a2f98 开头 |
0x428a2f98 辨识度极高,优先搜 |
| SHA-512 | 0x6a09e667f3bcc908 等 64 位 IV |
64 位版本 |
| SM3(国密哈希) | IV 0x7380166f 开头;常量 T 0x79cc4519(0≤j≤15)、0x7a879d8a(16≤j≤63) |
0x7380166f 和 0x79cc4519 是 SM3 铁证 |
对称加密 S-Box / 常量
| 算法 | 标志 | 说明 |
|---|---|---|
| AES | 正向 S-Box 以 63 7c 77 7b f2 6b 6f c5 开头;逆 S-Box 以 52 09 6a d5 开头 |
256 字节 S-Box 在 Data 段最常见,搜 637c777b |
| SM4(国密对称) | S-Box 以 d6 90 e9 fe cc e1 3d b7 开头;系统参数 FK 首项 0xa3b1bac6 |
搜 d690e9fe 或 0xa3b1bac6 |
| DES/3DES | IP/FP 置换表、S1–S8 盒(值域 0–15) | 特征是大量小数值表 |
| Base64 | 字符表 ABCD...wxyz0123456789+/(URL 变体末尾 -_) |
Data 段里直接可见,最易识别 |
| RC4 | 无固定常量,但有 for i in 0..256: S[i]=i 初始化模式 |
看代码逻辑 |
导出名与字符串线索
name 自定义段(若未 strip)或 Export 段可能直接暴露:encrypt/decrypt/sign/verify/hash/md5/sha256/sm3/sm4/aes/getSign/makeToken,C++ 修饰名如 __Z7encryptPKc。Data 段明文常泄露算法描述("AES/CBC/PKCS5Padding")、硬编码 key/iv、错误信息。
判别流程清单
1. 确认是 WASM:前 8 字节 00 61 73 6D 01 00 00 00 / 搜 AGFzbQ
2. wasm2wat module.wasm -o module.wat(或浏览器 DevTools 看反汇编)
3. 在 wat / hex 里搜魔法常量:
[ ] 67452301 -> MD5 或 SHA-1
[ ] C3D2E1F0 -> 确认是 SHA-1(而非 MD5)
[ ] 6a09e667 / 428a2f98 -> SHA-256
[ ] 7380166f / 79cc4519 -> SM3
[ ] 637c777b -> AES S-Box
[ ] d690e9fe / a3b1bac6 -> SM4
[ ] ABCDEFGH...+/ -> Base64 字符表
4. 搜 Export 段 / name 段函数名:encrypt/sign/md5/sm3 ...
5. 搜 Data 段明文字符串:算法描述、硬编码 key/iv
6. 字节序两头都搜(大端原值 + 小端反转)
7. 定位到 Code 段对应函数,结合 Hook 验证输入输出
最佳实践
-
优先黑盒调用,不要硬扣算法。 WASM 经过编译优化和混淆,逐行翻译成本极高且易错;只要在 Node 复现"相同输入产生相同输出"就满足签名需求。把
.wasm当.so加载即可。const sign = Module.cwrap("sign", "string", ["string"]); console.log(sign("payload")); // 与浏览器输出逐字节对比,一致即收工 -
先看导出与导入再动手。 用
wasm-objdump -x先弄清模块暴露了哪些函数、依赖哪些环境,决定复现路径和补垫片清单,避免盲目反汇编 VM 循环。wasm-objdump -x sign.wasm # Export 段告诉你能调什么,Import 段告诉你要补什么 -
每次访问内存都从 buffer 重建视图,不要缓存。
memory.grow()(如-sALLOW_MEMORY_GROWTH)会让旧ArrayBufferdetach,缓存的 TypedArray 失效。function readByte(ptr) { return new Uint8Array(window.__wasm.memory.buffer)[ptr]; // 每次重建 } -
Hook 必须在 wasm 加载前注入。
instantiate/instantiateStreaming一旦执行就抓不到了,用@run-at document-start或 content script"run_at": "document_start"注入主世界。streaming 走 Response 流,dump 字节前必须clone()。resp.clone().arrayBuffer().then(b => dump(b)); // clone 后流才能既 dump 又给 WASM -
复现时给环境依赖确定值。 算法若读
Date.now/Math.random/navigator指纹,输出会随环境变化。在垫片里固定这些值,必要时配合补环境(见 补环境)。env.emscripten_get_now = () => 1700000000000; // 固定时间确保可复现 -
用魔法常量快速判定算法族再决定要不要手翻。 搜到
637c777b(AES)或6a09e667(SHA-256)就知道是标准算法,可直接用现成库验证,不必逐行还原。wasm2wat app.wasm -o app.wat # 然后在 app.wat 里搜 637c777b / 6a09e667 / 7380166f
常见陷阱
陷阱一:memory.grow 后旧视图失效
-
现象:调用一次导出函数后再读内存,
HEAPU8.length变 0 或读到全 0,报RuntimeError: memory access out of bounds。 -
原因:模块用
-sALLOW_MEMORY_GROWTH,内部memory.grow()让旧ArrayBufferdetach(byteLength归零),所有缓存的 TypedArray 视图失效。 -
解决:每次访问前从
memory.buffer/Module.HEAPU8重新建视图,绝不缓存。// 错误:缓存视图,grow 后失效 const HEAPU8 = new Uint8Array(memory.buffer); function read(ptr) { return HEAPU8[ptr]; } // 错误:grow 后 HEAPU8 已 detach // 正确:每次重建 function read(ptr) { return new Uint8Array(memory.buffer)[ptr]; }
陷阱二:LinkError 缺少 import
-
现象:
WebAssembly.instantiate抛LinkError: Import #N "env" "emscripten_memcpy_big": function import requires a callable(Emscripten 3.1.43+ 产物里该符号名为emscripten_memcpy_js)。 -
原因:手工加载裸 wasm 时,importObject 没提供模块声明的某个导入符号。
-
解决:报错精确指出缺哪个,按需补一个再跑,循环到不报错。内存拷贝类用
copyWithin,无关 syscall 返回 0 或 throw。// 按 LinkError 提示补:内存拷贝用 copyWithin,杂项 syscall 返回 0 // 旧版符号名 emscripten_memcpy_big;Emscripten 3.1.43+(含 6.x)为 emscripten_memcpy_js const memcpy = (dest, src, num) => { new Uint8Array(memory.buffer).copyWithin(dest, src, src + num); return dest; }; env.emscripten_memcpy_big = memcpy; env.emscripten_memcpy_js = memcpy; env.__syscall_fcntl64 = () => 0;
陷阱三:Module.ccall / Module._xxx 不存在
-
现象:
TypeError: Module.ccall is not a function,或Module._sign is not a function。 -
原因:编译时未导出运行时方法(
ccall等需-sEXPORTED_RUNTIME_METHODS)或函数(_sign需-sEXPORTED_FUNCTIONS);也可能是导出名带不带下划线弄反了。 -
解决:用
wasm-objdump -x确认真实导出名;若运行时方法没导出,改走手动_malloc + Module._fn + UTF8ToString路径。// ccall 不存在时,直接调底层导出(注意带下划线前缀) const inPtr = Module._malloc(16); Module.stringToUTF8("data", inPtr, 16); const retPtr = Module._sign(inPtr); // 导出名以 wasm-objdump -x 确认 console.log(Module.UTF8ToString(retPtr)); Module._free(inPtr);
陷阱四:instantiateStreaming 在 Node 报 source must be Response
-
现象:Node 里跑 Emscripten 胶水,streaming 路径报
source must be Response。 -
原因:Node 内置 undici 的
Response与外部 npm 装的undici实例不互认。 -
解决:用
Module.wasmBinary注入字节,走非 streaming 路径。const fs = require("fs"); global.Module = { wasmBinary: fs.readFileSync("./sign.wasm") }; // 跳过 fetch/streaming require("./glue.js");