首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
逆向工程
发新帖
1
8
[原创] 某网站瑞数6反爬逆向:从 412 拦截到借道浏览器会话,一次性翻完全部页列表
发表于: 2026-10-2 21:56
677
[原创] 某网站瑞数6反爬逆向:从 412 拦截到借道浏览器会话,一次性翻完全部页列表
xiao_qi_lin
2026-10-2 21:56
677
## 起因:一个「看着很简单」的列表爬取 需求是抓某网站「公告」频道的列表,自动翻页,把标题、链接、发布日期全量收下来。 这种站点,列表页一眼看过去就是普通的 `<ul><li>` 结构,第一反应是 `requests` + `BeautifulSoup` 十分钟搞定。于是我随手一个 `curl` 先探一眼。 第一脚就踢到了铁板。 ## 第一个异常:为什么我拿到的是 412 和一堆乱码 ```bash $ curl -I "https://www.example.com/news/list.shtml" HTTP/1.1 412 Precondition Failed Server: *** Date: Tue, 29 Sep 2026 09:44:09 GMT Content-Type: text/html; charset=utf-8 ``` 不是 200,是 **412 Precondition Failed**,而且响应体不是列表 HTML,而是一大段高度混淆的 JavaScript: ```javascript if($_ts.cd){(function(_$c1,_$fy){var _$cY=0; function _$c9(){var _$$y=[86];Array.prototype.push.apply(_$$y,arguments);return _$$J.apply(this,_$$y);} ... _$c9=_$_A['$_ts']={}; // 创建一个 $_ts 对象 _$c9.lcd=_$_I; // lcd 字段 _$c9.aebi=[]; // aebi 字段 ... while(1){ _$$y=_$$y[_$$y++]; if(_$$y<12){ ... } } })(_$ts.scj, _$ts.aebi); // scj / aebi 作为 VM 执行上下文传入 ``` 几个信号同时出现,基本可以定性: 1. **`$_ts` 对象** —— 这是瑞数的核心状态对象,常见字段有 `cd`(执行标志)、`scj`(VM 上下文/种子)、`aebi`(环境采集数组)、`lcd`(计算结果缓存)。本次代码里直接观察到了 `cd` / `lcd` / `aebi` / `scj` 的读写。 2. **`while(1)` 里的字节码分发** —— `_$be = _$dq[_$__++]` 取「下一条指令」,再按数值区间 `if(_$be<12){ if(_$be<4){...} }` 跳转执行。这是典型的**自定义虚拟机(VM)**:真实业务逻辑被编译成一串字节码,运行时靠解释器逐条译码执行。 3. **`_$fW` / `_$ej` / `_$gp` / `_$i3` / `_$$Z` / `_$cm` 这类函数名** —— 高度混淆的标识符,无任何语义。 **为什么用 412?** 这是个细节:瑞数不用 403(禁止)而用 412(Precondition Failed,前提条件失败)。语义上的意思是「你缺一个前置条件——一个合法的 cookie」,然后它把「怎么算出这个 cookie」的 JS 连同响应体一起塞给你,让你的浏览器就地执行。**验证发生在客户端,而不是服务端直接拒绝。** 「简单爬一下」这个假设,当场作废。得先搞清楚 cookie 到底怎么来的。 ## 上工具:用浏览器 trace 把链路抓成铁证 靠肉眼读混淆代码不现实。我启用了基于 Firefox 内核的浏览器追踪工具,它能按类别记录 JS 运行时的各种行为: - **domtrace** —— JS 函数调用、属性读写(`get`/`set`/`call`)、定时器、XHR; - **descriptor** —— `Object.getOwnPropertyDescriptor` 的属性描述符探测; - **eval** —— 每次 `eval` 的内容(含间接 eval,正是瑞数 VM 的载体); - **http** —— 完整请求/响应序列、状态码、正文(raw + decoded)。 清空 cookie 后访问目标页,把整条链路录下来。先从 HTTP 序列入手,目标站相关请求被完整还原: ``` REQ GET https://www.example.com/news/list.shtml RESP 412 https://www.example.com/news/list.shtml REQ GET https://www.example.com/tQrlMwxgEtCS/xsWaJeZftrRw.294cc83.js RESP 200 https://www.example.com/tQrlMwxgEtCS/xsWaJeZftrRw.294cc83.js REQ GET https://www.example.com/news/list.shtml RESP 200 https://www.example.com/news/list.shtml ``` 链路清楚得不能再清楚: 1. 第一次访问列表页 → **412**,返回混淆 HTML/JS; 2. 浏览器按 412 正文里的指引,去拉一段**动态脚本** `tQrlMwxgEtCS/xsWaJeZftrRw.294cc83.js`——注意目录名 `tQrlMwxgEtCS` 和文件名 `xsWaJeZftrRw` 都是**每次访问随机生成**的,但后缀 `.294cc83.js` 是固定版本号,这是瑞数 6 代签名; 3. 这段 JS 执行后,页面**自动重载**,这次返回 **200** 和真实内容。 也就是说,cookie 的生成发生在「第 2 步执行动态 JS」这短短几百毫秒里。而这段 JS 就是那台 VM + 环境指纹采集的集合体。 ### 环境指纹采集的完整清单 瑞数不只是「算个 cookie」,它在算之前会先采集一大把环境特征。追踪工具记录到的探测行为,拼出了完整清单: | 指纹点 | trace 证据(次数) | 它在防什么 | |---|---|---| | `navigator.webdriver` | descriptor 26 + domtrace 48 | Selenium/Playwright 自动化标志 | | `canvas` + `fillText` + `toDataURL` | 8 / 2 / 2 | canvas 像素指纹(不同显卡/浏览器渲染差异) | | `webgl` | 4 | WebGL 渲染器指纹 | | `Function` | **290** | `Function.name` / 原型是否被补环境脚本改过 | | `Proxy` / `queueMicrotask` | descriptor | 检测运行环境是否被代理/篡改 | | `screen` | 2 | 屏幕分辨率 | | `setInterval` / `setTimeout` | 234 / 69 | 定时器心跳(headless 环境时间行为异常) | 其中 `Function` 构造器对象被触碰了 **290 次**——这是瑞数最狠的一招。补环境脚本(jsdom、vm2、Node 里手搓 browser 对象)最容易在 `Function` 构造器、`Function.prototype.toString`、`function(){}.constructor` 这些点上露出马脚,因为它们在真浏览器里的行为有大量边角 case 是模拟不出来的。瑞数专门盯着这些点反复测。 `navigator.webdriver` 的探测方式也值得说:它不直接读 `navigator.webdriver`,而是用 `Object.getOwnPropertyDescriptor` 分别在 **`Navigator` 实例**和 **`NavigatorPrototype` 原型**上各查一遍。实例上没有(`found:false`),原型上有个 getter(`found:true, descriptorKind:"accessor"`)——它要确认「这个属性是真浏览器原生的 getter,而不是补环境脚本硬塞的普通值」。**只看值是不够的,要看值的来源。** ```json {"target":{"class":"Navigator"},"prop":"webdriver","found":false,"descriptorKind":null} {"target":{"class":"NavigatorPrototype"},"prop":"webdriver","found":true, "descriptorKind":"accessor","hasGet":true,"getterName":"get webdriver"} ``` DOM 追踪里同时看到 JS 对 cookie 的反复读写: ``` "get cookie" (23 次) "set cookie" (16 次) "cookieHeader" (33 次) ``` 定性完毕:**这是瑞数动态反爬,核心 cookie 由混淆 JS 在客户端计算写入,计算前先做一轮环境指纹校验。** ## 选路线:三条路,我为什么挑了最「偷懒」的一条 摆在面前三条路: - **A:驱动真实浏览器** —— 不还原算法,让真浏览器把瑞数 JS 跑完、cookie 落地,再复用会话拿数据; - **B:还原 VM** —— 把那台 `while(1)` 字节码虚拟机 + 环境指纹采集逻辑完整逆出来,写成纯算法脚本; - **C:用现成工具** —— 找瑞数专用破解/过盾方案。 我选了 A。理由很现实:这次目标是**拿到列表数据**,不是研究瑞数算法本身。瑞数 6 代的 VM 还原是个大工程——字节码要逆向、环境指纹要逐项补、而且瑞数隔段时间就换一次混淆。而驱动浏览器是唯一能「今天内出结果」的路。 > 逆向要分清楚:你要的是「过程」还是「结果」——这次要结果。 ## 中途一次自证:三个我自己挖的坑 A 路线看着简单,真做起来我连踩三个坑,每个都踩得结结实实。 ### 坑 1:headless Chrome 过不了第二层 用 Playwright 驱动 Chrome,`headless=True` 跑,过盾判定也过了,但一看数据——**只有 2 个 cookie,正文是空的**。 > 教训:瑞数不只是「验 cookie」,它还会在 JS 里做环境指纹校验。headless 模式下 `navigator.webdriver` 等特征暴露,环境指纹对不上,它算出的 cookie 是「废」的,重载拿不到真数据。 解决办法是「屏幕外窗口」:`headless=False`,但把窗口丢到屏幕坐标外。窗口真实存在(有头、环境指纹对得上),但不弹出来打扰你: ```python from playwright.sync_api import sync_playwright args = ["--disable-blink-features=AutomationControlled", "--no-sandbox"] # 屏幕外窗口:有头(能过指纹)但不打扰 args += ["--window-position=-2000,-2000", "--window-size=1920,1080"] browser = p.chromium.launch( channel="chrome", # 用系统 Chrome,不用 Playwright 自带 Chromium headless=False, # 关键:不能 headless args=args, ) ctx = browser.new_context( viewport={"width": 1920, "height": 1080}, locale="zh-CN", timezone_id="Asia/Shanghai", # 指纹对齐:时区/语言要和真实用户一致 ) ``` 两个反检测细节值得单独说: - `channel="chrome"` 用**系统安装的 Chrome**,而不是 Playwright 自带的 Chromium——自带那个有更明显的自动化特征; - `--disable-blink-features=AutomationControlled` 关掉 Blink 引擎的 `AutomationControlled` 标志,这正是 `navigator.webdriver=true` 的来源之一。 ### 坑 2:过盾判定写早了 我用「`<meta r='m'>` 这个混淆页标记消失」作为过盾成功条件。这个条件本身没错——412 混淆页带 `<meta r='m'>`,真实页没有。但**标记消失 ≠ 业务 JS 把 cookie 写全了**。判定一过我就急着抓数据,抓到的是半成品。 > 教训:过盾判定之后,还要再等一段,让 SSO、无障碍这些业务 JS 把附加 cookie 写全,再动数据。 ```python page.wait_for_function( "() => !document.querySelector(\"meta[r='m']\")", timeout=30000 ) page.wait_for_timeout(5000) # 等业务 JS 写全附加 cookie ``` ### 坑 3:复用 cookie 只带两个,被 400 打回来 我天真地以为「瑞数就那两个 cookie(O 和 P)」,把它们抠出来塞给 `requests`,结果 **400**。 回看完整 cookie 集合才发现还有个 `7d0f4f97e8317b129e`(MD5 样式的 32 位 session cookie)——这是应用层自己下发的。缺了它,接口不认。 > 教训:瑞数 cookie 是「一整套」,不是「两个核心」。要复用,就带完整集合,别自作聪明挑「看起来重要」的。 ## 换一条思路:列表数据其实根本不在 HTML 里 过了盾、拿到了第一页 HTML,准备写翻页逻辑。结果发现:**列表项不是服务端渲染进 HTML 的**,分页容器 `#page_div` 是空的。 去抓前端 JS,在 `common_list.js` 里找到了真正的翻页逻辑,三段函数串成一条链: ```javascript // 1. 发起 AJAX 请求拿当前页 function table_ajax() { $.ajax({ url: '/common/search/' + this.channelId + '?_isAgg=false&_isJson=true&_pageSize=' + parseInt($("#pageSize").val()) + '&_template=index&_rangeTimeGte=&_channelName=&page=' + parseInt($("#page").val()), type: 'get', success: function(data) { table_page(data.data.total); // 2. 用 total 生成分页条 table_each("list", ajax_success(data)); // 3. 渲染列表项 } }); } ``` 而 `channelId` 就明晃晃放在页面的 `<meta name="channelId" content="...">` 里: ```javascript this.channelId = $('meta[name=channelId]').attr('content'); ``` 于是翻页接口现形: ``` GET /common/search/{channelId}?_isAgg=false&_isJson=true&_pageSize=20&_template=index&_rangeTimeGte=&_channelName=&page={N} ``` 试了一页,返回 200,JSON 结构是这样的: ```json {"data":{"page":1,"rows":20,"total":235,"results":[ {"title":"关于公开征求《…》意见建议的公告", "url":"/news/202609/17*******.shtml", "publishedTimeStr":"2026-09-24 15:29:34", "publishedTime":1790*********, "content":"(正文全文,篇幅较长,此处省略)…", "domainMetaList":[...]} ]}} ``` 几个要点: - **`data.total`** 是总数(235),**`data.results`** 是本页列表项,字段直接摊平在顶层——`title`、`url`、`publishedTimeStr`、`publishedTime`、`content` 一眼可读,不用再解析 HTML; - **`url` 是相对路径**(`/news/202609/17*******.shtml`),拼上域名就是详情页地址; - **`content` 字段里居然直接带着正文全文**——这意味着连详情页都不用爬; - 更深一层的 `domainMetaList` 里还有一套「元数据集」,用 name/value/key 三元组存了「发文机关、索引号、成文日期」等扩展字段,是典型的大汉/拓尔思 CMS 结构。 这个发现一下把问题简化了:**翻页不需要模拟点击、不用解析每页 HTML,直接打这个 JSON 接口就行。** ## 最后一步:借道浏览器会话,循环翻页 方案定型:过盾之后,用 Playwright 的 `ctx.request` 直接请求翻页接口——它**自动携带当前浏览器会话里已经生效的全部 cookie**,等于让瑞数把「门」开了之后,我踩着它的会话走。既不用研究 cookie 哪些够用,也不用担心接口被二次拦截。 完整脚本如下([crawl_list.py](crawl_list.py)): ```python import sys, json, csv from playwright.sync_api import sync_playwright BASE = "https://www.example.com" LIST_URL = f"{BASE}/news/list.shtml" CHANNEL_ID = "..." # 从页面 <meta name=channelId> 取 PAGE_SIZE = 20 with sync_playwright() as p: browser = p.chromium.launch( channel="chrome", headless=False, args=["--disable-blink-features=AutomationControlled", "--no-sandbox", "--window-position=-2000,-2000", "--window-size=1920,1080"], ) ctx = browser.new_context(viewport={"width": 1920, "height": 1080}, locale="zh-CN", timezone_id="Asia/Shanghai") page = ctx.new_page() # —— 过盾 —— page.goto(LIST_URL, wait_until="domcontentloaded", timeout=30000) page.wait_for_function("() => !document.querySelector(\"meta[r='m']\")", timeout=30000) page.wait_for_timeout(5000) # —— 复用会话 cookie 循环翻页 —— all_items, page_num = [], 1 while True: url = (f"{BASE}/common/search/{CHANNEL_ID}" f"?_isAgg=false&_isJson=true&_pageSize={PAGE_SIZE}" f"&_template=index&_rangeTimeGte=&_channelName=&page={page_num}") resp = ctx.request.get(url, headers={ "Referer": LIST_URL, "X-Requested-With": "XMLHttpRequest", # 模拟真实 AJAX }) d = resp.json()["data"] for it in d["results"]: all_items.append({"title": it["title"], "url": BASE + it["url"], "date": it.get("publishedTimeStr")}) if page_num * PAGE_SIZE >= int(d["total"]): break page_num += 1 ``` 两个细节: - **`Referer` 和 `X-Requested-With` 头**要带上,模拟真实 AJAX 请求,有些服务端会校验这两个头; - **翻页终止用 `page * pageSize >= total`**,同时保留「`results` 为空即停」的兜底,防止接口被风控截断时 `total` 虚报导致死循环。 跑起来,12 页一口气翻完: ``` page=1: +20 条,累计 20/235 page=2: +20 条,累计 40/235 ... page=11: +20 条,累计 220/235 page=12: +15 条,累计 235/235 ``` ## 结果:235 条,全字段无缺 - **总数 235 条 = 12 页**(11×20 + 末页 15),去重后 235/235 无缺失; - title / url / date **零空值**; - 时间跨度 2021-07-28 ~ 2026-09-24,倒序; - 落盘 `list_data.json`(结构化)+ `list_data.csv`(Excel 可直接打开)。 一条样本(值已脱敏): ```json {"title": "关于公开征求《…》意见建议的公告", "url": "https://www.example.com/news/202609/17*******.shtml", "date": "2026-09-24 15:29:34", "published_time": 1790*********} ``` ## 顺手摸清的「双 cookie」机制 过程中交叉验证出了这套瑞数的 cookie 下发逻辑(trace 工具的 HTTP 模块不记 Set-Cookie 头,换 Playwright 的 response 监听才看到,这里踩过「证据源单一」的坑): ``` [Set-Cookie 来源] 状态码 -> cookie 名 412 -> 4hP44ZykCTt5O HttpOnly 服务端 412 下发的种子(88 字,单段 base64) 200 -> 7d0f4f97e8317b129e HttpOnly 应用层 session(32 位 MD5) 200 -> Path 畸形 path=/ 被误当成名字的 cookie [document.cookie] 4hP44ZykCTt5P 非 HttpOnly 客户端 JS 算出(257 字,以 0 开头,4 段 '.' 分隔) ``` 「O / P」是一对相邻字母,结构对比一下: | | `...O`(种子) | `...P`(计算结果) | |---|---|---| | 下发方 | 服务端 412 Set-Cookie | 客户端 `document.cookie` | | HttpOnly | 是(JS 读不到也写不了) | 否 | | 长度 | 88 字 | 257 字 | | 结构 | 单段 base64 | 以 `0` 开头,4 段以 `.` 分隔 | 这正是瑞数 6 代「服务端种子 + 客户端计算」的双 cookie 校验:`O` 是服务端给的「题目」,`P` 是浏览器跑完 VM 算出的「答案」,两者配合服务端校验。名字本身每次会话都变,值形如: ``` 4hP44ZykCTt5P = 0********.***********.***********.*********** (257 字,以 0 开头,4 段以 '.' 分隔) ``` 还有个细节:那个畸形的 `Path` cookie 是 `path=/` 被浏览器误解析成「名为 `Path` 的 cookie」,值和 `path` 属性搞混了。这是某些老 CMS 的 bug,跟瑞数无关,但看到一堆 `Path` cookie 别慌。 ## 复盘:真正的转折点有 N 个 回看这条链,转折点其实有三个,每个都对应一次「放下执念」: 1. **从「还原算法」转向「借结果」** —— 我要的是数据,不是研究瑞数。硬刚 VM 是另一篇文章、另一周工期的事。 2. **从「解析 HTML」转向「打 AJAX 接口」** —— 列表不是静态渲染,翻页有现成的 JSON 接口,直接打比模拟浏览器点分页干净一百倍。 3. **从「抠两个 cookie」转向「复用整个会话」** —— 与其研究哪些 cookie 够用,不如让 `ctx.request` 把会话 cookie 全套带上,一个不漏。 留一句给同行:**逆向最省力的姿势,往往是「让真环境把难的那步跑完,你只做后面能做的」**。别跟一台混淆虚拟机死磕,先问一句「有没有更靠外的口子」。 ## 后续可做 - **出纯算法方案**:把 VM 还原,离线算出 cookie,脱离浏览器。难度高,但能做成可复现的 `requests` 脚本。 - **补环境方案**:在 Node/Python 里补 `navigator`/`Function`/`Proxy`/`queueMicrotask` 等指纹,跑原版 JS。介于 A 和 B 之间。 > 声明:目标为公开信息站点,本文仅用于逆向技术学习与爬虫方法交流。域名、cookie 值、channelId 均已脱敏
回复或点赞可查看完整内容
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。
收藏
・
1
点赞
・
8
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
wx_0.0_522
感谢你分享这么好的资源!
11小时前
教教我吧~
谢谢你的细致分析,受益匪浅!
1天前
git_94536luohuaweishui
感谢你分享这么好的资源!
1天前
我的小拇指啊
为你点赞!
2天前
mb_rjdrqvpa
感谢你的积极参与,期待更多精彩内容!
5天前
huangyalei
为你点赞!
5天前
git_51951meggadf3df
非常支持你的观点!
6天前
顽劣
期待更多优质内容的分享,论坛有你更精彩!
2026-10-3 03:22
查看更多
赞赏
×
1 雪花
5 雪花
10 雪花
20 雪花
50 雪花
80 雪花
100 雪花
150 雪花
200 雪花
支付方式:
微信支付
赞赏留言:
快捷留言
感谢分享~
精品文章~
原创内容~
精彩转帖~
助人为乐~
感谢分享~
最新回复
(
5
)
mb_ldbucrik
雪 币:
6
能力值:
( LV1,RANK:0 )
在线值:
发帖
0
回帖
707
粉丝
7
关注
私信
mb_ldbucrik
2
楼
感谢分享
2026-10-3 08:07
0
mb_elqwyvnm
雪 币:
2742
活跃值:
(3475)
能力值:
( LV2,RANK:10 )
在线值:
发帖
0
回帖
170
粉丝
0
关注
私信
mb_elqwyvnm
3
楼
xeixi
2026-10-3 08:18
0
kccp
雪 币:
14
活跃值:
(795)
能力值:
( LV2,RANK:10 )
在线值:
发帖
3
回帖
90
粉丝
0
关注
私信
kccp
4
楼
666
6天前
0
Imxz
雪 币:
110
活跃值:
(9571)
能力值:
( LV2,RANK:10 )
在线值:
发帖
6
回帖
775
粉丝
9
关注
私信
Imxz
5
楼
666
5天前
0
mb_zsmeyemp
雪 币:
213
活跃值:
(551)
能力值:
( LV2,RANK:10 )
在线值:
发帖
0
回帖
33
粉丝
0
关注
私信
mb_zsmeyemp
6
楼
666
1天前
0
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
xiao_qi_lin
5
发帖
2
回帖
10
RANK
关注
私信
他的文章
[原创]某约稿平台 M-S 签名逆向:从 "Ascon" 烟雾弹到 ChaCha20 铁证,再到 Node 黑盒补环境复现
191
[原创] 某网站瑞数6反爬逆向:从 412 拦截到借道浏览器会话,一次性翻完全部页列表
676
[原创] 头条评论 `_signature` 逆向实战:从 `_$jsvmprt` VM 字节码到纯 Node 零 Cookie 爬取用户评论
969
[原创]某金融App 登录请求逆向实战:从梆梆加固反 Frida 自毁到 Go 业务层明文抓包
9917
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部