免责声明: 本文所有分析均基于公开可访问的前端 JS 代码及研究过程中所接触网站的正常访问流量,仅用于安全研究、学习与了解 Web 前端防护机制。请勿将本文技术用于任何未授权的系统,违者后果自负。
一、瑞数是什么
瑞数信息(RisShu)是国内主流的 Web 前端动态安全产品,广泛应用于政务、金融、电商等高安全性场景。其核心产品 Botgate(人机对抗网关) 通过在服务端动态生成 JavaScript 代码来实现客户端行为检测,属于动态安全方案。
与传统静态规则防护不同,瑞数的核心思想是:
每次访问下发的 JS 代码都不相同,使得攻击者无法对固定代码进行逆向和复用。
目前常见版本为瑞数4、瑞数5、瑞数6 ,本文以研究中最常遇到的瑞数6为重点分析对象,采用**补环境(Node.js)**方案处理。
二、瑞数的核心特点
2.1 动态代码下发
瑞数保护的页面,每次请求首页时,HTML 中内嵌的 JavaScript 代码都会动态变化:
html
<!-- 每次访问,cd 字段内容不同 -->
<script>
;$_ts=window['$_ts'];if(!$_ts)$_ts={};
$_ts.nsd=81449;
$_ts.cd='qEpxrrAlo...(1000字符以上的动态字符串)';
if($_ts.lcd)$_ts.lcd();
</script>
<!-- 带哈希的动态 JS 文件,路径和文件名均为随机字符串 -->
<script src="/fpqQrgG7L6po/eaKJbLE9bqof.e17ed02.js"></script>
$_ts.cd:加密的配置数据,每次请求不同- 动态 VMP JS 文件:路径和文件名均为随机字符串,每次请求不同
2.2 VMP 虚拟机保护
上述动态 JS 文件是经过 VMP(Virtual Machine Protection) 处理的 JS 文件,其特点:
- 核心逻辑运行在自定义虚拟机中,无法直接阅读
- 对环境做大量检测:是否有 DevTools 打开、是否为 Selenium、是否有 Hook 痕迹
- 函数名、变量名均为随机字符串,每版本不同
2.3 多维度环境指纹采集
瑞数在客户端采集大量浏览器环境信息,用于服务端校验是否为真实浏览器:
| 指纹类型 | 采集内容 |
|---|---|
| 浏览器属性 | navigator.userAgent、navigator.platform、navigator.language 等 |
| 窗口属性 | window.screen、window.innerWidth、window.outerWidth 等 |
| Chrome 对象 | window.chrome.loadTimes、window.chrome.csi、window.chrome.app 等 |
| 存储对象 | localStorage、sessionStorage 的存在性及读写行为 |
| 时序特征 | 各阶段 JS 执行时间差 |
| CRC 校验 | 对关键函数的 toString() 做 CRC32,检测是否被 Hook |
2.4 Cookie 令牌机制
瑞数的 Cookie 验证分为两个部分,缺一不可:
Cookie 1(服务端下发) :首次 GET 首页时,服务端通过响应头 Set-Cookie 下发第一个 Cookie,名称同样为 13 位随机字母数字,值长度约 80~100 字符。此 Cookie 由服务端生成,客户端只需接收保存即可。值的第一个字符即为瑞数版本号 (如 6xxxx = 瑞数6,5xxxx = 瑞数5)。
Cookie 2(客户端生成) :VMP JS 在浏览器端执行完毕后,通过 document.cookie 写入第二个 Cookie,名称也是 13 位随机字母数字,值长度约 300~500 字符。此 Cookie 包含完整的指纹和校验数据,是补环境方案需要生成的核心目标。
两个 Cookie 的名称通常共享前12位字母,仅末位字母不同 (如 UA1L1zGonajvO 和 UA1L1zGonajvP),后续请求必须同时携带两者,缺少任意一个都会返回 412。
三、瑞数6 整体工作流程
客户端首次访问页面(GET 首页)
↓
服务端返回:
① 响应头 Set-Cookie → Cookie1(服务端生成,值较短,约90字符,**首字符为瑞数版本号**)
② HTML 体(含 $_ts.cd 动态数据 + 动态 VMP JS 文件 URL)
↓
Python:保存 Cookie1,提取 $_ts 代码块(ts_js)和 VMP JS 文件 URL
↓
下载 VMP JS 文件(03_source.js),保存到本地
↓
Node.js 加载补环境文件(01_env.js)→ 加载 ts_js(02_ts.js)→ 加载 03_source.js
↓
VMP JS 在补好的浏览器环境中执行,采集指纹,计算 Cookie 值
通过 document.cookie 写入 Cookie2(客户端生成,值较长,~400字符)
↓
Node.js 读取 document.cookie 并输出
↓
Python 接收 Cookie2,与 Cookie1 合并组成完整 Cookie 字典
↓
后续请求同时携带 Cookie1 + Cookie2 → 服务端校验通过 → 返回正常数据(200)
↓
(若 Cookie 失效)→ 服务端返回 412 → 重新触发上述流程(Cookie1 和 Cookie2 均需刷新)
四、抓包特征识别
4.1 HTML 特征
同时满足以下两点,即可判断目标站点使用了瑞数:
- HTML 第一个或第二个
<script>标签内有$_ts赋值,且$_ts.cd字段内容超过 1000 字符 - 页面中有
src指向一个带哈希的动态 JS 文件,路径和文件名均为随机字符串,每次请求不同
python
import re
# 提取 ts_js($_ts 赋值代码块)
ts_js = re.findall(r'<script.*?>(.*?)</script>', html, re.DOTALL)[1]
# 提取动态 VMP JS 文件路径
js_code_path = re.findall('src=(.*?) ', html)[0][1:-1]
4.2 响应状态码特征
| 状态码 | 含义 |
|---|---|
| 200 | Cookie 有效,正常返回数据 |
| 412 | 缺少或携带无效瑞数 Cookie,需刷新 |
| 202 | 部分站点的验证拒绝状态码,语义与 412 相同 |
| 493 / 403 | 请求频率过高被限流/封禁 |
4.3 Cookie 命名特征
每个瑞数站点会同时出现一对 Cookie,名称共享前12位、末位字母不同:
# Cookie1(服务端下发,值较短)
# 瑞数6站点:首字符为 6
UA1L1zGonajvO = 60uvxhWj09FTINp9...(约90字符)
# 瑞数5站点:首字符为 5
T0k1m0u5AfREO = 55ES67vuQ96q_ZPJ...(约90字符)
# Cookie2(客户端VMP JS生成,值较长)
UA1L1zGonajvP = 0VrEWqFthuHnL1on...(约400字符)
| Cookie1 | Cookie2 | |
|---|---|---|
| 来源 | 服务端 Set-Cookie | VMP JS 写入 document.cookie |
| 值首字符 | 瑞数版本号 (6=瑞数6, 5=瑞数5) |
0 等 |
| 值长度 | 约 80~100 字符 | 约 300~500 字符 |
| 名称末位 | O(字母O)、S 等 |
P、T 等 |
| 是否需要补环境 | 否,直接接收 | 是,核心目标 |
五、补环境方案详解
5.1 整体架构
Python (xxx.py)
└─ subprocess.run(['node', '04_main.js', 'gen_cookie'])
└─ 04_main.js(入口,按序加载)
├─ require('./01_env.js') # 浏览器环境模拟
├─ require('./02_ts.js') # 动态 $_ts 代码块(每次从页面提取)
└─ require('./03_source.js') # 动态 VMP JS(每次从页面下载)
5.2 01_env.js 需补充的浏览器对象
瑞数 VMP JS 在执行时会访问大量浏览器 API,在 Node.js 环境中这些对象不存在,需手动补充:
| 对象/属性 | 补充方式 | 说明 |
|---|---|---|
window |
window = global |
将 Node.js global 作为 window |
window.top、window.self |
同上 | 同 window 引用 |
window.innerWidth/Height |
固定数值(如 1920/983) | 模拟屏幕尺寸 |
window.outerWidth/Height |
固定数值(如 1920/1040) | 模拟窗口尺寸 |
window.chrome |
完整 chrome 对象 | 含 loadTimes、csi、app 等子属性 |
window.addEventListener |
空函数 | 阻止事件绑定报错 |
window.setInterval、setTimeout |
空函数 | 阻止定时器运行 |
window.name |
空字符串 '' |
模拟默认 window 名称 |
window.MutationObserver |
返回带 observe 方法的对象 |
阻止 DOM 变化监听 |
window.fetch、window.Request |
返回空对象的函数 | 阻止 fetch 请求 |
document.cookie |
注入 cookie 字符串 | VMP JS 可能读取 cookie 参与计算 |
navigator.userAgent |
与请求头 UA 一致的字符串 | UA 不一致会导致指纹校验失败 |
navigator.webdriver |
false |
规避自动化检测 |
localStorage、sessionStorage |
实现 Storage 类 |
含 getItem/setItem/clear 等方法 |
XMLHttpRequest |
空实现 | 阻止 JS 内部发起请求 |
HTMLAnchorElement、HTMLFormElement 等 |
空函数或 debugger 函数 |
防止构造函数调用报错 |
__dirname、__filename |
delete 删除 |
避免 Node.js 专有变量影响 JS 检测 |
关键注意 :
window.chrome的loadTimes等方法需要有完整的返回值结构(含connectionInfo、npnNegotiatedProtocol等字段),空函数可能导致指纹计算异常。
5.3 04_main.js 入口逻辑
javascript
// 1. 拦截 XMLHttpRequest.open,捕获 VMP JS 内部传递的动态后缀字符串
let req_url;
function get_suffix() {
window.XMLHttpRequest = function XMLHttpRequest() {};
window.XMLHttpRequest.prototype.open = function(method, url) {
req_url = url;
return {};
};
}
// 2. 按序加载
require('./01_env');
get_suffix();
require('./02_ts'); // 写入 $_ts,让 VMP JS 能读到配置数据
require('./03_source'); // 执行 VMP JS,完成 Cookie 计算并写入 document.cookie
// 3. 读取生成的 Cookie2
function gen_cookie() {
return document.cookie;
}
// 4. 获取请求 URL 后缀(部分站点需要)
function get_suffix_url(_url) {
const urls = new URL(_url);
const origin = urls.origin;
const path = urls.pathname + urls.search;
const g = new window.XMLHttpRequest();
g.open("POST", path); // 触发 open 拦截,req_url 被赋値为带后缀的路径
return origin + req_url; // 拼接完整 URL
}
5.4 URL 后缀机制(部分站点)
部分瑞数站点在 VMP JS 执行期间,会通过 XMLHttpRequest.open 把一个独立的动态参数 嵌入请求路径中传递出来。该参数以 ?键名=值 的形式附加在请求 URL 后面,与 Cookie1、Cookie2 是完全独立的第三个校验参数。Python 侧发请求时必须使用带后缀的 URL,否则服务端会拒绝。
处理流程:
VMP JS 执行时调用 XMLHttpRequest.open("POST", "/path/to/api?key=value...")
↓
04_main.js 中 open 被 Hook,将带后缀的路径赋值给 req_url
↓
Python 调用 get_suffix_url(original_url) → 返回拼接后缀的完整 URL
↓
Python 用带后缀的 URL 发起请求
实际示例(fangdi):
python
original_url = 'https://www.fangdi.com.cn/service/index/getWriteDict.action'
# 通过 JS 获取带后缀的完整 URL
suffix_url = call_js_function(os.path.join(_DIR, '04_main.js'), 'get_suffix_url', original_url)
# suffix_url 形如:
# 'https://www.fangdi.com.cn/service/index/getWriteDict.action?XJlCTRRM=01MikXalq...'
response = requests.post(suffix_url, cookies=cookies, headers=headers, data=data)
注意 :
get_suffix()必须在require('./02_ts')之前调用,确保在 VMP JS 开始执行前就已完成 Hook。大多数站点没有后缀机制,用gen_cookie()读取document.cookie即可;当请求接口返回 412 且确认 Cookie 正确时,才需考虑启用后缀机制。
5.5 Python 侧调用代码
python
import re
import subprocess
import requests
def call_js_function(js_file_path, function_name):
result = subprocess.run(
['node', js_file_path, function_name],
capture_output=True, text=True, check=True, encoding='utf-8'
)
return result.stdout.strip()
def get_rs_cookie(host, url, ts_js_path, source_js_path, main_js_path):
# 1. 请求首页,获取动态数据
res = requests.get(url, headers=headers, verify=False)
html = res.content.decode('utf-8')
# 2. 提取 ts_js 和 VMP JS 路径
ts_js = re.findall(r'<script.*?>(.*?)</script>', html, re.DOTALL)[1]
js_code_path = re.findall('src=(.*?) ', html)[0][1:-1]
# 3. 下载 VMP JS
js_code = requests.get(f"{host}{js_code_path}", headers=headers, verify=False).text
# 4. 写入本地文件
with open(ts_js_path, 'w', encoding='utf-8') as f:
f.write(ts_js)
with open(source_js_path, 'w', encoding='utf-8') as f:
f.write(js_code)
# 4. 同时保存服务端下发的 Cookie1(来自响应头 Set-Cookie)
cookie1 = res.cookies.get_dict() # {'UA1L1zGonajvO': '60uvx...'}
# 5. 调用 Node.js 生成 Cookie2(VMP JS 写入 document.cookie)
cookie_res = call_js_function(main_js_path, "gen_cookie")
key, value = cookie_res.split(';')[0].split('=', 1)
cookie2 = {key: value} # {'UA1L1zGonajvP': '0VrEW...'}
# 6. 合并两个 Cookie 一起返回
return {**cookie1, **cookie2}
def request_with_rs(api_url, cookies, data, headers):
res = requests.post(api_url, cookies=cookies, headers=headers, data=data, verify=False)
if res.status_code == 412:
# Cookie1 和 Cookie2 均需刷新
cookies.update(get_rs_cookie(...))
res = requests.post(api_url, cookies=cookies, headers=headers, data=data, verify=False)
return res
六、关于 content 字段
大多数瑞数站点的首页 HTML 中,除了 $_ts 代码块外,还会同时返回一个 content 字段(通常是一段 JSON 或配置数据,赋值给 window.content)。
html
<!-- 示例:页面中的 content 赋值 -->
<meta name="content" content='{"key":"value",...}' />
<!-- 或以 JS 变量形式出现 -->
<script>window.content = {...}</script>
是否需要在补环境中注入 content? 大多数站点的 VMP JS 实际上不会 读取 window.content 参与 Cookie 计算,即使不注入该字段,补环境也能正常生成 Cookie。仅在少数站点(VMP JS 内部确实引用了 window.content)时,才需要额外提取并注入:
python
# 仅在确认 VMP JS 依赖 content 字段时才需要此步骤
content_text = re.findall(r'content=(.*?) ', html, re.DOTALL)[1]
with open('./06_content.js', 'w', encoding='utf-8') as f:
f.write(f"window.content={content_text}")
# 加载顺序:01_env.js → 06_content.js → 02_ts.js → 03_source.js
调试建议 :若不确定是否需要注入,可先不注入直接跑,Cookie 生成成功则无需处理;若 Node.js 执行报错提示
window.content相关异常,再按需注入。
七、动态 VMP JS 文件缓存策略
VMP JS 文件的 URL 路径是完全随机的字符串(如 /fpqQrgG7L6po/eaKJbLE9bqof.e17ed02.js),服务端会定期更新。建议以完整 URL 路径作为缓存键,URL 变化时重新下载:
python
import os
import hashlib
def get_js_with_cache(host, js_url_path, cache_dir='./cache'):
"""
以 URL 路径的 MD5 为缓存键,文件存在则复用,URL 变化时重新下载
"""
# 用 URL 路径的 MD5 作为缓存文件名,避免文件名中含特殊字符
url_hash = hashlib.md5(js_url_path.encode()).hexdigest()[:12]
cache_path = os.path.join(cache_dir, f"source_{url_hash}.js")
if os.path.exists(cache_path):
# 缓存命中,直接复用
with open(cache_path, 'r', encoding='utf-8') as f:
return f.read()
else:
# 下载新版本,写入缓存
js_code = requests.get(f"{host}{js_url_path}").text
os.makedirs(cache_dir, exist_ok=True)
with open(cache_path, 'w', encoding='utf-8') as f:
f.write(js_code)
return js_code
八、本项目涉及的瑞数站点汇总
以下是本次研究中实际接入瑞数处理的站点清单:
8.1 政务监管类
| 站点 | 域名 | 说明 |
|---|---|---|
| 湖北市场监管 | scjg.hubei.gov.cn |
瑞数6,标准补环境方案 |
8.2 学术资源类
| 站点 | 域名 | 说明 |
|---|---|---|
| 维普期刊(期刊频道) | qikan.cqvip.com |
瑞数6,标准补环境方案 |
| 维普期刊(图书馆入口) | lib.cqvip.com |
瑞数6,与 qikan 使用相同 VMP JS |
8.3 政务数据类
| 站点 | 域名 | 说明 |
|---|---|---|
| 海关统计数据在线 | stats.customs.gov.cn |
瑞数6,标准补环境方案 |
| 上海房地产交易中心 | www.fangdi.com.cn |
瑞数6,另有 URL 后缀改写逻辑 |
| 国家知识产权局专利检索 | epub.cnipa.gov.cn |
瑞数6,需注入 content 字段(从 meta 标签提取) |
| 国家药品监督管理局 | www.nmpa.gov.cn |
瑞数6,标准补环境,另有 MD5 签名(sign + timestamp 参数) |
8.4 商业平台类
| 站点 | 域名 | 说明 |
|---|---|---|
| 欧冶云商(钢铁电商) | www.ouyeel.com |
瑞数6,标准补环境方案 |
九、常见问题与调试
Q1:Node.js 执行 03_source.js 时崩溃报错
通常是补环境不完整。建议在 01_env.js 中临时开启 Proxy 拦截,打印所有被访问的属性:
javascript
// 调试用:对 window 开启 Proxy,打印所有 get/set 访问
window = new Proxy(global, {
get(target, property) {
console.log('[GET]', property);
return Reflect.get(target, property);
},
set(target, property, value) {
console.log('[SET]', property, '=', value);
return Reflect.set(target, property, value);
}
});
根据打印结果,逐一补充缺失的属性。
Q2:Cookie 生成成功但请求仍返回 412
可能原因:
- Cookie 已过期:瑞数 Cookie 有效期较短,需在请求前重新生成
- Cookie 键名错误 :Cookie 名称由 VMP JS 动态计算,需从
document.cookie输出中正确提取 - UA 不一致 :
01_env.js中的navigator.userAgent必须与请求头中的User-Agent完全一致
Q3:VMP JS 文件更新后 Cookie 失效
VMP JS 文件的 URL 哈希变化时,旧版生成的 Cookie 可能被服务端拒绝。解决方案:
- 每次请求首页时重新提取最新的 VMP JS URL 并下载
- 检测到 URL 哈希变化时,清除旧缓存文件
Q4:确认已有两个 Cookie 但仍然返回 412
请检查两个 Cookie 是否都是最新的。瑞数的 Cookie1 和 Cookie2 是配套的(同一次首页请求产生),如果只刷新了其中一个而另一个使用了旧值,服务端仍会拒绝。刷新时必须同时重新获取两者。
十、小结
瑞数的核心防护思路可以概括为四点:
- 动态性:每次下发的 JS 代码和配置数据都不同,使固定逆向结果失效
- VMP 保护:核心逻辑运行在自定义虚拟机中,难以静态分析
- 多维指纹:从浏览器属性、窗口尺寸、Chrome 对象等多维度综合判断是否为真实浏览器
- 站点隔离:每个站点的关键参数独立配置,防止通用破解
补环境方案的优势:直接调用原始 VMP JS,无需手动还原算法,适配性强,对大量相似结构的站点可复用同一套补环境框架,仅需按站点调整关键配置。
十一、依赖安装
bash
pip install requests
node --version # 需要 Node.js 环境(建议 v16+)
本文技术仅供安全研究与学习,切勿用于任何未授权系统,违者后果自负。