瑞数6vmp逆向实战:从虚拟机入口到算法工程化落地
2026/9/16 20:56:08 网站建设 项目流程

说起瑞数这个反爬体系,搞过JS逆向的同行应该都不陌生。早些年的瑞数5还相对友好,虽然一样有动态令牌、cookie校验,但至少代码结构还能靠AST和正则梳理出个七七八八。到了6代,整套体系升级成 VMP 虚拟机保护方案,业内俗称“瑞数6vmp”,我印象里第一次接触是在某大厂的风控页面上,打开Sources直接看到一段几千行的动态JS,所有函数名全是短横线加数字,运算过程全部落到一个巨大的字节码分发器里,那一刻我才意识到:之前那套“硬啃JS逻辑”的思路,在6代面前基本行不通了。

这篇文章不打算写成那种“复制粘贴即可跑”的教程倒卖文。我想从自己实际逆向国家企业征信系统页面时遇到的瑞数6vmp开始,讲清楚这套虚拟机的入口怎么找、opcode怎么还原、控制流怎么脱混淆,以及最后如何把算法从浏览器里抽出并工程化落地。无论你是刚接触瑞数的新手,还是已经能过5代、正准备碰6代的进阶选手,这篇都有些参考价值。

1. 瑞数6vmp到底防的是什么:加密链路的全貌

1.1 动态令牌不是普通的cookie

很多人以为瑞数就是生成一个cookie那么简单的反爬机制,实际上它的核心逻辑远比“生成一个token”复杂。瑞数是一套动态安全防护方案,它会先在你的浏览器里注入一段JS,这段JS会采集浏览器环境信息(UA、WebGL图形渲染结果、Canvas绘制内容、字体列表、时间戳偏移、鼠标轨迹特征等)作为种子,再经过大量运算生成一个动态令牌。服务器端会验证这个令牌的合法性,包括令牌是否由真实浏览器生成、是否被篡改、生成时间是否合理、携带的指纹是否和当前请求环境一致。

所以,当你试图用requests直接请求目标页面时,除非你手动带上曾经通过浏览器生成的cookie,否则瑞数会在服务端检测到令牌缺失或异常,直接给你返回一段用于生成新cookie的JS,而不是你想要的数据。这个“动态”体现在两个层面:

  • 每次会话生成的cookie值都不同,而且同一会话内多次请求也可能刷新cookie;
  • 生成cookie的JS代码本身也在变化,每次请求返回的混淆代码几乎都不一样。

这两个特性叠加,导致网上很多“固定正则提取参数”的脚本在瑞数面前不堪一击。我见过不少初学逆向的同行,拿着瑞数5时代的老脚本去跑瑞数6,结果发现连生成cookie的入口函数名字都找不到了。原因很简单,6vmp把核心算法用虚拟机保护了起来,代码里真正执行的动作通过一串自定义字节码驱动,而不是直接写在明文JS里。

1.2 6代相比5代到底变化了什么

如果你以前逆向过瑞数5,你会有一个比较直观的印象:代码虽然混淆得很厉害,但你还是能找到一些有规律的结构,比如生成长度固定的大数组、某个字符串拼接的算法模块、以及一串处理cookie赋值的逻辑。瑞数6代则直接把那段“看得见摸得着”的算法逻辑收进了一个自定义虚拟机体系里。

我根据自己的分析经验,把瑞数5代和6代的关键差异列了一个表:

对比维度瑞数5代瑞数6vmp
核心算法形态普通JavaScript代码 + 字符串数组混淆字节码 + 虚拟机解释器
控制流混淆数组乱序、函数分裂、花指令控制流平坦化 + opcode分发
反调试强度有,但相对有限更强的环境检测,检测到异常直接生成错误结果
cookie更新频率相对稳定高频率动态变化,依赖复杂状态
逆向难度中等,熟悉AST后较易处理高,需要逆向虚拟机指令集

