第 14 关:Cookie 签名 —— 七层复合加密,和「密钥写在前端」这件事
- 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 第 14 关的解密教程。这一关的数据分 5 页、每页 10 个数字,但接口只认一个签过名的
signCookie:这个 Cookie 是前端一段混淆过的 JS连着过七层算法算出来的(拼盐 → MD5 派生密钥 → 链式 XOR 流加密 → 16 字节矩阵转置 → S-box 替换 → 位置混淆 → 自定义字母表 base64)。页面只帮你签前 4 页,第 5 页得自己签。真正要记住的不是那七层,而是结尾那句:七层看着吓人,但没有一层是秘密 —— 盐、S-box、字母表全在 JS 里,读了就能给任意页码签名。这正是「把密钥交给客户端」这种做法的天花板。
靶场:/sign?challenge=CH-014(登录后打开) 参考解法:参考代码/s16.py 签名源码(混淆前):参考代码/ch014-sign.src.js
本关的层次结构对着 参考代码/s14.js 搭 —— 那是从某真实站点扒出来的一段签名脚本,也是「拼盐 → 哈希 → MD5 派生密钥做流加密 → 矩阵转置 → 再哈希 → 矩阵乘 → 十六进制」这么一长串。真实站点管这叫「JS 加密」「七层签名」,本质一样。
现象
- 打开靶场,页面上是一张 10 行的数字表 + 一排页码按钮
1 2 3 4 5:前 4 个正常,第 5 个按钮上挂着一把小锁。 - 点 1~4 任意一页,表里的数字马上换掉 —— 页面自己签好 Cookie 再请求,你什么都不用管。
- 点第 5 页,页面只会给你一句话:
第 5 页不在页面里 —— 得自己把签名算出来(算法就在 /static/sign/sign.js 里)- F12 → Network,整关只有两条接口(都是在登录之后发的):
GET /api/challenges/CH-014/init -> {"cookie_name":"sign","total_pages":5,"visible_pages":4,"page_size":10}GET /api/challenges/CH-014/data?page=1 -> 200 {"code":"0","data":[362,783,...]} ← 必须带 sign CookieGET /api/challenges/CH-014/data?page=5 -> 403(页面没给它签)- Application → Cookies 里能看到那个 Cookie:
sign = 2v6ewxxz9cPu3Sp24OI_4K…一眼就看得出不是标准 base64:里面出现 - _,也没有末尾的 =。
- 三个「不能作弊」的细节,F12 里试两下就知道:
| 做法 | 结果 |
|---|---|
不带 sign Cookie 请求 ?page=1 | 403 {"reason":"missing"} |
拿第 1 页的 Cookie 去要 ?page=5 | 403 {"reason":"page"} —— 页码被签进 Cookie 了 |
| 拿 6 分钟前的 Cookie 去请求 | 403 {"reason":"expired"} —— 时间戳也被签进去了 |
页面上的
reason字段只是给排查用的粗分类,真实提示只有一句「签名无效或已过期」。服务端不会告诉你差在哪,cookie=...的原文只写服务端日志。
分层:这一关要拆七层
| 层 | 做了什么 | 还原要点 |
|---|---|---|
| L1 | 规范化拼串 body = f"{page}.{timestamp_ms}" | 签的其实就是「哪一页 + 什么时候」 |
| L2 | 派生密钥 KEY = md5("LS14.spot2026") | 盐写死在 JS 里 —— 这是全关最大的一处弱点 |
| L3 | 链式 XOR 流加密 c[i] = p[i] ^ ks[i%16] ^ c[i-1],ks = md5(KEY) | 和 CBC 一个味道;⚠️ 补零必须在加密之前 |
| L4 | 每 16 字节当成 4×4 矩阵转置 | 转置是自逆的,正反都用同一个函数 |
| L5 | 256 字节固定 S-box 替换(AES SubBytes 那个味道) | 表就是 JS 里那个 256 项的数组 |
| L6 | 位置混淆:(b + i*7 + 13) & 0xFF,再按 i%7+1 循环左移 | 逐字节可逆,但顺序不能乱 |
| L7 | 用打乱的 64 字符表做 base64(无 padding) | 字母表也在 JS 里 |
七层里没有一层用到真正的秘密:L2 的盐、L5 的表、L7 的字母表,全都在前端那一份 JS 里明摆着 —— 只是被混淆器换了个长相。
第一步:先把 Cookie 的用法搞清楚
别急着读算法,先确认签名绑了什么,这决定了你要重算什么:
init = session.get("/api/challenges/CH-014/init").json()# {"cookie_name": "sign", "total_pages": 5, "visible_pages": 4, "page_size": 10}
session.cookies.set("sign", "") # 不给签名session.get("/api/challenges/CH-014/data?page=1") # -> 403 missing
session.cookies.set("sign", cookie_of_page_1) # 拿第 1 页的session.get("/api/challenges/CH-014/data?page=5") # -> 403 page三个结论:
- 签名是按页签的(页码进签名),所以第 5 页不能复用第 4 页的 Cookie;
- 签名是按时签的(时间戳进签名,窗口 5 分钟),所以抄一个 Cookie 存起来慢慢用会过期;
- 签名只认 Cookie,不认 query / header,名字由
/init下发(sign)。
到这一步你已经能画出要重算的东西:给定页码 page,算出 sign 的合法值 = 过一遍七层。时间戳用当前毫秒就行。
第二步:把混淆脚本扒开(只有两招障眼法)
/static/sign/sign.js 是 JavaScript Obfuscator 的产物:控制流平坦化、十六进制变量名、死代码、字符串数组……别硬读。这个混淆器在这里其实只用了两招真正影响「能不能抄表」的手法:
招式一:数字被写成了算术表达式
产物里 S-box 不是 [195,71,12,...],而是:
[0x2*0x508+0x2117+-0x2a64, 0x1a42+-0x2178+0x27f*0x3, -0x10c1+-0xc43+0xf0*0x1f, ...]求个值就是原数(0x2*0x508+0x2117+-0x2a64 = 195)。256 项的那个数组就是 L5 的 S-box。
招式二:字符串数组用的不是标准 base64 字母表
字符串被塞进一个大数组、各自 base64 了一遍,但它所使用的字母表把大小写两半调换了:
var _0x5d73a6 = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/=';// 标准 base64 是 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='所以直接 base64.b64decode 会解出一堆乱码(这一点和 CH-012 的字符串表不一样,那个是别的变体)。把字母表换回去再解,长度 86 的那一条就是 L7 的 64 字符自定义字母表。
两招合起来,抠表的代码只有十几行:
import base64, re
JS_B64 = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/="STD_B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="
def grab_tables(sign_source: str): # ① S-box:混淆器把数字写成算术表达式,求值即可(只放行十六进制字面量和 +-*) sbox = [] for block in re.findall(r"\[[^\[\]]{300,}\]", sign_source): parts = block[1:-1].split(",") if len(parts) == 256 and all(re.fullmatch(r"[-+0-9a-fx*]+", part) for part in parts): sbox = [eval(part) & 0xFF for part in parts] break
# ② 字母表:字符串数组项是「换过字母表的 base64」 alphabet = "" for token in re.findall(r"'([A-Za-z0-9+/=]{40,})'", sign_source): fixed = token.translate(str.maketrans(JS_B64, STD_B64)) decoded = base64.b64decode(fixed + "=" * (-len(fixed) % 4)).decode("ascii", "replace") if len(decoded) == 64 and len(set(decoded)) == 64: alphabet = decoded break return alphabet, sbox还有一条更省事的路:页面上的
window.LSSign就是签名器本体,控制台里LSSign.applyCookie(5)一句话就能签第 5 页。能调用就等于能签 —— 这也正是本关要让你体会的那件事。但手写一遍七层,你才知道「混淆」到底挡住了什么。
第三步:把七层用 Python 复刻一遍
从 参考代码/s16.py 里摘出核心(完整版见文件):
def sign(page, timestamp_ms, *, alphabet, sbox): body = f"{int(page)}.{int(timestamp_ms)}".encode() # L1 if len(body) % 16: # ⚠️ 补零要在加密之前 body += b"\x00" * (16 - len(body) % 16)
key = hashlib.md5(b"LS14.spot2026").digest() # L2 stream = hashlib.md5(key).digest() # L3 的密钥流
encrypted, previous = bytearray(), 0 # L3 链式 XOR for index, byte in enumerate(body): value = byte ^ stream[index % 16] ^ previous encrypted.append(value) previous = value
transposed = bytearray() # L4 4×4 转置 for start in range(0, len(encrypted), 16): block = encrypted[start:start + 16] for column in range(4): for row in range(4): transposed.append(block[row * 4 + column])
substituted = bytes(sbox[byte] for byte in transposed) # L5 S-box
mixed = bytearray() # L6 位置混淆 for index, byte in enumerate(substituted): bits = index % 7 + 1 value = (byte + index * 7 + 13) & 0xFF mixed.append(((value << bits) | (value >> (8 - bits))) & 0xFF) # 循环左移 bits 位
accumulator, bits, out = 0, 0, [] # L7 自定义 base64(无 padding) for byte in mixed: accumulator = (accumulator << 8) | byte bits += 8 while bits >= 6: bits -= 6 out.append(alphabet[(accumulator >> bits) & 0x3F]) if bits: out.append(alphabet[(accumulator << (6 - bits)) & 0x3F]) return "".join(out)四个「一抄就错」的地方,全是我踩过的:
- 补零必须在加密之前。先加密再补零,那些零没被加密,服务端倒推回来就是垃圾 —— 表现为「怎么算都 403」,而且看不出原因。
- L6 是位置相关的:正着来的顺序是「先加偏移、再循环左移」,所以逆着来要先循环右移、再减偏移。顺序反了同样只是「403 看不出原因」。
- 逆层顺序要严格反着:正向是
L4 转置 → L5 S-box,所以解的时候先逆 L5(用逆 S-box),再逆 L4。这两层写反是这一关最容易犯的错。 - 如果顺手用 JS 对照实验,注意 JS 的
>>是带符号右移:写循环左移必须是(v << c) | (v >>> (32 - c));另外x >>> 56会把位移量按 32 取模变成x >>> 24,处理 64 位长度时得拆成高低 32 位分别写。
第四步:怎么确认自己抄对了(不用猜)
页面自己签的前 4 页就是现成的答案,拿它当测试向量:
- F12 → Application → Cookies,把第 1 页那次请求的
sign值抄下来; - 时间戳没法从 Cookie 里看出来(它被加密了),但能猜:签名就是刚刚这一分钟里生成的,于是从「当前时间」往前一秒一秒试:
for offset in range(120): guess = int(time.time() * 1000) - offset * 1000 if sign(1, guess, alphabet=alphabet, sbox=sbox) == sampled_cookie: print("对上了:七层还原正确,时间戳 =", guess) break参考代码/s16.py 的自检段就是这么干的。对上了再往下走 —— 这一关只要你把七层还原对了,后面的活就只有拼请求了。
另外两个顺手的对照点:
- 解出来的明文必须是
b"1.1760000000000"这种形状(页码.毫秒,尾部补零要去掉); - 长度必须对得上:
页码.毫秒是 15 个字符 → 补到 16 → 加密 16 字节 → base64 出来是 22 个字符(无 padding)。长度不对就是 L4/L7 抄错了。
第五步:五页取齐,求和
total = 0for page in range(1, 6): # 页面只会签 1~4 页,第 5 页得自己签 —— 差别只在这里 cookie = sign(page, int(time.time() * 1000), alphabet=alphabet, sbox=sbox) session.cookies.set("sign", cookie, domain="spider.jsnote.top") payload = session.get(f"/api/challenges/CH-014/data?page={page}").json() total += sum(payload["data"])print(total) # 提交这个总和跑法:
# 1) 浏览器登录靶场,F12 -> Application -> Cookies,抄下 session 的值# 2) 填进 参考代码/s16.py 的 SESSION_COOKIEpython 参考代码/s16.py脚本会先把两张表从 sign.js 里抠出来,再让你粘一个页面上的 sign 做自检,然后老老实实签 5 次、取 5 页、求和。
每个账号的 50 个数字是按账号派生的(同一个人稳定、人与人不同),所以别拿别人的总和去提交。
这一关真正要记住的:为什么「七层」不解决问题
- 盐、S-box、字母表全在前端 = 读者即拥有者。混淆只是把「读代码」变成「费点劲读代码」,它挡的是懒得读代码的人,不是攻击者。你能还原一次,就能给任意页码、任意时间戳签名。
- 更极端的是本关那条捷径:
LSSign.applyCookie(5)—— 能调用就等于能签。哪怕你把算法藏在 VM 里(见 CH-011 / CH-012),只要它必须在浏览器里跑出结果,调用方就一定能拿到结果。 - 正确的做法是密钥不出服务端:用 HMAC 对参数签名(服务端持密钥)、或服务端下发一次性 token、再叠上服务端风控和限速。本站 CH-005 就是这个套路(HMAC-MD5 签名 + 会话绑定),CH-010 是另一个变体(RSA 公钥在前端、私钥在服务端)—— 那两关的「客户端多算一层」是为了不泄密,而不是为了加密给别人看。
排查清单(⚠️ 先看前三条)
- 403 且
reason是format/ 怎么算都不对:九成是补零顺序、逆层顺序(先逆 L5 再逆 L4)、或者 L6 的移位/偏移反了。 - 403 且
reason是page:Cookie 里签的页码和?page=不一致 —— 别复用别的页的签名。 - 403 且
reason是expired:时间戳是毫秒,而且服务端只认 5 分钟内;本地时间跑偏也会踩到。 - 长度不对(不是 22 / 44 …):L4 的转置块大小别写成别的数,
body要补齐到 16 的倍数。 - 抠出来的 S-box 只有 255 项 / 有重复:正则抓歪了(别用
\d+,产物里是十六进制表达式)。 - 解出来的字母表里有
+/但没有 64 个字符:忘了把混淆器那个大小写调换的字母表换回标准 base64。 - 自检一直对不上:先确认你抄的那个
sign是不是同一页的、以及是不是刚刚才生成的(超过 5 分钟就没意义了)。 - 换了账号还是同一批数字:数字是按
user_id派生的,换个账号当然就变了 —— 别把数字硬编码进脚本。 - 请求 401:
sessionCookie 没带上(域名 / path 设错了)。 - 想省事直接用
LSSign.applyCookie(5):能过,但那就只是「调用」而不是「还原」;本关的分数想拿得踏实,还是把七层写出来。
FAQ
Q:为什么非要有 L4 转置和 L6 移位这种”看不出用途”的层? 真实站点也这么干:层数本身就是门槛。对写脚本的人来说,多一层就要多对一次答案;对防护方来说,成本几乎为零。理解了这一点,你就知道为什么看到「七层加密」不该先慌。
Q:verify 为什么不直接返回「第几层解错了」?
服务端只回一句「签名无效或已过期」,原因写日志。真实站点也这样 —— 把失败原因告诉调用方,等于给自动化脚本送调试信息。
Q:我能不能只逆推出「第 5 页」而不实现正向签名? 可以,但要先用正向实现自检一次:没有测试向量,你不知道自己逆出来的东西是不是对的。这一关最容易走的路是「正向复刻 + 拿 1~4 页的现成 Cookie 自检」。
Q:时间戳是干什么用的? 它的作用是限时:抄来的 Cookie 只有 5 分钟寿命,逼你现场算签名。真实站点还会把时间戳和会话/随机数绑在一起(一次一密),思路一致 —— 让「重放」失效。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!












