☰
webpack打包下anti-content逆向:补环境与JS逆向实战
2026/9/29 13:57:40 网站建设 项目流程

简介:这份资源面向有一定 JavaScript 基础、希望入门 Webpack 打包与补环境逆向的开发者,聚焦拼多多 anti_content 安全机制的应对思路。包内共 2 个文件,包含 1 个 Python 脚本与 1 个 JavaScript 文件,压缩包约 40KB,体量轻便,便于快速阅读与本地调试。Python 脚本通常用于请求构造、参数生成或结果验证,JS 文件则承载核心的加密逻辑与补环境代码,两者配合可帮助读者理解 anti_content 的生成链路与运行依赖。资源围绕 Webpack 模块加载机制与浏览器环境补丁展开,涉及常见全局对象、DOM/BOM 模拟及模块导出定位等知识点,适合作为逆向学习的对照案例。目前已有 431 人学习,读者可借此梳理从模块拆解到环境补齐的完整分析路径,积累定位加密入口、还原调用栈与排错验证的实战经验,为后续同类平台的安全机制研究提供参考。

1. 从一次请求被拒说起:webpack 打包下的 anti-content 与补环境到底在解决什么

打开目标站点的页面,抓到一个携带anti-content的请求,把它原样丢进 Node 里跑,返回的却是空对象或者干脆报错——这是很多人接触 anti-content 时的第一个场景。anti-content 本质上是站点在客户端生成的一段校验内容,它依赖浏览器环境里的各种对象和方法,一旦脱离真实浏览器,代码就会因为找不到window、document、navigator这些全局对象而走不到正常分支。而 webpack 打包又给这件事加了一层:业务代码被拆成模块、包在函数闭包里,变量名被压缩,想直接读源码定位逻辑,得先把 webpack 的模块结构还原出来。

这篇要讲的就是这条链路:怎么从 webpack 打包产物里定位到生成 anti-content 的那段逻辑,怎么用补环境的方式让它在 Node 里跑起来,以及中间那些让人反复翻车的细节。适合已经在做 JS 逆向、被 webpack 混淆卡住、或者补环境补到一半发现某个属性死活对不上的同学。如果你只是想了解概念,这里可能偏实操;如果你手上正有一个跑不通的 anti-content,那接下来的步骤可以对着做。

2. webpack 打包结构还原:先看清模块长什么样

2.1 webpack 运行时与模块加载器长什么样

webpack 打包后的文件,最外层通常是一个立即执行函数,参数是一个模块对象或者模块数组。核心结构可以简化成下面这样:

