做前端的人应该都有过这种体验:打开 QQ 音乐网页版,随手搜个歌、拉个歌单,Network 面板里一片请求,点开一看,请求头里有个 sign 字段,请求体不是明文 JSON,而是一串密文,返回的响应里 data 字段也完全不是能直接读的结构。我第一次碰这玩意的时候,盯着屏幕上那串密文看了半天,脑子里只有一个问题:这到底是谁在什么时候给我加密的?
这篇文章就围绕这三块展开:请求头里的 sign 签名到底怎么算出来的,请求体 payload 是用什么算法、什么参数加密的,以及响应回来之后那层解密逻辑是怎么还原的。我尽量把定位思路、断点调试、还原推导、离线复现这些过程都讲透,不只会告诉你结果,更会告诉你我是怎么一步步找到这个结果的。适合有一点 JS 基础、对 Web 逆向或者接口调试感兴趣的朋友,最好能自己打开 DevTools 跟着操作一遍。
1. 内容整体设计与思路拆解
1.1 目标定性与加密链路识别
先把我们要干的事说清楚。很多人一提 JS 逆向就想到“破解某网站”,其实这类工作的核心是搞清楚一个 Web 应用的三件事:请求如何被构造、参数如何被签名、数据如何被加解密。QQ 音乐网页版正好是一个很典型的研究样本,因为它同时包含三层防护,三层之间还有先后依赖关系。
这三层分别是:
- 请求头里的
sign签名,作用是让服务端能校验请求是否被篡改、是否来自伪造客户端; - 请求体
payload的对称加密,作用是让中间人即使抓到包也看不到实际提交的业务参数; - 响应体
data字段的对称解密,作用是防止响应内容被直接读取或篡改。
这三层不是独立的。实际请求流程是这样的:客户端构造完业务参数后,先做一次 payload 加密,把明文参数变成密文放进请求体;然后基于请求体(或请求体的一部分)结合某些固定参数计算 sign,把签名放进请求头;请求发出后,服务端校验签名、解密请求体,返回的响应数据再以密文形式下发,前端在接收到响应时统一解密一次,之后业务代码才能拿到真实数据。
所以做还原的时候,不能只盯着某一个加密函数看,得把整条链路串起来。我的建议是先从抓包出发,把一次完整请求的前后状态都记录下来,再回到代码里逐步定位,最后把算法抽象出来离线复现。这个过程很依赖经验和耐心,但路径其实非常固定。
1.2 方案选型:为什么从浏览器调试切入
面对一个前端加密体系,可选的分析路径其实就那么几条。
第一条是抓包改包,比如用 Fiddler、Charles 这类工具,观察请求内容、手动篡改参数看服务端反馈。这条路径能快速判断某个字段是否参与签名,但没法看到加密前的真实参数,也看不到加密算法本身。
第二条是静态分析,把 JS 文件下载下来,格式化之后全局搜索关键词,比如 sign、encrypt、decrypt、aes 这些。对小型项目很有效,但 QQ 音乐网页版是打包压缩过的代码,单个文件可能几万行,直接搜会搜出一大堆无关结果,新手很容易迷失。
第三条是我推荐的主路径:浏览器动态调试。打开 DevTools,在 XHR/fetch 发送处打断点,往回翻调用栈,一层层找到加密函数和签名函数。这个方法的核心优势是能直接看到函数被调用时的实时参数,也就是明文和密文的对应关系,只要抓到一组对应样本,算法还原就完成了一大半。
不过只有动态调试还不够。压缩混淆后的代码里,函数名和变量名完全没有语义,纯靠读代码非常痛苦。所以要配合两个辅助手段:一个是给 XHR 打 hook,在请求发出前记录调用栈,帮助我们快速锁定加密发生的位置;另一个是对关键 JS 文件做格式化,把混淆代码排列成可读结构,再结合变量赋值关系推断参数含义。整个方案选型的逻辑就是:以动态调试为主线,以 hook 和格式化作为辅助,尽量用最小的成本锁定加密位置。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 sign 签名:请求头里的“身份证”
sign 的本质是一个签名参数,服务端拿到后会按照同样的规则重新计算一遍,对比是否一致,不一致就拒绝请求。它的作用类似身份证上的防伪标记,既能防篡改,也能挡住一些简单的自动化调用。QQ 音乐网页版的签名实际不是一套逻辑走天下,不同接口可能走不同的分支,但整体思路一致:取请求中的关键参数,按特定顺序拼接,加上固定盐值,然后做 MD5。
定位 sign 的计算位置时,我一般会从 Network 面板复制一次完整的请求 headers,里面有 sign 字段。切到 Sources 面板,按 Ctrl+Shift+F 在所有文件里搜索 sign。压缩后的代码里搜出来会很多,但注意观察列表里那些文件体积大、命名带版本 hash 的 JS,十有八九你想要的逻辑就在里面。点进去之后先点左下角的格式化按钮,让代码变成多行可读形式,然后再搜一次 sign。
这里有个很重要的实操技巧:不要只搜 sign,也搜一下 sign= 或者 "sign",因为混淆后的代码可能把属性名拼出来,比如用了 "si"+"gn" 这种写法。真要遇到这种情况,直接搜索是找不到的,更可靠的方式是给 XHR 打 hook。在 DevTools 的 Console 里执行一段拦截脚本,把每次请求的 URL、headers、body 连同调用栈都打印出来,再从调用栈里定位到发起请求的那一层,往上找 sign 的赋值语句。打过几次之后你会发现,这比盲目搜索有效得多。
找到赋值语句后,下一步是判断参与签名的参数范围。我的习惯是修改某个业务参数,重新发起请求,对比前后两次请求的 sign 是否变化。如果变化,说明该参数参与签名;如果不变,说明不在签名范围内。通过这种差分对比,可以逐步收敛签名因子。
javascript复制// 还原思路示意:sign 的生成不是固定字符串,而是参数拼接后做 MD5
// 实际参数名和顺序需要根据目标版本动态确认
function generateSign(params, timestamp) {
// params 是参与的请求参数对象
var keys = Object.keys(params).sort();
var raw = keys.map(function (key) {
return key + "=" + params[key];
}).join("&");
// SECRET_SALT 是隐藏在 JS 里的固定盐值
return md5(raw + "&" + timestamp + "&key=" + SECRET_SALT);
}
上面这段只是还原思路的示意,不是现成可用的代码。真实版本里的拼接顺序、是否参与排序、盐值藏在哪个变量里,都需要在断点处直接打印看结果。我一般会在签名函数内部下一处断点,刷新页面触发一次请求,然后在 Console 里打印这几个变量的值,再对着抓包里的 sign 比对。如果一致,说明这个分支找对了;不一致,就继续向上回溯调用栈。
2.2 payload 加密:请求体为什么是一串乱码
请求体本身不是明文 JSON,而是一段经过 AES 加密后的字符串。为什么选 AES 而不是 RSA?很简单,AES 是对称加密,加解密速度快,适合前后端高频交互;RSA 虽然更安全,但性能开销大,通常只用来加密 AES 的密钥。前端场景里,AES 密钥无论如何都会暴露在 JS 代码里,所以靠它防住深度逆向不现实,但足以挡住普通的抓包查看。
定位 payload 加密逻辑,思路和找 sign 类似,多了一个关键词语列表:encrypt、CryptoJS、mode.CBC、Pkcs7、iv、key。QQ 音乐网页版用的还是经典的 CryptoJS 系列,打包体积很大,搜索时很容易找到。找到加密函数后,在函数入口处断点,刷新页面,可以看到传入的参数正好是你想要提交的明文字符串,而函数返回值就是请求体里的密文。
有一个细节值得一说:真正做 AES 加密之前,往往会先对明文做一次 JSON 序列化,也就是 JSON.stringify 之后才进入加密函数。所以你在断点里看到的明文参数不是原始对象,而是一段字符串。解密时同理,先做 AES 解密,得到字符串,再 JSON.parse 回对象。这个流程搞清楚了,后面自己写还原脚本时才不会乱。
AES 还原的关键是四个参数:密钥 key、偏移向量 iv、加密模式 mode、填充方式 padding。其中 mode 和 padding 通常一眼就能认出来,最常见的是 CBC + Pkcs7。真正麻烦的是 key 和 iv 的值,它俩一般不是写在代码里的明文字符串,而是通过某个函数从固定字符串派生出来的。比如有一段固定的种子字符串,先做一次 MD5 得到 32 位结果,再取前 16 位作为 key,取后 16 位作为 iv。这种派生逻辑在断点里能看到,只要在加密函数内部往下多走几步,观察传给 CryptoJS 的 key 和 iv 变量是怎么算出来的就行。
用 Node.js 还原 payload 加密时,骨架大概是这样的:
javascript复制const CryptoJS = require("crypto-js");
// 根据实际版本从代码中提取后填入
const SECRET_KEY = CryptoJS.enc.Utf8.parse("1234567890abcdef"); // 占位示意
const SECRET_IV = CryptoJS.enc.Utf8.parse("abcdef1234567890"); // 占位示意
function encryptPayload(params) {
const plainText = JSON.stringify(params);
const encrypted = CryptoJS.AES.encrypt(plainText, SECRET_KEY, {
iv: SECRET_IV,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
});
return encrypted.toString();
}
注意 SECRET_KEY 和 SECRET_IV 我用的是占位字符串,真实的 key 和 iv 需要你自己在断点里提取。不过只要你把模式、填充方式确认对了,提取出正确的 key 和 iv,这段代码就能跑出和网页端一致的密文。我每次还原完都会先拿线上抓包的密文和本地跑出来的结果对比一次,能对得上,这道关才算真正过了。
2.3 响应解密:拿到的数据为什么是密文
服务端返回的响应里,核心数据字段往往不是明文,而是一段 base64 字符串。前端在拿到请求结果后,会先解析响应体,判断 code 或状态字段,再对 data 字段做一次解密,之后业务代码才能正常渲染。这层解密的定位思路和加密几乎对称,直接搜索 decrypt 关键词即可。
有一个判断技巧:响应解密函数通常出现在 Axios 拦截器里。因为前端框架一般会把响应统一拦截下来做预处理,所以你搜索 interceptors.response 或者 decrypt 都能快速定位到。找到解密函数后,在解密函数内部下断点,刷新一次请求,你会看到入参是响应里的密文字符串,返回值是解密后的明文字符串。在这一步,你把断点里的 key、iv、mode、padding 全部记录好,解密部分基本就拿下了。
javascript复制function decryptResponse(encryptedData) {
const bytes = CryptoJS.AES.decrypt(encryptedData, SECRET_KEY, {
iv: SECRET_IV,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
});
const plainText = bytes.toString(CryptoJS.enc.Utf8);
return JSON.parse(plainText);
}
有个容易踩的坑:密文传输时一般会做 URL 编码或 base64 编码,如果直接拿密文去解密,偶尔会遇到长度不对、填充错误之类的报错。正确做法是先按抓包里的原始字符串处理,如果发现像是被 URL 编码过,就先用 decodeURIComponent 还原,再丢给 AES 解密。这类细节极不起眼,但排查起来很耗费时间。
3. 实操过程与核心环节实现
3.1 环境准备与目标选取
工欲善其事,必先利其器。做 QQ 音乐 Web 端逆向复现,我的工具清单非常固定:
- Chrome 或 Edge 浏览器,自带 DevTools,用于抓包和动态调试;
- 一个支持全局搜索的编辑器,比如 VS Code,用于分析下载好的 JS 文件;
- Node.js 环境,用于搭建本地还原脚本,验证算法和参数是否正确;
- 一个抓包工具(Fiddler 或 Charles),备选,用于观察浏览器之外的上层请求。
目标接口我建议选“首页推荐”或“搜索联想”这类高频接口。它们返回的数据结构丰富,且请求头里一定带 sign,请求体一定是密文,响应里一定带加密的 data 字段。选好了之后,先把当前页面的请求列表完整清空一次,刷新页面,稳稳地抓到一批干净请求。
抓包时建议先点开几个请求,看一下 URL 特征。QQ 音乐网页版的接口域名集中在 u.y.qq.com 或 c.y.qq.com 这类地址下,请求的 URL 里通常会带 format=json、ct=24、cv=0 之类的参数。看到这些基本可以判断这就是要找的接口。如果没有,就说明目标没选对,换个接口再试。
3.2 抓包定位:从发出的请求摸到加密函数
这一步是整个还原过程的关键,我把它拆成几个小步骤。
第一步,在 Network 面板里找到目标请求,右键选择 Copy -> Copy as fetch,或者直接复制请求的 headers 和 payload。别急着分析,先存到一个临时文件,留着后面做比对。
第二步,在 Sources 面板里打开目标 JS 文件。如果你之前在 Network 面板里看过这个请求的 Initiator 列,会发现它显示了发起请求的调用栈。点开调用栈可以直达发送请求的那一行代码。如果调用栈被混淆得看不懂,没关系,用 XHR/fetch breakpoints 的办法:在 Sources 面板右侧的 XHR/fetch breakpoints 里添加断点条件,比如 URL 包含 u.y.qq.com,然后刷新页面。只要请求一发出,代码就会自动停在发送处。
第三步,停在发送处后,顺着当前函数的作用域找。如果断点停在一个 axios 封装函数里,那你需要继续往上找调用者。DevTools 右侧的 Call Stack 面板会列出完整调用链,逐个点进去看。在某一个函数内部,很可能就会看到类似 sign = getSign(...) 或 body: encrypt(...) 这样的赋值语句。看到这句,恭喜你,核心加密函数已经找到了。
第四步,进入加密函数内部下断点。关键不是只看它怎么写,而是要在 Console 里打印参数值。比如在 getSign 里断下来后,打印入参对象,再打印函数内每一行关键变量的值,逐个和抓包数据比对,确认哪一个分支是真实生效的。这个过程可能会重复几次,因为同一个函数可能被多个接口复用,分支不同,签名结果也不同。
我把这四步总结成一个流程图式的路径:抓包拿到明文请求头 -> 通过 Initiator 或 XHR 断点进入代码 -> 在调用栈里找到 sign 赋值和 encrypt 赋值 -> 进入函数内部打印参数,确认算法。这比单纯搜索关键词要可靠得多。
3.3 算法还原与本地复现
当你在浏览器里已经把 sign、payload 加密、响应解密三个位置全部断过,并且拿到了 key 和 iv 等关键参数后,就可以进入本地复现阶段了。我会在 Node.js 里写一个完整脚本,把三个算法串起来跑一遍。
先建立一个项目目录,执行 npm init -y,安装 crypto-js 和 axios(请求库)。然后创建 index.js,按下面的骨架补齐:
javascript复制const CryptoJS = require("crypto-js");
const axios = require("axios");
// 以下四个值必须从浏览器断点中提取,这里仅做占位
const SECRET_KEY = CryptoJS.enc.Utf8.parse("your-real-key");
const SECRET_IV = CryptoJS.enc.Utf8.parse("your-real-iv");
const SALT = "your-real-salt";
const SIGN_KEY = "your-real-sign-key";
function encryptPayload(bodyObj) {
const plainText = JSON.stringify(bodyObj);
const encrypted = CryptoJS.AES.encrypt(plainText, SECRET_KEY, {
iv: SECRET_IV,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
});
return encrypted.toString();
}
function generateSign(payload, uin, timestamp) {
// 参与拼接的字段、顺序需要根据断点观察补齐
const raw = payload + "&uin=" + uin + "×tamp=" + timestamp + SALT;
return CryptoJS.MD5(raw).toString();
}
function decryptResponse(data) {
const bytes = CryptoJS.AES.decrypt(data, SECRET_KEY, {
iv: SECRET_IV,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
});
return JSON.parse(bytes.toString(CryptoJS.enc.Utf8));
}
这里我需要特别强调:SALT、SIGN_KEY、SECRET_KEY、SECRET_IV 这四个值没有现成的,必须自己从断点里提取。真正常见的情况是,key 和 iv 可能是一样的,也可能是由一个字符串的 MD5 前 16 位和后 16 位拆分而来。签名用的拼接顺序更有可能包含时间戳、随机数、comm 里的固定字段等,不能想当然按某个固定格式写死。
写好脚本后,把脚本里的 bodyObj 替换成抓包时看到的、加密前的明文参数,本地执行一次。然后将输出结果与抓包数据对比。对比的顺序是:
- 先对比
payload密文,如果一致,说明 AES 加密切换正确; - 再对比
sign,如果一致,说明签名因子顺序和盐值都正确; - 最后拿响应密文跑一次
decryptResponse,如果返回正常 JSON,说明解密参数正确。
只要三步全通过,这套还原就算完整落地了。以后想调试接口,直接本地生成签名和密文,不再依赖浏览器。
3.4 不同接口的差异适配
QQ 音乐网页版并不只有一个接口,不同页面的请求在参数结构上会有差异。比如搜索接口的请求体里 req_0 可能对应不同的 module 和 method,歌单接口用的是另一组 module 值。这会导致参与签名的参数范围不同。
所以我在本地脚本里通常会封装两个函数:一个是通用加密,另一个是通用签名。业务层只需要传不同的 bodyObj 进去,加密逻辑不用变。但签名函数可能需要接收一个 extraParams 对象,用来扩展参与拼接的字段。只要你在浏览器里对每个新接口都重新确认一次签名因子,这个设计完全够用。
这个适配过程虽然繁琐,但也是 JS 逆向最有意思的地方。每次遇到一个新接口,不再需要从零开始,只要重复“抓包 -> 断点 -> 提取参数 -> 验证”这个固定路径,熟练之后十分钟就能搞定。
4. 常见问题与排查技巧实录
4.1 典型坑位速查表
我做了这么多逆向工作,遇到的坑远比顺利的时刻多。下面是 QQ 音乐这类站点最常见的几个问题,做成速查表,方便直接对照排查。
| 问题现象 | 根本原因 | 解决思路 |
|---|---|---|
搜索 sign 找不到赋值语句 |
属性名被拼接混淆,或者代码经过了变量名混淆 | 改用 XHR hook,打印调用栈定位;搜索 "si"+"gn" 这类拼接写法 |
| 断点能停住,但参数是 undefined | 函数里走的是另一个分支 | 不要只在一个分支下断,多设几个断点,观察变量赋值路径 |
| AES 解密出来是乱码 | key、iv、mode、padding 有一项不对 | 逐一检查和抓包时断点里的值比对,特别留意 iv 是否参与 |
| 本地计算的 sign 和线上总不一致 | 拼接顺序不对,或漏掉了时间戳/随机数/固定盐值 | 在签名函数里打印完整拼接串,手动复制后本地复算一次 |
| 页面请求能成功,本地 Node 脚本请求失败 | 少了 cookie 或额外请求头 | 把浏览器里完整的 headers 和 cookies 复制到本地脚本 |
| 响应解密有时成功有时失败 | 部分接口返回的是普通 JSON,不是密文 | 解密前先判断 data 字段是否包含明显编码特征,比如长度和字符集 |
这条表里的每一行都能单独展开成一个故事。比如乱码问题,我第一次碰到时以为密钥错了,换了大半天 key 都不对,最后发现是 iv 的编码方式写错了,需要先做 UTF8 解析再传进去。这类问题没有捷径,只能靠经验和细心的比对。
4.2 我的调试心得与经验
再分享几个只有实际操作才会注意到的经验。
第一,格式化大文件之后,优先找闭包内部的工具函数。QQ 音乐这类打包项目通常都会把加密工具集中放在某个模块里,周围往往还有一串类似的通用工具函数,比如 md5、aes、base64 等。找到这个工具区,比在全文件里漫无目的地搜索高效得多。
第二,不要过度依赖 debugger 语句。一些站点会检测调试器,无限断点让你没法正常调试。遇到这种情况,要么点击“Deactivate breakpoints”临时跳过,要么用日志断点代替。日志断点不会中断执行,但会把参数值打印到控制台,足够用于观察数据。
第三,学会用“变量之间的依赖关系”反推参数含义。混淆代码里变量名都是乱的,但赋值语句之间的关联骗不了人。比如 a = b.toString() 后面跟一个 CryptoJS.AES.encrypt(...),那 a 大概率就是明文或密文字符串。顺着数据流走,比逐行读代码靠谱得多。
第四,本地复现永远用最小集。不要一上来就把浏览器里整个请求头、整个请求体都复制到脚本里跑,那样出了问题根本不知道是哪一部分导致的。先用一个最简单的接口,把 sign 和 payload 跑通,再逐步加复杂逻辑。
第五,遇到算法更新不要慌。你之前积累的断点位置、调用栈路径这些“方法论”不会失效,只是参数和盐值可能变了。重新按照“抓包 -> 断点 -> 提取 -> 验证”走一遍,通常半小时内就能适配。
4.3 安全边界的提醒
最后我想说点题外话。做 JS 逆向研究和做数据抓取是两码事。把签名、加密、解密这三层逻辑还原出来,更大的价值在于理解前后端交互的安全设计,比如如何设计更可靠的防篡改机制、如何在性能和安全之间做取舍。这些知识对 Web 开发者、安全测试工程师都是极好的训练材料。
但我也见过很多人学完逆向第一反应就是批量下载、爬取数据。这种事我劝你谨慎。一个是合规风险,另一个是平台的风控也在快速迭代,你今天跑通的脚本,明天可能就失效了。把精力放在原理和方法论上,反而能走得更远。
我自己做这类分析时,习惯把重点放在“为什么这样设计”而不是“怎么绕过它”。理解了一个加密体系的设计初衷,很多表面上看似奇怪的代码逻辑都会变得合理起来。比如服务端为什么用对称加密而不是非对称、签名为什么要把参数拼接后再哈希、响应为什么要在拦截器里统一解密,这些问题想清楚了,你的逆向能力才算真正提升。
