视频加载失败

Python 爬虫接口签名实战:参数签名 + 会话绑定(CH-005)

1486 字
7 分钟
Python 爬虫接口签名实战:参数签名 + 会话绑定(CH-005)

本文是 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)"}

响应里会把页数信息给它。也就是说:

  1. 页面在浏览器里用 crypto.randomUUID() 生成一个 uuid;
  2. 调 /session 把这个 uuid 绑定到当前登录账号(这一步需要登录 Cookie);
  3. 之后取数接口只认这个 uuid(不再要求登录 Cookie),并把「这一页的和」记在 uuid 名下。

这也解释了题面为什么要求「把 5 页都取一遍」:某页没取过,提交时会被点名。

签名怎么算#

签名算法同样在前端 bundle 里(NumbersChallengeView.vue),用 crypto.subtle.digest("SHA-256", ...) 算:

raw = f"/numbers|{page}|{timestamp}|{salt}" # 注意前导斜杠
hash = sha256(raw).hexdigest() # 十六进制,不是 Base64
t = 毫秒时间戳
s = "XJ2026" # 就是盐本身,明文带在参数里
uuid = 页面生成、已经绑定过的那个

服务端比对时忽略大小写(hmac.compare_digest(digest.lower(), ...))。

⚠️ 三个最容易翻车的地方:

坑现象
raw 里少了前导斜杠(写成 numbers|...)永远 403
把摘要写成 Base64永远 403
用别人(或另一个浏览器)的 uuid403;提交时则是 409「不属于当前账号」

完整脚本#

"""CH-005:答案 = 5 页共 50 个数字的总和(提交时带 uuid)
两个关键点:
1. 签名 raw 是 f"/numbers|{page}|{timestamp}|{salt}",**带前导斜杠**,结果是 sha256 的 hex
2. 取数只认 uuid —— 这个 uuid 要先由页面(或你)调 /session 绑定到账号
(绑定那次请求需要登录 Cookie,之后取数就不需要了)
"""
import hashlib
import time
import 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 交卷。

排查清单#

  1. 先看页面自己发的请求:除了取数接口,往往还有一个「初始化 / 绑定」接口。
  2. 403 且无解释 → 去前端 bundle 里搜签名关键词(sha256、subtle、hash)。
  3. 对一下 raw 字符串的每一段和分隔符(前导斜杠、竖线、字段顺序)。
  4. 确认摘要是 hex 还是 Base64 —— 这两者最容易混。
  5. 先取第 1 页打通,再写循环。
  6. 5 页都取过再提交(漏页会被点名)。
  7. 提交时带 uuid。
  8. 取数失败先分辨是 403(签名/uuid 问题)还是 503(后端 Redis 不可用)。

常见问题#

t 有时间校验吗?#

这一关的 t 只参与签名,服务端不校验时效(参考实现也是这么写的)。真实站点常常会校验时间窗口,所以养成「现算现用」的习惯没坏处。

为什么要设计成「uuid 绑定」?#

真实业务里,前端 SDK 会生成一个 sessionId / traceId 串起整段会话,后端用它做风控统计。这里是把它做成了「取数凭证」,让学员体会「会话标识也能成为一道门」。

少取一页会怎么样?#

提交时会返回 409,并明确告诉你缺第几页 —— 这个提示是故意给的,因为「漏页」不算攻击面。

最后提醒#

签名参数、会话凭证这类设计在真实站点上很常见,逆向它们时要清楚边界:只在自己的靶场或获得授权的目标上练习,别拿别人的接口做压力测试。

去挑战广场提交,然后进 CH-006:这次换一套古典密码,签名与响应都用「你认识的算法」重新包了一遍。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Python 爬虫接口签名实战:参数签名 + 会话绑定(CH-005)
https://jsnote.top/posts/ls-ch5/
作者
xiajiao
发布于
2026-10-02
许可协议
CC BY-NC-SA 4.0
相关文章智能推荐
1
Python 爬虫接口实战:翻页取完再累加(CH-002)
爬虫靶场本文是 LearnSpider 第 2 关的解密教程。这一关的数据不在 HTML 里,只能从分页接口拿;答案是全部电影「评分总人数」的累加值。重点是:怎么找到接口、怎么确认它没把总量直接给你、以及怎么把 21 页稳稳翻完。
2
Python 爬虫字体映射实战:HTML 里一个字都没有(CH-007)
爬虫靶场本文是 LearnSpider 第 7 关的解密教程。这一关把大众点评那套「字体库 + 坐标」搬了过来:页面 HTML 里只有随机类名,文字全在字体库(SVG 文字地图)里,而「每个类名要把图挪多少像素」是接口加密下发的。目标是拿到 5 段…
3
Python 爬虫实战:随机下发路径的资源怎么下载(CH-003)
爬虫靶场本文是 LearnSpider 第 3 关的解密教程。这一关要把图片下载下来,答案是这张图片的字节数。真正的考点是:拿到接口给的 image_id / image_url 之后,别把「字节」和「字符」搞混,也别拖太久(图片是随机下发的)。
4
Python 爬虫 TLS 指纹实战:requests 被挂住时怎么查(CH-004)
爬虫靶场本文是 LearnSpider 第 4 关的解密教程。这一关的接口数据本身是明文 JSON,难点在你根本连不上:requests 会一直卡到超时,服务端什么都不返回。答案是 5 页共 50 条关键词的 CPC 之和(保留 2 位小数)。
5
Python 爬虫加密实战:ROT + Base32 + 凯撒 + HMAC-MD5 组合(CH-006)
爬虫靶场本文是 LearnSpider 第 6 关的解密教程。这一关把「古典密码」和「MAC 签名」拼在一起:请求参数要被 ROT-N 两次,响应要按 ROT47 → Base32 → 凯撒反着剥三层,签名则是 HMAC-MD5 再做两次 ROT-…
随机文章随机推荐

评论区

Profile Image of the Author
xiajiao
写爬虫,也写防爬虫的靶场。
公告
欢迎来到我的博客!这是一则示例公告。
分类
标签
站点统计
文章
15
分类
1
标签
33
总字数
22,317
运行时长
0 天
最后活动
0 天前
站点信息
构建平台
Local
博客版本
Firefly v6.16.8
文章许可
CC BY-NC-SA 4.0
文章目录