0%

网络爬虫逆向(网易云音乐)

抓取目标

目标网址:https://music.163.com/#/song?id=2045090557

抓取目标:音乐下载抓取

image-20230509164245060

网页分析

打开开发者工具,点击播放按钮,生成播放音乐数据包。

image-20230509164521309

通过观察数据包可以知道,包含音乐链接的数据包为v1?csrf_token=

image-20230509164802245

为了验证该地址为音乐地址,可以将其复制到浏览器中打开。

image-20230509164850114

可以成功播放。

接下来只需搞清楚,如何可以正确的请求到该数据包即可。

点击该数据包的标头,可以看到数据包:

请求地址为https://music.163.com/weapi/song/enhance/player/url/v1?csrf_token=

请求方式为POST请求。

image-20230509164934674

点击载荷查看POST请求的表单。

image-20230509165041077

可以看到表单数据为一堆数字和英文的组合,说明表单参数被加密了。

接下来就需要去研究表单是如何进行加密的。

点击启动器,依次研究每个调用堆栈,查看数据包是从哪个步骤开始加密的。

image-20230509165531446

这里就需要慢慢找,数据包比较多。

点击第一个调用点,进入到源码。打赏断点后,重新点击播放按钮,让代码暂停到断点处。

image-20230509165646112

从断点位置可以看出,到当前位置参数已经被加密了,接下来就需要往前找。点击右下角的调用堆栈,往前找一个。可以发现在此处同样是被加密状态。

image-20230509170056200

按照该方式继续往前找。

中间步骤比较简单,就是找前一个调用参数有没有被加密,在此就不再赘述。

一直到下图所示数据包位置开始详细分析。

image-20230509170233040

往前找e2x的被赋值位置,在其前方打上断点。

image-20230509170340005

重新点击播放按钮。此时注意请求的url地址

我们POST请求发送的地址为https://music.163.com/weapi/song/enhance/player/url/v1?csrf_token=

第一次断点出的地址不正确,释放此次断点,直到地址正确(或者大致差不多)。

image-20230509170716968

连续点击释放断点,直到出现以下内容。此时的url与我们POST请求的url稍微有一点出入,这个后面会讲到是怎么还原的。

image-20230509170758601

接下来开始单步调试。

首先查看一下此时e2x的内容。

image-20230509170907864

发现这个时候参数内容是未加密的状态。我们进行单步调试,看看哪一步是将参数进行加密的。

一直调试的下图所示位置,我们发现在变量bMs8k中出现了加密后的数据。

image-20230509171224941

通过对源码的观察我们不难发现bMs8k是通过window.asrsea()i2x进行加密的。

将其这部分代码复制出来。

1
var bMs8k = window.asrsea(JSON.stringify(i2x), bsi5n(["流泪", "强"]), bsi5n(Vx0x.md), bsi5n(["爱心", "女孩", "惊恐", "大笑"]));

函数window.asrsea传入了4个参数:

  • JSON.stringify(i2x):用于将i2x转化为JSON格式。

  • bsi5n(["流泪", "强"]):通过在console中对其进行打印发现为固定参数,'010001'

    image-20230509171629282

  • bsi5n(Vx0x.md):通过在console中对其进行打印发现也为固定参数,'00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7'

image-20230509171740398

  • bsi5n(["爱心", "女孩", "惊恐", "大笑"]):通过在console中对其进行打印发现同样是固定参数,'0CoJUm6Qyw8W8jud'

image-20230509171835814

接下来对函数window.asrsea进行详细分析。

点击进入函数,将其内容复制出来。

image-20230509172115203

image-20230509172139364

复制代码如下:

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
}

按照JS语法规则对其进行改写成如下格式。

1
2
3
4
5
6
7
8
function d(d, e, f, g) {
var h = {}
var i = a(16);
h.encText = b(d, g);
h.encText = b(h.encText, i);
h.encSecKey = c(i, e, f);
return h
}

经过前面的分析,在该函数中参数efg为固定值,d为待加密参数的JSON格式。

还有很多变量是未知的,我们需要依次对其进行解决。

首先是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
}

a函数比较简单,作用是生成16位的随机字符串,字符在a-z0-9中取值。

接下来看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()
}

b函数的作用就是做一次AES加密,keybivb'0102030405060708'modeCBC格式。

接下来看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)
}

c函数的功能是对传入的参数aRSA加密,公钥为d。调用的RSA加密是自己写的encryptedString函数(这个在后面扩展内容中会对其进行验证),如果要进一步去读他这个自己写的RSA加密函数,难度会很大。

