JS 逆向补环境:用 Node.js 骗过浏览器检测的完整方案
详解 JS 逆向中的补环境技术,从 Proxy 代理到原型链模拟,附带环境检测框架和反爬实战代码,帮你在 Node.js 中跑通浏览器端加密逻辑。
你把网站的加密 JS 扒下来,丢进 Node.js 一跑——报错了。document is not defined。
补一行 global.document = {},再跑——又报错。navigator.userAgent 是 undefined。
一个一个补,补了十几个属性,终于不报错了,但返回的加密结果和浏览器里的对不上。
这就是"补环境"要解决的问题。
浏览器和 Node.js 的环境差异
先搞清楚为什么会报错。浏览器和 Node.js 都跑 V8 引擎,ECMAScript 标准对象(Array、Promise、Map 这些)两边都有。但浏览器额外提供了一整套 Web API:
| 对象 | 浏览器 | Node.js |
|---|---|---|
window | ✅ 全局对象 | ❌ 不存在 |
document | ✅ DOM 操作 | ❌ 不存在 |
navigator | ✅ 浏览器信息 | ❌ 不存在 |
location | ✅ URL 信息 | ❌ 不存在 |
XMLHttpRequest | ✅ 网络请求 | ❌ 不存在 |
网站的反爬代码就利用了这个差异。一段典型的环境检测:
function getToken() {
// 检测是否在真实浏览器中
var flag = document ? true : false;
if (flag) {
return realEncrypt(data); // 正确的加密逻辑
} else {
return fakeEncrypt(data); // 故意返回错误结果
}
}
在浏览器里跑,document 存在,走正确分支。在 Node.js 里跑,document 是 undefined,走错误分支。加密结果自然对不上。
补环境的目标很明确:在 Node.js 里伪造出足够真实的浏览器环境,让这些检测代码全部通过。
Proxy:补环境的核心武器
手动一个个补属性,效率太低,而且你不知道代码到底访问了哪些属性。ES6 的 Proxy 解决了这个问题——它能拦截对象上的所有操作,包括属性读取、赋值、删除、in 检查等。
const handler = {
get(target, prop, receiver) {
console.log(`[GET] ${prop}`);
return Reflect.get(target, prop, receiver);
},
set(target, prop, value, receiver) {
console.log(`[SET] ${prop} = ${value}`);
return Reflect.set(target, prop, value, receiver);
},
has(target, prop) {
console.log(`[HAS] ${prop}`);
return Reflect.has(target, prop);
}
};
const fakeDocument = new Proxy({}, handler);
global.document = fakeDocument;
现在把目标 JS 代码跑一遍,控制台会打印出它访问了 document 的哪些属性和方法。根据这些日志,你就知道该补什么了。
递归代理:处理嵌套对象
实际场景中,代码经常访问多层嵌套的属性,比如 window.screen.width 或 navigator.plugins.length。单层 Proxy 拦截不到深层访问,需要递归代理:
function deepProxy(obj, path = '') {
return new Proxy(obj, {
get(target, prop) {
const currentPath = path ? `${path}.${prop}` : String(prop);
// Symbol 属性直接返回,不代理
if (typeof prop === 'symbol') {
return target[prop];
}
console.log(`[GET] ${currentPath}`);
const value = target[prop];
// 如果值是对象,继续包一层代理
if (value !== null && typeof value === 'object') {
return deepProxy(value, currentPath);
}
return value;
}
});
}
// 用法
global.window = deepProxy({ screen: { width: 1920, height: 1080 } }, 'window');
跑一下目标代码,日志里会出现类似 [GET] window.screen.width 的输出,一目了然。
补环境的完整流程
实际操作分四步:定位代码 → 监控环境 → 补充环境 → 验证结果。
第一步:定位目标代码
用 Charles 或 Fiddler 抓包,找到生成加密参数的 JS 文件。在 Chrome DevTools 里打断点,确认关键函数的位置。这一步没什么捷径,就是耐心调试。
第二步:挂上环境监控
把目标 JS 代码复制到本地,在代码最前面插入监控脚本:
// env-monitor.js —— 放在目标代码之前执行
const accessed = new Set();
function monitor(name, obj) {
return new Proxy(obj, {
get(target, prop) {
if (typeof prop === 'string') {
accessed.add(`${name}.${prop}`);
}
return target[prop];
}
});
}
global.window = monitor('window', global);
global.document = monitor('document', {});
global.navigator = monitor('navigator', {});
global.location = monitor('location', {});
// 目标代码执行完后,打印所有被访问的属性
process.on('exit', () => {
console.log('被访问的属性:');
accessed.forEach(p => console.log(' ', p));
});
跑一遍,拿到属性列表。
第三步:逐个补充
根据监控结果,把缺失的属性和方法补上。这里给一个基础模板:
// env-supplement.js
global.window = global;
global.window.top = global;
global.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', 'en'],
onLine: true,
cookieEnabled: true,
// 按需补充更多属性
};
global.document = {
createElement(tagName) {
const el = {
tagName: tagName.toUpperCase(),
style: {},
childNodes: [],
appendChild(child) { this.childNodes.push(child); return child; },
setAttribute() {},
getAttribute() { return null; },
};
// canvas 元素需要特殊处理
if (tagName === 'canvas') {
el.getContext = () => ({
fillRect() {},
fillText() {},
measureText: () => ({ width: 0 }),
getImageData: () => ({ data: new Uint8Array(0) }),
});
el.toDataURL = () => 'data:image/png;base64,';
}
return el;
},
getElementById: () => null,
querySelector: () => null,
querySelectorAll: () => [],
addEventListener() {},
cookie: '',
};
global.location = {
href: 'https://target-site.com/page',
protocol: 'https:',
host: 'target-site.com',
hostname: 'target-site.com',
port: '',
pathname: '/page',
search: '',
hash: '',
};
注意 location.href 要填目标网站的真实 URL,很多加密逻辑会把 URL 作为参数参与计算。
第四步:验证结果
对比 Node.js 输出的加密结果和浏览器里的结果。如果一致,补环境就成功了。如果不一致,回到第二步,开更细粒度的监控,找出遗漏的属性。
// verify.js
require('./env-supplement.js');
const { encrypt } = require('./target-code.js');
const result = encrypt('test-data');
console.log('Node.js 结果:', result);
// 和浏览器里的结果对比
原型链:高级反爬的检测点
简单的属性检测好对付,但有些反爬代码会检查原型链。比如:
// 反爬代码可能这样检测
document.createElement('div') instanceof HTMLDivElement // 浏览器里是 true
你用普通对象 {} 模拟的 createElement 返回值,过不了 instanceof 检查。这时候需要模拟原型链:
// 构建原型链:EventTarget → Node → Element → HTMLElement → HTMLDivElement
function EventTarget() {}
EventTarget.prototype.addEventListener = function() {};
EventTarget.prototype.removeEventListener = function() {};
function Node() {}
Node.prototype = Object.create(EventTarget.prototype);
Node.prototype.constructor = Node;
Node.prototype.nodeType = 1;
function Element() {}
Element.prototype = Object.create(Node.prototype);
Element.prototype.constructor = Element;
Element.prototype.getAttribute = function() { return null; };
Element.prototype.setAttribute = function() {};
function HTMLElement() {}
HTMLElement.prototype = Object.create(Element.prototype);
HTMLElement.prototype.constructor = HTMLElement;
function HTMLDivElement() {}
HTMLDivElement.prototype = Object.create(HTMLElement.prototype);
HTMLDivElement.prototype.constructor = HTMLDivElement;
// 注册到全局
global.EventTarget = EventTarget;
global.HTMLElement = HTMLElement;
global.HTMLDivElement = HTMLDivElement;
// 改造 createElement
global.document.createElement = function(tagName) {
const tag = tagName.toLowerCase();
const constructors = {
div: HTMLDivElement,
span: HTMLElement,
a: HTMLElement,
// 按需扩展
};
const Ctor = constructors[tag] || HTMLElement;
const el = new Ctor();
el.tagName = tagName.toUpperCase();
el.style = {};
return el;
};
现在 document.createElement('div') instanceof HTMLDivElement 返回 true,和浏览器行为一致。
一个自动化检测框架
每次手动看日志太累。封装一个检测类,自动收集环境需求并生成补充代码的骨架:
class EnvDetector {
constructor() {
this.accessed = new Map(); // prop -> access count
}
wrap(name, obj = {}) {
const self = this;
const proxy = new Proxy(obj, {
get(target, prop) {
if (typeof prop === 'string') {
const key = `${name}.${prop}`;
self.accessed.set(key, (self.accessed.get(key) || 0) + 1);
}
const val = target[prop];
if (val !== null && val !== undefined && typeof val === 'object') {
return self.wrap(`${name}.${prop}`, val);
}
return val;
}
});
global[name] = proxy;
return proxy;
}
report() {
const sorted = [...this.accessed.entries()]
.sort((a, b) => b[1] - a[1]);
console.log('\n===== 环境访问报告 =====');
sorted.forEach(([key, count]) => {
console.log(` ${key} (${count}次)`);
});
}
generateStub() {
const objects = {};
this.accessed.forEach((_, key) => {
const [obj, ...rest] = key.split('.');
if (!objects[obj]) objects[obj] = [];
objects[obj].push(rest.join('.'));
});
let code = '// 自动生成的环境补充骨架\n';
Object.entries(objects).forEach(([obj, props]) => {
code += `global.${obj} = {\n`;
props.forEach(p => {
code += ` ${p}: undefined, // TODO: 填入正确的值\n`;
});
code += `};\n\n`;
});
return code;
}
}
// 用法
const detector = new EnvDetector();
detector.wrap('window', global);
detector.wrap('document');
detector.wrap('navigator');
detector.wrap('location');
// ... 执行目标代码 ...
detector.report();
console.log(detector.generateStub());
跑完之后,report() 告诉你哪些属性被访问了、访问了几次,generateStub() 直接生成补充代码的骨架,你只需要填值就行。
实战中容易踩的坑
补环境不是补完属性就万事大吉。几个常见的坑:
toString 检测。有些代码会检查函数的 toString() 输出。原生浏览器函数返回 function createElement() { [native code] },而你自己写的函数返回的是源码。解决办法:
function fakeNative(fn, name) {
const nativeStr = `function ${name || fn.name}() { [native code] }`;
fn.toString = () => nativeStr;
return fn;
}
document.createElement = fakeNative(function createElement(tag) {
// ... 你的实现
}, 'createElement');
canvas 指纹。很多反爬方案用 canvas 生成浏览器指纹。你需要让 canvas.toDataURL() 返回一个固定的、看起来合理的值,而不是空字符串。
window 自引用。浏览器里 window.window === window、window.self === window、window.top === window(非 iframe 场景)。漏掉任何一个都可能被检测到。
时间相关的检测。有些代码会检查 performance.now() 或 Date.now() 的执行时间差。如果你的环境里某些操作耗时异常(比如 Proxy 拦截导致的额外开销),可能会被识别。
什么时候该放弃补环境
补环境不是万能的。遇到以下情况,考虑换方案:
- 目标代码做了 WebGL 指纹检测,模拟成本极高
- 代码混淆程度太深,补了几十个属性还是对不上
- 网站频繁更新加密逻辑,维护成本大于收益
这时候用 Puppeteer 或 Playwright 直接跑无头浏览器可能更划算。补环境的优势是速度快、资源占用低,但如果补的成本太高,就失去了意义。
选择哪种方案,取决于你的场景:高频调用选补环境,低频或复杂场景选无头浏览器。没有银弹,只有取舍。