折腾记录

给播放器接上网易云账号登录:被 HTTP 200 空响应折磨了六个小时,最后败给一行代码

事情是这样的,我原先开发的多媒体库应用 LumiLuna 重构了,重构后我就添加了接入metingAPI播放在线音乐,但这玩意有个痛点:metingAPI只能读取歌单前200首,而且还得手动导入歌单,非常滴麻烦,那么,是时候加入网易云登录系统了,当时我的想法很天真:直接找现有的开源接口解密项目,照着抄就行了。结果这一抄,就是六个小时的噩梦。

前提:我的编译环境是薛定谔的

先交代一下背景约束,不然你没法理解为什么这么简单的功能能拖这么久:

  • 登录态(cookie)必须留在 Rust 侧,不能给 WebView 看到,所以签名加密都得自己用 Rust 写
  • UI 不单独做页面,融进现有的音乐选项卡
  • 这台开发机没有 MSVC / Windows SDK,C 盘常年只剩 0~2GB,本地编译?不存在的,全靠 GitHub Actions 云端构建,每改一次代码 = commit + push + 等 6~9 分钟 CI + 下载安装实测

也就是说,每一次试错的最小成本是十分钟起步。

第一阶段:顺利得让人害怕

网易云的接口加密大致分三套:

  • weapi:网页端协议,双层 AES-CBC + RSA-1024,账号/歌单/云盘都走它
  • eapi:客户端协议,MD5 + AES-ECB,扫码登录和播放 URL 走它
  • xeapi:新一代协议,X25519 密钥协商 + AES-128-GCM 加密 + HMAC 签名 + 外层 AES-ECB 套壳,三层链路叠满,用来注册「匿名设备身份」

参考项目是 api-enhanced,所有算法和请求头都照它来。Rust 侧把三套加密实现完,一路火花带闪电,除了两件事:

一是 RSA 公钥解析:网易云的公钥是 SPKI 格式(BEGIN PUBLIC KEY),我第一次用了只认 PKCS#1 格式的解析器,运行期必炸,又手贱用了 .expect(),结果一点音乐选项卡,应用直接闪退。

二是 Tauri 2 的同步命令会卡主线程,网络请求一开整个窗口冻住,得全部改成 async + spawn_blocking。

修完这两茬,扫码终于能出二维码了,但手机一扫,网易云直接甩脸:「检测到当前设备环境异常,本次操作已拦截」

排查发现参考项目启动时会先用 xeapi 注册一个匿名设备身份(MUSIC_A cookie),所有请求都带着它。我们裸奔请求,被风控一眼识破。于是又移植了 xeapi——这一波贡献了全项目最多的一批编译错误:x25519-dalek 的 feature 门控、try_into 类型推断、ECB trait 导入……还有个隐藏炸弹:xeapi 签名的 HMAC 密钥,参考项目直接把 base64 字符串的字节当 key,我傻乎乎先解码再用,签名从头错到尾。

搞定这些,扫码终于能授权成功了——顺带把轮询状态码也摸透了:800 已过期、801 等待扫码、802 已确认待点登录、803 登录成功。我以为要收工了。

第二阶段:HTTP 200,0 字节——地狱之门

扫码授权成功后的下一步是拉账号信息(weapi 的 account/get),然后不出意外就出意外了,报错:

网易云响应解析失败(/w/nuser/account/get,HTTP 200,0字节)

HTTP 200,响应体 0 字节。服务器客客气气收了请求,然后一个字都不回。

这个错误贯穿了后面六七个版本的构建。它本质上是好几个真实缺陷叠在一起,单个修完都不足以让请求成功,所以排查过程极其漫长。按时间线,这些坑长这样:

坑 1:reqwest 不自动带 Content-Type

参考项目用 axios,发表单自动带 Content-Type;reqwest 的 .body() 什么都不带,服务器读不到表单参数直接回空。补上之后 eapi 的 unikey 接口终于开始吐正常 JSON 了。

坑 2:unikey 字段位置

实测发现 unikey 在响应顶层,不在 data 下面,一行取值逻辑的事。

坑 3:PEM 必须 64 字符换行

RSA 公钥从参考项目拷过来是一整行 216 字符。Node/OpenSSL 无所谓,但 Rust 的 pem-rfc7468 是严格解码器,要求每行恰好 64 字符,否则报 invalid Base64 encoding。把 crate 源码下载下来逐行确认后,拆成四行搞定。

坑 4:请求要有「特征」

