
把瑞数4代的JS拉下来丢进Node.js里直接跑多半会卡在一个很尴尬的地方代码不是不执行而是执行到一半抛出一堆ReferenceError: window is not defined、navigator is not defined或者干脆死循环、内存暴涨。这个阶段做的事圈内叫“补环境”也就是把浏览器里的那一整套全局对象、DOM接口、原型链行为在Node.js里手动搭一个最小但足够像的副本让那些被混淆过的JS以为自己在浏览器里。这篇东西是我自己从零补瑞数4代环境时总结的完整思路和核心代码适合已经在逆向圈摸爬过一阵、了解JS混淆和抓包基础的人。文章不会给你什么“秒过”的魔法但会把我踩过的坑、反复调整的原型链细节以及为什么有的环境补了还是会挂一次性讲清楚。1. 瑞数4代到底难在哪1.1 它不是普通验证码很多人第一次看到瑞数4代的返回包第一反应是“哦一个JS挑战”。但跑起来就会发现它和市面上的极验、腾讯验证码完全是两码事。极验是用户交互式验证瑞数4代则是纯JavaScript动态混淆防护它会在服务端下发一段经过多层混淆的JS这段JS会在浏览器里动态采集环境指纹然后生成本地cookie或者通过请求头把计算结果回传。如果你的环境不是“真实浏览器”计算结果就对不上服务端直接拒绝。难点在于它不只是简单地读几个window.navigator.userAgent它会遍历整个window对象、document对象甚至调用一些你没有实现的原生API比如CanvasRenderingContext2D、WebGLRenderingContext、AudioContext然后把这些结果做哈希、做运算甚至把某些函数转换成字符串检查源码。这些行为加在一起就形成了一个非常庞大的浏览器环境指纹。1.2 补环境在整条链路中的位置做瑞数4代的人都知道整个流程有三步提取、脱混淆、补环境。提取比较容易抓包把JS下载下来就行脱混淆比较费劲因为代码本质是动态生成的很多字符串、函数名都是运行时拼接出来的补环境则是最熬人的一步也是决定最终能不能出结果的关卡。脱混淆完成后你会得到一段看起来相对“正常”的JS代码但它仍然依赖大量浏览器API。补环境做的事情就是把这些API一个一个在Node.js里mock出来让代码在Node.js运行时也认为自己处在一个浏览器里从而正常运行到最终计算结果的入口函数。1.3 为什么选Node.js补环境而不是直接上浏览器有的人会问直接用Puppeteer或者Playwright跑一个真正的浏览器不就没有补环境的问题了吗说得对但问题在于效率和隐蔽性。浏览器实例的重型开销、内存占用、并发能力完全不适合大规模数据采集场景。而且即使你用Puppeteer跑服务端依然可以通过WebDriver标记、CDP协议特征、浏览器插件痕迹等手段直接识别出这是自动化浏览器。Node.js补环境的核心优势是轻量和可控。代码不依赖GUI同一个进程里可以并发跑几十个环境实例资源消耗比开浏览器低几个数量级。而且你完全清楚自己的环境补了什么、没补什么排查问题比对着黑盒浏览器猜要容易得多。2. 动手前先搞懂检测点补环境的核心思路2.1 环境检测与指纹采集在写补环境代码之前一定要先弄清楚对方到底检测了什么。这正是抓代码、看调用栈的作用所在。瑞数4代这类动态防护系统一般会从几个维度采集指纹第一是基础身份信息。navigator.userAgent、navigator.platform、navigator.language、navigator.hardwareConcurrency、navigator.deviceMemory这些属性几乎每个环境检测脚本都会读协不协调、真不真实直接影响后面的计算结果。第二是浏览器API的存在性与行为。比如window.chrome对象及其内部属性window.outerWidth和window.outerHeight的数值范围window.screenX、window.screenY等。有些检测还会调用document.createElement(canvas)去获取toDataURL结果或者调用canvas.getContext(webgl)读取WebGL参数。第三是对象属性描述符。这个很多人会忽略。检测脚本会用Object.getOwnPropertyDescriptor(window, navigator)查看属性是否存在、是否可枚举、是否可配置。如果你用普通赋值方式强行给globalThis挂一个window属性属性描述符就会和真实浏览器不一致立刻暴露。第四是原型链的完整性。比如navigator对象的原型链上应该有Navigator.prototypedocument的原型链上应该有Document.prototype、Node.prototype、EventTarget.prototype这一长串继承关系。检测代码只要遍历原型链发现某个环节缺失就知道你是在mock环境。2.2 从“能跑”到“跑得对”补环境分两个阶段我习惯叫“能跑”和“跑得对”。“能跑”指的是Node.js不报错代码不说xxx is not defined所有用到的全局变量和函数都有。这个阶段其实很快写一个通用的Proxy拦截器把未定义的属性全部返回一个空对象或空函数基本就能跑起来。但“跑得对”才是真正花时间的地方。所谓“对”是指你补出来的环境在检测脚本眼里与真实浏览器一致。这不仅仅是属性存在还包括属性值正确、函数行为正确、原型链关系正确、属性描述符正确甚至连函数调用时的this指向、异常抛出行为都要一致。很多代码开头可能只是模糊地探一下如果某个属性返回值明显异常比如screen.width返回了0那么后续计算出的哈希就会完全不对拿到的cookie自然无效。2.3 原型链补环境的隐喻接触过一些新手的代码他们喜欢把window、document、navigator全部定义成{}空对象然后往里疯狂塞属性。这种做法的最大问题是空对象的原型链是Object.prototype而真实浏览器的document对象原型链是Document - Node - EventTarget - Object。检测代码只要document instanceof Node一下就直接判定为假环境。所以就有了“原型链补环境”这个说法也是最近圈子里讨论比较多的话题。它的核心思想是不是简单创建一个对象而是按照浏览器JavaScript运行时的真实结构把整个原型链“复刻”出来。这样检测代码在遍历原型链时看到的层级关系和真实浏览器几乎一致工具代码也能正常使用原型链上的方法。3. 原型链补环境实操从零搭一套可运行环境3.1 容器运行时的基础搭建第一步是把Node.js当作一个“容器”在全局对象上挂出浏览器环境。这里我习惯保留一个独立文件专门用来定义环境对象方便统一管理。// envSetup.js const fs require(fs); // 先保存原始的全局对象引用方便后面判断 const globalRef globalThis; // 创建一个window对象它的原型链上先不挂任何东西 const window {}; // 挂到globalThis上让所有模块都能直接访问 globalThis.window window; globalThis.self window; globalThis.top window; globalThis.parent window;这里有个细节globalThis.window window之后window.window也必须指向window自身。检测代码经常会读window.window window来判断环境一致性。所以还需要补上window.window window; window.self window; window.top window; window.parent window;这个基础的循环引用一旦漏掉很多检测脚本就会直接判定异常。3.2 window/document/navigator 三件套接下来是三个最重要的exportable对象window、document、navigator。这三样几乎覆盖了环境检测90%以上的属性读取。我的建议是先补结构再补属性。// 先定义document的原型链 function EventTarget() {} function Node() {} EventTarget.prototype Object.create(Object.prototype); Node.prototype Object.create(EventTarget.prototype); Node.prototype.nodeType 0; Node.prototype.nodeName ; function Document() {} Document.prototype Object.create(Node.prototype); Document.prototype.nodeType 9; Document.prototype.nodeName #document; // 真实浏览器中document对象不是普通函数构造出来的我们这里手动构造一个对象 const document Object.create(Document.prototype); // 挂载基本属性 document.readyState complete; document.visibilityState visible; document.hidden false; document.documentElement { nodeType: 1, nodeName: HTML, style: {}, clientWidth: 1920, clientHeight: 937 }; document.body { nodeType: 1, nodeName: BODY, style: {}, clientWidth: 1920, clientHeight: 937 }; // document也要指向window document.defaultView window; window.document document;navigator同理先把原型链建起来function Navigator() {} Navigator.prototype Object.create(Object.prototype); const navigator Object.create(Navigator.prototype); Object.defineProperties(navigator, { userAgent: { value: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, configurable: true, enumerable: true }, platform: { value: Win32, configurable: true, enumerable: true }, language: { value: zh-CN, configurable: true, enumerable: true }, languages: { value: [zh-CN, zh], configurable: true, enumerable: true }, hardwareConcurrency: { value: 16, configurable: true, enumerable: true }, deviceMemory: { value: 8, configurable: true, enumerable: true }, webdriver: { value: false, configurable: true, enumerable: true }, plugins: { value: { length: 0 }, configurable: true, enumerable: true }, mimeTypes: { value: { length: 0 }, configurable: true, enumerable: true } }); window.navigator navigator; globalThis.navigator navigator;属性描述符这里尤其重要。我见过很多人直接用navigator.userAgent xxx这种方式赋值这在浏览器里默认是可枚举的但真实浏览器环境中navigator.userAgent通常是不可枚举的。检测脚本如果对比了属性描述符就很容易暴露。所以能用Object.defineProperty或者Object.defineProperties就尽量用把enumerable、writable、configurable都设置成和真实浏览器接近的值。3.3 关键细节属性描述符和原型链这一块是最容易踩坑的地方。先看一个例子检测代码经常会这么写const des Object.getOwnPropertyDescriptor(window, navigator); if (!des || des.get ! undefined) { // 在某些环境中 navigator 是在window上的getter }真实浏览器里window.navigator是一个访问器属性accessor property有get没有value而很多人补环境时直接Object.defineProperty(window, navigator, { value: navigator })导致没有get方法。检测代码就这么一刀切把你筛掉了。解决方法是把它定义成带getter的属性Object.defineProperty(window, navigator, { get: function() { return navigator; }, configurable: true });同理window.document、window.screen、window.localStorage等也建议都用getter方式实现。如果你嫌一个个写太麻烦可以直接用Proxy包装window对象拦截get操作返回对应的私有变量。原型链方面要注意的是不要让所有对象都直接用Object.prototype当原型。比如document要挂到Document.prototype上navigator要挂到Navigator.prototype上window本身则相当于Window.prototype。检测脚本一声window.document instanceof Document如果你的document直接是一个普通对象这里就挂了。用代码表示就是console.log(document instanceof Document); // 需要为true console.log(navigator instanceof Navigator); // 需要为true console.log(document instanceof Node); // 需要为true console.log(document instanceof EventTarget); // 需要为true这些instanceof判断全部通过原型链层面的检测才算及格。另外还有一个细节构造函数的Symbol.hasInstance会影响instanceof行为。如果检测代码做了更高级的校验比如检查Document.prototype上是否有某个方法那么你还得把Document.prototype上缺失的方法补上比如getElementById、querySelector、createElement、createTextNode等。4. 真实跑代码时的高频坑4.1 补不全会崩补太多也崩做补环境最经典的一句话是补不全会崩补太多也崩。补不全容易理解缺了某个属性或者方法代码跑到那里直接抛异常。但补太多会崩很多新手就理解不了了。原因在于有些属性在检测脚本里是以“不存在”作为正常状态的。比如window.cdc_adoQpoasnK76wiB3pM这个属性Puppeteer和Chrome DevTools协议注入过JavaScript的环境里会存在真实用户浏览器里反而没有。检测代码只要发现这个属性存在就判断你是自动化浏览器然后直接发起一个错误标记。再比如navigator.webdriver真实用户浏览器里这个属性是不存在的或者为false。如果你直接navigator.webdriver false有些检测代码会检查这个属性是否存在存在就说明你改过。正确做法是用Object.defineProperty(navigator, webdriver, { value: false })让它的属性描述符与真实环境一致而不是用赋值。所以建议在补环境过程中始终保持一个原则以真实浏览器的行为为准而不是以“能跑通”为准。每补一个属性最好先确定它在真实环境里是什么样的再决定怎么补。4.2 常见错误速查表下面这个表是我整理的高频问题对照表可执行环境方案与排查方向可以组合使用现象可能原因排查方向window is not defined全局对象未挂载检查globalThis.window是否赋值document is not defineddocument对象未挂载检查globalThis.document是否赋值navigator.userAgent取值异常属性描述符设置错误用Object.getOwnPropertyDescriptor检查instanceof Node返回false原型链不完整检查Document.prototype的继承关系canvas.toDataURL返回空串canvas相关API没实现补canvas的getContext和toDataURL代码跑了特别久才报错某个API返回了异常值导致算法陷入循环在关键API处打日志缩小排查范围运行结果时好时坏环境使用了随机数或时间戳但两者与指纹不匹配检查Math.random、Date.now的一致性把该固定的值固定下来Promise未定义或为undefinedNode版本过低或环境变量被覆盖检查Node.js版本至少v16以上或恢复global的原有对象这里面canvas相关是最容易出问题的。瑞数4代对canvas和WebGL的检测很细致它会读取canvas的width、height、getContext(2d)返回的上下文然后调用measureText去测一段文字的宽度最后对像素数据做哈希。如果只是简单返回一个空对象计算出来的哈希值就和真实浏览器不一样整个结果作废。最省事的做法是引入一个真正的canvas库比如node-canvas在Node.js里生成一个真实的canvas对象再手动挂到document上。缺点是这个库依赖系统级cairo库安装稍麻烦但值得。熟练之后也可以做二次封装让canvas相关的方法尽量真实验证。4.3 用代理和日志把检测点逼出来补环境过程中最有用的工具之一是JavaScript的Proxy。你可以把window、document、navigator都包一层Proxy在get、set、has、getOwnPropertyDescriptor这些陷阱里打日志看看检测脚本到底访问了哪些属性。function createEnvProxy(target, name) { return new Proxy(target, { get(obj, prop, receiver) { const value Reflect.get(obj, prop, receiver); if (prop toString) { return () [object ${name}]; } return value; }, set(obj, prop, value) { obj[prop] value; return true; }, has(obj, prop) { return prop in obj; }, getOwnPropertyDescriptor(obj, prop) { return Object.getOwnPropertyDescriptor(obj, prop); } }); } globalThis.window createEnvProxy(window, Window); globalThis.document createEnvProxy(document, Document); globalThis.navigator createEnvProxy(navigator, Navigator);有了这层代理你就能知道代码运行到哪一步、访问了什么、返回了什么。如果某个属性返回undefined导致后续运算变成NaN日志里一眼就能定位到。还有个小技巧就是给所有空函数加一个toString钩子function mockFn(name) { const fn function() {}; fn.toString () function ${name}() { [native code] }; return fn; }很多检测代码会把函数转成字符串看里面有没有“native code”字样。如果你的mock函数toString返回的是一大段自己的代码就很明显是假的。这个钩子能把这个标记也抹掉。4.4 环境指纹的协调性最后讲一个容易被忽略但非常致命的问题环境指纹的协调性。可能你把userAgent设置成了Chrome 120 Windows但window.screen.width却设置成了1920screen.height设置成了1080这没问题。但如果你把navigator.hardwareConcurrency设置成了16而navigator.deviceMemory设置成了2这就非常可疑。真实设备上CPU核数和内存大小有相关性而且不同操作系统的屏幕尺寸分布也有一定规律。更隐蔽的是时间相关的指纹。Date.now()、performance.now()这些时间戳如果每次运行都完全一致也是异常信号。所以补环境不是一个“缺什么补什么”的简单问题而是要把整套指纹看作一个整体确保它们之间的数值比例、变化规律都和真实设备一致。我一般做法是维护一份浏览器指纹配置把UA、screen、navigator、canvas这些参数集中管理跑不同目标站点时按需切换。比如手机版UA对应screen.width375、navigator.platformiPhone桌面版UA对应screen.width1920、navigator.platformWin32不要让一个iPhone的UA带着一个screen.width1920的环境去跑。5. 从补环境到验证通过的完整流程到这里核心代码和排查思路都给了最后串一下从拿到JS到验证通过的整体流程。第一步抓包拿到瑞数4代下发的JS文件保存到本地。第二步用你习惯的JS反混淆工具先做初步的格式化不要指望一步到位很多代码里还嵌套了多层字符串拼接和动态调用。第三步在Node.js里先跑起来用Proxy日志看缺什么补什么循环往复直到代码不报错且输出一个结果。第四步把生成的cookie或请求头信息放到抓包工具或实际HTTP请求里验证看服务端是否接受。第五步如果服务端拒绝就回到第三步继续排查是哪一项指纹对不上。一个完整的补环境模块我建议按下面的目录结构组织代码env/目录放环境定义文件包含window、document、navigator、screen、canvas等接口env/index.js负责将所有环境对象挂载到globalThisenv/logger.js负责打印访问日志runner.js负责加载目标JS并调用最终入口函数config/device.js存储不同设备指纹配置这样在遇到不同站点、不同指纹要求时可以直接切换配置不需要在代码里大海捞针地改属性。举个例子一段常见的加载逻辑长这样const fs require(fs); const vm require(vm); require(./env); const jsCode fs.readFileSync(./target.js, utf-8); const sandbox { console, setTimeout, clearTimeout, Buffer, process: { version: v18.17.0, platform: win32 } }; vm.createContext(sandbox); vm.runInContext(jsCode, sandbox, { timeout: 15000 }); // 触发目标入口获取结果 const result sandbox.xxx(); console.log(result);这里用vm模块而不是直接eval是为了把目标JS的运行环境隔离起来避免它随意修改你Node.js进程的全局污染。Node的vm沙箱本身也不是完整隔离但对这种场景已经够用。补环境的最后一个忠告是保持耐心不能急。这不是一个“照着抄一段代码就能用”的活而是一套需要反复迭代、反复与真实浏览器对比的系统工程。你能把这份工作做到多细决定你能在多复杂的指纹检测面前站稳。我自己第一次完整跑通瑞数4代环境整整花了三个晚上大多数时间都用在了对照真实浏览器里那些不起眼的对象属性描述符上。说一句题外话后来我每次接类似项目都会先把自己的环境基础库整理一遍把常见API的mock实现、属性描述符模板、原型链关系图存档。这些沉淀下来的东西才是补环境这一行真正值钱的积累。