简介:JavaScript逆向中,绕过浏览器环境检测是绕不开的课题。理解浏览器API与Node.js运行时差异是基础,包括navigator、window等对象。补环境原理是伪造一个可信的浏览器指纹,让风控签名在Node环境正常运行。其技术价值在于数据采集、Web自动化测试、线上风控参数复现等场景。结合ali231补环境实践,讲解检测链识别、Mock环境搭建、Proxy探针等技巧,帮助定位缺漏并验证补环境效果。
1. ali231补环境:为什么签名代码在Node里总是“差一点”
做JS逆向的朋友,迟早都会撞上“补环境”这三个字。ali231不是某个公开框架的名字,而是逆向圈子里对某大型电商平台风控签名模块的一种习惯称呼——231是它在混淆代码里的内部代号。这个模块的典型特征是:压缩混淆后仍然有上百KB,内部塞满了各种运行环境探测逻辑,稍有不顺就抛异常或直接返回一个固定的error码。很多人在浏览器里拿它一点问题没有,一搬进Node.js跑就发现缺这个、少那个,最终只能对着报错堆栈一行行补对象。
补环境的本质,是替这段JS伪造一个它认可的“浏览器运行态”,让它在Node里也能正常算出签名值。对于读研读博做数据采集的、搞Web自动化测试的、以及需要复现线上风控参数的从业者来说,这几乎是一门必修课。本文不猜源码内容,只讲这个方向上一线通用的做法:怎么定位检测点、怎么补、怎么排查麻烦,以及如何验证你补得对不对。
2. 先看懂检测链:从报错堆栈到运行时的黑匣子
2.1 为什么签名代码在Node里“第一秒就死”
绝大多数风控签名JS开头都有类似这样一段代码:
(function (window) { var nav = window.navigator || {}; var ua = nav.userAgent || ""; if (!/Chrome/.test(ua)) { throw new Error("unsupported browser"); } // ...后续真正的签名逻辑 })(typeof window !== "undefined" ? window : undefined);这段代码在浏览器里不会有任何反应,但到了Node.js里,第一行就会出问题。因为Node的全局对象叫global,不是window,typeof window的结果是"undefined",整个IIFE直接被跳过,后续签名函数根本不会被定义。你调了半天,得到的是一个undefined。
这是最常见的第一层检测——存在性检测。它的目的不是拦截你,而是让代码在“非浏览器环境”里静默失效。很多人在这一步就开始疯狂补window、补navigator,但真正麻烦的不是这几个大对象,而是藏在它们下面的细分属性。
2.2 检测分支的三种典型形态
我经手过的几个类似模块,检测逻辑大体能分成三类。
第一类是typeof探测。比如typeof document === "undefined"就提前return。这类最简单,直接全局声明对应对象就能过。
第二类是属性完整性探测。代码里可能写navigator.appVersion、navigator.plugins[0].name、navigator.mimeTypes这类接口。它们不只检查“存在”,还检查“长得像不像”。比如plugins得是一个包含name、description、filename字段的数组结构,一个空对象糊弄不过去。
第三类是运行时行为探测。比如new Date().getTimezoneOffset(),又比如canvas.toDataURL()返回的PNG长度。这类检测不关心对象本身,关心执行结果是否在正常浏览器范围内。你补一个假的canvas没意义,它要的是最终指纹串和真实浏览器接近。
这三类检测常常嵌套出现。外层是一个try/catch,里面依次做几十个判断,任何一个不满足就抛出异常,异常被捕获后返回一个负数值。你看到的“报错堆栈”,其实是它故意吞掉的。
下面这份表格是这类签名模块里最常见的环境依赖清单,也是你补环境的重点对象:
| 对象 | 常用检测属性 | 说明 |
|---|---|---|
window | devicePixelRatio、innerWidth | 全局宿主对象 |
navigator | userAgent、appVersion、language | 浏览器标识 |
document | cookie、referrer、createElement | 文档与DOM操作入口 |
screen | width、height、colorDepth | 屏幕参数 |
localStorage | getItem、setItem、removeItem | 存储能力 |
canvas | toDataURL、getContext | 指纹采集关键 |
Date | getTimezoneOffset、getTime | 时区与时间戳 |
Math | random | 随机数种子 |
2.3 当检测逻辑遇上“环境指纹”
这里想多提一点:ali231这类模块里,环境检测不只是“门卫”,它还参与“指纹生成”。也就是说,环境对象的值会直接参与字符串拼接和哈希计算。这意味着你补的环境和真实环境差异越大,签名值偏差就越大——不是“能跑就行”,是要“补得像”。
比如它可能会这样用:
function getFingerprint() { var screen = window.screen || {}; var parts = [ navigator.userAgent, screen.width + "x" + screen.height, navigator.language, new Date().getTimezoneOffset(), canvasFingerprint() ]; return parts.join("|"); }这时候你必须对每一个参与计算的值做到“知道它是什么、应该补成什么”。如果只是简单置空,签名函数能跑,但计算出的结果在服务端验签时大概率被判定为低置信度。你在本地怎么测都是通的,一上线就失败,原因就出在这里。
所以补环境的第一条原则是:先定位哪些属性参与了后续计算,再决定补法。别一上来就铺一堆全局变量——代码只要检测到多余的、突兀的属性,同样会识别出“这是模拟器”。
3. 搭一套能用的补环境骨架:格式化、定位与注入顺序
3.1 格式化:压缩代码变成可读代码是第一步
拿到一份几百KB的压缩JS,第一件事是格式化,不是为了读每一行,而是为了定位检测结构。推荐用社区常见的beautify工具,或直接用在线格式化工具处理。格式化后,搜索几个高频关键词:
grep -n "navigator\|document\|localStorage\|canvas\|screen" ali231.js | head -50这一步能让你快速知道它引用了哪些环境对象、大概在哪些行号。接着用调试器在可疑位置打断点,观察报错前最后一次成功访问的属性是哪个。一个人工调试的小技巧是:给String.prototype挂一个临时的getter日志,但更简单的是直接用下面的注入方案逐步试。
3.2 补一个最小可运行环境:Node里手写Mock
我不太建议一上来就上jsdom,因为jsdom体积大,而且补出来的环境对象和真实浏览器仍有出入。更可控的方式是手写一个Mock环境,结构大概长这样:
// mock-base.js —— 一个最小可运行的Node环境骨架 const userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; global.window = global; global.navigator = { userAgent: userAgent, appVersion: "5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", language: "zh-CN", languages: ["zh-CN", "zh", "en"], platform: "Win32", vendor: "Google Inc.", plugins: [ { name: "Chrome PDF Plugin", description: "Portable Document Format", filename: "internal-pdf-viewer" }, { name: "Chrome PDF Viewer", description: "", filename: "mhjfbmdgcfjbbpaeojofohoefgiehjai" } ], mimeTypes: [ { type: "application/pdf", suffixes: "pdf", description: "Portable Document Format" }, { type: "text/pdf", suffixes: "pdf", description: "Portable Document Format" } ], cookieEnabled: true, onLine: true }; global.screen = { width: 1920, height: 1080, availWidth: 1920, availHeight: 1040, colorDepth: 24, pixelDepth: 24 }; global.document = { cookie: "", referrer: "", title: "", createElement: function (tag) { return { tagName: tag.toUpperCase(), style: {} }; }, getElementById: function () { return null; }, addEventListener: function () {} }; global.localStorage = { _data: {}, getItem: function (key) { return this._data[key] || null; }, setItem: function (key, value) { this._data[key] = String(value); }, removeItem: function (key) { delete this._data[key]; }, clear: function () { this._data = {}; } };这段代码有几个值得注意的细节。第一,plugins数组里的每一项都带了name、description、filename三个字段,因为很多检测代码会遍历navigator.plugins并检查每一项的字段完整性。第二,screen的availHeight往往和height不一致,真实的Windows任务栏会占用一块区域,这里体现了差值——如果availWidth和width完全相等,反而容易暴露是模拟的。第三,localStorage内部用一个_data对象存储,getItem对不存在的键返回null,这和浏览器行为保持一致。
3.3 用VM创建独立沙箱,把代码装进去跑
环境对象填好之后,下一步是把ali231的代码放进一个干净上下文里执行。直接global.window = global虽然能用,但污染太严重,可复现性也差。我一般用vm.createContext来做:
// run-ali231.js const fs = require("fs"); const vm = require("vm"); const mockEnv = require("./mock-base.js"); // 构造一个包含补齐环境的沙箱对象 const sandbox = { window: {}, navigator: mockEnv.navigator, document: mockEnv.document, screen: mockEnv.screen, localStorage: mockEnv.localStorage, console: console }; sandbox.window = sandbox; vm.createContext(sandbox); const code = fs.readFileSync("./ali231.js", "utf-8"); vm.runInContext(code, sandbox, { filename: "ali231.js" }); // 调用签名函数,比如全局暴露的 _sign 方法 const result = vm.runInContext("_sign('param1', 'param2')", sandbox); console.log(result);这段代码里,createContext会基于沙箱对象创建一个全新的全局作用域,ali231内的var声明不会泄露到Node主进程。函数执行时,代码里访问window实际访问的就是sandbox.window,然后通过sandbox.window = sandbox的相互引用保证了自引用关系。
注意runInContext里我传了filename参数。这个参数会让格式化的错误堆栈带上具体文件名,排查时很有帮助。如果ali231内部抛了异常,错误信息会显示ali231.js:12345,你能直接去源文件对应行号看逻辑。
3.4 这一步跑通了,怎么判断补得好不好
跑通只是第一步。判断环境补得好不好,可以看两个信号。
第一个信号是返回值形态。正常签名函数会返回一个长字符串或JSON对象,如果返回的是undefined、-1、{"code":-1}这类兜底值,说明代码在某个检测分支上“静默死掉了”。这种问题比直接抛异常更讨厌——没有报错,只能靠排查。
第二个信号是执行耗时。在多步补环境的过程中,执行耗时变化也很关键。如果某一步补上了document.createElement,却发现后续执行时间突然变长,大概率是代码真的创建了DOM元素并做了复杂操作——这本身也是后续要关注的性能点。
跑通之后,如果你发现自己补的对象越滚越大,光是环境就有几千行,这就是一个信号:你的方案离“泥沙俱下”不远了。真正的做法是边补边缩小范围,参考下一章说的排查思路,去定位它到底“卡在哪个判断上”。
4. 补环境避坑指南:五个翻车现场与对应解法
4.1 navigator属性齐全但还是报错?试试引用关系
现象:照着文档给navigator补齐了userAgent、language、plugins,代码还是会走到异常分支。
原因:有些代码不直接读navigator.xxx,而是先做了一次Object.keys(navigator).length判断。它要求“属性数量在合理范围内”。你补得太多或太少,都会触发异常。另一个常见问题是你把navigator直接定义成了普通对象,而不是函数或特殊对象——某些版本里代码会调用navigator.javaEnabled(),这个方法不存在就报TypeError。
解决:给navigator加上javaEnabled: function() { return false; },同时控制属性数量不要过多。一个经验值是保持在20~25个属性之间,和真实Chrome比较接近。属性太多会触发“环境异常”检测,太少则会被识别为“精简环境”。
4.2 格式化后代码序列变化,导致原先的定位行号失效
现象:用格式化工具美化代码后,再去定位检测点时发现和日志里的行号对不上。
原因:格式化会改变代码的换行和结构,压缩代码里原本一行的长表达式被拆成了多行,行号规律全变了。
解决:格式化前先做一次MD5记录,格式化后的文件单独保存,不覆盖原文件。调试定位时,用格式化后的文件;验证最终结果时,用原始压缩文件跑。不要混着用,这是很多人翻车的开始。
4.3 外部网卡检测:有些环境的指标来自操作系统层面
现象:本地补环境跑得很好,换一台机器跑同一份代码,结果却不一样。
原因:部分风控JS不只是读浏览器API,还会尝试读取系统能力,比如navigator.connection的downlink、effectiveType,或者window.deviceMemory。这些值直接反映宿主机状态——你要是用一台4GB内存的虚拟机,deviceMemory就可能是4,而普通开发机通常是8。模块拿这些值和userAgent一起算指纹,机器变了指纹自然变。
解决:主动把这类属性固定成常量值。global.navigator.deviceMemory = 8,global.navigator.hardwareConcurrency = 8,再给navigator.connection补上effectiveType: "4g"等字段。指纹要稳定,就得先让这些硬指标稳定。
4.4 恶意的getter属性你无法直接赋值
现象:把document.cookie赋值成字符串后,执行代码时又变成空串,怀疑代码里有什么东西“重置”了它。
原因:可能遇到了数据属性描述符的问题。有些JS在运行时会调用Object.defineProperty重新定义document.cookie,或者把window.navigator变成不可写。你在一开始全局赋值的方式,本质上是定义了普通属性,而不是浏览器内部那种getter/setter结构。
解决:改成用Object.defineProperty定义,并加上configurable: true,让后续代码能覆盖、但初始状态符合浏览器特征:
Object.defineProperty(global.document, "cookie", { get: function () { return this._cookie || ""; }, set: function (val) { this._cookie = val; }, configurable: true });这种方式模拟的是浏览器的真实存储行为。你在调试中如果发现某个对象属性怎么赋值都不生效,第一反应就应该是检查它是不是被defineProperty锁住了。
4.5 时序陷阱:一次性执行和连续执行结果不一致
现象:第一次调用签名函数得到正常签名,紧接着第二次调用,结果完全变了,参数明明没变。
原因:代码内部可能缓存了一个“首次加载时间戳”。首次加载时读取Date.now()存入闭包变量,后续签名过程把当前时间与首次加载时间做差值参与计算。你每次重新执行整份代码,缓存就被重置,前后自然不一致。还有另一种可能:它内部用Math.random()或某种累加器,每次调用都改变状态。
解决:如果确认代码内部是“时间差参与计算”,就把Date.now()也做一层稳定化处理,让它在一个会话内保持一个固定偏移量:
const baseTime = Date.now(); global.Date = new Proxy(Date, { apply: function (target, thisArg, args) { if (args.length === 0) { return baseTime; } return new (Function.prototype.bind.apply(target, [thisArg].concat(args)))(); }, construct: function (target, args) { return new target(...args); } });这个Proxy的作用是:当以函数方式调用Date()时,固定返回会话开始时间;当用new Date()构造时,仍走真实逻辑。这样既不影响其他地方对时间的依赖,又让签名过程的时间戳基准变得可控。至于到底是“时间差”还是“随机数累加”,可以通过保持其他条件不变、只改调用间隔来测试判断。
5. 进阶玩法:用Proxy钩子把补环境从手工活变成半自动化
5.1 捕获阶段:看看代码到底“读”了什么
手工补环境的痛点在于你不知道它需要什么。每补一个属性就想当然,结果越补越多,还找不到重点。常见做法是用Proxy包一层“探针”,让代码在运行过程中自己暴露它访问了哪些属性、在哪个顺序访问:
// probe-env.js —— 用Proxy捕获属性访问记录 const fs = require("fs"); const vm = require("vm"); function makeProxied(obj, path) { return new Proxy(obj, { get(target, prop, receiver) { const key = prop.toString(); console.log(`[GET] ${path}.${key}`); const value = Reflect.get(target, prop, receiver); if (value && typeof value === "object") { return makeProxied(value, `${path}.${key}`); } return value; }, set(target, prop, value) { const key = prop.toString(); console.log(`[SET] ${path}.${key} = ${value}`); return Reflect.set(target, prop, value); } }); } const sandbox = { navigator: makeProxied({ userAgent: "Mozilla/5.0 ..." }, "navigator"), document: makeProxied({}, "document") }; sandbox.window = sandbox; vm.createContext(sandbox); const code = fs.readFileSync("./ali231.js", "utf-8"); vm.runInContext(code, sandbox); const result = vm.runInContext("getSign('test')", sandbox); console.log(result);跑一次代码,控制台会输出一长串访问记录。比如[GET] navigator.userAgent、[GET] document.createElement、[GET] screen.width。从第一行到最后一行,完整还原了代码对环境的“读法顺序”。这个顺序本身就是信息:如果[GET] navigator.userAgent先出现,然后出现了[GET] document.cookie,说明代码在执行早期就既判断了UA又读取了Cookie。你可以根据这个行为特征去思考实现逻辑,而不是瞎猜。
5.2 自动应答阶段:把捕获到的东西变成环境配置
采集到访问路径后,下一步是把这些路径自动生成一个初始环境配置,而不是每次手动添加。做法很简单:收集到的所有[GET] path,按路径深度归类,然后预置一套“基于真实Chrome快照”的值。
我没有办法给你一份普适的“标准Chrome环境快照”,因为每个环境差异太大。但思路是一样的:用一个无头浏览器打开一个空页面,执行Object.keys(navigator)、navigator.plugins、screen.width等,把结果序列化成JSON。然后把这个JSON作为补环境的素材库,按访问路径去匹配。
// 把无头浏览器采集到的JSON做映射 const chromeSnapshot = require("./chrome-snapshot.json"); const probeResult = require("./probe-result.json"); function buildEnv(probeResult, chromeSnapshot) { const env = {}; for (const path of Object.keys(probeResult)) { const keys = path.split("."); let target = env; for (let i = 0; i < keys.length - 1; i++) { target[keys[i]] = target[keys[i]] || {}; target = target[keys[i]]; } const lastKey = keys[keys.length - 1]; // 优先从快照取真实值,取不到就置一个可配置的默认值 target[lastKey] = chromeSnapshot[path] !== undefined ? chromeSnapshot[path] : ""; } return env; }这段代码的逻辑很简单:对照采集到的属性路径,把快照里的真实值一一填进去。快照里没有的属性就填空字符串。这样生成的初始环境一定是为ali231量身定制的——它访问了哪些属性,你的环境里就有哪些属性,不多不少。
参数上需要说明的是:chrome-snapshot.json的采集方式,是在真实浏览器里执行JSON.stringify(navigator)得到的。注意这里不能静态写死,因为不同Chrome版本、不同操作系统的navigator属性差异很大。你需要在目标被测场景相近的浏览器环境里采集。
5.3 沙箱粒度:每次调用应该用新的还是复用的环境
环境搭好后,还有最后一个问题:每次调用签名,是重新创建一个全新沙箱,还是复用同一个沙箱?
两种方案各有代价。全新沙箱的好处是状态完全隔离,不会因为上一次调用修改了某个全局值而影响下一次结果,但缺点是创建沙箱本身有开销——环境对象越多,初始化越慢。复用沙箱则速度快,但如果ali231内部某个状态在运行时被改写,后面所有调用都会在错误的状态下执行。
我一般用的是折中策略:以“会话”为单位创建沙箱。一个会话内复用同一个沙箱,连续跑多次签名;新的任务组再创建新沙箱。如果发现某个会话里连续调用的签名值出现规律性变化,就重新创建沙箱。这个判断标准虽然听上去有点“玄学”,但在实践中比固定规则好用得多。
class SandboxPool { constructor(envFactory) { this.envFactory = envFactory; this.active = null; } acquire() { if (!this.active) { this.active = this.envFactory(); } return this.active; } release() { this.active = null; } } const pool = new SandboxPool(() => { const s = buildSandbox(); vm.createContext(s); vm.runInContext(aliCode, s); return s; }); // 每次业务调用 const sandbox = pool.acquire(); let result = vm.runInContext("getSign()", sandbox); if (needReset(result)) { pool.release(); }needReset的判断条件可以是返回值异常、调用耗时异常、连续多次失败等。这算是一种轻量级的故障转移,能让整个调用体系在复杂环境下保持相对稳定。
6. 怎么验证补出来的环境是真的:一致性、边界值和不变量
环境补完之后,很多人会用“能跑出来一个签名”当作验收标准。这在DEV环境里可以,但到了生产环境,一次成功的调用和一百次成功的调用是两回事。我一般会做三类验证。
第一是输入一致性验证。用同一组入参连续调用20次,记录每次签名值。如果签名里有时序因子,这20个值不能完全相同,但它们的“模式”应该一致——比如签名长度相同、分隔符位置相同、能解码出同一个指纹串。如果连模式都不稳定,那说明你的环境里有些值参与计算但没被固定住,典型的就是没补Date相关的时区偏移。
第二是边界值验证。把入参换成空字符串、超长字符串(几万个字符)、特殊符号组合,观察签名函数是否有稳定的错误返回。对风控签名JS来说,输入长度边界是必测项,很多模块会针对超长输入做截断或哈希,没有测过就上线,迟早遇到生产事故。
第三是我个人最推崇的验证手法——不变量检查。补环境一个常见的隐性bug是你补出来的属性和真实浏览器行为不一致,但代码又不直接依赖它。比如说,代码里有一个navigator.languages数组,你补了["zh-CN", "zh"],但它内部可能按固定格式拼接了语言列表,顺序错了、长度变了,都会影响最终指纹。这种问题靠“多测几次”看不出来,得靠对照:在真实浏览器里执行一段与ali231无关的JSON序列化脚本,把关键环境变量dump出来,在Node里跑同样的脚本,对比两份JSON的差异。
# 在真实浏览器控制台执行 JSON.stringify({ ua: navigator.userAgent, lang: navigator.languages, screen: [screen.width, screen.height], deviceMemory: navigator.deviceMemory || 0 }) # 在Node补齐环境后执行同样逻辑 node -e "const s = require('./mock-base.js'); console.log(JSON.stringify({ ua: s.navigator.userAgent, lang: s.navigator.languages, screen: [s.screen.width, s.screen.height], deviceMemory: s.navigator.deviceMemory || 0 }))"两边的输出一对比,哪个字段有出入,哪个就是隐患。这份对比报告我在每次补完环境后都会存一份,改环境时拿旧报告回归。时间久了你会发现,真正翻车的不是某个API返回错误,而是某个你以为无关紧要的字段值和真实浏览器差太多。这也是我对“补环境”这三个字理解最深的地方——它本质上不是一项“补代码”的工作,而是一项“对齐细节”的工作。希望这套方法和排查习惯能帮到你,少走一些我走过的弯路。
本文还有配套的精品资源,点击获取