JS 逆向补环境:用 Node.js 骗过浏览器检测的完整方案

你把网站的加密 JS 扒下来,丢进 Node.js 一跑——报错了。document is not defined

补一行 global.document = {},再跑——又报错。navigator.userAgentundefined

一个一个补,补了十几个属性,终于不报错了,但返回的加密结果和浏览器里的对不上。

这就是"补环境"要解决的问题。

浏览器和 Node.js 的环境差异

先搞清楚为什么会报错。浏览器和 Node.js 都跑 V8 引擎,ECMAScript 标准对象(ArrayPromiseMap 这些)两边都有。但浏览器额外提供了一整套 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 里跑,documentundefined,走错误分支。加密结果自然对不上。

补环境的目标很明确:在 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.widthnavigator.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 === windowwindow.self === windowwindow.top === window(非 iframe 场景)。漏掉任何一个都可能被检测到。

时间相关的检测。有些代码会检查 performance.now()Date.now() 的执行时间差。如果你的环境里某些操作耗时异常(比如 Proxy 拦截导致的额外开销),可能会被识别。

什么时候该放弃补环境

补环境不是万能的。遇到以下情况,考虑换方案:

  • 目标代码做了 WebGL 指纹检测,模拟成本极高
  • 代码混淆程度太深,补了几十个属性还是对不上
  • 网站频繁更新加密逻辑,维护成本大于收益

这时候用 Puppeteer 或 Playwright 直接跑无头浏览器可能更划算。补环境的优势是速度快、资源占用低,但如果补的成本太高,就失去了意义。

选择哪种方案,取决于你的场景:高频调用选补环境,低频或复杂场景选无头浏览器。没有银弹,只有取舍。