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

chuyaoxin發表於2020-08-07


昨天,我想將網易雲上下載的歌曲拷到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(C++,MIT協議)
    基於openssl庫編寫,所以速度非常快,而且又好。
  2. nondanee(python,MIT協議)
    依賴pycryptodome庫、mutagen庫,比較完善了。
  3. lianglixin(python,MIT協議)
    fork的nondanee作者的原始碼,修改了依賴庫依賴pycrypto庫,會有一些安裝和使用問題
  4. yoki123(go,MIT協議)
    依據anonymous51的工作,使用go語言實現

格式分析

總體結構

首先,我從yoki123那裡找到了一張NCM結構圖

由此可得知,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了這個問題,

大佬表示,他的金鑰也是從annoymous51處獲得的,但他推測是通過反編譯播放器客戶端得到的。
並給出了三條原因:

  1. 播放器也需要讀取ncm格式,客戶端就包含有解密邏輯
  2. 解密演算法是AES,是對稱加密
  3. 恰巧所有的檔案都使用了相同的AES key,那麼key在客戶端播放器中就是一個常量

而作為第一個搞到金鑰的大佬annoymous51,他的專案中竟然沒有一個人問這個問題,我自己問了一下,看大佬會不會回覆

程式碼分析

金鑰的問題暫時不糾結了,接下來對照lianglixin的程式碼來鑽研,
lianglixin
可以看到專案中有兩個檔案

從提交說明來看,folder_dump.py實現的是批量的轉換,雖說Python檔案操作的部分不難,但是有人做了這個工作也省得我自己動手了。
在她的README.md中說明了需要安裝依賴庫pycrypto,使用pip install pycrypto安裝,但如果使用了Anaconda,就不需要裝了

程式碼地址為:https://github.com/lianglixin/ncmdump/blob/master/folder_dump.py
相比於C++版本和Go語言版本,Python實現出來相對比較好懂,結構十分明朗,
只有main函式和dump函式

main函式


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

效果如下:

匯入模組

然後看看匯入的模組

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('<I', bytes(key_length))[0]
11.     key_data = f.read(key_length)
12.     key_data_array = bytearray(key_data)
13.     for i in range (0,len(key_data_array)): key_data_array[i] ^= 0x64
14.     key_data = bytes(key_data_array)
15.     cryptor = AES.new(core_key, AES.MODE_ECB)
16.     key_data = unpad(cryptor.decrypt(key_data))[17:]
17.     key_length = len(key_data)
18.     key_data = bytearray(key_data)
19.     key_box = bytearray(range(256))
20.     c = 0
21.     last_byte = 0
22.     key_offset = 0
23.     for i in range(256):
24.         swap = key_box[i]
25.         c = (swap + last_byte + key_data[key_offset]) & 0xff
26.         key_offset += 1
27.         if key_offset >= 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('<I', bytes(meta_length))[0]
33.     meta_data = f.read(meta_length)
34.     meta_data_array = bytearray(meta_data)
35.     for i in range(0,len(meta_data_array)): meta_data_array[i] ^= 0x63
36.     meta_data = bytes(meta_data_array)
37.     meta_data = base64.b64decode(meta_data[22:])
38.     cryptor = AES.new(meta_key, AES.MODE_ECB)
39.     meta_data = unpad(cryptor.decrypt(meta_data)).decode('utf-8')[6:]
40.     meta_data = json.loads(meta_data)
41.     crc32 = f.read(4)
42.     crc32 = struct.unpack('<I', bytes(crc32))[0]
43.     f.seek(5, 1)
44.     image_size = f.read(4)
45.     image_size = struct.unpack('<I', bytes(image_size))[0]
46.     image_data = f.read(image_size)
47.     file_name = meta_data['musicName'] + '.' + meta_data['format']
48.     m = open(os.path.join(os.path.split(file_path)[0],file_name),'wb')
49.     chunk = bytearray()
50.     while True:
51.         chunk = bytearray(f.read(0x8000))
52.         chunk_length = len(chunk)
53.         if not chunk:
54.             break
55.         for i in range(1,chunk_length+1):
56.             j = i & 0xff;
57.             chunk[i-1] ^= key_box[(key_box[j] + key_box[(key_box[j] + j) & 0xff]) & 0xff]
58.         m.write(chunk)
59.     m.close()
60.     f.close()
61. def file_extension(path):
62.     return os.path.splitext(path)[1]

第2行,core_key = binascii.a2b_hex("687A4852416D736F356B496E62617857")
第3行,meta_key = binascii.a2b_hex("2331346C6A6B5F215C5D2630553C2728")
第2行和第3行用到的binascii.a2b_hex函式,作用是將16進位制資料轉為字串,同時必須是偶數個十六進位制數字,否則會報錯
所以core_key等於b'hzHRAmso5kInbaxW'meta_key等於b"#14ljk_!\\]&0U<'("
第7行用到的binascii.b2a_hex函式與之相反,是將字串轉成16進位制
你可能會好奇這個b'4354454e4644414d'是什麼意思,
在Python3.x中,字串前面加個b表示後面的字串是bytes型別。類似的還有字串前面加個r,用來取消後面字串中反斜槓的轉義含義,比如r"\n\n",表示我就想輸出\n\n這個字串,不要把它理解為換行符。還有前面加個u的,用來表示後面的字串以Unicode編碼,防止出現因中文字元導致的亂碼問題。
而這個4354454e4644414d是什麼呢?對照一下前面我貼出來的NCM結構圖,這個就是8位元組的magic header。可以用二進位制編輯器開啟ncm檔案,比如UltraEdit,如果你只需要驗證這個magic的話,普通編輯器如記事本也可以。

換了幾首歌,這個值都一樣,都是CTENFDAM


然後看第4行,
unpad = lambda s : s[0:-(s[-1] if type(s[-1]) == int else ord(s[-1]))]
Python中定義函式有兩種方法,一種是用def定義,就如這個dump函式的定義一樣,這是比較常規的做法。第二種是用lambda定義,稱為lambda函式(或匿名函式)。
lambda的格式如下:
lambd 引數:表示式
舉一個簡單的例子:

add = lambda x, y : x + y
sum_ = add(2, 3)
print(sum_)
輸出為5

也就是通過lambda可以定義一個函式,然後冒號前面是函式的引數,冒號後面是執行的表示式,其值作為輸出返回,然後它將建立的函式物件分配給一個變數,那麼這個變數就是具有這個功能的函式了。
如果用熟悉的方式來看,這個相當於

def unpad(s):
      return s[0:-(s[-1] if type(s[-1]) == int else ord(s[-1]))]

其中ord函式的作用是將一個字元,轉換成ASCII碼對應十進位制的值,比如ord('a')的結果是97


