视频加载失败

第 14 关:Cookie 签名 —— 七层复合加密,和「密钥写在前端」这件事

3394 字
17 分钟
第 14 关:Cookie 签名 —— 七层复合加密,和「密钥写在前端」这件事

本文是 LearnSpider 第 14 关的解密教程。这一关的数据分 5 页、每页 10 个数字,但接口只认一个签过名的 sign Cookie:这个 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 加密」「七层签名」,本质一样。

现象#

  1. 打开靶场,页面上是一张 10 行的数字表 + 一排页码按钮 1 2 3 4 5:前 4 个正常,第 5 个按钮上挂着一把小锁。
  2. 点 1~4 任意一页,表里的数字马上换掉 —— 页面自己签好 Cookie 再请求,你什么都不用管。
  3. 点第 5 页,页面只会给你一句话:
第 5 页不在页面里 —— 得自己把签名算出来(算法就在 /static/sign/sign.js 里)
  1. 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 Cookie
GET /api/challenges/CH-014/data?page=5 -> 403(页面没给它签)
  1. Application → Cookies 里能看到那个 Cookie:
sign = 2v6ewxxz9cPu3Sp24OI_4K…

一眼就看得出不是标准 base64:里面出现 - _,也没有末尾的 =。

  1. 三个「不能作弊」的细节,F12 里试两下就知道:
做法结果
不带 sign Cookie 请求 ?page=1403 {"reason":"missing"}
拿第 1 页的 Cookie 去要 ?page=5403 {"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 矩阵转置转置是自逆的,正反都用同一个函数
L5256 字节固定 S-box 替换(AES SubBytes 那个味道)表就是 JS 里那个 256 项的数组
L6位置混淆:(b + i*7 + 13) & 0xFF,再按 i%7+1 循环左移逐字节可逆,但顺序不能乱
L7用打乱的 64 字符表做 base64(无 padding)字母表也在 JS 里

七层里没有一层用到真正的秘密:L2 的盐、L5 的表、L7 的字母表,全都在前端那一份 JS 里明摆着 —— 只是被混淆器换了个长相。

别急着读算法,先确认签名绑了什么,这决定了你要重算什么:

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)

四个「一抄就错」的地方,全是我踩过的:

  1. 补零必须在加密之前。先加密再补零,那些零没被加密,服务端倒推回来就是垃圾 —— 表现为「怎么算都 403」,而且看不出原因。
  2. L6 是位置相关的:正着来的顺序是「先加偏移、再循环左移」,所以逆着来要先循环右移、再减偏移。顺序反了同样只是「403 看不出原因」。
  3. 逆层顺序要严格反着:正向是 L4 转置 → L5 S-box,所以解的时候先逆 L5(用逆 S-box),再逆 L4。这两层写反是这一关最容易犯的错。
  4. 如果顺手用 JS 对照实验,注意 JS 的 >> 是带符号右移:写循环左移必须是 (v << c) | (v >>> (32 - c));另外 x >>> 56 会把位移量按 32 取模变成 x >>> 24,处理 64 位长度时得拆成高低 32 位分别写。

第四步:怎么确认自己抄对了(不用猜)#

页面自己签的前 4 页就是现成的答案,拿它当测试向量:

  1. F12 → Application → Cookies,把第 1 页那次请求的 sign 值抄下来;
  2. 时间戳没法从 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 = 0
for 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) # 提交这个总和

跑法:

Terminal window
# 1) 浏览器登录靶场,F12 -> Application -> Cookies,抄下 session 的值
# 2) 填进 参考代码/s16.py 的 SESSION_COOKIE
python 参考代码/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 公钥在前端、私钥在服务端)—— 那两关的「客户端多算一层」是为了不泄密,而不是为了加密给别人看。

排查清单(⚠️ 先看前三条)#

  1. 403 且 reason 是 format / 怎么算都不对:九成是补零顺序、逆层顺序(先逆 L5 再逆 L4)、或者 L6 的移位/偏移反了。
  2. 403 且 reason 是 page:Cookie 里签的页码和 ?page= 不一致 —— 别复用别的页的签名。
  3. 403 且 reason 是 expired:时间戳是毫秒,而且服务端只认 5 分钟内;本地时间跑偏也会踩到。
  4. 长度不对(不是 22 / 44 …):L4 的转置块大小别写成别的数,body 要补齐到 16 的倍数。
  5. 抠出来的 S-box 只有 255 项 / 有重复:正则抓歪了(别用 \d+,产物里是十六进制表达式)。
  6. 解出来的字母表里有 + / 但没有 64 个字符:忘了把混淆器那个大小写调换的字母表换回标准 base64。
  7. 自检一直对不上:先确认你抄的那个 sign 是不是同一页的、以及是不是刚刚才生成的(超过 5 分钟就没意义了)。
  8. 换了账号还是同一批数字:数字是按 user_id 派生的,换个账号当然就变了 —— 别把数字硬编码进脚本。
  9. 请求 401:session Cookie 没带上(域名 / path 设错了)。
  10. 想省事直接用 LSSign.applyCookie(5):能过,但那就只是「调用」而不是「还原」;本关的分数想拿得踏实,还是把七层写出来。

FAQ#

Q:为什么非要有 L4 转置和 L6 移位这种”看不出用途”的层? 真实站点也这么干:层数本身就是门槛。对写脚本的人来说,多一层就要多对一次答案;对防护方来说,成本几乎为零。理解了这一点,你就知道为什么看到「七层加密」不该先慌。

Q:verify 为什么不直接返回「第几层解错了」? 服务端只回一句「签名无效或已过期」,原因写日志。真实站点也这样 —— 把失败原因告诉调用方,等于给自动化脚本送调试信息。

Q:我能不能只逆推出「第 5 页」而不实现正向签名? 可以,但要先用正向实现自检一次:没有测试向量,你不知道自己逆出来的东西是不是对的。这一关最容易走的路是「正向复刻 + 拿 1~4 页的现成 Cookie 自检」。

Q:时间戳是干什么用的? 它的作用是限时:抄来的 Cookie 只有 5 分钟寿命,逼你现场算签名。真实站点还会把时间戳和会话/随机数绑在一起(一次一密),思路一致 —— 让「重放」失效。

文章分享

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

第 14 关:Cookie 签名 —— 七层复合加密,和「密钥写在前端」这件事
https://jsnote.top/posts/ls-ch14/
作者
xiajiao
发布于
2026-10-03
许可协议
CC BY-NC-SA 4.0
相关文章智能推荐
1
第 10 关:RSA 传密钥 + AES 加密报文(顺带一层 JavaScript 混淆)
爬虫靶场本文是 LearnSpider 第 10 关的解密教程。这一关把「RSA + AES 混合加密」和「JavaScript 混淆」凑在一起:请求头 sm 是每次现算的,报文也不是明文,靶场还只放你书单里的前四页。
2
第 11 关:无限 debugger + VM 混淆 + 签名请求头
爬虫靶场本文是 LearnSpider 第 11 关的解密教程。这一关的 JS 防守最"脏":无限 debugger(关键字还是拼出来的)、base64 源码交给 new Function 在运行时编译、请求头 m 要现算签名,票价则靠 base6…
3
第 13 关:点选验证码 + 一次一密的轨迹报文 + 行为风控
爬虫靶场本文是 LearnSpider 第 13 关的解密教程。这一关的靶场是一张点选验证码:把图上所有文字点掉。看着像「识图题」,真正的坎却在后面 —— 提交的不是「点了哪几个坐标」,而是一整段鼠标轨迹(时间、坐标、速度),用 AES+RSA 加…
4
第 15 关:蜜罐与爬虫封禁 —— 数据是假的,陷阱是真的
爬虫靶场本文是 LearnSpider 第 15 关的解密教程。这一关的靶场是最普通不过的一个 HTML 列表页:不加密、不验签、不弹验证码,5 页 × 10 条公告,谁都能抓。难的是你不知道自己抓到的是不是真的 —— 页面里埋了三类「只给爬虫准备…
5
第 12 关:接口下发 JS + 字符串混淆 + 内置 MD5 + 反调试
爬虫靶场本文是 LearnSpider 第 12 关的解密教程。这一关换了个思路:页面本身几乎是空的,真正的防守逻辑不在静态文件里,而是登录后由接口现发一段 JS 下来。这段 JS 里:关键字全被十六进制转义映射($dbsm_0x5d57)、一段字…
随机文章随机推荐

评论区

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