第 10 关:RSA 传密钥 + AES 加密报文(顺带一层 JavaScript 混淆)
- 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 第 10 关的解密教程。这一关把「RSA + AES 混合加密」和「JavaScript 混淆」凑在一起:请求头
sm是每次现算的,报文也不是明文,靶场还只放你书单里的前四页。
靶场:/books?challenge=CH-010(登录后打开) 参考解法:参考代码/s12.py
现象:请求头里有个看不懂的长串
打开靶场,F12 → Network,只有一条 POST /api/challenges/CH-010/data:
sm: NBUhZ9tR...(很长的 base64)@P1q8oX...(另一段 base64)- 没有请求体:参数全在
sm里; - 响应体不是 JSON:一串 base64 文本;
- 页面上的 5 个页码里最后 1 个是灰的,怎么点都不动;
- 把这条请求「Copy as cURL」重放一次 —— 大概率报
请求已过期。
辨型:两段字符 = RSA 传密钥、AES 传报文
真实站点(spa16.scrape.center 那一类)就是这套组合拳,原因很实在:
| 算法 | 用途 | 为什么 |
|---|---|---|
| RSA | 只加密对称密钥 | 非对称加密慢、还有长度上限,塞不下整页数据 |
| AES | 加密报文本体 | 对称加密快,能加密任意长度 |
| JS 混淆 | 让密钥/算法不好直接在源码里搜到 | 抬高静态分析成本 |
所以 sm 的两段是:
sm = base64( RSA_PKCS1_v1_5(AES 密钥明文) ) + "@" + base64( AES-128-CBC(明文 JSON) )定位资源:脚本里躺着的三件套
<head> 里依次加载三个脚本:
/static/vendor/crypto-js.js # AES(和第 11 关共用一份)/static/vendor/jsencrypt.min.js # RSA/static/books/security.js # 混淆过的业务逻辑:生成 sm + 解密响应security.js 是 JavaScript Obfuscator 的产物:字符串全部收进数组、控制流被平坦化、还塞了死代码。别人怎么挖它的?两条路:
- 动态 hook(最快)——在控制台劫持几个函数,刷新页面,参数自己就打印出来了:
const raw = JSEncrypt.prototype.setPublicKeyJSEncrypt.prototype.setPublicKey = function (key) { console.log('PUBLIC KEY =\n' + key); return raw.call(this, key) }
const enc = CryptoJS.AES.encryptCryptoJS.AES.encrypt = function (plain, key, cfg) { console.log('明文:', plain, '密钥:', key.toString(CryptoJS.enc.Utf8)); return enc.apply(this, arguments) }- 静态解混淆——用 de4js / synchrony 之类的工具还原字符串数组。注意:混淆器不只做了 base64,还把结果大小写翻转了一遍,所以直接
grep BEGIN是搜不到的。把数组元素抓出来,swapcase()之后再 base64 解码,才能看到 PEM:
for token in re.findall(r"['\"]([A-Za-z0-9+/=]{40,})['\"]", security_source): for candidate in (token, token.swapcase()): text = base64.b64decode(candidate + "=" * (-len(candidate) % 4)).decode("utf-8") if "BEGIN PUBLIC KEY" in text: print(text) # ← RSA 公钥到手(公钥本来就是公开的)还原公式
key_text = 16 个 0-9a-f 的随机串 # 每次请求现造一个plain = json.dumps({username, path, timestamp, pageNumber, pageSize})aes_part = base64(AES_CBC_PKCS7(plain, key_text, iv="LS2026CH010IV000"))rsa_part = base64(RSA_PKCS1_v1_5(key_text, public_key))sm = rsa_part + "@" + aes_part服务端会逐项核对(顺序大致如下),这就是为什么”复用同一个 sm 重放”必然失败:
| 检查 | 不通过时的表现 |
|---|---|
| 能否解出 RSA 段 + AES 段 | 400 报文格式不正确(此时它还没法用你的密钥加密回复,只能明文说) |
username 等于当前登录账号 | 请求与登录账号不一致 |
path 等于接口路径 | 请求路径与签名不一致 |
abs(now - timestamp) <= 300 秒 | 请求已过期,请重新生成凭据(注意时间戳是毫秒) |
pageNumber 在 1..5 范围内 | 页码超出范围(一共 5 页) |
页码只有序号:GET /api/challenges/CH-010/init(页面初始化时就会请求)只返回 username、page_count、visible_pages、page_size —— 不告诉你数据库里到底是哪五页。页面只放出前 visible_pages(=4)页,第 5 页只能自己写脚本取。
序号 n 对应哪一页数据由服务端按账号决定(每个账号一套,同人稳定、人人不同),所以在本地自己造一个 sm 去请求序号 1、2、3、4、5,就是你该拿的全部数据。
响应体同样是 AES:base64(AES_CBC_PKCS7(JSON, key_text, 同一个 IV)),用这次请求的 keyText 解。
完整脚本
见 参考代码/s12.py(grab_public_key + build_sm + decrypt + 逐页汇总)。核心就三行:
sm, key_text = build_sm(public_key, username, page, page_size) # 现造密钥并封装(page 是序号)body = decrypt(key_text, session.post(DATA_PATH, headers={"sm": sm}).text)total += sum(row["id"] for row in body["data"]["list"]) # 五页取齐后求和跑出来大概长这样:
账号 xiajiao 的书单:共 5 页(页面只放出前 4 页),每页 18 条第 1 页:18 条,本页 id 合计 123642696...五页书籍 id 总和:601884792(提交这个数字)排查清单
RSA.import_key报 “RSA key format is not supported”:PEM 的换行丢了。从混淆产物里解出来的字符串里是\n转义,解码后要保留换行(s12.py里直接strip()即可)。- 解出来是乱码:IV 不对(必须是脚本里那个 16 字节常量)、密钥长度不对(16 字符 ≠ 16 位十六进制数,是按 UTF-8 取字节)、或者你用了 AES-ECB。
请求已过期:时间戳单位写成了秒,Date.now()是毫秒。页码超出范围:序号只有1..5(/init的page_count),别把数据库页码当参数传。400 报文格式不正确:sm的分隔符不是@、RSA 段没做 base64、或者公钥抄错了。- 响应解不开:注意用的是本次请求的 keyText,不是第一次那个;每次请求都要重新生成。
常见问题
Q:为什么不能只 RSA、或只 AES? A:只 RSA 传不了长报文;只 AES 就得把密钥写在前端(等于公开)。混合加密是两者的折中:密钥每次随机、且只能被持有私钥的服务端读到。
Q:公钥是公开的,那这套加密还有意义吗?
A:有。它防的是「别人拿到你的报文改改就能用」——服务端核对 username/path/timestamp/页码,等于给请求绑上了会话与时效。
Q:为什么不干脆用浏览器自动化点五页? A:可以,但那是把「每页都要重算密钥」的成本交给浏览器,而且不方便汇总求和。真实项目里更常见的是脚本直连。
最后提醒
这套东西只在授权范围内的练习环境里练手。真实站点有服务条款和法律法规,未经允许的批量抓取可能违规甚至违法。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!












