文章目录

前言

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

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

(1)anonymous5l / ncmdump (C++,MIT协议)

基于openssl库编写,所以速度非常快,而且又好。

(2)nondanee / ncmdump (python,MIT协议)

依赖pycryptodome库、mutagen库,比较完善了。

(3)lianglixin / ncmdump(python,MIT协议)

fork的nondanee作者的源码(开始没有注意以为是独立提交的),修改了依赖库依赖pycrypto库,会有一些安装和使用问题:

(4)windows GUI 直接运行的EXE文件:

项目名和源码下载地址百度云下载地址 说明
yoki123 / ncmdump
点此下载(提取码:92ib)
批量的
anonymous5l / ncmdump-gui点此下载(提取码: fffn) 
大佬原生程序
 未知点此下载(提取码: xmd9)
只需要把ncm拖进main.exe就可以了

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

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

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

整个文件就一个函数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'

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

core_key = b'hzHRAmso5kInbaxW'

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

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

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

f.seek(2, 1)

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

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)解析出来的是:

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

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

key_data = b'10073261712832E7fT49x7dof9OKCgg9cdvhEuezy3iZCL1nFvBFd1T4uSktAJKmwZXsijPbijliionVUXXg9plTbXEclAE9Lb'   

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

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。

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的值是这样的:

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进行解密:

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字典:

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

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

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

这是封面的图像数据。

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进去,查了半天源码,终于找到合适的方法:

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++那个程序比较好。

附录

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

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

2、eyed3路径不支持中文名

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

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

改为:

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

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

3、ASCII 十进制、十六进制、字符对照表

4、unpack()参数含义

5、AES五种加密模式


转载请注明出处http://www.bewindoweb.com/228.html | 三颗豆子
分享许可方式知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议
重大发现:转载注明原文网址的同学刚买了彩票就中奖,刚写完代码就跑通,刚转身就遇到了真爱。
你可能还会喜欢
具体问题具体杠
  • nondanee
    评论于2018年12月10日
    感觉 lianglixin / ncmdump 这个 fork 不能当一个单独的项目吧,建议翻一下他的 commit,基本是无关痛痒的修改 ......
    三颗豆子
    回复于2018年12月11日
    竟然引来了大佬本尊……太棒啦ヾ(≧∇≦*)ゝ 开始没有注意fork,因为第一个搜到的是linaglixin作者的代码,最开始研究的是他的代码,后来发现没有注释,有一些不明白的地方,才到处去搜索,才搜到这个项目,因为用的库不一样,所以误以为是独立提交的。 原文中相关的叙述已经修改,并且整个思路都是参考大佬的注释来整理清楚的啊! 求大佬博客友链……