从 202 到 200:一次 aws-waf-token 纯 Python 还原实录

文章目录

    • 声明
    • [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

它模拟了最小浏览器环境:

  • window
  • document
  • navigator
  • location
  • crypto
  • localStorage
  • sessionStorage
  • fetch
  • performance
  • screen

这一步的目的不是"最终依赖 JS SDK",而是把 SDK 当成显微镜:

  • 它到底请求了哪些接口?
  • /verify body 长什么样?
  • 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.stringifyJSON.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 复制代码
不是魔法。
只是每个字节都终于站对了位置。
相关推荐
Amo Xiang2 天前
SpiderDemo 第5题:OB混淆实战 —— 反调试绕过与 signature 签名还原
python·js逆向·爬虫逆向·反调试·spiderdemo·ob混淆
冰履踏青云3 天前
宏翼平台登录参数逆向
js逆向·宏翼平台登录
Amo Xiang3 天前
申万宏源证券新闻中心 —— AES/ECB 响应解密(摩斯电码派生密钥)
js逆向·python爬虫·逆向工程·aes加密·响应解密
冰履踏青云3 天前
某音x-tt-session-dtrait 算法逆向复盘
js逆向·session-dtrait
如烟花的信页4 天前
*花顺cookie逆向分析
javascript·爬虫·python·js逆向
Amo Xiang4 天前
SpiderDemo 第1题:请求头检测挑战 —— Disable cache 缓存头与请求特征差异
js逆向·爬虫逆向·spiderdemo·tls指纹·请求头检测·disable cache
Amo Xiang5 天前
全国新书网 —— AES/CBC 双向加解密
js逆向·python爬虫·cryptojs·前端加密·aes加解密·pycryptodome
如烟花的信页5 天前
加速乐cookie逆向分析
javascript·爬虫·python·js逆向
Amo Xiang5 天前
福建公共资源交易平台 —— MD5 签名 + AES 响应解密
js逆向·python爬虫·md5·cryptojs·前端加密·axios拦截器·aes解密