ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

JS逆向补环境原理与实战:以ali231为例解析环境检测与绕过

JS逆向补环境原理与实战:以ali231为例解析环境检测与绕过 简介面向JS逆向学习者的ali231参数补环境源码包专门讲解如何定位加密位置、初始化环境值、处理环境检测与轨迹问题适合有一定前端基础、正在攻克补环境技术的开发者。压缩包整体仅6KB共3个文件包含可直接运行的HTML页面、inscode配置脚本以及gitignore文件结构精简便于快速查阅和修改调试。资源完整呈现了补环境过程的整体架构从目标URL与接口分析入手逐步拆解加密定位、环境值初始化、环境检测规避以及轨迹数据生成等环节并特别强调了避免漏环境、挂代理、补原型等实战关键点同时给出具体代码示例。通过对照源码演练读者可以更直观地识别环境检测差异、掌握补环境的核心思路与排错方法。目前已有220人学习适合希望系统掌握JS逆向补环境技巧的中高级前端开发者作为参考。1. JS逆向的补环境到底在补什么以 ali231 为例做 JS 逆向的人多半都有过这种经历拿到一段混淆过的加密 JS调试器里跑得正常换到自己的 Node 环境里一跑就报错不是window is not defined就是某个函数返回undefined。原因不是代码写错而是加密脚本在运行时检测了浏览器环境一旦发现环境特征不对要么直接抛异常要么返回假值。ali231 就是这类加密算法的一个典型案例从名字能看出它是阿里的某个签名参数核心逻辑并不复杂难的是它依赖的环境变量特别多。这份源码的价值在于它把补环境的完整思路落成了可跑的代码通过代理、Hook、原型链补齐等方式把浏览器环境在 Node 里模拟出来让加密函数以为自己在浏览器里运行。适合的人群很直接正在做 Web 端逆向、被各大厂加密脚本环境检测卡住的人或者想系统理解补环境原理的爬虫工程师新手拿它能少走弯路熟手可以拿它的框架去扩展别的站点。2. 补环境的底层逻辑为什么 ali231 在 Node 里跑不起来2.1 环境检测的三层手段显式、隐式与侧信道浏览器环境检测不是一个单一动作而是分层次叠加的。最表层是显式检测脚本直接判断typeof window ! undefined、typeof document ! undefined这类判断补起来最简单在 Node 全局挂上对应的变量就行。第二层是隐式检测脚本会访问navigator.userAgent、document.cookie、screen.width这类属性如果属性值是undefined或者与真实浏览器不一致就会触发异常分支。第三层最麻烦是行为侧信道检测脚本会调用canvas.toDataURL()生成一段特征指纹或者通过WebGLRenderer读取显卡信息甚至利用performance.now()的时间精度来判断当前环境是不是真实浏览器。ali231 里的检测大部分集中在第二层和第三层。单纯在global.window global这种水平上补环境根本不够因为脚本还会读取window.chrome的某个内部属性、检查navigator.plugins是否有内容、验证document.createElement(div)返回的对象原型链是否完整。这些属性在真实浏览器里是天然存在的但 Node 里完全没有需要模拟的对象层次非常多。这也是为什么直接跑会报各种奇奇怪怪的错——实际是某个环境变量没补上卡在了第一层检测之前。// 第一层显式检测示例 if (typeof window undefined || typeof document undefined) { throw new Error(Invalid environment); }这段代码是很多加密脚本开头的标准姿势。逻辑很简单但它是第一道门槛。补环境的第一步就是让typeof window不再等于undefined但这一步只解决最外层的问题内部更深层的检测会在代码执行到中途才暴露。2.2 原型链补环境的原理不是挂属性而是挂完整对象补环境的核心难点不在“缺什么补什么”而在“补出来的对象要长得和浏览器里一模一样”。JS 里的属性访问会沿着原型链向上查找如果补出来的对象原型链不完整脚本一旦访问到某个原型方法就会得到undefined或者直接抛错。ali231 里典型的检测是navigator.mediaDevices相关调用以及document.createElement(canvas).getContext(2d)的调用。常见做法我一般会这么处理是先构建一个Navigator类把userAgent、plugins、language、platform等属性全部挂上去再把它作为原型挂到全局。关键点在于不能只挂global.navigator fakeNavigator因为navigator.plugins本身是一个数组对象数组的每个元素还有name、filename、description属性这些都要逐层补齐。// 补全 navigator 对象核心是原型链层次 const plugins [ { name: PDF Viewer, filename: internal-pdf-viewer, description: Portable Document Format }, { name: Chrome PDF Plugin, filename: internal-pdf-viewer, description: Portable Document Format } ]; class FakeNavigator { constructor() { this.userAgent Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36; this.plugins plugins; this.language zh-CN; this.platform Win32; this.vendor Google Inc.; this.maxTouchPoints 0; this.hardwareConcurrency 8; } get mediaDevices() { return { enumerateDevices: async () [] }; } get cookieEnabled() { return true; } } global.navigator new FakeNavigator();这段代码里最值得注意的是mediaDevices用 getter 返回一个对象实例。这是因为navigator.mediaDevices本身是只读属性在真实浏览器里直接访问它不会报错但如果你的模拟对象里没有这个方法脚本调用链一断就整体崩掉。参数方面hardwareConcurrency建议设成 4 或 8别设太大有些站点的反调试逻辑会校验这个值和navigator.deviceMemory是否互相对应如果数字太反常反而暴露了。补环境绝不只补一层。window和document是核心但还有location、history、localStorage、sessionStorage、indexedDB等一整套 Web API。每个对象的属性访问方式、只读性、原型链长度都要模拟。ali231 源码里有个很实用的做法先写一个最小可运行版本跑起来看它报什么错缺什么补什么迭代式的把环境补全而不是一开始就写出几百个属性的全集。3. ali231 补环境源码结构核心文件与初始化流程3.1 文件目录设计源码包里的工程是这么组织的拿到这份源码包先别急着跑看它的目录结构比直接运行更重要。一个科学的补环境工程应该把环境注入、代码加载、结果获取三个阶段完全分离这样后续要换目标脚本或者调整参数都不用改动整体框架。ali231-project/ ├── init.js # 环境初始化入口负责注入全局变量 ├── env/ │ ├── window.js # window 对象构建 │ ├── document.js # document 对象构建 │ ├── navigator.js # navigator 对象构建 │ └── storage.js # localStorage/sessionStorage 模拟 ├── proxy/ │ └── hook.js # 通过 Proxy 拦截属性读写 ├── target/ │ └── ali231.js # 目标加密脚本副本 ├── run.js # 主入口加载环境、运行目标脚本、提取结果 └── output.log # 运行日志这个目录设计不是拍脑门定的。env/目录每个文件管一个独立的全局对象改起来互不影响proxy/hook.js是补环境的核心工具不是直接改目标脚本run.js是唯一需要手动的入口。整个思路就是目标脚本是一个黑盒我们不动它只动外面的环境。3.2 初始化流程从空 Node 环境到模拟浏览器的关键步骤初始化流程的先后顺序是有讲究的。先说结论先挂window和globalThis再挂document和location最后才是navigator和其余 API。这个顺序不能反因为document的构造依赖window而navigator的初始化又可能触发document的调用。// init.js 核心逻辑 const { buildWindow } require(./env/window); const { buildDocument } require(./env/document); const { buildNavigator } require(./env/navigator); const { buildStorage } require(./env/storage); const { installProxy } require(./proxy/hook); // 按依赖顺序注入全局变量 const fakeWindow buildWindow(); global.window fakeWindow; global.globalThis fakeWindow; global.window.window fakeWindow; // 确保 window.window 也是自己 const fakeDocument buildDocument(fakeWindow); global.document fakeDocument; fakeWindow.document fakeDocument; const fakeNavigator buildNavigator(); global.navigator fakeNavigator; fakeWindow.navigator fakeNavigator; // localStorage 与 sessionStorage global.localStorage buildStorage(); global.sessionStorage buildStorage(); // 最后安装 Proxy 拦截记录所有属性访问 installProxy(fakeWindow);代码的关键点在最后一行installProxy不是必须的但建议加上。这个 Proxy 拦截器会把所有对window对象的属性读取记录下来输出到output.log里。这样目标脚本跑完后能审计到底是哪几个属性被访问了但返回了undefined这是后面排错最直接的依据。global.globalThis fakeWindow这一步经常被忽略但很多现代 JS 加密库内部直接用了globalThis而不是window如果漏掉这个赋值跑两步就崩。参数上要注意的是buildWindow内部需要忽略window已有属性的冲突。常见做法是在构建时用Object.create(null)做原型再手动把需要的方法挂上去这样可以避免继承到 Node 本身的全局方法导致目标脚本通过原型链检测到异常。3.3 Proxy Hook 实现把所有读取痕迹都记录下来Proxy 是补环境里最实用的工具之一。它能在属性被读取时执行自定义逻辑这让补环境从“猜测”变成了“观察”。ali231 脚本运行时访问了哪些属性、属性值是什么通过 Proxy 的get陷阱全部可以拿到。// proxy/hook.js const logStream require(fs).createWriteStream(./output.log, { flags: a }); function installProxy(target) { return new Proxy(target, { get(obj, prop) { // 记录所有属性访问 logStream.write([GET] ${String(prop)}\n); // 如果属性不存在返回一个函数或空对象避免报错 if (!(prop in obj)) { return () undefined; } return Reflect.get(obj, prop); }, set(obj, prop, value) { logStream.write([SET] ${String(prop)}${String(value)}\n); return Reflect.set(obj, prop, value); } }); }get陷阱里的if (!(prop in obj)) return () undefined;是双刃剑。好处是目标脚本访问任何不存在的属性都不会直接抛TypeError运行不会中断坏处是如果目标脚本检测到某个方法返回了undefined可能会走异常分支导致结果不对。所以这个返回函数只适合在调试阶段用正式跑的时候需要把它去掉改成精确补全每个属性。Proxy 的另一个用途是用来做“动态补环境”当脚本访问一个未定义的属性时先返回一个占位值同时记录下这个属性名等第一轮跑完再根据记录的属性名去一一补全然后重新跑一遍。ali231 源码里就带了一套这样的一轮一轮迭代机制这是补环境的核心方法论——不要一次追求完美先让它跑起来再优化。4. 避坑常见问题ali231 补环境最容易翻车的五个细节4.1 报错window is not defined但全局明明已经挂上去了现象init.js和run.js顺序没问题global.window fakeWindow也执行过了但目标脚本仍然报window is not defined。原因目标脚本可能在最外层用了函数作用域隔离它在文件头部直接写了一句var window ...或者const window ...导致脚本内部自己定义了一个window变量把全局的覆盖掉了。还有一种情况是目标脚本用了VM模块运行在独立的context里执行global上的变量传不进去。解决在run.js里加载目标脚本时不要用 Node 原生的require改用vm.createContext创建一个独立沙箱把补好的环境对象塞进沙箱里再执行脚本。这样可以保证脚本在隔离环境运行时看到的window就是我们补的那个对象。const vm require(vm); const sandbox { window: fakeWindow, document: fakeDocument, navigator: fakeNavigator }; vm.createContext(sandbox); vm.runInContext(scriptContent, sandbox);逻辑说明vm.createContext会把sandbox对象作为全局对象来执行代码所有window、document的访问都会命中这个对象不会跑到 Node 全局上去。参数说明scriptContent是目标 JS 文件的内容字符串sandbox里包含的键就是脚本能访问的全局变量。这个方案还能顺带解决require缓存导致的环境污染问题。4.2navigator.plugins返回空数组导致检测失败现象脚本跑完了不报错但最后拿到的签名值跟浏览器里抓到的对不上。原因navigator.plugins在真实浏览器里有几个默认插件项某些加密库不是直接判断plugins.length 0而是会遍历插件列表里的某个具体名称比如Chrome PDF Plugin找不到就认为当前是伪环境。解决把plugins数组补全别偷懒只给一个空数组。至少补上PDF Viewer和Chrome PDF Plugin两项每个元素都要有name、filename和description字段。这个过程很琐碎但它是补环境里最经典的坑之一。4.3 用Date.now()做时间校准把结果打偏现象签名结果有时候对有时候错错的时候差值特别大几百毫秒甚至几秒。原因目标脚本可能用了Date.now()或者performance.now()来做时间戳校验安防系统会比对本地时间和服务器时间的偏差偏差过大的环境下生成的结果服务器直接拒绝。解决在补环境时把Date.now()和performance.now()统一接管让它们返回当前真实时间戳但不要做任何偏移。有些库会检测performance的存在性补一个performance.now返回高精度时间即可。时间偏差是个玄学问题看似无关紧要实际上是最容易卡壳的地方。4.4 只补环境没处理原型链getContext报TypeError现象执行到canvas.getContext(2d)时报错提示getContext不是函数或者返回null。原因补的document.createElement返回了普通对象没有getContext方法。而真实浏览器里这个方法是存在的但它的返回对象还包含fillRect、getImageData、measureText等一整套 API任何缺失都可能被检测到。解决做一个极简的 Canvas 模拟对象getContext返回带fillRect、getImageData、measureText、toDataURL方法的对象。getImageData返回的像素数据在真实环境里有固定特征如果完全给零值反而可疑可以生成一段随机的 Uint8ClampedArray。4.5 本地跑通了换到服务器上就失败现象自己电脑上执行结果正确部署到 Linux 服务器之后同样代码就报错或者结果不对。原因服务器环境跟本地环境差异大比如没有 GPU 相关的WebGL模拟、navigator.userAgent里的操作系统标识不对本地是 macOS服务器是 Linux导致检测到平台不一致。解决把环境对象的构建参数提取成配置文件userAgent、platform、hardwareConcurrency都做成可配置项。部署到服务器时重新生成一套对应的环境参数不要复用本地那份。最稳妥的做法是先用userAgent模拟你目标浏览器保证platform与userAgent一致。血泪经验有一次我本地跑通了上服务器死活不对最后查了两小时发现是navigator.platform是MacIntel但userAgent里写的 Windows改过来立刻通过。5. 验证补环境是否成功用签名结果回推改环境参数跑通只是第一步验证“补的环境够不够真”才是最终目的。ali231 的签名结果没法直接访问服务器接口来确认是否合法但有一个比纯黑盒更高效的验证方法对比浏览器里生成的签名和 Node 补环境生成的签名在相同入参下的长度和字符分布是否一致。如果长度对不上不用想环境肯定还有问题如果长度一致但字符分布差异大说明时间戳或者随机数部分的算法走到了不同分支。最好的验证方法是造一个“测试桩”。把 ali231 目标脚本的输入参数固定住分别用浏览器运行一次和用 Node 补环境运行一次把两次的执行结果同时输出出来做逐字符对比。此时要关注的不是签名本身对不对而是差异点出现在哪个参数段签名通常由时间戳、随机数、业务参数拼接后再做加密如果差异集中在末尾说明加密部分的输入是一致的问题可能出在某个环境变量的取值上。// run.js 验证模式 const input { token: a1b2c3d4e5f6, uid: test_user, ts: 1700000000000 // 固定时间戳排除时间干扰 }; function runInBrowser() { // 在浏览器中调用 ali231输出签名结果 return window.callAli231(input); } function runInNode() { // 在 Node 补环境后调用同一函数 return sandbox.window.callAli231(input); } const browserResult runInBrowser(); const nodeResult runInNode(); console.log(Browser:, browserResult); console.log(Node :, nodeResult);这段代码的用意是让环境变量差别的排查变得可观测。固定ts是为了排除时间戳随机性的干扰聚焦在环境相关的分支上。如果两次结果只在随机数部分有差异那基本可以判定环境补全到位了如果结果完全不同那就把output.log里的 Proxy 记录拉出来看目标脚本还访问了哪些未被补全的属性针对性补齐。从那以后我每次做补环境都强制走一遍“先跑通、再固定入参对比、最后查 Proxy 日志”的流程看起来多花十分钟但能省下后面整天的排错时间。希望帮到你。本文还有配套的精品资源点击获取
返回列表