有一个简单方法,从外部调用来看该函数的作用就是将前面生成的随机16位字符串进行了RSA加密,由于使用前面随机生成的16位字符串都可以达到对整个请求的正常访问,那么我们可以在这里直接将这个随机字符串固定下来肯定也是没问题的,后续调用RSA加密该参数时,直接也将加密的结果也复制出来,这样就可以大大简化我们的工作量。

直接在console打印出这两个参数,如下所示:

image-20230509174427523

1
2
a: '1eOHSUBwXoWq3xIQ'
encryptedString(d, a): '5275ed9a42b5a4c3a056bda80986295e57d4c0afebbc5edd76d21ef6a74e9cc9c4644eefc182c2a19fc8ade0307fda204254285100c47b0ad2339e3d0c633402ba037f90f5b90c0794f887d7393706150d5d0999f9b715d7b4e9a5c19613ef5c18d68414d84f7f4ed16a179d9d6243ffaddf3c9012e14b61d615f3f32d7e554b'

现在所有的位置变量我们都已经解决,将window.asrsea函数进一步改写为如下所示格式:

1
2
3
4
5
6
7
8
9
10
function d(d, 
e='010001', f='00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7',
g='0CoJUm6Qyw8W8jud') {
var h = {}
var i = '1eOHSUBwXoWq3xIQ'; // 随机数被我们固定下来
h.encText = b(d, g); // 对d进行AES加密,key为g,密钥为'0102030405060708'
h.encText = b(h.encText, i); // 对h.encText进行AES加密,key为i,密钥为'0102030405060708'
h.encSecKey = '5275ed9a42b5a4c3a056bda80986295e57d4c0afebbc5edd76d21ef6a74e9cc9c4644eefc182c2a19fc8ade0307fda204254285100c47b0ad2339e3d0c633402ba037f90f5b90c0794f887d7393706150d5d0999f9b715d7b4e9a5c19613ef5c18d68414d84f7f4ed16a179d9d6243ffaddf3c9012e14b61d615f3f32d7e554b';
return h
}

代码实现

整个加密函数的逻辑已经被我们捋清楚,接下来使用Python对其功能进行实现。

image-20230509180823133

成功生成对应加密参数。

image-20230509180839184

接下来使用该参数对https://music.163.com/weapi/song/enhance/player/url/v1?csrf_token=进行请求。

image-20230509181704362

请求成功,获取到对应的音乐下载地址。

image-20230509181723167

访问音乐地址,将其下载到本地。

image-20230509183212202

下载成功,并且可以成功播放。

image-20230509183246108

到现在为止,音乐已经被我们下载完毕。并且支持批量下载,只需在参数'ids'中加入对应的歌曲编号即可。

例如修改为:[2045090557, 1857630559],即可下载两个音乐。

image-20230509183555529

image-20230509183550209

都下载成功!

image-20230509183643678

但是现在还不太完美,我们只能够使用音乐的编号对音乐文件进行命名,并不知道歌曲名称和歌手名字。

解决这个问题的方式和前面完全一样,就不再赘述。

但是有一点需要注意,其参数格式不和之前一样,如下图所示。

image-20230509184239652

定义一个函数用于根据音乐id获取对应歌曲名称和歌手名字。

image-20230509190123783

image-20230509190131696

完整代码如下:

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
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
import json
import requests
import base64

def b(a, b):
c = b.encode('utf-8')
d = '0102030405060708'.encode('utf-8')
e = a.encode('utf-8')

aes = AES.new(key=c, mode=AES.MODE_CBC, iv=d)
e = pad(e, 16)
return base64.b64encode(aes.encrypt(e)).decode()

def d(d,
e='010001',
f='00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7',
g='0CoJUm6Qyw8W8jud'):
h = {}
i = '1eOHSUBwXoWq3xIQ'
h['encText'] = b(d, g)
h['encText'] = b(h['encText'], i)
h['encSecKey'] = '5275ed9a42b5a4c3a056bda80986295e57d4c0afebbc5edd76d21ef6a74e9cc9c4644eefc182c2a19fc8ade0307fda204254285100c47b0ad2339e3d0c633402ba037f90f5b90c0794f887d7393706150d5d0999f9b715d7b4e9a5c19613ef5c18d68414d84f7f4ed16a179d9d6243ffaddf3c9012e14b61d615f3f32d7e554b'
return h

def get_name(id):
music_detial_api = 'https://music.163.com/weapi/v3/song/detail?csrf_token='

unenc_data = {"id": str(id),
"c": '[{"id": "%s"}]' % str(id),
"csrf_token": ""}
enc_data = d(json.dumps(unenc_data))

