接手过前端加密的都知道,真正卡住进度的往往不是算法本身,而是你千辛万苦把代码抠出来后,在本地Node.js里一运行,直接甩给你一个navigator is not defined。这个报错背后的含义是:那段加密函数根本不是“纯算法”,它依赖了一整套浏览器运行时环境。我在分析234算法时就被这个问题卡了两个晚上,后来彻底想明白了一个事——与其去跟混淆后的代码硬碰硬,不如直接给它在Node里“补”一个能骗过检测的浏览器环境。这篇文章就围绕234算法逆向中的补环境思路展开,把我完整的分析过程、踩坑记录和可复用的实操套路整理出来,希望能帮你省下一些试错时间。
补环境这件事,说穿了就是四个字:环境自洽。你不需要把整个浏览器内核搬过来,只需要搞清楚目标算法依赖了哪些全局对象、哪些原型链特征,然后精准地补齐这些依赖。这篇文章适合正在做爬虫逆向、摸不透加密逻辑、或者对“原型链补环境”这个词只听过没实操过的朋友。本文不会涉及任何具体平台的破解操作,所有示例均基于通用前端加密分析场景,聚焦方法和原理。
1. 为什么234算法不能脱离浏览器裸奔:先看懂环境断层的根因
1.1 一个典型的报错现场
先还原一下我最初遇到的情况。把从压缩代码里格式化出来的234算法函数搬到本地,用Node跑,第一行就挂了:
bash复制ReferenceError: window is not defined
当时我还觉得奇怪,一个加密算法为什么要用到window?后来陆续把window、document、navigator这些变量全部声明成空对象后再跑,结果又冒出TypeError: Cannot read properties of undefined (reading 'userAgent')。这种报错就像剥洋葱,剥一层哭一层。
根本原因在于:234算法这一类前端加密逻辑,并不是单纯在算一个哈希或做一次加解密,它会采集运行时环境信息,把这些特征作为密钥种子、混淆因子或者签名内容的一部分。所以只要你运行的环境少了一个它要读取的属性,算法结果就和浏览器里的真值不一致,甚至直接抛异常。
这类函数在浏览器里能跑,是因为浏览器天生提供了完整的宿主环境;到了Node.js里,这些宿主对象统统不存在,这就是“环境断层”。补环境的本质,就是在Node进程里用纯JavaScript模拟出一个“假浏览器”,让算法以为自己在Chrome里面运行。
1.2 环境到底在检测什么
先说清楚234算法这类逻辑通常会读取哪些东西,这决定了补环境的覆盖面。
- 全局对象本身:
window、globalThis、self、top、parent。有些代码会直接判断typeof window === 'undefined'来检测是否处于浏览器环境。 - BOM对象:
navigator、location、history、screen。其中navigator的userAgent、platform、language、plugins、webdriver都是高频检测点。 - DOM对象:
document。包括document.createElement、document.cookie、document.documentElement、document.querySelector等。 - 存储对象:
localStorage、sessionStorage,注意这两个还有getItem、setItem、removeItem、clear方法。 - 计时器与事件:
setTimeout、setInterval、requestAnimationFrame、addEventListener、removeEventListener。 - Canvas与图像:
HTMLCanvasElement.prototype.toDataURL、canvas.getContext。 - WebGL:
WebGLRenderingContext相关属性和方法。 - Audio:
AudioContext、OfflineAudioContext,常用于生成音频指纹。 - CSS与样式计算:
getComputedStyle、CSSStyleDeclaration。
你不需要一开始就全部补上,但心里要有这份清单。补环境的顺序永远是“报错驱动、检测驱动”,不要凭感觉。
1.3 两类方案的区别:纯算法还原与补环境
在做234算法逆向的时候,摆在你面前有两条路。
第一条路:纯算法还原。 把混淆代码一点点readable化,跟进每一个函数调用,搞清楚每一段逻辑在算什么,最后用Python或者干净的JavaScript重新实现一套。这个方案的优点是执行环境干净,不需要维护一堆模拟对象;缺点是耗时巨大,而且遇到控制流平坦化、字符串加密、花指令这些混淆手段时,还原成本会指数级上升。
第二条路:补环境运行。 保留原始代码不动,只给它提供一个模拟的浏览器宿主环境,让原始算法在Node里原样执行,直接调用出结果。这个方案的优点是不需要完全读懂算法内部的每一行,只要环境补得够完整,函数输出就能和浏览器保持一致;缺点是需要维护环境代码,并且碰到深度检测环境合法性的算法时,需要频繁修补。
我当时选的是第二条路。原因是234算法内部大量的分支都是基于环境信息生成的,比如某个分支会读取navigator.connection.downlink动态调整签名内的某个参数。如果纯还原,我得先模拟一个网络环境模型;而补环境,我只需要把navigator.connection这个对象结构造出来,算法自己就会走和浏览器一致的分支。这也是为什么补环境在一线逆向实操中越来越流行。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 补环境之前的侦察工作:把检测点一个个找出来
2.1 入口函数与依赖调用链分析
拿到一段混淆后的234算法代码,别急着补环境,先做侦察。
第一步是找到入口函数。一般从页面里Hook目标方法,看调用栈里先进哪个函数。比如目标是某个签名,那就HookXMLHttpRequest.prototype.send,在参数里抓到完整URL后,逆向追到生成签名的函数位置。也可以在混淆代码里搜window、document、navigator这些关键字的出现频率,出现次数最多的函数段一定是环境依赖的重灾区。
第二步是确认入口函数接收的参数和返回值。用Chrome开发者工具在加密函数调用处打上条件断点,采集一组真实入参和出参。这组数据是你后面验证补环境是否成功的关键凭证——本地函数算出来的结果必须能和浏览器里的结果对得上。
注意:拿到真值样本要保留多组,最好覆盖不同参数组合和不同时间点。后面调环境的时候,任何一处环境属性补得不对,都会导致输出结果不一致,多组样本能帮你快速定位是哪一类检测出了问题。
第三步是顺着调用链把依赖摸清楚。如果代码是压缩过的,可以在格式化后用IDE的引用查找功能,点击window、document等全局标识符,看它们分别被哪些函数读取了。如果是混淆过的,就用动态Hook的方式在运行时收集访问情况。
2.2 对象检测、属性检测与方法检测的分类
补环境不是无脑堆代码,先要对检测点分个类。我在分析234算法时把检测分为三个层级。
| 检测类型 | 典型特征 | 应对策略 |
|---|---|---|
| 对象存在性检测 | typeof window === 'undefined'、window.a === undefined |
让对象存在,且类型正确 |
| 属性值检测 | navigator.userAgent.indexOf('Chrome') > -1、screen.width === 1920 |
让属性值符合目标浏览器特征 |
| 方法行为检测 | Object.prototype.toString.call(window)、document.createElement('canvas').getContext('2d') |
不仅要有方法,方法的返回结果也要逼真 |
最容易栽跟头的是第三类。很多初学者把window声明成一个普通对象就以为完事了,结果算法里一句Object.prototype.toString.call(window)直接暴露——普通对象的toString结果是[object Object],而浏览器的window是[object Window]。这个差异肉眼很难发现,但算法内部可能就用这个特征来区分真假浏览器。
2.3 通过堆栈日志快速定位第一处环境报错
当代码报错时,不要只盯着错误信息,要把完整的调用堆栈打出来,顺着堆栈往上找第一个访问环境属性的函数。
我在本地调试的时候,会在Node入口处做一次全局Hook,把navigator、window、document这些标识符用Proxy包装一层,任何属性读取都会打印一条日志:
javascript复制const handler = {
get(target, prop, receiver) {
const stack = new Error().stack.split('\n');
console.log(`[Access] ${prop} -> ${stack[1]}`);
return Reflect.get(target, prop, receiver);
}
};
global.navigator = new Proxy(global.navigator || {}, handler);
跑一次目标算法,日志会瞬间刷屏,这时候你要做的事情很简单:从上往下看第一条报错之前最后被访问的属性,那就是环境断层的第一个缺口。把缺口补上,再跑一次,收集下一轮日志。用这种“报错驱动”的方式,最多循环十几轮,基础环境就搭起来了。
3. 补环境的核心实操:手把手把假浏览器环境搭起来
3.1 工具链选择:Node脚本、vm2还是浏览器插件
补环境的载体有好几种,我分别评估过,这里直接说结论。
- 纯Node脚本:直接在Node.js的
global对象上挂载浏览器环境,然后用eval或Function执行目标代码。优点是简单直观、调试方便;缺点是目标代码里的window、document直接引用全局变量,污染了全局命名空间,多段代码同时跑容易冲突。 - Node vm模块:把目标代码放进一个独立的
vm.createContext沙箱里执行,沙箱里预置假浏览器对象。优点是隔离性好,目标代码里的全局对象不会污染外部环境;缺点是部分原生API(如CanvasRenderingContext2D)在vm沙箱里拿不到纯粹的原生实现,需要额外处理。 - Puppeteer/浏览器插件注入:直接把代码放到真实浏览器里跑,环境检测几乎不会出问题。但如果你要批量调用算法,起一个浏览器实例的成本比较高,不利于生产环境集成。
我最终用的是vm模块 + 手动构造环境对象的组合。232算法这种场景下,既需要沙箱隔离,又需要精细控制每一个环境属性,vm模块是性价比最高的选择。
3.2 基础环境骨架代码
下面给出一份可以直接用的补环境骨架,这份代码以Chrome 120为模拟目标,覆盖了最常见的检测点。
javascript复制const vm = require('vm');
// 伪造userAgent
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
// 构造navigator
const navigator = {
userAgent: UA,
appVersion: UA.replace('Mozilla/', ''),
platform: 'Win32',
language: 'zh-CN',
languages: ['zh-CN', 'zh'],
cookieEnabled: true,
onLine: true,
webdriver: false,
hardwareConcurrency: 8,
maxTouchPoints: 0,
vendor: 'Google Inc.',
vendorSub: '',
productSub: '20030107',
appCodeName: 'Mozilla',
appName: 'Netscape',
deviceMemory: 8,
connection: {
effectiveType: '4g',
rtt: 50,
downlink: 1.45,
saveData: false
},
plugins: {
length: 5,
0: { name: 'PDF Viewer', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
1: { name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', description: 'Portable Document Format' },
2: { name: 'Chromium PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', description: 'Portable Document Format' },
3: { name: 'Microsoft Edge PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', description: 'Portable Document Format' },
4: { name: 'WebKit built-in PDF', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', description: 'Portable Document Format' }
},
mimeTypes: { length: 0 }
};
// 构造location
const location = {
href: 'https://example.com/page?id=123',
protocol: 'https:',
host: 'example.com',
hostname: 'example.com',
port: '',
pathname: '/page',
search: '?id=123',
hash: '',
origin: 'https://example.com'
};
// 构造document基础对象
const document = {
cookie: '',
referrer: '',
title: '',
readyState: 'complete',
visibilityState: 'visible',
hidden: false,
documentElement: {
nodeType: 1,
nodeName: 'HTML',
style: {}
},
createElement(tagName) {
if (tagName.toLowerCase() === 'canvas') {
return {
getContext() {
return {
measureText(text) {
return { width: text.length * 10 };
}
};
},
toDataURL() {
return 'data:image/png;base64,fake';
},
width: 300,
height: 150
};
}
return { style: {}, setAttribute() {}, getAttribute() { return null; } };
},
getElementById() { return null; },
querySelector() { return null; },
querySelectorAll() { return []; },
addEventListener() {},
removeEventListener() {},
createEvent() { return { initEvent() {} }; }
};
// 构造window对象(指向自身,保证循环引用一致)
const window = {};
window.window = window;
window.self = window;
window.top = window;
window.parent = window;
window.navigator = navigator;
window.location = location;
window.document = document;
window.innerWidth = 1920;
window.innerHeight = 937;
window.outerWidth = 1920;
window.outerHeight = 1040;
window.screenX = 0;
window.screenY = 0;
window.DevicePixelRatio = 1;
window.devicePixelRatio = 1;
window.addEventListener = () => {};
window.removeEventListener = () => {};
window.getComputedStyle = () => ({
position: 'static',
display: 'block'
});
window.localStorage = localStorageStub();
window.sessionStorage = localStorageStub();
window.setTimeout = setTimeout;
window.clearTimeout = clearTimeout;
window.setInterval = setInterval;
window.clearInterval = clearInterval;
window.requestAnimationFrame = (cb) => setTimeout(cb, 16);
function localStorageStub() {
const store = {};
return {
getItem(key) { return Object.prototype.hasOwnProperty.call(store, key) ? store[key] : null; },
setItem(key, value) { store[key] = String(value); },
removeItem(key) { delete store[key]; },
clear() { for (const key in store) delete store[key]; },
key(index) { return Object.keys(store)[index] || null; },
get length() { return Object.keys(store).length; }
};
}
// 创建沙箱上下文
const sandbox = {
window,
navigator,
document,
location,
localStorage: window.localStorage,
sessionStorage: window.sessionStorage,
screen: {
width: 1920,
height: 1080,
availWidth: 1920,
availHeight: 1040,
colorDepth: 24,
pixelDepth: 24
},
console,
setTimeout,
clearTimeout,
setInterval,
clearInterval,
Buffer,
process: undefined,
global: window
};
vm.createContext(sandbox);
// 在沙箱中运行目标算法
const code = `
// 这里放234算法原始代码
function getSignature() {
return navigator.userAgent + '|' + window.screen.width;
}
getSignature();
`;
const result = vm.runInContext(code, sandbox);
console.log(result); // 可以正常输出拼接后的结果
这份骨架已经能解决大部分基础检测。但是注意,process这里我故意设成了undefined——很多加密算法会检测typeof process来判断自己是否运行在Node环境,一旦发现process存在就直接终止执行。设成undefined后,typeof process的结果就是'undefined',和浏览器行为一致。
3.3 细化属性补齐:window、navigator、document、localStorage等
骨架只是第一步,实际跑234算法时还要按报错逐项细化。我总结几个高频坑位。
navigator相关。 navigator.plugins不能只给一个{length: 5},有些算法会遍历plugins[i].name,还会调用navigator.plugins.refresh()。navigator.mimeTypes同理。另外,navigator.webdriver这个属性在多数场景下会被检测是否为true,必须给false。如果算法用了navigator.permissions.query,你还需要提供一个返回{state: 'prompt'}的Promise。
document相关。 document.addEventListener不能省略,有些算法注册了事件监听并依赖事件触发后的回调。document.createElement必须能处理iframe、div、span、a等多种标签,返回值要有style对象、setAttribute方法和getAttribute方法。document.cookie能被赋值也能被读取,建议实现成真实的存取逻辑,因为算法可能会写入一个临时Cookie后再读出来。
window相关。 window.localStorage不能和window.sessionStorage指向同一个stub对象,两个存储是相互独立的。如果算法调用了window.open,你得给一个返回空对象的实现。window.postMessage、window.attachEvent这些老接口偶尔也会被检测到。
在补这些属性时,别忘了类型一致性。typeof window.screen.width必须是'number',typeof navigator.userAgent必须是'string'。如果算法内部做了严格类型判断,null和undefined的区别也会导致分支走向不同。
4. 原型链补环境的原理:为什么Object.prototype.toString这么关键
4.1 原型链被检测的原因
前面提到了Object.prototype.toString.call(window)返回[object Window]的例子。这是原型链补环境中最核心的一个知识点。
JavaScript的Object.prototype.toString并不是简单返回typeof结果,它内部会读取目标对象的Symbol.toStringTag属性,或者根据对象内部的[[Class]]标记来生成格式为[object 类型名]的字符串。浏览器内置对象每个都有自己的内部标记,而普通JavaScript对象统一是[object Object]。
所以在补环境时,如果你只是这样写:
javascript复制const window = {};
window.window = window;
那么Object.prototype.toString.call(window)的结果是[object Object],而真实浏览器返回的是[object Window]。这个差异就是算法识别假环境的突破口。
我见过更狠的检测是用Function.prototype.toString.call(window.navigator)来看navigator是否由原生代码实现,如果是模拟的普通对象,打印出来的是一段JavaScript函数源码,而原生对象会显示function () { [native code] }。
4.2 常见原型链挂载点
应对这些检测,我总结了一套常用修补方案。
javascript复制const vm = require('vm');
const sandbox = {
window: {},
navigator: {},
document: {}
};
// 通过Symbol.toStringTag改变toString结果
sandbox.window[Symbol.toStringTag] = 'Window';
sandbox.navigator[Symbol.toStringTag] = 'Navigator';
sandbox.document[Symbol.toStringTag] = 'HTMLDocument';
// 伪造原生函数toString
const nativeCode = 'function () { [native code] }';
const nativeFunction = function () {};
nativeFunction.toString = () => nativeCode;
sandbox.window.navigator = sandbox.navigator;
sandbox.document.createElement = nativeFunction;
Symbol.toStringTag是ES6引入的特性,允许你自定义对象在Object.prototype.toString.call时返回的标签。浏览器环境里,window对象的Symbol.toStringTag就是'Window',document是'HTMLDocument'。这个方法几乎可以解决所有基于toString的环境检测。
另外,Function.prototype.toString检测比较隐蔽。比如算法执行了navigator.plugins.refresh.toString(),如果返回内容不是function () { [native code] },就会判定为伪造。所以凡是模拟的“原生方法”,都建议手动覆写toString。
4.3 容易被忽略的动态检测
除了原型链静态特征,还有一些动态检测很容易被忽略。
时间差检测。真实浏览器环境中,Date.now()和performance.now()的时间差是连续的;如果你在Node里补环境时自己写了setTimeout的mock实现,每次回调的间隔抖动可能异常,算法可以通过多次计算时间差来识别。
调用栈检测。浏览器里执行new Error().stack,C++原生函数的帧不会出现在栈里。而在Node的vm沙箱里,原生函数帧和用户函数帧的分布可能和浏览器有差异。有些专门的反调试会解析当前堆栈中的函数名来检查是否有可疑的帧。
逻辑一致性检测。window.innerWidth和window.screen.width、window.outerWidth之间的数量关系要合理。如果你给innerWidth填了1920,但screen.width只有1024,多写几个交叉检测就能发现矛盾。
这些动态检测在大多数算法里不会全部出现,但一旦出现,排查成本很高。我的经验是:先用基础环境跑,等报错驱动你发现检测点时再去补,不要一开始就试图模拟所有动态特征。
5. 验证、调优与维护:补环境不是一次就行的事
5.1 用代理工具和日志验证环境是否自洽
补环境工作完成后,最重要的就是验证。验证分为两步。
第一步是结果一致性验证。把浏览器里采集到的真实样本输入本地环境,跑出来的结果必须和浏览器输出完全一致。如果出现不一致,先看是字符串拼接差异还是整体乱码。整体乱码通常是环境属性缺失导致某个分支完全没走;局部差一两个字符,往往是某个动态属性值(如时间戳、随机数)没有对齐。
第二步是行为一致性验证。除了拿到正确输出,你还要确认算法在执行过程中访问到的每一个环境属性都在可控范围内。可以通过一个全局Proxy来记录所有访问:
javascript复制const handler = {
get(target, prop, receiver) {
if (!(prop in target)) {
console.warn(`[Missing] ${prop} accessed on a mocked object`);
}
return Reflect.get(target, prop, receiver);
}
};
如果算法运行完成后,你的Proxy日志里一条[Missing]都没有,说明当前分支的环境检测都已经被覆盖了。这比单纯看输出结果要可靠得多,因为有些环境读取不参与最终输出,但会影响后续分支走向,不记录下来早晚要还债。
5.2 关于浏览器指纹检测的注意点
234算法这类前端签名生成逻辑,经常会叠加浏览器指纹。Canvas指纹、WebGL指纹、Audio指纹、字体指纹这四类是最常见的。
Canvas指纹的检测原理是:将一段固定文本绘制到Canvas上,然后toDataURL导出图片数据,不同浏览器渲染结果不同,生成的数据串也不同。模拟时,你不需要真的实现Canvas渲染,只要给toDataURL返回一个固定的data:image/png;base64,xxx,并且保证同一个环境每次返回相同值即可。算法内部通常是把返回值和某个常量做比对,而不是真的去解码图片。
WebGL指纹和Canvas类似,关键是让canvas.getContext('webgl')返回一个带有getParameter方法和若干常量属性的对象。返回的具体数值要参考真实Chrome里的值,网上有公开的WebGL参数列表可以对照。
Audio指纹是走OfflineAudioContext,先把一段正弦波渲染到缓冲区,再对缓冲区数据做哈希。模拟的时候可以直接在OfflineAudioContext.prototype.startRendering里返回一个伪造的AudioBuffer对象。这块相对冷门,很多算法不会用,但如果是做高防护级别的站点时遇到,就要盯上了。
这些方法都依赖于你对目标浏览器版本的真实特征有多了解。建议用一段辅助脚本在真实Chrome里采集一份
navigator、screen、canvas指纹数据,然后把它作为模板导入到补环境代码中。这比自己手写硬编码要省事得多,也更容易保持一致。
5.3 三个实战心得
最后分享三个我在补环境实操里攒下来的心得。
第一个心得:报错驱动优于全量补齐。 不要想着把环境堆到完美再跑算法。全量补齐的维护成本很高,而且你补的很多属性算法根本不会访问,纯粹浪费精力和执行时间。正确的路径是让算法跑起来,收集报错,补上缺口,再跑,再补。每轮迭代都目标明确,不迷路。
第二个心得:环境必须自洽。 这是比“能用”更高一层的要求。自洽意味着所有对象之间的引用关系、类型关系、数值关系都是相互匹配的。window.navigator必须就等于navigator,window.document必须就等于document,window.localStorage和sessionStorage必须各自独立。如果你只是把对象赋值一下,没有维护好引用一致性,后续很容易被深度的环境一致性检测抓住。
第三个心得:定期回归测试。 我自己有一个固定的测试用例集,里面存了几十组真实浏览器采集到的输入输出样本。每次修改补环境代码之后,都跑一遍回归测试,确保修补某个检测点时没有破坏环境里其他部分的完整性。算法一旦升级、检测逻辑一旦变化,回归测试能第一时间暴露问题,避免你在错误的假设上继续开发。
写在最后的实际操作体会
补环境这个技术方向,本质上是在和时间赛跑。算法方可以随时更换检测特征,而补环境方需要做的就是快速识别新特征、快速修补。我在做过多个项目后发现,最有效的策略不是把代码写得多么精美,而是建立起一套“发现问题—定位问题—修复问题”的快速响应机制。234算法只是这条路上的一块试金石,把它跑通了,后面遇到再复杂的加密逻辑,你心里都会有一个清晰的补环境框架兜底。
如果你正在做类似的前端逆向分析,我建议从一开始就把环境设计成独立模块,方便复用。一个随时可以拿出来跑的补环境工具箱,比临时拼凑的调试代码要可靠得多。等你在真实项目中验证过这套方法,会对“算法逆向”这件事有一种新的理解——很多时候,破解一道锁不一定要拆掉整个锁芯,有时候只需要配一把合适的钥匙。