再來看第5行、第6行以及第8行,
第5行,f = open(file_path,'rb')
第6行,header = f.read(8)
第8行,f.seek(2, 1)
把這幾行放到一起,是因為它們都與檔案操作的知識有關。
open(file_path,'rb')
file_path是讀取的檔名,'rb'是讀取檔案的一種模式
進行讀檔案操作時,如果沒有其他條件,直到讀到文件結束符(EOF)才算讀取到檔案最後,Python會認為位元組\x1A(26)轉換成的字元為文件結束符(EOF)
那麼如果如果二進位制檔案中存在1A會怎樣呢?
如果使用'r'進行讀取,則讀到位元組為1A時,就認為檔案結束,此時可能造成檔案讀取不完全的問題。
如果使用'rb'按照二進位制位進行讀取的,不會將讀取的位元組轉換成字元,從而避免了上面的錯誤。
f.read(8)
read中可以沒有引數,f.read()則會一直讀取到檔案結束,如果有引數,f.read(size)表示讀取size個位元組的資料。
f.seek(2, 1)
在檔案操作中,有個指標指向當前讀寫的位置,剛開啟一個檔案時,這個指標指向檔案的開始位置,並且會隨著讀寫操作的進行而移動,使用f.close()關閉檔案後,再次開啟,該指標會重新指向開始位置。
但在關閉之前,如果想要改變該指標的位置,就要用到seek函式,格式如下:
seek(offset,whence)
offset是偏移值,也就是需要將該指標移動多少個位元組,為正時表示向後移動,為負時表示向前移動。
whence是對offset的定義,要移動指標,總得知道從哪開始移動吧。當whence為0時,表示從檔案起始處開始,whence為1時表示從當前位置開始,whence為2時表示從檔案末尾開始。
所以這個f.seek(2,1)的含義就是將指標從當前位置處,向後移動兩個位元組。
結合NCM的結構圖來看,對應的是2 bytes gap,
我不禁開始猜測這兩個位元組的含義,比如是不是這個檔案的校驗值之類的
但我發現正如每前8個位元組都是4354454e4644414d一樣,第9個和第10個位元組每個ncm檔案中也都是0170。
於是我稍稍更改了一下程式碼,進行試驗
原版:

header = f.read(8)
assert binascii.b2a_hex(header) == b'4354454e4644414d'
f.seek(2, 1)

更改後:

header = f.read(10)
assert binascii.b2a_hex(header) == b'4354454e4644414d0170'
# f.seek(2, 1)

重新執行之後,發現一樣可以轉換為MP3格式
也就是說,其實這兩個位元組沒什麼特別之處,和前面八個位元組一樣,應該也屬於magic才對,或許有別的什麼原因,不過這兩個位元組無論是跳過還是和前八個位元組一起讀取識別,都是一樣的效果。


第9行和第10行
這兩行的作用是獲得金鑰長度

key_length = f.read(4)
key_length = struct.unpack('<I', bytes(key_length))[0]

第9行是正常的讀取4個位元組的資料,
根據結構圖中的提示,這部分是記錄的金鑰的長度

第10行則是將第9行讀取的二進位制資料以小端位元組序、無符號整型的格式來解析讀取的資料。
struct有三種常用方法:

  • pack(fmt, v1, v2, ...)
    按照指定格式(fmt)將資料(v1, v2, ...)封裝為指定格式,就是把儲存的物件轉成二進位制資料。
  • unpack(fmt, string)
    按照指定格式(fmt)將想要解析的資料(string)解析後以元組(tuple)物件返回,將二進位制資料還原成Python物件。
  • calcsize(fmt)

計算指定格式(fmt)佔用多少位元組。
我將struct.unpack('<I', bytes(key_length))的結果賦給了變數key_length_struct變數
然後下個斷點,檢查一下執行過程中對應的值,結果如下:

再來對照unpack方法,就能理解了,
通過f.read(4)獲得4位元組的資料為b'\x80\x00\x00\x00'
然後通過struct.unpack('<I', bytes(key_length))十六進位制資料按照小端位元組序,無符號整型資料解析,對應的十六進位制資料也就是0x00000080,對應的十進位制數就是128,前面介紹過,AES有三種金鑰長度128、192、256,此處用的正是最常用的128位的金鑰長度。
從上圖中還可以看出,unpack方法返回的確實是一個元組物件,包含一個元素128,對應的元組為(128,)
補充:
struct函式中用到的格式

  • 與整數數值有關的資料格式
  • 與字元、浮點數有關的資料格式
  • 位元組的順序和大小

第11行到第18行

key_data = f.read(key_length)
key_data_array = bytearray(key_data)
for i in range (0,len(key_data_array)): key_data_array[i] ^= 0x64
key_data = bytes(key_data_array)
cryptor = AES.new(core_key, AES.MODE_ECB)
key_data = unpad(cryptor.decrypt(key_data))[17:]
key_length = len(key_data)
key_data = bytearray(key_data)

