简介:在JS逆向工程中,请求签名是保护接口数据的关键屏障。常见的签名算法依赖浏览器宿主环境,典型如ali231模块,其压缩代码混入时间戳、随机数和盐值,纯算法还原成本高且难以应对版本升级。补环境方案利用Node.js与浏览器共享V8引擎的特性,通过构造可信的window、document、navigator等宿主对象,让原始JS在沙箱中直接执行,从而快速获得与真实环境一致的签名结果。该技术兼顾了执行速度和破解效率,适合高频签名生成、批量数据采集、接口签名分析等场景,还能通过双路校验固化为常驻签名服务。围绕ali231补环境的完整落地路径,详解最小模型、高频坑位与固化服务,帮助读者少走弯路。
1. 先搞懂:ali231 到底卡在哪一步
做 JS 逆向的人一旦碰到某电商平台 web 端那个_signature请求签名,基本都会和 ali231 打照面——这是前端加密模块里一个带版本标记的签名脚本,代码压缩得厉害,字符串拼接里混着时间戳、随机数和盐值,不同版本之间还经常互相套调用。硬啃算法还原,两三天都不一定能产出一个和浏览器一致的结果;换一条路,把浏览器宿主环境补进 Node,让原样代码直接执行,往往一个小时就能拿到完全一致的签名值。这就是补环境方案。这篇文章想讲清楚的是:ali231 这类签名模块为什么适合补环境、最小可用模型怎么写、跑通之后会遇到哪些坑,以及怎么把它固化成能长期维护的签名服务。适合做站点数据采集、接口签名分析、Web 端自动化脚本的从业者接着往下读。
2. 为什么补环境能绕开 ali231:先想清楚再动手
2.1 纯算法还原的痛点在哪里
常见做法是把 JS 丢进格式化工具,再按调用栈一行行读逻辑。这套路对简单 AES、简单 MD5 加盐还行,碰到 ali231 这种带随机串参与签名、盐值又在多个函数间传递的模块,问题立刻暴露出来:第一,压缩代码里的变量名全部是_0x3f2a之类的短名,还原成本极高;第二,签名结果依赖运行时的Date.now()和Math.random(),你就算读懂了拼接顺序,也搞不清随机串长度和参与时机;第三,平台升级一次版本号,你前面花两天做的还原工作直接作废。所以行业内更认可的做法,是把这种「运行时才产生结果」的逻辑交给 JS 自己跑,而不是人工翻译。
2.2 补环境的本质:把浏览器宿主“借”给 JS
补环境的概念不难理解。Node.js 和浏览器用的都是 V8 引擎,所以 JS 语法层面几乎一致;真正缺的是宿主环境——浏览器里的window、document、navigator、location这些对象在 Node 的全局里是不存在的。ali231 这类模块被设计成运行在浏览器环境里,中间会有大段代码去取document.readyState、判断navigator.platform、拼 UA 字符串。补环境要做的,就是用代码伪造一个足够可信的宿主层,让这段 JS 跑到这里时不报错、不露馅、正常完成签名计算。
关键点是不要用「枚举属性」的方式逐个手动补,因为前端代码取属性的路径太多太杂。更可靠的思路是建一个 Proxy,用它拦截属性的读取和存在性检查,把没定义到的属性用合理的方式兜住。下面是最小骨架:
const vm = require('node:vm'); const host = { navigator: { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36', platform: 'Win32', language: 'zh-CN', languages: ['zh-CN', 'zh', 'en'], webdriver: false, deviceMemory: 8, hardwareConcurrency: 8 }, location: { protocol: 'https:', hostname: 'detail.tmall.com', pathname: '/item.htm' } }; const fakeWindow = new Proxy(host, { has(target, prop) { // 让 "xx in window" 的检查尽量通过 if (prop === 'webdriver') return false; return true; }, get(target, prop, receiver) { if (prop in target) return Reflect.get(target, prop, receiver); if (prop === Symbol.for('nodejs.util.inspect.custom')) return undefined; // 兜底返回空对象,避免代码读到 undefined 后继续调用抛错 return {}; } }); sandbox.window = fakeWindow; sandbox.self = fakeWindow; sandbox.globalThis = fakeWindow;这段代码里has返回true属于双刃剑,后面避坑章会展开;get兜底返回空对象而不是函数,是为了先保证「不抛 ReferenceError」。这里只是把架子搭起来,真正的补全要跟着后续报错逐步迭代。
2.3 方案选型:算法还原、无头浏览器、补环境怎么选
拿到 al231 这类加密模块时,市面上可走的路径大致有三条。其一是纯算法还原,前面已分析过,成本高、抗变更差,只适合逻辑简单或变化缓慢的场景。其二是用无头浏览器模拟,真实跑出签名值,开发快但内存占用大,并发上来之后顶不住,而且被风控识别到无头特征时会直接拒绝服务。其三就是本文讲的补环境,它在 V8 层面直接执行原始 JS,速度和无头浏览器不在一个量级,签名结果与真实环境几乎一致。
三者对比如下:
| 方案 | 开发成本 | 执行速度 | 抗平台升级能力 | 适用场景 |
|---|---|---|---|---|
| 纯算法还原 | 高 | 最快 | 差,升级即重写 | 算法简单且稳定 |
| 无头浏览器 | 低 | 慢,内存大 | 一般 | 低频、小规模采集 |
| 补环境 | 中 | 快 | 较好,局部补丁即可 | 高频签名、批量请求 |
2.4 环境指纹:ali231 不只会读 UA
很多刚接触补环境的人有个误区,以为 UA 对了就能跑。实际 al231 这类模块往往在计算签名前会对宿主收一大圈「情报」:读navigator.plugins.length、取devicePixelRatio、检查performance.timeOrigin是否存在、调用canvas.toDataURL()生成指纹字符串,甚至看window.chrome对象里的属性数量。这些值本身可能不直接参与签名,但它们决定了某一段分支代码走还是不走。补环境时如果只补明文用到的属性,等真正发起网络请求时业务方收到的签名永远不对,排查起来最耗时间的就是这种隐性依赖。
3. 用 Node 跑通 ali231:最小补环境模型与签名核对
3.1 把网页里的签名模块完整抠下来
这一步还没到写代码的时候,但它是补环境的前提。在浏览器网络面板里找到生成_signature的 XMLHttpRequest 调用,顺着调用栈往上翻,定位到实际生成签名的 JS 文件。这类文件通常是 webpack 打包后的 chunk 文件,文件名带哈希,内容里能找到_signature或sign相关关键字。把文件保存到本地,再用任意 JS 格式化工具把压缩代码展开成可读结构。
保存下来的代码不要手工改逻辑,补环境的前提是「原样执行」。如果资源包里给的是还原后的版本,也建议先跑通原版,再对比还原版是否改变了行为,否则后续签名对不上时连是不是改动导致的都无法判断。
3.2 搭建补环境沙箱并注入宿主对象
常见做法是用 Node 内置的vm模块创建独立上下文,把宿主对象注入进去。这一步编写的环境初始化脚本是整个方案的地基:宿主对象不完整,后面每一步都可能踩坑;宿主对象补过头,又可能被环境检测判定为「太假」。下面是一个可运行的补环境框架示例:
const vm = require('node:vm'); const fs = require('node:fs'); const path = require('node:path'); const source = fs.readFileSync(path.resolve(__dirname, './ali231.js'), 'utf-8'); const sandbox = { location: { protocol: 'https:', hostname: 'detail.tmall.com', pathname: '/item.htm', search: '?id=123456' }, navigator: { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36', platform: 'Win32', language: 'zh-CN', languages: ['zh-CN', 'zh', 'en'], webdriver: false, deviceMemory: 8, hardwareConcurrency: 8, maxTouchPoints: 0, cookieEnabled: true, onLine: true }, document: { referrer: '', cookie: '', hidden: false, readyState: 'complete', title: '', documentElement: { lang: 'zh-CN' } }, performance: { timeOrigin: Date.now() - 2000, navigation: { type: 0 }, timing: { navigationStart: Date.now() - 2000 } }, screen: { width: 1920, height: 1080, availWidth: 1920, availHeight: 1040, pixelDepth: 24 }, setTimeout: setTimeout, clearTimeout: clearTimeout, setInterval: setInterval, clearInterval: clearInterval, console: console }; // window / self / globalThis 互相指向,这是浏览器里的关键关系 sandbox.window = sandbox; sandbox.self = sandbox; sandbox.globalThis = sandbox; vm.createContext(sandbox); try { vm.runInContext(source, sandbox, { filename: 'ali231.js' }); console.log('模块加载成功'); } catch (e) { console.error('加载失败,请按报错补齐宿主属性:', e.message); }这里有个很关键的设定:sandbox.window = sandbox。在浏览器里,window指向全局对象本身,全局声明的函数和变量会挂到window上;沙箱里让window指向sandbox后,被注入的 JS 代码里写window.aliSign = ...,执行完就能在sandbox.aliSign里取到同一个函数。setTimeout、setInterval这类宿主 API 必须手动传入,因为vm.createContext只提供 V8 内置对象,不提供 Node 和浏览器的 API。
3.3 捕获签名函数并和浏览器值比对
模块加载成功不代表签名可用,必须先做一次「同参比对」。在浏览器里抓一条真实请求的参数、请求头和生成的_signature,然后把同样的参数传给沙箱里的签名函数,看结果是否一致。下面按 al231 常见的暴露方式演示捕获和调用:
// 假设模块加载时执行了 window.sign = function(params) {...} // 那么现在 sandbox.sign 就是我们要用的函数 const testParams = { itemId: '123456', price: '99.00', action: 'getPrice', timestamp: 1713427200000 }; if (typeof sandbox.sign === 'function') { const localSign = sandbox.sign(testParams); console.log('本地签名:', localSign); // 下面这行替换成你在浏览器里抓到的真实签名 const browserSign = ''; if (localSign === browserSign) { console.log('签名一致,补环境跑通'); } else { console.log('签名不一致,检查时间和参与计算的附加参数'); } } else { console.error('sandbox 上没有 sign 方法,打印 sandbox 全部键排查暴露名'); }第一次跑通时最容易出现的误判是:签名值每次都不同,但浏览器里抓到的也每次都不同,因此无法直接判断是否一致。这种情况下,要在同一秒内发起浏览器请求和本地调用,并且保证传入的业务参数、UA、location.href完全一致;如果仍有偏差,优先怀疑时间戳和哈希结果依赖了未补上的某个指纹对象。比对通过后,再把这个函数用业务参数的格式包一层,后续请求直接调用即可。
3.4 必调参数:UA、时间戳与宿主名要对齐
观察一段时间会发现,al231 的签名结果和几个参数强相关:首先是与浏览器完全一致的userAgent,其次是document.cookie里的某些会话值,再有就是签名函数内部可能读取location.href来参与计算。这些参数的取值必须和抓包时浏览器里的状态一致,不能想当然地写死一个假值。时间戳方面,推荐拿到服务器响应头里的时间做校准,避免本机时间偏差导致签名过期或校验失败。
4. ali231 补环境的高频坑与排查清单
4.1 Illegal invocation:this 指向在 Node 里丢了
现象:沙箱里加载 JS 后第一次调用签名函数,抛TypeError: Illegal invocation。
原因:浏览器里很多方法被调用时依赖正确的this,比如navigator.userAgent的取值、window.alert的调用。补环境时如果把方法简单挂在普通对象上,JavaScript 引擎会因this指向不符合预期而拒绝执行。
解决:给这类方法包一层函数,调用时显式绑定宿主对象。常见做法是Object.defineProperty(navigator, 'userAgent', { get: () => 'Mozilla/5.0 ...' }),让 getter 内部返回字符串而不是依赖this。遇到Illegal invocation报错时,优先在网络面板里确认是哪一个 API,再单独处理。
4.2 本地签名和浏览器不一致:先核对时间盐与指纹参数
现象:本地能正常计算出签名值,但和同参数浏览器结果对不上,且每次都不一样。
原因:最常见的两个因素——签名内部用了Date.now()参与计算,而传入的测试参数仍是固定的旧时间戳;或签名计算时读取了performance.timing.navigationStart这类「页面加载时刻」的值,该值在补环境里初始化不准。
解决:把沙箱里的performance.timeOrigin设为模块加载时刻;签名函数调用前先拿服务器时间对齐本地时钟,再传入一致的timestamp。如果还不对,在签名计算入口处临时打印每次参与运算的字符串拼接结果,和浏览器端 hook 到的拼接结果逐段比对,能快速锁定差异发生在哪一段。
4.3 Proxy 兜底对象调不动:无法执行函数
现象:代码不再报undefined错误,但继续往下走到了调用环节,报xxx is not a function。
原因:这就是前面 2.2 节骨架里get兜底返回{}埋下的雷。JS 里很多调用链是「先取对象再调用」,例如document.getElementById(...).appendChild(...),如果getElementById被兜底成了空对象,执行到下一环必然出错。
解决:把兜底线从return {}改成返回一个可调用的函数,同时给函数挂上常用对象的占位方法。参考实现是:
function fakeObject() { return fakeObject.prototype; }更省事的做法是针对性补齐报错路径上的对象——毕竟 al231 的调用点有限,报一个错补一个,比设计万能兜底更可靠。优先保证代码能跑通,再考虑完整度。
4.4 给 window 添加属性后读写失效:赋值 API 绕过拦截
现象:在沙箱外部给sandbox.window添加了一个自定义属性,JS 内部读写都读不到;或者Object.defineProperty(window, 'xxx', ...)执行后立即读取仍为undefined。
原因:vm.createContext(sandbox)创建的是一个新的上下文,外部通过对象属性交互时,如果该属性被设成了不可写或不可配置,赋值会被静默丢弃。补环境代码里常用defineProperty设置 enumerable/configurable,设置不当就会出这类问题。
解决:保存沙箱定义属性时的 descriptor,逻辑上和浏览器原生行为对齐。一个保险习惯是:不要在加载 JS 之后再去改宿主对象的结构,所有属性初始化都放在vm.runInContext之前完成。若确实需要在运行中补属性,优先使用Object.defineProperty且明确设置writable: true, configurable: true。
4.5 webdriver 检测过了一个还有下一个:环境指纹要成体系
现象:签名一致,但真实请求发出去后返回风控错误,提示检测到自动化环境。
原因:现在的风控检测不会只查navigator.webdriver一个字段。它可能同时检查window.cdc_开头的隐藏属性是否存在、document.querySelector('[webdriver]')能否查到节点、chrome.loadTimes或chrome.csi是否可用、plugins.length是否为 0。补了前者漏了后者,照样被识别。
解决:对照真实浏览器生成一份「环境指纹清单」,逐项检查补环境沙箱里的值和真机是否一致。通常需要覆盖navigator、window.chrome、document的隐藏属性、plugins、permissions、languages、deviceMemory、hardwareConcurrency这几组。这里没有捷径,只能靠抓包和实验积累一份自己的检查表。
5. 把补环境固化成常驻签名服务:双路校验与回归监控
补环境脚本有个容易被忽略的点:它本质上是一段「需要先加载原始 JS 再调用函数」的程序,每次启动都要重新初始化沙箱,重复执行导致耗时翻倍。所以跑通之后我会立刻把它包装成一个 HTTP 服务,常驻后台,保证签名接口能在毫秒级返回。下面是基于 Node 原生http模块的最简实现:
const http = require('node:http'); function createSignServer(sandbox, port = 8080) { http.createServer((req, res) => { let raw = ''; req.on('data', (chunk) => (raw += chunk)); req.on('end', () => { try { const params = JSON.parse(raw); const sign = sandbox.sign(params); res.end(JSON.stringify({ ok: true, sign })); } catch (e) { res.end(JSON.stringify({ ok: false, error: e.message })); } }); }).listen(port, () => { console.log('签名服务已启动,端口', port); }); }服务起来之后,真正让这个方案长期可用的动作是加「双路校验」:每天从浏览器或移动端抓一条携带真实签名的请求数据,放进回归脚本里和本地服务计算的签名做一致性比对。平台升级算法、或环境检测点变化导致签名出现偏差时,回归脚本会在第一次出错的瞬间告警,不用等到线上批量请求全部失败才反应过来。
这个行为我坚持了很久,也踩过不少次「某天突然全挂」的坑。事后复盘发现大多是补环境少了一个新加的检测点,或者是盐值拼接方式有了微调,只靠人工观察很难提前察觉。所以不必怕补环境,怕的是补完就跑、再也不管。把初始化脚本、回归脚本和这份「环境指纹清单」当作一套小系统维护起来,al231 这类模块的逆向工作才算真正落地。也希望这套从原理到排错的思路,能帮你在自己的目标站点上少走几步弯路。
本文还有配套的精品资源,点击获取