第 12 关:接口下发 JS + 字符串混淆 + 内置 MD5 + 反调试
- 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 第 12 关的解密教程。这一关换了个思路:页面本身几乎是空的,真正的防守逻辑不在静态文件里,而是登录后由接口现发一段 JS 下来。这段 JS 里:关键字全被十六进制转义映射(
$dbsm_0x5d57)、一段字节数组解码后丢给eval执行、内置了一份完整 MD5 用来校验数据、再加一个 500ms 轮询的反调试setInterval。
靶场:/shield?challenge=CH-012(登录后打开) 参考解法:参考代码/s14.py
现象
- 打开靶场,页面上是 10 个四位数字和一个「本页合计」,能翻 5 页,最后一个是灰的。
- 打开 F12 —— 还没看清代码,Sources 面板就开始周期性断住:断点停在一行看着没什么的调用上,点继续,半秒后又断。这就是时间轮询式反调试。
- Network 里只有三条请求:
GET /api/challenges/CH-012/init -> {"script_url": "...", "data_url": "...", "total_pages": 5, ...}GET /api/challenges/CH-012/script -> 一段 JS(Content-Type: text/javascript)GET /api/challenges/CH-012/data?page=1 -> {"code":"0","qz":[...字节数字...],"sig":"b1cd5e3ce7fb9b18e4390f76c27ec005","page":1,...}- 那段下发的 JS,静态搜
debugger、setInterval、eval、md5全都搜不到:
var $dbsm_0x5d57 = function (_0x1a2b, _0x3c4d) { var _0x1a2b = [...'\\x64\\x65\\x62\\x75\\x67\\x67\\x65\\x72', ...]; // 都是转义,看不出是什么 return _0x1a2b[_0x3c4d];};- 数据接口返回的
qz是一串数字(字节值),不是明文;旁边还有sig做校验。 - 直接改
page=6→{"code":"1","message":"页码超出范围(一共 5 页)"}。
分层:这次要拆的是”下发的代码”
| 层 | 手法 | 破法 |
|---|---|---|
| 反调试 | setInterval 每 500ms 用 (0, eval)(表[2]) 触发一次 debugger,并用 Date.now() 前后差值判断是否被停住 → 停住过就 for(;;){} 死循环 | Ctrl+F8 停用断点;或换无调试器的环境跑(脚本、Playwright) |
| 字符串混淆 | 所有关键字/函数名走 $dbsm_0x5d57(i) 字符串表,表里是 \xHH 转义 | 在控制台 [...Array(14)].forEach((_,i)=>console.log(i, $dbsm_0x5d57(i))) 直接打印全表 |
| 代码隐藏 | var qz = [...] 字节数组,XOR 后 eval 执行,真正逻辑(LSShield)到运行时才存在 | 解 XOR 就能拿到源码;或 hook eval/Function |
| 数据加密 | 数字被转成 ASCII 再用同一套 XOR 编码成 qz,附 sig = md5(明文 + 盐) | 找 XOR key 和盐,自己解 |
| 内置 MD5 | 整份 MD5 实现塞在脚本里,不依赖外部库 | 说明它只是自校验,不是”不可解密的加密” |
第一步:先把反调试按住
下发的脚本里(还原转义后)核心就这几行:
function _0x5d57_0x6() { var t0 = Date.now(); (0, eval)($dbsm_0x5d57(2)); // 表[2] = 'debugger' if (Date.now() - t0 > 200) { for (;;) {} } // 被调试器停住过 → 死循环}w[$dbsm_0x5d57(1)](_0x5d57_0x6, 500); // setInterval(fn, 500)三个要点:
debugger不是关键字写在源码里,而是从字符串表里取出来、再用间接 eval(0, eval)(...)执行 —— 所以”在debugger那行打个断点”这招没用,静态也搜不到;- 它用时间差检测调试器:真被断住时
Date.now()会跳几百毫秒以上,于是主动进死循环; 换句话说,没有调试器时它完全无害(这也就是为什么爬虫脚本可以在无头环境里正常跑); - 应对方式:DevTools 按
Ctrl+F8(Deactivate breakpoints),或在setInterval被调用前更省事的当然是根本不跟调试器。window.setInterval = (fn, ms) => (console.log('拦下定时器', fn.toString()), 0)
补充一个通用招数:
Function/eval都是”运行时才出现源码”的典型入口, 给它们加一层 Proxy 就能把参数(也就是被隐藏的代码)打出来:const rawEval = window.eval;window.eval = (src) => (typeof src === 'string' && console.log('eval:', src), rawEval(src));
第二步:把字符串表翻译出来
$dbsm_0x5d57 就是个数组下标查表,表项写成十六进制转义只是为了别让关键字出现在静态文本里。
在控制台(脚本已加载后)敲一行就全出来了:
$dbsm_0x5d57(0) // '\x6c\x73\x73\x68\x69\x65\x6c\x64' 系列...或者干脆本地解码(s14.py 里的 decode_qz 一样的思路):
>>> bytes.fromhex("6465627567676572").decode() # 'debugger'>>> bytes.fromhex("736574496e74657276616c").decode() # 'setInterval'>>> bytes.fromhex("6576616c").decode() # 'eval'翻译完,整段脚本的可读性就回来了:w.LSShield = { decode, split, digest, verify, ready }。
第三步:解 qz(真正的数据)
数据接口给的 qz 是明文的每个字节异或一个固定值(这一关是 0x5a),
拿到明文后还能顺手验一下摘要:
text = ''.join(chr(b ^ 0x5a) for b in payload["qz"]) # "9725,5484,..."assert md5(text + "qz2026") == payload["sig"] # 和内置 MD5 做的是同一件事values = [int(x) for x in text.split(",")]XOR key 和盐在哪儿? 就在下发的那段脚本里 —— 而且把它们解出来也能看到:
qz 解码后是那支 LSShield 工厂函数的源码,里面明写着
return md5(text + 'qz2026').toString(); // 盐bytes[i] ^ 0x5a // XOR key所以 s14.py 的思路是:先解开 qz 拿到源码 → 从源码里正则抠出盐,一步都不用猜。
第四步:五页求和
init 接口只说有几页(total_pages: 5)。循环翻页、每页解 10 个数字,累加即可:
for page in range(1, init["total_pages"] + 1): payload = session.get(init["data_url"], params={"page": page}).json() text = decode_qz(payload["qz"], 0x5a) assert md5(text + salt) == payload["sig"] total += sum(int(x) for x in text.split(","))跑出来(参考代码/s14.py):
XOR key = 0x5a,盐 = qz2026第 1 页:10 个数字,本页合计 42584...第 5 页:10 个数字,本页合计 51223
五页数字总和:251142(提交这个数字)排查清单
- 一开 F12 就反复断住:反调试,
Ctrl+F8停用断点;或先window.setInterval打补丁再刷新。 - 控制台里
LSShield是 undefined:脚本是页面动态插<script>加载的,等它加载完再取; 也可以直接fetch(init.script_url)把源码拿下来自己看。 - 静态搜不到
debugger/setInterval:正常,它们分别由(0, eval)(表[2])和表[1]生成。 qz解出来是乱码:XOR key 不对(这一关是0x5a,注意别用成了别的关卡的 key)。sig校验不过:盐不对,或者拼盐时多/少了分隔符(是明文 + 盐,没有分隔符)。code: "1":页码超范围,一共 5 页。/script返回 401:接口需要登录态,脚本里要带 Cookie。
常见问题
Q:为什么要把 JS 放到接口里发,而不是直接写进静态文件? A:静态文件是”看得见、搜得到、能下载”的;放到接口里至少能按会话下发(每人不完全一样的参数)、能随时改, 还想让你多花一步去”抓代码”。对爬虫来说多了一层”必须先登录、先分析”的门槛。
Q:内置 MD5 有什么用?不是随便就绕过了吗? A:它的作用不是”加密”(MD5 本来也不可逆),而是自校验 + 提高门槛: 让服务端能确认数据没被乱改,同时逼你读懂它的数据格式。
Q:既然是前端校验,改一下不就行了? A:可以,但判断题在后端。前端能改的只是”你自己看到的”;数据、答案、判分都在服务端, 所以正确姿势是还原算法,不是改前端。
最后提醒
这些手段只适合在授权范围内的练习环境里研究。真实站点有服务条款和技术措施保护,绕过它们可能违法。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!












