简介这份资源围绕 webpack 打包场景下的拼多多 anti_content 安全机制展开面向具备一定 JavaScript 基础、希望入门 JS 逆向与补环境实战的开发者。内容聚焦如何通过 webpack 配置分析与逆向调试思路理解 anti_content 参数的生成逻辑并借助补环境手段在本地复现请求流程适合爬虫学习、接口调试与前端安全研究等场景。压缩包共 2 个文件包含 1 个 py 脚本与 1 个 js 文件分别承担请求调用与核心逆向逻辑的演示用途整体约 40KB体量轻便、便于快速阅读与调试。目前已有 434 人学习下载说明该方向具备一定关注度。读者可从中获得 anti_content 补环境的整体思路、webpack 模块定位与调试切入点以及脚本调用与参数构造的参考写法为后续深入逆向分析打下基础。1. webpack 打包下的 anti-content 补环境一次把 JS 逆向的硬骨头啃明白做前端逆向的人迟早会撞上 anti-content 这类风控参数。它不像普通接口签名那样把算法明晃晃写在某个函数里而是被 webpack 打包、被环境检测层层包裹你在 Node 里直接require跑要么报window is not defined要么算出来的值跟浏览器里对不上。我最近就在一个模拟项目里啃这块目标很明确把 webpack 打包产物里的 anti-content 生成逻辑扒出来在 Node 环境里补出一套能稳定复现的运行时。这篇笔记写给两类人——刚接触 JS 逆向、被 webpack 模块加载绕晕的新手以及已经会扣代码、但一遇到环境检测就翻车的老手。核心就三件事看懂 webpack 的模块系统、把浏览器环境补对、让 anti-content 在本地算出和浏览器一致的结果。2. 先看懂 webpack 打包产物模块加载器是怎么把代码藏起来的anti-content 之所以难扣第一道坎不是算法本身而是 webpack 把成百上千个模块塞进一个自执行函数里用一套自己的require来调度。你直接搜函数名往往搜到的是一串数字 ID真正的逻辑藏在modules[moduleId]里。所以补环境之前得先把这套加载机制摸清楚否则你连入口在哪都找不到。2.1 webpack 运行时结构__webpack_require__到底做了什么一个典型的 webpack 产物长这样最外层是个 IIFE接收一个modules对象里面每个 key 是模块 IDvalue 是模块工厂函数。运行时定义了一个__webpack_require__负责按 ID 取出模块、执行、缓存。简化后的骨架如下(function (modules) { // 模块缓存避免重复执行 var installedModules {}; function __webpack_require__(moduleId) { // 命中缓存直接返回这是 webpack 模块单例的关键 if (installedModules[moduleId]) { return installedModules[moduleId].exports; } // 新建模块对象并挂到缓存 var module (installedModules[moduleId] { i: moduleId, l: false, exports: {}, }); // 执行模块工厂函数把 module、module.exports、require 传进去 modules[moduleId].call( module.exports, module, module.exports, __webpack_require__ ); module.l true; return module.exports; } // 入口模块在这里被调用 return __webpack_require__((__webpack_require__.s 0)); })({ 0: function (module, exports, __webpack_require__) { // 入口逻辑通常会去 require 别的模块 var core __webpack_require__(1); core.init(); }, 1: function (module, exports, __webpack_require__) { // anti-content 相关逻辑可能在这里 module.exports { init: function () { /* ... */ }, }; }, });逻辑说明installedModules是模块缓存保证同一个模块只执行一次这点很重要——很多 anti-content 逻辑依赖模块级变量在多次调用间保持状态如果你手动扣代码时把缓存去掉了行为就会变。modules[moduleId].call(...)里的this指向module.exports所以模块内部用this挂属性时实际挂到了导出对象上。参数说明moduleId是数字或字符串webpack 4/5 默认用数字但配置了optimization.moduleIds后可能变成字符串哈希。module.l标记是否已加载完成某些懒加载逻辑会检查它。__webpack_require__.s存入口模块 ID找到它就知道从哪开始执行。2.2 定位 anti-content 入口从调用栈反推模块 ID实际项目里模块几百个靠肉眼找不现实。我一般用两种方式定位。第一种是在浏览器里打断点打开 DevTools在 Network 里找到携带 anti-content 的请求看 Initiator 调用栈一层层往上翻通常能翻到某个__webpack_require__(xxx)的调用点那个 xxx 就是入口模块 ID。第二种是关键词搜索加断点。anti-content 的生成往往伴随一些特征字符串比如anti_content、antiContent、或者某个固定的盐值。在 Sources 面板全局搜索这些词找到引用处再在引用处下断点刷新页面断下来后看 Call Stack同样能定位到模块。定位到入口后把入口模块和它依赖的模块 ID 记下来这是后续补环境的范围。常见做法是写个脚本从入口开始递归收集所有__webpack_require__(id)的调用把涉及的模块 ID 全部列出来避免漏掉依赖。// 在浏览器控制台执行收集从入口可达的所有模块 ID (function collectDeps() { const visited new Set(); const queue [0]; // 假设入口是 0实际替换成你定位到的 ID while (queue.length) { const id queue.shift(); if (visited.has(id)) continue; visited.add(id); // 通过 webpack 内部 require 的缓存拿到模块工厂函数源码 const factory __webpack_require__.m[id]; if (!factory) continue; const src factory.toString(); // 正则匹配所有 require 调用提取依赖 ID const re /__webpack_require__\((\d)\)/g; let m; while ((m re.exec(src))) { queue.push(Number(m[1])); } } console.log(可达模块 ID, [...visited]); })();逻辑说明__webpack_require__.m是 webpack 暴露的模块工厂函数集合不同版本属性名可能不同webpack 5 里通常是__webpack_require__.m。把工厂函数转成字符串后用正则提取依赖递归收集就能得到完整的模块闭包。参数说明正则里的\d假设模块 ID 是纯数字如果你的产物用了字符串 ID改成([\w$])并去掉Number()。visited用 Set 去重防止循环依赖导致死循环。2.3 把模块抠出来手动还原 vs 直接复用运行时定位到模块后有两种扣法。手动还原是把每个模块的工厂函数体复制出来改写成普通函数自己维护依赖关系。这种方式干净但工作量大而且一旦模块间有复杂的循环依赖很容易出错。我更推荐直接复用 webpack 运行时。做法是把整个 IIFE 连同modules对象一起复制到一个 Node 文件里然后只补环境不改模块逻辑。这样模块间的依赖、缓存、循环引用都由原运行时处理你只需要保证window、document、navigator这些全局对象存在且行为正确。这也是补环境这个说法的由来——不是改代码是补运行环境。// 把 webpack 产物整体粘进来前面补上环境 const fs require(fs); const vm require(vm); // 读取扣下来的 webpack 产物 const code fs.readFileSync(./webpack_bundle.js, utf-8); // 构造一个沙箱上下文先塞入最小环境 const sandbox { window: {}, document: {}, navigator: { userAgent: Mozilla/5.0 ... }, location: { href: https://example.com }, console: console, }; sandbox.window sandbox; // window 自引用很多代码依赖这个 sandbox.self sandbox; sandbox.globalThis sandbox; vm.createContext(sandbox); vm.runInContext(code, sandbox); // 执行后从沙箱里取结果 console.log(sandbox.antiContent);逻辑说明用 Node 的vm模块创建独立上下文把 webpack 产物跑在里面避免污染主进程全局。sandbox.window sandbox是关键浏览器里window指向全局对象自身很多检测会检查window.window window。参数说明vm.createContext把普通对象变成上下文对象runInContext在其中执行代码。userAgent要填真实浏览器的值后面环境检测会校验。location.href同理风控常检查域名。3. 补环境的核心anti-content 到底在检测哪些浏览器特征代码能跑起来只是第一步算出来的 anti-content 跟浏览器不一致才是真正的坑。anti-content 这类参数的本质是把一堆浏览器环境特征收集起来经过哈希或加密生成一个指纹。你补的环境只要有一项跟真实浏览器对不上指纹就变了。所以补环境不是随便塞几个空对象而是要把检测点一个个对齐。3.1 常见检测点清单navigator、screen、canvas、时间戳我把 anti-content 里高频出现的检测点整理成一张表方便对照排查。这些不是全部但覆盖了八成以上的场景。检测对象常见属性说明navigatoruserAgent、platform、language、languages、hardwareConcurrency、deviceMemory设备和语言指纹值必须和真实浏览器一致screenwidth、height、availWidth、availHeight、colorDepth屏幕分辨率影响指纹windowinnerWidth、innerHeight、devicePixelRatio、outerWidth窗口尺寸无头环境常缺失documentcookie、referrer、visibilityState、hidden页面状态cookie 尤其关键performancenow()、timing时间相关影响带时间戳的签名DategetTimezoneOffset、toString时区Node 默认时区和浏览器可能不同canvastoDataURL、getImageData渲染指纹无头环境渲染结果可能不同补的时候不要一次性全塞而是先跑一遍看报什么错、缺什么补什么。我一般会在沙箱里用 Proxy 拦截所有属性访问把访问路径打印出来这样能快速发现代码到底读了哪些属性。// 用 Proxy 记录所有被访问的全局属性快速定位检测点 function createProxyLogger(target, name) { return new Proxy(target, { get(obj, prop) { // 过滤掉 Symbol 和常见内置属性减少噪音 if (typeof prop string !prop.startsWith(_)) { console.log([访问] ${name}.${prop}); } const value obj[prop]; // 如果属性值是对象递归包一层 Proxy if (value typeof value object) { return createProxyLogger(value, ${name}.${prop}); } return value; }, }); } const sandbox { navigator: createProxyLogger({}, navigator), screen: createProxyLogger({}, screen), document: createProxyLogger({}, document), };逻辑说明Proxy 的get陷阱在每次读取属性时触发打印访问路径。递归包装保证嵌套对象也被监控。这样跑一遍控制台会输出代码实际读了哪些属性比盲目猜高效得多。参数说明过滤_开头是为了排除内部属性你可以按需调整。递归包装要注意循环引用生产环境用 WeakMap 记录已包装对象避免无限递归。3.2 用 Proxy 拦截属性访问把缺失的环境项一个个补齐有了访问日志补环境就变成填空题。比如日志显示代码读了navigator.userAgent、navigator.platform、navigator.languages你就把这三个补上值从真实浏览器里复制。这里有个血泪经验navigator.languages是数组很多人只补了language字符串结果指纹对不上排查半天。补的时候还要注意属性描述符。有些检测会检查属性是不是原型上的、是不是可枚举的。比如navigator.userAgent在浏览器里是 getter直接赋值可能被检测到。稳妥做法是用Object.defineProperty定义 getter。// 用 defineProperty 模拟浏览器原生 getter避免被检测 Object.defineProperty(sandbox.navigator, userAgent, { get() { return Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ...; }, configurable: true, enumerable: true, }); Object.defineProperty(sandbox.navigator, languages, { get() { return [zh-CN, zh, en]; }, configurable: true, enumerable: true, });逻辑说明defineProperty定义的 getter 在每次访问时返回新值行为更接近原生。configurable: true允许后续修改enumerable: true保证for...in能遍历到这两项和浏览器默认行为一致。参数说明userAgent的值必须和你在请求头里带的一致否则服务端交叉校验会失败。languages数组顺序也有讲究一般按优先级排列。3.3 时间与随机数为什么你的签名每次都不一样anti-content 里常带时间戳和随机数这是最容易被忽略的坑。浏览器里Date.now()返回毫秒时间戳Math.random()返回伪随机数。你在 Node 里跑时间戳没问题但Math.random()的序列和浏览器不同如果算法里用随机数做了盐值结果自然对不上。更隐蔽的是时区。Node 默认用系统时区浏览器用用户设置的时区new Date().getTimezoneOffset()返回值可能不同。如果 anti-content 里混入了本地时间字符串时区一差指纹就变了。// 固定时区和随机数保证结果可复现 process.env.TZ Asia/Shanghai; // 必须在进程启动前设置或启动时带 TZ 环境变量 // 如果算法依赖 Math.random可以替换成可控的伪随机 let seed 12345; Math.random function () { // 简单的线性同余保证每次运行序列一致 seed (seed * 1103515245 12345) 0x7fffffff; return seed / 0x7fffffff; };逻辑说明process.env.TZ设置时区但必须在 Node 启动前生效运行时改可能无效稳妥做法是启动命令带TZAsia/Shanghai node main.js。替换Math.random是为了让结果可复现方便对比调试正式跑的时候再换回真随机。参数说明线性同余的参数是经典值够用即可。种子seed随便定只要每次一致。注意替换Math.random会影响全局最好在沙箱内替换别污染主进程。4. 避坑与排查补环境时最容易翻车的五个地方补环境这事原理不难难在细节。下面这五条是我踩过的坑每条都按现象、原因、解决写清楚你对照着排查能省不少时间。4.1 现象代码能跑但结果为空原因入口模块没执行有时候你把产物粘进去跑完发现sandbox.antiContent是 undefined。第一反应是环境没补对其实更可能是入口模块压根没执行。webpack 的入口调用是__webpack_require__(__webpack_require__.s)如果你复制的时候漏了最后那行return或者把 IIFE 的返回值丢了入口就不会跑。解决检查产物末尾有没有return __webpack_require__(__webpack_require__.s 0)这类调用。如果没有手动加上。另外确认入口 ID 对不对有些产物入口不是 0是别的数字。4.2 现象报Cannot read property xxx of undefined原因环境对象缺层级比如报navigator.userAgent读不到但你明明补了navigator。这种情况多半是代码访问的是window.navigator.userAgent而你的window和navigator没关联上。浏览器里window.navigator和全局navigator是同一个对象你补的时候要保证引用一致。解决sandbox.window sandbox然后sandbox.navigator {...}这样window.navigator和navigator指向同一个对象。同理document、location都要挂到window上。4.3 现象签名偶尔对偶尔错原因时间戳或随机数没固定调试阶段最烦这种时好时坏的情况。如果算法里用了Date.now()两次调用间隔几毫秒结果就不同。如果用了Math.random()每次都不一样。这不是环境问题是算法本身带随机性。解决调试时把Date.now()固定成某个值Math.random()替换成固定序列这样每次结果一致方便对比。确认算法逻辑对了之后再放开。注意正式跑的时候时间戳要用真实的否则服务端校验时间窗口会失败。4.4 现象Node 里跑正常浏览器里对不上原因环境差异没对齐反过来也有你在 Node 里算出来一个值拿去请求却失败。这通常是 Node 和浏览器的环境差异导致的比如userAgent不一致、时区不一致、screen尺寸不一致。服务端可能同时校验 anti-content 和请求头里的 UA两者对不上就拒绝。解决把 Node 沙箱里的环境值和真实浏览器的值逐项对齐。最笨也最有效的办法是写个脚本在浏览器里把所有检测点打印出来复制到 Node 里。别嫌麻烦这一步省不得。4.5 现象补了环境还是报检测失败原因属性描述符不对有些风控会检测属性是不是原生 getter。比如navigator.userAgent在浏览器里是原型上的 getter你用普通对象属性模拟Object.getOwnPropertyDescriptor一查就露馅。这种检测比较高级但确实存在。解决用Object.defineProperty在原型上定义 getter模拟原生行为。更彻底的做法是用class定义Navigator类把属性挂在原型上然后sandbox.navigator new Navigator()。这样Object.getPrototypeOf(navigator)返回的就不是Object.prototype了。5. 进阶把补环境做成可复用的沙箱框架单次扣代码补环境做完就完了。但如果你经常做这类逆向每次都从头补一遍太亏。我的习惯是攒一个沙箱框架把常用的浏览器环境封装成模块下次遇到新的 anti-content直接换 webpack 产物环境部分复用。这一章讲怎么把这个框架搭起来以及怎么验证补出来的环境靠不靠谱。5.1 环境模块化把 navigator、document 拆成独立文件框架的核心是环境模块化。我一般按对象拆文件navigator.js、document.js、screen.js、location.js每个文件导出一个工厂函数接收配置参数返回模拟对象。这样不同项目之间可以复用只需要改配置。// navigator.js module.exports function createNavigator(config {}) { const ua config.userAgent || Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36; const navigator {}; Object.defineProperty(navigator, userAgent, { get: () ua, configurable: true, enumerable: true, }); Object.defineProperty(navigator, platform, { get: () config.platform || Win32, configurable: true, enumerable: true, }); Object.defineProperty(navigator, languages, { get: () config.languages || [zh-CN, zh, en], configurable: true, enumerable: true, }); Object.defineProperty(navigator, hardwareConcurrency, { get: () config.cores || 8, configurable: true, enumerable: true, }); return navigator; };逻辑说明工厂函数接收配置返回带 getter 的 navigator 对象。所有属性用defineProperty定义模拟原生行为。配置项有默认值不传也能跑。参数说明userAgent要和请求头一致platform和 UA 里的平台对应languages按优先级排列cores是 CPU 核心数一般填 4 或 8。5.2 用 vm 隔离避免污染 Node 全局框架跑在vm沙箱里环境对象注入沙箱不污染 Node 全局。这样你可以同时跑多个沙箱互不干扰。vm的性能比vm2好但隔离性弱一些做逆向够用。// sandbox.js const vm require(vm); const createNavigator require(./navigator); const createDocument require(./document); function createSandbox(config {}) { const sandbox {}; sandbox.window sandbox; sandbox.self sandbox; sandbox.globalThis sandbox; sandbox.navigator createNavigator(config.navigator); sandbox.document createDocument(config.document); sandbox.location { href: config.url || https://example.com }; sandbox.console console; sandbox.setTimeout setTimeout; sandbox.clearTimeout clearTimeout; vm.createContext(sandbox); return sandbox; } module.exports { createSandbox };逻辑说明createSandbox组装所有环境对象挂到沙箱上然后vm.createContext激活。window、self、globalThis都指向沙箱自身模拟浏览器的全局对象自引用。参数说明config里可以传navigator、document、url等配置分别透传给对应的工厂函数。setTimeout这类异步 API 也要补有些代码会用到。5.3 验证方法用已知输入对比浏览器输出框架搭好后怎么验证补得对不对最直接的办法是找一个已知输入在浏览器里跑一遍拿到输出再在沙箱里跑一遍对比结果。如果一致说明环境补对了不一致就逐项排查差异。我一般会构造一个测试用例固定时间戳、固定随机数、固定环境配置然后分别在浏览器和 Node 里执行 anti-content 生成函数对比输出。这个过程可能要反复几次每次调整一个环境项直到完全一致。// verify.js const { createSandbox } require(./sandbox); const fs require(fs); const sandbox createSandbox({ navigator: { userAgent: Mozilla/5.0 ... }, url: https://example.com, }); // 固定时间保证可复现 sandbox.Date class extends Date { constructor(...args) { if (args.length 0) { super(1700000000000); // 固定时间戳 } else { super(...args); } } static now() { return 1700000000000; } }; const code fs.readFileSync(./webpack_bundle.js, utf-8); require(vm).runInContext(code, sandbox); // 调用生成函数对比输出 const result sandbox.generateAntiContent(); console.log(沙箱输出, result); // 和浏览器里拿到的值对比一致则通过逻辑说明继承Date并覆盖now()固定时间戳。runInContext执行产物后调用生成函数拿结果。和浏览器输出对比一致就说明环境对齐了。参数说明固定时间戳选一个合理的值别用 0有些检测会校验时间范围。对比时注意输出格式有的返回字符串有的返回对象要按实际调整。这套框架我用了挺久最大的好处是下次遇到新的 anti-content环境部分基本不用动只需要换 webpack 产物、调一下配置。补环境这事说到底是个细致活没有捷径但把工具磨好能少受很多罪。希望帮到你。本文还有配套的精品资源点击获取