
做爬虫做到后面几乎都会撞上同一个坎接口参数里多了一个看不懂的签名。比如请求体里有个sign你直接去掉它服务端理都不理你你按固定字符串猜一段加密发现每次请求的sign都在变说明里面掺了时间戳、随机数或者 token。更麻烦的是前端代码压根不是人写的整个 js 文件被 webpack 打包成了一坨格式化之后全是数字 ID 和压缩过的变量名想靠肉眼理解逻辑根本不现实。这篇博文就从我的实际经验出发围绕“webpack 打包的代码怎么调试”和“签名参数怎么还原”这两个问题完整跑一遍流程。从浏览器开发者工具定位加密函数到理解 webpack 模块运行机制再到用 Python 写出等价签名逻辑最后成功请求接口每一步都会提到关键操作和我踩过的坑。适合刚接触 JS 逆向、想系统了解签名还原思路的爬虫开发者和前端调试爱好者。我会尽量少说废话直接给方法论和可复现的步骤。1. 先搞懂 webpack 打包出来的代码长什么样1.1 模块化代码的“包装”方式webpack 的核心作用是把项目里分散的模块文件合并成少数几个浏览器可用的 bundle 文件。它不会把你的逻辑原样平铺而是给每个模块外面包一层函数整个 bundle 用一个立即执行函数包裹。大致结构是这样(function(modules) { var __webpack_require__ function(moduleId) { var module { exports: {} }; modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); return module.exports; }; return __webpack_require__(0); })([ /* 0 */ function(module, exports, __webpack_require__) { // 你的一部分业务代码 }, /* 1 */ function(module, exports, __webpack_require__) { // 另一部分业务代码 } ]);不同版本和配置的 webpack 产物细节会有差异比如用对象而非数组、模块 ID 用相对路径字符串、加上__webpack_require__.r、__webpack_require__.d这类辅助方法但总体思想一致模块 ID 决定位置__webpack_require__负责加载模块。遇到压缩过的变量名就只有r、t、e、n这种单字母读起来非常痛苦。遇到这种代码第一件事不是直接读而是先格式化。在 Chrome DevTools 的 Sources 面板里底部状态栏最左侧有一个格式化按钮点击后会把压缩代码展开成多行虽然变量名还是乱的但至少结构清晰了花括号、函数边界一眼就能看清。更推荐的做法是把代码复制出来放到本地编辑器里用 Prettier 格式化这样后续搜索、定位都方便。1.2 模块加载器如何工作理解__webpack_require__的工作方式对于调试签名逻辑很重要。它的作用很像 Node.js 里的require接收一个模块 ID找到对应模块函数执行它最后返回模块的exports对象。同一个模块被多次引用时webpack通常会对模块做缓存也就是说模块代码只会执行一次后续都拿缓存里的导出值。调试时我很喜欢利用这个机制。很多网站把加密算法放在某个独立模块里其他模块统一调用它。我只要找到哪个模块负责加密在对应模块函数的第一行打断点就能看到所有调用它的地方非常方便。因为在模块函数入口处arguments里就能看到调用方传进来的参数浏览器会直接显示调用栈往上点一层就是业务调用逻辑。另外要注意 webpack 5 常见的异步 chunk 加载。入口 bundle 可能只加载主逻辑真正的加密模块在某个独立的 JS 文件里通过网络动态加载。这种文件一般长这样(self[webpackChunk] self[webpackChunk] || []).push([ [chunk-name], { 123: function(module, exports, __webpack_require__) {} } ]);遇到这种结构搜索关键词的工具就从“搜索单个文件”变成“搜索所有文件”。DevTools 里按CtrlShiftF在搜索框输入关键词比如chunk、模块 ID或者直接搜加密特征字符串就能定位到具体 JS 资源。千万记得在 Network 面板里确认目标 chunk 已经加载否则搜不到。1.3 从压缩代码到可读代码的第一步拿到格式化代码后我一般会先做一次“变量重命名”和“函数拆分”的心理映射。压缩工具常做的操作包括变量名单字母化、删除空格换行、把参数尽量合并、内联简单调用。比如一段签名代码压缩前可能是function generateSign(params, secret) { const arr Object.keys(params).sort(); const str arr.map(key key params[key]).join(); return md5(str secret); }压缩加改名后就变成function k(e, t) { return r(Object.keys(e).sort().map(n n e[n]).join() t); }实际上加密逻辑没有变变的只是名字和排版。调试思路在理解模板后就很清晰先搜索md5、sha256、sign、encrypt、secret、key、token这些关键词找到函数体然后顺着函数调用关系走一遍把单字母变量名替换成有语义的名字最后把这个逻辑搬到 Python逐行等价转换。2. 定位签名参数的生成逻辑2.1 关键字搜索法这个方法最直接也是我每次动手第一件事。打开 Chrome DevTools切到 Sources 面板按CtrlShiftF全文件搜索。关键词优先试这些sign、signature、sigtoken、secret、keymd5、sha1、sha256、hmacencrypt、decrypt、sha、cipher参数名本身比如请求里的字段叫nonce、timestamp、params搜索sign可能出现大量干扰结果因为这个词在正常业务代码里也很常见可能是“signal”、可能是“design”也可能就是一个变量名包含这三个字母。建议配合搜索关键词的上下文筛选。比较好的起点是先看请求里实际传的参数名比如sign、x-sign、token-sign直接搜这个完整字段名。搜到候选位置后先别急着分析先在那一行打断点。大多数压缩代码用debugger语句或者单行函数封装打断点后重新触发一次请求就能看到加密函数被调用时的输入参数。如果打断点后停不下来说明这个函数不是每次请求都会走可能还有前置条件或者在异步回调里需要耐心等一下或者多触发几次操作。另一种情况是加密函数并不在入口链路中而是通过事件绑定触发这时候需要从发起请求的地方反向找。2.2 从网络请求反向定位如果纯搜关键词找不到就从网络请求入口反向查。打开 Network 面板找到目标 API 请求点击 Initiator 列这里会显示这个请求是哪个 JS 函数的XHR或者fetch发起的。点击调用栈里的函数名DevTools 会自动跳到对应代码位置并停留在发请求的语句上。这个办法的优点是不依赖关键词是否混淆也不管加密函数叫什么名字因为它是从真实的请求链路反推的。到了请求发起处向上看几个函数作用域通常能发现签名参数是在headers、params、body中构造的。顺着赋值语句往前找就能定位到计算签名的那段代码。补充一个实用小技巧在XHR和fetch的发起处挂一个条件断点。在请求代码行上右键选择Edit breakpoint填写一个只在目标 URL 出现时命中的条件表达式示例写法this._url.indexOf(/api/search) ! -1这样其他无关请求不会频繁断下调试体验会舒服很多。2.3 hook 关键函数收集调用栈有时候加密函数隐藏得很深搜索搜不到网络请求入口也找得费劲。这时候可以考虑用 hook 的技巧。原理很简单在加密函数被调用之前把某个公共方法替换成我们自己的实现在新的实现里打印参数、调用栈和返回值再转调原始函数。最常见的 hook 目标是JSON.stringify因为很多签名算法会先把对象序列化成字符串再拼盐哈希。在 DevTools Console 里执行(function() { var originalStringify JSON.stringify; JSON.stringify function() { console.trace(JSON.stringify called); console.log(arguments:, arguments); var result originalStringify.apply(this, arguments); console.log(result:, result); return result; }; })();这样一旦业务代码调用JSON.stringify控制台就会打印当时的调用栈和参数。着重要看的是调用栈顶部那里就是真正调用签名函数的位置。类似地可以 hookFunction.prototype.call、Array.prototype.sort、String.prototype.replace但不要全 hook避免打印太多日志导致页面卡死。一个比较稳的做法是逐个 hook先用JSON.stringify不行再换别的。Hook 是很实用的动态调试手段能绕过大量静态代码分析的时间消耗。3. 在 webpack 模块结构中精准调试3.1 模块 ID 和调用关系理解 webpack 的模块机制后定位逻辑其实不难。整个 bundle 的入口就是__webpack_require__(0)或某个对象键对应的模块。我们关心的加密函数一定存在于某个模块的内部。调试前先把格式化后的 bundle 保存到本地整体搜索模块 ID把可疑模块的代码单独拎出来。更高效的办法是借助浏览器调试器的“作用域”和“调用栈”面板。停在可疑函数内部时Scope 面板会列出当前作用域内所有变量调用栈面板则会显示从入口到当前函数的一整条调用链。顺着调用链翻能看到每个模块的引用关系。这个方式比纯粹从代码文本里猜要快得多。模块 ID 一般会在模块函数前面以注释形式标注比如/* 12 */。如果压缩配置把这个注释去掉了可以看__webpack_require__.e异步加载时用的函数里的 chunk ID或者在模块函数第一行打断点看moduleId参数的实际值。3.2 断点调试的实操细节定位到模块函数后具体调试步骤我习惯这样做在模块函数第一行打断点。这个地方命中时会显示模块 ID在 Scope 里能看到module.exports的初始内容。往下逐行执行关注exports对象上挂载了哪些方法。加密函数通常会被挂到exports.xxx或者module.exports { ... }上。在浏览器 Console 手动调用这个模块的导出函数传入测试参数观察返回结果。要拿到模块导出对象可以临时修改变量引用。举个例子假设格式化后的代码里模块 12 导出了一个encrypt函数。我可以在模块函数最后一行打断点等它执行完后在 Console 里直接执行__webpack_require__(12).encrypt(test)前提是__webpack_require__还在当前作用域可见。如果当前函数闭包把__webpack_require__变量名改掉了比如压缩成了t那就用t(12).encrypt(test)。这个方法很适合验证某个加密函数的行为。还有一个小技巧是用Object.defineProperty或者直接在 Console 中修改module.exports给它增加一个自定义的debug方法把内部变量暴露出来。这在某些需要对内部状态做检查的场景非常有用。3.3 快速把目标模块抠出来单独运行很多人在分析完逻辑后会在 Python 里重写一遍算法。但有时候 JS 用了非常复杂的混淆或者依赖了很多外部库重写成本很高。这时候可以考虑“抠模块”方案把 webpack 里相关的几个模块代码复制出来配上自己写的一个 mini runtime在 Node.js 里直接运行。构造一个最小 runtime 其实很简单核心只做三件事定义模块数组或对象实现一个require函数调用入口模块例如// node 环境中运行 const modules { 12: function(module, exports, require) { // 从网页里复制出来的模块代码 }, 34: function(module, exports, require) { // 依赖模块 } }; function require(moduleId) { const module { exports: {} }; modules[moduleId](module, module.exports, require); return module.exports; } const encrypt require(12).encrypt; console.log(encrypt(test));执行后如果能得到与浏览器一致的结果说明模块依赖提取正确后续 Python 复现也就有了直接对照。如果报错提示某个函数未定义说明那个模块没有被包含进来回 bundle 里搜索对应的模块 ID补进modules对象里就行。这个方案对入门者更快因为不需要完全读懂全部逻辑只要保证被抠出来的模块能运行即可。4. 用 Python 完整还原签名参数4.1 从 JS 逻辑到 Python 逻辑的映射思路把 JS 代码转换成 Python 代码其实是在做两件事数据结构映射和算法映射。数据结构上JS 的普通对象对应 Python 的 dict数组对应 list字符串和数字基本一一对应。算法上JS 里常见的md5、sha256在 Python 的hashlib里都有同款CryptoJS.AES.encrypt、CryptoJS.HmacSHA256也有对应的第三方库pycryptodome或者直接用标准库。有一点最容易踩坑JS 的字符串编码、字节序、补位、URL 编码规则和 Python 未必一致。比如encodeURIComponent和 Python 的quote并不是全等关系CryptoJS.enc.Utf8.parse对应 Python 的text.encode(utf-8)但CryptoJS.enc.Base64.stringify对应 Python 的base64.b64encode(...).decode()。转换前先确认 JS 用的是哪种编码方式否则结果对不上。再有一个是 JS 对号拼接字符串的处理。很多签名逻辑是Object.keys(params).sort()然后map拼成a1b2再加盐。Python 里对应写法是sorted_keys sorted(params.keys()) base_str .join(f{k}{params[k]} for k in sorted_keys) secret组合起来就是完整还原过程。先保持每一步的输出和浏览器一致再往下走千万不要直接一步到位写整个签名函数否则中间任何一步出错都难以排查。4.2 一个签名还原案例的完整流程假设我在某个网站遇到下面的请求参数uri: /api/v1/search timestamp: 1734567890 nonce: a1b2c3d4 sign: 6f3e9b3f3a0b04f9a2e05ce8f4c9e2a1浏览器里搜到签名相关的压缩代码格式化后发现核心逻辑如下function sign(params) { var secret my-secret-key; var str uri params.uri timestamp params.timestamp nonce params.nonce; return md5(str secret).toString().toUpperCase(); }实际情况里参数会更多、拼接逻辑会更复杂这里为了演示做了脱敏简化但还原思路是一样的。我从格式化代码里看出来签名拼接顺序是固定的uri、timestamp、nonce盐值是硬编码在 JS 里的字符串哈希算法是 MD5最终结果转大写于是用 Python 写出等价函数import hashlib import time import random import string def generate_sign(uri: str, timestamp: str, nonce: str) - str: secret my-secret-key raw furi{uri}timestamp{timestamp}nonce{nonce}{secret} return hashlib.md5(raw.encode(utf-8)).hexdigest().upper() def main(): uri /api/v1/search timestamp str(int(time.time())) nonce .join(random.choices(string.ascii_lowercase string.digits, k8)) sign generate_sign(uri, timestamp, nonce) print(uri, timestamp, nonce, sign) if __name__ __main__: main()接着用 requests 构造请求import requests def fetch_data(): ts str(int(time.time())) nonce .join(random.choices(string.ascii_letters string.digits, k8)) sign generate_sign(/api/v1/search, ts, nonce) params { uri: /api/v1/search, timestamp: ts, nonce: nonce, sign: sign, } resp requests.post(https://example.com/api/v1/search, dataparams) print(resp.status_code) print(resp.json())如果返回结果里包含正常数据说明签名已经通过。如果服务端返回签名错误则优先逐项排查拼接顺序、大小写、编码这三处。4.3 边调边测让 Python 结果和浏览器保持一致很多人被签名搞崩溃的最大原因是还原后的 Python 代码算出的值和浏览器里的值不一致又不知道哪里不一致。我建议做“边调边测”在浏览器 Console 里手动调用原生签名函数打印中间值同时在 Python 里打印同样步骤的中间值两个对照很快就知道差别在哪。操作步骤是这样在浏览器 Console 里执行一次原 JS 签名函数比如sign({uri: /api/v1/search, timestamp: 1734567890, nonce: a1b2c3d4})记录输出。在 Python 里用同样参数调用你的函数比较结果。不一样时把 JS 里每一步的字符串打印出来Python 里也一样打印逐段比较。举一个真实遇到过的情况网站 JS 里对时间戳做了字符串拼接看起来像是timestamp nonce实际上先调用了String(timestamp)而且内部把时间戳当作字符串处理。Python 里如果用整数拼接结果自然不一致。这种问题很难一眼看出来必须靠打印中间值排查。另一个高频坑是 hash 的结果。Python 的.hexdigest()默认小写JS 的.toString()默认也是小写但很多网站上层逻辑会再调用.toUpperCase()导致最终请求包里是大写。只写界面的sign不仔细看大小写调试起来会很痛苦。建议每次还原后在代码里显式调用.upper()或.lower()不要靠运气。还有base64的问题。如果签名用 AES 或 RSAJS 里真正参与请求的字符串往往是 Base64 编码后的密文。Python 里对应逻辑是先对字节做加密再base64.b64encode()最后再.decode(utf-8)。顺序反了就完全对不上。这种细节建议在还原前先列一个“编码转换对照表”避免在脑子里绕。5. 常见问题与排查技巧5.1 格式化后变量名还是看不懂怎么办这是很多新手的第一个坎。格式化只是让代码的排版变正常并不会把变量名还原成有意义的单词。被压缩的代码里a、b、c随处可见。我的应对方法有两种可以一起用。第一不读全部代码只关心和请求参数相关的数据流。在函数入口打断点关注参数的变化。只要知道输入是什么、输出是什么中间的变量名完全可以先忽略。第二借助浏览器的“重命名”能力。在 Scope 面板里右键变量可以手动修改显示名称。虽然不改动原始代码但对理解逻辑很有帮助。这个功能适合在分析长函数时使用我用它把n改成params、把r改成result读起来顺畅很多。5.2 签名逻辑调用了很多层函数怎么办遇到层层封装、甚至互相递归的函数最忌讳的是顺着调用栈一层一层读到底。很多页面用了统一封装的请求库签名逻辑藏得很深顺着看可能几百行下去都找不到算法在哪里。应对方案是抓“算法特征”。搜索代码里的md5、sha256、encrypt、HmacSHA256等关键字或者搜索硬编码的盐值字符串。比如我曾在某个目标站点里搜timeStamp没搜到有用的但搜salt直接命中了核心加密代码。算法特征比参数名更可靠因为参数名可以被混淆算法实现很难完全藏起来。另一个办法是看最后的赋值语句。在请求函数里签名参数最终一定被赋给了params、headers或body对象。在赋值那一行打断点反向查看这个值来自哪个变量、哪个函数调用通常能快速找到源头。5.3 常见问题速查表问题现象可能原因排查思路签名总是校验失败拼接顺序不对或盐值错误在 JS 和 Python 里同时打印原始字符串逐一对比Python 结果和 JS 一致但接口仍报错接口还有额外参数或时间戳校验检查请求头、cookie、请求体是否还有别的动态值搜索关键字搜不到加密函数代码经过深度混淆或字符串拆解尝试搜索分段字符串、hook JSON.stringify、从网络请求反向找在 Console 里调用__webpack_require__(12)报错变量名被压缩或不在当前作用域在模块函数内打断点查看实际引用名再调用计算结果大小写不一致上层逻辑调用了.toUpperCase()或.toLowerCase()对比最终请求参数显式统一大小写字符串拼接多出空格或引号JS 模板字符串里的细节被忽略复制原始字符串到 Python 里做精确比对表格里的问题是我实际工作中反复遇到的。尤其最后一条最容易被忽略。比如 JS 里用了uri uri但代码压缩后变成urie中间的空格消失肉眼很难发现。但只要用字符串比较问题立刻暴露。5.4 两个提高效率的调试习惯调试 JS 逆向和写业务代码不一样核心不是写得多而是验证得快。我个人有两条习惯一直觉得回报很高。一条是“能打印的不要猜”。不管是浏览器 Console 还是 Python多用console.log或print输出中间值。很多 bug 其实是猜错了某个变量对应的结果一打印就清楚了。另一条是“改代码不如改数据”。如果只是想验证某个算法的正确性没必要修改页面代码可以在 Console 里传参调用现有函数。把参数准备成和真实请求一致的对象直接调用导出的签名函数再用返回值去请求接口。这个方式改动最小也最能真实反映线上逻辑。6. 写在最后的一些体会做逆向调试这几年我最大的感受是它考验的不是“会不会读代码”而是“会不会验证”。webpack 打包再混淆代码的运行时行为是骗不了人的。打断点、看调用栈、hook 关键方法、打印中间值这套组合拳用熟练后大多数签名参数都能在半小时内定位到核心逻辑。Python 还原签名参数也一样难的不是语法翻译而是对编码、拼接、大小写、字节序这些细节的敏感度。只要把浏览器的输出和 Python 的输出逐步对齐哪怕遇到再复杂的算法也能一层层剥出来。遇到搞不定的情况不妨把目标拆小先复现字符串拼接再复现哈希最后再做完整请求。每一步验证通过后再继续基本不会跑偏。希望这篇内容能帮你走通第一遍完整的调试流程。后面如果再遇到 webpack 打包的项目可以按这个思路先定位模块、再抠函数、最后用 Python 复现整个过程会顺畅很多。