海外/机房 IP 的裸请求会被风控直接空响应,于是照参考项目的 server.js 抄了随机中国 IP 头(X-Real-IP / X-Forwarded-For)。

坑 5:RSA 填充模式——这个是真的坑

参考项目的 rsaEncrypt 用的是 node-forge 的 encrypt(str, 'NONE')。我一直以为 ‘NONE’ 是个无害的默认值,直到下载 node-forge 源码,看到:

} else if(['RAW', 'NONE', 'NULL', null].indexOf(scheme) !== -1) {
    scheme = {encode: function(e) {return e;}};   // 裸 RSA,无填充!
}

‘NONE’ = 无填充裸 RSA。 我用的 PKCS1v1.5 填充,服务端裸解密拿到带填充头的 128 字节,secretKey 提取失败。改成直接 modpow,输出和 forge 完全一致。

坑 6:双层 AES 层序写反

参考实现是 presetKey 在内层、secretKey 在外层,我一开始写反了。修。

……修完所有这些,依然空 body。

(期间还有个小插曲:这台机器常年开着 127.0.0.1:7890 代理,reqwest 默认会读 HTTP_PROXY 环境变量,请求全被塞进代理隧道,报一堆隧道错误,.no_proxy() 一发入魂。它不属于空 body 家族,但属于「环境比你想象得脏」家族——顺手记一笔。)

第三阶段:让应用自己交代

到这里我意识到不能再靠猜了。好在这台机器上参考项目能本地跑、Node 也能跑,于是建立了三对照调试法:

  1. Node 复刻:把 Rust 的加密算法用 Node 逐行复刻一遍打真实接口——永远成功,完整 JSON
  2. curl 对照:请求参数导出用 curl 原样发——也成功
  3. 应用实测永远失败,空 body

三个实现,内容一模一样,只有应用失败。这大概就是程序员最折磨的状态:不是不知道怎么做,是「照着能跑的实现抄都抄不对」。

接下来是几招逐步缩小包围圈:

第一招,给应用加全量请求日志。 每次请求把 URL、完整请求头(含 Cookie 全值)、请求体全文写进 appdata 下的日志文件。用户测完我直接读文件——彻底绕开「肉眼转述 + OCR 乱码」这条失真通道。这一步非常关键,后面所有判断都建立在真实请求上。

第二招,原样重放。 把应用日志里的请求用 Node 逐字节重放——空 body。再把请求头/URL 原样保留、只把 BODY 换成 Node 自己生成的——成功。

结论锁定:应用生成的加密产物内容不对。长度对、格式对,但服务端解不开。

第三招,加密自检。 让 Rust 在加密后用同一把密钥把密文解回来验证。结果吓人一跳:

WEAPI-SELFCHECK 自检内层可解但外层失败:Invalid symbol 0, offset 0. 内层前40=0000000000000000000000000000000000000000

用密钥解自己刚加密的密文,得到全 0。 AES 加密/解密不自洽,这在密码学上几乎是不可想象的,除非实现有 bug。

第四招,固定密钥复现。 为了排除随机性,加了一段固定密钥的复现加密,把 text、inner、params、解密结果全部打进日志。然后我看到了决定性的一幕:两次请求的 text 不同(csrf 不同),加密出来的 inner 却完全相同。

加密函数对不同的明文输出相同的密文——那它加密的到底是什么?

(插曲:这套固定密钥调试代码本身还闹了个笑话。随手写的调试密钥 FixedSecret123456 有 18 个字符,AES 密钥长度断言直接 panic,应用一启动就闪退——功能还没修好,界面先崩了,改回 16 字符的 AbCdEfGhIjKlMnOp 才消停。)

真相:一行代码的缺席

答案藏在 cipher crate 的 API 语义里。encrypt_padded_mut就地接口

fn encrypt_padded_mut<P>(self, buf: &mut [u8], msg_len: usize) -> Result<&[u8], PadError>

它把 buf 的前 msg_len 个字节当作明文输入,加密后写回 buf 开头。而我的代码是:

let mut buf = vec![0u8; msg.len() + 16];   // 只分配了全 0 的 buffer
let ct = enc.encrypt_padded_mut::<Pkcs7>(&mut buf, msg.len())?;

明文从来没写进 buf。 于是每一次加密的都是「全 0 的 61 字节」,两次请求当然产出相同的 inner;服务端拿到密文解出来是 0,自然一个字都不想回。之前所有诡异现象——包括自检解出全 0——全部自洽地指向这个事实。

