WebAssembly逆向:Wasm文件结构、Wabt工具链、反编译Wasm到C

WebAssembly,简称Wasm,这几年在浏览器端和边缘计算领域越来越火。说实话,我第一次接触Wasm逆向时,心里还挺没底的——这玩意儿是二进制格式,又不是传统的x86或ARM指令集,怎么下手?后来啃了几份规范,又折腾了Wabt工具链,才算摸到门道。

今天我就带你走一遍Wasm逆向的完整流程。从文件结构开始,到工具链的使用,最后把Wasm反编译成可读的C代码。嗯,咱们一步步来。

Wasm文件结构:二进制里藏着什么?

Wasm文件是二进制格式,后缀通常是.wasm。它的设计很紧凑,不像PE或ELF那样有复杂的节区表。整个文件由一个个段(Section)组成,每个段都有固定的ID和结构。

我整理了一张表,把常见的段列出来:

段ID 段名称 作用
1 Type 定义函数签名(参数和返回值类型)
2 Import 声明从宿主环境导入的函数、内存、全局变量
3 Function 列出所有函数的索引,指向Code段
4 Table 定义函数表(用于间接调用)
5 Memory 定义线性内存的初始大小和最大大小
6 Global 定义全局变量
7 Export 导出函数、内存、全局变量给外部调用
8 Start 指定一个初始化函数,模块加载后自动执行
9 Element 初始化Table段中的元素
10 Code 存放所有函数的字节码(核心部分)
11 Data 初始化线性内存中的数据

你看,结构其实挺清晰的。逆向时最关注的是Import段Export段——它们告诉你这个Wasm模块跟外界怎么交互。Code段则是真正的逻辑所在,里面是Wasm的虚拟指令。

核心要点:Wasm的指令是栈式虚拟机指令。比如i32.add就是从栈顶弹出两个32位整数,相加后再压回栈顶。理解这个模型,反编译时才能看懂生成的C代码。

Wabt工具链:我的瑞士军刀

Wabt(WebAssembly Binary Toolkit)是官方维护的一套工具。我个人习惯把它装到本地,因为命令行操作比在线工具灵活得多。你想想看,批量处理几十个Wasm文件时,脚本一跑就完事了。

常用的几个工具:

  • wasm2wat:把二进制Wasm转成文本格式WAT。WAT是Wasm的可读表示,类似汇编但更结构化。
  • wat2wasm:反过来,从WAT生成Wasm二进制。
  • wasm2c:直接把Wasm转成C代码。这是逆向的利器。
  • wasm-objdump:查看Wasm文件的段结构,类似objdump
  • wasm-interp:解释执行Wasm,可以单步调试。

我记得有一次分析一个混淆过的Wasm模块,用wasm-objdump一看,发现Import段里导入了十几个外部函数,但实际只用了其中三个。这就是典型的“烟雾弹”手法——逆向时别被这些假象带偏。

反编译Wasm到C:实战流程

好,咱们动手试试。假设你有一个target.wasm文件,想看看它到底干了什么。

第一步:转成WAT,快速浏览

wasm2wat target.wasm -o target.wat

打开target.wat,你会看到类似这样的结构:

(module
  (type (;0;) (func (param i32 i32) (result i32)))
  (import "env" "printf" (func (;0;) (type 0)))
  (func (;1;) (type 0) (param i32 i32) (result i32)
    local.get 0
    local.get 1
    i32.add
    return)
  (export "add" (func 1)))

这个模块导入了env.printf,导出了一个add函数。函数体很简单:取两个参数,相加,返回。WAT格式虽然可读,但逻辑复杂时看着还是费劲。

第二步:用wasm2c生成C代码

wasm2c target.wasm -o target.c

生成的C代码会很长,因为wasm2c会生成一个完整的运行时环境。但核心逻辑就在里面。比如上面的add函数,会变成类似这样:

u32 w2c_add(u32 w2c_p0, u32 w2c_p1) {
  u32 w2c_l0 = w2c_p0;
  u32 w2c_l1 = w2c_p1;
  u32 w2c_l2 = w2c_l0 + w2c_l1;
  return w2c_l2;
}

你看,虽然变量名被改成了w2c_p0这种形式,但逻辑一目了然。这就是反编译的价值——把栈式指令还原成高级语言。

小技巧:wasm2c生成的代码里,所有内存操作都通过w2c_memory这个全局结构体访问。如果你看到大量w2c_memory->data[offset]的读写,说明这个Wasm模块在操作线性内存。逆向时重点关注这些偏移量,它们往往对应数据结构。

第三步:分析Import和Export

我建议你先看Export段,找到模块对外暴露的函数。然后看Import段,了解它依赖了哪些宿主函数。比如一个Wasm模块导入了emscripten_memcpy,那它大概率是用Emscripten从C/C++编译过来的。这时候反编译出来的C代码会跟原始源码比较接近。

注意:有些Wasm模块会做控制流平坦化(Control Flow Flattening)。反编译出来的C代码里会有大量switchgoto,逻辑非常混乱。遇到这种情况,我建议先用wasm-interp单步执行,动态跟踪数据流,比静态分析效率高得多。

SVG:Wasm逆向核心流程

下面这张图总结了从Wasm二进制到C代码的完整流程,我画的时候特意把关键工具和输出都标出来了:

Wasm逆向核心流程 target.wasm Wasm二进制文件 Wabt工具链 wasm2wat / wasm2c target.wat WAT文本格式(可读) target.c C代码(反编译结果) 关键分析点 Import段 → 外部依赖 | Export段 → 对外接口 | Code段 → 核心逻辑 wasm-interp 单步执行 / 动态跟踪 理解原始逻辑 还原算法 / 协议 / 行为

避坑指南:我踩过的几个坑

做Wasm逆向时,有几个坑我印象特别深,分享给你:

  • 不要迷信反编译结果:wasm2c生成的C代码虽然可读,但变量名全是w2c_l0这种,而且会有大量临时变量。我曾经花了两小时分析一个循环,后来发现那只是Wasm栈操作展开后的冗余代码。先看WAT,再对比C,效率更高。
  • 注意内存对齐:Wasm的线性内存是字节对齐的,但C代码里经常出现memcpy或指针转换。如果反编译出来的代码有奇怪的offset & 3这种操作,多半是在做对齐处理。
  • Import函数可能是陷阱:有些Wasm模块会导入大量无用的函数,或者把真实逻辑藏在导入函数的回调里。我遇到过一种情况:Wasm只导出一个run函数,但run内部调用了导入的callback,而callback才是真正的业务逻辑。这时候必须结合宿主环境的代码一起分析。

总结一下:Wasm逆向的核心是理解段结构,用好Wabt工具链,然后结合静态分析和动态调试。别被二进制格式吓到——它比x86指令集简单多了。你只要把WAT和C代码对照着看,很快就能摸清模块的底细。

好了,这一章的内容就到这里。记住,工具只是手段,理解数据流和控制流才是逆向的本质。下次遇到Wasm文件,先wasm-objdump看一眼结构,再决定怎么下手。


公众号:蓝海资料掘金营,微信deep3321