视频加载失败

第 11 关:无限 debugger + VM 混淆 + 签名请求头

1562 字
8 分钟
第 11 关:无限 debugger + VM 混淆 + 签名请求头

本文是 LearnSpider 第 11 关的解密教程。这一关的 JS 防守最”脏”:无限 debugger(关键字还是拼出来的)、base64 源码交给 new Function 在运行时编译、请求头 m 要现算签名,票价则靠 base64 + CSS 伪元素拼出来。

靶场:/mover?challenge=CH-011(登录后打开) 参考解法:参考代码/s13.py

现象#

  1. 打开靶场,页面数据正常。一按 F12,Sources 面板疯狂断住,断点停在一行看不出名堂的代码上,点继续还会再断 —— 这就是无限 debugger。
  2. Network 里只有一条 GET /api/challenges/CH-011/flights?page=1,请求头有个 m:
m: 6cee2f75d7dab9a29548288f66fb3025_1790950486

看着像”32 位十六进制 + 下划线 + 秒”。

  1. 页面上的票价是 ¥ 后面几个空白的 <span>,源码里搜不到数字:
<span class="mv-digit d0a6fd0"></span><span class="mv-digit d2478eb"></span>
  1. 直接请求接口(不带 m)→ 403 {"code":"1","message":"请求凭据无效或已过期"}。
  2. 只有 5 个页码,最后一个是灰的。

分层:三层伪装,逐一剥#

层手法破法
反调试无限 debugger;关键字用 ['de','bu','g','ger'].join('') 拼出来,整段逻辑藏在 base64 里由 new Function 编译先停掉断点(见下),或者干脆不跟 JS、直接 hook
请求签名m = md5(页码 + 毫秒时间戳 + 盐) + "_" + 秒找到盐,自己复现 md5
数据展示票价是 base64,页面上再用随机类名 + CSS ::before{content} 逐字画解 base64 就行;想从 DOM 抄则要去读样式表

第一步:让无限 debugger 闭嘴#

它长这样(backend/static/mover/security.js 里的真实逻辑,混淆后更难认):

var KEYWORD = ['de', 'bu', 'g', 'ger'].join('') // 静态搜 "debugger" 搜不到
function spin() {
new Function(KEYWORD)() // 每次现编译一次,断点打不住
setTimeout(spin, 700)
}
spin()

随手可用的几个办法:

  • DevTools 里按 Ctrl+F8(或 Sources 面板右上角那个「禁止进入的斜杠图标」)Deactivate breakpoints —— 最省事;
  • 在断住的那行右键 Never pause here;
  • 控制台先打补丁再刷新:
    window.Function = new Proxy(Function, { apply: (t, a) => { return t(...a) } }) // 甚至顺便 console.log 参数
  • 或者根本别跟:这一关的签名不依赖浏览器运行环境,直接 hook + 写脚本更快。

⚠️ 别用 “Disable JavaScript”:页面自己也要靠 JS 取数。

第二步:把盐挖出来#

盐(XJ2026)在 base64 里那份「VM 源码」中。三条路,任选:

a) hook 摘要函数(最快) —— 页面加载了 crypto-js,全局有 CryptoJS:

const raw = CryptoJS.MD5
CryptoJS.MD5 = (s) => { console.log('md5 输入:', s); return raw(s) }

刷新页面,你会在控制台看到形如 11234567890123XJ2026 的明文:前缀是「页码 + 毫秒时间戳」,后缀就是盐。

b) hook Function,让它把源码吐出来:

const rawFunction = window.Function
window.Function = new Proxy(rawFunction, {
apply(target, thisArg, args) { console.log('VM 源码:\n' + args[args.length - 1]); return target(...args) },
})

刷新后直接能看到那段 base64 解码后的源码(里面就有 SALT 和 buildM)。

c) 静态解 base64 —— 把产物里的长 base64 抠出来 atob。注意混淆器还给字符串做了大小写翻转(和 CH-010 抠公钥一样),所以更推荐 a/b 两条动态路线。

第三步:复现签名#

后端(和参考脚本)都在做同一件事:

m = md5(f"{page}{毫秒时间戳}{盐}") + "_" + 秒

几个容易踩的点:

  • 毫秒参与摘要,但发出去的只有秒 → 服务端把那一秒内的 1000 个毫秒值各算一遍 md5 来比对(_ch011_verify_m()就是这么做的),所以别把毫秒直接拼进字符串发过去;
  • 页码参与签名:用第 2 页的签名去请求第 1 页会被拒;
  • 分隔符只能是 ASCII:HTTP 请求头是 Latin-1 的,塞个中文全角括号进去,浏览器会直接报 Failed to read the 'headers' property from 'RequestInit': String contains non ISO-8859-1 code point (真实站点之所以敢用中文括号,是因为它把签名放在 GET 参数里)。

第四步:票价(base64 + CSS)#

接口返回的每一行:

{"id": 101, "airline": "中国联合航空", "flight": "KN5911 波音737(中)",
"depart": "13:50", "arrive": "17:30", "duration": "3小时40分",
"value": "MTQ3Mg==", // base64("1472")
"cls": ["d46c76e", "d0a6fd0", "de8a727", "d0041c8"]}

页面把 cls 逐字渲染成空 span,然后往 <head> 插一份 CSS:

[class~="d46c76e"]::before { content: "1" }
[class~="d0a6fd0"]::before { content: "4" }
...

所以在 DOM 里 textContent 永远是空的 —— 数字只活在样式表里。写脚本的人不用管这一层:base64 解开就是数字。

完整脚本#

见 参考代码/s13.py。核心就三行:

m = build_m(page) # md5(page + 毫秒 + 盐) + "_" + 秒
payload = session.get(DATA_PATH, params={"page": page}, headers={"m": m}).json()
total += sum(int(base64.b64decode(row["value"])) for row in payload["data"])

跑出来:

共 5 页(页面只放出前 4 页),每页 10 条
第 1 页:10 条,本页合计 11111
...
五页票价总和:65635(提交这个数字)

排查清单#

  1. 403 请求凭据无效或已过期:盐不对 / 页码没参与签名 / 时间戳用了秒(参与摘要的必须是毫秒)/ 本机时间与服务器差太多。
  2. 浏览器报 String contains non ISO-8859-1 code point:请求头里放了非 ASCII(比如中文括号)。
  3. 一开 F12 就断:Ctrl+F8 停用断点,或给 Function 打补丁。
  4. 自己 new Function(payload) 报 SyntaxError:函数体要先 atob 解码;另外 Function 的最后一个参数才是函数体,前面的都是形参名(别把 base64 当形参名传)。
  5. 页面价格是空白:正常现象 —— 数字在 CSS 里(mv-digit 的 ::before)。用 getComputedStyle(el, '::before').content 才能看到。

常见问题#

Q:这算”反爬”还是”反调试”? A:这几种手法在真实站点里通常一起出现(机票、电商比价那类页面尤其多):拖慢你读代码的速度,同时用签名保证请求是”当场生成”的。

Q:为什么非要 md5 不可? A:它只是个便宜的摘要:既能让服务端判断”这个请求是照着我的算法生成的”,又不至于像 HMAC 那样给客户端一把真钥匙(客户端 JS 里的东西都能被扒出来,本质上是”提高门槛”,不是”绝对安全”)。

Q:既然盐能扒出来,签名还有意义吗? A:有。它把”随便构造请求”变成”你得先看懂我的 JS”,并且配合时间戳让旧签名很快失效。

最后提醒#

这些手段只适合在授权范围内的练习环境里研究。真实站点有服务条款和技术措施保护,绕过它们可能违法。

文章分享

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

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

评论区

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