更直白地说,5代你面对的是“一段复杂但仍然是JS的算法”;6代你面对的是“一个自定义的指令集解释器”。前者可以用语法分析工具强行梳理,后者你得先搞清楚这个虚拟机的指令集长什么样,然后才能谈还原算法。这也是为什么很多人都说“瑞数6vmp是个分水岭”,一旦过了这道坎,再去看其他家的VMP方案,思路基本是通用的。

1.3 分析前的准备环境

在正式开搞之前,环境准备这部分值得多说两句。我用的是 Node.js + Puppeteer 来控制无头浏览器加载目标页面,同时用 Chrome DevTools Protocol(CDP)来断点调试和hook脚本。之所以选择 Puppeteer 而不是直接用本地 Chrome 手动调试,是因为瑞数对“是否在真实浏览器环境运行”非常敏感。手动打开浏览器去页面调试当然可以,但如果每次都要用手指去点断点,人肉效率太低。通过 CDP 可以远程控制浏览器,在关键代码位置自动下断点,效率会高很多。

另外,我建议在你的调试环境里提前安装好以下工具:

  • Node.js 环境(版本建议16以上,配合Puppeteer使用)
  • Puppeteer最新版本
  • Chrome DevTools Protocol相关的基础库(puppeteer-core即可,不需要额外引其他CDP库)
  • 一个顺手的数据抓包代理工具,比如Fiddler或者Charles,方便比对请求和响应差异

这些准备不是必要条件,但能显著减少你“实际动手时才发现环境不对”的返工时间。

2. 定位虚拟机入口:绕过外围混淆的几条有效路径

2.1 从cookie赋值点逆推是最稳的路径

瑞数6vmp代码虽然极度混淆,但它终究要在一个时间点把生成好的cookie写入document.cookie,或者通过fetch/XHR的请求头把cookie带出去。因此,定位cookie写入点是我个人认为最可靠的突破口。

具体操作上,我一般先在Chrome开发者工具里打开目标页面,让页面刷新加载瑞数脚本,然后执行以下几步:

  1. 在控制台输入document.cookie,观察页面加载前后cookie的变化,识别出瑞数生成的cookie名(常见的有_xc_xcd之类的命名);
  2. 在Sources面板中对cookie名进行全局搜索(按Ctrl+Shift+F),找到所有对该cookie字符串的引用;
  3. 在所有引用位置下断点,刷新页面观察哪一个位置实际触发了cookie赋值。

只要断点命中,你就是站在了vm入口的边缘。接下来要做的,就是从当前调用栈向上回溯,一层层脱离外围混淆壳,最终找到那个真正开始处理字节码的函数。这个函数通常是整个vm的启动器,在上一层的调用环境中,你会看到类似runVm(vd, key, args)这种形态的调用(当然变量名不会是明文,这里是我按理解抽象出来的名字)。

2.2 hook关键APIs收集调用上下文

直接下断点观察是一次性的,如果刷新几次代码变了,断点位置就会失效。更稳的做法是在关键API层面进行hook。瑞数6vmp的代码无论怎么变,它最终必然要访问document.cookielocalStoragecanvas.toDataURLWebGL的读取结果、以及各类存活时间相关API。你可以通过CDP的Page.addScriptToEvaluateOnNewDocument在页面加载最早期注入hook代码,把这些最关键API的调用记录和返回值全部打印或者缓存起来。

我在实际调试瑞数页面时,最优先hook的是以下几个API:

  • document.cookie的setter和getter
  • localStoragesessionStorage的存取接口
  • canvas.toDataURLcanvas.getContext('2d')的调用结果
  • navigator.userAgent等环境属性的读取
  • Date.now()performance.now()的时间戳结果

为什么优先hook这些?因为瑞数6vmp的cookie参数来源于环境指纹和时间戳,而这些指纹提取一定建立在调用上述API之上。你只要把这些API的返回值和当前调用栈记录下来,就能知道vm在生成算法时“吃了哪些输入”。有了输入和输出,后续做纯算法还原时你就有了一张明确的对应表:哪一段字节码对应读取了canvas指纹,哪一段对应了UA特征拼接,等等。

