视频加载失败

Python 爬虫加密实战:ROT + Base32 + 凯撒 + HMAC-MD5 组合(CH-006)

1363 字
7 分钟
Python 爬虫加密实战:ROT + Base32 + 凯撒 + HMAC-MD5 组合(CH-006)

本文是 LearnSpider 第 6 关的解密教程。这一关把「古典密码」和「MAC 签名」拼在一起:请求参数要被 ROT-N 两次,响应要按 ROT47 → Base32 → 凯撒反着剥三层,签名则是 HMAC-MD5 再做两次 ROT-N。答案是 5 页共 50 个数字的总和。

现象:接口参数和响应都「不像人话」#

靶场页 /cipher?challenge=CH-006。F12 里能看到两个请求:

1) 取参数(GET)

GET /api/challenges/CH-006/init
{
"rot13Offset": 7,
"rot47Offset": 31,
"caesarShift": 11,
"salt": "9f2c1d0b7a4e5c88",
"total_pages": 5,
"visible_pages": 4,
"page_size": 10
}

四个参数就是这一关的四把钥匙(按账号派生,同一个账号每次一样)。

2) 取数据(POST)

POST /api/challenges/CH-006/data
X-ROT-Sign: XywUaIsNnAvLxYdcIhSnyqcJBsDpvSJr
{"rotParams": "{\"Qwde\": 1, \"Wqjprx...\": ...}"}

响应:

{"encrypted": true, "rotData": "p&XmzB1#kQ...(一串 ASCII 可见字符)"}

签名头是字母混排(看着像凯撒),请求体和响应体都是可见字符但读不懂 —— 这就是典型的「古典密码套娃」。

四把钥匙分别管什么#

参数用在哪
rot13Offset(1-25)① 请求参数 JSON 做两次 ROT-N;② 签名摘要(hex)也做两次 ROT-N
rot47Offset(1-93)响应体最外层:ASCII 33~126 的循环平移(ROT47 的推广)
caesarShift(1-25)响应体最内层:对 Base32 解出来的 JSON 文本做凯撒
salt(16 位 hex)HMAC-MD5 的密钥,同时也出现在签名 raw 里

两个必须记住的坑#

坑 1:ROT-N 做两次 ≠ 原样。 只有 N=13 时两次才会互相抵消(罗13表)。这里是 N=7、N=11 之类的值,所以:

rot_letters(rot_letters("hello", 7), 7) # "spaar" —— 不是 hello

签名只能用 +N 做两遍(服务端就是这么算的),不能偷懒直接用 HMAC-MD5 的结果。

坑 2:Base32 解码要补齐填充。 去掉噪声字符后长度可能不是 8 的倍数,要自己补 =:

cleaned = "".join(c for c in text.upper() if c in "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567")
base64.b32decode(cleaned + "=" * ((-len(cleaned)) % 8))

响应是怎么加密的(正序)#

明文 JSON
→ 凯撒(caesarShift) 只动字母
→ Base32 编码 变成 A-Z2-7
→ ROT-N(rot47Offset) 在 ASCII 33~126 上平移 → 可见字符

解密就是反过来:ROT47 反向 → Base32 解码 → 凯撒反向 → json.loads。

完整脚本#

"""CH-006:答案 = 5 页共 50 个数字的总和"""
import base64
import hashlib
import hmac
import json
import time
import requests
BASE = "https://spider.jsnote.top"
ROUTE = "/cipher" # 签名里带前导斜杠
INIT_URL = f"{BASE}/api/challenges/CH-006/init"
DATA_URL = f"{BASE}/api/challenges/CH-006/data"
TOTAL_PAGES = 5
ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
# ---------- 三个基础变换 ----------
def rot_letters(text, offset):
"""凯撒 / ROT-N:只动字母,大小写各自成环(offset 允许为负 = 反向)。"""
step = offset % 26
out = []
for char in str(text):
if "a" <= char <= "z":
out.append(chr((ord(char) - 97 + step) % 26 + 97))
elif "A" <= char <= "Z":
out.append(chr((ord(char) - 65 + step) % 26 + 65))
else:
out.append(char)
return "".join(out)
def rot_printable(text, offset):
"""ROT-N over ASCII 33..126(ROT47 的推广)。"""
step = offset % 94
out = []
for char in str(text):
code = ord(char)
out.append(chr((code - 33 + step) % 94 + 33) if 33 <= code <= 126 else char)
return "".join(out)
def base32_decode(text):
cleaned = "".join(char for char in str(text).upper() if char in ALPHABET)
return base64.b32decode(cleaned + "=" * ((-len(cleaned)) % 8)).decode()
# ---------- 签名 / 参数 / 解密 ----------
def sign(config, page, timestamp):
"""HMAC-MD5(raw, salt) 的 hex,再做两次 ROT-N(N = rot13Offset)。"""
raw = f"{ROUTE}|{page}|{timestamp}|{config['salt']}"
digest = hmac.new(config["salt"].encode(), raw.encode(), hashlib.md5).hexdigest()
offset = config["rot13Offset"]
return rot_letters(rot_letters(digest, offset), offset)
def encode_params(config, page, timestamp):
"""参数是「两次 ROT-N 之后的 JSON 字符串」,直接当字段值发出去。"""
raw = json.dumps({"page": page, "timestamp": timestamp}, separators=(",", ":"))
offset = config["rot13Offset"]
return rot_letters(rot_letters(raw, offset), offset)
def decrypt(config, rot_data):
"""ROT47 还原 → Base32 解码 → 凯撒还原 → JSON。"""
unrotated = rot_printable(rot_data, -config["rot47Offset"])
unbased = base32_decode(unrotated)
return json.loads(rot_letters(unbased, -config["caesarShift"]))
def main():
session = requests.Session()
session.headers.update({
"Content-Type": "application/json",
"Referer": f"{BASE}/cipher?challenge=CH-006",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/147.0.0.0 Safari/537.36",
})
session.cookies.set("session", "<把浏览器里的 session cookie 粘到这里>",
domain="spider.jsnote.top")
config = session.get(INIT_URL, timeout=10).json()
print("拿到钥匙:", {k: config[k] for k in
("rot13Offset", "rot47Offset", "caesarShift", "salt")})
total = 0
for page in range(1, TOTAL_PAGES + 1):
timestamp = str(int(time.time() * 1000))
response = session.post(DATA_URL, headers={
"X-ROT-Sign": sign(config, page, timestamp),
}, data=json.dumps({
"rotParams": encode_params(config, page, timestamp),
}), timeout=10)
if response.status_code == 403:
raise SystemExit("403:签名不对。先确认 raw 是 '/cipher|page|timestamp|salt',再做两遍 ROT-N")
response.raise_for_status()
payload = decrypt(config, response.json()["rotData"])
numbers = payload["data"]
total += sum(numbers)
print(f"第 {page} 页:{numbers} → 累计 {total}")
time.sleep(0.5)
print(f"\n答案:{total}")
if __name__ == "__main__":
main()

