简介:这份资源围绕 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 是模块 ID,value 是模块工厂函数。运行时定义了一个__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 里高频出现的检测点整理成一张表,方便对照排查。这些不是全部,但覆盖了八成以上的场景。
| 检测对象 | 常见属性 | 说明 |
|---|---|---|
| navigator | userAgent、platform、language、languages、hardwareConcurrency、deviceMemory | 设备和语言指纹,值必须和真实浏览器一致 |
| screen | width、height、availWidth、availHeight、colorDepth | 屏幕分辨率,影响指纹 |
| window | innerWidth、innerHeight、devicePixelRatio、outerWidth | 窗口尺寸,无头环境常缺失 |
| document | cookie、referrer、visibilityState、hidden | 页面状态,cookie 尤其关键 |
| performance | now()、timing | 时间相关,影响带时间戳的签名 |
| Date | getTimezoneOffset、toString | 时区,Node 默认时区和浏览器可能不同 |
| canvas | toDataURL、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 启动前生效,运行时改可能无效,稳妥做法是启动命令带TZ=Asia/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 产物、调一下配置。补环境这事,说到底是个细致活,没有捷径,但把工具磨好,能少受很多罪。希望帮到你。
本文还有配套的精品资源,点击获取