视频加载失败

第 12 关:接口下发 JS + 字符串混淆 + 内置 MD5 + 反调试

1759 字
9 分钟
第 12 关:接口下发 JS + 字符串混淆 + 内置 MD5 + 反调试

本文是 LearnSpider 第 12 关的解密教程。这一关换了个思路:页面本身几乎是空的,真正的防守逻辑不在静态文件里,而是登录后由接口现发一段 JS 下来。这段 JS 里:关键字全被十六进制转义映射($dbsm_0x5d57)、一段字节数组解码后丢给 eval 执行、内置了一份完整 MD5 用来校验数据、再加一个 500ms 轮询的反调试 setInterval。

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

现象#

  1. 打开靶场,页面上是 10 个四位数字和一个「本页合计」,能翻 5 页,最后一个是灰的。
  2. 打开 F12 —— 还没看清代码,Sources 面板就开始周期性断住:断点停在一行看着没什么的调用上,点继续,半秒后又断。这就是时间轮询式反调试。
  3. 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,...}
  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];
};
  1. 数据接口返回的 qz 是一串数字(字节值),不是明文;旁边还有 sig 做校验。
  2. 直接改 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(提交这个数字)

排查清单#

  1. 一开 F12 就反复断住:反调试,Ctrl+F8 停用断点;或先 window.setInterval 打补丁再刷新。
  2. 控制台里 LSShield 是 undefined:脚本是页面动态插 <script> 加载的,等它加载完再取; 也可以直接 fetch(init.script_url) 把源码拿下来自己看。
  3. 静态搜不到 debugger / setInterval:正常,它们分别由 (0, eval)(表[2]) 和 表[1] 生成。
  4. qz 解出来是乱码:XOR key 不对(这一关是 0x5a,注意别用成了别的关卡的 key)。
  5. sig 校验不过:盐不对,或者拼盐时多/少了分隔符(是 明文 + 盐,没有分隔符)。
  6. code: "1":页码超范围,一共 5 页。
  7. /script 返回 401:接口需要登录态,脚本里要带 Cookie。

常见问题#

Q:为什么要把 JS 放到接口里发,而不是直接写进静态文件? A:静态文件是”看得见、搜得到、能下载”的;放到接口里至少能按会话下发(每人不完全一样的参数)、能随时改, 还想让你多花一步去”抓代码”。对爬虫来说多了一层”必须先登录、先分析”的门槛。

Q:内置 MD5 有什么用?不是随便就绕过了吗? A:它的作用不是”加密”(MD5 本来也不可逆),而是自校验 + 提高门槛: 让服务端能确认数据没被乱改,同时逼你读懂它的数据格式。

Q:既然是前端校验,改一下不就行了? A:可以,但判断题在后端。前端能改的只是”你自己看到的”;数据、答案、判分都在服务端, 所以正确姿势是还原算法,不是改前端。

最后提醒#

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

文章分享

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

第 12 关:接口下发 JS + 字符串混淆 + 内置 MD5 + 反调试
https://jsnote.top/posts/ls-ch12/
作者
xiajiao
发布于
2026-10-02
许可协议
CC BY-NC-SA 4.0
相关文章智能推荐
1
第 11 关:无限 debugger + VM 混淆 + 签名请求头
爬虫靶场本文是 LearnSpider 第 11 关的解密教程。这一关的 JS 防守最"脏":无限 debugger(关键字还是拼出来的)、base64 源码交给 new Function 在运行时编译、请求头 m 要现算签名,票价则靠 base6…
2
第 10 关:RSA 传密钥 + AES 加密报文(顺带一层 JavaScript 混淆)
爬虫靶场本文是 LearnSpider 第 10 关的解密教程。这一关把「RSA + AES 混合加密」和「JavaScript 混淆」凑在一起:请求头 sm 是每次现算的,报文也不是明文,靶场还只放你书单里的前四页。
3
Python 爬虫实战:随机下发路径的资源怎么下载(CH-003)
爬虫靶场本文是 LearnSpider 第 3 关的解密教程。这一关要把图片下载下来,答案是这张图片的字节数。真正的考点是:拿到接口给的 image_id / image_url 之后,别把「字节」和「字符」搞混,也别拖太久(图片是随机下发的)。
4
第 15 关:蜜罐与爬虫封禁 —— 数据是假的,陷阱是真的
爬虫靶场本文是 LearnSpider 第 15 关的解密教程。这一关的靶场是最普通不过的一个 HTML 列表页:不加密、不验签、不弹验证码,5 页 × 10 条公告,谁都能抓。难的是你不知道自己抓到的是不是真的 —— 页面里埋了三类「只给爬虫准备…
5
第 14 关:Cookie 签名 —— 七层复合加密,和「密钥写在前端」这件事
爬虫靶场本文是 LearnSpider 第 14 关的解密教程。这一关的数据分 5 页、每页 10 个数字,但接口只认一个签过名的 sign Cookie:这个 Cookie 是前端一段混淆过的 JS连着过七层算法算出来的(拼盐 → MD5 派生密…
随机文章随机推荐

评论区

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