寫出這些程式碼的大佬,雖然厲害,但是變數名重用次數極多,而且也沒有一點註釋,可讀性有點差,一定要分清哪個變數當前處於什麼值。
第11行,key_data = f.read(key_length)
第11行處的key_length承接的是第10行的整數128,因此第11行的意思是向後讀取128位元組的內容,並賦給key_data
此時的key_data為b',\xce\xd5\xebi\xea\xfb\x14U\rE\xbfa\xdd\x17\x1d\xff\xdfj\x1dWxF\x85z\xc6e\x82\xd4\x8f\x00\x0f= 2\xda\xe7\x03U!\x91q\xa2H\xfe\x8f\x88\xbe'e\xceNbet\xd7\x91\xd4-\xbe'\xd2\xd1\xc0\xbcd\x8d\xf30\xf8\xba\x8a@ ]R(\x10q\x003\xa5\xc3\xf3"\xbc`\xf3\xa8\xb2\x90\xfc\xa5\x95zm\xf7\xa4\xe9%R(\xd6\x00\x9f\x05\xb2r\xf3\xda~<\x14\x05\xa4\xc6\xa6\xf4X\x0f_\x84\xc5\xaf\xfc\xd7M\x1e'

第12行,key_data_array = bytearray(key_data)
通過bytearray將128位元組的資料轉換成位元組陣列。
bytearray與bytes的區別在於它是可變的,可以通過元素賦值進行修改,方法是將對應的位元組處賦一個範圍為0-255的整數,比如下面這個例子:

>>> 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例項,包含了金鑰和解密模式,decryptCrypto.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盒的作用相當於一個函式,一個位元組通過這個函式可以轉換到另一個位元組,這個過程稱為位元組代換

第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('<I', bytes(meta_length))[0]
meta_data = f.read(meta_length)
meta_data_array = bytearray(meta_data)
for i in range(0,len(meta_data_array)): meta_data_array[i] ^= 0x63
meta_data = bytes(meta_data_array)
meta_data = base64.b64decode(meta_data[22:])
cryptor = AES.new(meta_key, AES.MODE_ECB)
meta_data = unpad(cryptor.decrypt(meta_data)).decode('utf-8')[6:]
meta_data = json.loads(meta_data)

不少東西都和前面相同,不懂的地方看看前面的分析。簡要說一說大致的流程。
首先讀取4位元組的內容,然後以小端位元組序、無符號整型的格式解析這個位元組序列,得到長度為514
然後再向後讀取514位元組,得到的位元組序列轉為位元組陣列。再將這個位元組陣列逐位元組異或0x63。
異或操作完成之後,再轉為不可變的bytes型別的位元組序列,此時得到的meta_data的值為:b"163 key(Don't modify):L64FU3W4YxX3ZFTmbZ+8/YZCV9ufCcdlM1ujbONJR87i4NNPDeH1CSepQa8pfIqD6YVjsvwuQ/0tZRYHJ1WPIzm9r25BGMoAzMdfkiEjlif8VGkcV9qxjuDCrfs4kyw3Qk0MO38TqO13dP1QFqwyGBg136s014agaLb9aILz/o5prV1bJzeMAPIcLaztgyAHUYOoG71Vntk8qjah8nRwtvu9RK3E+q0xbYQZo4MLizOFaRlU0qT0hskVCmbJqb8rwXymivivZlZtw+HRd+OlevtsE4alT+R591CFU3rZ3WNaofo+jD5KYCGNEjMW1EGCoa2RPFaCgbY5dR3Czw3XPnZAFnyCywhp8QkvM+AU3FLRCJxIaTMcRRWpQcGzi/MlFJ5MhX5fSF/ahlk370d5nd3AMqRuII8TSN7rEzZ/wa/vLE45eMglcQ4Kp0YFlDpscxh/q1K3chuIVviUu1QG3spTcmRaiz/b6YnyZDz7S5k="
前面這一串莫名的眼熟,原來是格式表中當初感到莫名其妙的東西