值得注意的是,hook代码要尽量隐蔽,不要改变原始API的返回值和行为。我见过一些人在hook时图方便直接修改了返回值,结果瑞数的环境检测瞬间就察觉到异常,返回了错误数据——这种错误数据会让你误判自己的分析方向,浪费大量时间。

2.3 第一次靠近vm:从大数组到dispatcher

当你通过hook拿到调用栈后,把断点设置到实际进入vm的那一层,再单步进去,你会看到vm内部的典型结构:一串超长数组、一个指令指针、若干个寄存器/参数栈,以及一个巨大的分发循环。

大数组就是瑞数6vmp的“指令表”或者说“opcode存储区”。有些4、5代瑞数的数组可能只有几百个元素,但6代的数组动辄几千个元素、每个元素有复杂的规律。6代的数组还有一个明显特征:数组的内容在每次加载后都可能发生变化,这意味着你无法写死某一组固定的opcode序列,必须在运行时动态读取并解析。

我自己的经验是:不要在vs code或者文本编辑器里硬啃数组本身,直接把数组dump下来,交给脚本去分析其中的规律。比如统计每个opcode出现的频次、梳理opcode之间的跳转关系、找出哪些数组元素是数据而哪些是真正的指令。这一步听起来简单,但在6vmp里是决定效率的关键。我在第一次逆向6代时,就是因为过于相信自己的眼睛,试图人工从几千个数字里找规律,浪费了整整两天,后来改成脚本统计后才真正开了窍。

初次进入vm后,你看不到任何有意义的函数名,只会看到类似这样一个循环:

