WebAssembly逆向
WebAssembly逆向
本文适合
熟悉 字符串、交叉引用与控制流,并做过基础前端题的逆向学习者。学完你能:从网页中定位 .wasm,分析导入导出表和线性内存,复现或逆向 Wasm 中的校验逻辑
一句话判断
网页前端题里出现 .wasm、WebAssembly.instantiate、WebAssembly.Memory、wasm_exec.js 或浏览器 Network 面板加载 application/wasm,就要把关键校验逻辑从 JavaScript 追进 WebAssembly。
Wasm 题的核心不是“看懂所有栈式指令”,而是找到输入写入哪里、调用哪个导出函数、返回值如何影响成功失败。
题目中常见信号
文件信号:
main.wasm
check.wasm
module.wasm
wasm_exec.js
index.js
bundle.jsJavaScript 关键词:
WebAssembly.instantiate
WebAssembly.instantiateStreaming
WebAssembly.Memory
instance.exports
memory.buffer
check(
verify(命令行识别:
file main.wasm典型输出:
main.wasm: WebAssembly (wasm) binary module version 0x1核心概念
Wasm 模块常由这些部分组成:
- import:从 JS 或运行时传入的函数、内存或表。
- export:暴露给 JS 调用的函数,例如
check、verify。 - memory:线性内存,一大段字节数组。
- data segment:初始化到内存里的字符串、常量表、目标数组。
- function body:真正的校验逻辑。
前端常见调用链:
用户输入
-> JS 写入 wasm memory
-> 调用 exports.check(ptr, len)
-> check 返回 0/1
-> JS 显示 wrong/correct分析时要把 JS 和 Wasm 一起看。只看其中一边都会漏信息。
最小分析流程
1. 把 Wasm 文件拿出来
浏览器 Network 面板下载,或从本地题目目录中找到:
rg -n "WebAssembly|\\.wasm|exports|instantiate" .2. 看导入导出表
常用工具:
wasm-objdump -x main.wasm
wasm2wat main.wasm -o main.wat
wasm-decompile main.wasm -o main.c优先看导出函数:
Export[3]:
- memory
- check
- malloc如果导出里有 check、verify、main、validate,它们就是优先入口。
3. 看 JS 如何调用
搜索:
rg -n "exports\\.|memory|check|verify|TextEncoder|Uint8Array" .重点看:
- 输入是否经过
TextEncoder。 - 是否调用
malloc分配内存。 - 输入写入
new Uint8Array(memory.buffer)的哪个位置。 check参数是什么。- 返回值是
0成功还是1成功。
4. 找数据段和常量表
在 .wat 或反编译结果中搜索:
flag
correct
wrong
0x66 0x6c 0x61 0x67
i32.xor
i32.add
i32.rem_u如果有目标数组,优先还原数组和变换关系。
最小验证示例
假设 JS 中有:
const bytes = new TextEncoder().encode(input);
const ptr = wasm.exports.malloc(bytes.length);
new Uint8Array(wasm.exports.memory.buffer).set(bytes, ptr);
if (wasm.exports.check(ptr, bytes.length) === 1) {
alert("correct");
}最小验证动作:
- 确认
check(ptr, len)是校验入口。 - 在浏览器 DevTools 给
check调用前下断点。 - 查看
ptr、len和 memory 中的输入。 - 修改输入,观察返回值变化。
如果要在 Node.js 中直接调用:
const fs = require("fs");
(async () => {
const wasm = fs.readFileSync("main.wasm");
const { instance } = await WebAssembly.instantiate(wasm, {});
const input = Buffer.from("test");
const ptr = instance.exports.malloc(input.length);
const mem = new Uint8Array(instance.exports.memory.buffer);
mem.set(input, ptr);
console.log(instance.exports.check(ptr, input.length));
})();如果 import 不为空,需要按报错补 import stub。
常见利用 / 解题路线
路线总览:
路线一:静态还原算法
适合简单变换:
for i in range(len):
if ((input[i] ^ key[i % k]) + i) != target[i]:
return 0
return 1做法:
- 从 data segment 抠出
target和key。 - 读循环里的 XOR、ADD、SUB、比较。
- 写 Python 反推输入。
路线二:动态调用 check 爆破
适合每个字符独立或局部相关:
- 用 Node.js 加载 Wasm。
- 构造候选输入。
- 调用
check或内部比较函数。 - 根据返回值或中间状态逐位恢复。
如果 check 只返回整体对错,可以在 Wasm 中 patch 比较点,或者在浏览器中断到失败分支。
路线三:直接 patch 返回值
适合只要求页面显示成功、不需要真实 flag 的题。但多数 CTF 需要真正 flag,不建议只 patch UI。
可用于确认:
- JS 成功分支在哪里。
- Wasm 返回值怎么被解释。
- 你是否找对了校验函数。
路线四:看 JS import
如果 Wasm 从 JS import 随机数、时间、DOM 字符串,关键值可能不在 .wasm:
imports.env.get_seed = () => Date.now() & 0xff;这时要一起逆 JS 和 Wasm。
常见失败原因
- 只看 JS,不看 Wasm:页面上的
if只是壳,真实校验在check。 - 只看 Wasm,不看 JS:不知道输入怎么进 memory,参数意义会猜错。
- 内存偏移搞错:
ptr是 Wasm 线性内存里的偏移,不是宿主进程地址。 - 返回值方向反了:有的题
0是成功,有的题1是成功。 - 忽略 import:Wasm 校验依赖 JS 传入的函数或数据。
- 工具反编译不准:
wasm-decompile看不懂时,回到.wat和导出表。
迷你案例
题目目录:
index.html
app.js
check.wasmapp.js 里看到:
const ok = instance.exports.check(ptr, input.length);第一步查看导出:
wasm-objdump -x check.wasm | rg "Export|check|memory"发现:
- func[4] <check> -> "check"
- memory[0] -> "memory"第二步转文本:
wasm2wat check.wasm -o check.wat搜索 i32.xor:
i32.load8_u
i32.const 35
i32.xor
i32.const 0
i32.load8_u offset=1024
i32.ne说明输入字节 XOR 0x23 后和 1024 偏移处的目标数组比较。
第三步抠 data segment:
(data (i32.const 1024) "\45\4f\42\44...")写脚本反推:
target = bytes.fromhex("45 4f 42 44")
print(bytes([b ^ 0x23 for b in target]))如果输出像 flag 开头,就继续恢复完整数组。
WP 要写清楚:
.wasm是真实校验所在。- JS 负责把输入写入 memory 并调用
check。 - Wasm 中是 XOR 后和 data segment 比较。
- Python 脚本反推出 flag。