网站地址:爬虫练习网 spa15
这一例是「爬虫练习网」里专门练 WebAssembly(wasm) 的一关。接口返回的数据是真的电影榜单,但请求里多了一个叫
token的参数,不带它就 403。难点在于:生成 token 的那段 JS 被编译成了.wasm二进制,你在 Sources 里点开只能看到一堆字节码,读不懂。别慌------这关其实不需要你去啃 wasm 里的汇编,我们照样能把它拿下。
一、找加密参数:哪个请求带了 token
打开网站,按 F12 调出开发者工具,切到 Network(网络) 面板,勾选 Fetch/XHR,刷新页面。你会看到一个发往列表接口的请求:
GET https://spa15.scrape.center/api/movie/?limit=10&offset=0&token=MDViMWRhOGU3MTE2NmY4ZTQ3YTRlYjcwMjgxOTBkOWQzMzA0OTBkZiwxNzkzMTUyMDQ2
(这条是我用调试工具真实抓下来的,不是编的。)
它的 Query 参数长这样:
limit = 10
offset = 0
token = MDViMWRhOGU3MTE2NmY4ZTQ3YTRlYjcwMjgxOTBkOWQzMzA0OTBkZiwxNzkzMTUyMDQ2
limit、offset 是翻页用的,一眼就懂;可疑的就是那串又长又像乱码的 token。它就是本例要还原的加密参数。
二、定位 token 在哪生成:先全局搜一把
token 是拼在 URL 上发出去的,那它一定是在发请求之前,由某段 JS 算出来的。我们到 Sources(源代码) 面板里按 Ctrl+Shift+F 做全局搜索,关键词先用最直白的 token------能搜到一堆,噪音大。换个思路:既然这一关叫「wasm」,那页面里多半加载了一个 .wasm 文件,直接搜 Wasm.wasm 更准。

图里能看到:搜索框输入 Wasm.wasm,底部提示「Found 1 matching line in 1 file」,命中在 chunk-1e26c652.6d5384a8.js 里,代码是 var lt="Wasm.wasm"; function ht(t){ try{ if(t==lt&&E) return new Uint8Array(E); ... throw "both async and sync fetching of the wasm failed" } ... }。
给完全没接触过 wasm 的同学补一句白话:WebAssembly 是一种能塞进浏览器里跑的「二进制字节码」 ,性能高、但人眼读不了(打开是一堆 \x00\x61\x73\x6d)。网站把「怎么算 token」这件事编译进 wasm,就是为了让你没法像看普通 JS 那样直接读懂。它通常配一个「胶水文件」(就是命中这个 chunk),负责:把 .wasm 下载下来 → 实例化 → 把里面的函数包装成 JS 能调用的样子。
三、看胶水代码:token 是调用 wasm 的哪个函数算的
顺着命中,我们把这个 chunk 用 Ctrl+P 打开,点右下角 {} 做 Pretty print(美化) (不然全挤在一行没法看),再 Ctrl+F 搜 encrypt:

