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_keymeta_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_keykey_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_databytes 类型转换为 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(解密后的音频文件),将解密后的文件命名为源文件相同的名字。一切结束。