首页
社区
课程
招聘
[原创] 某网站瑞数6反爬逆向:从 412 拦截到借道浏览器会话,一次性翻完全部页列表
发表于: 2026-10-2 21:56 1370

[原创] 某网站瑞数6反爬逆向:从 412 拦截到借道浏览器会话,一次性翻完全部页列表

2026-10-2 21:56
1370

需求是抓某网站「公告」频道的列表,自动翻页,把标题、链接、发布日期全量收下来。

这种站点,列表页一眼看过去就是普通的 <ul><li> 结构,第一反应是 requests + BeautifulSoup 十分钟搞定。于是我随手一个 curl 先探一眼。

第一脚就踢到了铁板。

不是 200,是 412 Precondition Failed,而且响应体不是列表 HTML,而是一大段高度混淆的 JavaScript:

几个信号同时出现,基本可以定性:

为什么用 412? 这是个细节:瑞数不用 403(禁止)而用 412(Precondition Failed,前提条件失败)。语义上的意思是「你缺一个前置条件——一个合法的 cookie」,然后它把「怎么算出这个 cookie」的 JS 连同响应体一起塞给你,让你的浏览器就地执行。验证发生在客户端,而不是服务端直接拒绝。

「简单爬一下」这个假设,当场作废。得先搞清楚 cookie 到底怎么来的。

靠肉眼读混淆代码不现实。我启用了基于 Firefox 内核的浏览器追踪工具,它能按类别记录 JS 运行时的各种行为:

清空 cookie 后访问目标页,把整条链路录下来。先从 HTTP 序列入手,目标站相关请求被完整还原:

链路清楚得不能再清楚:

也就是说,cookie 的生成发生在「第 2 步执行动态 JS」这短短几百毫秒里。而这段 JS 就是那台 VM + 环境指纹采集的集合体。

瑞数不只是「算个 cookie」,它在算之前会先采集一大把环境特征。追踪工具记录到的探测行为,拼出了完整清单:

其中 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,而不是补环境脚本硬塞的普通值」。只看值是不够的,要看值的来源。

DOM 追踪里同时看到 JS 对 cookie 的反复读写:

定性完毕:这是瑞数动态反爬,核心 cookie 由混淆 JS 在客户端计算写入,计算前先做一轮环境指纹校验。

摆在面前三条路:

我选了 A。理由很现实:这次目标是拿到列表数据,不是研究瑞数算法本身。瑞数 6 代的 VM 还原是个大工程——字节码要逆向、环境指纹要逐项补、而且瑞数隔段时间就换一次混淆。而驱动浏览器是唯一能「今天内出结果」的路。

逆向要分清楚:你要的是「过程」还是「结果」——这次要结果。

A 路线看着简单,真做起来我连踩三个坑,每个都踩得结结实实。

用 Playwright 驱动 Chrome,headless=True 跑,过盾判定也过了,但一看数据——只有 2 个 cookie,正文是空的。

教训:瑞数不只是「验 cookie」,它还会在 JS 里做环境指纹校验。headless 模式下 navigator.webdriver 等特征暴露,环境指纹对不上,它算出的 cookie 是「废」的,重载拿不到真数据。

解决办法是「屏幕外窗口」:headless=False,但把窗口丢到屏幕坐标外。窗口真实存在(有头、环境指纹对得上),但不弹出来打扰你:

两个反检测细节值得单独说:

我用「<meta r='m'> 这个混淆页标记消失」作为过盾成功条件。这个条件本身没错——412 混淆页带 <meta r='m'>,真实页没有。但标记消失 ≠ 业务 JS 把 cookie 写全了。判定一过我就急着抓数据,抓到的是半成品。

教训:过盾判定之后,还要再等一段,让 SSO、无障碍这些业务 JS 把附加 cookie 写全,再动数据。

我天真地以为「瑞数就那两个 cookie(O 和 P)」,把它们抠出来塞给 requests,结果 400。

回看完整 cookie 集合才发现还有个 7d0f4f97e8317b129e(MD5 样式的 32 位 session cookie)——这是应用层自己下发的。缺了它,接口不认。

教训:瑞数 cookie 是「一整套」,不是「两个核心」。要复用,就带完整集合,别自作聪明挑「看起来重要」的。

过了盾、拿到了第一页 HTML,准备写翻页逻辑。结果发现:列表项不是服务端渲染进 HTML 的,分页容器 #page_div 是空的。

去抓前端 JS,在 common_list.js 里找到了真正的翻页逻辑,三段函数串成一条链:

而 channelId 就明晃晃放在页面的 <meta name="channelId" content="..."> 里:

于是翻页接口现形:

试了一页,返回 200,JSON 结构是这样的:

几个要点:

这个发现一下把问题简化了:翻页不需要模拟点击、不用解析每页 HTML,直接打这个 JSON 接口就行。

方案定型:过盾之后,用 Playwright 的 ctx.request 直接请求翻页接口——它自动携带当前浏览器会话里已经生效的全部 cookie,等于让瑞数把「门」开了之后,我踩着它的会话走。既不用研究 cookie 哪些够用,也不用担心接口被二次拦截。

完整脚本如下(crawl_list.py):

两个细节:

跑起来,12 页一口气翻完:

一条样本(值已脱敏):

过程中交叉验证出了这套瑞数的 cookie 下发逻辑(trace 工具的 HTTP 模块不记 Set-Cookie 头,换 Playwright 的 response 监听才看到,这里踩过「证据源单一」的坑):

「O / P」是一对相邻字母,结构对比一下:

这正是瑞数 6 代「服务端种子 + 客户端计算」的双 cookie 校验:O 是服务端给的「题目」,P 是浏览器跑完 VM 算出的「答案」,两者配合服务端校验。名字本身每次会话都变,值形如:

还有个细节:那个畸形的 Path cookie 是 path=/ 被浏览器误解析成「名为 Path 的 cookie」,值和 path 属性搞混了。这是某些老 CMS 的 bug,跟瑞数无关,但看到一堆 Path cookie 别慌。

回看这条链,转折点其实有三个,每个都对应一次「放下执念」:

留一句给同行:逆向最省力的姿势,往往是「让真环境把难的那步跑完,你只做后面能做的」。别跟一台混淆虚拟机死磕,先问一句「有没有更靠外的口子」。

声明:目标为公开信息站点,本文仅用于逆向技术学习与爬虫方法交流。域名、cookie 值、channelId 均已脱敏

$ curl -I "027K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6%4N6%4N6Q4x3X3g2W2P5r3q4E0M7r3I4W2i4K6u0W2j5$3!0E0i4K6u0r3L8X3g2%4M7#2)9J5c8X3I4A6M7%4c8Q4x3X3g2K6K9s2c8E0L8l9`.`."
HTTP/1.1 412 Precondition Failed
Server: ***
Date: Tue, 29 Sep 2026 09:44:09 GMT
Content-Type: text/html; charset=utf-8
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 执行上下文传入
REQ  GET 68fK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6%4N6%4N6Q4x3X3g2W2P5r3q4E0M7r3I4W2i4K6u0W2j5$3!0E0i4K6u0r3L8X3g2%4M7#2)9J5c8X3I4A6M7%4c8Q4x3X3g2K6K9s2c8E0L8l9`.`.
RESP 412 54eK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6%4N6%4N6Q4x3X3g2W2P5r3q4E0M7r3I4W2i4K6u0W2j5$3!0E0i4K6u0r3L8X3g2%4M7#2)9J5c8X3I4A6M7%4c8Q4x3X3g2K6K9s2c8E0L8l9`.`.
REQ  GET cf4K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6%4N6%4N6Q4x3X3g2W2P5r3q4E0M7r3I4W2i4K6u0W2j5$3!0E0i4K6u0r3N6q4q4J5L8p5#2%4P5r3N6q4N6p5y4e0i4K6u0r3P5s2y4i4j5f1A6W2h3X3k6@1M7W2u0%4i4K6u0W2x3U0V1@1j5$3x3^5x3#2)9J5k6h3A6K6
RESP 200 cb0K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6%4N6%4N6Q4x3X3g2W2P5r3q4E0M7r3I4W2i4K6u0W2j5$3!0E0i4K6u0r3N6q4q4J5L8p5#2%4P5r3N6q4N6p5y4e0i4K6u0r3P5s2y4i4j5f1A6W2h3X3k6@1M7W2u0%4i4K6u0W2x3U0V1@1j5$3x3^5x3#2)9J5k6h3A6K6
REQ  GET ae2K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6%4N6%4N6Q4x3X3g2W2P5r3q4E0M7r3I4W2i4K6u0W2j5$3!0E0i4K6u0r3L8X3g2%4M7#2)9J5c8X3I4A6M7%4c8Q4x3X3g2K6K9s2c8E0L8l9`.`.
RESP 200 9c0K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6%4N6%4N6Q4x3X3g2W2P5r3q4E0M7r3I4W2i4K6u0W2j5$3!0E0i4K6u0r3L8X3g2%4M7#2)9J5c8X3I4A6M7%4c8Q4x3X3g2K6K9s2c8E0L8l9`.`.
{"target":{"class":"Navigator"},"prop":"webdriver","found":false,"descriptorKind":null}
{"target":{"class":"NavigatorPrototype"},"prop":"webdriver","found":true,
 "descriptorKind":"accessor","hasGet":true,"getterName":"get webdriver"}
"get cookie"   (23 次)
"set cookie"   (16 次)
"cookieHeader" (33 次)
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",   # 指纹对齐:时区/语言要和真实用户一致
)
page.wait_for_function(
    "() => !document.querySelector(\"meta[r='m']\")", timeout=30000
)
page.wait_for_timeout(5000)   # 等业务 JS 写全附加 cookie
// 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. 渲染列表项
        }
    });
}

[招生]科锐逆向工程师培训(2026年11月13日实地,远程教学同时开班, 第57期)

收藏
点赞 ・17
打赏
分享
最新回复 (12)
雪    币: 6
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
2 楼
感谢分享
2026-10-3 08:07
0
雪    币: 2845
活跃值: (3680)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
3 楼
xeixi
2026-10-3 08:18
0
雪    币: 14
活跃值: (840)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
4 楼
666
2026-10-4 09:43
0
雪    币: 110
活跃值: (9656)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
5 楼
666
2026-10-5 09:17
0
雪    币: 250
活跃值: (661)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
6 楼
666
2026-10-8 14:42
0
雪    币: 200
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
7 楼
思路很好,学习学习
2026-10-12 11:49
0
雪    币: 200
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
8 楼
66
2026-10-12 20:05
0
雪    币: 2790
活跃值: (7221)
能力值: ( LV6,RANK:90 )
在线值:
发帖
回帖
粉丝
9 楼
感谢分享
2026-10-14 08:44
0
雪    币: 0
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
10 楼
123
2026-10-14 09:45
0
雪    币: 2845
活跃值: (3680)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
11 楼
1111
2026-10-14 09:49
0
雪    币: 0
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
12 楼
666
2026-10-16 15:38
0
雪    币: 0
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
13 楼
666
2026-10-21 19:52
0
游客
登录 | 注册 方可回帖
返回