webpack打包下anti-content逆向:补环境与JS逆向实战

📅 发布时间:2026/9/29 13:57:48
webpack打包下anti-content逆向:补环境与JS逆向实战
简介这份资源面向有一定 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改成完整的浏览器 UAscreen尺寸填合理值。另外检查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 依赖 BB 又依赖 AB 在加载时 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。下次遇到类似的先翻记录能省不少时间。补环境这事玄学的地方在于你永远不知道下一个检测点是什么但血泪经验攒多了套路也就那些。希望帮到你。本文还有配套的精品资源点击获取