参考实现见 参考代码/s6.py。

排查清单#

  1. 先拿 /init 的参数,别硬编码(每个账号不同)。
  2. 自己实现一遍三个变换,并用「正着加密、反着解密」自测一次。
  3. 签名 403 → 检查 raw 的四段拼接顺序与分隔符、摘要是不是 hex、ROT 是不是做了两遍。
  4. 解密报错 → 打印中间结果:unrotated 应该是 Base32 字符(A-Z2-7),不是的话说明 rot47Offset 用错了。
  5. Base32 解码前先清洗字符(去掉 ?、空格、小写要转大写)并补 =。
  6. 参数 JSON 用 separators=(",", ":")(不要有空格),两边必须完全一致。
  7. 网页上只放出前 4 页,第 5 页必须靠接口。

常见问题#

这是真加密吗?#

不是。ROT/凯撒/Base32 都是编码或古典替换,没有密钥强度可言;它们的价值只是「挡一挡随手抓包的人」。真正能扛的是后面的 MAC 签名和会话校验。

为什么签名用 MD5 而不是 SHA-256?#

这里只是把常见套路凑齐。HMAC-MD5 作为 MAC 在「防篡改」场景仍然可用,但不要把它当哈希用在口令上(那是另一回事)。

rot13Offset 为什么叫这个名字?#

因为它就是 ROT-N 的偏移量,N=13 时正好是经典的 ROT13(自逆)。这里的 N 是随机的,所以不能自逆 —— 这就是第一个坑。

最后提醒#

学习加密伪装的目的,是能看懂、能定位、能写报告,而不是去构造攻击。靶场里随便试;真实站点上,抓取前先确认授权与规则。

去挑战广场提交,然后进 CH-007:数据会变成「一个字都看不到」的字体库映射。

文章分享

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

Python 爬虫加密实战:ROT + Base32 + 凯撒 + HMAC-MD5 组合(CH-006)
https://jsnote.top/posts/ls-ch6/
作者
xiajiao
发布于
2026-10-02
许可协议
CC BY-NC-SA 4.0
相关文章智能推荐
1
Python 爬虫字体加密实战:码位混淆 + 自定义字体文件(CH-008)
爬虫靶场本文是 LearnSpider 第 8 关的解密教程。这一关是真正的字体加密:价格和销量是一串 Uncode 私有区字符(\ue1a2),站点用「字体文件」说明每个码位的字形长什么样。目标是还原 30 件商品的价格/销量,提交价格总和(保留…
2
Python 爬虫验证码实战:AES 加密报文 + 每页算术验证码(CH-009)
爬虫靶场本文是 LearnSpider 第 9 关的解密教程。这一关把政务站点常见的一套反爬凑齐了:请求/响应都是 AES-ECB 加密的 hex,还要带一个前端写死的 Adam-AppKey,而且每翻一页都得先过一道算术验证码。目标是取 10 页…
3
Python 爬虫接口签名实战:参数签名 + 会话绑定(CH-005)
爬虫靶场本文是 LearnSpider 第 5 关的解密教程。这一关的接口参数要签名,而且取数只认一个「会话标识」(uuid)—— 这个 uuid 是页面自己生成的、并且已经在后端和你的账号绑定过。答案是 5 页共 50 个数字的总和。
4
Python 爬虫接口实战:翻页取完再累加(CH-002)
爬虫靶场本文是 LearnSpider 第 2 关的解密教程。这一关的数据不在 HTML 里,只能从分页接口拿;答案是全部电影「评分总人数」的累加值。重点是:怎么找到接口、怎么确认它没把总量直接给你、以及怎么把 21 页稳稳翻完。
5
Python 爬虫字体映射实战:HTML 里一个字都没有(CH-007)
爬虫靶场本文是 LearnSpider 第 7 关的解密教程。这一关把大众点评那套「字体库 + 坐标」搬了过来:页面 HTML 里只有随机类名,文字全在字体库(SVG 文字地图)里,而「每个类名要把图挪多少像素」是接口加密下发的。目标是拿到 5 段…
随机文章随机推荐

评论区

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