Skip to content

网易云音乐ncm文件格式解析

做了好久的心理建设鼓起勇气花了8块钱充了网易云音乐一个月会员,准备下载一些歌到ipod上听,下下来的却是: e7f8b2c1484a01093bceee5a0bf7ddb5.png

喵喵喵?充钱下了个加密文件,是心梗的感觉,参考知乎《如何评价网易云音乐的ncm格式?》。开始搜转码吧,可是总会好奇到底是怎么转的,所以一步一步debug,下载各种工具去查看,大概弄懂了一些。而且和实习的时候研究的网易云音乐前端JS加密一样,猪厂真的很喜欢用AES和RSA加密方式,而且很喜欢对数据加密之后再把它的密钥给加密……首先贴上代码和软件,如果不感兴趣可以直接去下载使用就好了:

开源组件类型说明
anonymous5l/ncmdumpC++,MIT协议基于openssl库编写,所以速度非常快,而且又好
nondanee/ncmdumppython,MIT协议依赖pycryptodome库、mutagen库,比较完善了
lianglixin/ncmdumppython,MIT协议fork的nondanee作者的源码(开始没有注意以为是独立提交的),修改了依赖库依赖pycrypto库,会有一些安装和使用问题
yoki123/ncmdumpwindows GUI exe文件批量的
anonymous5l/ncmdump-guiwindows GUI exe文件大佬原生程序

写到这里,默默大喊一句,开源大法好!

NCM文件格式和解密代码分析

由于第一个搜索到的项目是lianglixin/ncmdump,所以我们以lianglinxin作者的python代码为基础来进行分析,参考nondanee作者的代码注释来理清思路。测试的ncm文件是郭顶 - 水星记.ncm,字节查看器为UltraEdit。

整个文件就一个函数dump(file_path),下面进行分段分析。

js
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'

定义了core_key和meta_key,binascii.a2b_hex的意思就是把这个字符串按照十六进制反解析为二进制字节序列(bytes类型),可以用ascii字符来表示,b2a则进行相反操作。如果对ascii码不熟悉可以查表,比如0x68=h,0x7A=z,所以:

js
core_key = b'hzHRAmso5kInbaxW'
meta_key = b"#14ljk_!\\]&0U<'("

然后定义了一个lamda表达式(内嵌函数)unpad。

打开了ncm文件并读取了8个字节,确认这8个字节是否是字节序列b'4354454e4644414d',用UltraEdit查看ncm文件,发现这些字节是CTENFDAM0x43=C,说明这些就是ncm独有的文件标记,就是通俗所谓的magic。

js
f.seek(2, 1)

然后从当前位置跳过了2个字节,这两个字节是0x01 0x70,而且打开几个ncm文件都是一样的值,为什么它不能直接读10个字节的magic呢?可能代表不同的含义吧,暂时不管。

js
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)

获取了4字节的key长度,并且按照小端(<)的方式(高字节保存在内存的高地址中,而数据的低字节保存在内存的低地址中,也就是反序)转为整型(I)。这4个字节是:0x80 0x00 0x00 0x00,所以反序再转换就是0x00000080 = 128。读取128个字节的key数据,并转为字符数组,每个字节和0x64进行异或,还不清楚这个异或是为了什么目的。

然后用之前的core_key创建了AES_ECB(Electronic Codebook Book,电码本模式)的解密器。ECB模式是将整个明文分成若干段相同的小段,然后对每一小段进行加密,如果不足则会进行补足。

cryptor.decrypt(key_data)解析出来的是:

text
b'neteasecloudmusic10073261712832E7fT49x7dof9OKCgg9cdvhEuezy3iZCL1nFvBFd1T4uSktAJKmwZXsijPbijliionVUXXg9plTbXEclAE9Lb\r\r\r\r\r\r\r\r\r\r\r\r\r'

而应用unpad的lamda表达式之后,末尾的\r就去掉了,并且去掉了开头的标记符号,NCM的大名Netease Cloud Music:

js
key_data = b'10073261712832E7fT49x7dof9OKCgg9cdvhEuezy3iZCL1nFvBFd1T4uSktAJKmwZXsijPbijliionVUXXg9plTbXEclAE9Lb'

在最后更新了一下key的长度,更新为了128-13-17=98。

js
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

上面这部分是标准RC4-KSA算法(Key-scheduling algorithm)去计算S-box。

js
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)

这部分和前面的key很相似,读取4字节长度,然后把数据进行异或,注意这里异或的是0x63,这个值怎么来的也不清楚。接着发现meta_data的值是这样的:

