第 11 关:无限 debugger + VM 混淆 + 签名请求头
- 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 第 11 关的解密教程。这一关的 JS 防守最”脏”:无限 debugger(关键字还是拼出来的)、base64 源码交给
new Function在运行时编译、请求头m要现算签名,票价则靠 base64 + CSS 伪元素拼出来。
靶场:/mover?challenge=CH-011(登录后打开) 参考解法:参考代码/s13.py
现象
- 打开靶场,页面数据正常。一按 F12,Sources 面板疯狂断住,断点停在一行看不出名堂的代码上,点继续还会再断 —— 这就是无限 debugger。
- Network 里只有一条
GET /api/challenges/CH-011/flights?page=1,请求头有个m:
m: 6cee2f75d7dab9a29548288f66fb3025_1790950486看着像”32 位十六进制 + 下划线 + 秒”。
- 页面上的票价是
¥后面几个空白的<span>,源码里搜不到数字:
<span class="mv-digit d0a6fd0"></span><span class="mv-digit d2478eb"></span>- 直接请求接口(不带
m)→403 {"code":"1","message":"请求凭据无效或已过期"}。 - 只有 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.MD5CryptoJS.MD5 = (s) => { console.log('md5 输入:', s); return raw(s) }刷新页面,你会在控制台看到形如 11234567890123XJ2026 的明文:前缀是「页码 + 毫秒时间戳」,后缀就是盐。
b) hook Function,让它把源码吐出来:
const rawFunction = window.Functionwindow.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(提交这个数字)排查清单
403 请求凭据无效或已过期:盐不对 / 页码没参与签名 / 时间戳用了秒(参与摘要的必须是毫秒)/ 本机时间与服务器差太多。- 浏览器报
String contains non ISO-8859-1 code point:请求头里放了非 ASCII(比如中文括号)。 - 一开 F12 就断:
Ctrl+F8停用断点,或给Function打补丁。 - 自己
new Function(payload)报SyntaxError:函数体要先atob解码;另外Function的最后一个参数才是函数体,前面的都是形参名(别把 base64 当形参名传)。 - 页面价格是空白:正常现象 —— 数字在 CSS 里(
mv-digit的::before)。用getComputedStyle(el, '::before').content才能看到。
常见问题
Q:这算”反爬”还是”反调试”? A:这几种手法在真实站点里通常一起出现(机票、电商比价那类页面尤其多):拖慢你读代码的速度,同时用签名保证请求是”当场生成”的。
Q:为什么非要 md5 不可?
A:它只是个便宜的摘要:既能让服务端判断”这个请求是照着我的算法生成的”,又不至于像 HMAC 那样给客户端一把真钥匙(客户端 JS 里的东西都能被扒出来,本质上是”提高门槛”,不是”绝对安全”)。
Q:既然盐能扒出来,签名还有意义吗? A:有。它把”随便构造请求”变成”你得先看懂我的 JS”,并且配合时间戳让旧签名很快失效。
最后提醒
这些手段只适合在授权范围内的练习环境里研究。真实站点有服务条款和技术措施保护,绕过它们可能违法。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!












