资讯动态

JS逆向实战:webpack模块定位与签名还原完整教程

发布时间:2026/9/20 19:04:22 来源:尧图企业网站定制
做爬虫的肯定不会对JS逆向这个词陌生接口地址找到了参数也看到了但里面有个sign字段——不是明文是一串看起来毫无规律的字符串。你以为是简单的时间戳结果往代码里一翻发现整个加密逻辑被塞进了一个几千行甚至上万行的压缩 JS 文件里开头是!function(e){var t{};function n(r){...底下跟着一个巨大的数组。有经验的同行会告诉你这是 webpack 打包出来的。我最初开始接触 JS逆向时就是被这样的文件绕晕的前前后后浪费了不少时间才摸出头绪。这篇内容适合已经有 Python 爬虫基础、但还没系统接触过 JS逆向 的读者。我会以一次实际接口签名分析为主线把调试 webpack 打包代码、还原签名参数的完整链路拆开讲先认识 webpack 包的模块结构再讲怎么在浏览器 DevTools 里定位目标函数然后把模块搬进 Node.js 环境运行最后用 Python 完成调用。你跟着走一遍之后再遇到类似的站点心里就有底了。开始之前多说一句不管目标站点是谁先确保你的采集行为符合对方服务条款和相关法律法规以下内容只用于技术学习、接口调试和合规场景。1. webpack 包长什么样先学会“识货”再谈“拆解”1.1 一眼认出 webpack 的“厂牌涂装”webpack 打包后的 JS 有一个特别明显的特征整个文件是一个自执行函数函数体里定义了一个模块加载器然后传入一个巨大的参数。这个参数可能是数组也可能是对象里面的每一项都是一个函数——每个函数对应一个原始模块。这种结构你见过一次之后基本就不会认错。一个典型的 webpack 4 打包产物结构如下!function(e) { var t {}; function n(r) { if (t[r]) return t[r].exports; var o t[r] { i: r, l: !1, exports: {} }; return e[r].call(o.exports, o, o.exports, n), o.l !0, o.exports } n.m e, n.c t, // ... 各种运行时方法挂载 n(0) // 调用入口模块 }([ function(module, exports, n) { // 模块0 }, function(module, exports, n) { // 模块1 }, // ...更多模块 ])判断是不是 webpack有几个快速信号出现自执行函数内部有类似n.m、n.c的赋值模块函数统一接收module、exports、__webpack_require__三个参数代码里频繁出现module.exports和__webpack_require__。在 webpack 5 里模块 ID 可能从数字变成了模块路径但整体骨架不变运行时函数加上模块集合。1.2 模块加载器的工作方式最外层那个函数传进来的参数不管数组还是对象每一项都是一个模块函数。模块函数接收的三个参数里第一、二个是当前模块的导出容器第三个是加载其他模块的入口函数。运行时内部维护了一个模块缓存对象同一个模块被多处引用时不会重复执行第一次加载完成后exports会被缓存下次直接返回。我习惯用一个类比来记模块集合就像一本菜单每个模块函数是一道菜的做法__webpack_require__是服务员你说“5号菜”服务员先看这道菜有没有做过做过就直接端上来没做过就去后厨现做做完记住下次直接端。理解了这一点逆向思路就打开了只要拿到服务员模块加载器就可以点任意一道菜模块 ID也能看每道菜是怎么做的模块源码。1.3 入口从哪里找webpack 打包产物里所有模块本身不会主动执行只有被入口调用才会跑。入口通常藏在代码末尾常见形式有n(0)、__webpack_require__(0)、r(0)有的也会写成(0,t.default)({...})。在 DevTools 的 Sources 面板里打开文件点左下角格式化按钮然后搜索\.s 或.s。webpack 4 的入口一般会留下__webpack_require__.s 0这样的赋值语句没有的话就翻到文件末尾看最后一个调用。不过说实话定位入口不一定是你逆向的第一步我们真正想找的是“哪个模块负责生成签名”这个靠入口一步步跟可能绕很远用后面的定位方法效率更高。2. 定位签名函数的实战打法从 Network 到调用栈2.1 用 XHR/fetch 断点抓住请求发起的瞬间打开 DevTools 的 Network 面板找到带签名参数的那个请求右键它选择Copy再选Copy as fetch。复制到 Console 里备用然后用 XHR/fetch 断点这把“探针”来定位发起请求的代码位置。在 Sources 面板右侧找到XHR/fetch Breakpoints点击加号填入请求 URL 里的一段特征字符串比如/api/user/list。填好之后刷新页面或触发操作只要网页内部发起匹配该片段的请求执行就会在fetch或XMLHttpRequest.send那一行自动暂停。暂停之后不要盯着暂停那一行发呆马上看右边的Call Stack面板。调用栈就是从“发起请求”这个动作倒推回去的函数链。栈最顶端一般是send或fetch的原生代码再往下就是业务代码。在业务代码那一层点一下就能看到是谁调用了请求以及这个函数所在的模块和上下文。如果你的目标签名是在请求发起前计算出来的fetch断点暂停时源码里大概率就在附近有sign xxx(...)这样的赋值。往上翻几行找到赋值语句再点进那个函数基本就到加密函数本体了。2.2 全局搜索不是所有关键词都值得搜按下CtrlShiftFMac 上用CmdOptionF可以在全部文件里搜索文本。这里有个经验别上来就搜sign因为sign命中结果可能上千条反而把人看晕。我建议按照下面这个顺序搜。先搜接口路径字符串比如/user/list。webpack 模块里生成这个 URL 的模块大概率跟签名逻辑在同一个作用域附近结果会直接告诉你它出现在哪个文件哪一行。再搜页面上能看到的中文文案比如“加载更多”“刷新”。动态文案基本也在业务模块里可以作为定位锚点。然后搜加密算法特征词比如md5、sha256、encrypt、AES、CryptoJS。这些词如果存在往往就是算法库或对应调用处。最后才搜sign、token、params这类通用词因为它们命中太多适合在已经缩小目标范围之后用来确认。搜索时建议把正则模式打开。比如搜sign\s*能直接定位到赋值语句比搜sign少很多噪声。2.3 Hook 大法直接“监听”关键函数压缩代码里变量名全是e、t、n、r靠肉眼阅读代码定位函数确实容易劝退新手。这时候我习惯用另一个办法在 Console 里以重写 API 的方式挂一个探针。const _oldStringify JSON.stringify; JSON.stringify function(...args) { if (JSON.stringify.caller) { console.trace(); console.log(JSON.stringify args:, args); } return _oldStringify.apply(this, args); };这段代码把JSON.stringify重包了一层每次被调用时输出参数和调用栈。很多签名算法在拼接参数时一定会调用JSON.stringify探针可以直接告诉我们谁调用了它传入的参数是什么。类似的探针可以挂在Object.prototype.toString、String.prototype.replace上也可以挂在网站自己暴露到window上的加密函数上。探针方法不用一次定位到最终结果它的作用是快速缩小范围把“搜索关键词查半天”变成“看两条调用栈就有方向”。3. 把 webpack 模块搬进 Node.js组装一个可控的调试环境3.1 确认目标模块 ID定位到目标函数以后下一步是确认它在 webpack 的哪个模块里。格式化后的代码里你找到的那个函数会处于一个function(module, exports, __webpack_require__) { }包裹结构中。这个包裹函数就是模块函数。要拿模块 ID往回看这个包裹函数在整个数组里的下标或者对象里的 key。如果目标函数嵌套很深模块 ID 不容易直接看出来我常用两个办法。一是断点暂停时在 Console 里执行arguments.callee.caller.toString().slice(0, 200)查看当前调用者的函数源码从而定位到模块包裹层。不过有些代码启用了严格模式arguments.callee会报错那就用第二个办法在 Sources 面板里找到模块包裹层点行号打断点刷新复现请求暂停后看右侧 Scope 面板里的module变量里面会有i、l、exports等字段module.i就是模块 ID。3.2 保存 bundle 到本地开始改造拿到完整的 webpack bundle 以后在工作目录里把包含所有模块的文件保存为bundle.js。这个 bundle 可能是某个单独的 JS 文件也可能是一个总入口文件加上几十个 chunk 文件。研究阶段建议先在单个总 bundle 文件上练手它不需要处理跨文件依赖。创建好工作目录后在bundle.js末尾追加一段代码把模块加载器暴露出来。这不是对原站代码做破坏性修改只是给本地调试环境开一扇门this.__getModule __webpack_require__;如果是 webpack 5加载器可能被压缩成别的名字你需要先找一下运行时函数里那个负责加载模块的函数。实在找不到还可以在代码里搜索\.exports附近通常能定位到模块加载器本体。改完之后建议立刻用 Node 跑一次确认没有语法错误。3.3 在 Node.js 里加载并调用模块浏览器环境不可控而且每次都要打开 DevTools 刷新效率太低。确认模块 ID 之后直接写一个 Node 加载脚本const fs require(fs); const vm require(vm); const code fs.readFileSync(./bundle.js, utf-8); const sandbox {}; // 这里先补上运行环境后面会专门讲补什么 sandbox.window sandbox; sandbox.globalThis sandbox; sandbox.navigator { userAgent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) }; vm.createContext(sandbox); vm.runInContext(code, sandbox); // 假设已经确认目标模块 ID 是 87 const signModule sandbox.__getModule(87); console.log(signModule.toString());执行完以后如果signModule是一个对象说明 87 号模块导出的是一个模块对象如果它是一个函数说明这个模块把函数直接导出了。想要确认模块导出了什么还有个更直接的笨办法在bundle.js里找到 87 号模块的源码看它的module.exports到底赋值了什么。module.exports xxx是 webpack 模块的出口出口就是我们可以调用的接口。3.4 目标模块依赖其他模块怎么办webpack 模块之间靠__webpack_require__互相引用所以直接调用 87 号模块时它内部调用__webpack_require__(其他ID)也能正常工作因为整个模块集合还在原地加载器也在。这一步最省心。真正容易出问题的是目标模块依赖了浏览器专有 API比如document、window、navigator、localStorage。这就是下一章要解决的问题。4. 补环境到底在补什么不是玄学是运行时依赖4.1 报错不可怕怕的是“找不到对象”把 bundle 完整搬进 Node 后第一次执行大概率会报错常见的有window is not defined、document is not defined、navigator is not defined等。这个报错不是 bug是代码在提示你它需要什么。很多刚接触 JS逆向 的人栽在这一步老想着“是不是要把整个浏览器都搬进去”其实完全不需要。我们的目标不是把代码原封不动地跑成和浏览器一模一样而是让目标签名函数能用到的那些 API 都存在且返回值在合理范围内。说白了别让代码一跑就抛Cannot read property xxx of undefined就行。4.2 以“按需补齐”为原则而不是堆砌环境比如某网站生成签名时会读取浏览器的navigator.userAgent、screen.width、screen.height还会调用document.cookie那环境里就需要补这三个。如果它还会用 canvas 生成指纹补环境就更细一点需要往document.createElement返回的对象上挂getContext方法。下面这个最小环境模板是我在 Node 侧常用的起点let window {}; window.window window; window.self window; window.top window; window.parent window; window.navigator { 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: Win32, language: zh-CN, languages: [zh-CN, zh, en], webdriver: undefined }; window.document { cookie: , referrer: , title: , createElement: function(tag) { return { getContext: function() { return {}; }, addEventListener: function() {}, setAttribute: function() {}, getAttribute: function() { return null; }, appendChild: function() {}, style: {}, classList: { add: function() {}, remove: function() {} }, tagName: (tag || div).toUpperCase() }; }, getElementById: function() { return null; }, querySelector: function() { return null; }, querySelectorAll: function() { return []; }, addEventListener: function() {}, documentElement: { style: {} }, body: { appendChild: function() {}, style: {} }, head: { appendChild: function() {}, style: {} } }; window.location { href: https://target-site.com/, host: target-site.com, hostname: target-site.com, pathname: /, protocol: https:, origin: https://target-site.com, search: , hash: , reload: function() {} }; window.localStorage { getItem: function() { return null; }, setItem: function() {}, removeItem: function() {} }; window.sessionStorage { getItem: function() { return null; }, setItem: function() {}, removeItem: function() {} };4.3 用 Proxy 做兜底比逐条补属性省心补环境还有个更省事的思路用Proxy做一个“万能对象”。当代码访问一个不存在的方法时返回一个空函数当代码访问不存在的属性时返回undefined。这样大量不重要的环境依赖基本都能自动绕过。window new Proxy({}, { get(target, prop) { if (prop in target) return target[prop]; if (typeof prop string /^(get|set|create|append|query|add|remove|exec|send|request)/.test(prop)) { return function() {}; } return undefined; }, set(target, prop, value) { target[prop] value; return true; } }); window.navigator { userAgent: Mozilla/5.0 ... }; window.window window;不过要注意Proxy 不是万能的。如果签名算法内部用Object.keys(window)或for in window做环境检测那 Proxy 对象就会露馅。遇到这种情况要么老老实实把关键环境补全要么针对检测点做一个字段值修正。4.4 环境和签名函数隔离模块化你的调试代码补环境的时候建议把“环境代码”和“签名调用代码”分开不要最后揉成一个几千行的脚本。不然以后维护和排错都是灾难。我习惯用这样的目录结构sign-debug/ ├── bundle.js # 从站点保存的 webpack 包 ├── env.js # 专门放 window/document/navigator 等环境变量 ├── loader.js # 读取 bundle.js注入环境并暴露模块加载器 ├── test.js # 测试某个模块是否可调用的脚本 └── sign.js # 最终封装好的签名方法这样调试哪个模块就写哪个脚本报错也能快速定位是环境问题还是模块逻辑问题。5. Python 爬虫调用签名subprocess、execjs 与整体封装5.1 先想清楚架构签名逻辑运行在 Node 里很多爬虫新手有个误区觉得还原签名就是要用 Python 把 JS 算法重写一遍。这个想法在算法简单时可行比如就是“参数排序 密钥拼接 MD5”。但一旦遇到复杂的 webpack 模块重写成本极高。更好的思路是让签名逻辑继续跑在 Node.js 里Python 只负责把参数传进去把结果拿回来。这样做的好处有三个一是 JS 代码零改写只需要把模块出口暴露出来二是算法更新时同步更新 bundle 即可三是 Python 侧代码量小逻辑清晰。5.2 Node 侧封装把签名函数转成命令行可调用在 sign-debug 目录下创建get_sign.js把目标模块包装成可供外部调用的入口const fs require(fs); const vm require(vm); const code fs.readFileSync(./bundle.js, utf-8); const sandbox {}; // 补环境按需补齐 sandbox.window sandbox; sandbox.self sandbox; sandbox.globalThis sandbox; // 这里按需补齐 navigator/document/location 等... vm.createContext(sandbox); vm.runInContext(code, sandbox); const __getModule sandbox.__getModule; function getSign(params) { // 假设 87 号模块导出的是一个对象里面有个方法叫 getSign const signer __getModule(87); return signer.getSign(params); } module.exports { getSign }; if (require.main module) { // 命令行调用node get_sign.js {page:1} const input JSON.parse(process.argv[2]); console.log(JSON.stringify(getSign(input))); }这段代码的意思是如果get_sign.js是被node直接执行就走命令行分支把参数通过process.argv[2]接进来调用签名函数后把结果输出为 JSON。如果它被别人require就只导出getSign方法。5.3 Python 侧的第一种方案subprocess 调用 Node最常见的方案是直接用 Python 的subprocess启动一个 Node 进程把参数通过命令行传进去。命令行传参适合参数体积小的场景简单直观import subprocess import json def get_sign_from_js(params: dict) - str: params_json json.dumps(params, ensure_asciiFalse) proc subprocess.run( [node, get_sign.js, params_json], capture_outputTrue, textTrue, encodingutf-8, timeout5 ) if proc.returncode ! 0: raise RuntimeError(fNode 执行失败: {proc.stderr}) return json.loads(proc.stdout)[data]这个方案有三个细节要注意。第一个是编码Windows 上 Node 输出可能不是 UTF-8Python 默认会用系统编码读取可能乱码。解决方法是设置encodingutf-8必要时在 Node 侧用Buffer.from(JSON.stringify(result)).toString(base64)做一层中转Python 侧再 base64 解码彻底绕开编码问题。第二个是性能每次签名都启动一个 Node 进程进程初始化有开销。对于高并发爬虫建议用常驻 Node 服务代替。第三个是错误处理Node 脚本抛异常时proc.stderr里会有完整堆栈一定要把它带出来别只取 stdout。5.4 Python 侧的第二种方案execjs如果 Node 侧封装比较简单也可以用PyExecJSimport execjs with open(get_sign.js, r, encodingutf-8) as f: js_code f.read() ctx execjs.compile(js_code) result ctx.call(getSign, {page: 1})execjs 的好处是写起来简单自动管理 Node 子进程。但它在复杂场景下坑也不少Node 路径配置不对会报错、超时控制不如 subprocess 直接、Windows 下偶发环境问题。我现在更推荐直接用 subprocess 或常驻服务execjs 适合快速验证签名函数是否可用。方案优点缺点推荐场景subprocess可控性最强无重量级依赖每次启动进程有开销中低频调用、生产环境execjs写代码最少环境兼容性一般调试不便快速验证签名函数是否可用常驻 Node HTTP 服务性能最好连接复用要多维护一个服务进程高频调用、分布式爬虫5.5 常驻 Node 服务高频爬虫的进阶方案如果每分钟要生成上千个签名每次都 subprocess 开 Node 进程肯定扛不住。这时候我会直接在 sign-debug 目录里放一个server.js用 Node 内置的http模块起一个 HTTP 服务Python 侧用requests调用它const http require(http); function getSign(params) { // 和 get_sign.js 里的逻辑一样 } http.createServer((req, res) { if (req.method POST req.url /sign) { let body ; req.on(data, chunk body chunk); req.on(end, () { const params JSON.parse(body); const sign getSign(params); res.writeHead(200, { Content-Type: application/json }); res.end(JSON.stringify({ code: 0, data: sign })); }); } else { res.writeHead(404); res.end(); } }).listen(3000, () console.log(sign service listening on 3000));Python 侧就是一次普通 POST 请求import requests resp requests.post(http://127.0.0.1:3000/sign, json{page: 1}, timeout5) sign resp.json()[data]这个方案在不追求极致性能时完全够用。如果你用 Scrapy可以把这个 HTTP 调用封装成中间件在发送请求前统一给每个请求补上签名。6. 实操中容易踩的坑反调试、异步、模块耦合6.1 遇到无限 debugger 怎么办有些站点会在 webpack 模块里嵌入debugger语句配合setInterval反复触发让你一打开 DevTools 就陷入无限断点。处理思路分几种。第一种是在 Sources 面板的debugger那一行右键选Never pause here让调试器跳过这个位置。第二种是直接把触发debugger的定时器清掉在 Console 里执行for (let i 1; i 9999; i) { clearInterval(i); }第三种是修改Function.prototype的某些方法让代码里的debugger语句变得无效不过这招比较暴力一般用不上。遇到无限 debugger 时清定时器是最直接的方法。6.2 异步问题setTimeout 和 Promise 环境webpack 模块里有异步逻辑很常见比如setTimeout(() { ... }, 1000)、Promise.resolve().then(...)。在 Node 环境里这些异步逻辑会让模块的初始化顺序和浏览器里不同影响最终结果。大多数情况下我们调用的签名函数是同步的异步逻辑只发生在初始化阶段。如果遇到必须等待异步初始化完成的情况可以在get_sign.js里显式等待function getSign(params) { return new Promise((resolve, reject) { // 假设目标模块初始化时调用了一个回调 const result signer.getSign(params); resolve(result); }); }然后用 top-level await 或回调方式处理。Node 里的vm跑完同步代码后event loop 会继续跑微任务和定时器所以只要 Node 进程没有退出异步操作最终会执行。如果等不到就在外部设置超时计时器时间到了强制返回。6.3 模块耦合拿不到独立模块时的备用方案有些时候目标签名函数和整体业务耦合很深即使确认了模块 ID单独调用也还是报错。这种情况有一个备用方案通过浏览器自动化现场调用签名函数Python 侧用 Selenium 或 Playwright 控制浏览器在页面上下文里执行签名函数并返回结果。但这种方式性能差、容易被检测一般只建议在纯 Node 方案走不通时使用。另外一个思路是不直接调函数而是找签名算法里的核心步骤用 Python 重写。比如发现签名就是“把参数排序、拼上密钥、做一次 MD5”那重写成本就很低。这个重写决策要等在摸清算法逻辑后做别一开始就无脑重写。6.4 遇到 webpack 分包chunk怎么办前面画的都是单 bundle 场景。有些站点用了 webpack 的代码分割打包出几十个 chunk 文件按需加载。这种情况下主 bundle 里可能没有目标模块目标模块在一个异步加载的 chunk 里。调试这类站点时先用 Network 面板找到实际加载的 chunk 文件chunk 文件名通常带 hash比如1f3a2b.chunk.js。把它也保存下来和主 bundle 放在一起。模块加载器初始化时会注册 chunk 里的模块所以只要把 webpack 的 JSONP 数组或方法暴露出来把 chunk 内容合并进 bundle 里就能让模块加载器识别到更多模块 ID。window.__webpack_jsonp window.webpackJsonp || [];这一步相对复杂建议先尝试把多个 chunk 按顺序拼接再在整个文件末尾追加导出。拼接时要注意每个 chunk 的开头和结尾都有独立的 JSONP 注册代码不能简单删掉。写在最后的实操建议看到这里你已经走完了“识别 webpack → 定位签名 → 调试模块 → 补环境 → Python 对接”的完整链路。我自己在项目里跑这套流程的次数已经不少了最后分享几个朴素的建议。第一分析阶段一定要建立自己的“模块 ID → 功能”对照表。网页会改版但模块结构往往能复用有了对照表下次算法更新时可以少走很多弯路。第二遇到奇怪报错别急着搜索先在 Console 里打印stack大部分问题在报错信息里已经暗示了根因。第三准备一个已经调通的“模块加载模板”把补环境、暴露加载器这些基础工作固化下来以后拿到新 bundle 直接套模板能省掉大量重复劳动。JS逆向 不是一个死磕代码的活儿它很依赖“猜结构和验证猜想”的节奏感。多找几个 webpack 打包的网站练手把本文的定位流程跑通两三次你就能把整套方法内化成自己的手艺。等你哪天看到 webpack 压缩代码不再发晕而是下意识去找module.exports和入口调用时这门技术你就已经真正上手了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价