首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
逆向工程
发新帖
0
7
[原创] 记一次 .ncm 文件格式分析
发表于: 2026-9-30 15:34
361
[原创] 记一次 .ncm 文件格式分析
岁月。
1
2026-9-30 15:34
361
先交代两句:样本是自己电脑上的本地文件,纯好奇这格式怎么封的,不干别的。地址和反编译都来自我手上某一个特定构建,仅供研究参考,别拿去对号入座。 ## 起因 事情起得很普通。我平时两个平台都用,最近想在 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 字节,处理流程是四步,一步都不能少: 1. 每个字节异或 `0x64` 2. AES-128-ECB 解密,密钥 `hzHRAmso5kInbaxW` 3. PKCS#7 去填充 4. 砍掉 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 里还埋着一层,跟俄罗斯套娃似的一层接一层: 1. 异或 `0x63` 2. 砍掉前缀 `163 key(Don't modify):` 3. Base64 解码 4. 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 < ' (`。 我是真服了。为了防人,把密钥拆成十六段,每段还单独起个名字。你以为这样很安全? 结果就是——把一把钥匙掰成十六截,每截都贴上标签,整整齐齐码在沙发缝里。你这不叫加密,你这叫给我发快递。 解出来是这么个结构: ```json { "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,我到现在也没想明白。 --- 完整实现与脚本见仓库:<mark class="encrypted">5d4K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6K6L8h3q4D9L8s2y4#2L8U0p5H3y4#2)9J5c8W2u0W2e0X3y4E0</mark> *(地址与反编译来自某一个特定构建,仅供格式与算法研究参考。)*
回复或点赞可查看完整内容
传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!
最后于
2026-10-1 16:04 被岁月。编辑 ,原因:
#调试逆向
#加密算法
收藏
・
0
点赞
・
7
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
wx_0.0_522
为你点赞!
11小时前
梧桐生
你的分享对大家帮助很大,非常感谢!
2026-10-1 14:05
huangyalei
你的分享对大家帮助很大,非常感谢!
2026-9-30 23:16
顽劣
感谢你分享这么好的资源!
2026-9-30 21:18
git_51951meggadf3df
非常支持你的观点!
2026-9-30 20:23
mb_qtvylfog
为你点赞!
2026-9-30 16:31
孤独的街
感谢你的积极参与,期待更多精彩内容!
2026-9-30 15:55
查看更多
赞赏
×
1 雪花
5 雪花
10 雪花
20 雪花
50 雪花
80 雪花
100 雪花
150 雪花
200 雪花
支付方式:
微信支付
赞赏留言:
快捷留言
感谢分享~
精品文章~
原创内容~
精彩转帖~
助人为乐~
感谢分享~
最新回复
(
2
)
xingbing
雪 币:
158
活跃值:
(5786)
能力值:
( LV2,RANK:10 )
在线值:
发帖
2
回帖
1400
粉丝
2
关注
私信
xingbing
2
楼
感谢分享。
2026-9-30 17:00
0
Imxz
雪 币:
110
活跃值:
(9571)
能力值:
( LV2,RANK:10 )
在线值:
发帖
6
回帖
775
粉丝
9
关注
私信
Imxz
3
楼
123
2026-10-1 11:40
0
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
岁月。
1
6
发帖
12
回帖
90
RANK
关注
私信
他的文章
[原创] 记一次 .ncm 文件格式分析
358
[原创]一个Android壳简单实现。
11923
[原创]某锁机APP脱壳笔记。
11958
[原创]某P保护之调戏权限清0的学习。
19267
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部