做了好久的心理建设鼓起勇气花了8块钱充了网易云音乐一个月会员,准备下载一些歌到ipod上听,下下来的却是: 
喵喵喵?充钱下了个加密文件,是心梗的感觉,参考知乎《如何评价网易云音乐的ncm格式?》。开始搜转码吧,可是总会好奇到底是怎么转的,所以一步一步debug,下载各种工具去查看,大概弄懂了一些。而且和实习的时候研究的网易云音乐前端JS加密一样,猪厂真的很喜欢用AES和RSA加密方式,而且很喜欢对数据加密之后再把它的密钥给加密……首先贴上代码和软件,如果不感兴趣可以直接去下载使用就好了:
| 开源组件 | 类型 | 说明 |
|---|---|---|
| anonymous5l/ncmdump | C++,MIT协议 | 基于openssl库编写,所以速度非常快,而且又好 |
| nondanee/ncmdump | python,MIT协议 | 依赖pycryptodome库、mutagen库,比较完善了 |
| lianglixin/ncmdump | python,MIT协议 | fork的nondanee作者的源码(开始没有注意以为是独立提交的),修改了依赖库依赖pycrypto库,会有一些安装和使用问题 |
| yoki123/ncmdump | windows GUI exe文件 | 批量的 |
| anonymous5l/ncmdump-gui | windows 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文件,发现这些字节是CTENFDAM,0x43=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字典: 
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程序输出的文件不一样呢:
竟然木有封面……用MP3tag比较一下,其他信息都全,就是图片没有啊:
于是用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数据: 
不过python还是很慢的,以后还是用C++那个程序比较好。
附录
- 安装pycrypto报错unable to find vcvarsall.bat
pycrypto对于python3.5要求VS2015的库,我只安装了VS2013,所以需要下载新版的库,大概3G,强制占用C盘嘤嘤嘤…… 
- 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。



粤公网安备44030602007943号