跳到主要内容

网易云音乐直链解析

· 阅读需 12 分钟
Skyone
科技爱好者

本文详细介绍了提取网易云音乐前端歌曲播放链接的分析过程。

注意

在写这篇文章时刚刚学习前端不久,代码写的比较烂……总之重点在如何破解网易云音乐的加密方式。

后面有一篇博文展示了如何写一个真正可用的 API ,但由于本博客正在搬迁,搬运完成后会在这里把链接贴出来。

技术栈

  • Koa基础

推荐一篇博客,把koa讲的非常易懂,几乎看懂他提供的例子后koa就会得差不多了,如果有时间,我可能也会写一篇教程。

  • JavaScript基础
  • HTML基础

用到的Node.js模块

  • koa
  • koa-route
  • axios
  • crypto-js

如果你用的WebStorm,直接写

const Koa = require("koa");
const querystring = require("querystring");
const CryptoJS = require("crypto-js");
const axios = require("axios");
const fs = require("fs");
const route = require("koa-route");
const app = new Koa();

即可,WebStorm会自动提示你安装。

其实自己安装也很简单,先切换到工作目录,用cmd或shell运行:

npm install koa
npm install koa-route
npm install axios
npm install crypto-js

实现

1. 抓包、定位加密代码

首先打开网易云音乐网页版,进入任意一首歌,打开浏览器的DevTools,选择Network,点击播放,稍加分析,不难看出,这个post请求是用来获取音乐链接。

抓包

切换到调用栈(Initiator),看看发送它的函数在哪

调用栈

打开,发现是一大坨看不懂的代码

一大坨不想看的代码

看来这样走不通,那就搜搜post请求的data吧,Ctrl+F,搜encSecKey

请求头

定位加密代码

嗯,完美,很显然,这里的两个参数来自第13297行(可能你看到的行数和我不一样)的window.asrsea()函数

先刷新一下,再在那一行打个断点,点击播放

断点触发,进入window.asrsea()函数

进入加密函数

再在那个d(d,e,f,g)函数的第一行打个断点,可以看到这就是我们要找的加密函数。

2. 分析加密代码

加密代码

在左边的局部变量中看出,d保存的是一个字符串化的json,保存着要获取的歌曲id

{
"id": [
32102297
],
"level": "standard",
"encodeType": "aac",
"csrf_token": ""
}

经过多次测试,e是一个定值:"010001",来自["流泪", "强"]两个表情转换为对应的代码,转换映射如下:

{
"色": "00e0b", "流感": "509f6", "这边": "259df", "弱": "8642d",
"嘴唇": "bc356", "亲": "62901", "开心": "477df", "呲牙": "22677",
"憨笑": "ec152", "猫": "b5ff6", "皱眉": "8ace6", "幽灵": "15bb7",
"蛋糕": "b7251", "发怒": "52b3a", "大哭": "b17a8", "兔子": "76aea",
"星星": "8a5aa", "钟情": "76d2e", "牵手": "41762", "公鸡": "9ec4e",
"爱意": "e341f", "禁止": "56135", "狗": "fccf6", "亲亲": "95280",
"叉": "104e0", "礼物": "312ec", "晕": "bda92", "呆": "557c9",
"生病": "38701", "钻石": "14af6", "拜": "c9d05", "怒": "c4f7f",
"示爱": "0c368", "汗": "5b7a4", "小鸡": "6bee2", "痛苦": "55932",
"撇嘴": "575cc", "惶恐": "e10b4", "口罩": "24d81", "吐舌": "3cfe4",
"心碎": "875d3", "生气": "e8204", "可爱": "7b97d", "鬼脸": "def52",
"跳舞": "741d5", "男孩": "46b8e", "奸笑": "289dc", "猪": "6935b",
"圈": "3ece0", "便便": "462db", "外星": "0a22b", "圣诞": "8e7",
"流泪": "01000", "强": "1", "爱心": "0CoJU", "女孩": "m6Qyw",
"惊恐": "8W8ju", "大笑": "d"
}

f同样是定值,来自一下表情转换为代码

