2017年5月追记:
我探索这些的目的,是以一个玩家的身份,让 CGSS 变得更好玩。私下里我类比过普罗米修斯,将天火——谱面的制作和玩的能力从“天上”分一点出来。其实,修改 CGSS 去作弊是很简单的,无论是客户端还是 MITM。但是我不会去作弊,也不希望这系列文章的读者们将文章内容用在歪门邪道上,不希望去作弊、破坏平衡性。玩过 LLSIF、SB69、GF(note)、Arcaea 之后,我仍然认为 CGSS 的综合指标(游戏性、操作体验、曲目等等)是行业内翘楚,Cy这次比较用心。那么作弊还有什么意思嘛!对了,我不氪金,不冲分,只是闲暇时间来两局,玩得舒心。
当然,まゆさまが見ています。
(3月3日,3月7日晚)
一、声明
The Idolmaster Cinderella Girls Starlight Stage 软件和相关媒体的著作权为 Bandai Namco Entertainment Inc. 所有。本系列文章仅从兴趣出发,研究数据反向过程。在此不会给出完整的反向过程、反向结果和源代码,只给出思路和部分非关键数据。
我相信大牛即使是没有看过我写的这些玩意儿也能上的。
二、初探 CGSS 的程序集
在 Unity3D 的程序集目录下,可以找到两个明显不属于 CLR 和 Unity3D 的程序集文件 Assembly-CSharp.dll
和 Assembly-CSharp-firstpass.dll
。至于另一个 P31RestKit.dll
,看文件名和上一篇的过程就知道,这是用来调用 prime[31] 的 RESTful 服务的,直接过滤掉。现在我们就该进入到程序集内部了,工具选择很多,各位随便:.NET Reflector、JustDecompile、dotPeek 以及 ILSpy。在以下的文章中,我用的是 ILSpy,因为比较熟悉。
可以看到,左边的 Assembly-CSharp.dll
明显是编译自 Spine(可以想到,Live 时的骨骼就是它负责了)的,而 Assembly-CSharp-firstpass.dll
则不知是什么来头。
三、CGSS 的反作弊措施
在 CodeStage.AntiCheat
命名空间下可以看到,为了防止作弊(模块注入类、变速齿轮类和另外两种不知道是什么的),BNEI 的程序员们是下了功夫的。里面还有基础类型混淆……
不过这也有(至少没有封上的)漏洞,提示:后台服务。不过别玩过火了,游戏是练技巧寻开心的,作弊赢的一点意思都没有。
四、Assembly-CSharp.dll
概览
为什么是概览呢?因为我说过了不会给你们看关键代码的,有兴趣的可以自己看。
4.1 根命名空间
众多的游戏类:骨骼动画、UI、碰撞检测,还有其他有意思的东西。要注意的有两个类:AudioManager
和 Debug
。
4.2 Cute
命名空间
很奇怪,为什么要叫做 Cute
呢?难道架构同志是专攻 cute 组的P?
在这个命名空间下,主要是一些游戏逻辑(提供功能封装的)类,例如系统环境检测、支付、其他网络通信。
这个命名空间中最有价值的是 AssetHandle
类,它告诉了我们游戏资源是怎么保存和加载的。我本来是想看完整的保存逻辑,不过下载的代码反编译出来是这样的:
[DebuggerHidden]
private IEnumerator _Download()
{
AssetHandle.<_Download>c__IteratorD <_Download>c__IteratorD = new AssetHandle.<_Download>c__IteratorD();
<_Download>c__IteratorD.<>f__this = this;
return <_Download>c__IteratorD;
}
这看上去就是编译器自动生成的代码。不过我调试 async
、yield
这些语法糖的经验不够,况且 ILSpy 是能自动处理以上两种情况的,所以我也不确定这段代码究竟启动了什么,看上去像是 yield
。再想想,异步操作为什么是 yield
(实质还是同步)呢?在玩游戏的时候,如果下载卡住了,过一段时间才会引发 UI 无响应,看上去不是简单的同步调用啊。至于 AssetHandle.Download()
方法,各位就自己看吧,带了一个委托的。在没确定调用链的情况下,不清楚调用的委托的代码是什么。
在我分析 AudioManager
类的时候,它的一个方法 AudioManager.AddCueSheet()
中就有对 AssetHandle.BuildLocalPath()
的调用。看到这函数名字和调用关系,AssetHandle
的这个方法一定是关键所在。果然,这个函数告诉了我们各个数据目录的意义。其中提到了 AssetHandle.cryptFilename
属性,这又告诉了我们文件名的生成方式。最后一击来自 AssetHandle._LoadManifestContent()
方法,还记得第一个分析中我们的主数据库吗,用它的内容和这个方法的代码就能看到整个实际保存的文件结构。
4.3 Cutt
命名空间
各种 Live 相关的东西。
4.4 Stage
命名空间
都是 Unity3D 组件,游戏 UI 和逻辑。
五、Assembly-CSharp-firstpass.dll
概览
在打开这个程序集的时候我惊讶了,怪不得带一个“firstpass”,原来顺序上也应该是这个程序集在先的。查看依赖关系可以看到,这个程序集只依赖于 CLR 和 Unity3D,并被 Assembly-CSharp.dll
依赖。打开一看,命名空间只有4个,分别是根命名空间、CriManaPlayerDetail
、Sqlite3Plugin
和 UnityStandardAssets.ImageEffects
。嗯,数据库的访问就是在这里包装的。
还记得我们现在的目标吗,HCA 是谁研发的?CRI Middleware。其中,音频部分是 Atom,视频部分是 Mana。这个结论得出的过程很简单,看看根命名空间下的 Cri*
类就知道了。
仔细看的话,可以发现 Cri*
类大多是对 CRI Unity3D 插件的 C# 封装。我随便抽一个例子,打开 CriAtomEx
类,可以看到这样一个 P/Invoke 成员和对它的封装:
[DllImport("cri_ware_unity")]
private static extern void criAtomEx_SetRandomSeed(uint seed);
public static void SetRandomSeed(uint seed)
{
CriAtomEx.criAtomEx_SetRandomSeed(seed);
}
看到没有,DllImport
,上一篇我说过什么了?对应的正是 libcri_ware_unity.so
中导出的 criAtomEx_SetRandomSeed()
函数。其他的以此类推。
可以看到,CRI 的插件包含几个组成部分,分别是 FS(独立的文件系统抽象)、Atom(音频)、Mana(视频,依赖 Atom,看初始化代码就知道了)、AtomEx(添加了功能,扩展 Atom,原因下文会说)。
我们先将目光放到 CriHcaDecoder
类和 CriPcmData
类上。CriHcaDecoder
是整个程序集中唯一名字上和 HCA 有关系的类,但是它的命运很糟糕。它的声明是这样的:
public static class CriHcaDecoder
{
public static CriPcmData Decode(byte[] data);
public static CriPcmData Decode(string path);
public static AudioClip CreateAudioClip(string name, string path);
[DllImport("cri_ware_unity")]
private static extern bool criAtomDecHca_GetInfo(IntPtr data, int nbyte, out int sampling_rate, out int num_channels, out int num_samples, out int loop_start, out int loop_length);
[DllImport("cri_ware_unity")]
private static extern int criAtomDecHca_DecodeShortInterleaved(IntPtr in_data, int inbyte, IntPtr out_buf, int out_nbyte);
[DllImport("cri_ware_unity")]
private static extern int criAtomDecHca_DecodeFloatInterleaved(IntPtr in_data, int in_nbyte, IntPtr out_buf, int out_nbyte);
}
咦,为什么我这里敢放上所有的函数声明呢?因为这个类根本就没用!查看一下 libcri_ware_unity.so
的导出表,根本就没有 criAtomDecHca_DecodeShortInterleaved
这样的导出函数。而 CriPcmData
是与 CriHcaDecoder
息息相关的,从名字上看这么底层的结构也应该只有底层 API 会用,所以很不幸地,它也没用到。那么问题来了,既然放弃使用 API 进行分步解码,那么音频解码在什么地方呢?
问得好,这就是 CriAtomExPlayer
类存在的意义。我心中上演了一个小剧场:CRI Middleware 的那群人决定,每个数据包都用回调的形式来玩那真是暴殄天物,干脆就由我们的插件包了吧!于是我们就见到了 AtomEx 类别下的这一系列函数,比如 criAtomExPlayer_Start()
、criAtomExPlayer_SetAisacControlById
和 criAtomExPlayer_AttachFader
等等。真的是包了一些常用的高级功能呢。这也进一步加大了反向的难度,因为既然不暴露底层的方法,只暴露出一个“包办”的接口(没错你看那句柄,真是招摇),那么解码器的配置也应该是在内部进行的,意味着要看到这些就得反向汇编代码。
不过天无绝人之路,很容易就能在一群类中发现 CriWareDecrypterConfig
类。这个类包含四个成员:key
、authenticationFile
、enableAtomDecryption
和 enableManaDecryption
。哇,还有解密用的验证文件(内心一颤,难道是二重加密?),而且还能单独设置音视频的解密启用状态,真是体贴的服务。找这个类的使用场景,会发现它只在 CriWareInitializer
中出现过:
public CriWareDecrypterConfig decrypterConfig = new CriWareDecrypterConfig();
以及,作为一个配置信息类,必须有的读取操作,然后设置。在你们看到这一段代码的时候,心里也许会这么想:原来如此,这还不简单吗!但是我要告诉你们,这个 decrypterConfig
成员的各个字段值,由始至终都没变过,至少没有通过常规手段变过——甚至 useDecrypter
都好像一直是 false
(意味着不进行解密,直接使用原始数据)。这和从外部表现推导的内部流程的完全不一样。我当时也是想,如果这里直接能找到在 C# 中读取密钥的代码我不是赚大了吗,结果就栽了一个大跟头。
线索就这么断了。攻克这个难题后来费了我两天多。
六、其他的尝试
6.1 硬上反汇编
3月3日晚,既然 C# 那边又撞墙了,只好硬着头皮上汇编了。手拿挂上了 Hex-Rays Decompiler 的 IDA,我开始反向关键的 criWareUnity_SetDecryptionKey()
函数,看看文件路径为空指针时的反应。然后发现这个情况最后很可能掉到抛出错误信息接着返回调用方的分支中。验证文件是通过 CRI 自己的 FS 部分加载的,然后进行一堆的计算验证,跟踪非常困难。
6.2 思想实验:音频交叉对比
我也想过,不就是 264 种可能吗,反正音频文件也不长,如果撞上了呢。假设有个并行处理的机器,然后录下正确的声音,让这个机器去跑解密过程。不过这个方案有一个缺陷,就是输出都是非数字化的——因为从声卡录下的声音未必就是解码出的波形数据本身,所以要对比只能“听着”来对比,这就需要有一个感知上的音频对比引擎。而且,如果如网上所说的,随机数列导致的音频前期微小差别难以察觉的话,难道筛选出一部分可能的密钥(数量也许上千)之后还要拿长文件来测试吗?首先时间就划不来,而且还存在误报的问题(毕竟人的听力不是完美),我也没有足够长的样本,需要多长我也不知道。总之这只能是一个思想实验,正确性有可能,但代价太大。
6.3 强制跳过 HCA 加密类型判断
我说过我找到了一个 HCA 解码器的源代码。我之所以相信它是正确的,是因为它能解码几个未加密的 HCA 样本,而且能正确显示 CGSS 所用的 HCA 信息。里面有一段是判断加密类型的,我想也许 CGSS 里只是玩了一个把戏,将未加密的文件写上一个“加密”头,欺骗破解者。于是3月7日晚(中间4天赶毕设了),我短路了加密判断代码,强制作为未加密的文件解码,不过得到的还是一片噪声。
6.4 截获网络通信
继续,到了3月8日凌晨。既然密钥不存在于所有的代码中,有没有可能用了动态密钥呢,隐藏在每次的启动通信中?(杨彦君建议的……)不过其实再想想,其实是有逻辑漏洞的。第一次播放声音是在启动画面(5人的那张图片,见本系列的第2部分),当点击画面后才会进行第一次网络通信,然后是更多的载入。也就是说,文件解密是在网络之前的,用的至少也应该是本地缓存的密钥吧。而且,没有必要为了解密,每次都要浪费用户流量吧,这于情也说不通。
尽管如此,我还是用模拟器配合 Fiddler 抓了请求。里面至少没有明显的数据,所有的内容都是加密了的,除了每日通知的页面(也就是用上 Unity3D WebView 插件的地方啦)。我也不想花时间去破这个,为了一个手游投入破解商业密码的精力实在不值,毕竟我清楚手游的商业模型,也不会中招,别人往坑里跳我是不管的。不过在抓请求的时候,能看到一些有趣的东西,看请求头。
总之,以上的尝试都没有收到满意结果。但是,就是在3月8日凌晨,我开了一个大脑洞,出其不意(如果假想为一场战斗的话)地攻下了最后的防线。方法是想了,不过实行起来难度很大,而且还不知道是否可行,所以其实当时心里是一直没底的。详情请见下一期。