网易云音乐 Web 登录算法解密过程分析以及实现

  • 测试环境 Ubuntu 18.10 x64
  • 软件环境 Chrome 71.0.3578.98

近期需要对网易云音乐的登录和评论点赞功能做一些工具类的软件,记录一下网易云音乐API的解密过程和实现方法

登录分析

首先使用浏览器打开 网易云音乐

抓包

打开网易云音乐后 点击右上角登录 -> 使用手机号登录,此时按下F12,打开开发者工具,并切换到 Network

开发者工具 Network

输入自己的网易云音乐账号,得到登录的包(为了方便测试,密码可以输错)

抓取登录数据包

如图,登录时向

https://music.163.com/weapi/login/cellphone?csrf_token=

提交了两个字段 paramsencSecKey

猜测 encSecKeyparams 的解密密匙

寻找 encSecKey 算法

在开发者工具切换到 Sources 右键 Top 后点击 Search in all files 随后输入 encSecKey

搜索结果

搜索结果中,只有一个js包含了encSecKey

https://s3.music.126.net/web/s/core_db15e45072c12697d6fed065e7a71f62.js

静态分析算法

从搜索结果中,定位到88行和90行,将其格式化后

88行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
!
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
} ();

90行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
(function() {
var c9h = NEJ.P,
er2x = c9h("nej.g"),
v9m = c9h("nej.j"),
j9a = c9h("nej.u"),
RQ5V = c9h("nm.x.ek"),
l9c = c9h("nm.x");
if (v9m.bl0x.redefine) return;
window.GEnc = true;
var brK2x = function(cxx7q) {
var m9d = [];
j9a.be0x(cxx7q,
function(cxv7o) {
m9d.push(RQ5V.emj[cxv7o])
});
return m9d.join("")
};
var cxt7m = v9m.bl0x;
v9m.bl0x = function(Y0x, e9f) {
var i9b = {},
e9f = NEJ.X({},
e9f),
mt4x = Y0x.indexOf("?");
if (window.GEnc && /(^|\.com)\/api/.test(Y0x) && !(e9f.headers && e9f.headers[er2x.Ae0x] == er2x.Hb2x) && !e9f.noEnc) {
if (mt4x != -1) {
i9b = j9a.hg3x(Y0x.substring(mt4x + 1));
Y0x = Y0x.substring(0, mt4x)
}
if (e9f.query) {
i9b = NEJ.X(i9b, j9a.fR2x(e9f.query) ? j9a.hg3x(e9f.query) : e9f.query)
}
if (e9f.data) {
i9b = NEJ.X(i9b, j9a.fR2x(e9f.data) ? j9a.hg3x(e9f.data) : e9f.data)
}
i9b["csrf_token"] = v9m.gR2x("__csrf");
Y0x = Y0x.replace("api", "weapi");
e9f.method = "post";
delete e9f.query;
var bVs1x = window.asrsea(JSON.stringify(i9b), brK2x(["流泪", "强"]), brK2x(RQ5V.md), brK2x(["爱心", "女孩", "惊恐", "大笑"]));
e9f.data = j9a.cB0x({
params: bVs1x.encText,
encSecKey: bVs1x.encSecKey
})
}
cxt7m(Y0x, e9f)
};
v9m.bl0x.redefine = true
})();

静态分析后,90行处的代码只是进行了简单的赋值,params由bVs1x.encText而来,encSecKey由bVs1x.encSecKey而来,在88行处,可以看到加密的方式算法和过程,因此88行作为我们的突破口

根据 encSecKey 为关键字寻找,发现在 function d 对其进行了赋值

function d:

1
2
3
4
5
6
7
8
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 a,b,c

逐一静态分析各个 function

function a:

1
2
3
4
5
6
7
8
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 a 随机生成一个长度为传入 int 值的字符串

function b:

1
2
3
4
5
6
7
8
9
10
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()
}

从调用的关键字来看,这是一个有关AES的对称加密算法,模式为CBC,iv(向量)为d,也就是“0102030405060708”

function c:

1
2
3
4
5
6
function c(a, b, c) {
var d, e;
return setMaxDigits(131),
d = new RSAKeyPair(b, "", c),
e = encryptedString(d, a)
}

静态分析来看,这是一个有关于 RSA 的参数,接下来我们使用断点动态分析加密函数

断点调试算法