修复只有一行:

buf[..msg.len()].copy_from_slice(msg);   // 明文写入 buf 开头

然后,整个流程就通了。扫码、账号、歌单、播放,全部正常。

这个 bug 为什么这么难找?因为它藏在一个几乎没人会细看的 API 语义里:encrypt_padded_mut 不是「传明文、返回密文」的函数式接口,而是「你把明文放进 buffer,我原地加密」的就地接口。而 Node 的 createCipheriv、参考项目的 CryptoJS 都是前一种语义——用「正确实现的直觉」去写「就地接口」的代码,写出来的就是这种全 0 密文。

顺带一提,eapi 之所以一直正常,是因为它用的 AES-ECB 是逐块显式拷贝明文实现的,绕开了这个坑。同一套代码里,一个函数躲过了 bug,另一个踩得死死的。

现在的状态

全链路现在是通的:扫码登录、账号信息、我的歌单、云盘列表、登录态下的在线播放,一个不缺;登录态会持久化到本地,下次打开应用不用重新扫码。搜索和推荐歌单暂时还走 meting 聚合接口——它们不需要登录,能省则省,哪天 meting 挂了再考虑全量迁到官方接口。

代价也记一笔:这些接口全是逆向出来的非官方协议,网易云哪天改协议、加风控,这段代码可能一夜之间变废纸。到时候,再回来折腾吧。

一些真实的感想

这场仗打下来,几个体会值得记下:

  1. API 语义比算法本身更容易出错。 加密算法移植最大的风险不是算法写错,而是「你以为的接口行为」和「实际接口行为」不一致。encrypt_padded_mut 的就地语义、pem-rfc7468 的 64 字符限制、forge ‘NONE’ 的裸 RSA——全是这类。下载 crate 源码逐行确认,比相信直觉快得多。

  2. 让程序自己交代,别靠肉眼转述。 一旦陷入「报错 → 猜 → 改 → 再报错」的循环,立刻停下来做自证:日志、自检、固定输入复现、原样重放。每一层自证都把问题的包围圈缩小一层。

  3. 「同一个逻辑,A 实现成功、B 实现失败」时,差异一定藏在某个你没对比过的字节里。 把它找出来的办法就是逐字段对比,直到把内容全比完,剩下的只有内容本身。

  4. 这种 bug 不是靠聪明解决的,是靠把怀疑对象一个个钉死排除掉的。 六个小时里大部分时间在做排除法,真正动手改代码的时间可能不到半小时。

  5. 调试代码也是代码。 18 字符的密钥能闪退整个应用——debug 代码的边界条件,和生产代码一样重要。

最后,当那个「已登录」三个字出现的时候,感觉比任何技术结论都值钱。虽然过程很痛,但下次再遇到这种诡异的空响应,我应该能第一时间想起:先怀疑加密函数到底加密了什么。

爱来自大肥鱼v4f,以及埋头苦脸修破防的我

——完——

继续阅读

相关
关于我是如何一步步被edge逼疯的,以及如何换到其他浏览器的

本文介绍了我和edge的前仇旧怨以及辗转反侧最终切到了Chromium,顺带介绍了下我用的一些扩展/脚本

折腾记录2026-08-01
邪道,让你的个人站点也能显示博客的文章相关
邪道,让你的个人站点也能显示博客的文章

本文主要讲述了神人站长为了给自己的个人页集成博客的功能,但又不想迁移原有的 Astro 博客,而发动鬼脑想出了邪道手法——让个人页显示博客文章的摘要

折腾记录2026-07-26
相关
记一次导航栏栏无法展开二级菜单的bug修复

屎山代码发力了,排查半天发现压根不是导航栏的问题,真凶另有其人

折腾记录2026-07-22
随机
0成本将你的OneDrive部署至互联网,以及博客的一些小规划

使用CVercel和OneDrive API,无需服务器和域名即可将OneDrive网盘部署到互联网,实现文件在线预览和下载

教程2026-06-14
开发了一个个人导航页随机
开发了一个个人导航页

一款基于 Svelte 5 + Vite 6 的暗黑风个人导航页,具有包含动态粒子特效、音乐播放器、博客文章时间线显示等功能,可以通过

有趣的项目2026-07-11
随机
为 Fuwari 博客添加 Umami 访问量统计

本文介绍如何基于 Umami 逆向 API 为静态博客添加实时访问量统计,包括侧边栏总览卡片和文章独立浏览量。

教程2026-05-24