[
"色", "流感", "这边", "弱", "嘴唇", "亲", "开心", "呲牙", "憨笑",
"猫", "皱眉", "幽灵", "蛋糕", "发怒", "大哭", "兔子", "星星", "钟情",
"牵手", "公鸡", "爱意", "禁止", "狗", "亲亲", "叉", "礼物", "晕",
"呆", "生病", "钻石", "拜", "怒", "示爱", "汗", "小鸡", "痛苦",
"撇嘴", "惶恐", "口罩", "吐舌", "心碎", "生气", "可爱", "鬼脸",
"跳舞", "男孩", "奸笑", "猪", "圈", "便便", "外星", "圣诞"
]

g同上,是["爱心", "女孩", "惊恐", "大笑"]转换为代码

所以

e = "010001"
f = "00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7"
g = "0CoJUm6Qyw8W8jud"

嗯,常量搞清楚了,再看看加密方法

!function () {
function a(a) {
var d, e, b = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", c = "";
for (d = 0; a > d; d += 1)
e = Math.random() * b.length,
e = Math.floor(e),
c += b.charAt(e);
return c
}
function b(a, b) {
var c = CryptoJS.enc.Utf8.parse(b)
, d = CryptoJS.enc.Utf8.parse("0102030405060708")
, e = CryptoJS.enc.Utf8.parse(a)
, f = CryptoJS.AES.encrypt(e, c, {
iv: d,
mode: CryptoJS.mode.CBC
});
return f.toString()
}
function c(a, b, c) {
var d, e;
return setMaxDigits(131),
d = new RSAKeyPair(b, "", c),
e = encryptedString(d, a)
}
function d(d, e, f, g) {
var h = {}
, i = a(16);
return h.encText = b(d, g),
h.encText = b(h.encText, i),
h.encSecKey = c(i, e, f),
h
}
function e(a, b, d, e) {
var f = {};
return f.encText = c(a + e, b, d),
f
}
window.asrsea = d,
window.ecnonasr = e
}();

enText就是post请求里的params参数,来自b函数加密两次

encSecKey来自c函数加密一次

a函数

function a(a) {
var d, e, b = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", c = "";
for (d = 0; a > d; d += 1)
e = Math.random() * b.length,
e = Math.floor(e),
c += b.charAt(e);
return c
}

显然,用来生成指定长度的随机字符串

b函数

function b(a, b) {
var c = CryptoJS.enc.Utf8.parse(b)
, d = CryptoJS.enc.Utf8.parse("0102030405060708")
, e = CryptoJS.enc.Utf8.parse(a)
, f = CryptoJS.AES.encrypt(e, c, {
iv: d,
mode: CryptoJS.mode.CBC
});
return f.toString()
}

只使用了CryptoJS的函数,由于我们也用JavaScript写代码,直接复制即可,管他干嘛的-_-,只要在开头加个

const CryptoJS = require("crypto-js")

即可。

c函数

function c(a, b, c) {
var d, e;
return setMaxDigits(131),
d = new RSAKeyPair(b, "", c),
e = encryptedString(d, a)
}

看着很短,实际上调用了自定义的类,这样就不能用对付b函数的方法了,这里我们先不去看c函数干了什么

我们看看它的参数

c函数的参数d函数里对应的变量
ai长度位16的随机字符串
be"010001"
cf"00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7"

站在网易的角度想想,向网易传了一串加密后的字符串,这个字符串经过一个随机数和三个常量的加密,要想解密,必然需要那个随机数,而encSecKey显然是用来提供那个随机数的,encSecKey只来自c函数且c函数接受一个随机数和两个常量,可以解密出那个随机数,因此:

如果那个a函数得到的随机数如果我们用定值代替,嘿嘿嘿,c函数得出的encSecKey也将是定值!

我们同调试函数即可获取一个随机数和与之对应的加密后的encSecKey

我获得的是:

i = "bEjJE2aqLOyTEZiv"
encSecKey = "3e7ad1dbe03a65fc32268930314b88bcbfc1e9782c3b398c30b62776e39b66a048a7122d282a13d99f9b63bd4e1940b136169fbedf56c1887933fa59a01f95c4c0e78a6d9bb7f91605408e9c1c3c2e57873c53cdf09a3d79a43cfe26260741097089e4bd19808aab395190274e687b807ffddee89f39d75f2288e28a582f3d08"

写代码

经过上面的分析,我们可以得出如下代码:

const CryptoJS = require("crypto-js")

