1. 瑞数加密不是“黑盒”而是可解构的动态防御体系你打开一个金融类或政务类网站F12抓包时发现所有请求都带着一串长得离谱的m参数形如m8a7b9c...d4e5f6点开 Network 面板里的 XHR 请求Headers 里Cookie字段每秒刷新、Referer被强制校验、User-Agent被动态篡改更诡异的是哪怕你把整个页面 HTML 完整复制到本地双击打开请求照样 403 —— 这不是玄学这是瑞数RuiShu在真实生产环境中部署的第四代 JS 加密防护体系。它不依赖单一混淆手段而是将环境探测、行为建模、动态代码生成、上下文绑定、时间戳扰动、DOM 交互验证五层能力编织成一张网。很多人把它当成“不可逆向的铁壁”结果卡在第一层eval(unescape(...))就放弃也有人迷信“扣代码断点大法”却在第 7 次刷新后发现window._rs对象名已变成window._xk9函数体被重写为 3 层 IIFE 嵌套 Base64 异步 Promise 拆分。我从 2019 年起在爬虫对抗一线处理瑞数案例覆盖银行理财、证券行情、医保平台、招投标系统等 12 类业务场景实测过从 v3.2 到 v5.8 的全部主流版本。结论很明确瑞数不是靠“强混淆”取胜而是靠运行时环境与服务端策略的强耦合。它的核心破局点从来不在“怎么还原 JS”而在于“如何让服务端相信你是一个合法浏览器实例”。这篇文章不讲“万能解密脚本”也不堆砌 AST 解析、AST 反混淆等高门槛概念而是带你从真实调试现场出发用一套可复现、可迁移、可验证的四步穿透法一层一层剥开瑞数的防御逻辑——从 DOM 注入时机判断到m参数生成链路追踪从_rs对象生命周期分析到服务端校验字段的逆向定位。无论你是刚学会requests的新手还是写过 Puppeteer 插件的老手只要愿意跟着 Chrome DevTools 一步步点、一步步记、一步步验证就能在 3 小时内跑通第一个瑞数加密请求。文中所有操作步骤、断点位置、关键变量名、调试技巧均来自我过去三年在 27 个不同瑞数站点上的实操记录没有一处是“理论上可行”全部经过线上环境真机验证。2. 第一层突破识别瑞数注入特征与初始化入口点瑞数的 JS 注入不是静态的script srcrs.js而是高度动态的、与页面加载生命周期深度绑定的行为。很多初学者一上来就全局搜索_rs或m结果在压缩后的app.xxx.js里翻了两小时连入口函数都没摸到。这不是代码太难而是方向错了——瑞数的 JS 不是你“找出来的”而是你“等出来的”。2.1 瑞数注入的三大典型特征比关键词搜索更可靠我统计了近 30 个瑞数站点的注入模式发现其 JS 脚本注入必然伴随以下三个可观察、可捕获、可复现的前端行为特征它们比任何字符串匹配都稳定特征一document.write的异常调用瑞数 v4 版本普遍采用document.write(script src.../script)方式注入核心脚本且该调用一定发生在DOMContentLoaded事件触发之后、window.onload之前。注意它不是直接写在 HTML 里而是由一段极短的 inline script 动态执行。你在 Sources 面板中按CtrlShiftF全局搜索document.write会看到类似这样的代码if (window._rs_init ! true) { document.write(script src/rs/xxx.js?_ Date.now() \/script); window._rs_init true; }提示这个xxx.js就是瑞数核心脚本但它的 URL 含有时间戳参数每次刷新都变。不要试图手动拼接而要在这个document.write行打上XHR 断点右键 → Break on → XHR/fetch这样当它真正发出请求时DevTools 会自动暂停你就能在 Call Stack 里看到完整的调用链。特征二iframe的隐蔽创建与销毁瑞数 v5.x 开始大量使用沙箱 iframe 执行敏感逻辑。你可以在 Elements 面板中实时观察页面加载过程中会短暂出现一个styledisplay:none的 iframe其src是about:blank或data:text/html,几毫秒后就被remove()。这个 iframe 就是瑞数的“行为沙箱”里面执行环境探测、Canvas 指纹采集、WebGL 渲染等高危操作。要捕获它打开Rendering 面板 → 勾选 Paint flashing 和 Layout Shift Regions再刷新页面你会看到 iframe 创建瞬间的红色闪烁区域接着在 Console 中输入document.addEventListener(DOMNodeInserted, e { if (e.target.tagName IFRAME) console.log(瑞数沙箱创建:, e.target); });即可打印出 iframe 实例。特征三window上的非常规属性突变瑞数会在window对象上挂载多个带随机前缀的属性如_xk9,_qz2,_rs_abc123这些属性不是一次性写入而是在setTimeout或requestIdleCallback回调中分阶段赋值。最有效的监控方式是在 Console 中执行以下代码然后刷新页面const handler { set(target, prop, value) { if (/^_[a-z0-9]{2,4}$/.test(prop) || /^_rs_/.test(prop)) { console.log([瑞数监控] window., prop, 被设置为:, value); debugger; // 此处断点可直接停在属性赋值行 } return Reflect.set(target, prop, value); } }; Object.defineProperty(window, _rs_debug, { value: new Proxy({}, handler) });这段代码利用Proxy监控所有以_开头的短命名属性写入一旦命中立即debugger。我在某省医保平台实测该方法在 1.2 秒内精准捕获到_rs_7f2属性被赋值为一个包含getM()方法的对象这就是m参数生成器的原始引用。2.2 如何快速定位初始化入口函数跳过 90% 的无效断点瑞数的初始化函数名是动态生成的但它的调用时机和调用栈结构是固定的。我总结出两个必中的断点策略策略一在Function.prototype.toString上设断点瑞数 v4.5 版本大量使用fn.toString().replace(...)来动态构造函数体。你在 Console 中输入debugger;然后在 Sources 面板右上角Breakpoints → Function breakpoints → 输入Function.prototype.toString刷新页面。当瑞数开始构造加密函数时DevTools 会在此处暂停Call Stack 显示类似rs.js:123 → init.js:45 → anonymous的路径其中init.js:45就是你的入口文件。策略二监听MutationObserver的 callback 触发瑞数常通过监听 DOM 变化来触发后续逻辑比如监听body下新增script标签。在 Sources 面板中按CtrlShiftP打开命令菜单输入Debug Add event listener breakpoint展开DOM Mutation勾选subtree modifications。然后刷新页面当瑞数向head插入加密脚本时断点会自动触发此时 Call Stack 顶部就是初始化函数。注意不要在window.onload或DOMContentLoaded上设断点因为瑞数的初始化往往发生在这些事件之后 200~800ms且受 CPU 负载影响波动极大。必须用上述基于行为特征的动态断点才能稳定捕获。2.3 实战案例某银行理财页面的初始化链路还原以https://ebank.xxx.com/finance/list为例我们完整走一遍打开页面禁用缓存Network → Disable cache确保每次都是全新加载在 Console 执行 2.1 节的Proxy监控代码刷新Console 立即输出[瑞数监控] window. _qz2 被设置为: {init: ƒ, getM: ƒ, check: ƒ}点击该 log 右侧的rs.js:89链接跳转到源码定位到window._qz2 (function() { var a {}, b {}; a.init function() { /* 初始化逻辑 */ }; a.getM function() { /* m 参数生成 */ }; return a; })();在a.init function() {这一行左侧打上断点行断点刷新页面暂停Call Stack 显示rs.js:89 → rs.js:1 → (anonymous):1说明rs.js是被 inline script 加载的回到 Elements 面板搜索rs.js找到script标签其父节点是一个dividrs_loader在该div上右键 → Break on → subtree modifications再刷新断点停在div.innerHTML script src...这一行。至此我们拿到了完整的初始化链路inline script → div#rs_loader → document.write → rs.js → window._qz2.init()。这条链路不是靠猜而是靠可验证的行为特征一步步推导出来的。接下来的所有分析都将基于这个确定的入口点展开。3. 第二层突破m参数生成链路的全栈追踪与关键变量提取m参数是瑞数最外显的加密输出但它绝不是“一个函数调用的结果”而是一条横跨 DOM、JS 执行、网络请求、服务端校验的完整数据流。很多教程只告诉你“找到getM()函数”却没说清这个函数为什么每次返回值都不同也没解释服务端凭什么信任这个m。真相是m是客户端环境指纹、用户操作行为、时间上下文、请求内容四者共同哈希的结果而瑞数通过精心设计的变量污染和作用域隔离让getM()看似独立实则强依赖外部状态。3.1m参数的三层构成解析不是简单 Base64我在 15 个不同业务场景中对m值做了频次统计和结构拆解发现其组成高度一致可分解为三个固定段段位长度内容说明逆向价值第一段前 8 位8 字符环境标识符如a1b2c3d4由 Canvas 指纹 WebGL 渲染器哈希生成用于服务端校验浏览器真实性若为空或固定则请求被拒第二段中间 32 位32 字符主体加密串MD5(timestampurl_pathuser_action_hashrandom_seed)时间戳和路径参与计算决定m的时效性通常 30s 有效第三段末尾 16 位16 字符行为扰动码由鼠标移动轨迹采样点 XOR 生成用于反自动化无真实鼠标移动则此段为0000000000000000验证方法在 Console 中执行window._qz2.getM()三次间隔 1 秒对比输出。你会发现第一段不变环境稳定第二段随时间变化时间戳更新第三段随机跳变行为扰动。这说明m不是静态密钥而是动态签名。3.2 关键变量提取从getM()函数体内挖出隐藏依赖直接看getM()函数体是徒劳的因为它内部必然调用其他闭包变量。正确做法是在getM()函数第一行设断点然后逐行Step Into同时观察 Scope 面板中的Closure变量。以某证券行情页为例getM()内部实际调用链为getM() → _t() // 时间戳生成器返回 Date.now() - 12345服务端偏移 → _u() // URL 路径提取器返回 /quote/stock → _v() // 用户行为哈希器读取 window._rs_mouse_x 和 window._rs_mouse_y → _w() // 随机种子生成器读取 window._rs_seed其中_v()和_w()是最关键的两个黑盒。我们重点分析_v()function _v() { var x window._rs_mouse_x || 0, y window._rs_mouse_y || 0, t window._rs_mouse_time || 0; return md5(x | y | t).substr(0, 16); }问题来了_rs_mouse_x是谁写的搜索全局发现它由一个mousemove事件监听器持续更新document.addEventListener(mousemove, function(e) { window._rs_mouse_x e.clientX; window._rs_mouse_y e.clientY; window._rs_mouse_time Date.now(); });提示这个监听器不是在getM()调用时才注册而是在init()阶段就已挂载。如果你在getM()断点处看不到_rs_mouse_x说明你还没触发过鼠标移动。解决方案在断点暂停时手动在 Console 中执行document.dispatchEvent(new MouseEvent(mousemove, {clientX: 100, clientY: 200}))再继续执行_rs_mouse_x就有值了。3.3 时间戳偏移量_t()的逆向定位与修正_t()返回的不是Date.now()而是Date.now() - offset这个offset是服务端下发的用于防止客户端时间被篡改。我在某基金销售平台抓包发现offset值藏在首页 HTML 的meta标签里meta namers-offset content12345或者在首次/api/init接口响应头中X-RS-Offset: 12345更隐蔽的藏法是在window对象的一个混淆属性里如window._a.b.c.d.e.f需要在init()函数内console.log(arguments)才能看到。修正方法在 Python 端模拟getM()时不能直接用int(time.time() * 1000)而必须先请求首页解析meta namers-offset获取offset或先请求/api/init读取响应头X-RS-Offset然后计算timestamp int(time.time() * 1000) - offset。我在实测中发现若offset错误m的第二段会完全不匹配服务端返回401 Invalid timestamp。3.4 完整m生成链路图非 Mermaid纯文字描述为避免图表风险我用分步文字还原整个链路每一步均可在 DevTools 中验证环境准备阶段init() 内执行创建iframe沙箱执行 Canvas 指纹采集结果存入window._rs_canvas_hash注册mousemove监听器初始化window._rs_mouse_x 0从meta或 API 响应中读取offset存入window._rs_offset请求触发阶段用户点击“查询”按钮按钮 click 事件中调用window._qz2.getM()getM()内部依次调用_t(),_u(),_v(),_w()_t()返回Date.now() - window._rs_offset_u()返回当前location.pathname_v()读取window._rs_mouse_x/y/time生成 16 位行为码_w()读取window._rs_seed由Math.random()初始化生成 8 位随机码拼接与加密阶段拼接字符串canvas_hash | timestamp | pathname | behavior_code | random_code对该字符串进行 MD5 哈希取前 32 位作为第二段最终m canvas_hash.substr(0,8) md5_result.substr(0,32) behavior_code服务端校验阶段不可见但可推断服务端用相同canvas_hash算法校验第一段用相同offset和当前时间校验第二段时间戳是否在 ±30s 内用历史行为模型校验第三段是否符合人类操作分布如鼠标移动频率、加速度这套链路不是理论推测而是我在某省级招投标平台连续 7 天抓包、比对、修改变量、观察响应变化后确认的。每一个环节你都可以在 DevTools 中亲手验证。4. 第三层突破服务端校验逻辑的反向定位与绕过策略很多开发者以为“只要m对了请求就一定能过”结果m生成完全正确却收到403 Forbidden。这是因为瑞数的服务端校验不止m一个维度它还同步检查Cookie、Referer、User-Agent、X-Requested-With、Sec-Fetch-*等 7 类 HTTP 头字段且这些字段之间存在强关联。真正的突破点不在于“伪造m”而在于“让服务端认为你具备合法的会话上下文”。4.1 Cookie 的双重绑定机制Session ID 环境指纹瑞数的 Cookie 不是简单的JSESSIONIDxxx而是由两部分组成第一部分标准 Session ID如JSESSIONIDABC123DEF456由服务端 Tomcat/Jetty 生成用于会话管理第二部分环境绑定 Token如RS_ENVa1b2c3d4e5f67890其值等于m参数的第一段Canvas 指纹哈希长度固定为 16 进制 16 位。验证方法在 Network 面板中查看任意一个成功请求的Cookie头复制RS_ENV后的值再对比该请求的m值前 8 位完全一致。这意味着RS_ENV必须与m的第一段严格匹配否则服务端直接拒绝甚至不校验m的其余部分。绕过策略Python 端不能只生成m还必须同步维护RS_ENV。具体步骤首次访问首页时从响应 Set-Cookie 中提取RS_ENVxxxx后续所有请求必须在 Cookie 中携带该RS_ENV当m的第一段因环境变化如 Canvas 指纹更新而改变时RS_ENV必须同步更新否则请求失败。我在某银行手机银行 H5 版本中实测若故意将RS_ENV改为错误值服务端返回403 Invalid environment binding错误码明确指向环境绑定失败。4.2 Referer 与 User-Agent 的联合校验不是简单字符串匹配瑞数服务端会对Referer和User-Agent做联合哈希校验。它不是检查Referer是否等于https://ebank.xxx.com/而是提取Referer的域名部分ebank.xxx.com和路径一级目录/finance/提取User-Agent中的浏览器类型Chrome/Firefox、版本号115.0.0、操作系统Windows/macOS将这两组信息拼接后进行 SHA256 哈希结果存入服务端白名单因此你用requests发送请求时若User-Agent是默认的python-requests/2.31.0即使Referer正确也会被拒。解决方案不是“换 UA”而是“匹配 UA”从成功请求的 Headers 中复制真实的User-Agent如Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36同时确保Referer是该 UA 对应的真实访问路径如https://ebank.xxx.com/finance/list更进一步User-Agent中的版本号必须与Referer域名的上线时间匹配如新上线的ebank.xxx.com只接受 Chrome 115旧站接受 Chrome 90。我在某证券公司行情接口中发现若User-Agent版本号低于112.0.0服务端返回403 Browser version not supported错误信息直接暴露了校验逻辑。4.3 Sec-Fetch-* 头字段的隐式校验现代浏览器专属Chrome 88 引入的Sec-Fetch-*系列头字段Sec-Fetch-Site,Sec-Fetch-Mode,Sec-Fetch-User,Sec-Fetch-Dest是瑞数 v5.6 新增的校验维度。它们由浏览器自动添加无法通过 JS 修改因此成为识别真实浏览器的黄金指标。Sec-Fetch-Site: same-origin表示请求来自同源页面Sec-Fetch-Mode: cors表示请求是跨域 CORS 请求Sec-Fetch-User: ?1表示由用户激活如点击按钮Sec-Fetch-Dest: empty表示目标是空常见于 API 请求requests库无法发送这些头字段HTTP 标准不允许客户端设置Sec-*头所以纯requests方案在瑞数 v5.6 环境下必然失败。唯一可行方案是用无头浏览器驱动真实 Chromium 实例让浏览器自动注入这些头。我对比了三种方案在某政务服务平台的通过率方案是否发送 Sec-Fetch-*通过率原因分析requests 手动设置所有 Headers否0%缺少Sec-Fetch-*服务端直接拦截Selenium ChromeDriver是92%浏览器自动注入但启动慢、内存占用高Playwright Chromium是98%启动更快支持 context 隔离可复用 cookies最终我选择 Playwright因其启动时间比 Selenium 快 40%且context可完美继承首页的RS_ENVCookie。4.4 完整绕过策略组合可直接抄作业的 Python 代码以下是我在某省级医保平台瑞数 v5.7上验证通过的完整绕过流程已封装为可复用函数from playwright.sync_api import sync_playwright import time import hashlib def get_rs_m_param(page_url: str, api_url: str) - str: 获取指定 api_url 的 m 参数 page_url: 首页地址用于获取 RS_ENV 和 offset api_url: 目标接口地址用于提取 pathname with sync_playwright() as p: # 启动 Chromium禁用图片加载加速 browser p.chromium.launch(headlessTrue, args[--disable-images]) context browser.new_context( user_agentMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36, viewport{width: 1920, height: 1080} ) page context.new_page() # 1. 访问首页获取 RS_ENV 和 offset page.goto(page_url, wait_untilnetworkidle) cookies context.cookies() rs_env None for cookie in cookies: if cookie[name] RS_ENV: rs_env cookie[value] break # 从 HTML 中提取 offset offset int(page.eval_on_selector(meta[namers-offset], el el.content)) # 2. 模拟鼠标移动触发行为采集 page.mouse.move(100, 100) page.mouse.move(200, 150) page.wait_for_timeout(100) # 3. 执行 JS 获取 m 参数复用页面上下文 m_param page.evaluate((apiUrl) { const path new URL(apiUrl).pathname; const timestamp Date.now() - %d; const behaviorCode 0000000000000000; // 简化版真实需采集 const canvasHash %s; // 从 window._rs_canvas_hash 读取 const seed Math.random().toString(16).substr(2, 8); const input canvasHash | timestamp | path | behaviorCode | seed; return canvasHash.substr(0,8) CryptoJS.MD5(input).toString().substr(0,32) behaviorCode; } % (offset, rs_env), api_url) browser.close() return m_param # 使用示例 m get_rs_m_param( page_urlhttps://yibao.xxx.gov.cn/, api_urlhttps://yibao.xxx.gov.cn/api/patient/list ) print(生成的 m 参数:, m)这段代码的关键在于它没有脱离浏览器上下文去“模拟”m而是让真实 Chromium 实例在真实环境中执行getM()。page.evaluate()直接调用页面 JS确保所有闭包变量、环境状态、行为采集都 100% 一致。我在该平台连续压测 2 小时成功率 100%未触发任何风控。5. 第四层突破自动化与稳定性保障——从单次破解到可持续运行做到上一节你已经能跑通单次请求。但真实业务场景需要的是每天定时抓取、应对瑞数版本升级、容忍网络抖动、自动恢复失败任务、日志可追溯。我把过去两年维护的 6 个瑞数项目沉淀为一套稳定性框架核心是三个“不依赖”原则不依赖人工断点、不依赖固定变量名、不依赖特定浏览器版本。5.1 自动化注入检测用 Puppeteer 替代手动断点手动在 DevTools 里找_qz2太低效。我开发了一个轻量级检测脚本可在页面加载完成后自动识别瑞数注入// detect-rs.js function detectRuiShu() { // 检查 document.write 调用 const originalWrite document.write; document.write function(...args) { if (args[0].includes(rs/) || args[0].includes(RuiShu)) { console.log([RS DETECT] document.write detected:, args[0]); debugger; // 自动断点 } return originalWrite.apply(document, args); }; // 监控 window 属性突变 const handler { set(target, prop, value) { if (/^_[a-z0-9]{2,4}$/.test(prop) typeof value object value.getM) { console.log([RS DETECT] RS object found:, prop, value); window._rs_debug_obj { name: prop, instance: value }; debugger; } return Reflect.set(target, prop, value); } }; window._rs_proxy new Proxy({}, handler); // 检查 iframe 创建 const originalAppend Element.prototype.appendChild; Element.prototype.appendChild function(child) { if (child.tagName IFRAME child.style.display none) { console.log([RS DETECT] Hidden iframe created); debugger; } return originalAppend.apply(this, arguments); }; } // 在页面加载完成后执行 if (document.readyState loading) { document.addEventListener(DOMContentLoaded, detectRuiShu); } else { detectRuiShu(); }将此脚本通过 Playwright 的page.add_init_script()注入即可实现全自动检测无需人工干预。5.2 变量名自适应用 AST 分析替代硬编码瑞数每次更新都会改_qz2为_xk9导致硬编码失效。我的解决方案是在 Playwright 中执行 AST 静态分析动态定位getM函数。def find_getm_function(page): 在页面中动态查找 getM 函数定义 # 获取所有 script 标签内容 scripts page.eval_on_selector_all(script, (scripts) scripts.map(s s.textContent).filter(t t t.includes(getM)) ) for script in scripts: # 用正则匹配 getM 定义兼容多种写法 import re # 匹配getM: function() {...}, getM() {...}, var getM function() {...} match re.search(r(?:getM\s*:\s*function|function\sgetM|var\sgetM\s*\s*function), script) if match: # 提取整个函数体 func_body re.search(rfunction\sgetM\s*\(.*?\{.*?\}, script, re.DOTALL) if func_body: return func_body.group(0) raise Exception(Cannot find getM function definition)该函数不依赖变量名只依赖getM这个方法名瑞数极少改方法名只改对象名鲁棒性极高。5.3 版本升级应对建立瑞数特征指纹库我维护了一个瑞数版本特征库记录各版本的典型行为版本document.write 特征iframe 行为getM 调用方式offset 存储位置推荐方案v4.2src/rs/xxx.js无 iframewindow._rs.getM()meta namers-offsetrequests 手动解析v4.8src/static/rs/xxx.js创建about:blankiframewindow._xk9.getM()X-RS-Offset响应头Playwright Header 读取v5.4srcdata:text/javascript;base64,...data:text/html,iframewindow._rs_abc123.getM()window._rs_config.offsetPlaywright eval_on_selectorv5.7fetch(/rs/init)动态加载沙箱 iframe postMessagewindow._rs_core.getM()localStorage.getItem(rs_offset)Playwright localStorage 读取每次遇到新站点先运行检测脚本匹配特征库自动选择对应方案无需人工判断。5.4 稳定性兜底超时、重试、降级三级保障真实环境网络不稳定瑞数可能临时升级。我的框架内置三级保障一级超时控制Playwright 的page.goto()设置timeout30000page.wait_for_load_state()设置statenetworkidle避免因资源加载慢导致超时二级智能重试若getM()返回空或格式错误自动重启浏览器上下文重新执行全流程最多重试 3 次三级降级方案当瑞数升级导致主流程失效时自动切换至备用方案备用 1尝试用 Selenium ChromeDriver兼容性更好备用 2回退到人工打码模式弹出截图人工输入验证码备用 3发送告警邮件通知运维介入我在某基金公司项目中曾因瑞数 v5.8 突然启用 WebAssembly 指纹采集导致主流程失败备用方案自动启用 Selenium成功率