Python 爬虫加密实战:ROT + Base32 + 凯撒 + HMAC-MD5 组合(CH-006)
- 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 第 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/dataX-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 base64import hashlibimport hmacimport jsonimport 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 = 5ALPHABET = "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。
排查清单
- 先拿
/init的参数,别硬编码(每个账号不同)。 - 自己实现一遍三个变换,并用「正着加密、反着解密」自测一次。
- 签名 403 → 检查 raw 的四段拼接顺序与分隔符、摘要是不是 hex、ROT 是不是做了两遍。
- 解密报错 → 打印中间结果:
unrotated应该是 Base32 字符(A-Z2-7),不是的话说明rot47Offset用错了。 - Base32 解码前先清洗字符(去掉
?、空格、小写要转大写)并补=。 - 参数 JSON 用
separators=(",", ":")(不要有空格),两边必须完全一致。 - 网页上只放出前 4 页,第 5 页必须靠接口。
常见问题
这是真加密吗?
不是。ROT/凯撒/Base32 都是编码或古典替换,没有密钥强度可言;它们的价值只是「挡一挡随手抓包的人」。真正能扛的是后面的 MAC 签名和会话校验。
为什么签名用 MD5 而不是 SHA-256?
这里只是把常见套路凑齐。HMAC-MD5 作为 MAC 在「防篡改」场景仍然可用,但不要把它当哈希用在口令上(那是另一回事)。
rot13Offset 为什么叫这个名字?
因为它就是 ROT-N 的偏移量,N=13 时正好是经典的 ROT13(自逆)。这里的 N 是随机的,所以不能自逆 —— 这就是第一个坑。
最后提醒
学习加密伪装的目的,是能看懂、能定位、能写报告,而不是去构造攻击。靶场里随便试;真实站点上,抓取前先确认授权与规则。
去挑战广场提交,然后进 CH-007:数据会变成「一个字都看不到」的字体库映射。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!












