资讯详情

JS逆向补环境实战:ALi-231样本与vm框架搭建指南

📅 2026/10/20 8:07:08 | 华诺云谱 👁 阅读
JS逆向补环境实战:ALi-231样本与vm框架搭建指南
简介面向JS逆向学习者与前端安全研究者的ali231参数补环境源码包适合已有一定JavaScript逆向基础、想掌握补环境完整流程的开发者。压缩包内共3个文件包含HTML页面调试入口、.inscode运行配置以及.gitignore忽略规则整体仅6KB结构清晰、便于快速分析。目前已有221人学习。该源码围绕ali231参数逆向中的关键环节展开从加密位置定位、初始化环境值分析到环境检测注意事项与轨迹异常处理均有对应代码示例同时针对补环境最常见的漏环境、挂代理、补原型等易错点给出了具体解决思路。读者既能按步骤理解补环境的整体架构也能直接复用其中的检测与绕过逻辑减少自主逆向中的重复踩坑。资源仅供学习交流使用请勿用于任何商业用途。1. JS 补环境实战先搞清楚 ALi-231 这类脚本在被什么难住拿到一份 JS 逆向样本第一件事不是打开编辑器狂写补丁而是先跑一遍看它到底在哪里断。我见过太多人花一个下午给window、document手动塞属性结果样本根本不读这些全卡在一个不起眼的Function.prototype.toString校验上。ALi-231 这个名字听起来像某个加密组件的代号但它代表的是同一类问题一段经过混淆的浏览器端脚本在脱离浏览器后靠着一整套环境指纹来决定自己要不要正常执行。补环境的本质就是在一个没有浏览器的运行时里把对方要查的那张“环境清单”逐项还原出来。这个资源包能解决的是给你一个可以直接跑起来改的补环境骨架而不是一段玄学粘贴代码。你不需要自己从零摸索哪些属性要补、哪些检查是伪检查也不需要被各种“补一下就过”的碎片教程带偏。适合的人群是已经会用 Node.js 跑脚本但被环境自检卡住的前端或爬虫方向开发者以及想系统理解浏览器环境对象之间引用关系、准备长期和混淆脚本打交道的人。2. 补环境框架搭建window/document/navigator 不是三个对象是一条证据链刚接触补环境时有个常见误解——以为把window、document、navigator各造一个对象脚本就能跑。真不是这样。浏览器里这三个对象彼此引用window.navigator和navigator.window是双向的document.defaultView又指回window。混淆脚本往往会沿着这条引用链做一致性校验缺一环都会暴露。2.1 为什么不能用简单对象冒充浏览器全局先看一个反面例子。很多初学版补环境是这样写的// 反面示例只做一层浅拷贝必挂 const window { navigator: { userAgent: Mozilla/5.0 ..., platform: Win32 }, document: {}, location: { href: https://target.example.com/ } };这段代码的问题在于window.document和document是同一个对象吗显然不是。但浏览器里window.document document是成立的。如果脚本里有一句window.document document ? pass : fail这种简单对象结构直接暴露。我一般会这样搭骨架把几个关键全局对象串成一张网// 基础补环境骨架 v0.1先保证引用关系成立 const vm require(vm); const ctx { console: console, setTimeout: setTimeout, clearTimeout: clearTimeout, setInterval: setInterval, clearInterval: clearInterval, Date: Date, JSON: JSON, Math: Math, RegExp: RegExp, Array: Array, Object: Object, String: String, Number: Number, Boolean: Boolean, Promise: Promise, Proxy: Proxy, Reflect: Reflect, Symbol: Symbol, Error: Error, TypeError: TypeError, RangeError: RangeError, ArrayBuffer: ArrayBuffer, Uint8Array: Uint8Array, Float64Array: Float64Array, Int32Array: Int32Array, DataView: DataView, URL: URL, URLSearchParams: URLSearchParams, TextEncoder: TextEncoder, TextDecoder: TextDecoder }; // 构造 document先行占位 const document { defaultView: null, // 稍后指回 window cookie: , referrer: , createElement: function(tag) { return { tagName: String(tag).toUpperCase(), children: [], style: {}, getContext: undefined }; } }; // 构造 navigator const navigator { userAgent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, platform: Win32, language: zh-CN, languages: [zh-CN, zh], hardwareConcurrency: 8, deviceMemory: 8, webdriver: undefined, maxTouchPoints: 0, cookieEnabled: true, onLine: true, vendor: Google Inc. }; // 引用关系互相指认这是最容易被忽略的一层 const window { ...ctx, navigator: navigator, document: document }; // 关键双向引用否则走 window.document.xxx 的代码会炸 window.window window; window.self window; window.top window; window.parent window; window.this window; document.defaultView window; document.window window; navigator.window window; navigator.userAgent navigator.userAgent; // 保持自身属性可读 window.navigator navigator; window.document document; ctx.window window; ctx.document document; ctx.navigator navigator; ctx.self window; ctx.top window; ctx.parent window; ctx.globalThis window; // 最终放进 vm 上下文执行 vm.createContext(ctx);这段代码的逻辑说明先用ctx收集 JavaScript 内置的全局对象避免脚本在vm里解析不到Date、Promise这类基础构造函数然后创建document和navigator最后统一由window把它们串起来。核心是引用关系的闭合——window.document必须和全局document是同一个对象navigator.window必须能取回window。参数层面有几个地方需要注意webdriver我故意设成undefined而不是false因为不少检测脚本用的是webdriver in navigator这种逻辑属性一旦存在就可能有值maxTouchPoints: 0是桌面浏览器默认值如果你在补移动端页面这里要改成5或10hardwareConcurrency和deviceMemory按目标环境补Windows 常见8但不要照抄最好以真实目标浏览器取值为准。这段代码只算“地基”很多脚本会在运行时动态读取属性后面你还需要配合Proxy来拦截未知属性的读取请求这一点在 3.3 节展开。2.2 脚本怎么查环境常见指纹采集点要补环境先要知道对方查什么。混淆脚本查环境的方式五花八门但高频采集点其实就那么几类。以我拆过的几个伪装成独立 SDK 的样本为例他们最喜欢做的检查是按下面表格里的套路走的检测维度具体采集点常见校验方式浏览器身份navigator.userAgent、navigator.appVersion静态字符串匹配、分段正则匹配渲染能力document.createElement(canvas)、canvas.toDataURL调用后比对数据指纹特性暴露window.chrome是否存在及对象键key in window或遍历对象键插件信息navigator.plugins、navigator.mimeTypes取数组长度、枚举插件名时序特征performance.now()、Date.now()两次调用差值判断是否同毫秒原型链完整性Function.prototype.toString、Object.getOwnPropertyDescriptor检查函数是否被改过看明白这张表你就能理解为什么只补userAgent没用。脚本可能先在navigator.plugins上取长度发现是0或者取不到navigator.plugins.length直接短路走异常分支。另一个高频检查点是原型链完整性。正常浏览器里某个原生函数转成字符串会带[native code]标记// 校验原生函数是否被篡改 const checkNative (fn) { const str Function.prototype.toString.call(fn); return str.includes([native code]); }; // 示例检测 setTimeout 是否为原生实现 console.log(checkNative(setTimeout)); // 如果被代理过此处 false console.log(checkNative(Function)); // 原生 Function 应该 true很多补环境脚本会直接改写setTimeout或addEventListener来模拟浏览器行为这就留下了一个破绽。我一般会保留原生setTimeout不动只在外层包一层调度器而不是直接替掉原函数。这个细节你后面踩坑会深有体会。2.3 三种跑脚本的路线为什么最后选了 vm 补环境做 JS 逆向跑目标脚本有三条路线。第一条是直接用无头浏览器puppeteer、selenium加载页面让脚本在真实浏览器里跑然后从页面里拿结果。优点是环境真实缺点也很明显重、慢并且每次要起一个浏览器实例不适合批量操作。第二条是用jsdom来模拟 DOM 环境。jsdom在页面渲染模拟上很成熟但它补的是“DOM 逻辑”不是“浏览器环境自检”。ALi-231 这类样本恰恰会检查jsdom的破绽——比如某个 DOM 事件的构造函数跟真实浏览器不一样或者window.Proxy没有被正确配置。第三条就是我常用的路线Node.js vm 手动补环境。用vm.createContext打开一个独立上下文然后把手工构造的window、document等注入进去。这条路的优势是所有环境完全受控脚本查什么你就补什么可定制性最高代价是你要逐个排查脚本到底查了什么前期劳动量大。所以这个资源包提供的不是一个“一键运行”而是一个“你能完全掌控”的框架。骨架代码帮你省掉最底层的引用关系搭建但具体脚本查了哪些偏门属性还是需要你自己跑日志、抓读取、逐步补全。3. 把骨架跑起来从扫描特征到首个稳定输出的四个步骤有了骨架之后最怕的就是直接拿完整样本往里扔然后看报错猜原因。我更推荐按“扫描特征 → 打日志 → 补属性 → 验输出”四步走每一步都让问题暴露得尽量小。3.1 第一步先扫描目标脚本的特征关键词在 Node.js 环境里写个小脚本把样本源码里的环境相关字符串扫出来。这比人肉眼读混淆代码快得多grep -oE window\.|document\.|navigator\.|location\.|performance\.|screen\. target.js | sort | uniq -c | sort -rn | head -30但这条命令只能统计到显式的引用。很多混淆脚本会把字符串拆开、编码后拼接比如window被变成\x77\x69\x6e\x64\x6f\x77。所以我还会写一个更粗一点的脚本把源码里所有十六进制和 Unicode 编码的字符串解码后再扫一遍// feature_scan.js const fs require(fs); const src fs.readFileSync(./target.js, utf-8); // 解码常见转义 const decoded src .replace(/\\x([0-9a-fA-F]{2})/g, (m, hex) String.fromCharCode(parseInt(hex, 16))) .replace(/\\u([0-9a-fA-F]{4})/g, (m, hex) String.fromCharCode(parseInt(hex, 16))); const features [ navigator, userAgent, webdriver, plugins, mimeTypes, document, cookie, createElement, addEventListener, performance, timeOrigin, now, screen, availWidth, canvas, toDataURL, getContext, chrome, webstore, toString, getOwnPropertyDescriptor, prototype, constructor ]; for (const key of features) { const count (decoded.match(new RegExp(${key}, g)) || []).length; if (count 0) { console.log(${key}: ${count}); } }这个脚本的核心逻辑是先把转义字符串还原成明文再拿一份常见环境关键词清单做匹配输出每个关键词出现的次数。次数高的大概率是脚本要采集的环境特征出现次数少但出现在可疑位置的也不能放过。参数上要注意关键词清单不要用太通用的词比如name、value这类词在混淆代码里到处都是扫出来没有区分度。我这份清单里的词基本都是完整的环境对象名或方法名。3.2 第二步给上下文加日志钩子抓“读”操作特征扫描是静态的只能说明脚本里提到了这些词但运行时到底读了哪些属性、读取顺序如何需要通过日志钩子抓。最常用的手段是给window包一层Proxy在get和set上打点// logger.js const vm require(vm); const fs require(fs); const src fs.readFileSync(./target.js, utf-8); const logStack []; const baseWindow { /* 引用关系同 2.1 节 */ }; const proxyWindow new Proxy(baseWindow, { get(target, prop, receiver) { const value Reflect.get(target, prop, receiver); // 只记录读操作避免刷屏 if (typeof prop string !prop.startsWith(__)) { logStack.push([get] window.${prop} ${typeof value}); } // 如果取到的是函数包一层记录调用 if (typeof value function) { return new Proxy(value, { apply(targetFn, thisArg, args) { logStack.push([call] window.${prop}(${args.map(a typeof a).join(,)})); return Reflect.apply(targetFn, thisArg, args); } }); } return value; }, has(target, prop) { const exists Reflect.has(target, prop); logStack.push([has] ${prop} in window - ${exists}); return exists; } }); const context { ...baseWindow, window: proxyWindow, self: proxyWindow, top: proxyWindow }; vm.createContext(context); vm.runInContext(src, context); // 等脚本执行完输出日志 setTimeout(() { fs.writeFileSync(./access.log, logStack.join(\n), utf-8); console.log(done, total ${logStack.length} access); }, 3000);这段代码的作用是拦截所有对window上属性的读取。凡是脚本读了一个环境属性日志里就会留下一条记录凡是调用了window上的函数也会被包装后记录参数类型。拿到这份访问日志你就能精确知道脚本启动阶段先读了什么后读了什么哪些属性读出来是undefined哪些函数被调用了但参数不对。参数细节我在get拦截器里没有返回undefined而是返回真实的Reflect.get结果。这样脚本第一次运行时行为最接近原样不会因为补了错误的值而跳进异常分支——你要做的是先看日志再决定补什么而不是一次性把全部值都补上。3.3 第三步按日志逐项补属性优先处理undefined拿到access.log后重点筛选两类记录一类是[get] window.xxx undefined说明脚本要读但当前上下文里没有另一类是[has] xxx in window - false说明脚本在检测属性是否存在。前者对应值后者对应键。// patch_example.js // 根据日志补环境每次在 patchMap 里加一项 const patchMap { screen: { width: 1920, height: 1080, availWidth: 1920, availHeight: 1040, colorDepth: 24, pixelDepth: 24 }, devicePixelRatio: 1, outerWidth: 1920, innerWidth: 1920, outerHeight: 1080, innerHeight: 978, screenX: 0, screenY: 0, pageXOffset: 0, pageYOffset: 0, chrome: { loadTimes: function() {}, csi: function() {}, app: {}, bookmarks: {}, connect: {}, cpu: {} } }; // 把上面的字段全部挂到 window 上 for (const [key, value] of Object.entries(patchMap)) { if (!(key in baseWindow)) { Object.defineProperty(baseWindow, key, { configurable: true, enumerable: true, writable: false, value: value }); } }这段代码的重点不是属性本身而是什么时候补。日志里脚本读到screen.width返回undefined你就补screen读到devicePixelRatio返回undefined你就补devicePixelRatio。不要一次把所有常见属性全塞进去因为有些检测脚本会遍历window上的键多出来的键反而暴露你不是浏览器环境。Object.defineProperty里我特意设了writable: false防止脚本运行中修改这些环境值这跟真实浏览器里很多只读属性的行为一致。chrome这个对象我补了很多空子对象。原因是在真实 Chrome 里window.chrome是一个包含多个子对象和函数的结构体混淆脚本一旦检测loadTimes in window.chrome缺了就直接判定为假。3.4 第四步跑目标函数用输出稳定性验补环境环境补到不再报错、日志里也没几条undefined之后就该测真实输出。ALi-231 这类样本通常暴露一个加密函数你传一份固定的输入它返回一串加密结果。这个结果应该具备两个特征确定性和稳定性。确定性是同一份输入跑五次结果完全一样稳定性是跟浏览器里跑出来的结果一致。# 运行 5 次检查 hash 是否一致 for i in 1 2 3 4 5; do node run.js test-input | md5sum; done如果五次 MD5 一样说明你的补环境至少没有引入随机性如果五次不一样优先查一下是否跟Math.random()、Date.now()这类时间相关 API 有关——有些脚本会把时间戳混进加密流程你需要把Date.now()固定成常量来排查// 排查随机性来源固定时间源 Object.defineProperty(baseWindow, Date, { value: class extends Date { constructor(...args) { if (args.length 0) { super(1700000000000); // 固定值 } else { super(...args); } } static now() { return 1700000000000; } } });这段代码把Date替换成固定时间的子类new Date()和Date.now()返回的都是同一时刻。这样如果某次加密结果变了问题就出在别处而不是时间极点。补完后记得把时间源换回真实Date因为实际业务中时间本身就是要参与计算的。4. 补环境排坑五个典型异常与对应的收口策略补环境是个反复试错的过程跟玄学沾边的地方不少但大部分异常有明确规律。下面这五类问题是我在多个样本上真实遇到的按“现象 → 原因 → 解决”给你拆开。4.1 现象脚本运行直接抛错window is not defined第一次跑vm.runInContext如果上下文里没有注入window常会看到这个错。原因很直白你的骨架里没有给ctx.window赋值脚本在独立上下文里找不到全局window。有些代码会写globalThis.window但vm的createContext没有帮他建立这层关系。解决方式是在注入上下文时显式声明ctx.window ctx; ctx.self ctx; ctx.top ctx; ctx.parent ctx; ctx.globalThis ctx;把window、self、top、parent、globalThis都指向同一对象脚本从哪个入口取全局都不落空。注意不能只赋ctx.window因为脚本里很可能用globalThis.window或self.navigator这种间接引用来取。每次我都会检查这五个引用是否全部对齐少一个都埋雷。4.2 现象脚本不报错但日志里反复读同一个属性造成死循环有一次补环境时脚本在一个get拦截器上不断陷入自我引用日志刷到几十万条后崩溃。原因是我的Proxy拦截器里读取了被代理对象的同一个属性比如Reflect.get(target, prop)但这个target本身又被某个 getter 绑定了后续逻辑。这类问题的典型诱因是window.window递归引用。你设置了window.window window但如果检测脚本执行window.window.window.window这种多层引用而你的代理对象在每一层get都返回一个新构造的值就可能在某个判断里反复触发。解决思路是让多层引用返回同一个对象并且给代理加访达上限const getSafeWindow () { let count 0; return new Proxy(baseWindow, { get(target, prop, receiver) { count; if (count 10) { // 防止无限递归 return undefined; } if (prop window || prop self || prop top) { return proxyWindow; // 返回同一份 } return Reflect.get(target, prop, receiver); } }); };这里的参数要点是引用型属性永远返回缓存的同一对象而不是新造的同时加一个计数保护万一逻辑写错也能及时截断。4.3 现象日志里读到了属性但脚本在哪个环节都不报错就死等不出来这种情况多半是脚本启动了某个异步任务比如setTimeout(() {...}, 1000)或者Promise.resolve().then(...)而你的 Node 主进程在脚本执行完直接退出了异步回调没机会跑。我不会只靠setTimeout加时延来碰运气而是给上下文强制特定时长的待机窗口node run.js sleep 5 wait或者直接在脚本文件末尾加一个“手动事件循环等待”的逻辑在 Node 里可以用Atomics.wait阻塞主线程给异步回调让出时间// wait_for_async.js // 在 vm.runInContext 之后执行 const SHARED new Int32Array(new SharedArrayBuffer(4)); setTimeout(() { Atomics.store(SHARED, 0, 1); Atomics.notify(SHARED, 0, 1); }, 5000); Atomics.wait(SHARED, 0, 0); // 阻塞主线程最多等 5 秒注意这段代码里的setTimeout必须用浏览器的setTimeout如果上下文里你替换过setTimeout需要保留原生引用。另外Atomics.wait不能直接在浏览器主线程用但这正是 Node 环境补环境的优势——上下文隔离后主线程可以做等待外挂。4.4 现象同一个补环境脚本换台机器跑结果就不一样这是典型的环境参数差异。你补的screen.width1920、navigator.platformWin32、hardwareConcurrency8是写死的换到别人机器上对方浏览器里这些值可能完全不同。但补环境脚本本身不关心“真实值”只关心“和检测逻辑是否自洽”。比如样本先用navigator.userAgent里的 Chrome 版本号拼接一个screen.width判断如果你的userAgent是 Chrome 120但screen.width却是 1440有的检测逻辑就会认为不匹配判定为机器人。我一般的处理方式是把整套环境参数放在一个配置文件里按目标环境切换// env_config.js module.exports { desktopChrome: { userAgent: Mozilla/5.0 ... Chrome/120.0.0.0, platform: Win32, screen: { width: 1920, height: 1080 }, hardwareConcurrency: 8, deviceMemory: 8 }, mobileIOS: { userAgent: Mozilla/5.0 ... iPhone, platform: iPhone, screen: { width: 390, height: 844 }, hardwareConcurrency: 4, deviceMemory: undefined } };然后运行时根据目标传入配置。这样换环境只改配置不用改骨架代码。deviceMemory 在 iOS Safari 上大概率是undefined你要是补了反而画蛇添足。4.5 现象加密结果稳定但某些参数边界跟浏览器对不上最后一种情况最隐蔽。脚本运行通了输出也固定但用真实浏览器比对时个别参数就是差几个字节。通常是字符串编码或数组排序问题。例如样本在处理参数时用了Array.prototype.sort而 V8 的sort在不同版本、不同宿主下的排序稳定性有微妙差异。还有JSON.stringify在遍历对象属性时顺序取决于属性的插入顺序——你在补环境时用Object.defineProperty定义的属性顺序跟真实页面里的创建顺序很可能不同。处理这类问题的方法比较朴素先JSON.stringify打印一遍整个中间对象再跟真实浏览器的输出比对逐字段找出差异点// diff_output.js const real { a: 1, b: 2, c: [x, y] }; const mine { b: 2, a: 1, c: [y, x] }; console.log(JSON.stringify(real) JSON.stringify(mine)); // false键序不同 // 转成排序后的字符串再比 const normalize obj JSON.stringify(obj, Object.keys(obj).sort()); console.log(normalize(real) normalize(mine)); // 忽略键序后是否一致这个示例虽然简单但思路是可扩展的把所有需要比对的中间对象先排序再序列化快速定位差异是来自字段值还是字段顺序。确认是字段顺序问题后再去生成该对象的地方调整插入顺序。5. 把补环境做成可持续资产自检脚本与版本回归习惯补环境跑通一次不是终点因为目标脚本会更新你补的环境可能失效。长期维护的诀窍是把“能跑”变成“能验证”。我会在项目里单独维护一个selfcheck.js每次改完补丁先跑自检不给自己留侥幸空间。// selfcheck.js const assert require(assert); const run require(./run); // 你的补环境执行入口 const testCases [ { input: hello, expected: a1b2c3d4e5 }, { input: test-001, expected: f6g7h8i9j0 }, { input: , expected: k1l2m3n4o5 } ]; for (const t of testCases) { const output run(t.input); assert.strictEqual(output, t.expected, case ${t.input} failed); console.log([PASS] ${t.input} - ${output}); }跑完自检我还会做一次“全链路回归”把之前抓到的access.log和现在跑出来的新日志对比看新增了哪些属性读取、哪些读取顺序变了。这一步能提前发现目标脚本是否悄悄加了新的环境检测。我的习惯做法是把日志按“属性名-读取顺序-返回值类型”归一化成一行签名然后用diff工具直接比node logger.js current.log sed -E s/\[[0-9:]\]/[time]/g current.log | sort current.norm.log diff previous.norm.log current.norm.logsed的作用是把日志里的时间戳统一替换成占位符避免每次跑日志都因时间差产生无效差异。sort的作用是把读取顺序打平只比对“读了哪些属性”先忽略顺序差异。如果 diff 结果只有少量的新增项说明目标脚本更新幅度不大如果出现大批量新增或删除就要重新走一遍 3.3 节的补属性流程。还有一个成本很高的坑我必须提醒你不要图省事把补好的环境脚本整体复制到其他项目里直接用。每次新项目都要先跑一遍selfcheck.js因为目标脚本不同环境指纹清单差异可能非常大。资源包里给的骨架可以复用但环境参数和补丁项必须跟随样本变化。这个理念我从一开始就强制自己执行——只要改动过环境相关的任何代码先跑一次完整自检再跑一次回归对比两步都通过才认为维护完成。这个习惯帮我少踩了至少半数以上的模糊问题希望它能帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