在下图几个地方打上断点

断点1
断点2
断点3
断点4

打上了断点以后我们再一次点击登录

看到js运行到断点时,停止了,第个断点处,传入了参数 a:16 生成一个16位的字符串

断点运行1

运行到第二个断点时,传入参数 c : “fAY0MsZsT3WA0eXg” 这是AES的加密密匙

断点运行2

运行到第三个断点时,传入参数 b : “0CoJUm6Qyw8W8jud” 和一串json a(登录信息)

断点运行3

运行到第四个断点时,传入参数 b : “fAY0MsZsT3WA0eXg”(AES密匙)和一串Base64字符串:

1
2
a : "2pi8lzed+/YUH63EXHn6d9IwIDeruHJmzW0rjEtUqUh00G+8ElR2XnxRvDOg+eWHcO06ukSrADFNFazIhKGNMJ/fYDsS2bTep4lkTxH42fLDCPm/v+Z/7P3guz9HcZWwNQ1Y1qb2pUNt1nmGpR89n+MiPQb3gbOscmJPXf8V60kDuLF0u0dwHkeqD5hIhgqRa3qZJCeYZcU3GrAEABFMe/cNhfcrUHpGgTOB4JJw=="
(为了保护信息,已经对这里的字符串a进行了删减)

断点运行4

运行到第五个断点时,RSA的加密函数,传入了:

1
2
3
a : "fAY0MsZsT3WA0eXg"(AES密匙)
b : "010001"
C : "00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7"

断点运行5

第一次断点结束后,我们再点击一次登录,再次进行一次断点调试,通过第二次断点调试,发现第三次断点时的传入参数 b 和 第五个断点时的参数 b c 是不变的,分别为

1
2
3
4
b = "0CoJUm6Qyw8W8jud" #第三次断点参数 b
--- 第五次断点 ---
b = "010001"
c = "00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7"

动静结合分析算法

根据 function d :

1
2
3
4
5
6
7
8
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
}

在 var h = {}, 处断点 得到d,e,f,g的值

断点运行6

可以看到

1
2
3
4
d = json a(登录信息) 
e = "010001"
f = "00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7"
g = "0CoJUm6Qyw8W8jud"

将参数放入 function d 中,首先是使用第一个密匙”0CoJUm6Qyw8W8jud”对 JSON 登录信息的字符串进行了一次 AES 加密,随后得到的结果,在使用通过 function a 生成的16位字符作为AES的加密密匙对第一次AES加密的json的登录信息结果,再次加密,而得出的加密结果就作为 encText,也就是整个加密的流程为:

流程图

验证算法

在摸清了整个算法流程以后,我们使用AES在线加解密,将加密结果解密,看是否能得到最初的json登录信息,只需要params的值

  • 使用生成的密匙进行第一次解密 params

第一次decrypt

  • 第一次解密后,我们仍然得到了一串Base64字符串,别着急,在使用默认的密匙”0CoJUm6Qyw8W8jud”对结果进行再次解密

第二次decrypt

此时已经得到了登录信息的 json 字符串 至此,整个网易云登录的算法分析到此结束

Python 实现登录

  • Python 3.6.7
  • pip 18.1

安装第三方库

1
2
3
$ sudo pip install requests
$ sudo pip install pycrypto
$ sudo pip install future

编写加密函数

首先引用 AES 的默认密匙和 RSA 的 MODULUS 和 Pub_Key

1
2
3
4
5
6
7
MODULUS = ('00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7'
'b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280'
'104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932'
'575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b'
'3ece0462db0a22b8e7')
nonce = b'0CoJUm6Qyw8W8jud'
pub_key = '010001'

生成随机密匙

1
2
def create_secret_key(size):
return binascii.hexlify(os.urandom(size))[:16] # 使用库 binascii

AES 加密

1
2
3
4
5
6
def aes_encrypt(text, sec_key):
pad = 16 - len(text) % 16
text = text + bytearray([pad] * pad)
encryptor = AES.new(sec_key, 2, b'0102030405060708')
ciphertext = encryptor.encrypt(text)
return base64.b64encode(ciphertext)

RSA 加密

RSA 加密采用非常规填充方式,既不是PKCS1也不是PKCS1_OAEP,网易的做法是直接向前补0
这样加密出来的密文有个特点:加密过程没有随机因素,明文多次加密后得到的密文是相同的
算法选取2个很大的质数p,q,得到它们的乘积n,然后选取e,d满足e*d = 1 mod (p-1)(q-1)