data = {
'params': enc_data['encText'],
'encSecKey': enc_data['encSecKey']
}

# 定义请求头
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36",
}
resp = requests.post(music_detial_api, headers=headers, data=data).json()
song_name = resp['songs'][0]['name']
author_name = ','.join([i['name'] for i in resp['songs'][0]['ar']])
return song_name + f'({author_name}).m4a'

def main(songs_list):
# 定义请求api
music163_api = 'https://music.163.com/weapi/song/enhance/player/url/v1?csrf_token='
# 定义未加密参数
unenc_data = {"ids": songs_list,
"level": "standard",
"encodeType": "aac",
"csrf_token": ""}


# 进行参数加密
enc_data = d(json.dumps(unenc_data))
# 定义表单
data = {
'params': enc_data['encText'],
'encSecKey': enc_data['encSecKey']
}
# 定义请求头
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36",
}
resp = requests.post(music163_api, headers=headers, data=data).json()
for i in range(len(resp["data"])):
filename = get_name(resp["data"][i]["id"])
with open(filename, 'wb') as f:
f.write(requests.get(resp["data"][i]["url"]).content)
print(filename + '下载成功!')


if __name__ == '__main__':
songs_list = [2045090557, 1857630559]
main(songs_list)

songs_list中更换为其他音乐的id即可完成批量下载到本地,如下图所示。

image-20230509190428523

拓展

RSA加密基本原理

算法本身基于一个简单的数论知识:给出两个素数,很容易将它们相乘,然而给出它们的乘积,反过来想得到这两个素数就显得尤为困难。如果能够解决大整数(比如几百位的整数)分解的快速方法,那么RSA算法将轻易被破解。

image-20230510211513281

加密过程:

image-20230510211528545

解密过程:

image-20230510211538342

Python代码实现

根据前面的步骤我们可以发现有以下结论

网页中RSA加密部分JS代码如下所示:

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)
}

待加密参数为1eOHSUBwXoWq3xIQb010001c00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7

RSA加密原理可知,其中bc即为公式中的pq的16进制格式,根据原理可写如下代码复现RSA加密过程。

1
2
3
4
5
6
7
8
9
def c(i, e, f):
e = int(e, 16) # 将e和f 16进制数据转化为10进制数值
f = int(f, 16)
bs = i.encode("utf-8") # 将待加密参数字符串i转化为字节
s = binascii.b2a_hex(bs).decode() # 将字节转化为16进制
s = int(s, 16) # 将16进制又转化为10进制数值
# 到这一步,e,f,s全都被转化为了10进制数值
res = (s**e) % f # 进行rsa加密
return format(res, "x")

验证普通RSA加密是否和网页加密结果一致

使用该方式加密参数i,得到的结果为:

246f920e0538f745126b1dc5cfc0a3fa42dfd437dea4a9e2dad3418aa7362d9e246a0978de2bb7c0b164159169f9387303905eb88a9b33144b890e37c99a5ba4622677f7f458c5b9c2a220fec07b573d46aaf9ad1cc3d58e38516512bfcb871a19cdd4d0cd791f0a22172fb001d17ad5c4fc2e6e34f5a09e16eebf71d754b71b

而网页中加密的结果为:

5275ed9a42b5a4c3a056bda80986295e57d4c0afebbc5edd76d21ef6a74e9cc9c4644eefc182c2a19fc8ade0307fda204254285100c47b0ad2339e3d0c633402ba037f90f5b90c0794f887d7393706150d5d0999f9b715d7b4e9a5c19613ef5c18d68414d84f7f4ed16a179d9d6243ffaddf3c9012e14b61d615f3f32d7e554b

这两个加密结果对比可以看出网易云音乐对传统的RSA加密过程进行了魔改,结果对不上。故使用传统的参数去访问页面也拿不到真实数据。

image-20230510213540820

该案例中RSA加密分析

为了搞清楚网易云音乐网页中RSA经过了什么样的魔改还需要再回到源码中进行分析。

进入如图所示加密函数中。

image-20230510213750558

进入到函数中后,再打上断点,释放当前断点,进入加密函数处。

image-20230510214017686

找到图中所示函数,进入函数。

image-20230510214046470

image-20230510214144011

可以看到在处理参数e时使用到了一个biShiftRight函数,该函数的功能就是不断的在原始字符串中取出最后一个值。最后的结果就是将待加密字符串整个反转过来再进行RSA加密。根据观察到的这个现象我们再次修改RSA中的Python代码。将待加密参数反转。

image-20230510214416850

再次测试加密结果。

得到此时的加密结果为:

5275ed9a42b5a4c3a056bda80986295e57d4c0afebbc5edd76d21ef6a74e9cc9c4644eefc182c2a19fc8ade0307fda204254285100c47b0ad2339e3d0c633402ba037f90f5b90c0794f887d7393706150d5d0999f9b715d7b4e9a5c19613ef5c18d68414d84f7f4ed16a179d9d6243ffaddf3c9012e14b61d615f3f32d7e554b

原始的加密结果为:

5275ed9a42b5a4c3a056bda80986295e57d4c0afebbc5edd76d21ef6a74e9cc9c4644eefc182c2a19fc8ade0307fda204254285100c47b0ad2339e3d0c633402ba037f90f5b90c0794f887d7393706150d5d0999f9b715d7b4e9a5c19613ef5c18d68414d84f7f4ed16a179d9d6243ffaddf3c9012e14b61d615f3f32d7e554b

惊奇的发现结果完全匹配。

使用该函数替换原始代码中对h['encSecKey']的赋值结果。

image-20230510214707989

再次运行整个代码,发现代码运行成功。

image-20230510214733042

最终完整代码

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
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
import binascii

from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
import json
import requests
import base64

def b(a, b):
c = b.encode('utf-8')
d = '0102030405060708'.encode('utf-8')
e = a.encode('utf-8')

aes = AES.new(key=c, mode=AES.MODE_CBC, iv=d)
e = pad(e, 16)
return base64.b64encode(aes.encrypt(e)).decode()


def c(i, e, f):
e = int(e, 16) # 将e和f 16进制数据转化为10进制数值
f = int(f, 16)
i = i[::-1]
bs = i.encode("utf-8") # 将待加密参数字符串i转化为字节
s = binascii.b2a_hex(bs).decode() # 将字节转化为16进制
s = int(s, 16) # 将16进制又转化为10进制数值
# 到这一步,e,f,s全都被转化为了10进制数值
res = (s**e) % f # 进行rsa加密
return format(res, "x")


def d(d,
e='010001',
f='00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7',
g='0CoJUm6Qyw8W8jud'):
h = {}
i = '1eOHSUBwXoWq3xIQ'
h['encText'] = b(d, g)
h['encText'] = b(h['encText'], i)
# h['encSecKey'] = '5275ed9a42b5a4c3a056bda80986295e57d4c0afebbc5edd76d21ef6a74e9cc9c4644eefc182c2a19fc8ade0307fda204254285100c47b0ad2339e3d0c633402ba037f90f5b90c0794f887d7393706150d5d0999f9b715d7b4e9a5c19613ef5c18d68414d84f7f4ed16a179d9d6243ffaddf3c9012e14b61d615f3f32d7e554b'
h['encSecKey'] = c(i, e, f)
return h



def get_name(id):
music_detial_api = 'https://music.163.com/weapi/v3/song/detail?csrf_token='

unenc_data = {"id": str(id),
"c": '[{"id": "%s"}]' % str(id),
"csrf_token": ""}
enc_data = d(json.dumps(unenc_data))

data = {
'params': enc_data['encText'],
'encSecKey': enc_data['encSecKey']
}

# 定义请求头
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36",
}
resp = requests.post(music_detial_api, headers=headers, data=data).json()
song_name = resp['songs'][0]['name']
author_name = ','.join([i['name'] for i in resp['songs'][0]['ar']])
return song_name + f'({author_name}).m4a'

def main(songs_list):
# 定义请求api
music163_api = 'https://music.163.com/weapi/song/enhance/player/url/v1?csrf_token='
# 定义未加密参数
unenc_data = {"ids": songs_list,
"level": "standard",
"encodeType": "aac",
"csrf_token": ""}


# 进行参数加密
enc_data = d(json.dumps(unenc_data))
print(enc_data['encSecKey'])
# 定义表单
data = {
'params': enc_data['encText'],
'encSecKey': enc_data['encSecKey']
}
# 定义请求头
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36",
}
resp = requests.post(music163_api, headers=headers, data=data).json()
for i in range(len(resp["data"])):
filename = get_name(resp["data"][i]["id"])
with open(filename, 'wb') as f:
f.write(requests.get(resp["data"][i]["url"]).content)
print(filename + '下载成功!')


if __name__ == '__main__':
songs_list = [2045090557, 1857630559]
main(songs_list)

注:使用该方法并不是所有的歌曲都可以下载,只能下载免费,不需要登录就可以听的歌曲,像VIP才可以听的歌曲需要在访问页面的时设置cookie才可下载。

未设置cookie时访问报错。

image-20230510215517869

设置cookie后访问成功。

image-20230510215553341

-------------本文结束感谢您的阅读-------------