先交代两句:样本是自己电脑上的本地文件,纯好奇这格式怎么封的,不干别的。地址和反编译都来自我手上某一个特定构建,仅供研究参考,别拿去对号入座。
事情起得很普通。我平时两个平台都用,最近想在 Mac 上把之前缓存下来的几首歌丢进别的播放器听,不想再被绑死在客户端里。结果文件拉出来一看,一堆 .ncm,普通播放器一个都不认。
按理说这也不是什么新鲜事,这格式存在好多年了,网上也翻得到一些零零散散的说法。但大部分帖子要么甩你一个成品工具,要么一句“用 xx 解一下就行”,中间怎么想、怎么试、在哪栽的,一个字不提。我这人有个毛病,别人说“就行”的地方,我偏想知道为什么行。
行吧,自己看。上 IDA。
几百兆的 Mach-O 摊在那儿,跟进了大楼先找厕所一样——两眼一抹黑,但总得先找个带方向的东西。开工。
老规矩,先搜字符串。
CTENFDAM 这个魔数在整个二进制里只出现一次,地址 0x1007cdb8f。这运气是真的好——只要它唯一,引用它的那个函数就是解析器,一圈弯路直接省掉,不用在海量函数里盲猜。
顺手把几个相关字符串也丢进去搜:
看着都挺像回事。接着按符号表往下翻,翻到一个类:
Clound。
不是 Cloud。官方代码里就是这么拼的。
我盯着这个类名看了好几秒。商业软件的代码里,这种拼写错误其实不多见,反倒像个面包屑——行吧,就从你这开始拆。当时我还不知道,这个错别字会是整条线里最“正常”的一个东西。
顺着它往下看,七拐八拐总算摸到真正的类:NCM 文件对象,虚表里排着一串函数——Open、setKey、readDataWithLength。到这里,整体骨架算是有了。剩下的就是往里钻。
这段我单独拎出来说,因为它吃掉的时间比后面搞清楚整个 NCM 还多。真的,我现在想起来还有点气。
我是顺着调用链往下摸的。摸到一半,撞见一个类,文件名是 CodedInputDataCrypt.cpp。里面赫然调着 AES_decrypt,还带一整套断言,什么:
还有 m_decrypter、m_number、AES 密钥长度……我一个个变量名看过去,越看越觉得对味。这不就是音频的加密器吗?
说实话那一刻挺爽。你想啊,魔数找到了,解析器定位到了,现在又迎面撞上一个现成的 AES 解密器,进度条眼瞅着就要拉满。我甚至已经开始想帖子标题怎么写了。
于是我按 AES-CFB 吭哧吭哧写了一通解码器。
跑出来一看:开头几个字节,居然还真挺像 LOAS 音频的头部。你知道那种“快到嘴边了”的感觉吗?就那一下——我以为要成了,手指头已经挪到播放按钮上了。
然后我往下翻。
全是噪点。
我不信邪。第一反应不是“方向错了”,而是“肯定是我代码写错了”。这是逆向里的通病——人总觉得错的是自己,而不是对面的设计。
于是我开始查:
来来回回,代码改了七八个版本,每个版本解出来都是一样的结局——前几个字节像样,后面烂尾。
那一个下午,我大概体会到了什么叫“在错误的路上越努力越绝望”。
最后是我不甘心,回去看那个类的引用。一看虚表——空的。这个类根本没有虚表引用,也没有任何地方通过 NCM 那套对象调用它。它就是个孤零零的类,跟 NCM 那套体系一点关系都没有。
再一看那些断言,里面还引着一串别的文件名,跟 NCM 完全不搭。合着它就是二进制里另一个模块的 AES-CFB 加密器,纯粹是碰巧被编进同一个可执行文件里。音频压根不走它。
假的。全特么是假的。
我一下午的成果,就是给一个八竿子打不着的模块写了个解密器。
教训就一句:住一栋楼,不代表是一家人。别看到隔壁屋摆着个 AES,就觉得是你家的锁。
这段还有个后遗症——那个“开头像 LOAS”的假象,害我后面面对一堆“看着差不多对”的输出反复怀疑人生。后面会说到,这不是最后一次。
气归气,活儿还得干。
把 CloundMusicAudioFile 的 init 老老实实扒开,结构大概是这样的:
setKey 那行的参数是明晃晃写死的字符串 neteasecloudmusic,Open 里面把文件拆成 key、meta、cover 三块,最后读音频的时候走一个异或。
看到“盒”这个字,我精神就来了。前面被 AES 骗得那么惨,现在总算看到熟人了——RC4 那套东西,绕不开一个 256 字节的盒子。
先把容器本身量清楚。NCM 写得其实挺老实的,布局一眼能看懂:
看着挺清楚是吧?我一开始把 key 长度当成在 0x08,解了半天全是乱码,一度怀疑是不是算法写错了、字节序搞反了、还是 key 本来就该长得这么难看。
回头老老实实对着反汇编里读头部那 14 个字节的代码看:
也就是文件的 0x0A。
就差两个字节。卡了我小半个钟头。逆向里这种事太多了,一个偏移看走眼,后面全是错的,而你还傻乎乎地以为是思路错了。那种感觉,就像你拧了一晚上螺丝,最后发现拧反的是另一头。
封面那段也很有意思,大小字段连着存了两遍。我对着 xxd 数了好几次才确认不是我眼花:
存两遍是图啥?怕丢?归一化?还是哪位大哥复制粘贴的时候手滑了?算了,不问了,问就是历史遗留。
key 数据 144 字节,处理流程是四步,一步都不能少:
最后得到 112 字节的 box_key。
先说说那几个异或值。key 段用的是 0x64,字符 d;meta 段用的是 0x63,字符 c。再回头看容器头,0x09 那个「保留」字节,样本里正好是 0x6d——字符 m。
c、d、m。就差个 n。
一开始我以为他们是写代码写到一半饿了,脑子里全是 McDonald's,还笑了一下。
后来我把这几个字母顺手摆一块,补上那个缺的 n,按拼音首字母念了念……
……
算了。人家正经商业软件,我不能往那方面想。真的不能。
但你要说纯属巧合吧,我又有点不信。
再说 AES 密钥哪来的。二进制里躺着一串 62 个字符的字母表:
代码取的是 alphabet[i+8],i 从 0 到 15,正好切出 hzHRAmso5kInbaxW。
藏得不算深,就是考验你会不会数数。
这一步有个顺序坑,单独提醒:先去 PKCS#7 填充,再砍前缀。反过来,或者少做其中一步,出来的数据看着特别正常,其实全错,然后你会一直以为是别的地方出的问题,浪费一整天。我就在这栽过。
有了 box_key,建一张 256 个格子的表。这一步是标准 RC4 的 KSA,没什么好说的: