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代码里会有大量switch和goto,逻辑非常混乱。遇到这种情况,我建议先用wasm-interp单步执行,动态跟踪数据流,比静态分析效率高得多。
SVG:Wasm逆向核心流程
下面这张图总结了从Wasm二进制到C代码的完整流程,我画的时候特意把关键工具和输出都标出来了:
避坑指南:我踩过的几个坑
做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