1. 程式人生 > >網易雲音樂ncm格式分析以及ncm與mp3格式轉換

網易雲音樂ncm格式分析以及ncm與mp3格式轉換

[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('