ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

webpack打包代码逆向实战:从anti-content参数学习补环境与模块还原

webpack打包代码逆向实战:从anti-content参数学习补环境与模块还原 简介面向JavaScript逆向学习者以webpack补环境为核心研究拼多多anti_content机制适合有一定前端基础、关注JS混淆与模块还原的开发者。压缩包仅40KB共2个文件1个Python脚本与1个JavaScript脚本可配合完成请求模拟与浏览器环境补全已有431人学习下载是典型的小型逆向案例。围绕anti_content环境补丁代码展示了webpack加载器的调试思路包括定位关键函数、补齐window与document对象并通过Python脚本验证参数。读者可借此掌握常用补环境技巧并能直接复现学习快速对照自身项目的同类问题。该示例虽小却足以体会从捕获到解码再到环境模拟的整体流程适合补环境入门者参考。 前阵子我在复盘 webpack 相关的 JS 逆向正好用拼多多 web 端的 anti-content 参数做了一次完整的练手。这个参数名看着不起眼背后却是一整套 webpack 打包后的模块调用关系把它当成学习样本可以一次性把“定位、扣模块、补环境”这三个逆向基本功全部串起来。这篇文章会把我的完整思路和实际操作过程写清楚包括怎么从浏览器里找到 anti-content 的生成逻辑怎么把 webpack 模块搬到 Node.js 里跑以及补环境时最容易踩的坑。适合懂一点 JavaScript、想入门 JS 逆向或者想从逆向角度重新理解前端构建工具的读者。1. 项目整体思路与目标拆解1.1 先搞清楚 webpack 打包后的文件长什么样webpack 打包出来的 JS 文件不管业务代码多复杂最终都会变成一个立即执行函数函数内部维护一个模块表和模块加载器。模块表可以是一个数组也可以是一个对象里面每个元素就是一个模块函数。模块加载器一般叫__webpack_require__它的作用就是根据模块 ID 找到对应模块执行后返回 exports。简化后的结构大概是这个样子(function(modules) { var installedModules {}; function __webpack_require__(moduleId) { if (installedModules[moduleId]) { return installedModules[moduleId].exports; } var module (installedModules[moduleId] { exports: {} }); modules[moduleId](module, module.exports, __webpack_require__); return module.exports; } return __webpack_require__(0); })({ 0: function(module, exports, require) { var getSign require(1); module.exports { sign: getSign }; }, 1: function(module, exports, require) { module.exports function() { return hello-sign; }; } });这个结构对逆向的意义非常大。它意味着所有业务代码都被拆散成独立模块任何加密函数都不是单独存在的而是存在于某个模块里并且通过require引用其他模块。比如一个签名算法可能依赖时间戳函数、md5 工具、甚至是某个环境检测模块。所以我们实际要做的是把相关模块连成一张依赖图然后整体搬出来。1.2 anti-content 是什么anti-content 是拼多多 web 端请求里的一个签名参数通常会出现在部分敏感接口的请求头或者请求体里。它的作用是做风控校验防止请求被篡改也在一定程度上防止数据被批量抓取。所以在浏览器里打开开发者工具刷新页面就能在 Network 面板里看到这个参数。这里要强调一下研究它并不是为了去突破别人的风控而是把它当作一个典型的 webpack 场景来学习。真实工作中前端大多会用到 webpack 打包很多企业又在 webpack 之上做了一层代码混淆导致我们看到的代码可读性极差。通过这种案例可以学会如何在压缩混淆的代码里找到关键逻辑如何还原模块依赖。这套能力对前端性能分析、安全测试、代码维护都有帮助。1.3 学习路线拆解我的习惯是不急着看代码先画一条路线图后面所有动作都围绕它走。对于 anti-content 这个参数路线大概是这样的在 Network 面板里找到参数名。通过全局搜索定位到参数字符串所在的代码位置。向上回溯调用栈找到生成参数的核心函数。把核心函数所在的 webpack 模块以及它依赖的所有模块抠出来。在 Node.js 里搭一个__webpack_require__加载器尝试执行。根据报错信息补环境反复调试直到输出结果与浏览器一致。这个路线其实是 JS 逆向的通用方法论anti-content 只是载体。学会这套流程之后再遇到其他 webpack 项目基本都能套用。2. 环境准备与定位技巧2.1 需要准备的开发环境工具不需要多复杂我实际使用的就三样Node.js14 以上版本、Chrome 浏览器、VS Code。Chrome DevTools 是主力Node.js 用来跑抠出来的模块代码VS Code 用来写调试脚本。不需要额外安装任何抓包工具因为浏览器自带的 Network 面板和调试器已经足够定位参数生成逻辑了。有一个小技巧我特别推荐进入开发者工具后先打开 Sources 面板在左侧找到页面引用的 JS 文件如果代码是压缩过的点击左下角的“格式化”按钮花括号图标代码就会展开成可读的格式。拼多多的代码量很大格式化之后搜索速度会慢一点但可读性提高很多。2.2 在网页里定位 webpack 模块定位的第一步是先在 Network 面板里找到包含 anti-content 的那个请求。右键这个请求选择“Break on”里面的“Fetch/XHR”然后刷新页面或者重新触发请求就会自动断在发起请求的地方。这个办法比手动下断点快很多因为请求发起的地方往往离参数生成的位置很近。断下之后在 Call Stack 调用栈面板里能看到发起请求的函数链。每一层函数对应的代码文件可能都不同但如果是 webpack 项目很多调用关系都是通过模块 ID 关联的。这时候可以在当前作用域里找一找有没有__webpack_require__或者module这类关键字如果有就能顺藤摸瓜找到模块 ID。接下来用全局搜索定位 anti-content 字符串。在格式化后的代码里按 CtrlShiftFMac 是 CmdShiftF输入 anti-content搜索结果一般会出现在某个模块的字符串常量里。点击进去在那一行附近打上断点再重新触发请求就能看到这个参数被赋值的具体上下文。2.3 找到参数生成调用链定位到 anti-content 赋值语句后通常能看到类似t.headers[anti-content] (0, s.getSign)()这样的代码。这里面的s.getSign就是从这个模块的 require 引用中取出的函数。接下来要做的就是把s对应的模块 ID 记录下来。我一般会在 Console 里临时执行require.resolve或者直接查看模块对象但拼多多的代码里不一定能把require暴露到全局。所以更稳的办法是回到格式化代码里找s的来源比如var s __webpack_require__(123)。这一步会得到一个模块 ID比如 123然后去模块表里把 123 对应的函数内容复制出来。此时不要急着复制整个文件只需要复制这个模块函数体以及后续会依赖到的其他模块。实际操作中我习惯用递归收集的方式先搜索这个模块里的所有require(数字)把数字记下来再去模块表里找对应的函数继续重复直到所有依赖都找齐。这一步虽然繁琐但非常必要因为缺一个模块运行就会报错。3. webpack 模块加载器与补环境原理3.1webpack_require到底做了什么很多人第一次接触 webpack看到那一大段打包代码就蒙了其实核心逻辑特别简单。__webpack_require__做了三件事判断这个模块是不是已经加载过了如果加载过直接返回缓存里的 exports。如果没有就新建一个module对象里面挂一个空对象exports。调用模块函数把module、module.exports、__webpack_require__传进去最终返回module.exports。这个过程和 Node.js 的 CommonJS 加载机制很像。所以我们在 Node.js 里复刻一个__webpack_require__本质上是模拟一个最小的模块加载器让抠出来的模块代码能正常互相引用。网上能找到很多现成模板但最好还是自己手写一遍理解更深。我写过的简化版长这样const modules { 0: function(module, exports, require) { const getSign require(1); module.exports { sign: getSign }; }, 1: function(module, exports, require) { module.exports function() { return sign-value; }; } }; const cache {}; function __webpack_require__(id) { if (cache[id]) return cache[id].exports; const module (cache[id] { exports: {} }); modules[id](module, module.exports, __webpack_require__); return module.exports; } const result __webpack_require__(0).sign(); console.log(result);3.2 为什么需要补环境如果直接把扣出来的 webpack 代码丢到 Node.js 里跑第一反应基本就是报错。原因很简单浏览器里的 JS 运行环境有window、document、navigator、location这些全局对象而 Node.js 的全局对象是global两者不是一回事。那些加密函数在浏览器里正常访问window.navigator.userAgent到了 Node.js 里连window都没有自然直接抛异常。补环境的本质就是在 Node.js 里把浏览器环境模拟出来让代码以为它还跑在浏览器里。很多人一听到“补环境”就觉得高级其实没那么玄乎就是缺什么补什么。真正麻烦的地方在于代码检测环境的方式非常多样有的直接读属性有的判断属性是否存在有的是判断原型的toString标签有的是访问某个属性时触发了 getter。这些都需要我们逐个去满足。3.3 原型链补环境最粗浅的补环境方式是直接把所有全局属性挂在global上global.window global; global.navigator { userAgent: Mozilla/5.0 };这种写法在一些简单场景下能用但遇到稍微聪明一点的代码就会被识别出来。因为真实浏览器里navigator是一个内置对象它的userAgent属性是挂在原型链上的而不是一个普通对象上面的可枚举属性。如果代码里执行Object.keys(navigator)真实环境得到的可能是空数组而上面这种粗暴写法会得到[userAgent]一眼就能分辨出来。所以更推荐用原型链的方式来补。先定义一个构造函数把属性和方法挂到原型上再创建实例赋值给全局变量function Navigator() {} Navigator.prototype.userAgent Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...; Navigator.prototype.platform Win32; Navigator.prototype.language zh-CN; global.navigator new Navigator();用这种方式模拟出来的对象属性访问正常Object.keys又不会暴露原型上的键和真实对象的行为更接近。这种思路在面试题里也经常出现考察的就是对 JavaScript 原型链的理解深度。很多“webpack 面试题”里会问“如何手动实现一个模块加载器”其实补环境里的原型链操作也是类似的思想。3.4 补环境不是越多越好我刚开始学补环境的时候总想在开始之前把所有可能用到的浏览器对象全部声明一遍结果发现这样反而更慢。因为每个项目访问的环境属性都不一样你补齐了 A它又报 B 缺失永远补不完。正确的做法是“按需补环境”跑一次看报错补一个再跑再看报错再补一个。每轮补的时候尽量定位到源代码里真正访问那个属性的一行理解它为什么要读这个属性。另外一个常见误区是把所有对象都补成同一个对象。比如有人图省事直接把window、document、navigator全部赋值为global这会在代码检测window ! document时直接失败。补环境的时候不同对象要有不同的形态和职责不能图省事。4. 实操扣出 anti-content 生成逻辑并跑通4.1 把模块代码搬出来定位到核心模块之后下一步就是把模块代码搬到本地。这里要注意webpack 模块表里的每一个模块都只是一个函数函数签名固定是function(module, exports, require)函数体内部才真正执行业务逻辑。我一般会把模块表做成一个 JavaScript 文件格式类似const modules { 123: function(module, exports, require) { // 这里放从浏览器里复制出来的模块函数体 }, 456: function(module, exports, require) { // 另一个依赖模块 } };然后写一个简单的模块加载器运行入口模块。如果你复制的模块比较多可以用脚本批量提取。我给一个简单思路在格式化后的整个 JS 文件里用正则匹配模块ID:function(module,exports,require){...}然后把函数体提取出来。不过正则容易出错建议前期手动复制熟悉之后再写自动化脚本。在复制模块函数体时一定要留意代码里是否有eval、new Function这类动态执行逻辑有的话要特别小心因为动态执行很容易拿到当前作用域外的变量导致运行结果和浏览器不一致。4.2 在 Node.js 里跑第一次拿到模块表之后写一个最小化的加载器并运行入口模块。这一步大概率会报错而且报错信息五花八门。常见的报错类型有ReferenceError: window is not defined缺少全局对象。TypeError: Cannot read property userAgent of undefined缺少 navigator。TypeError: document.createElement is not a function缺少 document。ReferenceError: location is not defined缺少 location。我的习惯是在补环境之前先全局搜索一下代码里到底用了多少个环境变量。可以用 VS Code 的全局搜索把window.、document.、navigator.、location.都搜一遍做个初步统计这样补起来更有方向。4.3 环境补全实录下面我记录一个简化版的补环境过程不是完全还原拼多多的代码而是展示常见的操作路径。第一次运行报了window is not defined。我直接在最上面加global.window global;但紧接着又报了Cannot read property location of undefined。因为代码里访问了window.location.href。所以我补充 location 对象global.location { href: https://example.com/path, host: example.com, hostname: example.com, protocol: https:, pathname: /path, search: };继续运行又遇到 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.plugins []; global.navigator new Navigator();这个时候程序能继续跑了但输出的结果和浏览器里的值对不上。我怀疑代码里读了一个我没补充的属性于是采用 Proxy 拦截的方式打印所有属性访问global.navigator new Proxy(new Navigator(), { get(target, prop) { console.log(navigator get:, prop); return target[prop]; } });通过打印我发现代码读取了navigator.webdriver这是一个很典型的自动化检测点。于是我显式设置Navigator.prototype.webdriver false;再执行最终得到的 anti-content 值和浏览器里的一致。整个过程大约用了十几轮“报错、定位、补充、再跑”的循环。只要思路清晰其实是体力活。4.4 验证结果而不是只看不报错很多人以为程序不报错就说明成功了其实并不一定。由于环境属性缺失会被当成undefined有时候代码并不会报错但计算结果已经错了。比如某些函数内部对一个 undefined 做了拼接得到的就是undefined字符串看起来能跑值却完全不对。我建议在浏览器的 Console 里先手动执行一次拿到真实的 anti-content 值然后再跑 Node.js 脚本两个值做对比。如果对不上就回到上一步继续用 Proxy 打印属性访问。还有一个技巧是在执行结果前插入一些调试日志把参与计算的中间变量打印出来和浏览器里手动计算的中间值做对比这样能快速定位是哪一步环境没补对。5. 常见问题与排查技巧5.1 变量值为 undefined 但不报错这个问题上面提到过最典型的表现就是程序正常跑完但结果的哈希值不对。排查思路很简单先确认参与计算的每个变量都有值尤其是时间戳、随机数、userAgent 这类容易变化的值。可以把整个环境对象都用 Proxy 包一层记录所有 get 操作然后逐个和浏览器里的执行结果对比。5.2 模块缺失导致的死循环或报错如果报错信息是Cannot find module 123说明还有依赖模块没抠出来。可以在当前模块函数体里搜索所有的require(123)这类调用然后去 webpack 模块表里补上对应 ID 的模块。有人会问能不能直接用正则批量把整个模块表都抠出来可以但要注意模块表很大里面可能包含大量无关代码运行效率会很低而且可能会因为某个无关模块引用了更复杂的浏览器 API给补环境增加负担。所以尽量只保留依赖链路上的模块。5.3 检测 window 与 document 的一致性有些代码会做window.document document这样的判断。如果你把window直接指向global而document单独定义那么这个判断可能为 false。我一般会统一维护一个环境对象让window.document和global.document指向同一个对象保持一致性。还有window.top window、window.self window这类判断也需要把引用关系打通。下面整理了一个问题速查表方便对照排查典型现象可能原因解决办法window is not defined缺少全局 window 对象global.window global同时补 location 等子对象navigator读取为 undefined缺少 navigator 对象用构造函数加原型的方式补完整属性document.createElement is not a functiondocument 对象太简陋补充 document 核心方法并让 createElement 返回带 style 等属性的对象程序不报错但结果不对某个环境变量被当成 undefined 参与运算用 Proxy 拦截打印属性访问检查中间变量Object.prototype.toString.call(navigator)不符合预期缺少 Symbol.toStringTag给原型的 Symbol.toStringTag 赋值为 Navigator模块运行时出现死循环或 CPU 飙升依赖模块缺失或加载器缓存异常检查模块表与加载器的缓存逻辑缩小模块范围5.4 关于 Symbol.toStringTag 的细节浏览器内置对象通常都会有类型标签比如Object.prototype.toString.call(navigator)返回的是[object Navigator]。我们手动补出来的对象默认返回[object Object]如果代码刚好做了这个判断就会出问题。解决办法是在原型上定义 Symbol.toStringTagObject.defineProperty(Navigator.prototype, Symbol.toStringTag, { value: Navigator, configurable: true });这种细节很冷门但遇到就会卡住。平时做补环境练习时多积累这些“冷知识”后面遇到类似问题就能快速反应。6. 一点学习心得做这个案例的过程中我比较强烈地感觉到理解构建工具比单纯写业务代码更有长尾价值。webpack 的打包优化配置本质上就是对模块加载策略的理解而 JS 逆向要求你反着理解这个加载策略两者相互印证学起来会很快。现在我再看 webpack 和 vite思路会更清晰webpack 打包后是一个自执行函数加模块表vite 则依赖浏览器原生 ESM模块暴露得更加直接。这也是为什么有的同学说 vite 项目里逆向更“容易”因为代码结构和源码差异更小。如果你是想通过这个案例入门 JS 逆向我的建议是不要只盯着 anti-content 这三个字母而是把重点放在“如何从打包代码里还原模块依赖”和“如何模拟浏览器对象”这两件事上。把这两个基本功练扎实从拼多多练到其他站点从 webpack 练到其他打包工具思路都是通透的。最后说一句技术本身是中性的拿来做正向学习、安全研究、性能分析都没问题但别用它去爬取他人数据或者绕过别人的风控系统。希望这篇学习笔记能帮你少走一些弯路。本文还有配套的精品资源点击获取
返回列表