图里两处关键信息:
-
上面是加载 wasm 的胶水:
WebAssembly.instantiateStreaming(...)、t["instantiatiewasm"]------ 确认了「先实例化 wasm 模块」; -
最底下高亮那行是重点:
t["_encrypt"] = function() {
return (t["_encrypt"] = t["asm"]["encrypt"]).apply(null, arguments)
}
翻译成人话:wasm 模块实例化后,它对外暴露的函数都挂在 t.asm 上;这里把 wasm 里的 encrypt 函数取出来,包装成 t._encrypt。也就是说------token 是调用 wasm 的 encrypt 函数算出来的。
到这一步,「扣 wasm」的标准做法就出来了:既然 token = encrypt(入参),而 encrypt 是 wasm 里的一个导出函数,那我们根本不用去读懂 wasm 内部 ,只要把同一个 Wasm.wasm 文件拿到 Node 里,补一个最小的运行环境,直接调用它的 encrypt,就能得到和浏览器一模一样的 token。
四、先别急着啃 wasm:把线上 token 反解一下,看看它长什么样
在动手扣 wasm 之前,我习惯先把抓到的真实 token 拆一拆,往往能省一大半力气。这串 token 看着像 base64(结尾没有 = 但字符集是 base64 的样子),那就先 base64 解码:
MDViMWRhOGU3MTE2NmY4ZTQ3YTRlYjcwMjgxOTBkOWQzMzA0OTBkZiwxNzkzMTUyMDQ2
↓ base64 解码
05b1da8e71166f8e47a4eb7028190d9d330490df,1793152046
结构一下就清楚了,它是「一段 40 位十六进制字符串 + 逗号 + 一段 10 位数字」拼起来再做 base64:
05b1da8e71166f8e47a4eb7028190d9d330490df------ 40 个十六进制字符,正好是 160 位,这是 SHA1 摘要的典型长度;1793152046------ 10 位,是秒级的 Unix 时间戳。
于是大胆猜一句:token = base64( sha1( 某段原文 ) + "," + 时间戳 )。而 wasm 的 encrypt 收的入参,从调用处看就是「接口路径」和「时间戳」两样。
五、判断是不是「标准 SHA1」:看着像不算数,必须验
「看着像 sha1」和「它真的是标准 sha1」是两回事 ,很多网站会在 sha1 前后加盐、改拼接顺序(也就是所谓的魔改)。所以这里必须做相互证明:用标准算法在本地重算一遍,和线上真实值逐字节对齐。
我试了几种拼法,命中是 sha1(url + "," + 时间戳),其中 url = "/api/movie"(注意:不带请求 URL 里那个尾部斜杠)。用 Python 标准库直接验:
import hashlib, base64
url, ts = "/api/movie", "1793152046"
sha1_hex = hashlib.sha1((url + "," + ts).encode()).hexdigest()
token = base64.b64encode((sha1_hex + "," + ts).encode()).decode()
print(token)
真实跑出来的结果(本地重算 vs 线上抓包):
线上真实 token : MDViMWRhOGU3MTE2NmY4ZTQ3YTRlYjcwMjgxOTBkOWQzMzA0OTBkZiwxNzkzMTUyMDQ2
纯Python复现 : MDViMWRhOGU3MTE2NmY4ZTQ3YTRlYjcwMjgxOTBkOWQzMzA0OTBkZiwxNzkzMTUyMDQ2
本地 == 线上 : MATCH: True ✅
一模一样。说明这个 wasm 里藏的就是一个没被魔改的标准 SHA1 ,配方是 sha1("/api/movie," + 时间戳)。
顺带一提,为了确认「wasm 内部确实也是这么算的」,我也用扣 wasm 的办法(把
Wasm.wasm丢进 Node 调encrypt)跑了一遍,喂相同的(url, 时间戳),得到的 token 和上面、和线上完全一致。两条路殊途同归,互相印证,可以放心。
六、端到端证明:用本地造的 token 去请求,服务器认不认
前面证明的是「我能算出和线上一样的 token」,但服务器会不会因为时间戳太旧、或另有校验而拒绝?再做一步端到端:用「此刻」的时间戳本地生成一个新 token,直接打接口。
本地新 token : ZDgyOWQzZjViOWRjZGNiOGQwMjVjN2Y1ZTc4OGQ5Yjg2YjI5MzBlOCwxNzkzMTUyNDkw
HTTP 返回 : 200 count = 104 本页条数 = 10
第一条电影 : 霸王别姬 | Farewell My Concubine | 评分 9.5
服务器照单全收,返回真实榜单。至此,相互证明(本地==线上)+ 端到端证明(服务器接受本地 token) 双证齐全,纯算还原成立。
七、完整代码(纯算版,三行核心,连 wasm 都不用跑)
一旦看清配方,最省事的写法根本不需要 Node、也不需要 wasm,纯 Python 就够:
import base64, hashlib, time, requests
def make_token(url="/api/movie", ts=None):
ts = str(ts) if ts is not None else str(int(time.time()))
sha1_hex = hashlib.sha1((url + "," + ts).encode()).hexdigest()
return base64.b64encode((sha1_hex + "," + ts).encode()).decode()
def fetch_page(offset=0, limit=10):
return requests.get(
"https://spa15.scrape.center/api/movie/",
params={"limit": limit, "offset": offset, "token": make_token()},
headers={"Referer": "https://spa15.scrape.center/page/1",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36"},
timeout=20,
).json()
for page in range(3):
d = fetch_page(offset=page * 10)
print(f"第 {page + 1} 页:", " / ".join(x["name"] for x in d["results"][:5]))
跑起来:
第 1 页: 霸王别姬 / 这个杀手不太冷 / 肖申克的救赎 / 泰坦尼克号 / 罗马假日
第 2 页: V字仇杀队 / 少年派的奇幻漂流 / 美丽心灵 / 初恋这件小事 / 借东西的小人阿莉埃蒂
第 3 页: 黄金三镖客 / 海洋 / 我爱你 / 阿飞正传 / 7号房的礼物
八、如果你遇到的是「真·魔改 wasm」怎么办(扣 wasm 通用法)
本例运气好,wasm 里面就是个标准 sha1,反解一下就露馅了。但很多 wasm 案例是真把自定义算法藏进了字节码,反解不出来。这时候就用第三节说的「扣 wasm」通用法:不读内部,直接把 .wasm 拿来在 Node 里跑,调它的导出函数。核心就三步:
fs.readFileSync读出Wasm.wasm,用WebAssembly.instantiate(buffer, imports)实例化------imports里补上它需要的env.memory / env.table / wasi_snapshot_preview1.proc_exit等(缺什么补什么,看报错);- 写两个小工具:
字符串 → wasm内存指针(stackAlloc+ 塞字节)和指针 → 字符串(按\0截断读回); - 拿到
exports.encrypt,把入参(url、时间戳)转成指针丢进去,返回值再转回字符串。
这套「补最小环境 + 调导出函数」的模板,我在案例目录里也存了一份 59_wasm_gen.js,换个 wasm 站点基本改改函数名和入参就能复用。
小结
token = base64( sha1("/api/movie," + 秒级时间戳) + "," + 秒级时间戳 ),是标准 SHA1、无魔改;- wasm 只是把这段逻辑「藏」起来,读不懂字节码没关系:先反解 token 结构 常常直接看穿;看不穿就扣 wasm ,在 Node 里调它的
encrypt; - 判断「标准 / 魔改」不能靠猜,一定要本地重算 == 线上真实值 ,再补一步服务器接受本地 token 的端到端证明。
本例代码、真实抓包样本(_live_token_req.json)和两种生成脚本(59_纯算token.py、59_wasm_gen.js)都放在 js逆向百例/059-爬虫练习网wasm 目录下,可自行复现。
如有问题请及时提出,加油加油。