文章目录
-
- 声明
- [1. 第一眼:这个 202 不简单](#1. 第一眼:这个 202 不简单)
- [2. 先用 Node VM 打开黑盒,不把它当最终方案](#2. 先用 Node VM 打开黑盒,不把它当最终方案)
- [3. 第一层:Hashcash 不是难点,但容易让人误判](#3. 第一层:Hashcash 不是难点,但容易让人误判)
- [4. 第二层:空 signals 是假成功](#4. 第二层:空 signals 是假成功)
- [5. 抓一次完整 verify body,开始拆 telemetry](#5. 抓一次完整 verify body,开始拆 telemetry)
- [6. 用 JSON 探针捕获加密前明文](#6. 用 JSON 探针捕获加密前明文)
- [7. checksum:不是玄学,是 CRC32](#7. checksum:不是玄学,是 CRC32)
- [8. Present 密文格式:三段式](#8. Present 密文格式:三段式)
- [9. AES key:藏在混淆常量里](#9. AES key:藏在混淆常量里)
- [10. Python 生成 Present](#10. Python 生成 Present)
- [11. 最终 verify body](#11. 最终 verify body)
- [12. TLS 指纹:别让算法替网络背锅](#12. TLS 指纹:别让算法替网络背锅)
- [13. 从 static 到 generated:三阶段演进](#13. 从 static 到 generated:三阶段演进)
-
- [13.1 PoW-only 版本](#13.1 PoW-only 版本)
- [13.2 Static telemetry 模板版](#13.2 Static telemetry 模板版)
- [13.3 Generated telemetry 纯 Python 版](#13.3 Generated telemetry 纯 Python 版)
- [14. 工程实现核心流程](#14. 工程实现核心流程)
- [15. 本次踩坑清单](#15. 本次踩坑清单)
-
- [坑 1:/verify 返回 token 不等于成功](#坑 1:/verify 返回 token 不等于成功)
- [坑 2:checksum 和 solution 绑定](#坑 2:checksum 和 solution 绑定)
- [坑 3:JSON 空格会改变 checksum](#坑 3:JSON 空格会改变 checksum)
- [坑 4:Present 明文不是纯 JSON](#坑 4:Present 明文不是纯 JSON)
- [坑 5:AES-GCM tag 位置别拼错](#坑 5:AES-GCM tag 位置别拼错)
- [坑 6:IV 是 12 字节,不是 16 字节](#坑 6:IV 是 12 字节,不是 16 字节)
- [坑 7:gokuProps 要用当前页面的](#坑 7:gokuProps 要用当前页面的)
- [坑 8:TLS 指纹不是玄学,是工程变量](#坑 8:TLS 指纹不是玄学,是工程变量)
- [16. 收尾](#16. 收尾)
声明
本文章中所有内容仅供学习交流,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关,若有侵权,请私信我立即删除!
《JS逆向为爱发电》专栏素材征集令:十年饮冰,热血难凉 | JS逆向为爱发电
目标地址:
c
aHR0cHM6Ly93d3cuaW1kYi5jb20v
1. 第一眼:这个 202 不简单
直接请求首页时,响应状态不是常见的 403,而是:
text
HTTP 202
x-amzn-waf-action: challenge
这类响应的意思通常是:
你还没被拒绝,但你也别急着进门。先把门口这套题做了。
页面里能看到两类关键内容:
js
window.gokuProps = {
key: "...",
iv: "...",
context: "..."
}
以及一个 Challenge SDK:
text
https://...token.awswaf.com/.../challenge.js
这时不要急着开写算法,最怕的不是不会写代码,而是把一个"多环节校验"错看成"一个 hash 参数"。然后你就会在错误方向上越写越兴奋,最后得到一个非常稳定的失败版本。
本次链路大致是:
text
GET 首页
-> 202 challenge page
-> 提取 gokuProps 和 challenge.js
-> GET /inputs?client=browser
-> 求解 Hashcash
-> 生成 telemetry signal
-> POST /verify
-> 得到 aws-waf-token
-> 携带 token 再请求首页
-> 200
2. 先用 Node VM 打开黑盒,不把它当最终方案
前期为了快速判断 SDK 行为,我先做了一个 Node VM 环境:
text
get_waf_token.js
它模拟了最小浏览器环境:
windowdocumentnavigatorlocationcryptolocalStoragesessionStoragefetchperformancescreen
这一步的目的不是"最终依赖 JS SDK",而是把 SDK 当成显微镜:
- 它到底请求了哪些接口?
/verifybody 长什么样?- token 为什么有时能返回,但首页还是 202?
- 哪些字段是服务端真正关心的?
Node VM 很快跑通了:
text
Node VM -> /verify -> token -> 首页 200
补环境就是快的飞起,这说明方向没错。
但我们是为了研究算法实现,那就继续分析。
3. 第一层:Hashcash 不是难点,但容易让人误判
/inputs?client=browser 会返回一个 challenge,里面有:
json
{
"challenge": {
"input": "...",
"hmac": "...",
"region": "us-east-1"
},
"challenge_type": "...",
"difficulty": 8
}
本次遇到两种 Hashcash:
text
HashcashSHA2
HashcashScrypt
对应的类型 ID:
text
HashcashScrypt:
h72f957df656e80ba55f5d8ce2e8c7ccb59687dba3bfb273d54b08a261b2f3002
HashcashSHA2:
h7b0c470f0cfe3a80a9e26526ad185f484f6817d0832712a4a37a908786a6a67f
求解逻辑不复杂:
python
payload = f"{challenge_input}{checksum}{nonce}".encode()
如果是 SHA2:
python
digest = hashlib.sha256(payload).hexdigest()
如果是 Scrypt:
python
digest = hashlib.scrypt(
payload,
salt=checksum.encode(),
n=128,
r=8,
p=1,
dklen=16,
).hex()
然后检查 digest 前面是否满足指定数量的 0 bit。
比如 difficulty 是 8,就要求 digest 开头至少一个字节为 0:
text
00xxxxxxxx...
这部分写完以后,/verify 能返回 token。但问题来了:
text
/verify 有 token
首页还是 202
这就是本次第一个关键坑。
如果你只看 /verify 返回值,会以为"成功了"。但真正的判定不是 /verify 有没有 token,而是带着 token 去访问业务页面是否返回 200。
4. 第二层:空 signals 是假成功
最初的纯 Python 尝试里,body 大概是这样:
json
{
"challenge": {...},
"solution": "...",
"signals": [],
"checksum": "...",
"existing_token": null,
"client": "Browser",
"domain": "www.imdb.com",
"metrics": [],
"goku_props": {...}
}
这能让 /verify 返回 token,但这个 token 不够"硬"。首页请求继续 202。
于是可以得出一个结论:
Hashcash 只是入场券,telemetry 才是身份证。
signals 里最重要的是:
json
{
"name": "Zoey",
"value": {
"Present": "..."
}
}
这个 Present 是一大段密文。名字叫 Present,实际一点也不 present,完全不打算把自己明文展示给你。
5. 抓一次完整 verify body,开始拆 telemetry
通过 Node VM 包装 fetch,捕获 /verify 的请求 body:
js
async function wrappedFetch(url, options = {}) {
if (String(url).endsWith("/verify")) {
verifyBody = options.body || null;
}
return fetch(url, options);
}
拿到完整 body 后,发现结构如下:
json
{
"challenge": {...},
"solution": "...",
"signals": [
{
"name": "Zoey",
"value": {
"Present": "..."
}
}
],
"checksum": "B19C567E",
"existing_token": null,
"client": "Browser",
"domain": "www.imdb.com",
"metrics": [...],
"goku_props": {...}
}
此时做了一个非常有价值的实验:
- 复用捕获 body 里的
signals/checksum/metrics/goku_props - 只替换新的
challenge - 重新计算
solution
结果首页返回 200。
这说明 PoW 本身可动态求解,而 telemetry 可以暂时作为模板复用。但这还不是纯 Python 生成,只是"静态 telemetry 模板 + 动态 PoW"。
这个阶段的脚本很有用,因为它把问题缩小到了最后一段:
如何用 Python 生成
Zoey.Present和对应 checksum?
6. 用 JSON 探针捕获加密前明文
SDK 里必然会先构造 telemetry 对象,再序列化,再加密。因此可以包装 JSON.stringify 和 JSON.parse:
js
const wrappedJSON = {
stringify(value, replacer, space) {
// 记录疑似 telemetry 对象
return JSON.stringify(value, replacer, space);
},
parse(value, reviver) {
const parsed = JSON.parse(value, reviver);
// 记录 parse 后的对象
return parsed;
}
};
捕获到的 telemetry 明文对象大概包含:
json
{
"metrics": {
"fp2": 3,
"browser": 1,
"capabilities": 1,
"gpu": 1,
"dnt": 0,
"math": 0,
"screen": 0,
"navigator": 0,
"auto": 1,
"stealth": 0,
"subtle": 0,
"canvas": 1,
"formdetector": 2,
"be": 4
},
"start": 1781326152062,
"flashVersion": null,
"plugins": [],
"dupedPlugins": "unknown||1365-768-728-24-*-*-*",
"screenInfo": "1365-768-728-24-*-*-*",
"referrer": "",
"userAgent": "...Firefox/139.0",
"location": "https://www.imdb.com/",
"webDriver": false,
"capabilities": {...},
"math": {...},
"automation": {...},
"crypto": {...},
"formDetected": false,
"numForms": 0,
"numFormElements": 0,
"be": {"si": false},
"end": 1781326152067,
"errors": [...],
"version": "2.4.0",
"id": "..."
}
有意思的是,JSON.stringify 捕获到的对象没有 checksum,而 JSON.parse 后多了:
json
{
"checksum": "B19C567E"
}
这说明 SDK 内部先生成了某种:
text
checksum + separator + JSON
再 parse 回对象,把 checksum 挂进去。
继续看源码定位,发现 separator 是:
text
#
也就是:
text
B19C567E#{...telemetry json...}
7. checksum:不是玄学,是 CRC32
源码里出现了这一组组合:
text
FWCIMObjectEncoder
JSONEncoder
UTF8Encoder
HexEncoder
CRC32Calculator
这基本已经把答案写在脸上了,只差没有贴个横幅。
用 Python 对捕获到的 telemetry 对象做测试:
python
telemetry_json = json.dumps(
telemetry,
separators=(",", ":"),
ensure_ascii=False,
)
checksum = f"{zlib.crc32(telemetry_json.encode()) & 0xFFFFFFFF:08X}"
对照捕获值:
text
compact JSON CRC32: B19C567E
captured checksum: B19C567E
命中。
这里的坑非常小,但很致命:
python
json.dumps(obj)
默认会带空格,CRC32 会变。
必须使用紧凑序列化:
python
json.dumps(obj, separators=(",", ":"), ensure_ascii=False)
这就是逆向里常见的"差一个空格,世界不同"。你以为是 AES 错了,其实只是 JSON 里多了一口空气。
8. Present 密文格式:三段式
拿到 Present 后按 :: 拆分:
text
iv_b64::tag_hex::ciphertext_hex
实际形态类似:
text
Tg3ZmNcPPN9W5meO::f2586802ac2fa91ae0b4850c86fb4048::76da...
为了确认第一段是不是 IV,包装 crypto.getRandomValues:
js
crypto.getRandomValues = (arr) => {
const out = originalGetRandomValues(arr);
console.log(arr.length, Buffer.from(out).toString("hex"));
return out;
};
捕获到:
text
getRandomValues length=16
getRandomValues length=12
其中 12 字节随机值与 Present 第一段 base64 解码后完全对应。
所以第一段就是 AES-GCM 的 12 字节 IV。
第二段长度 32 hex,即 16 字节,是 GCM tag。
第三段是 ciphertext。
于是密文结构明确:
text
base64(iv).rstrip("=") + "::" + tag.hex() + "::" + ciphertext.hex()
9. AES key:藏在混淆常量里
继续看 SDK 加密逻辑,定位到:
text
AES-GCM
同时能看到一个 key provider:
js
new Encryptor(new KeyProvider())
provider 里有:
js
identifier: "Zoey"
...
真正的 AES key 来自混淆字符串常量:
text
6f71a512b1e035eaab53d8be73120d3fb68a0ca346b9560aab3e5cdf753d5e98
长度 32 字节,即 AES-256。
用 Python 解密捕获样本验证:
python
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
key = bytes.fromhex(
"6f71a512b1e035eaab53d8be73120d3fb68a0ca346b9560aab3e5cdf753d5e98"
)
plaintext = AESGCM(key).decrypt(iv, ciphertext + tag, None)
解出来正好是:
text
B19C567E#{...telemetry compact json...}
到这里,Present 的生成逻辑已经闭环。
10. Python 生成 Present
核心代码如下:
python
def encode_telemetry_signal(telemetry):
telemetry_json = json.dumps(
telemetry,
separators=(",", ":"),
ensure_ascii=False,
)
checksum = f"{zlib.crc32(telemetry_json.encode()) & 0xFFFFFFFF:08X}"
plaintext = f"{checksum}#{telemetry_json}".encode()
iv = os.urandom(12)
encrypted = AESGCM(TELEMETRY_AES_KEY).encrypt(iv, plaintext, None)
ciphertext, tag = encrypted[:-16], encrypted[-16:]
iv_b64 = base64.b64encode(iv).decode().rstrip("=")
present = f"{iv_b64}::{tag.hex()}::{ciphertext.hex()}"
return {"name": "Zoey", "value": {"Present": present}}, checksum
这段代码是本次逆向的关键落点。
它把原来 SDK 里的:
text
telemetry object
-> compact JSON
-> CRC32
-> checksum#json
-> AES-256-GCM
-> Zoey.Present
完整搬到了 Python。
11. 最终 verify body
最终 /verify body 是:
json
{
"challenge": {...},
"solution": "...",
"signals": [
{
"name": "Zoey",
"value": {
"Present": "iv::tag::cipher"
}
}
],
"checksum": "...",
"existing_token": null,
"client": "Browser",
"domain": "www.imdb.com",
"metrics": [...],
"goku_props": {...}
}
注意几点:
checksum必须和 telemetry JSON 对应。solution必须基于同一个checksum求解。signals[0].value.Present的明文里也包含同一个checksum。challenge必须用新鲜/inputs返回的对象。goku_props来自当前 challenge 页面。
这几个字段像一组联动齿轮。只改其中一个,其他不动,就会出现一种非常熟悉的工程现象:
看上去什么都没错,但就是不行。
12. TLS 指纹:别让算法替网络背锅
算法还原对了,不代表请求一定过。
这类站点除了 body 校验,还可能看:
- TLS ClientHello
- HTTP/2 行为
- Header 顺序
- UA 与 TLS 是否匹配
- Cookie 带法
本次 Python 请求使用:
python
from curl_cffi import requests
session = requests.Session(impersonate="firefox")
这一步很重要。否则你可能会遇到:
text
算法完全正确
/verify 正常
首页仍然异常
然后你会开始怀疑 AES、CRC32、Hashcash、人生规划。其实只是网络指纹不太像浏览器。
爬虫工程里有一条经验:
先把协议指纹问题和算法问题拆开。不要让它们互相背锅。
13. 从 static 到 generated:三阶段演进
本次逆向过程中我有写三个 Python 阶段:
13.1 PoW-only 版本
能力:
- 拉 challenge
- 求 Hashcash
- 发
/verify
问题:
signals为空/verify可能有 token- 首页仍然 202
结论:
text
只有 PoW 不够
13.2 Static telemetry 模板版
能力:
- 复用捕获到的 telemetry 模板
- 动态替换 challenge
- 重新求 solution
- 首页 200
意义:
text
证明 telemetry 是核心变量
缺点:
text
依赖旧捕获,不是真正算法生成
13.3 Generated telemetry 纯 Python 版
能力:
- Python 构造 telemetry
- Python 计算 CRC32 checksum
- Python AES-GCM 加密 Present
- Python 求 Hashcash
- Python 请求
/verify - Python 请求首页

结果:
text
首页 200
这才是本次目标版本。
14. 工程实现核心流程
最终脚本主流程可以概括为:
python
session = requests.Session(impersonate="firefox")
ctx = fetch_challenge(session)
signal, checksum = encode_telemetry_signal(build_telemetry())
solution, digest = solve_hashcash(
ctx.challenge["challenge_type"],
ctx.challenge["challenge"]["input"],
checksum,
int(ctx.challenge.get("difficulty", 4)),
)
body = {
"challenge": ctx.challenge["challenge"],
"solution": solution,
"signals": [signal],
"checksum": checksum,
"existing_token": None,
"client": "Browser",
"domain": "www.imdb.com",
"metrics": build_metrics(),
"goku_props": ctx.goku_props,
}
verify = session.post(f"{ctx.api_base}/verify", data=json.dumps(body, separators=(",", ":")))
token = verify.json()["token"]
home = session.get(TARGET, headers={"Cookie": f"aws-waf-token={token}"})
可以看到,最终代码并不复杂。
真正复杂的是证明每一段为什么这么写。
逆向工程经常是这样:
- 最后代码 100 行。
- 中间分析 10000 行。
- 真正值钱的是知道哪 100 行不能少。
15. 本次踩坑清单
坑 1:/verify 返回 token 不等于成功
必须拿 token 请求最终业务页面。
验收标准:
text
GET 首页 -> 200
x-amzn-waf-action -> None
坑 2:checksum 和 solution 绑定
Hashcash 的 payload 是:
text
challenge_input + checksum + nonce
所以 checksum 一变,solution 必须重新算。
坑 3:JSON 空格会改变 checksum
必须紧凑 JSON:
python
json.dumps(obj, separators=(",", ":"), ensure_ascii=False)
坑 4:Present 明文不是纯 JSON
明文是:
text
checksum#json
不是:
text
json
坑 5:AES-GCM tag 位置别拼错
Python AESGCM.encrypt() 返回:
text
ciphertext + tag
SDK 发送格式是:
text
iv_b64::tag_hex::ciphertext_hex
顺序不一样。
坑 6:IV 是 12 字节,不是 16 字节
GCM 常见 IV 长度是 12 字节。本次也是。
坑 7:gokuProps 要用当前页面的
虽然本次 telemetry AES key 是静态混淆常量,但 verify body 里仍带 goku_props。实践中不要拿旧页面字段硬塞。
坑 8:TLS 指纹不是玄学,是工程变量
用普通 requests 不一定能复现浏览器请求。这里使用 curl_cffi 的 Firefox impersonation。
16. 收尾
这次最有价值的不是某个固定 key,也不是某段 Python 代码,而是一条排查路线:
text
先证明链路,再还原算法。
先用黑盒拿样本,再用白盒解释样本。
先让静态模板跑通,再把模板替换成动态生成。
每次只改变一个变量,用最终业务响应做裁判。
反爬逆向最怕"脑补式自信"。看见 hash 就写 hash,看见 AES 就写 AES,看见 token 就以为成功。结果每一段都像,组合起来就是不对。
这次从 202 到 200,本质上不是打赢了一个接口,而是把一个黑盒拆成了几块可以验证、可以替换、可以维护的工程模块。
这也是我们逆向最需要的能力:
不迷信工具,不迷信猜测,不迷信一次成功。
只相信可复现的证据链和最终业务请求的状态码。
状态码 200 出来的那一刻,感觉很朴素:
text
不是魔法。
只是每个字节都终于站对了位置。