拼多多 anti-content 逆向:webpack 模块定位与补环境实战
简介这份资源围绕 webpack 打包场景下的拼多多 anti_content 安全机制提供补环境与 JS 逆向的学习素材面向具备一定 JavaScript 基础、希望深入理解前端加密与反爬对抗的开发者。包内共 2 个文件包含 1 个 py 脚本与 1 个 js 文件分别用于接口请求验证与核心逆向逻辑演示压缩包整体约 40KB体量轻便便于快速阅读与本地调试。资源重点呈现 anti_content 参数的生成链路与补环境思路读者可借此梳理浏览器环境缺失项的补齐方法、webpack 模块加载机制以及常见风控字段的定位与还原流程从而建立从抓包分析到代码复现的完整认知。目前已有 433 人学习适合作为逆向入门到进阶的参考案例帮助开发者在合规前提下提升对前端安全机制的理解与排错能力。1. 从一次请求被拒说起anti-content 到底在防什么打开拼多多的商品详情页抓包能看到一个叫anti-content的请求头值是一长串 Base64 风格的密文。你把它删掉重放接口直接返回风控错误你原样复制粘贴过几分钟再重放还是失败。这不是普通的 token它跟时间、页面上下文、浏览器环境指纹绑在一起服务端会校验它是不是在“真实浏览器”里生成的。这就是 anti-content 的核心定位它不是加密你的业务数据而是证明“这个请求来自一个它认可的 JS 运行环境”。所以做 JS 逆向时光把加密函数抠出来跑通没用你得把函数依赖的浏览器环境一起补上这就是补环境。而拼多多的前端代码是 webpack 打包的模块被压缩、拆分、动态加载找入口本身就是第一道坎。这篇笔记面向的是已经会基本抓包、能看懂 JS、想系统搞明白 anti-content 生成链路和补环境套路的同学从 webpack 模块定位一路讲到补环境验证把能复现的步骤和踩过的坑都摊开。2. webpack 打包产物里怎么定位 anti-content 的生成入口2.1 先认清 webpack 运行时长什么样webpack 打包后的代码最外层通常是一个立即执行函数里面维护一个模块数组或模块对象配合__webpack_require__做模块加载。压缩之后变量名变成单个字母但结构特征还在。常见形态是(function(e){...})([...])或者!function(e){...}({...})模块函数体里能看到e.exports、t.d、t.r这类 webpack 注入的辅助函数。定位 anti-content 的第一步不是搜字符串而是先确认这份代码是不是 webpack 产物。在浏览器开发者工具的 Sources 面板里找到加载的 JS 文件看开头结构。如果是 webpack你会看到模块 ID 到模块函数的映射。这一步的意义在于后面补环境时你要知道哪些函数是 webpack 运行时提供的哪些是业务模块自己写的不能一股脑全补。我一般会先把目标 JS 格式化用开发者工具的 Pretty print或者本地用js-beautify。格式化之后搜索anti-content、antiContent、anti_content这几个关键词。注意大小写和连字符变体拼多多在不同版本里命名习惯会变。搜不到就直接搜content配合header或者搜X-开头的请求头设置逻辑。2.2 用调用栈反推生成函数搜到关键词只是找到“使用点”不是“生成点”。真正生成 anti-content 的函数往往在设置请求头之前被调用。最有效的办法是在设置请求头那一行下断点然后触发一次真实请求看调用栈。具体操作在 Sources 里找到类似headers[anti-content] xxx的位置行号处点断点。回到页面触发接口请求断住之后看右侧 Call Stack。从栈顶往下翻找到第一个不属于 webpack 运行时的业务函数那个通常就是生成函数或者它的上层封装。// 断点处看到的典型代码形态变量名已压缩 var s n(a1b2).default; // ... headers[anti-content] s.getAntiContent()上面这段是示意实际压缩后n(a1b2)就是 webpack 的模块引用a1b2是模块 ID。你要做的是顺着这个模块 ID 找到模块定义把整个模块函数抠出来。在 webpack 产物里模块定义通常是a1b2: function(e, t, n) { ... }这种形式。找到模块后不要急着复制到本地跑。先在浏览器里验证把这个模块函数单独拿出来在 Console 里手动构造e、t、n三个参数调用看能不能拿到 anti-content。这一步能帮你确认模块的依赖边界。2.3 模块依赖的递归展开一个生成 anti-content 的模块往往依赖十几个甚至几十个子模块。webpack 的n(moduleId)就是加载器。你在本地复现时不能只抠一个模块要把依赖链上的模块都拿下来。常见做法是写一个小的提取脚本在浏览器 Console 里运行把目标模块及其依赖的模块源码全部打印出来。思路是先拿到__webpack_require__的引用然后递归调用__webpack_require__.m模块映射去取源码。// 在浏览器 Console 中运行提取指定模块及其依赖源码 (function () { // webpack 5 的模块映射通常挂在 __webpack_require__.m var modules window.__webpack_require__.m; var visited {}; var result {}; function collect(id) { if (visited[id]) return; visited[id] true; var fn modules[id]; if (!fn) return; result[id] fn.toString(); // 从函数源码里正则匹配 n(xxx) 形式的依赖 var deps fn.toString().match(/n\(\s*[]([^])[]\s*\)/g) || []; deps.forEach(function (d) { var depId d.match(/[]([^])[]/)[1]; collect(depId); }); } collect(a1b2); // 替换成你找到的模块 ID console.log(JSON.stringify(result, null, 2)); })();这段脚本的逻辑是从入口模块 ID 开始读取模块函数源码用正则找出所有n(xxx)形式的依赖递归收集。参数说明modules是 webpack 的模块映射对象不同 webpack 版本挂载位置可能不同webpack 4 常见在__webpack_require__.c或直接是数组需要根据实际情况调整。visited防止循环依赖导致死递归。拿到所有模块源码后在本地搭一个最小的 webpack 运行时壳子把这些模块塞进去就能在 Node 里加载了。壳子不用完整只要实现__webpack_require__的基本逻辑根据 ID 找模块函数、执行、缓存 exports。提示不同版本的 webpack 运行时 API 有差异n.d、n.r、n.n这些辅助函数要一并实现否则模块加载会报错。最省事的办法是直接把目标页面里 webpack 运行时的那一段整体复制过来只替换模块映射。3. 补环境让抠出来的代码在 Node 里跑通3.1 补环境到底在补什么把 webpack 模块搬到 Node 里第一件事就是报错window is not defined。补环境不是简单地把window设成{}而是要模拟出代码运行时会访问的所有浏览器 API并且这些 API 的返回值要符合代码的预期。anti-content 生成过程中常见的环境依赖包括window、document、navigator、location、screen、history以及canvas、WebGL相关的指纹接口。代码会读取这些对象的属性做运算后参与加密。你补的值不对生成的 anti-content 就是错的服务端一验就挂。补环境的核心思路是“按需补”不是“全量补”。先跑报什么错补什么但补的时候要理解代码为什么读这个属性。比如代码读navigator.userAgent你补一个空字符串可能能跑通但生成的密文跟真实浏览器不一致请求照样被拒。所以关键属性要用真实浏览器的值。3.2 用 Proxy 拦截未定义的属性访问手动补环境最痛苦的是不知道代码到底访问了哪些属性。用 Proxy 可以自动记录所有属性访问帮你快速定位缺失项。// 用 Proxy 包裹 window记录所有被访问的属性 function createEnvProxy(target, name) { return new Proxy(target, { get: function (obj, prop) { if (prop Symbol.toPrimitive || prop toString) { return obj[prop]; } if (!(prop in obj)) { console.log([缺失] name . String(prop)); // 返回一个空函数或空对象避免直接报错 return undefined; } var val obj[prop]; // 如果属性值是对象递归包裹继续监控 if (val typeof val object !val.__proxied) { var p createEnvProxy(val, name . String(prop)); p.__proxied true; return p; } return val; }, set: function (obj, prop, value) { obj[prop] value; return true; } }); } var window createEnvProxy({}, window); global.window window;这段代码的逻辑Proxy 的get拦截器在属性不存在时打印缺失日志存在时如果值是对象就递归包裹这样能追踪到深层属性访问。参数说明target是初始环境对象name用于日志标识。注意Symbol.toPrimitive和toString要放行否则很多隐式转换会出问题。跑一遍生成逻辑控制台会打印出一串缺失属性。把这些属性按重要性排序先补navigator、location、document这些高频对象再补canvas、WebGL这些指纹相关接口。3.3 原型链补环境的正确姿势热词里提到的“原型链补环境”指的是很多检测代码不直接读navigator.userAgent而是通过Object.getOwnPropertyDescriptor、Object.getPrototypeOf去检查属性是在实例上还是在原型上。如果你把所有属性都挂在实例上检测代码一比对原型链就发现异常。正确做法是把浏览器原生对象的属性挂到对应的原型上。比如navigator的属性应该定义在Navigator.prototype上window的属性定义在Window.prototype上。// 模拟 Navigator 原型链 function Navigator() {} Navigator.prototype.userAgent Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ...; Navigator.prototype.platform Win32; Navigator.prototype.language zh-CN; Navigator.prototype.languages [zh-CN, zh, en]; Navigator.prototype.hardwareConcurrency 8; Navigator.prototype.deviceMemory 8; var navigator new Navigator(); global.navigator navigator; // 验证原型链 console.log(Object.getPrototypeOf(navigator) Navigator.prototype); // true console.log(navigator.hasOwnProperty(userAgent)); // false属性在原型上这样检测代码用hasOwnProperty或getOwnPropertyDescriptor检查时结果跟真实浏览器一致。参数说明userAgent要填你实际使用的浏览器 UAhardwareConcurrency和deviceMemory要跟你的机器匹配这些值会参与指纹计算。注意不要用Object.defineProperty把属性定义成不可枚举或不可配置除非你确认浏览器原生行为就是如此。大多数 navigator 属性是可枚举的定义错了反而露馅。3.4 canvas 与 WebGL 指纹的补法拼多多的 anti-content 会采集 canvas 指纹和 WebGL 渲染器信息。canvas 指纹的原理是用 canvas 绘制一段文字或图形然后读取像素数据不同环境渲染结果有细微差异形成指纹。在 Node 里补 canvas 有两种思路。一是用canvasnpm 包做真实渲染但安装依赖重且渲染结果跟浏览器不完全一致。二是直接返回固定的像素数据模拟一个常见浏览器的渲染结果。// 模拟 canvas toDataURL 返回固定指纹 var canvasProto { getContext: function (type) { if (type 2d) { return { fillText: function () {}, fillRect: function () {}, getImageData: function (x, y, w, h) { // 返回一段固定的像素数据模拟真实渲染 return { data: new Uint8ClampedArray(w * h * 4) }; }, measureText: function (text) { return { width: text.length * 10 }; } }; } if (type webgl || type experimental-webgl) { return { getParameter: function (param) { // 返回常见的 WebGL 参数值 var params { 37445: Google Inc., // UNMASKED_VENDOR_WEBGL 37446: ANGLE (Intel...), // UNMASKED_RENDERER_WEBGL 7936: WebKit, // VENDOR 7937: WebKit WebGL // RENDERER }; return params[param] || ; }, getExtension: function () { return null; } }; } return null; }, toDataURL: function () { return data:image/png;base64,iVBORw0KGgoAAAANSUhEUg...; } }; function HTMLCanvasElement() {} HTMLCanvasElement.prototype canvasProto; global.HTMLCanvasElement HTMLCanvasElement;这段代码模拟了 canvas 的getContext和toDataURL。参数说明getParameter里的常量对应 WebGL 的枚举值37445 和 37446 是获取显卡厂商和渲染器的关键参数值要填成常见显卡的字符串。toDataURL返回的 Base64 要是一个真实 canvas 渲染结果的编码随便填会被识破。实际补的时候最可靠的办法是在真实浏览器里执行同样的绘制逻辑把toDataURL的结果和getImageData的像素数据导出来硬编码到补环境代码里。这样指纹跟真实浏览器一致。4. 避坑与排查补环境跑通但请求仍失败的 5 个原因4.1 现象本地能生成密文服务端返回风控错误原因通常不是加密算法错了而是参与加密的环境值跟真实浏览器不一致。anti-content 的生成过程会把环境指纹混入加密输入任何一个值不对密文就不同。排查方法在真实浏览器里把生成函数的输入参数打印出来跟本地跑的结果逐项对比。重点对比navigator属性、screen尺寸、时区、语言。4.2 现象代码报Cannot read property xxx of undefined这是补环境不完整。用 3.2 节的 Proxy 方案跑一遍看缺失日志。常见缺失项包括document.cookie、localStorage、sessionStorage、performance.now。补的时候注意document.cookie的 getter 和 setter 行为要跟浏览器一致直接赋一个字符串可能不生效。4.3 现象生成的密文长度对但解码后结构错乱多半是btoa或atob的实现有问题。Node 的Buffer和浏览器btoa对非 Latin1 字符的处理不同。如果加密过程涉及中文或特殊字符要用浏览器一致的编码方式。解决用Buffer.from(str, binary).toString(base64)模拟或者直接引入btoa的 polyfill。4.4 现象第一次请求成功第二次就失败anti-content 通常带时间戳和随机数服务端会校验时效性和唯一性。如果你在本地缓存了生成结果重复使用第二次必然失败。解决每次请求都重新调用生成函数。另外检查生成函数里有没有依赖Date.now()或Math.random()的地方确保每次执行都产生新值。4.5 现象补了环境但检测代码仍然识别出 Node有些检测会检查process、global、require这些 Node 特有对象是否存在。解决在补环境代码开头把这些全局变量删掉或设为undefined。另外window.chrome对象、navigator.plugins数组这些细节也要补缺失会被判定为非浏览器环境。// 清理 Node 特有全局变量 delete global.process; delete global.require; delete global.Buffer; delete global.__dirname; delete global.__filename; // 补 chrome 对象 window.chrome { runtime: {} }; // 补 plugins navigator.plugins [{ name: Chrome PDF Plugin }];5. 验证补环境是否到位的一个实用技巧补环境最怕的是“以为补全了其实还差”。我常用的验证方法是在真实浏览器里把生成函数的完整调用链路录下来包括所有环境属性的读取顺序和读取值然后在本地跑同样的链路逐项 diff。具体做法是在浏览器里 hookObject.getOwnPropertyDescriptor和Reflect.get记录生成过程中所有对环境属性的访问。然后在 Node 里用同样的 hook 记录两边对比。差异项就是补环境没对齐的地方。// 在浏览器和 Node 里都运行这段 hook记录属性访问 var accessLog []; var originalGet Reflect.get; Reflect.get function (target, prop, receiver) { var result originalGet.call(this, target, prop, receiver); // 只记录环境相关对象的访问 if (target window || target navigator || target document) { accessLog.push({ obj: target window ? window : target navigator ? navigator : document, prop: String(prop), value: typeof result object ? [object] : String(result) }); } return result; }; // 触发一次生成然后打印 accessLog这个 hook 会记录所有对window、navigator、document的属性读取。把浏览器里的日志和 Node 里的日志按顺序对比如果 Node 里某次读取返回undefined而浏览器返回了具体值那就是缺失项。如果读取顺序不同说明代码执行路径有分支可能是某个环境判断条件不一致导致的。对比的时候重点关注navigator.userAgent、navigator.platform、screen.width、screen.height、window.devicePixelRatio、Intl.DateTimeFormat().resolvedOptions().timeZone这几个值。它们直接参与指纹计算错一个就前功尽弃。我自己的习惯是每补一个环境属性就在代码里加一行注释写明这个属性在真实浏览器里的值是什么、从哪来的。这样下次拼多多更新检测逻辑我能快速定位到需要改哪一行。补环境这件事没有一劳永逸版本一更新检测点就变保持记录习惯比记住具体值更重要。希望帮到你。本文还有配套的精品资源点击获取