资讯详情

资讯详情

网易云音乐Web端加密链路全解析:csrf_token、params与encSecKey生成实战

如果你在浏览器里打开网易云音乐的网页版随便搜一首歌然后打开开发者工具的 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_tokenxxxxxxxx打开请求的 Form Data看到的不是s晴天type1这种直观参数而是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/, headersheaders) csrf_token session.cookies.get(__csrf, )注意csrf_token在请求里出现了两次。一次是放在 URL 的 query 参数上?csrf_tokenxxx另一次是放在 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 文件里写死了是0CoJUm6Qyw8W8judIV 固定为0102030405060708。拿固定密钥去加密第一层密文的文本形式得到第二层密文这个最终结果 base64 编码之后就是params字段。把过程拆开看是这样原始 JSON{s:晴天,type:1,offset:0,limit:10,csrf_token:xxx} 随机密钥xK7sP2mQ9fL0wR8v 第一层加密AES_CBC(原始JSON, 随机密钥, IV0102030405060708) - 第一层密文(文本) 固定密钥0CoJUm6Qyw8W8jud 第二层加密AES_CBC(第一层密文, 固定密钥, IV0102030405060708) - 第二层密文(文本) 最终 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即十六进制的010001n是那个很长的模数c就是加密后的密文。RSA 本身有一个特点同样的明文用同样的公钥加密结果应该是确定性的。但 JS 里实际生成encSecKey时还会在明文前做 PKCS1 v1.5 类型的填充填充字节里包含随机数所以每次请求的encSecKey看起来都不一样但服务端都能用私钥正常解出来。注意encSecKey加密的对象是“随机密钥字符串”比如xK7sP2mQ9fL0wR8v而不是业务参数 JSON。如果脑子里没有这根弦你会看到有些人把业务参数拿去 RSA 加密那结果肯定不对。还有个容易搞混的逻辑RSA 公钥是公开的固定在 JS 里。所以任何人都可以用它生成合法的encSecKey。这个字段存在的意义不是“防止有人伪造”而是“防止有人直接拿到随机密钥”。RSA 私钥在服务端前端拿不到等于给随机密钥上了一道只有服务端能开的锁。3. 实操复现从抓包到跑通完整加密链路3.1 定位 JS 入口一搜 encSecKey 就找到了如果你是从零开始分析这套加密第一步应该是在浏览器里找到核心 JS 文件。打开网易云音乐网页版按 F12 打开开发者工具切到 Sources 面板按CtrlShiftF全局搜索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(length16): 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/, headersheaders) 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, datapayload, headersheaders) 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 里就是b0102030405060708。有人会把十六进制字符串误当成 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 的地址不对或者会话没有保持 Cookietoken 自然拿不到。第一次 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加密逻辑换汤不换药。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →