如果你在浏览器里打开网易云音乐的网页版,随便搜一首歌,然后打开开发者工具的 Network 面板,大概率会被一段“黑话”吓一跳:请求地址是weapi/...,请求体里没有明文参数,只有两个字段分别叫params和encSecKey,后面跟着一大串看不懂的字符。再往下看,URL 尾巴上还挂着一个csrf_token。很多人第一次抓包时都会卡在这里:参数都拿到了,就是不知道这些字符串是怎么拼出来的。
这篇文章就是来解决这个问题的。我会从一次真实请求出发,把csrf_token、params、encSecKey这三个关键参数从头到尾拆开,讲清楚它们各自是什么、为什么设计成这样、以及怎么用代码完整复现这套加密链路。不管你是做接口调试、爬虫开发,还是纯粹对前端加密感兴趣,照着这篇文章走一遍,基本就能把网易云音乐 Web 端的请求加密机制摸透。
1. 整体设计思路:先搞清楚它为什么要加密
1.1 从一次最普通的请求说起
我还是拿搜索功能举例。打开网页版,在搜索框里输入“晴天”,点搜索。这时候浏览器会往后台发一个 POST 请求,地址类似:
https://music.163.com/weapi/cloudsearch/get/web?csrf_token=xxxxxxxx打开请求的 Form Data,看到的不是s=晴天&type=1这种直观参数,而是:
params: 6f9yP7p2K1Oe...(一长串 base64 字符) encSecKey: 6a4e8d9f...(一长串十六进制字符)URL 上的csrf_token是一串看起来像随机数的十六进制字符串,会跟着 Cookie 一起变化。
这说明什么?说明网易云音乐的网页版接口,并没有把业务参数直接明文 POST 给服务端,而是走了两层加密后再提交。服务端拿到密文后,先解密再执行真正的搜索逻辑。整个链路大致是这样的:
业务参数(JSON) -> AES 加密(用随机密钥) -> AES 加密(用固定密钥) -> 得到 params 字段提交 随机密钥 -> RSA 加密(用固定公钥) -> 得到 encSecKey 字段提交 服务端拿到 encSecKey 私钥解密出随机密钥 -> 用固定密钥解开 params 的外层 -> 用随机密钥解开内层 -> 拿到原始 JSON 参数这套流程在网易云音乐的 Web 端 API 里几乎是通用的。搜索、歌单、评论、歌词这些接口,只要走weapi路径,加密结构基本一样。
1.2 两层加密的用意:混合加密的简化版
为什么要搞这么复杂?直接明文传输不行吗?
站在服务端的角度,接口加密能挡住很多低级恶意请求。明文接口的最大问题是容易被直接篡改和批量调用:别人拿到请求地址和参数格式,就能伪造任意请求,比如刷评论、刷播放量、批量拉数据。加密之后,初级攻击者连参数都拼不出来,更别说模拟请求了。
但前端加密有个天然矛盾:密钥在浏览器里,只要打开 JS 文件看源码,密钥就全暴露了。所以网易云音乐的做法是“混合加密思路”:用随机密钥加密业务数据,再把这个随机密钥用 RSA 加密。这样即使有人拿到了固定密钥,也没法直接解开密文,因为内层还需要随机密钥;而随机密钥又被 RSA 公钥加密了。
这有点像寄快递的“双锁”设计:数据先锁进一个小保险箱(随机密钥 AES),再把保险箱放进一个大木箱(固定密钥 AES),最后把保险箱钥匙单独用一把高级锁套起来(RSA)。服务端用钥匙模板(私钥)打开高级锁取出钥匙,再打开小保险箱,整个过程环环相扣。
不过说句实话,前端加密做到这个程度,本质上是“增加门槛”,而不是“绝对安全”。因为 AES 固定密钥和 RSA 公钥都写在 JS 里,有耐心的人迟早能扒出来。但从商业角度讲,这个门槛已经足够挡住绝大多数人了。
1.3 需要先理解的关键概念:AES、RSA、csrf_token
在具体复现之前,先把三个基础知识点过一遍。
AES 是对称加密,加密和解密用同一个密钥。网易云这里用的是 AES-128-CBC 模式,密钥长度 128 位(16 字节),还需要一个 16 字节的 IV(初始化向量)。CBC 模式下,每一块明文会跟上一块密文做异或再加密,所以同样的明文配合不同的 IV,输出的密文完全不同。
RSA 是非对称加密,有公钥和私钥两个。公钥负责加密,私钥负责解密。网易云的前端代码里只放了公钥,私钥保存在服务端。RSA 的特点是:公钥加密的结果,只有私钥能解开。
csrf_token 是“跨站请求伪造防护令牌”。它的核心作用是防跨站请求伪造:攻击者在一个恶意页面上诱导用户提交表单,浏览器会带上目标站点的 Cookie,但攻击者没办法知道当前页面的动态 token,于是服务端校验 token 和 Cookie 是否匹配,就能判断请求是不是来自本站页面。放到网易云音乐的接口里,csrf_token其实就是一个随登录态变化的动态令牌。
2. 核心细节拆解:三个参数各自的职责
2.1 csrf_token:身份校验的身份牌
先从最简单的csrf_token说起。它不是加密生成的,而是从 Cookie 里取的。
访问网易云音乐首页后,服务端会返回一组 Cookie,其中有个字段叫__csrf,它的值就是我们要的csrf_token。如果你是登录状态,这个值会跟账号会话绑定;未登录状态下,也会存在一个匿名 token。
实际操作时,用 Python 的 requests 库模拟的话,流程是这样的:
import requests session = requests.Session() headers = { "User-Agent": "Mozilla/5.0 ...", "Referer": "https://music.163.com/" } resp = session.get("https://music.163.com/", headers=headers) csrf_token = session.cookies.get("__csrf", "")注意:csrf_token在请求里出现了两次。一次是放在 URL 的 query 参数上(?csrf_token=xxx),另一次是放在 POST 的 JSON 业务参数里。服务端会同时校验这两处,并且和 Cookie 中的__csrf做比对,三个值必须一致,请求才会被放行。
注意:
csrf_token是和 Cookie 强绑定的。如果你手动指定了 Cookie 里的__csrf,那 URL 和请求体里的 token 也必须同步改,否则大概率会返回类似“参数错误”或“csrf 校验失败”的响应。
2.2 params:真正的请求体,AES/CBC 加密后的产物
params字段是网易云音乐加密链路里最核心的部分,也是新手最容易绕晕的地方。它不是一次 AES 加密的结果,而是连续两次 AES 加密后的产物。
第一次加密,用的是随机密钥。每次请求前,前端代码会生成一个 16 位的随机字符串,比如xK7sP2mQ9fL0wR8v,这个字符串就是第一个 AES 密钥。拿它去加密业务参数的 JSON 字符串,得到第一层密文。
第二次加密,用的是固定密钥。这个固定密钥在 JS 文件里写死了,是0CoJUm6Qyw8W8jud,IV 固定为0102030405060708。拿固定密钥去加密第一层密文的文本形式,得到第二层密文,这个最终结果 base64 编码之后,就是params字段。
把过程拆开看是这样:
原始 JSON:{"s":"晴天","type":1,"offset":0,"limit":10,"csrf_token":"xxx"} 随机密钥:xK7sP2mQ9fL0wR8v 第一层加密:AES_CBC(原始JSON, 随机密钥, IV=0102030405060708) -> 第一层密文(文本) 固定密钥:0CoJUm6Qyw8W8jud 第二层加密:AES_CBC(第一层密文, 固定密钥, IV=0102030405060708) -> 第二层密文(文本) 最终 params = base64(第二层密文)这里有几个细节值得注意。
第一,两次 AES 用的是同一个 IV,都是0102030405060708,所以 IV 本身算不上秘密,它只是 CBC 模式的必要参数。
第二,第一层加密的“明文”是业务 JSON 的原始字符串,不是 JSON 对象。也就是说,在加密之前,必须先把业务参数用JSON.stringify()转成字符串。
第三,第二次加密时,明文是第一次加密后得到的 base64 字符串。这一步非常关键,很多人复现时在这里翻车:他们把第一次加密结果直接当二进制喂给第二次 AES,或者压根没做第二次加密,最终解密出来的结果必然对不上。
第四,AES 加密需要做补位处理。JSON 字符串长度不一定是 16 的倍数,CBC 模式要求明文按块对齐,所以加密前要补足长度。前端 JS 用的是 CryptoJS 的默认 PKCS7 补位,Python 里对应的是pad()函数。
2.3 encSecKey:给随机密钥套上的 RSA 外壳
params加密后,服务端怎么拿到随机密钥?答案在encSecKey里。
前端生成随机密钥之后,会用 RSA 公钥对它加密。RSA 加密的结果(十六进制字符串)就是encSecKey字段。
具体公钥参数在 JS 里是硬编码的,指数固定为010001,模数是一长串 256 字节的十六进制数。我们可以把 RSA 加密理解成一次幂模运算:
c = m^e mod n其中m是要加密的数据转换而成的大整数,e是指数65537(即十六进制的010001),n是那个很长的模数,c就是加密后的密文。
RSA 本身有一个特点:同样的明文,用同样的公钥加密,结果应该是确定性的。但 JS 里实际生成encSecKey时,还会在明文前做 PKCS1 v1.5 类型的填充,填充字节里包含随机数,所以每次请求的encSecKey看起来都不一样,但服务端都能用私钥正常解出来。
注意:
encSecKey加密的对象是“随机密钥字符串”,比如xK7sP2mQ9fL0wR8v,而不是业务参数 JSON。如果脑子里没有这根弦,你会看到有些人把业务参数拿去 RSA 加密,那结果肯定不对。
还有个容易搞混的逻辑:RSA 公钥是公开的,固定在 JS 里。所以任何人都可以用它生成合法的encSecKey。这个字段存在的意义不是“防止有人伪造”,而是“防止有人直接拿到随机密钥”。RSA 私钥在服务端,前端拿不到,等于给随机密钥上了一道只有服务端能开的锁。
3. 实操复现:从抓包到跑通完整加密链路
3.1 定位 JS 入口:一搜 encSecKey 就找到了
如果你是从零开始分析这套加密,第一步应该是在浏览器里找到核心 JS 文件。
打开网易云音乐网页版,按 F12 打开开发者工具,切到 Sources 面板,按Ctrl+Shift+F全局搜索encSecKey。点击搜索结果,浏览器会跳转到某个加密核心 JS 文件里,里面能看到类似这样的代码结构:
window.asrsea = function(a, b, c, d) { ... }这个函数名看起来像乱敲键盘,其实就是算法封装。重点不是函数名,而是它内部做的事情。在函数内部下个断点,刷新页面触发一次搜索请求,就能看到传进来的参数:第一个参数是业务 JSON 字符串,后面跟着 AES 固定密钥、RSA 公钥相关常量,以及随机生成密钥的逻辑。
继续在函数内部单步调试,会看到几个关键步骤:
- 随机生成一个 16 位字符串。
- 用这个随机字符串对业务 JSON 做 AES 加密。
- 再用固定密钥对上一步结果做 AES 加密。
- 用 RSA 公钥对随机字符串做加密。
如果你不想在浏览器里折腾断点,也可以在全局搜索里直接搜固定密钥0CoJUm6Qyw8W8jud,能立刻定位到密钥所在行;搜0102030405060708能定位到 IV。
3.2 整理加密流程的伪代码
把 JS 里的逻辑剥开,整理成和语言无关的伪代码,大概是下面这个样子:
input: 业务参数对象 data 随机密钥 randomKey = 随机16位字符串 第一层密文 firstCipher = AES_CBC_Encrypt( plaintext = JSON.stringify(data), key = randomKey, iv = "0102030405060708" ) 第二层密文 params = AES_CBC_Encrypt( plaintext = firstCipher, // 注意,这里是第一次加密的文本结果 key = "0CoJUm6Qyw8W8jud", iv = "0102030405060708" ) encSecKey = RSA_Encrypt( plaintext = randomKey, modulus = 从JS中提取的RSA模数, exponent = "010001" ) 输出: params, encSecKey只要按这个顺序执行,理论上就能生成和前端一致的结果。接下来看 Python 怎么落地。
3.3 用 Python 复现整个加密切口
先装依赖。推荐用pycryptodome处理 AES 和 RSA,用requests发请求。
pip install pycryptodome requests完整代码框架如下:
import base64 import json import random import string from Crypto.Cipher import AES, PKCS1_v1_5 from Crypto.PublicKey import RSA as CryptoRSA # 固定参数,从 JS 里可以直接看到 FIXED_KEY = "0CoJUm6Qyw8W8jud" IV = "0102030405060708" RSA_EXPONENT = 0x010001 # 十进制 65537 # 这个模数很长,从 JS 的 RSAKeyPair 调用里复制完整值 RSA_MODULUS_HEX = "在这里粘贴从JS中提取的模数" def generate_random_key(length=16): chars = string.ascii_letters + string.digits return "".join(random.choice(chars) for _ in range(length)) def aes_encrypt(text: str, key: str, iv: str) -> str: data = text.encode("utf-8") pad_len = 16 - len(data) % 16 data += bytes([pad_len]) * pad_len cipher = AES.new(key.encode("utf-8"), AES.MODE_CBC, iv.encode("utf-8")) encrypted = cipher.encrypt(data) return base64.b64encode(encrypted).decode("utf-8") def rsa_encrypt(text: str) -> str: n = int(RSA_MODULUS_HEX, 16) e = RSA_EXPONENT pub_key = CryptoRSA.construct((n, e)) cipher = PKCS1_v1_5.new(pub_key) encrypted = cipher.encrypt(text.encode("utf-8")) return encrypted.hex() def encrypt_payload(data: dict): random_key = generate_random_key() text = json.dumps(data, separators=(",", ":")) first_cipher = aes_encrypt(text, random_key, IV) params = aes_encrypt(first_cipher, FIXED_KEY, IV) enc_sec_key = rsa_encrypt(random_key) return { "params": params, "encSecKey": enc_sec_key }几点解释。
aes_encrypt里的补位逻辑,是 PKCS7 的标准做法:差几个字节就补几个值为几的字节。比如明文长度 50,差 14 字节对齐,就补 14 个值为 14 的字节。这和 CryptoJS 默认行为一致。
rsa_encrypt里用CryptoRSA.construct((n, e))构造公钥对象,再用PKCS1_v1_5加密。它内部会做 PKCS1 v1.5 类型 2 填充,效果和 JS 的encryptedString一致。输出的十六进制字符串就是encSecKey。
3.4 完整调用示例:跑通一次搜索请求
有了加密函数,剩下的就是组装请求。需要注意几个兜底细节:第一,csrf_token要从 Cookie 里取;第二,URL 的 query 里要带上它;第三,业务 JSON 里也要带它。
import requests headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ...", "Referer": "https://music.163.com/", "Content-Type": "application/x-www-form-urlencoded", } session = requests.Session() session.get("https://music.163.com/", headers=headers) csrf_token = session.cookies.get("__csrf", "") data = { "s": "晴天", "type": 1, "offset": 0, "limit": 10, "csrf_token": csrf_token, } payload = encrypt_payload(data) url = "https://music.163.com/weapi/cloudsearch/get/web?csrf_token=" + csrf_token resp = session.post(url, data=payload, headers=headers) print(resp.json()["result"]["songs"][0]["name"])实际跑下来,正常会返回搜索结果,第一个歌曲名就是“晴天”。第一次跑通的时候你会觉得,原来这套“黑话”也没那么神秘。
4. 常见问题与排查技巧实录
4.1 AES 加解密结果对不上?先检查补位和加密顺序
这是复现时最高频的问题。
最常见的坑是只做了一次 AES 加密。记住:params必须是“先用随机密钥加密原始 JSON,再用固定密钥加密第一次结果”。顺序反了或者漏了一层,服务端解密必失败。
第二个坑是补位问题。Python 里如果直接用AES.new(...).encrypt(data)而 data 长度不到 16 的倍数,会直接报错;如果硬凑到 16 的倍数但补的内容不对,加密能跑通,但服务端解出来是乱码。正确做法是用 PKCS7 补位,而不是补全零字节。
第三个坑是 IV 传参。CryptoJS 里 IV 是 16 字节的 UTF-8 字符串,对应 Python 里就是b"0102030405060708"。有人会把十六进制字符串误当成 hex 字节流去转换,结果密文和前端完全对不上。务必要用字符串本身的 UTF-8 字节。
4.2 RSA 生成结果不对?看看填充方式和输入长度
encSecKey的输入是 16 字节的随机密钥字符串,不是业务 JSON。如果你把整个业务 JSON 塞进 RSA 加密,长度超过 RSA 单次加密上限(256 字节减填充开销),加密会失败,就算硬塞进去,服务端也解不出来。
另外,PKCS1 v1.5 填充是带随机性的,所以两次生成同一个随机密钥的encSecKey,结果也不会完全相同。只要服务端能解密出原来的随机密钥,就是正确的。不需要纠结和前端结果逐字符一致。
我还见过有人用pow(m, e, n)手工做幂模运算,但漏了 PKCS1 填充步骤。这样虽然能做出来一个能解密的密文,但如果服务端严格做填充校验,就可能拒绝。直接用PKCS1_v1_5更省心,JavaScript 里的encryptedString就是标准 PKCS1 v1.5 类型 2。
4.3 csrf_token 拿不到或者对不上?检查 Cookie 和请求时机
csrf_token藏在家目录 Cookie 的__csrf字段里。如果你先 GET 的地址不对,或者会话没有保持 Cookie,token 自然拿不到。
第一次 GET 一定要访问网易云音乐的首页域名,也就是带网页界面的那个地址,而不是直接打 API。否则服务端没下发完整 Cookie,后续请求就没有__csrf。
登录状态变化之后,__csrf也会更新。如果你用代码模拟登录,记得登录后重新取一次 token,别用登录前的旧值硬顶,否则可能触发风控。
还有一点:URL 上的csrf_token和 POST 体 JSON 里的csrf_token要一致。建议以后者为准,从同一个变量取值。
4.4 服务端返回参数错误?检查 JSON 序列化格式
前端加密前用的是JSON.stringify,默认没有多余空格。Python 里如果直接json.dumps(data),默认会有空格分隔符,加密结果当然不同。
我们虽然不能直接“看到”服务端接收的是什么明文,但从加密协议推导,它一定是用和前端相同的序列化规则去解密和解析的。所以复现时要保证两边序列化规则一致:
text = json.dumps(data, separators=(",", ":"))这样能把空格压缩掉,让待加密字符串和前端尽可能一致。
另外,业务参数里的csrf_token必须放在 JSON 里。有些接口可能不强制,但搜索接口实测是需要带的。
4.5 请求频率一高就被风控?注意访问节奏
网易云音乐的服务端有风控策略。就算加密参数完全正确,如果短时间高频请求,也可能返回错误码或者直接拒绝。
我的经验是:正常的接口调试没必要开多线程并发,逐条请求、中间加个 1 到 3 秒随机延时,基本够用。反过来,如果你用同一个 IP 短时间疯狂拉歌单、拉评论,触发风控后不一定报加密错误,而是返回类似“操作频繁”的提示。
遇到这种情况,第一步不是改加密逻辑,而是降低频率、换 UA、检查 Referer。把这些基础问题排除了,再回头看加密参数有没有因为代码改动而出错。
4.6 快速排查表
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
| 返回参数错误 | 只做了一次 AES 加密 | 确认二次加密顺序 |
| 返回解密失败 | RSA 输入不是随机密钥 | 检查 encSecKey 输入 |
| 返回 csrf 校验失败 | Cookie 与 token 不一致 | 重新 GET 首页取 token |
| 偶尔成功偶尔失败 | 随机密钥或填充随机性 | 对比解密结果,不必逐字符一致 |
| 高频请求被拒 | 触发风控 | 降低频率,增加延时 |
最后再分享一个实操中很实用的小技巧:分析这类前端加密时,别一上来就埋头读 JS 压缩代码。先在 Network 面板找到一个真实请求,Copy as cURL,再拿到本地用curl重放一遍,能跑通就说明请求头、Cookie、加密参数都完整。接着再慢慢替换params和encSecKey的生成方式,每替换一步对照一次结果。这样出了问题能立刻判断是哪一步坏了,排查效率比盲目改代码高得多。
我个人在实际操作中的体会是,这套加密链路的核心不在于“密码学多深”,而在于“链路完整”。AES 和 RSA 本身都是标准算法,难的是把各个环节准确对上:密钥用哪个、IV 用哪个、加密顺序如何、填充规则是什么、csrf_token 放在哪。只要把这张链路图刻在脑子里,不管是准备接口调试、写自动化脚本,还是研究其他网站的前端加密,都会顺手很多。如果后续你想继续扩展,还可以看看歌单详情、评论翻页这类接口,它们复用的都是同一套weapi加密逻辑,换汤不换药。