NCM 不是网易自己搞的新音频格式,应该说是一种容器,把原有的 MP3、FLAC 等音乐放进去,然后加密,这样没会员就听不了了。这样一来解密我觉得是更方便了,只要搞清楚怎么加密的就好说。
根据大佬的分析(见上图),NCM 文件结构还是比较清晰的。NCM 使用了 AES ECB pack7padding 加密,但是密钥已经被其他大佬搞出来了,至于怎么搞到的就不得而知,据说是通过逆向网易云音乐客户端拿到的。目前可考的最早得到密钥的 anonymous5l 大佬也早已删库,只留下流传于世间的密钥:
core_key = binascii.a2b_hex("687A4852416D736F356B496E62617857")
meta_key = binascii.a2b_hex("2331346C6A6B5F215C5D2630553C2728")
其中,core_key
用于解密从 .ncm
文件中提取的音频数据密钥,meta_key
用于解密元数据部分(如歌曲标题、艺术家等信息)。
现存的各种 ncm 转 mp3 等格式的小工具,几乎无一例外,原理都和下文中的脚本一致,anonymous5l 大佬提供了最初的解密方法,而 lianglixin 等大佬又进行了改进完善,可惜如今他们都已经删库,只剩下脚本代码流传在互联网的各个犄角旮旯。
接下来,我会通过一份解密脚本的代码,来研究 .ncm
文件的原理。其实我一开始也没咋弄明白,尤其是涉及到这么多加密算法一看就头疼,结合其他博主的文章才勉强搞明白些。
# -*- coding: utf-8 -*-
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)
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 = f.name.split("/")[-1].split(".ncm")[0] + '.' + 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()
return file_name
if __name__ == '__main__':
file_list = ['安九.ncm']
for file in file_list:
filepath = "./"+file
dump(filepath)
现在看代码。
f = open(file_path, 'rb')
header = f.read(8)
assert binascii.b2a_hex(header) == b'4354454e4644414d'
f.seek(2, 1)
这里是以二进制模式打开文件并读取前 8 字节,确保文件头为 CETNFDAM
。不过前文提到的文件头有 10 字节,实测后两个字节内容随便改都可以,可以忽视。
key_length = f.read(4)
key_length = struct.unpack('<I', bytes(key_length))[0]
key_data = f.read(key_length)
这部分代码的主要功能是读取 .ncm
文件中的密钥长度和相应的密钥数据。
f.read(4)
:意思是从文件中读取 4 个字节。.ncm
文件的格式中,密钥长度是用 4 个字节来表示的(通常是一个无符号整型数,32 位)。读取到的数据存储在 key_length
变量中,类型为 bytes
,包含这 4 个字节。
而在struct.unpack('<I', bytes(key_length))
中,struct.unpack
是用来将字节数据解包为 Python 的原生数据类型。'<I'
指定了字节序和数据类型:'<I'
表示使用小端字节序(即低位在前,高位在后)解包一个无符号整数(I
)。bytes(key_length)
: 确保 key_length
是字节类型(虽然在此处已经是 bytes
,可以省略)。unpack
方法返回一个元组,在这里我们只关心第一个元素,即密钥的长度,所以用 [0]
来获取它。
key_data = f.read(key_length)
这行代码根据上一步获得的 key_length
,从文件中读取相应长度的密钥数据。f.read(key_length)
将从当前位置读取 key_length
字节的数据,并将其存储在 key_data
变量中。
关于这堆密钥之间是什么关系,其实是这样的:
core_key
和 meta_key
是固定的密钥,分别用于解密不同部分的数据:
core_key
用于解密从.ncm
文件中提取的音频数据密钥(即前面提到的密钥)。meta_key
用于解密元数据部分(如歌曲标题、艺术家等信息)。
这两个密钥是硬编码在程序中的,意味着它们是固定不变的,不会根据具体的 .ncm
文件而改变。在提取的过程中,程序从 .ncm
文件中读取一个加密的密钥(称为“密钥数据”),这个密钥需要通过 core_key
解密。提取的密钥经过一系列的异或和 AES 解密操作,以生成一个用于解密音频数据的动态密钥流。
而上一步中提取到的密钥(key_data
)是通过 core_key
解密 .ncm
文件中的密钥数据后得到的,而 core_key
是一个固定的、用于 AES 解密的密钥。meta_key
则用于解密文件中的元数据,两者在解密流程中起到不同的作用。可以说提取的密钥与 core_key
有直接的关系,因为它是通过 core_key
解密得到的,但它们本身并不是同一个密钥。
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)
这部分就是继续处理上面提取到的密钥,异或相同数字两次等于解密,这个0x64
是怎么来的不清楚,可能是大佬逆向得到的。
cryptor = AES.new(core_key, AES.MODE_ECB)
key_data = unpad(cryptor.decrypt(key_data))[17:]
这部分代码使用 AES 算法和 core_key
对 key_data
进行解密。解密后的数据会包含填充的字节,使用 unpad
函数去除填充后,得到真实的密钥数据。通过 [17:]
只保留解密后的有效密钥数据,丢弃掉前 17 个字节(其实就是 “neteasecloudmusic”),最终生成可以用于后续解密音频数据的密钥。这个key_data
在经历 AES 解密后才能现出原形。
key_length = len(key_data)
key_data = bytearray(key_data)
第一行获取 key_data
的长度。key_data
是通过 AES 解密后的密钥数据,key_length
记录了该密钥数据的长度,后续会用到这个长度来控制密钥流的生成。第二行将 key_data
从 bytes
类型转换为 bytearray
类型,以便对每个字节进行修改。其中这个bytearray
是可变字节序列,允许修改其中的每个字节,这是后续操作所需要的。
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
这部分就是标准的 RC4-KSA 算法生成 S 盒。RC4 算法的核心是通过一个伪随机生成器(PRNG)来生成密钥流,而生成这个密钥流的过程就依赖于这个 S 盒。key_box = bytearray(range(256))
初始化了一个包含从 0 到 255 的字节数组。通过 for
循环 生成一个新的、伪随机的 key_box
。每次交换的操作,结合 key_data
和上一轮的计算结果(last_byte
),使得 key_box
中的元素变得难以预测,从而生成一个 伪随机的 S 盒。key_offset
用来从 key_data
中循环读取字节,c
是用于交换的计算值,last_byte
保持上一轮的计算结果,以增加随机性。
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)
这段干了点什么活呢,读取 metadata 长度(4 字节),读取 metadata 数据(加密的 JSON 字符串),然后使用 0x63 进行异或解密(得到 Base64 编码的字符串),再Base64 解码(得到 AES 加密的数据),再使用 AES-128-ECB 解密,去除填充数据,转换成 JSON 字符串,最后解析 JSON,获取歌曲的元信息(格式、歌曲名、歌手等)。
meta_length = f.read(4)
meta_length = struct.unpack('<I', bytes(meta_length))[0]
f.read(4)
读取 4 字节,它表示 metadata 的长度(以小端字节序存储的无符号整数)。<I
表示 小端字节序(Little-endian) 的 4 字节无符号整数(unsigned int
)。bytes(meta_length)
将 meta_length
变量转换为字节串(在 Python 3 中,f.read(n)
返回的是 bytes
类型,所以这里的转换一般不需要)。[0]
取出解包后的整数值,即 meta_length
(metadata 的字节大小)。
meta_data = f.read(meta_length)
meta_data_array = bytearray(meta_data)
f.read(meta_length)
读取metadata 数据**(加密的 JSON 格式字符串)。bytearray(meta_data)
将其转换为 bytearray
,方便后续的字节级修改(异或解密)。
for i in range(0,len(meta_data_array)):
meta_data_array[i] ^= 0x63
meta_data_array
里面的每个字节都与 0x63
(不知道怎么来的,前人智慧)进行异或。这是一种简单的对称加密方式(固定异或掩码),用于加密 meta_data
。
这个步骤的作用是 还原 metadata 的原始内容(但它仍然是 Base64 编码的)。
meta_data = bytes(meta_data_array)
meta_data = base64.b64decode(meta_data[22:])
bytes(meta_data_array)
把解密后的 bytearray
重新转换回 bytes
形式。meta_data[22:]
跳过前 22 个字节,因为前面是无用的填充数据(ncm 文件里,metadata 的前 22 字节是无意义的 padding)。base64.b64decode(...)
Base64 解码,得到 AES 加密的 JSON 字符串。
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)
AES.new(meta_key, AES.MODE_ECB)
使用 meta_key = "2331346C6A6B5F215C5D2630553C2728"
(固定密钥)。采用 AES-128-ECB 模式 解密。
cryptor.decrypt(meta_data)
使用 AES 解密 meta_data
,得到一个 JSON 字符串(但可能仍包含填充数据)。
unpad(...)
去除 AES 填充(AES 采用 PKCS7 填充方式)。
.decode('utf-8')
将解密后的 bytes
转换成 UTF-8 字符串。
[6:]
去掉前 6 个字符(前 6 个字节是 “music:”,需要手动去掉。如果不去掉输出的就是 music:{"musicId"……}
,具体内容见下面的例子)。
而 json.loads 这行就是给字符串 JSON 格式化一下,得到 Python 的 dict
字典,包含 歌曲的元信息,如专辑、歌曲名之类的。比如:
{'musicId': '1384356392', 'musicName': '安九', 'artist': [['老王乐队', '12676071']], 'albumId': '82497735', 'album': '吾日三省吾身', 'albumPicDocId': '109951164432181024', 'albumPic': 'http://p4.music.126.net/Ds5cadSofYKP6enuIC1_xA==/109951164432181024.jpg', 'bitrate': 3999000, 'mp3DocId': '9545f5c6a890dfa989f93f2396544cf5', 'duration': 386000, 'mvId': '10894184', 'transNames': [], 'format': 'flac', 'fee': 1, 'volumeDelta': -5.0797, 'privilege': {'flag': 1544196}}
其中最关键的是 "format"
,它决定了解密后的音频格式(mp3、flac 等)。
crc32 = f.read(4)
crc32 = struct.unpack('<I', bytes(crc32))[0]
f.seek(5, 1)
这个是读取 4 字节的数据,然后转为十进制整数,得到 CRC 校验码,然后跳过 5 字节的数据,试了一下这 5 字节的内容确实没啥用,直接跳过。
image_size = f.read(4)
image_size = struct.unpack('<I', bytes(image_size))[0]
image_data = f.read(image_size)
这儿就是读取 .ncm 文件中的专辑封面图片数据了。
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()
这一段代码的作用是,读取 .ncm 文件中的加密音频数据,然后使用 key_box 进行 XOR 解密,恢复原始音频数据,然后保存解密后的音频文件(mp3 或 flac),最终程序会在相同目录下生成一个解密后的音频文件。
file_name = meta_data['musicName'] + '.' + meta_data['format']
meta_data
是之前解密得到的 JSON 数据,包含音乐的元信息(metadata)。meta_data['musicName']
获取音乐名称(例如 "老王乐队 - 安九"
)。meta_data['format']
获取音频格式(例如 "mp3"
或 "flac"
)。拼接后得到最终的输出文件名,如:file_name = "老王乐队 - 安九.mp3"
。
m = open(os.path.join(os.path.split(file_path)[0], file_name), 'wb')
os.path.split(file_path)[0]
获取输入文件所在目录,确保解密后的文件保存在 相同目录。open(..., 'wb')
以 二进制写入模式 打开文件,准备存储解密后的音频数据。
chunk = bytearray()
while True:
chunk = bytearray(f.read(0x8000)) # 读取 32 KB 数据块
chunk_length = len(chunk)
if not chunk:
break
f.read(0x8000)
每次读取 32 KB(0x8000 字节),这样可以高效地处理大文件,减少内存占用。if not chunk: break
读到文件末尾(EOF)时退出循环。
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]
这个过程使用 key_box
进行解密,key_box
在前面已经通过 key_data
进行初始化。key_box
类似于 RC4 流加密的密钥调度(KSA)+ 伪随机生成(PRGA),用于对 .ncm
音频数据进行解密。chunk[i-1] ^= ...
逐字节进行 XOR 解密,恢复原始音频数据。while 循环中有个 for 循环,其实就是 RC4 的第二部分,伪随机序列产生算法,每次从 S 盒选取一个元素输出,并置换 S 盒便于下一轮取出,取出来的伪随机序列就是 RC4 的密钥流。
m.write(chunk)
将解密后的音频数据写入 m
(目标音频文件)。循环读取 -> 解密 -> 写入,直到 .ncm
文件全部转换完成。
m.close()
f.close()
return file_name
关闭输入文件 f
(.ncm
文件)。关闭输出文件 m
(解密后的音频文件),将解密后的文件命名为源文件相同的名字。一切结束。