function b(a, b) {
const c = CryptoJS.enc.Utf8.parse(b)
, d = CryptoJS.enc.Utf8.parse("0102030405060708")
, e = CryptoJS.enc.Utf8.parse(a)
, f = CryptoJS.AES.encrypt(e, c, {
iv: d,
mode: CryptoJS.mode.CBC
});
return f.toString()
}
function maker(d) {
var h = {}
, i = "bEjJE2aqLOyTEZiv";
h.encText = b(d, "0CoJUm6Qyw8W8jud");
h.encText = b(h.encText, i);
h.encSecKey = "3e7ad1dbe03a65fc32268930314b88bcbfc1e9782c3b398c30b62776e39b66a048a7122d282a13d99f9b63bd4e1940b136169fbedf56c1887933fa59a01f95c4c0e78a6d9bb7f91605408e9c1c3c2e57873c53cdf09a3d79a43cfe26260741097089e4bd19808aab395190274e687b807ffddee89f39d75f2288e28a582f3d08";
return [h.encText, h.encSecKey];
}

是不是很简单?

3. 实现后端

这部分没什么可讲的,要讲的话也只是将web,因此,跳过。。

完整代码如下:

app.js

const Koa = require("koa")
const querystring = require("querystring")
const axios = require('axios')
const fs = require("fs");
const route = require('koa-route');
const app = new Koa();
const CryptoJS = require("crypto-js")

function b(a, b) {
const c = CryptoJS.enc.Utf8.parse(b)
, d = CryptoJS.enc.Utf8.parse("0102030405060708")
, e = CryptoJS.enc.Utf8.parse(a)
, f = CryptoJS.AES.encrypt(e, c, {
iv: d,
mode: CryptoJS.mode.CBC
});
return f.toString()
}
function maker(d) {
var h = {}
, i = "bEjJE2aqLOyTEZiv";
h.encText = b(d, "0CoJUm6Qyw8W8jud");
h.encText = b(h.encText, i);
h.encSecKey = "3e7ad1dbe03a65fc32268930314b88bcbfc1e9782c3b398c30b62776e39b66a048a7122d282a13d99f9b63bd4e1940b136169fbedf56c1887933fa59a01f95c4c0e78a6d9bb7f91605408e9c1c3c2e57873c53cdf09a3d79a43cfe26260741097089e4bd19808aab395190274e687b807ffddee89f39d75f2288e28a582f3d08";
return [h.encText, h.encSecKey];
}

const page = (ctx) => {
ctx.response.type = "html";
ctx.response.body = fs.createReadStream("./index.html");
}
const request = async (id) => {
const answer = {status: 500, body: {}}
let params = maker(JSON.stringify({
ids: [id],
level: "standard",
encodeType: "aac",
csrf_token: ""
}));
const settings = {
method: "post",
url: "https://music.163.com/weapi/song/enhance/player/url/v1?csrf_token=",
data: querystring.stringify({
params: params[0],
encSecKey: params[1]
})
};
await axios(settings)
.then(res => {
answer.body = res.data;
answer.status = true;
})
.catch((err => {
answer.body = err;
answer.status = false;
}));
return answer;
}
const analyze = async (ctx) => {
let result = "<p>服务器出错!请联系管理员</p>";
await request(ctx.request.query.id)
.then((res) => {
let data;
if (res.status) {
data = res.body;
if (data.code === 200) {
result = '<p>歌曲id为:' + data.data[0].id + '</p><p>点击<a href="' + data.data[0].url + '">链接</a>下载</p>';
} else {
result = '<p>输入错误!</p>';
}
}
})
.catch((err) => {
result = '<p>输入错误!</p><br />' + JSON.stringify(err);
})
ctx.response.body = result;
ctx.response.type = "html";
}
app.use(route.get("/", page))
app.use(route.get("/url", analyze))

app.listen(3000);

4. 实现前端

额,前端我真的不太会,随便写一个吧,能用就行

index.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>网易云音乐链接分析器</title>
</head>
<body>
输入歌曲id:<br>
<label for="id"></label><input id="id" type="text" name="id" value="">
<br>
<button onclick="getWyyyy()">获取下载链接</button>
</body>
<script>
function getWyyyy() {
let input = document.getElementById("id").value
window.location.href = "http://localhost:3000/url?id=" + input
}
</script>
</html>

记得完成后把localhost换成自己的IP或域名哦(如果像远程使用的话)

测试

运行命令:

node app.js

打开浏览器,输入网址:

http://localhost:3000/

如果一切正常,你将看到你写的前端界面

测试01

输入歌曲id,点击按钮

测试02

测试03

弹出下载界面,成功!