WebAssembly 逆向实战:从 0 搞懂猿人学第 15 题
题目:备周则意怠,常见则不疑。 核心考点:前端把关键加密函数编译成了 WASM,
m参数的生成入口不再是一段看得懂的 JS,而是一个.wasm二进制模块。
很多人在爬虫逆向里第一次遇到 WASM 时会懵:右键 View Source 全是 JS,怎么突然冒出个 .wasm 文件?window.q 明明在调用,点进去却是空的?
这篇文章用一个真实靶场题,把 WASM 是什么、怎么定位、怎么反编译、怎么复刻成 Python 一条龙讲清楚。
1. 先搞明白:WASM 到底是什么
WebAssembly(WASM) 是一种可以在浏览器里运行的二进制指令格式,设计目标是让 C / C++ / Rust 等语言编译后的代码能跑在网页上,同时获得接近原生的执行速度。
一句话理解它和 JS 的关系:
| JavaScript | WebAssembly | |
|---|---|---|
| 形态 | 文本源码 | 二进制字节码 |
| 可读性 | 人能直接看 | 需要工具反编译 |
| 谁编译的 | 不用编译,浏览器解释/即时编译 | 由 C/Rust 等编译而来 |
| 速度 | 快 | 更快,接近原生 |
| 逆向难度 | 低 | 中高 |
在逆向场景里,WASM 常被用来做两件事:
- 加密 / 签名算法:把 MD5、SHA、自研 hash、魔改算法编译进去,JS 里只留一行
window.q = instance.exports.encode,让你在 JS 里看不到任何逻辑。 - 性能敏感的计算:比如图像处理、游戏物理引擎。
本题就属于第一种: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' }
]
两个关键结论:
imports: []:这个 wasm 不依赖任何浏览器/JS 环境,意味着任何能跑 wasm 的环境都能执行它(Node、Python 的 wasmtime/wasm3、甚至浏览器)。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/xor | 32 位整数运算 | 结果要回绕到 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 方案)。