text
b"163 key(Don't modify):L64FU3W4YxX3ZFTmbZ+8/fOGFX4ZDFzRxiE6WTSCw8Wbw8yYSVQFmAmCHw9A96ZnO0UOuMsVWYFWvoqD0/YcH3r7VAGU8B3l+FBJm4JL6is23S2yXChnSbfLIksnEUcTC7JtrA1JAoR0GVnz+OT3hGTJRsjGIVQXg2yide/YKBACffE+oYBApqZ5Isq0n7h/MlBnjn6ihuSlIl5V2rXEjSISQr031eSBdEVJ/JcwttzLafIPBh2FQfaVd/U0inWY5jxCXZCw/jxcIdGmGH/0Oft3UlNPt2kDBrsivoVuD03tMWL6A5Flg/jCbofSOblHFC79oU3WF9doUjD24BXuu6K7wyoWkgyG7SJu8tk72hkGw3rLK1nbTHsSEIPjocC6Ba9mzF48SB087MFTSn+9PXPZIboMXFXGI3TpMj4rR6cD+6CEWS7EoZrUC1cipi/A0jT/rFtAirM4hmkbrvslJumMHDJz1q9o6t3XRWydyoIaC3ktXuesyV8sbuoQ+Y/EMWNZRN3KhGR/jnnQPBtseQ=="

面有22位的“163 key(Don't modify):”,去掉之后用base64解码,并同样地通过AES_ECB和meta_key进行解密:

text
b'music:{"musicId":441491828,"musicName":"\xe6\xb0\xb4\xe6\x98\x9f\xe8\xae\xb0","artist":[["\xe9\x83\xad\xe9\xa1\xb6",2843]],"albumId":35005583,"album":"\xe9\xa3\x9e\xe8\xa1\x8c\xe5\x99\xa8\xe7\x9a\x84\xe6\x89\xa7\xe8\xa1\x8c\xe5\x91\xa8\xe6\x9c\x9f","albumPicDocId":2946691248081599,"albumPic":"https://p4.music.126.net/wSMfGvFzOAYRU_yVIfquAA==/2946691248081599.jpg","bitrate":320000,"mp3DocId":"668809cf9ba99c3b7cc51ae17a66027f","duration":325266,"mvId":5404031,"alias":[],"transNames":[],"format":"mp3"}\r\r\r\r\r\r\r\r\r\r\r\r\r'

于是这个作者去掉了前面的“music:”,然后转为了json字典: fedd9da8584c1430eaa23b6e1abc6c2e.png

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

这是CRC32校验码,以及5个不知道为什么跳过的字符。

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

这是封面的图像数据。

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

这部分是用修改后的RC4-PRGA算法(Pseudo-random generation algorithm)进行还原并输出成文件,这是MP3的原本数据。

原本故事到这里就结束了,然而发现输出的文件和另一个用GUI程序输出的文件不一样呢: e01c339bd1627f6374177d8d9941450f.png 竟然木有封面……用MP3tag比较一下,其他信息都全,就是图片没有啊: a2630113b3b3d9f143d19a6cc7ce39b7.png 于是用eyed3库添加image_data进去,查了半天源码,终于找到合适的方法:

js
audiofile = eyed3.load(u"E:\\CloudMusic\\3.mp3")
audiofile.tag.images.set(0x06, image_data, 'image/jpeg')
audiofile.tag.save()

这样就有封面啦。nondanee/ncmdump作者也发现了这个问题,并且也是手动添加的image_data的tag数据: ecc0939d74b4ced007e9050ba6151c0c.pnge2168a3fde644ce514aaaf12cc5ba2e8.png 不过python还是很慢的,以后还是用C++那个程序比较好。

附录

  1. 安装pycrypto报错unable to find vcvarsall.bat

pycrypto对于python3.5要求VS2015的库,我只安装了VS2013,所以需要下载新版的库,大概3G,强制占用C盘嘤嘤嘤…… bf4dcb045344cc27bde7d31ccfb7a5dc.png

  1. eyed3路径不支持中文名

这是bug……而且trick方法是修改magic.py文件(是库文件),我试过确实可以,但是这个方法并不好,在230行左右:

python
if is_unicode:
    return filename.encode('utf-8'
else:
    return filename

改为:

python
if is_unicode:
        import locale
        lan, encoding = locale.getdefaultlocale()
        return filename.encode(encoding)
else:
        return filename

很明显可以参考nondanee/ncmdump作者采用mutagen库,放弃eyed3。

  1. ASCII 十进制、十六进制、字符对照表

  2. unpack()参数含义

  3. AES五种加密模式

转载请注明出处https://bananaoven.com/articles/228.html | 香蕉微波炉
分享许可方式知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议
重大发现:转载注明原文网址的同学刚买了彩票就中奖,刚写完代码就跑通,刚转身就遇到了真爱。