Python 爬虫接口签名实战:参数签名 + 会话绑定(CH-005)
- 1Python 爬虫入门实战:服务端渲染页面怎么取数(CH-001)
- 2Python 爬虫接口实战:翻页取完再累加(CH-002)
- 3Python 爬虫实战:随机下发路径的资源怎么下载(CH-003)
- 4Python 爬虫 TLS 指纹实战:requests 被挂住时怎么查(CH-004)
- 5Python 爬虫接口签名实战:参数签名 + 会话绑定(CH-005)本文
- 6Python 爬虫加密实战:ROT + Base32 + 凯撒 + HMAC-MD5 组合(CH-006)
- 7Python 爬虫字体映射实战:HTML 里一个字都没有(CH-007)
- 8Python 爬虫字体加密实战:码位混淆 + 自定义字体文件(CH-008)
- 9Python 爬虫验证码实战:AES 加密报文 + 每页算术验证码(CH-009)
- 10第 10 关:RSA 传密钥 + AES 加密报文(顺带一层 JavaScript 混淆)
- 11第 11 关:无限 debugger + VM 混淆 + 签名请求头
- 12第 12 关:接口下发 JS + 字符串混淆 + 内置 MD5 + 反调试
- 13第 13 关:点选验证码 + 一次一密的轨迹报文 + 行为风控
- 14第 14 关:Cookie 签名 —— 七层复合加密,和「密钥写在前端」这件事
- 15第 15 关:蜜罐与爬虫封禁 —— 数据是假的,陷阱是真的
本文是 LearnSpider 第 5 关的解密教程。这一关的接口参数要签名,而且取数只认一个「会话标识」(uuid)—— 这个 uuid 是页面自己生成的、并且已经在后端和你的账号绑定过。答案是 5 页共 50 个数字的总和。
现象:接口返回 forbidden,什么都不说
打开靶场页 /numbers?challenge=CH-005,页面正常渲染出前 4 页数字。F12 → Network,取数请求长这样:
GET /api/challenges/CH-005/data?page=1&uuid=9f1c...&hash=8b1d...&t=1790936209983&s=XJ2026四个看起来像随机的参数,外加一个固定值 s。手动去掉 hash 再发一次,得到的是:
{"error": "forbidden"}没有「缺少参数」也没有「签名错误」——服务端故意什么都不说。这种时候就要回到页面自己的请求里找线索。
先看页面做了什么
刷新靶场页时还有一个 POST 请求,它比取数接口更值得看:
POST /api/challenges/CH-005/session{"uuid": "9f1c...(标准 UUID)"}响应里会把页数信息给它。也就是说:
- 页面在浏览器里用
crypto.randomUUID()生成一个 uuid; - 调
/session把这个 uuid 绑定到当前登录账号(这一步需要登录 Cookie); - 之后取数接口只认这个 uuid(不再要求登录 Cookie),并把「这一页的和」记在 uuid 名下。
这也解释了题面为什么要求「把 5 页都取一遍」:某页没取过,提交时会被点名。
签名怎么算
签名算法同样在前端 bundle 里(NumbersChallengeView.vue),用 crypto.subtle.digest("SHA-256", ...) 算:
raw = f"/numbers|{page}|{timestamp}|{salt}" # 注意前导斜杠hash = sha256(raw).hexdigest() # 十六进制,不是 Base64t = 毫秒时间戳s = "XJ2026" # 就是盐本身,明文带在参数里uuid = 页面生成、已经绑定过的那个服务端比对时忽略大小写(hmac.compare_digest(digest.lower(), ...))。
⚠️ 三个最容易翻车的地方:
| 坑 | 现象 |
|---|---|
raw 里少了前导斜杠(写成 numbers|...) | 永远 403 |
| 把摘要写成 Base64 | 永远 403 |
| 用别人(或另一个浏览器)的 uuid | 403;提交时则是 409「不属于当前账号」 |
完整脚本
"""CH-005:答案 = 5 页共 50 个数字的总和(提交时带 uuid)
两个关键点: 1. 签名 raw 是 f"/numbers|{page}|{timestamp}|{salt}",**带前导斜杠**,结果是 sha256 的 hex 2. 取数只认 uuid —— 这个 uuid 要先由页面(或你)调 /session 绑定到账号 (绑定那次请求需要登录 Cookie,之后取数就不需要了)"""import hashlibimport timeimport uuid as uuid_lib
import requests
BASE = "https://spider.jsnote.top"BIND_URL = f"{BASE}/api/challenges/CH-005/session"DATA_URL = f"{BASE}/api/challenges/CH-005/data"ROUTE = "/numbers" # 签名里的路径,带前导斜杠SALT = "XJ2026"TOTAL_PAGES = 5
session = requests.Session()session.headers.update({ "Accept": "application/json, text/plain, */*", "Referer": f"{BASE}/numbers?challenge=CH-005", "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36" ),})# 只有「绑定 uuid」这一步需要登录态session.cookies.set("session", "<把浏览器里的 session cookie 粘到这里>", domain="spider.jsnote.top")
def sign(page: int, timestamp: str, salt: str = SALT) -> str: raw = f"{ROUTE}|{page}|{timestamp}|{salt}"
# 如果你怎么算都是 403,先打出来对一下服务端那行的哈希: # sha256("/numbers|1|1790936209983|XJ2026") # = 3a03b48eda815c1a0fac05d817831fa9d2787e836b47d0cfdbf7431e5f8a2a21 return hashlib.sha256(raw.encode()).hexdigest()
def bind_account(): """生成 uuid 并绑定到当前账号;页面就是这么干的。""" uuid_value = str(uuid_lib.uuid4()) response = session.post(BIND_URL, json={"uuid": uuid_value}, timeout=10) if response.status_code == 401: raise SystemExit("绑定失败:这一步需要登录 Cookie") response.raise_for_status() print("已绑定 uuid:", uuid_value, response.json()) return uuid_value
def main(): uuid_value = bind_account()
total = 0 for page in range(1, TOTAL_PAGES + 1): timestamp = str(int(time.time() * 1000)) response = session.get(DATA_URL, params={ "page": page, "uuid": uuid_value, "hash": sign(page, timestamp), "t": timestamp, "s": SALT, }, timeout=10)
if response.status_code == 403: raise SystemExit( "403:签名不对。自查 ——\n" ' · raw 是 "/numbers|<page>|<timestamp>|<salt>"(带前导斜杠)\n' " · 摘要是 sha256 的 hex(不是 Base64)\n" " · uuid 得是绑定过的那个" ) response.raise_for_status()
numbers = response.json()["numbers"] total += sum(numbers) print(f"第 {page} 页:{numbers} → 累计 {total}") time.sleep(0.5)
print(f"\n答案:{total}") print("提交时记得带上同一个 uuid:" f'POST /api/challenges/CH-005/submit {{"answer": {total}, "uuid": "{uuid_value}"}}')
if __name__ == "__main__": main()参考实现见 参考代码/s5.py(里面还留了「前导斜杠」那个坑的注释)。
为什么「只带 uuid、不带 Cookie」也能取数
这是刻意设计的:绑定一次之后,uuid 就是这次采集会话的凭证。好处是采集脚本可以把 uuid 抄走、跨进程复用;坏处也一样 —— 因此有两条保护:
- uuid 必须存在(没绑定过 → 403);
- 提交答案时会核对 uuid 是否属于当前登录账号(不是自己的 → 409)。
所以别指望用别人的 uuid 交卷。
排查清单
- 先看页面自己发的请求:除了取数接口,往往还有一个「初始化 / 绑定」接口。
- 403 且无解释 → 去前端 bundle 里搜签名关键词(
sha256、subtle、hash)。 - 对一下 raw 字符串的每一段和分隔符(前导斜杠、竖线、字段顺序)。
- 确认摘要是 hex 还是 Base64 —— 这两者最容易混。
- 先取第 1 页打通,再写循环。
- 5 页都取过再提交(漏页会被点名)。
- 提交时带 uuid。
- 取数失败先分辨是 403(签名/uuid 问题)还是 503(后端 Redis 不可用)。
常见问题
t 有时间校验吗?
这一关的 t 只参与签名,服务端不校验时效(参考实现也是这么写的)。真实站点常常会校验时间窗口,所以养成「现算现用」的习惯没坏处。
为什么要设计成「uuid 绑定」?
真实业务里,前端 SDK 会生成一个 sessionId / traceId 串起整段会话,后端用它做风控统计。这里是把它做成了「取数凭证」,让学员体会「会话标识也能成为一道门」。
少取一页会怎么样?
提交时会返回 409,并明确告诉你缺第几页 —— 这个提示是故意给的,因为「漏页」不算攻击面。
最后提醒
签名参数、会话凭证这类设计在真实站点上很常见,逆向它们时要清楚边界:只在自己的靶场或获得授权的目标上练习,别拿别人的接口做压力测试。
去挑战广场提交,然后进 CH-006:这次换一套古典密码,签名与响应都用「你认识的算法」重新包了一遍。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!