while (1) { opcode = bytecode[ip++]; switch (opcode) { case 1: // 入栈 stack.push(bytecode[ip++]); break; case 2: // 出栈 a = stack.pop(); b = stack.pop(); stack.push(a + b); break; // 大量case分支... } }

当然,真实代码里不会有这些case 1: 入栈的注释,opcode的值也通常并不是直接从bytecode[ip++]取出来就行,可能会带上一些变长参数、可能带立即数混淆、可能通过一个间接跳转表来分发。但整体框架就是这样一个“取指令→解码→执行→跳回分发循环”的模式。只要你能识别出这个框架,就已经成功了一大半。

3. 虚拟机机制拆解:opcode、指令处理和状态流转

3.1 从数据流理解vm,而不是从代码流理解

不少人在逆向vm时第一反应是逐条读懂opcode的含义,然后试图把那一段字节码的算法还原成能够阅读的代码。这个思路在指令集比较小的时候是可行的,但6vmp的指令集通常非常庞大,而且很多opcode的功能看起来高度相似,实际上只是处理不同数据类型或不同上下文。

我后来用了一个更高效的思路:从数据流的角度去理解vm。具体来说,vm在执行过程中会持续消费数组中的数据,同时也可能会修改数组内部的部分元素作为临时变量。我先标记所有读和写的位置,画出数据从哪里来、到哪里去,然后反过来推测某段opcode的功能。这种做法比逐条翻译更快,因为它符合一个基本事实:瑞数生成cookie的核心算法并不复杂,复杂的是它被千奇百怪的指令拆分成了无数个小块。你只要沿着数据流把小块串起来,核心算法就会浮出水面。

比如,6vmp生成cookie时,几乎必然存在一个“从环境中读取某些指纹数据→拼接原始数据→执行哈希或加密运算→生成最终cookie”的过程。你去vm字节码里直接找“加密算法”,大概率什么也找不到;但你按数据流梳理,先找到指纹数据的来源点,再追踪它经过哪些操作后变成最终输出,整个过程就会非常清晰。

3.2 dispatcher的还原:核心操作是“找出opcode表的规律”

在8.x时代还有不少逆向者试图直接抠出整个dispatcher代码放本地跑,到了6vmp这个思路基本没戏。因为dispatcher的代码本身也是动态变化的,你这次看到的分发循环,下次加载可能完全不同。关键是找到opcode表的规律,而不是copy某一份具体的实现。

我在实际分析中总结了一套相对通用的“dispatcher识别流程”:

  1. 在cookie赋值断点处停止,回溯到vm分配数组的位置;
  2. 设定条件断点,在dispatcher循环的入口打印当前ip值和opcode值;
  3. 连续单步执行几十次,记录下每次ip的变化轨迹;
  4. 观察ip的变化是否有特定模式:是线性递增,还是频繁跳转到某些固定位置;
  5. 统计opcode的取值分布,尝试对opcode进行分类,比如“数据搬移类”“算术运算类”“控制流类”“环境交互类”。

做完这五步,你对odopcode表的认识基本就成型了。接着可以做一件事:把每条指令的参数长度也统计出来,因为在可变长指令集里,参数长度决定了你能否正确解码整个字节码流。如果这里解码错误,后面的一切分析都白搭。

这里有一个很重要的细节:6vmp的odopcode表本身可能被“加盐”处理。也就是说,实际使用的opcode值不是简单的1、2、3,而可能是一个很大的数,或者经过某个初始密钥异或后的值。你要在dispatcher里找到它是如何从原始字节码计算出最终opcode值的,然后把这个“解密”逻辑同步到你的还原脚本里。

3.3 控制流平坦化:让还原后的代码变成可阅读的代码

vm解密后你会发现,虚拟机的字节码其实已经是对控制流平坦化的一种极端实现。你从cookie赋值点出发,顺藤摸瓜找到一个又一个基本块,但这些基本块之间的连接关系非常混乱:if判断、跳转表、自增循环、异常跳转等等。想让代码变得可读,需要做一次“控制流重整”。

我常用的方法是:先记录所有基本块,然后分析每块结尾的跳转指令,把真实目标恢复出来,最后用工具画出一个简化版的控制流图。这个步骤会得到一个非常冗长的图,但至少逻辑是清晰的。

接下来你就可以做“指令折叠”了——把一组功能单一的连续指令合并成一个高层次的语义动作。比如连续几行都在做栈顶数据的加减运算,那你就可以把它折叠成一个“累加器操作”。折叠得足够多之后,原本几百步的字节码就变成了几十个操作。这几十个操作组合起来,就是生成某个cookie子参数的算法。

我说说实际逆向中的感受:这一步是最枯燥但也是最出成果的。当那些本来像是天书一样的字节码折叠成类似"hash.update(...); hash.digest()"这样语义清晰的操作时,整个vm的黑盒感会瞬间消失。后面的事,就从逆向问题变成了普通的算法重写问题。

4. 实战中的坑与完整排查链路

4.1 环境检测:UA、canvas、音频指纹一个都不能少

如果说还原vm算法是“学走路”,那过环境检测就是“学跑步”。瑞数6vmp不会只验证cookie本身,它还会在生成cookie的过程里收取大量环境指纹,并且在服务端进行交叉验证。也就是说,就算你把vm算法原样还原,生成出来的cookie看起来格式完全正确,只要你的实际请求环境指纹和cookie里携带的指纹不符,服务端一样会拒绝。

我在实际调试中碰到过一个非常典型的案例:我本机浏览器是Chrome 122,UA里有一个特定版本的Sec-CH-UA头,但我的调试脚本却误用了Puppeteer默认给的旧UA,导致生成的cookie里携带的字段和实际请求头不一致。表面看cookie生成成功了,发请求也返回了200,但下一页就立刻拿到一个“安全验证不通过”的拦截页。后来我把UA统一后,问题就消失了。

所以在整个逆向过程中,以下环境要素必须和实际请求严格保持一致:

  • navigator.userAgent
  • navigator.platform
  • navigator.language
  • WebGL渲染器的vendor和renderer字符串
  • Canvas指纹(包括字体渲染差异)
  • 音频指纹(AudioContext输出数据)
  • 屏幕分辨率、色深、设备内存等硬件相关信息
  • 浏览器是否处于自动化控制状态

尤其在最后一点上,瑞数对webdriver、CDP连接、多线程调试等自动化特征有专门的检测。如果你用Puppeteer这类工具,尽量加上--disable-blink-features=AutomationControlled参数,并且主动覆盖掉navigator.webdriver属性。

4.2 补环境的陷阱:为什么知道“该补什么”比“补什么”更难

很多新手在过瑞数时喜欢“补环境”——也就是在Node.js里模拟出一整套浏览器API,把vm跑起来。这种思路本身没有问题,但难点在于你根本不知道哪些环境是瑞数真正会读取的。聪明的方案不是一股脑把所有浏览器API都实现一遍,而是先通过hook环境API,确定瑞数在运行期间到底消费了哪些值,然后只针对性地提供这些值。

我犯过的一个低级错误是:我以为瑞数一定会读取canvas指纹,于是优先补了canvas相关接口。但实际跑起来才发现,瑞数在该版本的代码里根本没有读取canvas,反而先去读了WebGL和字体列表。后来我再做瑞数项目时,第一件事永远是先hook所有常见环境API,观察并记录哪些被调用了、调用了多少次、参数是什么。基于这份记录来补环境,效率会高得多。

这背后其实也暴露了一个6vmp的设计思路:它通过动态变化指令集,让你无法用固定的“环境清单”来应对。今天它是依赖canvas指纹,明天可能就换了另一种方式取指纹。所以真正有效的方案不是“补齐某套环境”,而是“感知瑞数需要什么,再去提供什么”。

4.3 正则误判与超大数组泄漏问题

还有一个我在瑞数6vmp实战中花费不少时间的坑,就是正则匹配的误判。6代的代码里有一个很夸张的现象:一个数组元素可能长达几千个字节,里面既有看起来像指令的数字段,也有大段十六进制编码的数据。如果你用粗糙的正则去提取数组,很容易把多个元素切错,或者把非数组内容当成数组,最终导致dump下来的数据错误。

我的解决方案是:通过断点拿到数组对象的真实引用,然后直接在浏览器控制台里调用JSON.stringify(arr),把数组完整序列化输出。这样拿到的数组是绝对可靠的。从浏览器控制台拿数据之后再保存在本地文件里,后续所有脚本都基于这份dump来跑。每次页面刷新后数组可能变化,所以每次分析时都要重新dump,并保存好对应的上下文环境信息(UA、页面URL、时间等),方便后续排查问题。

有一次我偷懒,觉得自己已经分析清楚了,直接用上一份dump去验证新生成的cookie,结果两者不匹配。后来才发现那次是数组发生了变化,而不是我的算法还原有误。所以在瑞数6vmp项目里,保持“分析环境、dump数据、生成结果”三者的一致性是特别重要的工作习惯。

5. 从算法到工程落地:三种方案的选型对比

5.1 纯算法还原:高可控但维护成本高

在把vm逻辑还原到一定程度后,很多人会想把它转写成Node.js版本的纯算法模块。这个方案的最大好处是生成速度和稳定性都是最好的——完全脱离浏览器,没有页面加载开销,也没有自动化检测的风险。但缺点也很明显:维护成本高。瑞数一旦更新算法或者改变指令集,你就要重新分析一次。

如果你打算走纯算法路线,我的建议是:

  • 模块化设计:把环境指纹的采集、cookie各字段的生成、最终token的组装拆分成独立模块;
  • 参数外部化:把可能变化的部分(比如UA、时间戳偏移、种子数组)设计成外部可配置项;
  • 写好自测用例:每次改动后,用同一组输入跑验证脚本,确认输出和浏览器一致。

我自己的项目里,纯算法方案大概占了20%的代码量,但它服务的是最核心的业务需求,所以我愿意花时间维护。

5.2 自动化浏览器方案:最省心的“过检测”利器

如果你不想纠结于彻底还原算法,自动化浏览器方案是最直接的选择。热词里有“playwright过瑞数”,其实就是把playwright或puppeteer当成浏览器环境,让页面自己加载、生成cookie,然后你从浏览器里把cookie取出来,再交给其他业务逻辑使用。这种方式避开了对vm的逐条逆向,是很多业务量不大的爬虫项目最常用的方案。

这里有一种很成熟的操作方式:用浏览器打开目标网站,等瑞数完成cookie设置后,你通过page.cookies()获取cookie,然后交给requests或axios去请求实际接口。这样你既能拿到瑞数校验通过的cookie,又不需要自己实现算法。关键是控制好页面加载和cookie生成之间的时间差,太早获取cookie会导致生成不完整。

playwright在过瑞数方面的优势在于它的自动化特征较少,比puppeteer更容易通过瑞数的基础检测。但还是那句话,瑞数6vmp的环境检测是动态的,不能说某个浏览器自动化框架一定能100%过检测。做好UA、webdriver隐藏等基本操作仍然很有必要。

5.3 RPC混合方案:算法还原与浏览器执行结合的折中

RPC方案是我在实际项目中用得最多的方案,它的思路是:用浏览器去执行瑞数的JS生成cookie,但业务脚本不关注浏览器内部实现,而是通过本地RPC接口获取最新cookie。这种方式既保留了纯算法方案的高性能,又兼容了自动化浏览器方案的易维护性,特别适合在分布式环境下使用。

我用RPC方案时有过一个很深刻的教训:在没有缓冲cookie的情况下,每次请求都去实时调用RPC生成新cookie,导致页面加载量大到被服务端限流。后来我加了一层缓存池,预生成多个cookie放到队列里,并设置合理的过期时间,整体稳定性明显提升。

方案开发效率性能维护成本风险点
纯算法还原算法更新时需重新逆向
自动化浏览器自动化检测可能拦截
RPC混合方案中高需要维护浏览器的稳定运行

选型时没有绝对优劣,关键看你的业务体量和团队投入。如果只是几千条数据的小任务,自动化浏览器方案完全够用;如果是抖音、电商这类大流量采集,那就值得投入做纯算法或RPC混合方案。

5.4 工程落地中的常见排查流程

最后分享一个我在工程化阶段反复使用的排查链路,碰到了cookie失效、请求被拦截等问题时,按这个顺序查基本都能定位到原因:

  1. 先确认cookie生成是否成功:输出抓取到的cookie,比对格式是否和浏览器完全一致;
  2. 确认cookie生成时间和请求时间差:如果时间差过大,可能触发时间窗口校验;
  3. 确认请求头环境:UA、Accept、Accept-Language、Sec-CH-UA等头是否和cookie生成时一致;
  4. 确认IP的来源是否漂移:如果cookie是在A IP下生成的,请求却从B IP发出,部分严格的服务端会拒绝;
  5. 确认自动化特征:检查webdriver、CDP连接、控制台日志等是否暴露了自动化痕迹。

这五步走下来,80%的瑞数6vmp接入问题都能找到原因。剩下的一小部分,往往是因为目标网站做了额外的风控策略,比如行为频控、链路追踪等,那已经不是单纯逆向能解决的范围了。

我在实际跟进这些项目时最深的一个体会是:瑞数6vmp的逆向不是“一次性知识”,而是一套持续迭代的方法论。你每次遇到新版本,都要重新走一遍“定位入口→分析odopcode→还原算法→落地工程”的流程。只有在第一次把这套流程完整走通,之后遇到再大的加密变化也能快速适应。如果只是一遍遍地搜网上的现成脚本,那永远只能跟在版本更新后面跑,很被动。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询