逆向工程实战手册 第 28 章

第 28 章 WebAssembly 逆向专项

WASM(WebAssembly)正在成为前端、游戏、云函数的新主流"二进制格式"。它既是 Web 技术又是二进制格式——逆向方法介于"读 JS"和"逆原生"之间。本章讲清 WASM 的结构、工具链和逆向流程。

📍 知识点地图 | 主题:WASM逆向 | 前置:第3章 | 后续:— | 核心概念:栈机、wasm2wat、wasm-decompile、name段

28.1 WASM 是什么

定义: 浏览器可执行的紧凑二进制格式(.wasm),由 C/C++/Rust/Go 等编译而来
特点:
  - 不是汇编,是"结构化指令集"(有明确的块结构,不是线性指令流)
  - 类型系统简单: i32/i64/f32/f64
  - 无寄存器,用"栈机"模型(指令操作值栈)
  - 导出/导入函数机制(与 JS 互操作)
出现场景:
  - 高性能前端(游戏/图像处理/加密)
  - 反爬虫/风控(把 JS 校验逻辑编译成 WASM 藏起来)
  - 云函数/边缘计算
  - CTF 题目(WASM 逆向题逐渐变多)

28.2 为什么 WASM 逆向与原生不同(核心知识点)

对比 原生二进制 WASM
指令 线性汇编 结构化(块/循环/分支标签)
寄存器 真实寄存器 值栈 + 局部变量
内存 进程内存 显式线性内存(memory.grow)
函数 地址调用 索引/导出名调用
导入 动态链接 显式 import 段(与 JS 接口)
调试信息 DWARF(可选) name 段(保留函数名!)
反编译 伪 C 质量参差 可还原成接近原 C(结构化优势)

关键认知:WASM 保留了函数块结构,反编译质量通常远好于同复杂度的原生二进制。

28.3 WASM 文件结构

.wasm 二进制 = 一系列 section:
  type    函数签名表
  import  导入(JS 函数/内存/表)
  function 函数索引
  table/export 导出(函数/内存/全局)
  code    函数体(栈机指令)
  name    函数名(编译时可选保留 ← 金矿!)
  data    数据段(字符串/常量)
  custom  自定义段(sourceMappingURL 等)

第一件事永远是检查 name 段——很多 WASM 未 strip,函数名直接可见,等于"半开源"。

28.4 工具链

工具 用途
wabt(wasm2wat/wasm2c) 官方工具:WASM ↔ WAT 文本互转
Binaryen(wasm-opt) 优化/去混淆
Ghidra WASM 反编译(内置支持,输出 C)
wasm-decompile(Binaryen 内) 输出"类 C"伪代码(最高效)
wasmtime / wasmer 运行 WASM(脱离浏览器)
wasm-objdump 查看段结构

28.5 WASM 逆向标准流程

Step 1: 侦察
  wasm-objdump -h target.wasm        # 段概览
  wasm-objdump -x target.wasm        # 详细(imports/exports/name)
  strings target.wasm                # 字符串(含函数名线索)
  → 检查 name 段!很多题到这就结束了

Step 2: 转 WAT 文本
  wasm2wat target.wasm -o target.wat   # 可读的指令文本
  # 或直接用 Binaryen:
  wasm-decompile target.wasm -o target.dcmp   # 输出类 C 伪代码

Step 3: 找入口
  从 exports 找到导出的 main/校验函数
  → 从 import 看它依赖 JS 提供什么(加密函数?环境变量?)

Step 4: 还原逻辑
  优先 wasm-decompile 输出(最接近 C)
  复杂函数 → Ghidra 加载 .wasm 反编译(支持现代 WASM 特性)
  → 结合 strings/常量表还原算法

Step 5: 验证
  用 wasmtime 直接运行(可传参/打印输出)
  或重放算法(Node/Python 复现)

28.6 WAT 文本阅读基础(能读懂即可)