(function (modules) { // 模块缓存 var installedModules = {}; // require 函数 function __webpack_require__(moduleId) { if (installedModules[moduleId]) { return installedModules[moduleId].exports; } var module = installedModules[moduleId] = { i: moduleId, l: false, exports: {} }; modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); module.l = true; return module.exports; } // 挂载一些属性 __webpack_require__.m = modules; __webpack_require__.c = installedModules; __webpack_require__.d = function (exports, name, getter) { ... }; __webpack_require__.r = function (exports) { ... }; // 入口 return __webpack_require__(__webpack_require__.s = 0); })({ 0: function (module, exports, __webpack_require__) { // 入口模块 }, 1: function (module, exports, __webpack_require__) { // 其他模块 } });

这段结构里,modules就是所有模块的集合,键是模块 ID,值是一个函数。__webpack_require__负责按 ID 加载模块,加载过的会缓存到installedModules。理解这个结构之后,你要找的 anti-content 逻辑,一定在某个模块函数里。

常见做法是先把整个文件格式化,然后在modules对象里搜索关键词,比如anti-content、antiContent、sign、encrypt这类字段名。如果代码被压缩得厉害,可以搜字符串常量,比如请求头里那个字段名本身。

2.2 定位生成 anti-content 的模块

定位模块有几个实用手段。第一个是搜索字符串常量,anti-content 这个字段名在代码里通常以字符串形式出现,搜到之后往上找最近的函数边界,基本就是目标模块。第二个是下断点,在浏览器里对XMLHttpRequest.prototype.setRequestHeader或者fetch做 hook,打印调用栈,看 anti-content 是从哪个函数传进来的。

// 在浏览器控制台注入,拦截请求头设置 const originalSet = XMLHttpRequest.prototype.setRequestHeader; XMLHttpRequest.prototype.setRequestHeader = function (key, value) { if (key.toLowerCase().includes('anti')) { console.log('anti-content 来源调用栈:'); console.trace(); console.log('值:', value); } return originalSet.apply(this, arguments); };

这段 hook 的作用是在设置请求头时打印调用栈,console.trace()会输出完整的函数调用链。参数说明:key是请求头名称,value是值。跑一遍页面操作,触发请求后看控制台,就能顺着调用栈找到生成逻辑所在的模块和函数。

找到模块后,把对应的模块函数单独抠出来,或者直接在原文件里给那个函数打断点,观察它依赖了哪些外部变量。这些外部变量,就是补环境时要补的对象。

2.3 把目标模块从闭包里抽出来单独跑

webpack 的模块都在闭包里,直接复制某个模块函数出来跑,会缺少__webpack_require__和其他模块的引用。有两种处理方式:一种是把整个 webpack 运行时和所有模块一起搬到 Node 里,让__webpack_require__正常工作;另一种是只抽目标模块,手动把它的依赖补齐。

第一种方式更稳,适合模块间依赖复杂的情况。做法是把整个打包文件读进来,在 Node 里构造一个假的window和document,然后执行这个文件,让 webpack 运行时跑起来,最后通过入口模块拿到导出。第二种方式适合依赖少的场景,把目标函数复制出来,缺什么补什么。

const fs = require('fs'); const vm = require('vm'); // 读取 webpack 打包文件 const code = fs.readFileSync('./target.js', 'utf8'); // 构造基础环境 const sandbox = { window: {}, document: {}, navigator: { userAgent: 'Mozilla/5.0 ...' }, location: { href: 'https://example.com' }, console: console, setTimeout: setTimeout, clearTimeout: clearTimeout }; sandbox.window = sandbox; // window 自引用 vm.createContext(sandbox); vm.runInContext(code, sandbox); // 如果入口模块挂载了全局变量,可以从 sandbox 里取 console.log(Object.keys(sandbox));

这段代码用 Node 的vm模块创建一个沙箱上下文,把 webpack 打包文件放进去执行。sandbox里预先放了window、document、navigator等基础对象,window自引用是为了让window.window也能访问到。执行完之后,如果代码把结果挂到了全局,就能从sandbox里读出来。参数上,userAgent要填和目标浏览器一致的字符串,location.href要和目标页面域名匹配,否则代码里的环境检测分支会走错。

3. 补环境的核心:让 Node 里的全局对象骗过检测

3.1 原型链补环境为什么比直接赋值更靠谱

很多人补环境的第一反应是window.xxx = yyy,但遇到toString检测就露馅。比如代码里会检查navigator.userAgent的toString结果,直接赋一个字符串,toString返回的是function toString() { [native code] }之外的东西,一比对就发现是伪造的。原型链补环境的思路是:不直接改实例属性,而是在原型上定义,让toString走原生逻辑。

// 不推荐:直接赋值,容易被 toString 检测识破 // navigator.userAgent = 'Mozilla/5.0 ...'; // 推荐:通过原型链定义 const Navigator = function () {}; Navigator.prototype.userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ...'; Navigator.prototype.platform = 'Win32'; Navigator.prototype.language = 'zh-CN'; const navigator = new Navigator(); Object.defineProperty(global, 'navigator', { value: navigator, writable: false, configurable: true });

这段代码定义了一个Navigator构造函数,把userAgent、platform等属性挂在原型上,然后实例化并挂到全局。这样navigator.userAgent访问的是原型上的属性,navigator.toString()走的是Object.prototype.toString,返回[object Object],比直接赋值更接近真实环境。Object.defineProperty的writable: false和configurable: true是为了模拟原生属性的不可写但可配置特性。

原型链补环境的关键在于:真实浏览器里,navigator、document、window这些对象的属性大多定义在原型上,而不是实例自身。检测代码如果用了Object.getOwnPropertyDescriptor或者hasOwnProperty,直接赋值就会暴露。所以补环境时,尽量用构造函数加原型的方式,而不是简单对象字面量。

3.2 document、window、location 的最小可用集合

补环境不需要把整个 DOM 都实现,只需要补目标代码实际用到的部分。但难点在于,你不知道它到底用了哪些。实用的做法是先跑一遍,看报错缺什么,再补什么。下面是一个最小集合的示例:

const document = { cookie: '', referrer: 'https://example.com/', title: 'example', createElement: function (tag) { return { tagName: tag.toUpperCase(), style: {}, setAttribute: function () {}, getAttribute: function () { return null; }, appendChild: function () {} }; }, getElementsByTagName: function () { return []; }, getElementById: function () { return null; }, addEventListener: function () {} }; const location = { href: 'https://example.com/path', protocol: 'https:', host: 'example.com', hostname: 'example.com', pathname: '/path', search: '', hash: '' }; const window = { document: document, location: location, navigator: navigator, screen: { width: 1920, height: 1080, availWidth: 1920, availHeight: 1040 }, innerWidth: 1920, innerHeight: 937, outerWidth: 1920, outerHeight: 1040, devicePixelRatio: 1, addEventListener: function () {}, setTimeout: setTimeout, setInterval: setInterval, clearTimeout: clearTimeout, clearInterval: clearInterval, btoa: function (str) { return Buffer.from(str, 'binary').toString('base64'); }, atob: function (str) { return Buffer.from(str, 'base64').toString('binary'); } }; window.window = window; window.self = window; window.top = window;

这段代码构造了document、location、window三个核心对象。document.cookie初始为空,因为 anti-content 可能会往里面写值再读。createElement返回一个带基本方法的假元素,够应付大多数检测。window里补了screen、innerWidth这些尺寸属性,因为有些检测会看窗口大小是否合理。btoa和atob用 Node 的Buffer实现,注意btoa要按二进制处理,否则中文会出错。

参数上,screen的宽高要和innerWidth、outerWidth保持逻辑一致,比如outerHeight通常大于innerHeight,差值在 100 左右。devicePixelRatio一般填 1 或 2。这些数值如果乱填,某些严格的检测会通过比例关系发现异常。

3.3 用 Proxy 拦截未定义的属性访问

补环境最头疼的是不知道还缺什么。用Proxy包一层,可以在访问未定义属性时打印出来,方便逐步补齐。

function createProxy(target, name) { return new Proxy(target, { get: function (obj, prop) { if (prop in obj) { return obj[prop]; } // 未定义的属性,打印出来并返回一个占位 console.log(`[缺失] ${name}.${String(prop)}`); return undefined; }, set: function (obj, prop, value) { obj[prop] = value; return true; } }); } const proxiedWindow = createProxy(window, 'window'); const proxiedDocument = createProxy(document, 'document'); const proxiedNavigator = createProxy(navigator, 'navigator');

这段代码用Proxy的get陷阱拦截属性访问,如果属性不存在就打印日志。跑一遍目标代码,控制台会列出所有缺失的属性,按需补上。注意Proxy会影响this指向和instanceof判断,有些检测会检查window instanceof Window,这时候需要额外处理。实际使用时,可以只在调试阶段开Proxy,补全后关掉,避免性能损耗和副作用。

4. anti-content 生成逻辑的还原与参数对齐

4.1 从调用栈反推输入参数

找到生成函数后,下一步是搞清楚它接收什么参数、返回什么。在浏览器里给那个函数下断点,触发请求,看调用时的实参。常见输入包括:当前时间戳、随机数、页面上的某些 DOM 值、cookie 里的某个字段、以及一个固定的密钥。把这些参数记下来,在 Node 里复现时按同样的顺序和格式传入。

// 假设定位到的生成函数签名是这样的 function generateAntiContent(a, b, c) { // a: 时间戳 // b: 随机字符串 // c: 从 cookie 里取的值 var result = ''; // ... 一系列运算 return result; } // 在 Node 里调用 const timestamp = Date.now(); const random = Math.random().toString(36).slice(2); const cookieVal = '从 document.cookie 里解析出来的值'; const antiContent = generateAntiContent(timestamp, random, cookieVal); console.log(antiContent);

这段代码演示了在 Node 里调用生成函数的方式。关键点在于参数格式要和浏览器里一致:时间戳是毫秒还是秒,随机字符串的长度和字符集,cookie 值的编码方式。如果浏览器里传的是Date.now(),Node 里也要用Date.now(),不要用new Date().getTime(),虽然结果一样,但某些检测会看调用方式。

如果生成函数依赖了this,比如是某个对象的方法,那在 Node 里调用时要保证this指向正确。可以用call或apply绑定。

4.2 时间戳、随机数与浏览器指纹的对齐

anti-content 里通常会混入时间戳和随机数,服务端校验时会检查时间偏差是否在合理范围内。Node 里的Date.now()和浏览器一致,问题不大。但随机数要注意:浏览器里可能用的是Math.random(),也可能用的是crypto.getRandomValues()。如果是后者,Node 里要用crypto.randomBytes()模拟。

const crypto = require('crypto'); // 模拟 crypto.getRandomValues function getRandomValues(array) { const bytes = crypto.randomBytes(array.length); for (let i = 0; i < array.length; i++) { array[i] = bytes[i]; } return array; } // 如果代码里用了 crypto.getRandomValues(new Uint8Array(16)) const arr = new Uint8Array(16); getRandomValues(arr); console.log(arr);

这段代码用 Node 的crypto.randomBytes生成随机字节,填充到Uint8Array里,模拟浏览器的crypto.getRandomValues。参数说明:array.length决定生成多少字节,crypto.randomBytes返回的是Buffer,逐字节复制到目标数组。如果代码里用的是crypto.getRandomValues(new Uint32Array(4)),那要注意字节序和元素大小的对应关系。

浏览器指纹方面,常见的有navigator.userAgent、navigator.platform、screen尺寸、时区、语言等。这些值要和真实浏览器保持一致,否则服务端可能通过指纹比对发现异常。时区可以用process.env.TZ设置,语言在navigator.language里补。

4.3 用日志对比法验证生成结果

补环境补到一定程度,生成函数能跑通了,但结果和服务端期望的是否一致,需要验证。实用的方法是:在浏览器里跑一遍,把输入参数和输出结果都打印出来;在 Node 里用同样的输入跑一遍,对比输出。如果输出不一致,就在生成函数内部逐步打日志,找到第一个出现差异的步骤。

// 在生成函数内部插入日志 function generateAntiContent(a, b, c) { console.log('输入:', a, b, c); var step1 = someOperation(a, b); console.log('step1:', step1); var step2 = anotherOperation(step1, c); console.log('step2:', step2); // ... return result; }

这段代码在生成函数的关键步骤插入console.log,把中间结果打印出来。浏览器和 Node 两边都跑,逐行对比。第一个不一致的地方,就是问题所在。常见差异来源:字符串编码(UTF-8 vs UTF-16)、数值精度(浮点数运算)、Math.random()的调用次数和顺序、以及某些依赖浏览器环境的隐式转换。

对比时要注意,浏览器控制台打印的对象可能是引用,展开后看到的是最终状态,而 Node 里打印的是当时的值。所以尽量打印原始值,比如JSON.stringify之后再打。

5. 避坑与排查:补环境路上最容易翻车的几个点

5.1 现象:代码在 Node 里跑不报错,但生成的 anti-content 服务端不认

原因:最常见的是环境检测分支走错了。代码里可能有if (typeof window !== 'undefined' && window.document)这样的判断,Node 里补了window和document,判断通过,但后续某个属性值不对,导致走了另一条生成路径。比如navigator.userAgent里包含了Node.js字样,或者screen.width是 0。

解决:在生成函数入口和关键分支打日志,确认走的是哪条路径。把navigator.userAgent改成完整的浏览器 UA,screen尺寸填合理值。另外检查document.cookie是否为空,有些逻辑会从 cookie 里取值参与运算。

5.2 现象:报错Cannot read property 'xxx' of undefined

原因:某个全局对象或属性没补。比如代码里用了window.performance.now(),但window里没补performance;或者用了document.createElement('canvas').getContext('2d'),但createElement返回的假元素没有getContext方法。

解决:用Proxy拦截未定义属性,把缺失的列出来。对于canvas,可以补一个getContext返回带fillText、getImageData等方法的假对象。如果代码真的依赖 canvas 渲染结果,那就需要引入canvas库在 Node 里模拟,或者直接跳过相关逻辑。

5.3 现象:toString检测不通过,提示[native code]缺失

原因:直接赋值的函数或属性,toString返回的是自定义函数的源码,而不是function xxx() { [native code] }。检测代码会比对toString结果里是否包含native code。

解决:用Object.defineProperty在原型上定义,或者用Function.prototype.toString的代理来伪造。更彻底的方式是用Proxy包装函数,拦截toString调用,返回原生格式的字符串。

const originalToString = Function.prototype.toString; Function.prototype.toString = new Proxy(originalToString, { apply: function (target, thisArg, args) { const result = target.apply(thisArg, args); if (result.includes('native code')) { return result; } // 对于自定义函数,返回伪造的原生格式 return 'function ' + (thisArg.name || '') + '() { [native code] }'; } });

这段代码代理了Function.prototype.toString,当调用结果不包含native code时,返回伪造的格式。注意thisArg.name可能为空,需要兜底。这种全局代理影响面大,建议只在补环境沙箱里用,不要污染 Node 主环境。

5.4 现象:时间戳或随机数导致每次结果不同,无法复现

原因:anti-content 里混入了时间戳和随机数,每次生成结果都不一样,导致无法通过对比输出定位问题。

解决:在调试阶段,把时间戳和随机数固定成常量。比如把Date.now()替换成固定值,把Math.random()替换成返回固定序列的函数。等逻辑调通后,再恢复真实值。

// 调试阶段固定随机数 let randomSeed = 0; Math.random = function () { randomSeed += 0.1; return randomSeed % 1; };

这段代码把Math.random替换成按固定步长递增的函数,保证每次调用返回可预测的值。注意这会影响所有依赖随机数的地方,调试完要恢复。

5.5 现象:webpack 模块间的循环依赖导致某个模块导出为空

原因:webpack 处理循环依赖时,如果模块 A 依赖 B,B 又依赖 A,B 在加载时 A 还没执行完,拿到的可能是空对象。补环境时如果只抽了部分模块,循环依赖关系被破坏,就会出现导出为空。

解决:尽量把整个 webpack 运行时和所有模块一起搬进 Node,保持原有的加载顺序。如果必须抽模块,手动调整依赖顺序,确保被依赖的模块先执行。

6. 进阶:把补环境做成可复用的沙箱框架

补环境补多了会发现,每次都在重复构造window、document、navigator。把这些封装成一个沙箱框架,下次遇到新的 anti-content,只需要改配置和补差异部分。我一般会按下面的结构组织:

// sandbox.js const vm = require('vm'); const fs = require('fs'); function createSandbox(options = {}) { const sandbox = {}; // 基础全局对象 sandbox.window = sandbox; sandbox.self = sandbox; sandbox.top = sandbox; sandbox.globalThis = sandbox; // navigator sandbox.navigator = { userAgent: options.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: options.platform || 'Win32', language: options.language || 'zh-CN', languages: options.languages || ['zh-CN', 'zh', 'en'], cookieEnabled: true, onLine: true }; // document sandbox.document = { cookie: options.cookie || '', referrer: options.referrer || 'https://example.com/', title: options.title || '', createElement: function (tag) { return { tagName: tag.toUpperCase(), style: {}, setAttribute: function () {}, getAttribute: function () { return null; }, appendChild: function () {}, getContext: function () { return { fillText: function () {}, getImageData: function () { return { data: [] }; }, fillRect: function () {}, drawImage: function () {} }; } }; }, getElementsByTagName: function () { return []; }, getElementById: function () { return null; }, addEventListener: function () {}, querySelector: function () { return null; }, querySelectorAll: function () { return []; } }; // location sandbox.location = { href: options.href || 'https://example.com/', protocol: 'https:', host: options.host || 'example.com', hostname: options.hostname || 'example.com', pathname: options.pathname || '/', search: options.search || '', hash: '' }; // screen sandbox.screen = { width: options.screenWidth || 1920, height: options.screenHeight || 1080, availWidth: options.screenWidth || 1920, availHeight: (options.screenHeight || 1080) - 40, colorDepth: 24, pixelDepth: 24 }; // 其他常用 sandbox.innerWidth = options.screenWidth || 1920; sandbox.innerHeight = (options.screenHeight || 1080) - 143; sandbox.outerWidth = options.screenWidth || 1920; sandbox.outerHeight = options.screenHeight || 1080; sandbox.devicePixelRatio = 1; // 定时器 sandbox.setTimeout = setTimeout; sandbox.setInterval = setInterval; sandbox.clearTimeout = clearTimeout; sandbox.clearInterval = clearInterval; // 编码 sandbox.btoa = function (str) { return Buffer.from(str, 'binary').toString('base64'); }; sandbox.atob = function (str) { return Buffer.from(str, 'base64').toString('binary'); }; // console sandbox.console = console; // crypto sandbox.crypto = { getRandomValues: function (array) { const bytes = require('crypto').randomBytes(array.length); for (let i = 0; i < array.length; i++) { array[i] = bytes[i]; } return array; } }; return sandbox; } function runInSandbox(code, options = {}) { const sandbox = createSandbox(options); vm.createContext(sandbox); vm.runInContext(code, sandbox, { timeout: options.timeout || 5000 }); return sandbox; } module.exports = { createSandbox, runInSandbox };

这个框架把常用的全局对象都封装好了,options里可以覆盖userAgent、cookie、href等关键参数。runInSandbox用vm.runInContext执行代码,timeout防止死循环。使用时,把 webpack 打包文件读进来,调用runInSandbox,然后从返回的sandbox里取结果。

验证沙箱是否够用,可以跑一个检测脚本,检查navigator.userAgent、document.cookie、screen.width这些值是否符合预期。如果目标代码里有debugger语句,vm默认会忽略,但可以用inspector模块调试。

几个参数上的经验值:innerHeight通常比outerHeight小 100 到 150,因为浏览器有地址栏和标签栏;availHeight比height小 40 左右,因为任务栏;colorDepth和pixelDepth一般填 24。这些细节在严格的指纹检测里会被比对,填错可能触发风控。

最后说个我自己的习惯:每次补完一个站点的 anti-content,把差异部分单独记下来,比如这个站点多检测了navigator.plugins,那个站点看了document.referrer。下次遇到类似的,先翻记录,能省不少时间。补环境这事,玄学的地方在于你永远不知道下一个检测点是什么,但血泪经验攒多了,套路也就那些。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询