WASM 逆向实战手册:从观测到复刻
核心原则:不要先删除,先证明它没被执行。 直接裁剪可能触发完整性校验、crash 或行为检测。正确顺序是:观测 → 定位执行路径 → 在必要时才考虑工具化裁剪。
第一层:动态覆盖率观测
目标:用最小成本判断哪些代码根本没跑过。
Chrome DevTools 的 Coverage 面板可以直接记录 WASM 执行覆盖。操作路径:
- 打开 DevTools →
F12 - 按
Ctrl+Shift+P(Mac:Cmd+Shift+P)打开命令菜单 - 输入
coverage,选择 Show Coverage - 在 Coverage 面板顶部下拉框中选择 Per function 或 Per block
- 点击 重新加载 按钮开始录制,然后手动执行目标业务操作(登录、请求接口、生成签名)
- 点击 Stop instrumenting coverage and show results 停止录制
关键读数: 看 Unused Bytes 列。灰色部分是未使用代码,绿色部分是实际执行代码 。将 Usage Visualization 按未使用比例从高到低排序,未使用 80% 以上的模块就是你的首要目标。
局限: Coverage 面板主要面向 JS/CSS 设计,对 .wasm 的支持取决于浏览器版本和 DevTools 的具体实现。如果 WASM 资源没有出现在 Coverage 列表中,退回第二层。
第二层:Hook Export 记录真实调用
目标:不依赖 DevTools 对 WASM 的原生支持,通过包装 export 函数直接记录哪些被调用了。
// hook-exports.js —— 注入到页面控制台或通过 CDP 执行
const called = new Set();
const callCounts = {};
const exportNames = Object.keys(wasmInstance.exports);
for (const k of exportNames) {
const original = wasmInstance.exports[k];
if (typeof original !== 'function') continue;
// 保留原始函数属性(name, length)
const wrapper = function (...args) {
called.add(k);
callCounts[k] = (callCounts[k] || 0) + 1;
return original.apply(this, args);
};
Object.defineProperty(wrapper, 'name', { value: k });
wrapper.toString = () => original.toString();
wasmInstance.exports[k] = wrapper;
}
// 业务操作完成后执行:
console.table(Object.entries(callCounts).sort((a, b) => b[1] - a[1]));
console.log('未被调用的 export:', exportNames.filter(n => !called.has(n)));
执行方式: 如果 WASM 实例变量在闭包中不可直接访问,可以通过 Chrome DevTools 的 Sources → Snippets 创建一个 snippet 并运行,或在关键函数处下断点,在断点暂停时在 Console 中执行上述代码(此时 wasmInstance 可能在作用域链中)。
输出解读: 未被调用的 export 是“疑似垃圾代码”的第一层筛选。但注意:未被调用 ≠ 可以安全删除。它可能是通过 间接调用(call_indirect)触发的,只是当前业务路径没走到。继续用以下方法交叉验证。
与 Coverage 交叉验证: 将 Coverage 的 Unused Bytes 结果与 Hook 结果对比。如果一个 export 在 Coverage 中显示未使用,在 Hook 中也未被调用,那它是垃圾代码的置信度很高。如果一个 export 在 Hook 中未被调用,但 Coverage 中显示使用(或反之),说明它可能通过间接路径执行,不要动它。
第三层:wasm-snip 定点裁剪
目标:对已确认的未调用函数,用“替换为 unreachable”的方式安全裁剪,而非物理删除。
wasm-snip 的核心机制不是删除函数,而是将函数体替换为 unreachable 指令,然后 级联删除 所有只被该函数调用、且不再被其他函数引用的函数 。
安装:
cargo install wasm-snip
基础用法:
# 裁剪指定函数
wasm-snip input.wasm -o output.wasm annoying_space_waster
# 模糊匹配裁剪(正则)
wasm-snip input.wasm -o output.wasm -p ".*debug.*"
# 裁剪 Rust 标准库的 panic 和 fmt 代码(最常用)
wasm-snip input.wasm --snip-rust-panicking-code --snip-rust-fmt-code -o out.wasm
前置条件: wasm-snip 依赖 .wasm 文件中的 name section(函数名)才能按名称定位。如果 WASM 已剥离符号,先用 wasm-objdump -h input.wasm | grep name 检查是否存在 name section。如果没有,此工具不适用,跳到第五层。
裁剪后验证:
# 反汇编检查 panic 是否仍存在
wasm-dis output.wasm | grep -i panic
如果输出为空,说明 panic 相关代码已被完全清除。如果有输出,检查是 name section 的残留还是实际代码 —— name section 中的字符串不影响运行时行为。
安全边界: wasm-snip 级联删除的逻辑是“只被 snip 函数调用且无其他引用的函数会被删除” 。这意味着如果一个“垃圾函数”实际上被某个关键路径间接引用(比如通过函数表),级联删除不会碰它。这是相对安全的,但替换为 unreachable 意味着如果它 意外被调用,会直接 trap,而不是静默执行垃圾逻辑。
第四层:wasm-gc 全局回收
目标:删除未引用的函数、全局变量、表和内存段。
# 基础用法
wasm-gc input.wasm output.wasm
wasm-gc 的作用类似于 ELF 领域的 strip + gc-sections:遍历模块的引用图,移除所有不可达的实体。它不依赖 name section,对已剥离符号的 WASM 同样有效。
典型工作流(Rust WASM 标准裁剪链):
# 1. wasm-snip 定点裁剪已知垃圾
wasm-snip app.wasm --snip-rust-panicking-code --snip-rust-fmt-code -o app.snip.wasm
# 2. wasm-gc 全局回收
wasm-gc app.snip.wasm app.gc.wasm
# 3. wasm-opt 最终优化(见第五层)
wasm-opt -Oz app.gc.wasm -o app.opt.wasm
这个组合是 Rust WASM 工具链中的经典配置 。
局限: wasm-gc 只删除 静态不可达 的代码。如果垃圾函数被放在函数表中,或者通过 call_indirect 被“潜在”调用,它不会被删除,因为工具的静态分析无法证明它永远不会被间接调用。这也是为什么第四层必须放在动态观测(第一、二层)之后 —— 你已经知道它没被调用,但工具不知道。
第五层:wasm-opt 优化与降噪
目标:在保留执行语义的前提下,自动消除死代码、简化控制流、内联小函数。
# 最高优化级别(激进)
wasm-opt -O4 input.wasm -o output.wasm
# 以体积为优先
wasm-opt -Oz input.wasm -o output.wasm
# 保持调试信息
wasm-opt -O4 -g input.wasm -o output.wasm
-O4 除了常规优化 pass 外,还会 flatten IR,对嵌套复杂、未充分优化的 WASM 效果显著,但耗时和内存消耗也更高 。-Oz 以体积最小化为目标,在逆向场景中通常更实用 —— 你的目标是降低噪声,不是追求运行时性能。
关键优化 pass 对逆向的意义:
| 优化 | 对逆向的影响 |
|---|---|
| Dead Code Elimination | 自动移除被常量传播证明不可达的分支 |
| Constant Propagation | 消解 if (x*x%3==2) 这类 bogus control flow |
| Inlining | 减少函数调用层级,垃圾函数可能被直接暴露或消除 |
| Block Merging | 简化控制流平坦化后的 switch dispatcher |
验证效果:
# 对比前后大小
ls -lh input.wasm output.wasm
# 查看优化后的文本格式,检查垃圾模式是否减少
wasm-opt -O4 input.wasm -o /dev/null --print | grep -c "unreachable"
注意: wasm-opt 的优化可能改变某些依赖 精确内存布局 或 指令级时序 的行为。如果优化后的 WASM 在目标环境中出现行为差异(签名结果不同、风控触发),说明原始代码依赖了优化器会破坏的假设。此时回退到 -O2 或 -O1 逐级测试。
第六层:Ghidra 静态定位未引用函数
目标:在无法动态运行或需要理解垃圾代码结构时,用 Ghidra 的交叉引用分析定位孤立函数。
操作步骤:
- 用 Ghidra 加载
.wasm文件(需要安装 WASM 插件,或使用支持 WASM 的 Ghidra 版本) - 打开 Function Call Tree(Window → Function Call Tree)
- 按 Incoming References 排序,0 引用的函数即为候选垃圾
Ghidra 脚本自动化(批量导出无 XREF 函数):
# @category WASM
# 导出没有任何交叉引用的函数
fm = currentProgram.getFunctionManager()
ref_mgr = currentProgram.getReferenceManager()
orphans = []
for func in fm.getFunctions(True):
if func.isExternal() or func.isThunk():
continue
entry = func.getEntryPoint()
refs = ref_mgr.getReferencesTo(entry)
# 过滤掉自身引用
external_refs = [r for r in refs if r.getFromAddress() != entry]
if len(external_refs) == 0:
orphans.append(func.getName())
print("无引用函数 ({}个):".format(len(orphans)))
for name in orphans:
print(" " + name)
局限: Ghidra 对 WASM 的静态分析能力受限于编译器的优化级别和符号剥离程度。GCC 编译的二进制通常比 Clang/MSVC 更容易分析,未剥离符号时函数识别率显著更高 。但 WASM 的间接调用(call_indirect 通过函数表)对 Ghidra 来说是一个黑箱 —— 它无法追踪表索引的运行时值,所以“0 引用”的函数仍可能是表调用的目标。
第七层:识别 Bogus Control Flow
目标:识别并消解 if ((x*x)%3==2) { real(); } else { fake(); } 这类永远走同一分支的伪条件。
静态识别思路: 在 Ghidra 或 IDA 中,寻找 条件分支的两个目标,其中一个目标在后续分析中被证明永远不可达。常见模式:
- 条件表达式中包含 编译期常量(
2*2==5) - 条件依赖的变量 永远被赋为固定值
else分支通向一个 只包含垃圾指令或无限循环 的块
动态消解(angr 符号执行基础模板):
import angr
import claripy
proj = angr.Project("app.wasm", auto_load_libs=False)
func_addr = 0x400 # 目标函数地址
# 创建空白状态,CALLLESS 减少外部调用干扰
state = proj.factory.blank_state(addr=func_addr)
state.options.add(angr.options.CALLLESS)
# 找到到达 fake 分支的路径并检查可满足性
simgr = proj.factory.simulation_manager(state)
# 探索到达“假分支”的路径
simgr.explore(find=lambda s: s.addr == 0x500, # fake 分支地址
avoid=lambda s: s.addr == 0x480) # real 分支地址
if len(simgr.found) == 0:
print("假分支不可达 —— 确认是 bogus control flow")
实战注意: angr 分析 WASM 的支持程度取决于版本。如果 angr 对 WASM 的支持不完善,可以用 Triton 或 Miasm 作为替代符号执行引擎。路径爆炸是常见问题,限制在单个函数内部探索,不要跨函数追踪 。
第八层:控制流平坦化恢复
目标:将 while(1) { switch(state) { ... } } 恢复为正常的控制流图。
识别特征: 函数入口后立即进入一个 无限循环,循环体内是一个大的 switch 或 br_table,每个 case 设置一个新的 state 值然后 continue,另有一个分发块根据 state 跳转到对应的真实块。
核心逻辑(angr 路径探索法):
不要一次性模拟整个循环 —— 那会导致路径爆炸。正确做法是 以真实块为节点,逐对探索连接关系 :
# 伪代码流程
real_blocks = [0x100, 0x200, 0x300, 0x400] # 已识别的真实块地址
connections = []
for src in real_blocks:
# 从 src 开始探索,直到遇到任何一个 real_block
state = proj.factory.blank_state(addr=src)
state.options.add(angr.options.CALLLESS)
simgr = proj.factory.simulation_manager(state)
# 限制步数防止爆炸
simgr.run(n=50)
for s in simgr.active:
if s.addr in real_blocks:
connections.append((src, s.addr))
工具替代: 如果 angr 对 WASM 支持不足,可以手动在 Ghidra 中追踪 state 变量的赋值:每个真实块的末尾将 state 设为常量,分发块是一个 dense switch。在 Ghidra 的 P-Code 视图中,可以写脚本自动提取每个 case 的 state 赋值和跳转目标,重建 CFG。
恢复后的验证: 重建的 CFG 应该是一个 有向无环图或简单循环,不再包含无限 switch 分发。用 wasm-opt --print 对比恢复前后的 block 数量 —— 垃圾 dispatcher 消除后,块数量应显著下降。
第九层:VM 垃圾指令识别
目标:在 VM-based 混淆中,通过 opcode 频率分布定位永不执行的 handler 和垃圾指令。
原理: 在真实的 VM 执行中,字节码指令的使用频率遵循特定分布。垃圾指令(FAKE_ADD、FAKE_HASH、NOP)如果从未被执行,其 handler 的触发次数为 0 。
操作方法:
- Hook VM dispatcher。 找到 VM 的主循环(通常是
while(1)内的一个switch或函数指针表跳转),在入口处记录每个 opcode 值。 - 运行完整业务路径。 覆盖登录、签名、请求等所有目标操作。
- 统计频率。
// 在 VM dispatcher 入口处注入
const opcodeCounts = new Map();
// 假设 dispatcher 是一个函数,第一个参数是 opcode
const originalDispatcher = wasmInstance.exports.vm_execute;
wasmInstance.exports.vm_execute = function (opcode, ...rest) {
opcodeCounts.set(opcode, (opcodeCounts.get(opcode) || 0) + 1);
return originalDispatcher.call(this, opcode, ...rest);
};
// 业务完成后:
const sorted = [...opcodeCounts.entries()].sort((a, b) => a[1] - b[1]);
console.table(sorted.map(([op, count]) => ({ opcode: '0x' + op.toString(16), count })));
输出解读:
- 高频 opcode(数千次以上):核心业务指令,不要动。
- 低频 opcode(个位数):可能是边缘路径或错误处理。
- 零频 opcode:在当前业务路径中从未执行的 handler。不要立即删除 —— 它们可能在反调试路径或完整性校验分支中触发。但它们是“垃圾代码”的最高置信度候选。
与 wasm-opt 的配合: 如果一个 handler 对应的 opcode 在静态分析中只被分发逻辑引用,且动态执行次数为 0,可以用 wasm-opt 的 --dce pass 配合自定义可达性分析尝试消除。但注意:VM dispatcher 的 switch 结构会让编译器难以证明某个 case 不可达,DCE 可能不会生效。此时需要手动在 dispatcher 中“禁用”该 case(将 handler 替换为 unreachable 或直接跳过)。
工具选择决策树
你当前的目标是什么?
│
├─ 快速判断哪些代码没跑过 → 第一层 Coverage + 第二层 Hook Export
│
├─ 减小体积,去掉已知无用代码 → 第三层 wasm-snip + 第四层 wasm-gc
│
├─ 降低整体噪声,让反汇编更可读 → 第五层 wasm-opt -Oz
│
├─ 静态定位孤立函数 → 第六层 Ghidra XREF 脚本
│
├─ 消解伪条件分支 → 第七层 angr/Triton 符号执行
│
├─ 恢复控制流平坦化 → 第八层 angr 真实块连接 + Ghidra P-Code
│
└─ VM 混淆,找垃圾 opcode → 第九层 动态 opcode 频率统计
最终建议: 对于签名、Token、HLS、WebSocket 风控场景,70% 的所谓垃圾代码根本不需要删。正确路线是:Hook → 找输入 → 找输出 → 复刻。真正影响复刻成功率的是参数预处理、随机源和内存布局,而不是那些看起来很复杂的垃圾函数。当你把输入输出链路抓出来后,垃圾代码是否存在已经不重要了。