;; 一个函数: 输入两个 i32,返回 i32
(func $add (param $a i32) (param $b i32) (result i32)
  local.get $a
  local.get $b
  i32.add
)

;; 比较分支
(func $check (param $x i32) (result i32)
  local.get $x
  i32.const 42
  i32.eq            ;; x == 42 压入栈 0/1
  if (result i32)   ;; 结构化分支!
    i32.const 1
  else
    i32.const 0
  end
)

阅读要点

栈机: 指令消费栈顶值、压入结果(local.get 压值,i32.add 弹两压一)
结构化: if/loop/block 有明确的 end,控制流一目了然(比线性汇编好读)

28.7 常见 WASM 逆向场景

场景 1:风控/反爬 WASM(最常见)

形态: 网站把签名/加密逻辑编译成 WASM,JS 调用它生成参数
分析:
  1. 找 import 段 → 看它向 JS 要了什么(随机数? 时间? 密钥?)
  2. 找导出函数 → 用 wasmtime 直接调用它复现参数
  3. 还原算法 → 用 Node/Python 重写(比浏览器内跑更可控)
  4. 验证: 生成的参数能被服务端接受

场景 2:CTF WASM 题

形态: 直接给 .wasm,输入 flag 校验
常见套路:
  - name 段保留 → 函数名直读(最简单的题)
  - name 段被删 → 从字符串/常量/导出名推断
  - 逻辑藏在线性内存数据段(数组/表)
  - 用 JS 调 WASM(.js 里封装校验逻辑)→ 先看 JS!
解法:
  1. wasm-decompile 还原伪代码(90% 的题到这步就出答案)
  2. 复杂算法 → Ghidra 反编译或 wasm2c + 传统逆向
  3. 直接调用: wasmtime 跑起来观察行为

场景 3:游戏/高性能前端

形态: C/Rust 编译的游戏逻辑、图像处理
分析: 和原生逆向类似但更友好(结构保留)
注意: 大 WASM 的 data 段可能包含资源/字符串表

28.8 WASM 混淆与对抗

混淆手段 特征 应对
strip name 段 函数名消失 从字符串/导出/常量推断
控制流扁平化 大量 block/br 嵌套 wasm-opt 优化 / 手工还原
常量加密 常量藏在线性内存 运行观察 / 动态解密
import 隐藏 关键逻辑依赖 JS 先逆 JS 部分
多模块 拆分多个 wasm 逐个分析 + 找交互接口

28.9 决策树

拿到 .wasm
├─ name 段在? → 函数名直读,直接看主逻辑
├─ 有 .js 配套? → 先逆 JS(调用关系是文档)
├─ 逻辑复杂度低 → wasm-decompile 还原伪代码
├─ 逻辑复杂/混淆 → Ghidra 反编译 + wasm2c
├─ 需要运行验证 → wasmtime/wasmer 直接跑
└─ 目标是复现签名 → 还原算法后 Node/Python 重写

动手练习

  1. 用 C 写一个 flag 校验程序编译成 WASM(emccclang --target=wasm32),用 wasm-decompile 还原并解出 flag。
  2. 找一个真实网站的风控 WASM(浏览器 Network 面板搜 .wasm),分析它的 import/export 结构。
  3. 练习 WAT 阅读:把一段简单的 add/check 逻辑从 wasm2wat 输出翻译成 C 语言。
  4. 用 Ghidra 打开一个 .wasm,体验它的反编译输出质量。

深入阅读

  • 仓库:skills/reverse-engineering/tools.md(WASM 分析:decompile/C 转换/常见模式)
  • 仓库:skills/reverse-engineering/languages.md(WASM 与字节码)
  • 仓库:skills/reverse-engineering/field-notes.md(WASM patching 案例:WAT 翻转比较)
  • 仓库:CTF-Sandbox-Orchestrator/competition-web-runtime/(Web 运行时 CTF 子技能)