網易雲音樂ncm格式分析以及ncm與mp3格式轉換
阿新 • • 發佈:2020-08-07
[TOC]
昨天,我想將網易雲上下載的歌曲拷到MP3裡面,方便以後跑5公里的時候聽,結果,突然發現不少歌都是ncm格式,不禁產生了好奇。
## NCM格式分析
### 音訊知識簡介
特意讀了一下《音視訊開發進階指南》,總結如下:
我們平常說的mp3格式、wav格式的音樂其實是說的壓縮編碼格式。
一首歌是怎麼從歌手的喉嚨裡發出後變成一個檔案的呢?
需要經過取樣、量化和編碼三個步驟。
* 取樣
聲音是連續的模擬訊號,通過取樣,將之轉變為離散的數字訊號,其中要遵循的是奈奎斯特定理:只要取樣頻率不低於聲音訊號最高頻率的兩倍,取樣得到的數字訊號就能保真地記錄、還原聲音。
人耳能夠聽到的範圍是20Hz到20kHz,所以取樣頻率一般為44.1kHz,這樣就可以保證取樣聲音達到20kHz也能被數字化,從而使得經過數字化處理之後,人耳聽到的聲音質量不會被降低。而所謂的44.1kHz就是代表1秒會取樣44100次
* 量化
量化是指在幅度軸上對訊號進行數字化,就是用多少位的資料來記錄一個取樣。比如用16位元的二進位制訊號來表示聲音的一個取樣,而16位元(一個short)所表示的範圍是[-32768,32767],共有65536個可能取值,因此最終模擬的音訊訊號在幅度上也分為了65536層
* 編碼
編碼就是我們按一定的格式對取樣和量化後的數字資料進行記錄。直接儲存的話,檔案可能過大,像CD那樣直接儲存下來的沒什麼問題,但如果要在網路中線上傳播,就必須進行壓縮。
壓縮的原理是壓縮掉冗餘訊號,包括人耳感知不到的訊號以及人耳掩蔽效應(指人耳只對最明顯的聲音反應敏感)掩蔽掉的訊號。同時壓縮演算法包括有失真壓縮和無失真壓縮。無失真壓縮是指解壓後的資料可以完全復原。有失真壓縮是指解壓後的資料不能完全復原,會丟失一部分資訊。
### 兩種可能
第一種可能是網易獨立進行了壓縮編碼演算法的研究,創造出來的新的格式。
第二種是在現有格式的基礎上,增加了一些冗餘資訊,相當於將一首MP3格式的歌放入密碼箱中,付費者可開啟。
不管是哪種,都必須瞭解格式的構成。
### GitHub專案
我自知學藝不精,所以去萬能的GitHub上尋求答案。
果然有先驅者,貌似是anonymous5l提供了最初的ncmdump版本,然後再由其他幾位大佬進行重構和功能完善
1. [anonymous5l](https://github.com/anonymous5l/ncmdump)(C++,MIT協議)
基於openssl庫編寫,所以速度非常快,而且又好。
2. [nondanee](https://github.com/nondanee/ncmdump)(python,MIT協議)
依賴pycryptodome庫、mutagen庫,比較完善了。
3. [lianglixin](https://github.com/lianglixin/ncmdump)(python,MIT協議)
fork的nondanee作者的原始碼,修改了依賴庫依賴pycrypto庫,會有一些安裝和使用問題
4. [yoki123](https://github.com/yoki123/ncmdump) (go,MIT協議)
依據anonymous51的工作,使用go語言實現
### 格式分析
#### 總體結構
首先,我從[yoki123](https://github.com/yoki123/ncmdump)那裡找到了一張NCM結構圖
![](https://img2020.cnblogs.com/blog/1776217/202008/1776217-20200805233617432-1395590207.png)
由此可得知,NCM 實際上不是音訊格式是容器格式,封裝了對應格式的 Meta 以及封面等資訊
#### 金鑰問題
另外,NCM使用了NCM使用了AES加密,但每個NCM加密的金鑰是一樣的,因此只要獲取了AES的金鑰KEY,就可以根據格式解開對應的資源。
AES我知道,一種對稱加密演算法嘛,這學期剛好學了網路密碼。
AES是一種迭代型分組加密演算法,分組長度為128bit,金鑰長度為128、192或256bit,不同的金鑰長度對應的迭代輪數不同,對應關係如下:
| 金鑰長度 | 輪數 |
| :---: | :---: |
| 128 | 10 |
| 192 | 12 |
| 256 | 14 |
我最好奇的是AES的金鑰是怎麼搞到的。出於“不可能只有我一個人好奇”的信念,看了好幾個專案的README.md以及issues
結果只有一個人在yoki123的專案中issues了這個問題,
![](https://img2020.cnblogs.com/blog/1776217/202008/1776217-20200806001310469-322652095.png)
大佬表示,他的金鑰也是從annoymous51處獲得的,但他推測是通過反編譯播放器客戶端得到的。
並給出了三條原因:
1. 播放器也需要讀取ncm格式,客戶端就包含有解密邏輯
2. 解密演算法是AES,是對稱加密
3. 恰巧所有的檔案都使用了相同的AES key,那麼key在客戶端播放器中就是一個常量
而作為第一個搞到金鑰的大佬annoymous51,他的專案中竟然沒有一個人問這個問題,我自己問了一下,看大佬會不會回覆
![](https://img2020.cnblogs.com/blog/1776217/202008/1776217-20200806001632770-330227725.png)
#### 程式碼分析
金鑰的問題暫時不糾結了,接下來對照lianglixin的程式碼來鑽研,
[lianglixin](https://github.com/lianglixin/ncmdump)
可以看到專案中有兩個檔案
![](https://img2020.cnblogs.com/blog/1776217/202008/1776217-20200805230128440-144117060.png)
從提交說明來看,folder_dump.py實現的是批量的轉換,雖說Python檔案操作的部分不難,但是有人做了這個工作也省得我自己動手了。
在她的README.md中說明了需要安裝依賴庫pycrypto,使用`pip install pycrypto`安裝,但如果使用了`Anaconda`,就不需要裝了
![](https://img2020.cnblogs.com/blog/1776217/202008/1776217-20200805231756334-849382184.png)
程式碼地址為:https://github.com/lianglixin/ncmdump/blob/master/folder_dump.py
相比於C++版本和Go語言版本,Python實現出來相對比較好懂,結構十分明朗,
只有main函式和dump函式
##### main函式
![](https://img2020.cnblogs.com/blog/1776217/202008/1776217-20200806002748544-626570274.png)
main函式中用來進行檔案操作,根據輸入的引數中的資料夾,在此資料夾中的全部檔案中進行篩選,找到.ncm格式的檔案,執行dump函式
這個程式按理來說,執行的方法是在命令列中cd到此檔案所在路徑,然後輸入`python folder_dump.py ncm儲存資料夾路徑`
但這種方式挺麻煩的,而且程式中竟然還有變數都沒有定義,比如rootdir,因此無法執行成功,
於是我對她這一部分再次進行了修改,我將main函式改成如下所示的內容:
```
if __name__ == '__main__':
file_path = input("請輸入檔案所在路徑(例如:E:\\ncm_music)\n")
list = os.listdir(file_path) # Get all files in folder.
for i in range(0,len(list)):
# path = os.path.join("E:\\ncm_music",list[i])
path = os.path.join(file_path, list[i])
print(path)
if os.path.isfile(path):
if os.path.isfile(path):
if file_extension(path) == ".ncm":
try:
dump(path)
except:
pass
```
效果如下:
![](https://img2020.cnblogs.com/blog/1776217/202008/1776217-20200806004256638-148682573.png)
##### 匯入模組
然後看看匯入的模組
```
import binascii
import struct
import base64
import json
import os
from Crypto.Cipher import AES
```
* binascii的主要作用是實現進位制和字串之間的轉換。
* Python提供了struct模組,它是一個類似C或C++的struct結構,配合其模組提供的方法可以將二進位制資料與Python的資料結構互相轉換。
* Base64 是網路上最常見的用於傳輸 8Bit 位元組碼的編碼方式之一,Base64 就是一種基於 64 個可列印字元來表示二進位制資料的方法。可檢視 RFC2045 ~ RFC2049,上面有 MIME 的詳細規範。Base64 編碼是從二進位制到字元的過程,可用於在 HTTP 環境下傳遞較長的標識資訊。比如使二進位制資料可以作為電子郵件的內容正確地傳送,用作 URL 的一部分,或者作為 HTTP POST 請求的一部分。
* json模組提供了對JSON的支援,它既包含了將JSON字串恢復成Python物件的函式,也提供了將Python物件轉換成JSON字串的函式。
* os模組提供了多數作業系統的功能介面函式。當os模組被匯入後,它會自適應於不同的作業系統平臺,根據不同的平臺進行相應的操作,在python程式設計時,經常和檔案、目錄打交道,所以離不開os模組。
* Crypto是一個加密演算法模組,Cipher是該模組下的對稱加密演算法物件。
##### dump函式
最後看看dump函式,這個才是重點
```
1. def dump(file_path):
2. core_key = binascii.a2b_hex("687A4852416D736F356B496E62617857")
3. meta_key = binascii.a2b_hex("2331346C6A6B5F215C5D2630553C2728")
4. unpad = lambda s : s[0:-(s[-1] if type(s[-1]) == int else ord(s[-1]))]
5. f = open(file_path,'rb')
6. header = f.read(8)
7. assert binascii.b2a_hex(header) == b'4354454e4644414d'
8. f.seek(2, 1)
9. key_length = f.read(4)
10. key_length = struct.unpack('= key_length: key_offset = 0
28. key_box[i] = key_box[c]
29. key_box[c] = swap
30. last_byte = c
31. meta_length = f.read(4)
32. meta_length = struct.unpack('>> x = bytearray(b"Hello!")
>>> x[1] = ord(b"u")
>>> x
bytearray(b'Hullo!')
```
要將第一個位元組處的字元“e”替換成“u”,首先得藉助ord函式將“u”轉換成整數再賦給x[1]
第13行,`for i in range (0,len(key_data_array)): key_data_array[i] ^= 0x64`
將位元組陣列`key_data_array`的每個位元組中的值與0x64進行異或操作
這一步挺讓人費解的,這個0x64像是從天而降一般毫無徵兆。
但我估計這是一種混淆策略(推測而已),0x64可能只是加密的人隨意構造的一個數,用來進一步加強解密的難度,只不過不知道這個專案的創始人`anonymous5l`是怎麼發現的。
第14行,`key_data = bytes(key_data_array)`
這128位元組的內容逐位元組與0x64異或完之後,再次用bytes函式將其轉為不可更改的位元組序列。
第15行,`cryptor = AES.new(core_key, AES.MODE_ECB)`
AES.new()函式建立一個AES例項,通常是三個引數,分別為金鑰key,模式mode以及初始向量iv
由於此處是電碼本模式(ECB),所以不需要初始向量iv
補充:
分組加密有四種工作模式
* 電碼本ECB(electronic codebook mode)
* 密碼分組連結CBC(cipher block chaining)
* 密文反饋CFB(cipher feedback)
* 輸出反饋OFB(output feedback)
第16行,`key_data = unpad(cryptor.decrypt(key_data))[17:]`
第16行可以分成三步來看。
1. 第一步是`cryptor.decrypt(key_data)`得到明文,`cryptor`是上一行程式碼中建立的AES例項,包含了金鑰和解密模式,`decrypt`是`Crypto.Cipher.AES`庫中的解密函式,`key_data`是待解密的密文。
2. 第二步是用第4行用匿名函式lambda定義的函式unpad,結合起來看就是將`cryptor.decrypt(key_data)`得到的明文中的第1位到第-s[-1]位的資料提取出來,s[-1]是最後一位的值,這個第-s[-1]位是指倒數第s[-1]位。
以“不再猶豫”這首歌為例,通過`cryptor.decrypt(key_data)`得到的明文為b'neteasecloudmusic116782465020426E7fT49x7dof9OKCgg9cdvhEuezy3iZCL1nFvBFd1T4uSktAJKmwZXsijPbijliionVUXXg9plTbXEclAE9Lb\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c',那麼第-1位為十六進位制的c,也就是12,那麼`unpad(cryptor.decrypt(key_data))`之後得到的結果為b'neteasecloudmusic116782465020426E7fT49x7dof9OKCgg9cdvhEuezy3iZCL1nFvBFd1T4uSktAJKmwZXsijPbijliionVUXXg9plTbXEclAE9Lb',也就是從第一位到倒數第13位(不包括倒數第12位)
需要這一步的原因是分組加密的工作原理決定的,分組加密中給定加密訊息的長度是隨機的,因此,最後一個分組的訊息不一定夠一個標準的分組長度,此時需要進行填充,填充的原則如下:
如果資料的長度不是分組的整數倍,需要填充資料到分組的倍數,如果資料的長度是分組的倍數,需要填充分組長度的資料,填充的每個位元組值為填充的長度。
3. 第三步是將第二步去掉填充後的結果去掉前面的neteasecloudmusic,並將這個最終的結果賦值給key_data
第17行,`key_length = len(key_data)`
計算`key_data`的長度,我們自己都可以算出來了,128位-12位填充-17位“neteasecloudmusic”,那就是99位,也就是說此時`key_length`等於99
第18行,`key_data = bytearray(key_data)`
將bytes型別的key_data再次轉為可變的bytearray型別
***
RC4(來自Rivest Cipher 4的縮寫)是一種流加密演算法,金鑰長度可變。它加解密使用相同的金鑰,一個位元組一個位元組地加密。因此也屬於對稱加密演算法。突出優點是在軟體裡面很容易實現。
包含兩個處理過程:一是祕鑰排程演算法(KSA),用於打亂S盒的初始排列,另外一個是偽隨機數生成演算法(PRGA),用來輸出隨機序列並修改S的當前順序。
1. 根據祕鑰生成S盒
2. 利用PRGA生成祕鑰流
3. 祕鑰與明文異或產生密文
s盒的作用相當於一個函式,一個位元組通過這個函式可以轉換到另一個位元組,這個過程稱為位元組代換
![](https://img2020.cnblogs.com/blog/1776217/202008/1776217-20200806213356149-56343392.png)
第19行到第30行,是標準的RC4-KSA演算法生成S盒
```
key_box = bytearray(range(256))
c = 0
last_byte = 0
key_offset = 0
for i in range(256):
swap = key_box[i]
c = (swap + last_byte + key_data[key_offset]) & 0xff
key_offset += 1
if key_offset >= key_length: key_offset = 0
key_box[i] = key_box[c]
key_box[c] = swap
last_byte = c
```
第19行,`key_box = bytearray(range(256))`
生成一個位元組取值為0-255的位元組陣列,作為s盒的初值。
`bytearray(b'\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0c\r\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f !"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\x7f\x80\x81\x82\x83\x84\x85\x86\x87\x88\x89\x8a\x8b\x8c\x8d\x8e\x8f\x90\x91\x92\x93\x94\x95\x96\x97\x98\x99\x9a\x9b\x9c\x9d\x9e\x9f\xa0\xa1\xa2\xa3\xa4\xa5\xa6\xa7\xa8\xa9\xaa\xab\xac\xad\xae\xaf\xb0\xb1\xb2\xb3\xb4\xb5\xb6\xb7\xb8\xb9\xba\xbb\xbc\xbd\xbe\xbf\xc0\xc1\xc2\xc3\xc4\xc5\xc6\xc7\xc8\xc9\xca\xcb\xcc\xcd\xce\xcf\xd0\xd1\xd2\xd3\xd4\xd5\xd6\xd7\xd8\xd9\xda\xdb\xdc\xdd\xde\xdf\xe0\xe1\xe2\xe3\xe4\xe5\xe6\xe7\xe8\xe9\xea\xeb\xec\xed\xee\xef\xf0\xf1\xf2\xf3\xf4\xf5\xf6\xf7\xf8\xf9\xfa\xfb\xfc\xfd\xfe\xff')`
第20行到第22行,
```
c = 0
last_byte = 0
key_offset = 0
```
對三個變數賦初值,三個變數的含義可以在後面看出來
第23行到第30行,
```
for i in range(256):
swap = key_box[i]
c = (swap + last_byte + key_data[key_offset]) & 0xff
key_offset += 1
if key_offset >= key_length: key_offset = 0
key_box[i] = key_box[c]
key_box[c] = swap
last_byte = c
```
這個for迴圈用來生成s盒,i是用來保證s盒中的每個元素都得到處理。c保證s盒的攪亂是隨機的。last_byte是上一輪的c。key_offset是偏移值,每輪加1。swap用於key_box[i]和key_box[j]的交換,是一箇中間值。`c = (swap + last_byte + key_data[key_offset]) & 0xff`,這個& 0xff,主要是用來防止c的值超出0-255的範圍,起到了一個模256的作用。
***
第31行到第40行,
```
meta_length = f.read(4)
meta_length = struct.unpack('https://www.runoob.com/python3/python3-file-methods.html
第49行,`chunk = bytearray()`
得到一個長度為0的位元組陣列chunk
從第50行開始進入一個死迴圈,每次讀取32768個位元組的資料,並把得到的位元組陣列賦給chunk,直到chunk長度為0時跳出迴圈。
然後while迴圈中有個for迴圈,這個迴圈是RC4演算法的第二部分,偽隨機序列產生演算法(Pseudo Random Generation Algorithm,PRGA),每次從S盒選取一個元素輸出,並置換S盒便於下一輪取出,取出來的偽隨機序列就是RC4演算法的金鑰流。
![](https://img2020.cnblogs.com/blog/1776217/202008/1776217-20200807010828463-1854104229.png)
最後依次關閉檔案物件m和f,否則可能會導致檔案出現錯誤。
### 參考資料
RC4加密演算法:《網路安全原理與應用》2.4.3節
[RC4原理以及python實現](http://www.manongjc.com/article/30918.html)
[python3 Cipher_AES(封裝Crypto.Cipher.AES)解析](https://www.2cto.com/kf/201807/763348.html)
[python 內建函式bytearray](https://www.cnblogs.com/baxianhua/p/10208183.html)
[Python3 File(檔案) 方法](https://www.runoob.com/python3/python3-file-methods.html)
### 程式碼完整版
```
# -*- coding = utf-8 -*-
# @time:2020/8/3/003 23:26
# Author:cyx
# @File:folder_dump.py
# @Software:PyCharm
# Modifier: Liang Lixin
# Folder dump version by LiangLixin
import binascii
import struct
import base64
import json
import os
from Crypto.Cipher import AES
def dump(file_path):
core_key = binascii.a2b_hex("687A4852416D736F356B496E62617857")
meta_key = binascii.a2b_hex("2331346C6A6B5F215C5D2630553C2728")
unpad = lambda s : s[0:-(s[-1] if type(s[-1]) == int else ord(s[-1]))]
f = open(file_path,'rb')
header = f.read(8)
assert binascii.b2a_hex(header) == b'4354454e4644414d'
f.seek(2, 1)
key_length = f.read(4)
key_length = struct.unpack('= key_length: key_offset = 0
key_box[i] = key_box[c]
key_box[c] = swap
last_byte = c
meta_length = f.read(4)
meta_length = struct.unpack('