因此 RSA 的加密算法应该为

1
2
3
4
5
def rsa_encrypt(text, pub_key, modulus):
text = text[::-1]
rs = pow(int(binascii.hexlify(text), 16),
int(pub_key, 16), int(modulus, 16))
return format(rs, 'x').zfill(256)

组合登录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def login_cell_phone(number,password):
md_create = hashlib.md5()
md_create.update(password.encode('utf-8')) #计算密码的md5
text = {
'phone': number,
'password': md_create.hexdigest(),
'rememberLogin': 'true'
}
text = json.dumps(text).encode('utf-8') #获取 JSON 字符串
sec_key = create_secret_key(16) #第二次加密使用的密匙
result = aes_encrypt(text, nonce) #第一次使用默认密匙加密的结果
enc_text = aes_encrypt(result, sec_key) #加密第一次的结果为params
enc_sec_key = rsa_encrypt(sec_key, pub_key, MODULUS)
data = {
'params': enc_text,
'encSecKey': enc_sec_key
}
response = requests.post('https://music.163.com/weapi/login/cellphone', data=data)

这样一看是没有问题了,可是当Run时,返回的登录结果为

{“code”:-460,”msg”:”Cheating”}

是因为缺少了 Headers ,因此添加一个 Headers 并修改为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
header = {
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36'
}

def login_cell_phone(number,password):
md_create = hashlib.md5()
md_create.update(password.encode('utf-8')) #计算密码的md5
text = {
'phone': number,
'password': md_create.hexdigest(),
'rememberLogin': 'true'
}
text = json.dumps(text).encode('utf-8') #获取 JSON 字符串
sec_key = create_secret_key(16) #第二次加密使用的密匙
result = aes_encrypt(text, nonce) #第一次使用默认密匙加密的结果
enc_text = aes_encrypt(result, sec_key) #加密第一次的结果为params
enc_sec_key = rsa_encrypt(sec_key, pub_key, MODULUS)
data = {
'params': enc_text,
'encSecKey': enc_sec_key
}
response = requests.post('https://music.163.com/weapi/login/cellphone', data=data, headers=header)
obj = json.loads(response.text)
print(str(obj["profile"]["nickname"]) + " Login success !")

此时,当账户密码正确时就能返回登录账号的信息了

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import base64
import binascii
import hashlib
import json
import os
import requests
from Crypto.Cipher import AES
from future.builtins import int, pow

MODULUS = ('00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7'
'b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280'
'104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932'
'575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b'
'3ece0462db0a22b8e7')
header = {
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36'
}
nonce = b'0CoJUm6Qyw8W8jud'
pub_key = '010001'

def rsa_encrypt(text, pub_key, modulus):
text = text[::-1]
rs = pow(int(binascii.hexlify(text), 16),
int(pub_key, 16), int(modulus, 16))
return format(rs, 'x').zfill(256)


def create_secret_key(size):
return binascii.hexlify(os.urandom(size))[:16]


def aes_encrypt(text, sec_key):
pad = 16 - len(text) % 16
text = text + bytearray([pad] * pad)
encryptor = AES.new(sec_key, 2, b'0102030405060708')
ciphertext = encryptor.encrypt(text)
return base64.b64encode(ciphertext)

def login_cell_phone(number,password):
md_create = hashlib.md5()
md_create.update(password.encode('utf-8')) # 计算密码的md5
text = {
'phone': number,
'password': md_create.hexdigest(),
'rememberLogin': 'true'
}
text = json.dumps(text).encode('utf-8') # 获取 JSON 字符串
sec_key = create_secret_key(16) # 第二次加密使用的密匙
result = aes_encrypt(text, nonce) # 第一次使用默认密匙加密的结果
enc_text = aes_encrypt(result, sec_key) # 加密第一次的结果为params
enc_sec_key = rsa_encrypt(sec_key, pub_key, MODULUS)
data = {
'params': enc_text,
'encSecKey': enc_sec_key
}
response = requests.post('https://music.163.com/weapi/login/cellphone', data=data, headers=header)
obj = json.loads(response.text)
print(str(obj["profile"]["nickname"]) + " Login success !")
print(response.text)

login_cell_phone('your_number','your_password')