去掉前22個位元組163 key(Don't modify):後,以base64的方式解碼,得到的又是AES演算法的密文,同樣還是以ECB電碼本模式解密的。
順便一提,電碼本模式不愧為最簡單的加密模式,相同的明文對應相同的密文,我覺得直接根據密文都可以將這段資料的結構分析出來。

然後解密之後,以“不再猶豫”這首歌為例,unpad(cryptor.decrypt(meta_data)).decode('utf-8')得到的結果為music:{"musicId":347597,"musicName":"不再猶豫","artist":[["Beyond",11127]],"albumId":34250,"album":"猶豫","albumPicDocId":54975581392009,"albumPic":"https://p4.music.126.net/jFVtPnc0-cBv4k2_Fuld-A==/54975581392009.jpg","bitrate":192000,"mp3DocId":"dcc2aa566779833f1630c34e603a41b4","duration":255896,"mvId":5501499,"alias":[],"transNames":[],"format":"mp3"}
有點牛逼,把“music:”去掉不就是一個json格式嘛,所以後面加了一個[6:]
而且最後的"format":"mp3",感覺有種賣萌的意思,這就是爺下的無損音樂?
中間還有個url,點開一看原來是封面圖片,寫個簡單的爬蟲就可以下載到本地了,但是對我來說沒啥用,就沒搞了。


第41行到第43行

crc32 = f.read(4)
crc32 = struct.unpack('<I', bytes(crc32))[0]
f.seek(5, 1)

這個是讀取4位元組的資料,然後轉為十進位制整數,得到CRC校驗碼
然後跳過5位元組的資料,這5位元組的內容好像確實沒啥用,我嘗試讀取了一下,得到的結果每次還不同,我人都看傻了。反正不用管這5位元組,直接seek函式跳過即可。


第44行到第46行

image_size = f.read(4)
image_size = struct.unpack('<I', bytes(image_size))[0]
image_data = f.read(image_size)

得到封面的資料資訊


第47行到第60行

file_name = meta_data['musicName'] + '.' + meta_data['format']
m = open(os.path.join(os.path.split(file_path)[0],file_name),'wb')
chunk = bytearray()
while True:
    chunk = bytearray(f.read(0x8000))
    chunk_length = len(chunk)
    if not chunk:
        break
    for i in range(1,chunk_length+1):
        j = i & 0xff;
        chunk[i-1] ^= key_box[(key_box[j] + key_box[(key_box[j] + j) & 0xff]) & 0xff]
    m.write(chunk)
m.close()
f.close()

第47行,file_name = meta_data['musicName'] + '.' + meta_data['format']
結合第40行通過json.loads得到的字典型別的meta_data,可以根據對應的鍵獲得對應的值,從而得到想要的命名合理的音樂檔名。
第48行,m = open(os.path.join(os.path.split(file_path)[0],file_name),'wb')
建立了一個檔案物件m,第一個引數是檔案所在的路徑,該路徑由兩部分組成,第一部分是輸入的ncm檔案所在的資料夾的路徑,由使用者輸入;第二部分是生成的對應的mp3檔案的名稱,
比如輸入E:\ncm_music,然後得到的檔案是E:\ncm_music\不再猶豫.mp3
wb的含義是以二進位制格式開啟一個檔案只用於寫入。如果該檔案已存在則開啟檔案,並從開頭開始編輯,即原有內容會被刪除。如果該檔案不存在,建立新檔案。一般用於非文字檔案如圖片等。
其他模式可以參考: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演算法的金鑰流。

最後依次關閉檔案物件m和f,否則可能會導致檔案出現錯誤。

參考資料

RC4加密演算法:《網路安全原理與應用》2.4.3節
RC4原理以及python實現
python3 Cipher_AES(封裝Crypto.Cipher.AES)解析
python 內建函式bytearray
Python3 File(檔案) 方法

程式碼完整版

# -*- 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('<I', bytes(key_length))[0]
    key_data = f.read(key_length)
    key_data_array = bytearray(key_data)
    for i in range (0,len(key_data_array)): key_data_array[i] ^= 0x64
    key_data = bytes(key_data_array)
    cryptor = AES.new(core_key, AES.MODE_ECB) # 建立一個AES例項,ECB模式不需要初始向量iv
    key_data = unpad(cryptor.decrypt(key_data))[17:]
    key_length = len(key_data)
    key_data = bytearray(key_data)
    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
    meta_length = f.read(4)
    meta_length = struct.unpack('<I', bytes(meta_length))[0]
    meta_data = f.read(meta_length)
    meta_data_array = bytearray(meta_data)
    for i in range(0,len(meta_data_array)): meta_data_array[i] ^= 0x63
    meta_data = bytes(meta_data_array)
    meta_data = base64.b64decode(meta_data[22:])
    cryptor = AES.new(meta_key, AES.MODE_ECB)
    meta_data = unpad(cryptor.decrypt(meta_data)).decode('utf-8')[6:]
    meta_data = json.loads(meta_data)
    crc32 = f.read(4)
    crc32 = struct.unpack('<I', bytes(crc32))[0]
    f.seek(5, 1)
    image_size = f.read(4)
    image_size = struct.unpack('<I', bytes(image_size))[0]
    image_data = f.read(image_size)
    file_name = meta_data['musicName'] + '.' + meta_data['format']
    m = open(os.path.join(os.path.split(file_path)[0],file_name),'wb')
    chunk = bytearray()
    while True:
        chunk = bytearray(f.read(0x8000))
        chunk_length = len(chunk)
        if not chunk:
            break
        for i in range(1,chunk_length+1):
            j = i & 0xff;
            chunk[i-1] ^= key_box[(key_box[j] + key_box[(key_box[j] + j) & 0xff]) & 0xff]
        m.write(chunk)
    m.close()
    f.close()
def file_extension(path):
    return os.path.splitext(path)[1]

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

使用方式為:執行該程式,輸入ncm檔案儲存的路徑,然後回車即可。

轉換工具

ncmdump

  • 下載地址
    連結:https://pan.baidu.com/s/1ggM8RBKiKYpwdxuemE0H8w
    提取碼:cyx6
  • 使用方法
    把ncm檔案拖進main.exe,就會在ncm檔案所在目錄下生成同名的MP3檔案
  • 優點
    支援批量操作,生成的MP3檔案與ncm檔案儲存在同一目錄,且與該檔案同名
  • 缺點
    操作不夠方便,無法自由選擇儲存路徑

ncmdump-gui

  • 下載地址
    連結:https://pan.baidu.com/s/1NMVQo4bYlJtPPHA1LQPCIQ
    提取碼:cyx6
  • 使用方法
    直接開啟DesktopTool.exe

    將待轉換的ncm檔案拖入框中
  • 優點
    具有視覺化的圖形視窗
  • 缺點
    生成的mp3檔案會儲存在可執行檔案所在的資料夾中,無法自由選擇儲存路徑

ncm-mp3

  • 下載地址
    連結:https://pan.baidu.com/s/1NcB36Nafyfn5MwdyEUNjig
    提取碼:cyx6
  • 使用方法
    kpjgBV.png
  • 優點
    配合ctrl鍵和shift鍵選中多個檔案,可以實現批量轉換,而且新生成的MP3檔案儲存在原ncm檔案所在路徑下,相對比較合理
  • 缺點
    不夠方便,需要將ncm檔案拖拽到main.exe處,且無法自由選擇儲存路徑

NCM檔案轉換

  • 下載地址
    連結:https://pan.baidu.com/s/1mcGWY3RWLvwrs3VlqGr6gw
    提取碼:cyx6
  • 使用方法
  • 優點
    具有視覺化的圖形視窗,支援批量操作,可以自定義轉換後的音樂檔案儲存路徑。


    點選開始轉換後可以選擇儲存目錄
  • 缺點

相關文章