多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

补环境实战:ali231源码跑通混淆脚本与浏览器指纹配置

补环境实战:ali231源码跑通混淆脚本与浏览器指纹配置 简介这是JS逆向领域ali231参数补环境案例的完整源码包面向具备一定前端基础、希望突破逆向工程中环境适配难题的开发者。压缩包共包含3个文件以index.html源码为主体并附有inscode环境编排配置与.gitignore工程管理文件整体仅6KB结构精简便于直接研读。内容围绕加密位置定位、初始化环境值分析、环境检测规避、运行轨迹处理四条主线展开对补环境过程中容易出现的漏环境、挂代理时机、原型链补全等关键难点均配有对应代码示例展示了从定位加密入口到模拟执行环境的完整操作路径。尤其是环境检测部分通过具体代码演示了如何识别并绕过常见反爬检测逻辑具有较强实战参考价值。读者可借此理解浏览器环境与Node环境的差异掌握利用原型链补齐缺失属性的常见手法。目前已有221人学习适合用作JS逆向补环境入门到进阶的参考资料。1. 从依赖源码到可跑通的补环境先弄清楚这份代码在逆向流程里的位置在浏览器环境里随便跑一段混淆过的目标脚本大概率会立刻报 ReferenceError 或 TypeError卡死在一个看似平常的对象上。原因是脚本一开始就在借用浏览器注入的 API只是 V8 引擎没有这些东西。ali231 补环境源码解决的就是这个缺口给脚本的执行沙箱补齐浏览器对象、属性、方法让代码顺着原逻辑往下跑而不是被环境卡在入口。它适合已经拿到混淆代码但被环境检测拦在半路的开发者。依赖源码的加载顺序和属性覆盖策略就是整个案例的骨架后面几章会逐一展开。补环境本质是在一个隔离的执行环境里伪造一个可交互的宿主对象。给虚拟机塞入 window、navigator、document 这些常用 API 之后脚本里每一次 API 调用都能正常返回不再因为环境缺失直接崩掉。这套源码并不直接解决算法还原而是提供一段可维护的宿主补全框架让验证脚本时不被环境变量误导。下面要做的就是把这套代码跑起来摸清它的注入方式再把容易翻车的细节理顺。2. 跑通 ali231 的主链路先建好宿主对象再让目标代码接管静态分析只能看到调用关系看不出运行时的真实分支。环境补齐的价值就在于让目标代码在可控的壳子里继续执行把关键函数的返回值打出来。这一章从运行原理讲到实际跑通重点是把框架的执行顺序理清楚。2.1 补环境与单纯 mock 的差异很多教 mock 的工具教程会把整段 window 写死成一个普通对象碰到什么返回什么。这种方式在小脚本里可行但一旦遇到层层嵌套的调用链漏掉一个属性就要反复试错。ali231 的源码采用的是“先建宿主对象、再覆写细节”的做法定义 window 作为全局对象把 document、navigator、location、canvas 等常见宿主先挂上再根据目标脚本需要逐步细化。为什么要这样设计因为多数混淆后的代码进入执行时第一件事就是访问宿主对象绕不开的内容比如navigator.userAgent、window.screen、document.createElement。如果你在第一步就没给到后面再用 hook 去补脚本已经进入别的分支了。先建壳再细化等于先把房子搭好再去装修家具。实际工程里我发现直接 mock 最大的问题是维护成本目标脚本每次升级变量名或属性名一变mock 数据立刻作废。而补环境框架把对象层次保持和真实浏览器一致脚本升级时多数情况下只需要改属性值不需要改结构。2.2 源码包的目录与入口典型的一级目录包含三块主框架、依赖源码、工具脚本。拿到依赖源码后我一般会先看主框架的入口文件确认它是支持直接执行还是需要先经过一步构建。操作步骤分四步走。第一步打开终端进入项目根目录cd ali231 ls -la这个案例的结构里会看到src/放主框架deps/放依赖源码utils/放抓取脚本和日志工具。先确认入口文件是不是src/index.js是的话直接进入下一步。第二步看主框架的引用方式const { createEnv } require(./src/env.js); const { proxyHandler } require(./src/handler.js);这里createEnv负责构造宿主对象proxyHandler负责拦截属性访问时的日志打印与补漏逻辑。主框架里这两者是核心没有它们后面的注入无从谈起。第三步在 node 环境里加载目标代码const fs require(fs); const vm require(vm); const code fs.readFileSync(./target.js, utf-8); vm.createContext(env); vm.runInContext(code, env); env.window env;这里要注意vm.createContext(env)把宿主对象变成一个独立的上下文避免污染主线程最后一行env.window env是补环境常见的写法因为很多脚本直接访问window而非globalThis如果不把自身引用挂成 window会报找不到变量。第四步运行工具脚本抓取报错记录node utils/trace.js log.txt打开log.txt凡是缺对象的报错都会被记录作为下一步补齐的线索。整套流程走下来补环境框架就跑通了。有人会问为什么不直接在浏览器开发者工具里跑因为那会暴露自动化特征而且目标代码里往往有反调试逻辑隔离环境可控性更好。2.3 宿主对象与全局变量的映射策略补环境有一个容易被忽略的设计点宿主对象与全局变量的映射关系。真实浏览器里window是顶层的全局对象但在补环境里env自身要同时扮演globalThis和window两个角色。ali231 源码的做法是const env {}; env.window env; env.self env; env.globalThis env;这一行代码直接决定了脚本里三种写法都能被识别window.screen、self.screen、globalThis.screen。很多补环境脚本只挂了前两个遇到globalThis就失效。这里的参数含义不复杂但它决定了兼容面的宽度。做补环境时不要只盯着目标脚本当前用到的引用方式要把常见宿主引用都挂上一次省得后续反复补。映射做完之后还有一个优先级问题。目标代码里如果出现window.screen.width而补环境定义的是screen: { width: 1920 }脚本运行时先访问 window再访问 screen如果 window 里没有这个属性就会被 Proxy 拦截。ali231 的主框架在这块用的是 eslint 风格的分层window 这一层是代理层实际数据层放在 env 内部私有字段。这样设计的好处是代理层可以在属性不存在时抛出记录而数据层保持干净不被运行时污染。3. 环境代码的骨架注入、代理与差异化指纹补环境的核心不在于堆属性而在于代理逻辑。属性堆多了检测方会按属性存在性与属性描述符来筛代理逻辑写好了很多检测点在未触及的时候自动返回 undefined反而与真实环境更接近。这一章把骨架拆开讲清楚。3.1 代理层的设计get 与 set 的双向控制一个合格的补环境代理得同时管住读取和写入两条路。读取时返回伪造的数据写入时判断目标属性的可写性避免出现“写入成功但读出来没变”的矛盾状态。ali231 的 handler 文件里有一段典型的代理配置const handler { get(target, prop, receiver) { if (prop navigator) return target.navigator; if (prop document) return target.document; if (prop in target) { return Reflect.get(target, prop, receiver); } logMissing(prop); return undefined; }, set(target, prop, value) { const desc Object.getOwnPropertyDescriptor(target, prop); if (desc !desc.writable) { return false; } target[prop] value; return true; } };补环境里的 get 拦截是主入口每一条缺失属性都会走logMissing这一条是关键开发期靠它定位缺失项上线前也可以直接关掉日志以免产生外部特征。set 拦截主要看描述符真实浏览器里navigator很多属性是只读的如果脚本试图给navigator.userAgent赋值但没有被拦截那这个环境就会暴露出可写性特征。参数层面prop in target是一个精确的判断它要求属性必须真实存在才返回数据。有人会图省事直接写成return target[prop]那样做会让 undefined 属性也走数据层丢失缺失记录。还有一点handler 里的receiver参数不能随便返回别的对象否则在属性访问链上会出现跨对象错位有时表现为“读到的值是对的但对象本身不对”。3.2 浏览器内置对象的分层补全补到后面你会发现window、navigator、document 只是第一层。真正决定检测结果的是更深层的对象比如navigator.plugins、document.querySelector、window.CSS。ali231 的分层方式比较直观按对象深度分三层补充。第一层顶层对象包括 window、document、navigator、location、history 这几个必须在初始环境里就存在。第二层方法级对象例如document.createElement、canvas.getContext、localStorage.getItem这些是目标脚本运行时动态调用的。第三层描述符级细节包括属性的enumerable、configurable、writable是否和真实浏览器一致以及toString方法返回的结果是否匹配原生函数特征。const localStorage { getItem(key) { return store[key] || null; }, setItem(key, val) { store[key] String(val); }, removeItem(key) { delete store[key]; }, key(index) { return Object.keys(store)[index] || null; }, get length() { return Object.keys(store).length; } }; Object.defineProperty(localStorage, getItem, { enumerable: false, configurable: false, writable: false });这个写法揭示了补环境的进阶思维方法要可用同时属性描述符也要禁改。真实现场里检测方会用Object.getOwnPropertyDescriptor(window.localStorage, getItem)来查看方法是否可枚举如果返回 undefined 或可枚举为 true就直接判定为伪造环境。所以补环境不只是补值和补函数还要补描述符。ali231 在这一点上做得比较完善入口处统一封装了defineHidden方法将所有方法默认设为不可枚举。3.3 目标脚本的差异化指纹配置指纹是绕不开的一个环节。补环境做得再全如果webdriver属性与正常浏览器不一致或者userAgent与实际操作系统对不上就前功尽弃。这部分的差异化配置需要单独拉出来看。const fingerprint { 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, zh, en], webdriver: false, deviceMemory: 8, hardwareConcurrency: 8, screen: { width: 1920, height: 1080, availWidth: 1920, availHeight: 1040, colorDepth: 24 } };UA 和平台必须配套Windows 内核对上Win32语言列表第一个值要与头部的 Accept-Language 呼应。deviceMemory 和 hardwareConcurrency 的值要匹配一个 8 核机上出现 2GB 内存比较违和。这些值打点分布在各个页面里只改其中一个就容易串味。更重要的是动态与静态的组合。完全固定的指纹会在大规模并发时被聚类识别ali231 源码里也预留了随机偏移的入口。比如在合理范围内把 deviceMemory 设为 4 或 8硬件并发数为 4 或 8浏览器窗口尺寸在 1366x768 到 1920x1080 之间浮动。这些差异化参数通常在 config 目录里统一维护运行时按一定概率选择一组避免单一指纹长时间不变。4. 补环境的四个边界坑少了哪一步都会在检测时翻车补环境调试到一定阶段你会发现报错越来越少但通过率并不提升。问题往往不在数量而在细节。这里有几个反复出现的坑值得花时间专门整理。4.1 现象脚本不报错但运行结果总是空对象很多次在本地调试时目标脚本不报错console 里日志也能打出来但关键接口返回的数据是空对象。逐行定位后发现目标脚本在运行过程中动态给window.fetch赋了新函数而这个函数没有被正确代理导致结果汇总到了内部字段上没有同步到外部。原因set 拦截对fetch这类属性判断为可写写入成功但 get 拦截读取时走的是 original 对象数据读不到。解决把所有需要动态改写的属性集中列出来在 set 之后同步到 get 的读取源里。ali231 中有一个syncKey方法function setValue(target, prop, value) { if (!(prop in target)) { Object.defineProperty(target, prop, { value, writable: true, enumerable: true, configurable: true }); } else { target[prop] value; } }核心逻辑是不存在时才用 defineProperty 新建已存在的直接赋值。这样能避免 defineProperty 在重复设置时抛错。4.2 现象toString检测必挂目标脚本执行到特定函数时调用fn.toString()来核实函数源码结果补环境里返回的是[object Object]与预期不符整个流程直接终止。原因伪造的方法不是真正的原生函数Function.prototype.toString.call(fn)返回的是自定义实现代码。解决对暴露出来的方法统一覆写 toStringconst fakeFn function() {}; fakeFn.toString function() { return function getItem() { [native code] }; };实际工作里要把这一条包装成工具函数不能手写。手写最大的问题是函数名与返回字符串里的名字不一致一比对就露馅。ali231 的utils/native.js里有一个现成的mockNative(name)使用方式为const getItem mockNative(getItem);它会自动生成对应名称的函数并挂上原生 toString 结果。这里有一个值得注意的边界对应关系必须是目标脚本真实使用的函数名称不能凭空生成否则调用栈对不上。4.3 现象绕过第一步检测卡在第二步的原型链第一层环境检测通过了但脚本继续调用navigator.plugins的原型链方法比如plugins.refresh()。补环境里没有这个方法脚本崩溃。原因真实浏览器中PluginArray和MimeTypeArray自带一组原型方法常规做法只用了一个简单的数组或对象来替代丢失了原型方法。解决class PluginArray { refresh() { return undefined; } item(index) { return null; } namedItem(name) { return null; } } const plugins new PluginArray(); plugins[0] { name: PDF Viewer, filename: internal-pdf-viewer, description: Portable Document Format };真实环境里的plugins既是一个数组形态又包含refresh方法。补环境要把这两个形态同时做出来数字索引和length属性由数组提供原型方法由类提供。ali231 在这个位置做得偏细不仅做了类还把length设置成了只读属性防止脚本通过修改length来干扰检测结果。4.4 现象属性补齐后浏览器特征还是对不上设备指纹类的属性全补上之后目标脚本访问document.hidden返回 undefined导致页面可见性判断逻辑错乱。补环境返回 false但实际运行环境认为页面不可见后续逻辑分支完全不同。原因这些属性不是简单数据而是一个状态。它们需要根据检测时的上下文动态返回不能写死。解决在 get 拦截里动态判断根据当前上下文返回不同值。常见做法是get(target, prop) { if (prop hidden) return false; if (prop visibilityState) return visible; }动态判断并不是说要把所有属性都做成函数式返回。关键是区分哪些是恒定值如userAgent哪些是状态值如document.readyState、document.hidden。恒定值提前写好状态值走运行时返回混合处理才能和真实环境拉开差距。5. 验证结果用目标脚本的固定输出给改造结果作判定补环境改到哪一步算合理不能凭感觉。需要建立一个稳定的验证路径用固定输入跑出固定输出再来判定当前环境是否合格。这一章给出一个可落地的验证闭环。5.1 构建一个最小化检测脚本集拿目标脚本去跑当然最直接但它流程长、耗时高不适合每一轮改动后的快速回归。更好的做法是自建一个最小化检测脚本覆盖补过的每个关键点const checks { ua: navigator.userAgent.includes(Chrome), platform: navigator.platform Win32, plugins: typeof navigator.plugins.refresh function, webdriver: navigator.webdriver false, language: navigator.languages[0] zh-CN, hidden: document.hidden false, pluginsLen: navigator.plugins.length 0, nativeToString: navigator.plugins.refresh.toString().includes([native code]) }; const failed Object.entries(checks).filter(([k, v]) !v); if (failed.length) { console.log([FAIL], failed.map(([k]) k).join(, )); } else { console.log([PASS] all checks done); }把这段代码写在一个独立的verify.js里每次改动补环境源码后执行一次就能快速暴露哪个点挂掉了。实际推荐将该脚本放到工具目录里与主框架解耦更新源码时不至于连验证脚本一起打进去。5.2 多轮验证与回归记录执行一次不能证明环境稳定尤其是做了指纹随机化之后需要跑多轮来验证结果分布。我一般会挂一个简单的循环跑 20 次并统计通过率for i in {1..20}; do node verify.js | grep -c PASS; done单轮全过一次并不能说明问题环境随机化会带来偶发失败。如果 20 次中失败率超过 5%说明某个指纹参数存在矛盾需要回去查差异化配置。这里有两个值得注意的观察点。其一失败集中出现在同一个检测点时大概率是补环境内部属性写死不随指纹变化。比如 deviceMemory 随机成 4但 hardwareConcurrency 固定成 8两个参数在浏览器里存在隐性对应关系检测方会拿它们做关联比对。其二某些失败一次后恢复正常这类随机性问题通常来自描述符不一致比如某次设置了不可写属性下一次又被赋值覆盖了。把回归记录保留下来连续看 3 轮结果比单次错误日志更有说服力。5.3 与目标脚本的联调验证最小检测脚本过了 20 轮之后才会进入目标脚本联调阶段。联调不是为了跑通而是为了拿到正确输出。对目标脚本先输入一组固定参数记录输出结果改完补环境之后再用同一组参数去跑对比输出是否一致。这个阶段最需要关注的不是报错而是输出差异。输出差异分为两类。一类是数值差异如某个接口返回时间戳变了这类通常是环境里的Date.now被覆写导致的。遇到这种问题检查补环境中是否暴露了时间相关的 mock有则去掉让它取系统真实时间。另一类是对象结构差异比如返回的字段从对象变数组或 null这类多数是某个属性在 get 拦截里被提前返回了 undefined导致后续处理分支没走通。联调时建议把目标脚本的 console 输出重定向到文件方便对比前后两次结果node run.js output1.json node run.js output2.json diff output1.json output2.json这两份输出文件的差异点就是补环境改动造成的影响面。影响面控制在一个字段以内说明骨架稳定影响面超过五个字段就要考虑是不是代理层引入了副作用不该动的属性被动到了。6. 把环境谱系复制成自己的环境池版本控制与批量管理技巧补环境源码不是一份改完就完的东西它会跟着目标脚本一起升级。继续沿用单文件不断修改的方式早晚会出问题。更好的做法是把每轮改过的、验证通过的环境固化成独立版本放进一个环境池里。我的习惯是给每个环境版本命名时带上特征标签例如env_win_chrome120、env_mac_safari17目录内同时存放该版本的主框架、指纹配置和验证日志。当目标脚本升级导致某个环境失效时不直接改原版而是复制出一个新目录再改保持旧版可回滚。这么做的好处是每个版本的状态都清晰怎么改的、改了哪几个属性、验证结果如何全都在同一份变更记录里排查时不用重新回忆。指纹随机化放到环境池这个层面来做会更省心。我不在单份环境里做随机偏移而是按固定组合预生成多份环境每次发起新任务时随机选一份。这样每一份环境自身是稳定、自洽的不会出现同一份环境内参数互相打架的情况。从那以后我每次拿到新的补环境源码都会先强制走一遍环境池化流程拆分依赖、配置指纹、建立验证脚本、固化基线、再开新副本。整个过程下来维护成本下降了很多。希望帮到你。本文还有配套的精品资源点击获取
返回列表