记一次 .ncm 文件格式分析
先交代两句:样本是自己电脑上的本地文件,纯好奇这格式怎么封的,不干别的。地址和反编译都来自我手上某一个特定构建,仅供研究参考,别拿去对号入座。
起因
事情起得很普通。我平时两个平台都用,最近想在 Mac 上把之前缓存下来的几首歌丢进别的播放器听,不想再被绑死在客户端里。结果文件拉出来一看,一堆 .ncm,普通播放器一个都不认。
按理说这也不是什么新鲜事,这格式存在好多年了,网上也翻得到一些零零散散的说法。但大部分文章要么甩你一个成品工具,要么一句“用 xx 解一下就行”,中间怎么想、怎么试、在哪栽的,一个字不提。我这人有个毛病,别人说“就行”的地方,我偏想知道为什么行。
行吧,自己看。上 IDA。
几百兆的 Mach-O 摊在那儿,跟进了大楼先找厕所一样——两眼一抹黑,但总得先找个带方向的东西。开工。
一、定位入口
老规矩,先搜字符串。
CTENFDAM 这个魔数在整个二进制里只出现一次,地址 0x1007cdb8f。这运气是真的好——只要它唯一,引用它的那个函数就是解析器,一圈弯路直接省掉,不用在海量函数里盲猜。
顺手把几个相关字符串也丢进去搜:
| 搜的东西 | 结果 |
|---|---|
CTENFDAM |
0x1007cdb8f,全程序唯一一处 |
neteasecloudmusic |
0x100b8aa2d |
163 key(Don't modify): |
0x100b918eb |
hzHRAmso5kInbaxW |
0x100b417f8 |
看着都挺像回事。接着按符号表往下翻,翻到一个类:
-[CloundMusicAudioFile initWithFilePath:client:]
Clound。
不是 Cloud。官方代码里就是这么拼的。
我盯着这个类名看了好几秒。商业软件的代码里,这种拼写错误其实不多见,反倒像个面包屑——行吧,就从你这开始拆。当时我还不知道,这个错别字会是整条线里最“正常”的一个东西。
顺着它往下看,七拐八拐总算摸到真正的类:NCM 文件对象,虚表里排着一串函数——Open、setKey、readDataWithLength。到这里,整体骨架算是有了。剩下的就是往里钻。
二、一段不该走的弯路
这段我单独拎出来说,因为它吃掉的时间比后面搞清楚整个 NCM 还多。真的,我现在想起来还有点气。
我是顺着调用链往下摸的。摸到一半,撞见一个类,文件名是 CodedInputDataCrypt.cpp。里面赫然调着 AES_decrypt,还带一整套断言,什么:
m_position % AES_KEY_LEN == m_decrypter.m_number
还有 m_decrypter、m_number、AES 密钥长度……我一个个变量名看过去,越看越觉得对味。这不就是音频的加密器吗?
说实话那一刻挺爽。你想啊,魔数找到了,解析器定位到了,现在又迎面撞上一个现成的 AES 解密器,进度条眼瞅着就要拉满。我甚至已经开始想文章标题怎么写了。
于是我按 AES-CFB 吭哧吭哧写了一通解码器。
跑出来一看:开头几个字节,居然还真挺像 LOAS 音频的头部。你知道那种“快到嘴边了”的感觉吗?就那一下——我以为要成了,手指头已经挪到播放按钮上了。
然后我往下翻。
全是噪点。
我不信邪。第一反应不是“方向错了”,而是“肯定是我代码写错了”。这是逆向里的通病——人总觉得错的是自己,而不是对面的设计。
于是我开始查:
- 是不是字节序搞反了?换了大小端,还是噪点。
- 是不是 iv 没取对?翻遍了函数也没找到 iv,怀疑是不是硬编码在别处。
- 是不是填充处理错了?试了带 pad、不带 pad、手动 pad,来回折腾。
- 是不是我拿错了 key?把附近所有像 key 的字符串都试了一遍。
来来回回,代码改了七八个版本,每个版本解出来都是一样的结局——前几个字节像样,后面烂尾。
那一个下午,我大概体会到了什么叫“在错误的路上越努力越绝望”。
最后是我不甘心,回去看那个类的引用。一看虚表——空的。这个类根本没有虚表引用,也没有任何地方通过 NCM 那套对象调用它。它就是个孤零零的类,跟 NCM 那套体系一点关系都没有。
再一看那些断言,里面还引着一串别的文件名,跟 NCM 完全不搭。合着它就是二进制里另一个模块的 AES-CFB 加密器,纯粹是碰巧被编进同一个可执行文件里。音频压根不走它。
假的。全特么是假的。
我一下午的成果,就是给一个八竿子打不着的模块写了个解密器。
教训就一句:住一栋楼,不代表是一家人。别看到隔壁屋摆着个 AES,就觉得是你家的锁。
这段还有个后遗症——那个“开头像 LOAS”的假象,害我后面面对一堆“看着差不多对”的输出反复怀疑人生。后面会说到,这不是最后一次。
三、真正的调用链
气归气,活儿还得干。
把 CloundMusicAudioFile 的 init 老老实实扒开,结构大概是这样的:
-[CloundMusicAudioFile initWithFilePath:client:] 0x1007d73cd
└─ Open sub_1007CBE32
└─ 头部 / 分段解析 sub_1007CBEA2
├─ key 段 sub_1007CD438
├─ meta 段 sub_1007CD66E
└─ cover 段 sub_1007CD892
└─ setKey("neteasecloudmusic") sub_1007CCA22
└─ 派生 box key sub_1007CCDEC → sub_1007CB47C → sub_1007CB76E
└─ RC4 式建盒 sub_1007CE900
└─ readDataWithLength: 0x1007d7d59
└─ 虚表[11] sub_1007CD03A
└─ 状态盒 XOR sub_1007CEA18 → sub_1007CEA22
setKey 那行的参数是明晃晃写死的字符串 neteasecloudmusic,Open 里面把文件拆成 key、meta、cover 三块,最后读音频的时候走一个异或。
看到“盒”这个字,我精神就来了。前面被 AES 骗得那么惨,现在总算看到熟人了——RC4 那套东西,绕不开一个 256 字节的盒子。
四、容器格式
先把容器本身量清楚。NCM 写得其实挺老实的,布局一眼能看懂:
0x00 8 "CTENFDAM"
0x08 1 版本
0x09 1 保留
0x0A 4 LE key 长度
0x0E n key 数据
4 LE meta 长度
n meta 数据
4 LE crc
1 封面标志
4 LE 封面大小
4 LE 封面大小(重复一遍)
n 封面 JPEG
... 音频
看着挺清楚是吧?我一开始把 key 长度当成在 0x08,解了半天全是乱码,一度怀疑是不是算法写错了、字节序搞反了、还是 key 本来就该长得这么难看。
回头老老实实对着反汇编里读头部那 14 个字节的代码看:
- 读出来先比
CTEN和FDAM - 然后 key 长度落在缓冲区
[10..13]
也就是文件的 0x0A。
就差两个字节。卡了我小半个钟头。逆向里这种事太多了,一个偏移看走眼,后面全是错的,而你还傻乎乎地以为是思路错了。那种感觉,就像你拧了一晚上螺丝,最后发现拧反的是另一头。
封面那段也很有意思,大小字段连着存了两遍。我对着 xxd 数了好几次才确认不是我眼花:
00000370: 05 15 31 24 17 53 11 57 20 d8 dd 9b 01 39 98 00
00000380: 00 39 98 00 00 ff d8 ff
^crc ^flag ^size ^size(又来一遍)^JPEG
存两遍是图啥?怕丢?归一化?还是哪位大哥复制粘贴的时候手滑了?算了,不问了,问就是历史遗留。
五、剥皮,一共三层
第一层:盒子钥匙
key 数据 144 字节,处理流程是四步,一步都不能少:
- 每个字节异或
0x64 - AES-128-ECB 解密,密钥
hzHRAmso5kInbaxW - PKCS#7 去填充
- 砍掉 17 字节前缀
neteasecloudmusic
最后得到 112 字节的 box_key。
先说说那几个异或值。key 段用的是 0x64,字符 d;meta 段用的是 0x63,字符 c。再回头看容器头,0x09 那个「保留」字节,样本里正好是 0x6d——字符 m。
c、d、m。就差个 n。
一开始我以为他们是写代码写到一半饿了,脑子里全是 McDonald's,还笑了一下。
后来我把这几个字母顺手摆一块,补上那个缺的 n,按拼音首字母念了念……
……
算了。人家正经商业软件,我不能往那方面想。真的不能。
但你要说纯属巧合吧,我又有点不信。
再说 AES 密钥哪来的。二进制里躺着一串 62 个字符的字母表:
FKy9uSYMhzHRAmso5kInbaxWGDVLXQ8OgPCl6ZE4vN2jJed1...
代码取的是 alphabet[i+8],i 从 0 到 15,正好切出 hzHRAmso5kInbaxW。
藏得不算深,就是考验你会不会数数。
这一步有个顺序坑,单独提醒:先去 PKCS#7 填充,再砍前缀。反过来,或者少做其中一步,出来的数据看着特别正常,其实全错,然后你会一直以为是别的地方出的问题,浪费一整天。我就在这栽过。
第二层:那个“假 RC4”
有了 box_key,建一张 256 个格子的表。这一步是标准 RC4 的 KSA,没什么好说的:
box[i] = i
c = 0; kp = 0
for i in 0..255:
t = box[i]
c = (key[kp] + t + c) & 0xFF
kp = (kp + 1) % len(key)
box[i] = box[c]
box[c] = t
不瞒你说,写到这我心情是放松的。前面又是假 AES 又是偏移栽跟头,现在终于看到一个“标准件”了。我脑子里已经把标准 RC4 的 PRGA 默写出来了——j = (j + box[i]) & 0xff,交换,取 box[(box[i] + box[j]) & 0xff],教科书级别的东西,闭着眼都能写。手都已经搭在键盘上了。
结果往下一看取流的式子。
手停住了。
j = (i + 1) & 0xFF
a = box[j]
out[i] = data[i] ^ box[(a + box[(a + j) & 0xFF]) & 0xFF]
我把标准 RC4 摆出来对比一下你就知道有多膈应:
# 标准 RC4 PRGA
j = (j + box[i]) & 0xFF
swap(box[i], box[j])
k = box[(box[i] + box[j]) & 0xFF]
# NCM 这个
j = (i + 1) & 0xFF
a = box[j]
k = box[(a + box[(a + j) & 0xFF]) & 0xFF]
合着你还用着 RC4 的建盒,取流自己另发明了一套。拿人家的壳,装自己的芯。这算哪门子 RC4?
而最阴的地方来了——这个“套娃”索引,你要是按标准 RC4 去解,开头真的能骗过你自己。它解出来的开头,长得像 LOAS 的音频头,给你一种“就快成了”的错觉。你会盯着那几行看起来还挺像样的字节反复安慰自己“差不多了差不多了”,然后播放器冷冰冰地告诉你打不开。
我对着屏幕想:为什么,为什么你要长得像 LOAS。
那一刻我算是明白了,这格式的设计者是懂怎么膈应人的。前面用假 AES 骗我一次,这里用 RC4 的外壳骗我第二次。第一次我怪自己贪快,第二次我是真没什么好说的了——它就差在代码里写一行注释“猜不到吧”。
你说气不气人。
第三层:元数据
音频出来了,ID3 comment 里还埋着一层,跟俄罗斯套娃似的一层接一层:
- 异或
0x63 - 砍掉前缀
163 key(Don't modify): - Base64 解码
- AES-128-ECB 解密,密钥
#14ljk_!\]&0U<'(
那串 AES 密钥在反汇编里不是字符串,是 16 个 selector 一个一个拼出来的:
pound, _1, _4, l, j, k, underscore, exclamation,
back_slash, bracket_right, ampersand, _0, U, less_than,
apostrophe, paren_left
翻译成人话:# 1 4 l j k _ ! \ ] & 0 U < ' (。
我是真服了。为了防人,把密钥拆成十六段,每段还单独起个名字。你以为这样很安全?
结果就是——把一把钥匙掰成十六截,每截都贴上标签,整整齐齐码在沙发缝里。你这不叫加密,你这叫给我发快递。
解出来是这么个结构:
{
"musicId": "<id>",
"musicName": "<title>",
"artist": [["<artist>", "<id>"]],
"album": "<album>",
"bitrate": 320000,
"duration": 186135,
"format": "mp3"
}
六、完整流程梳理
把上面几步串起来,整个解密大概是这么回事:
raw = read(path)
assert raw[0:8] == "CTENFDAM"
# 拆容器
key_blob = raw[14 : 14+key_len]
meta_blob = ...
cover = ...
audio = ...
# 盒子钥匙
plain = AES128_ECB_decrypt(xor(key_blob, 0x64), "hzHRAmso5kInbaxW")
plain = pkcs7_unpad(plain)
box_key = plain[17:]
# 建盒
box = KSA(box_key)
# 音频
out = state_box_xor(audio, box)
# 元数据
blob = AES128_ECB_decrypt(base64(xor(meta_blob, 0x63)),
"#14ljk_!\\]&0U<'(")
out 就是 MP3,blob 里是那串 JSON。
七、验证
逆向这东西,最大的一条纪律:别拿“magic 对上了”当成功。我被假象耍过两次了,不敢了。老老实实跑完整解码:
$ file out.mp3
MPEG ADTS, layer III, v1, 320 kbps, 44.1 kHz, Stereo
$ ffprobe
duration = 186.135918
bit_rate = 320113
$ ffmpeg -v error -i out.mp3 -f null -
(无输出,退出码 0)
一首正常的 MP3,320 kbps,44.1 kHz 立体声,时长分毫不差。ffmpeg 完整解码无报错——这才是真的成了。
那一刻的感觉,就像你在一栋陌生大楼里黑灯瞎火摸了半天,踹开了那扇门,结果门后面啥机关都没有,就是一趟普普通通的楼梯。你站在那儿,又解气又空虚。
看到 Clound 那个拼错的类名时别光顾着笑,整条线索就是从它开始的。逆向的时候,那些看起来“不该出现在这儿”的小东西——一个拼错的单词、一个重复的字段、一段拆得七零八落的字符串——往往就是人家留在墙上的记号。至于 c、d、m 为什么就差个 n,我到现在也没想明白。
完整实现与脚本见仓库:
(地址与反编译来自某一个特定构建,仅供格式与算法研究参考。)