抓取目标 目标网址:https://music.163.com/#/song?id=2045090557
抓取目标:音乐下载抓取
网页分析 打开开发者工具,点击播放按钮,生成播放音乐数据包。
通过观察数据包可以知道,包含音乐链接的数据包为v1?csrf_token=
为了验证该地址为音乐地址,可以将其复制到浏览器中打开。
可以成功播放。
接下来只需搞清楚,如何可以正确的请求到该数据包即可。
点击该数据包的标头,可以看到数据包:
请求地址为https://music.163.com/weapi/song/enhance/player/url/v1?csrf_token=
请求方式为POST
请求。
点击载荷查看POST
请求的表单。
可以看到表单数据为一堆数字和英文的组合,说明表单参数被加密了。
接下来就需要去研究表单是如何进行加密的。
点击启动器,依次研究每个调用堆栈,查看数据包是从哪个步骤开始加密的。
这里就需要慢慢找,数据包比较多。
点击第一个调用点,进入到源码。打赏断点后,重新点击播放按钮,让代码暂停到断点处。
从断点位置可以看出,到当前位置参数已经被加密了,接下来就需要往前找。点击右下角的调用堆栈,往前找一个。可以发现在此处同样是被加密状态。
按照该方式继续往前找。
中间步骤比较简单,就是找前一个调用参数有没有被加密,在此就不再赘述。
一直到下图所示数据包位置开始详细分析。
往前找e2x
的被赋值位置,在其前方打上断点。
重新点击播放按钮。此时注意请求的url
地址
我们POST
请求发送的地址为https://music.163.com/weapi/song/enhance/player/url/v1?csrf_token=
第一次断点出的地址不正确,释放此次断点,直到地址正确(或者大致差不多)。
连续点击释放断点,直到出现以下内容。此时的url
与我们POST
请求的url
稍微有一点出入,这个后面会讲到是怎么还原的。
接下来开始单步调试。
首先查看一下此时e2x
的内容。
发现这个时候参数内容是未加密的状态。我们进行单步调试,看看哪一步是将参数进行加密的。
一直调试的下图所示位置,我们发现在变量bMs8k
中出现了加密后的数据。
通过对源码的观察我们不难发现bMs8k
是通过window.asrsea()
对i2x
进行加密的。
将其这部分代码复制出来。
1 var bMs8k = window .asrsea (JSON .stringify (i2x), bsi5n (["流泪" , "强" ]), bsi5n (Vx0 x.md ), bsi5n (["爱心" , "女孩" , "惊恐" , "大笑" ]));
函数window.asrsea
传入了4个参数:
JSON.stringify(i2x)
:用于将i2x
转化为JSON
格式。
bsi5n(["流泪", "强"])
:通过在console
中对其进行打印发现为固定参数,'010001'
bsi5n(Vx0x.md)
:通过在console
中对其进行打印发现也为固定参数,'00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7'
bsi5n(["爱心", "女孩", "惊恐", "大笑"])
:通过在console
中对其进行打印发现同样是固定参数,'0CoJUm6Qyw8W8jud'
接下来对函数window.asrsea
进行详细分析。
点击进入函数,将其内容复制出来。
复制代码如下:
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 }
经过前面的分析,在该函数中参数e
、f
、g
为固定值,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
加密,key
为b
,iv
为b'0102030405060708'
,mode
是CBC
格式。
接下来看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
函数的功能是对传入的参数a
做RSA
加密,公钥为d
。调用的RSA
加密是自己写的encryptedString
函数(这个在后面扩展内容中会对其进行验证),如果要进一步去读他这个自己写的RSA
加密函数,难度会很大。
有一个简单方法,从外部调用来看该函数的作用就是将前面生成的随机16
位字符串进行了RSA
加密,由于使用前面随机生成的16
位字符串都可以达到对整个请求的正常访问,那么我们可以在这里直接将这个随机字符串固定下来肯定也是没问题的,后续调用RSA
加密该参数时,直接也将加密的结果也复制出来,这样就可以大大简化我们的工作量。
直接在console
打印出这两个参数,如下所示:
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); h.encText = b (h.encText , i); h.encSecKey = '5275ed9a42b5a4c3a056bda80986295e57d4c0afebbc5edd76d21ef6a74e9cc9c4644eefc182c2a19fc8ade0307fda204254285100c47b0ad2339e3d0c633402ba037f90f5b90c0794f887d7393706150d5d0999f9b715d7b4e9a5c19613ef5c18d68414d84f7f4ed16a179d9d6243ffaddf3c9012e14b61d615f3f32d7e554b' ; return h }
代码实现 整个加密函数的逻辑已经被我们捋清楚,接下来使用Python
对其功能进行实现。
成功生成对应加密参数。
接下来使用该参数对https://music.163.com/weapi/song/enhance/player/url/v1?csrf_token=
进行请求。
请求成功,获取到对应的音乐下载地址。
访问音乐地址,将其下载到本地。
下载成功,并且可以成功播放。
到现在为止,音乐已经被我们下载完毕。并且支持批量下载,只需在参数'ids'
中加入对应的歌曲编号即可。
例如修改为:[2045090557, 1857630559]
,即可下载两个音乐。
都下载成功!
但是现在还不太完美,我们只能够使用音乐的编号对音乐文件进行命名,并不知道歌曲名称和歌手名字。
解决这个问题的方式和前面完全一样,就不再赘述。
但是有一点需要注意,其参数格式不和之前一样,如下图所示。
定义一个函数用于根据音乐id
获取对应歌曲名称和歌手名字。
完整代码如下:
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 AESfrom Crypto.Util.Padding import padimport jsonimport requestsimport base64def 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 ): 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
即可完成批量下载到本地,如下图所示。
拓展 RSA加密基本原理 算法本身基于一个简单的数论知识:给出两个素数,很容易将它们相乘,然而给出它们的乘积,反过来想得到这两个素数就显得尤为困难。如果能够解决大整数(比如几百位的整数)分解的快速方法,那么RSA
算法将轻易被破解。
加密过程:
解密过程:
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) }
待加密参数为1eOHSUBwXoWq3xIQ
,b
为010001
,c
为00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7
由RSA
加密原理可知,其中b
和c
即为公式中的p
与q
的16进制格式,根据原理可写如下代码复现RSA
加密过程。
1 2 3 4 5 6 7 8 9 def c (i, e, f ): e = int (e, 16 ) f = int (f, 16 ) bs = i.encode("utf-8" ) s = binascii.b2a_hex(bs).decode() s = int (s, 16 ) res = (s**e) % f return format (res, "x" )
验证普通RSA加密是否和网页加密结果一致 使用该方式加密参数i
,得到的结果为:
246f920e0538f745126b1dc5cfc0a3fa42dfd437dea4a9e2dad3418aa7362d9e246a0978de2bb7c0b164159169f9387303905eb88a9b33144b890e37c99a5ba4622677f7f458c5b9c2a220fec07b573d46aaf9ad1cc3d58e38516512bfcb871a19cdd4d0cd791f0a22172fb001d17ad5c4fc2e6e34f5a09e16eebf71d754b71b
而网页中加密的结果为:
5275ed9a42b5a4c3a056bda80986295e57d4c0afebbc5edd76d21ef6a74e9cc9c4644eefc182c2a19fc8ade0307fda204254285100c47b0ad2339e3d0c633402ba037f90f5b90c0794f887d7393706150d5d0999f9b715d7b4e9a5c19613ef5c18d68414d84f7f4ed16a179d9d6243ffaddf3c9012e14b61d615f3f32d7e554b
这两个加密结果对比可以看出网易云音乐对传统的RSA
加密过程进行了魔改,结果对不上。故使用传统的参数去访问页面也拿不到真实数据。
该案例中RSA加密分析 为了搞清楚网易云音乐网页中RSA
经过了什么样的魔改还需要再回到源码中进行分析。
进入如图所示加密函数中。
进入到函数中后,再打上断点,释放当前断点,进入加密函数处。
找到图中所示函数,进入函数。
可以看到在处理参数e时使用到了一个biShiftRight
函数,该函数的功能就是不断的在原始字符串中取出最后一个值。最后的结果就是将待加密字符串整个反转过来再进行RSA
加密。根据观察到的这个现象我们再次修改RSA
中的Python
代码。将待加密参数反转。
再次测试加密结果。
得到此时的加密结果为:
5275ed9a42b5a4c3a056bda80986295e57d4c0afebbc5edd76d21ef6a74e9cc9c4644eefc182c2a19fc8ade0307fda204254285100c47b0ad2339e3d0c633402ba037f90f5b90c0794f887d7393706150d5d0999f9b715d7b4e9a5c19613ef5c18d68414d84f7f4ed16a179d9d6243ffaddf3c9012e14b61d615f3f32d7e554b
原始的加密结果为:
5275ed9a42b5a4c3a056bda80986295e57d4c0afebbc5edd76d21ef6a74e9cc9c4644eefc182c2a19fc8ade0307fda204254285100c47b0ad2339e3d0c633402ba037f90f5b90c0794f887d7393706150d5d0999f9b715d7b4e9a5c19613ef5c18d68414d84f7f4ed16a179d9d6243ffaddf3c9012e14b61d615f3f32d7e554b
惊奇的发现结果完全匹配。
使用该函数替换原始代码中对h['encSecKey']
的赋值结果。
再次运行整个代码,发现代码运行成功。
最终完整代码 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 binasciifrom Crypto.Cipher import AESfrom Crypto.Util.Padding import padimport jsonimport requestsimport base64def 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 ) f = int (f, 16 ) i = i[::-1 ] bs = i.encode("utf-8" ) s = binascii.b2a_hex(bs).decode() s = int (s, 16 ) res = (s**e) % f 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' ] = 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 ): 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
时访问报错。
设置cookie
后访问成功。