NCM文件的加解密笔记 | manalogues
文章

NCM文件的加解密笔记

文章仅作学习交流所用,文中出现的文件仅作示例用途,并未进行分发传播,一切版权均归版权方所有。

概述

ncm文件嵌套了两层加密:

  • 第一层是对音频数据的加密,基于RC4算法,密钥被加密存储在文件中

  • 第二层是对音频密钥的加密,基于AES_ECB算法,并对密文按字节进行异或运算。文件中封装的元数据信息也使用了同样的算法,二者密钥如下:

    • 音频密钥:687A4852416D736F356B496E62617857

    • 元数据密钥:2331346C6A6B5F215C5D2630553C2728

文件结构

名称 长度 备注
文件头 8B 0x4354454E4644414D,即CTENFDAM
未知 2B 似乎被固定为0x0170
AES密钥长度 4B 小端存储,目前只存在128一种规格,对应AES128
音频密钥 128B 由ASCII字符构成,AES_ECB加密,异或0x64后需使用已知的密钥解密,明文开头固定为neteasecloudmusic
元数据长度 4B -
元数据 - AES_ECB加密,Base64表示的JSON文本,异或0x63后开头固定为163 key(Don't modify):,需使用已知的密钥解密
封面CRC 4B -
未知 5B -
封面长度 4B -
封面数据 - 未加密
音频数据 - 将音频密钥基于RC4算法生成密钥流,再与密钥流进行异或运算

解密流程

由于anonymous5l等一系列最早公开的相关repo已经被GitHub根据DMCA删除,解密流程主要参考了taurusxinOrinpl的repo。

1. 验证文件头

后缀为ncm的文件基本均为加密后的文件,但也有一小部分仅仅是Flac文件修改了后缀,因此需要对[0:8]的文件头加以验证。ncm的文件头为0x4354454E4644414D,对应ASCII码为CTENFDAM。不过,anonymous5l将其标注为2*4B、小端存储的文件头,此时对应的文本为NETCMADF,似乎更有意义一些。(NETease Cloud Music AuDio Format,仅为随意猜测)

2. 获取音频密钥

2.1 定位密文

文件的[10:14]字节声明了密钥长度,目前仅存在128B的版本,因此之后的128B即为音频密钥加密后的密文。

以《遠野ひかる - LOVE 2000》文件为例,此处的128B为

这里本来想用自己手里的文件进行展示,从而更直观地描述步骤,但是意外发现似乎每个人下载到的文件使用的key的前缀并不相同,怀疑这里是一人1key,因此不再展示。

2.2 解密

2.2.1 异或运算

密钥采用AES_ECB方式加密,并在加密后与0x64按字节进行了异或。由于异或的自反性,再次进行一次相同的异或运算即可得到原密文。仍然以上述密文为例,异或后的密文为请假装这里有一串数据

2.2.2 AES解密

利用上文提到的音频密钥,以AES128,ECB模式进行解密,可以得到相同长度的128B数据。密钥前17B固定为neteasecloudmusic,需要进行剔除;由于密钥长度小于128B,因此在加密前还对密钥进行了填充,填充内容为填充字节数,这一部分也需要剔除。上文的密文在解密后可以得到neteasecloudmusic?????????????E7fT49x7dof9OKCgg9cdvhEuezy3iZCL1nFvBFd1T4uSktAJKmwZXsijPbijliionVUXXg9plTbXEclAE9Lb\x0e\x0e\x0e\x0e\x0e\x0e\x0e\x0e\x0e\x0e\x0e\x0e\x0e\x0e可知使用了0x0E,即14进行了扩充,意为共填充了14B。经过处理后,得到音频密钥为?????????????E7fT49x7dof9OKCgg9cdvhEuezy3iZCL1nFvBFd1T4uSktAJKmwZXsijPbijliionVUXXg9plTbXEclAE9Lb

此处的?代表的是一串数字,似乎即使下载同一首歌的同一个音质版本,不同用户得到的这串数字也不尽相同,相同用户下载不同乐曲或同意乐曲的不同音质版本也不同,但相同用户在不同时间内重复下载同一首歌,这串数字并不会改变,猜测可能是某种用户标识符。

比较有意思的是,经过完全不严谨的小范围测试,似乎后面的部分,即E7fT49x7dof9OKCgg9cdvhEuezy3iZCL1nFvBFd1T4uSktAJKmwZXsijPbijliionVUXXg9plTbXEclAE9Lb是完全一致的,即使是不同乐曲、不同音质、不同用户,这一段都是完全一致的。

3. 获取元数据

3.1 定位密文

[142:146]以小端方式标识了元数据的长度,上文的例子中为0x000002C2,即706,对应的密文为:

1
0x5255504308061A4B270C0D4417430E0C070A051A4A592F555725365034573A1B3B503925370E0139485B4C3A00374C0835102C305A11080822122B11283A5001315009505A482E170F5B272E2C34302F132F48052E112233553234542E574C065A4C195A21554C3A151A155104543111271B040F00572E281332124C1333360C295A0050010213315205194C2E1B26320739512E090C141B5531043207331A222C340A01162C24192B254C0105362113203B105224000D1A2A1951562F220C4C30170D5224535504070420113150152F06552E56572A510A2030540551370A1A0D0F2A390151211036543735302F170B1257372800335A570B1125010448311B013A2E19360D090929115B482257392C31010D1B3B082D0F010E0D2D0412190D16340A19105351373B021125190C3A2A045322020A56120C2419271A5237052905152B015A02520E07012551340D05072554483A0C52052A0F015A0932560C5A0E141B2E045A045427150B501026262E5B2E335A533722045B2E291434120C110D13370408132D063A24104C3653012B202B19205306390413300C14512A505611221B30191920240A5354550D375B352739240A4C0D222D3B5317195A0C06362C3514334C1B20310A040728062E2212312109115313265706070A291A00025404483037253548002F52173348550B0C05502A1A341307482A065234525711040737343357332F5050082B0B2804121B5712351B10202B3429511B120A0231205B073A0A0556302234120B28212D22482C14152B0D24242C25272F12252E250720101556065A0748293615162A0124080213092502542F5B1A191035570D4C000F142B2D542505141B10053514303228052E29360F170E54192C302F30360533483A2535090C30480C510B16090F105520332A481B0101095A2E3910535A482E2B0D043A33140628175B3A2F0B544C483356485A392114170C240F0E0C5B51482E5E

3.2 解密

3.2.1 异或运算

与音频密钥同理,元数据也被按字节进行了异或运算,但运算数为0x63。异或后以ASCII字符表示为:

1
163 key(Don't modify):L64FU3W4YxX3ZFTmbZ+8/YcT/kVsOS9rkkAqHrKY3bR3j39+Mtl8DMOWSLpL+fMrAP6QW7M4/e9/z9B6/Yvyv2g7RrDxglc4MKpQq/pPUoJ9c3bapR1fz/MxEQdZ2Mjowx6RgQdPyAOWibuOGzHF/bfUBpCXs1GcnyIz25LAo/Stn1G06gdgCrR3vLe6M54I2iCS7f2TiynlIZb2BsU7TVSLthq4TKcP94hrFbg+RxbYMzUnjjJr8+A4ZORbnxXkNlbmnNgqznuWizs02TXarFzoYIg0Aai5qoGzDy1TfJfvHb9a1mdbF2WnfdF7+Yo1fIlb9jQ5o9mwxMg9g7Dvh3sEEM8MP90TAg8MJwWqornpTgkpNeYGs/U0bHCHzC0eZgpSow2I35rAxSzzCGi076nT8VDZGi/nANX0tz9oeUOVwP/xCRigdKeMAqRBjr0pE4ediJyca7g+STFV+cL1tP+6hof3IyWpd+Ie1W14rgdTWP4PL33kHhKgqx4qVxsCHWJ2xqiaRC8dYif5SAWqhKBNA+OwvHnGGOFDLqFMFdCsv5e9d+JUvuIbGkapjFa7L8yzsV4n/clwHN7FfwxsfVwSQKfMJUltm7zOSLSUfP+YFVjoS+o2hujls6CPI+xbbj9MZs09+MHngYPweKt8YLh7/+P5+9ZBwtoGlmo82+M=
3.2.2 AES解密

前22B固定为163 key(Don't modify):的ASCII字符,之后是以Base64编码的元数据密文。先用Base64解码一次得到密文,再根据上文的元数据密钥进行解密,方式与解密音频密钥相同。由于AES128需要保证明文长度恰好为16B的倍数,与音频密钥相似,元数据的明文结尾也进行了填充,填充内容为填充字节数。上述密文解密后的内容为:

1
music:{"musicId":"2604307454","musicName":"LOVE 2000","artist":[["\xe9\x81\xa0\xe9\x87\x8e\xe3\x81\xb2\xe3\x81\x8b\xe3\x82\x8b","33947223"]],"albumId":"241003755","album":"LOVE 2000","albumPicDocId":"109951169743863380","albumPic":"http://p4.music.126.net/gVjSHS4eTNYnqx73JBN7nA==/109951169743863380.jpg","bitrate":1999000,"mp3DocId":"202302cf8423b05edeb0bcf3bf301ad0","duration":263546,"mvId":"","alias":[],"transNames":["TV\xe5\x8a\xa8\xe7\x94\xbb\xe3\x80\x8a\xe8\xb4\xa5\xe7\x8a\xac\xe5\xa5\xb3\xe4\xb8\xbb\xe5\xa4\xaa\xe5\xa4\x9a\xe4\xba\x86\xef\xbc\x81\xe3\x80\x8b\xe7\x89\x87\xe5\xb0\xbe\xe6\x9b\xb21"],"format":"flac","fee":8,"volumeDelta":-11.4337,"privilege":{"flag":1806596}}\x07\x07\x07\x07\x07\x07\x07

可知使用了0x07进行填充,代表填充了7个字节。去除末尾的填充,并以UTF-8格式读取,可以得到元数据(已格式化):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
{
    "musicId": "2604307454",
    "musicName": "LOVE 2000",
    "artist": [
        [
            "遠野ひかる",
            "33947223"
        ]
    ],
    "albumId": "241003755",
    "album": "LOVE 2000",
    "albumPicDocId": "109951169743863380",
    "albumPic": "http://p4.music.126.net/gVjSHS4eTNYnqx73JBN7nA==/109951169743863380.jpg",
    "bitrate": 1999000,
    "mp3DocId": "202302cf8423b05edeb0bcf3bf301ad0",
    "duration": 263546,
    "mvId": "",
    "alias": [],
    "transNames": [
        "TV动画《败犬女主太多了!》片尾曲1"
    ],
    "format": "flac",
    "fee": 8,
    "volumeDelta": -11.4337,
    "privilege": {
        "flag": 1806596
    }
}

4. 获取封面

元数据结尾得到4B的封面文件CRC,间隔5个字节后得到用4B小端表示的封面文件大小。封面文件没有做任何封装,直接读取即可。上例中的CRC值为0x1925BA84封面大小为0x0000CC98,即52376字节,读取后可以得到一个JFIF格式的封面图片。(此处间隔的5B是0x0000CC9801,似乎是一个与文件大小有关的值)

5. 获取音频

音频文件使用RC4算法加密,由于该算法同属于对称加密算法,只需构建出密钥流即可对密文进行解密。

5.1 根据音频密钥构建S-Box

RC4生成密钥流依赖于S-Box和T-Box,其中,S-Box会用于加密数据,而T-Box由密钥生成。构建S-Box的步骤如下:

  1. 初始化256B长的S-Box,并依次填充0-255;
  2. 初始化256B长的T-Box,并将加密密钥填充进去,如果密钥长度小于256,则进行重复,直至填充完毕;
  3. 用T-Box对S-Box进行置换,从i=0开始,对于S-Box中的第i个元素,将其与S-Box中的第(j + s_box[i] + t_box[i]) mod 256个元素进行交换,其中,j的初始值为0

T-Box是一个逻辑概念,并不一定需要生成,因为我们可以定义一个指针,当指向密钥最后一位后,就移动到密钥的首位,从而完成循环。

python的代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 初始化S-Box
s_box = bytearray(range(256))
# 定义音频密钥key
key = xxx

j = 0
for i in range(256):
    # 计算j
    j = (j + s_box[i] + key[i % len(key)]) & 0xff # 使用i % len(key)的方式完成T-Box的“循环填充”
    # 交换数据
    swap = s_box[i]
    s_box[i] = s_box[j]
    s_box[j] = swap

对于上文中提到的密钥?????????????E7fT49x7dof9OKCgg9cdvhEuezy3iZCL1nFvBFd1T4uSktAJKmwZXsijPbijliionVUXXg9plTbXEclAE9Lb,可以得到对应的S-Box:

请假装这里有一串数据

5.2 生成密钥流并解密

RC4算法的密钥流生成方式被称为PRGA,标准流程如下:

  1. 定义与原数据长度length等长的密钥流key_stream;
  2. 定义ij,初值均为0,并进行如下循环,对key_stream进行循环填充:
    • i = (i + 1) mod 256
    • j = (j + s_box[i]) mod 256
    • 交换s_box[i]s_box[j]的值
    • (s_box[i] + s_box[j]) mod 256作为当前密钥流的值
  3. key_stream与原数据进行异或,即可得到加密数据;同理,将加密数据再进行一次异或,即可得到原数据

但是,ncm使用的并非是标准的PRGA过程,而是将j作为(i + s_box[i]),舍去了交换s_box[i]s_box[j]的步骤,并将s_box[(s_box[i] + s_box[j]) mod 256]作为密钥流的值。python的代码实现如下,其中source代表原数据流:

1
2
3
4
5
6
7
8
9
source = xxx

output = bytearray()

i, j = 0, 0
for r in range(len(chunk)):
    i = (i + 1) & 0xff
    j = (i + s_box[i]) & 0xff
    output.append(source[r] ^ s_box[(s_box[i] + s_box[j]) & 0xff])

如果进一步简化,因为ir的初始值相同,因此还可以将i替换成r,从而得到anonymous5l最初的解密方式,其中,mKeyBox即为S-Box,buffer为原数据流:

1
2
3
4
5
for (int i = 0; i < n; i++)
    {
        int j = (i + 1) & 0xff;
        buffer[i] ^= mKeyBox[(mKeyBox[j] + mKeyBox[(mKeyBox[j] + j) & 0xff]) & 0xff];
    }

至于为什么做出这样的修改,我猜测是因为RC4算法中的密钥流为了保障伪随机性,整体是不循环的,这就导致解密时只能一次性线性解密,不能分块,更不能进行多线程解密,而上述修改就将密钥流转化为固定256B长度的循环(整个密钥流可以看作是与j有关的函数,而j仅在0-255间循环自增),使得分区块解密成为可能。

例如,上文中提到的文件里,加密音频流的前10字节为0x87FFA3ECC5869BA7F947,解密后的数据为0x664C6143000000220400,前4字节对应的ASCII字符为fLaC,即Flac文件的文件头,证明解密正确。

本文由作者按照 CC BY 4.0 进行授权