本帖最后由 whc2001 于 2024-3-31 21:00 编辑
0. 写在前面
这是大概两年前搞的,能搞出来纯属瞎猫碰上死耗子,看了一眼厂家的官网都不在了,发上来备份一下吧
由于个人水平不行纯粹是在瞎搞,帖子写得有点长,不想看的可以直接拉到最后面,提供了原版升级包和歌曲包下载(厂商官网已经下线,WaybackMachine 也没有备份,这里算是备份一下吧)和解包工具源代码
1. 背景
楼主的一个朋友无意在小黄鱼上发现了一批清仓处理的看起来像电子琴的玩意,带有可以显示歌词的屏幕和基于硬件 LED 的钢琴卷帘(类似 Synthesia 那种)显示,放张官网图:
去官网看了一眼,内置快 3000 首歌曲,带有一层目录结构,官网还提供了升级包和歌曲包下载,于是果断下下来研究一下:
2. 初步观察
其中小的是系统升级包,大点的是歌曲包,先来看升级包:
文件具有自定义的后缀名和文件头,暂时不知道里面是什么,用 7zip 打开试试:
哦豁,竟然就是个普通 zip 包,而且里面就是 WinCE 平台的主程序,先记一笔,看看音乐包是个啥情况
官网提供了 3 种不同的音乐包,分别是【儿童版】【青年版】【长者版】,但下载下来之后发现大小完全相同:
推测内容应该一致,是使用了内部字段来控制应用内显示哪些歌曲,丢 WinHex 看眼先
3. 观察音乐包格式
头部是版本号,以及一大堆空白:
从 1KB 的位置开始,出现了很多非明文但大多重复的可疑内容
从 0x056380 偏移开始,出现了明文的 MIDI 数据(包括 MThd 头以及内嵌的 MIDI Text):
到了这步,其实格式就已经很清晰了,前面很明显是经过某种加密或编码的目录信息(以某网游的数据包体进行类比,就相当于 Trunk.dir),后面的则是连续的明文歌曲数据。
理论上来讲,现在已经可以开始拆内容了,只需要按顺序寻找 MThd 文件头,将之间的内容保存为文件就可以了。但实际操作下来发现,很多 MIDI 文件里没有内嵌歌曲名称数据,也就意味着不研究一下目录数据格式的话,歌曲名称和目录名称都是搞不出来的。
那就先把主程序丢进 IDA 看眼吧,万一呢.png
4. 查证目录部分加密方式与密钥
已知歌曲包文件名为 PianoCat.yym,从字符串入手:
引用还不少,一个一个看过去,发现其中一个函数的日志中提到名称 SongYYmDat_Find,可能和寻找歌曲偏移有关,先改下函数名称
其中这个 sub_14930 的函数名根据日志可以看到叫做 ReadFileShort,用来读取某个文件从某偏移开始的固定长度,里面有很多 COREDLL_XXX 的函数,由于手头没有 WinCE 的CoreDLL.dll,相关导入函数名称也没有,根据上下文猜测用途,一起重命名掉
可以看到,首先从偏移 1280 开始读取了 128 字节,然后对这 128 字节数据进行了 sub_394B0 的处理。回去观察歌曲包数据:
可以看到是从偏移 1280 开始,数据每 32 字节的头尾呈现明显的相似特征,而 1024 - 1279 这块似乎有个断层。那么 sub_394B0 在做什么呢?
刚看到还是有点迷惑的,但仔细看一下,就会发现这玩意做的事很简单:对于 buffer 里的每一个字节,使用 (word_1FA0F0 + 27174) 这个数组里的每一个字节(一共 32 字节,循环取)进行相减。C# 转写下:
[C#] 纯文本查看 复制代码 public static byte[] key = { ... };
public void Decrypt (ref byte[] buffer, int len)
{
for (int i = 0; i < len; ++i)
buffer[i] -= key[i % 32];
}
那么现在这个 word_1FA0F0 + 27174 应该就是密钥了,但是这玩意是从哪里被读出来的呢?IDA 似乎没有搜索某个变量加上一个特定的偏移的方式,而这玩意又是一大堆引用,没办法继续手动找,然后发现了这么个诡异的函数:
看一眼这玩意的引用,只有一个:
首先读取了从 1024 字节开始的连续 256 字节内容(也就是前文提到的目录之前的奇怪部分),然后塞进了这个函数。
但是这个函数其实没太看懂(尤其是“a1 [v2 + 27174] = ...”这一句),但是大概看出来了好像是使用后 32 字节的内容减去前 32 字节的内容。使用 C# 做一下实验吧,还记得之前读取歌曲目录的那边,从 1280 开始读取了 128 个字节,那么也复制目录部分最开始 128 字节,做一下解密测试:
离谱,瞎猫碰上死耗子,竟然成了!目前可以得知以下内容:
- 解密后的数据同时存在字符串和二进制
- 解密后的数据的字符串为 GBK 编码
- 1280 开始的这部分为目录数据中的文件夹部分,歌曲部分的数据应该更加靠后,且歌曲部分的密钥不知道是否相同
死马当活马医,把 1280 字节到 353151 字节这部分全部提取出来解密试试看:
密钥是相同的!1024 到 3071 字节部分是文件夹,3072 字节开始则是文件内容。至此,目录部分的解密已完成。
5. 查证目录数据的部分结构
刚才的函数中,还发现读取了从 252 字节开始的连续 4 个字节:
查看原文件,可以看到这里的数值是 24,刚好可以和解密出的文件夹的数量对上,同时位置处于目录数据之前也说得通,此处应为包含的所有目录数量:
同时,在函数 SongYYmDat_Find 中读取 128 字节时,出现了一个类似缓冲区溢出的行为:
也就是 v13 和 v14 分别对应了读出来的 128 字节的最后 2 个 4 字节,通过下面的代码分析一下作用,可以知道从 v14 开始读取了 v13 << 7 个字节,同时计数变量与 v7 有关。因此可知,v14(每个文件夹的数据块最后4字节)为文件夹内容的起始偏移,而 v13 (倒数第二个 4 字节)为文件夹内文件的数量,且文件夹内每个歌曲数据块的大小为 128(因为左移 7 相当于乘以 128)。
知道了这些就可以写出读取目录数据里文件夹部分的 C# 代码:
[C#] 纯文本查看 复制代码 private static (string dirName, int dirDataLength, int dirDataOffset)[] GetAllDirs(byte[] fileData)
{
List<(string dirName, int dirDataLength, int dirOffset)> ret = new List<(string dirName, int dirDataLength, int dirOffset)>();
int dirNum = BitConverter.ToInt32(fileData.Skip(252).Take(4).ToArray(), 0);
for (int i = 0; i < dirNum; ++i)
{
int offset = 1280 + i * 128;
byte[] item = fileData.Skip(offset).Take(128).ToArray();
for (int j = 0; j < item.Length; ++j)
item[j] -= key[j % 32];
string dirName = gbk.GetString(item.Take(120).ToArray()).TrimEnd('\0');
int dirDataLength = BitConverter.ToInt32(item.Skip(120).Take(4).ToArray(), 0) << 7;
int dirDataOffset = BitConverter.ToInt32(item.Skip(124).Take(4).ToArray(), 0);
ret.Add((dirName, dirDataLength, dirDataOffset));
}
return ret.ToArray();
}
而针对歌曲数据块的解析,讲真个人不太想继续看反编译出来的代码了,既然歌曲数据本身没有被加密,且现在要解包歌曲数据只需要得知每首歌曲的偏移量和长度,那直接到歌曲数据部分随便找几个,然后到目录区去硬对不就完事了!
随便找了一首歌曲数据里有嵌入标题的,偏移 356916 (0x057234),长度 825 (0x0339)
到目录区找到对应名称的歌曲数据块,可以看出数据块 +64 和 +68 就分别是歌曲数据偏移和歌曲数据长度:
这样就也可以写出已知文件夹起始偏移和文件夹总长度,读取文件夹中所有歌曲信息、以及读取每首歌曲的 MIDI 数据部分的 C# 代码:
[C#] 纯文本查看 复制代码 private static (string fileName, int songDataOffset, int songDataLength)[] GetAllFiles(byte[] fileData, int dirDataOffset, int dirDataLength)
{
List<(string fileName, int songDataOffset, int songDataLength)> ret = new List<(string fileName, int songDataOffset, int songDataLength)>();
for (int i = 0; i < dirDataLength / 128; ++i)
{
int offset = dirDataOffset + i * 128;
byte[] item = fileData.Skip(offset).Take(128).ToArray();
for (int j = 0; j < item.Length; ++j)
item[j] -= key[j % 32];
string fileName = gbk.GetString(item.Take(64).ToArray()).TrimEnd('\0');
int songDataOffset = BitConverter.ToInt32(item.Skip(64).Take(4).ToArray(), 0);
int songDataLength = BitConverter.ToInt32(item.Skip(68).Take(4).ToArray(), 0);
ret.Add((fileName, songDataOffset, songDataLength));
}
return ret.ToArray();
}
private static byte[] GetSongData(byte[] fileData, int songDataOffset, int songDataLength)
{
return fileData.Skip(songDataOffset).Take(songDataLength).ToArray();
}
6. 解包工具实现
有了这些,再实现一下文件的输入和输出,就可以把歌曲包里的内容按照相同的文件夹结构完美地解到本地文件系统,使用效果如图:
7. 相关下载
由于官网已不存在,在此上传官网提供的最后一版升级包和歌曲包:https://pan.baidu.com/s/111Otr48eP88zShUBaLd4gA?pwd=52pj
完整代码:https://github.com/whc2001/PianoCatSongDataExtractor
已编译的解包工具:https://pan.baidu.com/s/186KJtWOo1YhTEe6wDLDHcQ?pwd=52pj
完毕,感谢观看
|