WASM 逆向实战手册:从观测到复刻

WASM 逆向实战手册:从观测到复刻

核心原则:不要先删除,先证明它没被执行。 直接裁剪可能触发完整性校验、crash 或行为检测。正确顺序是:观测 → 定位执行路径 → 在必要时才考虑工具化裁剪。

第一层:动态覆盖率观测

目标:用最小成本判断哪些代码根本没跑过。

Chrome DevTools 的 Coverage 面板可以直接记录 WASM 执行覆盖。操作路径:

  1. 打开 DevTools → F12
  2. 按 Ctrl+Shift+P(Mac: Cmd+Shift+P)打开命令菜单
  3. 输入 coverage,选择 Show Coverage
  4. 在 Coverage 面板顶部下拉框中选择 Per function 或 Per block
  5. 点击 重新加载 按钮开始录制,然后手动执行目标业务操作(登录、请求接口、生成签名)
  6. 点击 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 的交叉引用分析定位孤立函数。

操作步骤:

  1. 用 Ghidra 加载 .wasm 文件(需要安装 WASM 插件,或使用支持 WASM 的 Ghidra 版本)
  2. 打开 Function Call Tree(Window → Function Call Tree)
  3. 按 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 。

操作方法:

  1. Hook VM dispatcher。 找到 VM 的主循环(通常是 while(1) 内的一个 switch 或函数指针表跳转),在入口处记录每个 opcode 值。
  2. 运行完整业务路径。 覆盖登录、签名、请求等所有目标操作。
  3. 统计频率。
// 在 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 → 找输入 → 找输出 → 复刻。真正影响复刻成功率的是参数预处理、随机源和内存布局,而不是那些看起来很复杂的垃圾函数。当你把输入输出链路抓出来后,垃圾代码是否存在已经不重要了。

No Comments

Send Comment Edit Comment


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
Previous