WebAssembly 逆向实战:从 0 搞懂猿人学第 15 题

0 阅读11分钟

WebAssembly 逆向实战:从 0 搞懂猿人学第 15 题

题目:备周则意怠,常见则不疑。 核心考点:前端把关键加密函数编译成了 WASM,m 参数的生成入口不再是一段看得懂的 JS,而是一个 .wasm 二进制模块。

很多人在爬虫逆向里第一次遇到 WASM 时会懵:右键 View Source 全是 JS,怎么突然冒出个 .wasm 文件?window.q 明明在调用,点进去却是空的?

这篇文章用一个真实靶场题,把 WASM 是什么、怎么定位、怎么反编译、怎么复刻成 Python 一条龙讲清楚。


1. 先搞明白:WASM 到底是什么

WebAssembly(WASM) 是一种可以在浏览器里运行的二进制指令格式,设计目标是让 C / C++ / Rust 等语言编译后的代码能跑在网页上,同时获得接近原生的执行速度。

一句话理解它和 JS 的关系:

JavaScriptWebAssembly
形态文本源码二进制字节码
可读性人能直接看需要工具反编译
谁编译的不用编译,浏览器解释/即时编译由 C/Rust 等编译而来
速度快更快,接近原生
逆向难度低中高

在逆向场景里,WASM 常被用来做两件事:

  1. 加密 / 签名算法:把 MD5、SHA、自研 hash、魔改算法编译进去,JS 里只留一行 window.q = instance.exports.encode,让你在 JS 里看不到任何逻辑。
  2. 性能敏感的计算:比如图像处理、游戏物理引擎。

本题就属于第一种:q 函数的真正实现被塞进了 main.wasm。


2. 从 0 定位:m 参数是怎么生成的

抓包拿到接口:

GET /api/question/15?page=1&pageSize=10&kw=&m=16620439|897036508|897036479

m 长得像三段式:16620439 | 897036508 | 897036479。

回到页面源码,翻到 JS 部分,能看到这样的钩子:

fetch('/static/new_match/question/15/main.wasm')
  .then(response => response.arrayBuffer())
  .then(bytes => WebAssembly.instantiate(bytes))
  .then(results => {
    instance = results.instance;
    window.q = instance.exports.encode;   // ← 关键!q 来自 wasm
    window.m = function () {
      t1 = parseInt(Date.parse(new Date()) / 1000 / 2);
      t2 = parseInt(Date.parse(new Date()) / 1000 / 2 - Math.floor(Math.random() * (50) + 1));
      return window.q(t1, t2).toString() + '|' + t1 + '|' + t2;
    };
  });

真相瞬间清晰:

m = q(t1, t2) + "|" + t1 + "|" + t2

t1 = floor(now_ms / 2000)          # 半秒级时间戳
t2 = t1 - random(1..50)            # 比 t1 早 1~50 个半秒
q  = wasm 导出的 encode 函数

所以这一步的结论是:m 的前半段是 encode(t1, t2) 的结果,后半段是明文的时间戳。真正需要逆向的,是 wasm 里的 encode。


3. 下载并反编译 WASM

3.1 下载

直接下载页面引用的那个文件:

curl -o main.wasm 'https://match.yuanrenxue.cn/static/new_match/question/15/main.wasm'

看到 00 61 73 6d 01 00 00 00(\0asm)就是标准 wasm 魔数:

00000000: 0061 736d 0100 0000 010a 0260 0000 6002  .asm.......`..`.

3.2 先看模块的导入导出(最快判断依赖)

用 Node 一行就能摸清这个 wasm 的底:

const fs = require('fs');
const bytes = fs.readFileSync('main.wasm');
const m = new WebAssembly.Module(bytes);
console.log('imports:', WebAssembly.Module.imports(m));   // 依赖了哪些外部东西
console.log('exports:', WebAssembly.Module.exports(m));   // 导出了哪些东西

输出:

imports: []           ← 零依赖,天大的好消息
exports: [
  { name: 'memory', kind: 'memory' },
  { name: '__heap_base', kind: 'global' },
  { name: '__data_end', kind: 'global' },
  { name: 'encode', kind: 'function' }
]

两个关键结论:

  1. imports: []:这个 wasm 不依赖任何浏览器/JS 环境,意味着任何能跑 wasm 的环境都能执行它(Node、Python 的 wasmtime/wasm3、甚至浏览器)。
  2. encode 是唯一的业务函数,memory 和两个 global 是编译器自动生成的运行时设施,不用管。

3.3 反编译成可读文本(WAT)

wasm 二进制人是读不了的,需要反编译成 WAT(WebAssembly Text Format,文本格式)。

装 wabt 工具集(官方 WebAssembly 二进制工具包):

npm install wabt
# 或者用系统包管理器装 wabt / wabt-tools

然后反编译:

wasm2wat main.wasm -o main.wat

打开 main.wat,函数签名一目了然:

(module
  (type (;1;) (func (param i32 i32) (result i32)))
  (func $encode (type 1) (param i32 i32) (result i32)
    ... 一大堆指令 ...
  )
  (export "encode" (func $encode))
)

解读:encode 接收 两个 i32(32 位整数),返回 一个 i32。这跟 JS 里 q(t1, t2) 的调用方式完全对上。


4. 两条逆向路线:跑起来 or 复刻

有了 wasm,你有两条路。先想清楚你要什么:

  • 只是想爬数据、快速出结果 → 路线 A:直接执行 wasm,零翻译、零风险。
  • 想真正吃透算法、摆脱 wasm 运行时、移植成纯 Python → 路线 B:复刻算法。

路线 A:Node 直接实例化执行(最简单,推荐先用)

Node 原生支持 WebAssembly,直接 instantiate 就能拿到 encode:

const fs = require('fs');
const wasm = fs.readFileSync('main.wasm');

let q;
async function init() {
  const { instance } = await WebAssembly.instantiate(wasm);
  q = instance.exports.encode;      // 这就是 window.q
}

function makeM() {
  const t1 = parseInt(Date.now() / 1000 / 2);
  const t2 = parseInt(Date.now() / 1000 / 2 - Math.floor(Math.random() * 50 + 1));
  return `${q(t1, t2)}|${t1}|${t2}`;
}

对拍验证,跟你抓包里的值一致:

q(897036508, 897036479) = 16620439   ✓ 正是 m 的第一段

一句话:路线 A 的本质就是「让 wasm 自己算」,你只是换了个运行环境把它调起来。

路线 B:把算法复刻成纯 Python

如果不想在 Python 爬虫里再套一层 Node,就把 WAT 翻译成 Python。

翻译前必须搞懂 wasm 的几条整数运算语义,否则必翻车:

wasm 指令含义容易踩的坑
i32.add/sub/and/or/xor32 位整数运算结果要回绕到 32 位有符号
i32.div_s有符号除法向零截断(Python 的 // 是向下取整,负数会错)
i32.shl左移移位量只取低 5 位(b & 31)
i32.shr_s算术右移负数高位补 1;移位量取低 5 位
i32.xor -1按位取反等价于 32 位 ~x

把这些语义封成 Python 辅助函数:

_MASK = 0xFFFFFFFF
_SIGN = 0x80000000

def i32(x):
    """压回 32 位有符号"""
    x &= _MASK
    return x - 0x100000000 if x >= _SIGN else x

def div_s(a, b):
    """wasm i32.div_s:向零截断"""
    if b == 0:
        return 0
    q = abs(a) // abs(b)
    return i32(-q if (a < 0) ^ (b < 0) else q)

def shl(a, b):
    return i32(a << (b & 31))      # 移位量取低 5 位

def shr_s(a, b):
    return i32(a) >> (b & 31)      # Python 负数右移即算术右移

然后照着 WAT 逐条翻译。这里把 encode 的完整逻辑还原成了下面这段(已用 400 组随机输入和原 wasm 对拍,0 误差):

def encode(t1, t2):
    A, B, C, D = i32(t1), i32(t2), 20, 15
    E = F = G = H = I = 0

    for _ in range(6):
        # 幅度钳制:防溢出,同时也在混淆
        if B >= 10000000:  B = div_s(B, 2)
        if B <= -10000000: B = div_s(B, 2)
        if A >= 10000000:  A = div_s(A, 2)
        if A <= -10000000: A = div_s(A, 2)
        if I >= 10000000:  I = div_s(I, 2)
        if I <= -10000000: I = div_s(I, 2)

        E = div_s(A, 16)
        F = div_s((A & B) | (C & ~D), 8)
        F = i32(F + div_s((A & B) | (D & ~C), 8))
        G = div_s(C ^ D ^ B, 9)
        H = i32(shl(A, C) | shr_s(A, 32 - D))

        if H >= 10000000:  H = div_s(H, 4)
        if H <= -10000000: H = div_s(H, 4)
        H = div_s(H, 16)

        I = i32(A ^ (B | ~D))
        A = i32(A ^ (B | ~D))
        B = i32((A & B) | (C & ~D))
        C = i32(shl(B, C) | shr_s(A, 32 - D))

        if C >= 10000000:  C = div_s(C, 2)
        if C <= -10000000: C = div_s(C, 2)
        C = i32(shl(B, C) | shr_s(A, 64 - D))

        if C >= 10000000:  C = div_s(C, 2)
        if C <= -10000000: C = div_s(C, 2)

        D = i32(A ^ (B | ~D))
        E = i32(E + F + G + I - H + B - A + D)

        if E >= 10000000:  E = div_s(E, 2)
        if E <= -10000000: E = div_s(E, 2)

    return div_s(div_s(E + F, 2) + I - H + B + A, 2)

阅读提示:A 对应第一个参数 t1,B 对应 t2,C 初值 20、D 初值 15 是内置常量,E/F/G/H/I 是中间累加变量。整个函数就是 6 轮「位运算 + 有符号除法 + 累加」的混淆计算,没有外部状态,所以是纯函数:同样的 (t1, t2) 永远得到同样的结果。

对拍测试:

encode(897036508, 897036479) -> 16620439  ✓
encode(100, 60)  -> 3
encode(5, 3)     -> 6
encode(1, 1)     -> 8
encode(0, 0)     -> 8
随机 400 组(含 8~9 亿真实量级) -> 0 误差

5. 拼出完整爬虫

m 的生成逻辑齐了:

import time, random
from encode_py import encode   # 上面的 q 复刻

def make_m():
    t1 = int(time.time() * 1000) // 2000      # floor(now_ms / 2000)
    t2 = t1 - random.randint(1, 50)           # t1 - random(1..50)
    return f"{encode(t1, t2)}|{t1}|{t2}"

然后带着登录 Cookie 请求接口:

import requests

HEADERS = {
    "accept": "application/json, text/javascript, */*; q=0.01",
    "user-agent": "Mozilla/5.0 ... Chrome/157.0.0.0 Safari/537.36",
    "x-requested-with": "XMLHttpRequest",
    "referer": "https://match.yuanrenxue.cn/match/15",
}
COOKIES = {"sessionid": "...", "yuanrenxue_cookie": "..."}  # 换成自己的

def fetch_page(page):
    params = {"page": page, "pageSize": 10, "kw": "", "m": make_m()}
    r = requests.get("https://match.yuanrenxue.cn/api/question/15",
                     params=params, headers=HEADERS, cookies=COOKIES)
    return r.json()["data"]

5.1 一个隐蔽的坑:第 5 页校验 UA

前 4 页都正常,但第 5 页会返回一串文字:

{"data": ["请", "将", "UA", "改", "为", "yuan", "ren", "xue", "哦"]}

翻译过来:「请将 UA 改为 yuanrenxue 哦」。

实测规则非常严格:

UA 值结果
Mozilla/5.0 ... Chrome/157前 4 页 OK,第 5 页被拦
Mozilla/5.0 ... Chrome/157 yuanrenxue❌ 仍被拦
yuanrenxue123❌ 被拦
xx yuanrenxue xx❌ 被拦
yuanrenxue✅ 通过

结论:第 5 页要求 UA 精确等于 yuanrenxue,不是「包含」就行。所以爬取时按页切换 UA:

def fetch_page(page):
    headers = dict(HEADERS)
    if page == 5:
        headers["user-agent"] = "yuanrenxue"
    ...

这个坑其实呼应了题名「常见则不疑」——前 4 页故意让你形成惯性,第 5 页突然加了个 UA 校验。


6. 最终结果

跑完 5 页,每页 10 条,共 50 条数据,求和:

page 1: [841529, 527066, 812141, 973106, 992403, 962235, 585475, 416617, 472064, 771179]
page 2: [493241, 435719, 789454, 551599, 736767, 357467, 227872, 977315, 451903, 549510]
page 3: [161813, 426122, 336092, 649321, 485327, 711537, 874521, 116759, 106926, 634585]
page 4: [181472, 919519, 856775, 546419, 870531, 560117, 191343, 129103, 816371, 640131]
page 5: [915646, 236267, 261325, 734307, 623380, 628508, 861590, 489430, 730687, 579277]

50 条求和 = 29199863

7. 方法论总结:以后遇到 WASM 怎么搞

把本题的流程抽象成一套通用打法,以后遇到任何 wasm 加密都能套:

1. 定位
   在页面 JS 里找 .wasm 的加载点(fetch / instantiate / instance.exports.xxx)
   → 确定「哪个 export 对应哪个 JS 函数」

2. 摸底
   WebAssembly.Module.imports / exports
   → 看有没有外部依赖(imports 为空最好办)

3. 反编译
   wasm2wat 出 WAT,读函数签名(几个参数、返回什么)

4. 选路线
   只想跑 → 直接用 Node / wasmtime / wasm3 执行 wasm
   想移植 → 逐条翻译 WAT,注意 i32 回绕、div_s 向零截断、移位量取低 5 位

5. 对拍
   用抓包里的真实输入输出,验证你的复刻结果完全一致
   再多跑随机输入和原 wasm 对拍,确保没翻车

6. 补环境
   注意目标站的额外校验(本题是第 5 页 UA 精确匹配)

核心心法就一句:WASM 本质还是一段「换了皮肤」的程序,有明确的指令语义,只是可读性差。把它当成一个「黑盒可调用、白盒可翻译」的模块,逆向难度并没有想象中高。


本文对应的完整代码:encode_py.py(纯 Python 复刻)、crawl15.py(完整爬虫)、solve15_node.js(Node 方案)。