2017年5月追记:
我探索这些的目的,是以一个玩家的身份,让 CGSS 变得更好玩。私下里我类比过普罗米修斯,将天火——谱面的制作和玩的能力从“天上”分一点出来。其实,修改 CGSS 去作弊是很简单的,无论是客户端还是 MITM。但是我不会去作弊,也不希望这系列文章的读者们将文章内容用在歪门邪道上,不希望去作弊、破坏平衡性。玩过 LLSIF、SB69、GF(note)、Arcaea 之后,我仍然认为 CGSS 的综合指标(游戏性、操作体验、曲目等等)是行业内翘楚,Cy这次比较用心。那么作弊还有什么意思嘛!对了,我不氪金,不冲分,只是闲暇时间来两局,玩得舒心。
当然,まゆさまが見ています。
(3月8日)
在这篇文章的初版推送之后我又见到了几个已经攻破了 CGSS 的例子或人。因此我觉得,既然已经有不少人找到了门路,现在可以将这部分的内容公开了。
这种水平的文章,要有相应水平的人看,小白看懂了,不一定是好事。
——杨彦君
谨记:吸星大法有反噬之险。
一、声明
The Idolmaster Cinderella Girls Starlight Stage 软件和相关媒体的著作权为 Bandai Namco Entertainment Inc. 所有。本系列文章仅从兴趣出发,研究数据反向过程。在此不会给出完整的反向过程、反向结果和源代码,只给出思路和部分非关键数据。
我相信大牛即使是没有看过我写的这些玩意儿也能上的。
二、插桩
在讲解之前,首先要知道什么是“插桩”(instrumentation);如果不知道,至少得从经验中推导出这种方法。我是后者,直到半个月前读到《安卓动态调试七种武器之长生剑 - Smali Instrumentation》的时候才知道:啊,原来我当初用的方法是插桩,一种已经理论化的方法啊,并没有我认为的那么难想到。
插桩本来是用在软件测试中的,用作检测数据流的正确性,就如 Huang 在提出这个概念的论文 Detection of Data Flow Anomaly Through Program Instrumentation 中所描述的那样。这篇论文中的抽象理论总结起来其实挺简单:在正常运行的程序中,可以插入一些“探针”(probe),在不影响原程序运行的情况下获取运行中的状态信息。
我按照我的理解大致解释一下这句话。首先,程序运行的时候,可以认为是一个独立的子系统,这个系统具有有限个正常运行中会到达的状态,状态之间转移时下一个状态能获得上一个状态的信息。假设原来程序在状态A,运行一句代码后到达状态B,而我们想查看状态A时程序内部的信息。然而,状态A、状态B都不是我们所能操纵的,一般因为技术原因,比如函数过于复杂我们难以跟踪。那么我们加入一个我们所能控制的状态,状态C,在C内我们保存上一个状态的快照,输出快照,然后将状态恢复为快照所保存的状态。然后我们修改指令流,让程序从状态A经由状态C到达状态B,就能收到状态A的快照了。
如果用代码表示的话应该更容易理解。这是原始的程序:
var a = 0; // 状态A
a++; // 状态B
这是修改后的程序:
var a = 0; // 状态A
Console.WriteLine($"a = {a}"); // 状态C
a++; // 状态B
当然,修改的前提是不能破坏原系统的完整性。由于C是原来不存在的状态,而对于系统内的所有状态,都只知道上一个状态的信息,因此对于状态A和B来说根本察觉不到状态C的加入,B仍然认为是在接收状态A的信息。在这样的条件下,插桩是可行的。显然,如果状态中的信息包含指令顺序的话,就不能插桩了:
LONG eip1 = $eip; // 状态A
// print_eip1(eip1); // 状态D
LONG eip2 = $eip; // 状态 B
if (eip2 - eip1 < EIP_THRESHOLD) {
do_something(); // 状态C
}
上面的例子中,如果加入状态D,EIP 的差值就发生了变化。假如这里的 EIP_THRESHOLD
是一个刚刚好的临界值,例如2(X86 下一个 mov eax, eip
的长度),就强制保证了指令相邻。状态D的加入使得 eip2
和 eip1
的差值超限,状态C就无法达到了,执行流程就发生了变化。不过这毕竟是极其罕见的情况了。附:CALL
的长度计算就麻烦多了。
不过还好,此次我们插桩的目标是一个 C# 程序,其载体是 CIL,作为基于栈的中间语言,能保证我们插桩所需的条件。
三、插入点
之前我们不是说过了吗,在 CriWareInitializer
中有一个很明显的函数调用:
CriWare.criWareUnity_SetDecryptionKey(key, text2, this.decrypterConfig.enableAtomDecryption, this.decrypterConfig.enableManaDecryption);
很明显,我们要插在它前面。而且,这是一个 P/Invoke 调用,也就是说传递给未经修改的(CRI 应该并不会授权源代码重新编译,分发给使用者的应该是编译好的结果,所以应该能保证内部没有第三方的其他加解密)多媒体插件,所给出的密钥应该就是真实的、正式使用的。
由于这个 Assembly-CSharp-firstpass.dll
可能经过了混淆,不过再怎么混淆,反编译到 CIL 总是可以再编译回去的。CIL 运行时内部使用 UTF-8 编码,所以混淆时可能会被替换为不可见字符,这种情况就要写程序对字符串进行操作了。幸好这次 Notepad++ 足够用。反编译当然使用的是 ildasm
:
C:\Program Files (x86)\Microsoft SDKs\Windows\v7.0A\Bin>ildasm Assembly-CSharp-firstpass.dll /out=Assembly-CSharp-firstpass.il /utf8
然后我们找到 CriWareInitializer.Initalize()
方法,在大概第17671行,这里就是需要动工的地方。如果不确定在哪里,找一下 criWareUnity_SetDecryptionKey()
的调用就知道了。
四、输出信息
由于 CGSS 是在 Unity3D 上(运行时是 Mono),运行在一台 Android 的手机(我的)上的,在这个环境下是没法使用 Console.WriteLine()
和 Debug.WriteLine()
的,用了也看不到它们的输出,因此要另辟蹊径。其实翻一翻 CGSS 的代码中,其实是能看到日志记录的方法的,看看 CriAtomPlugin.InitializeLibrary()
这个方法吧。然后我们就知道了,UnityEngine.Debug.Log()
方法是跨平台的大大杀器,不管在哪里都能输出到那个平台对应的调试器日志中,在 Android 上当然是 logcat 啦。那么,输出的重任就交给它了。
如果要看 logcat 中与 Unity3D 相关的调试信息:
$ adb logcat -d -s Unity > log.txt
我除了使用 UnityEngine.Debug.Log()
,还用了一个 StreamWriter
将相关信息写入了一个文件中——这样就不需要每次去看 logcat 了,直接打开文件就好。
其实都到了这一步了,其他都很简单了,只要注意维持栈的平衡就好。我当时试了一天,最大的问题就是找到输出方法,以及选 Application.persistentDataPath
还是 CriWare.streamingAssetsPath
的问题。我当时还没做过 Unit3D 的东西,看代码似乎后者就是赋值为前者了而已,结果运行的时候总是崩溃。现在我绝对会坚定不移地选择前者。
首先,我们要有一个保存路径的变量,我设为 V_filePath
。在方法声明中定义这个局部变量:
.locals init (
// ...
string V_filePath,
// ...
)
然后在代码中:
// var filePath = UnityEngine.Application.persistentDataPath;
call string [UnityEngine]UnityEngine.Application::get_persistentDataPath()
stloc.s V_filePath
// filePath = Path.Combine(filePath, "debug.out");
ldloc.s V_filePath
ldstr "debug.out"
call string [mscorlib]System.IO.Path::Combine(string, string)
stloc.s V_filePath
我们还需要构造一个 System.IO.StreamWriter
,StreamWriter
的构造需要一个 Stream
对象,这里明显该用 FileStream
。要先定义:
.locals init (
// ...
class [mscorlib]System.IO.FileStream V_fs,
class [mscorlib]System.IO.StreamWriter V_sw,
// ...
)
然后打开文件流,准备写入:
// var V_fs = File.Open(V_filePath, FileMode.OpenOrCreate);
ldloc.s V_filePath
ldc.i4.4
call class [mscorlib]System.IO.FileStream [mscorlib]System.IO.File::Open(string, valuetype [mscorlib]System.IO.FileMode)
stloc.s V_fs
// var V_sw = new StreamWriter(fs, Encoding.UTF8);
ldloc.s V_fs
call class [mscorlib]System.Text.Encoding [mscorlib]System.Text.Encoding::get_UTF8()
newobj instance void [mscorlib]System.IO.StreamWriter::.ctor(class [mscorlib]System.IO.Stream, class [mscorlib]System.Text.Encoding)
stloc.s V_sw
关于枚举的使用,枚举是按照其实际值硬编码在 CIL 中的,这个特性我准备在另一篇文章中讲解。
然后我们来看一下究竟是否使用了解密:
// V_sw.WriteLine(string.Format("useDecrypter: {0}", this.useDecrypter.ToString()));
ldloc.s V_sw
ldstr "useDecrypter: {0}"
ldarg.0
ldflda bool CriWareInitializer::useDecrypter
call instance string [mscorlib]System.Boolean::ToString()
callvirt instance void [mscorlib]System.IO.TextWriter::WriteLine(string, class [mscorlib]System.Object)
答案是,虽然代码中好像没变过这个值,按照默认值应该是 false
,但是它确实是 true
。那么我们就有必要输出一下剩下的信息了。在使用了密钥的那个分支,输出密钥和附加的验证文件(别忘了处理逻辑跳转的关系):
// V_sw.WriteLine(string.Format("decrypterConfig.Key: {0}", this.decrypterConfig.key));
ldloc.s V_sw
ldstr "decrypterConfig.Key: {0}"
ldarg.0
ldfld class CriWareDecrypterConfig CriWareInitializer::decrypterConfig
ldfld string CriWareDecrypterConfig::key
callvirt instance void [mscorlib]System.IO.TextWriter::WriteLine(string, class [mscorlib]System.Object)
// authenticationFilePath = this.decrypterConfig.authenticationFile; (这一段是反编译的内容)
IL_0358: ldarg.0
IL_0359: ldfld class CriWareDecrypterConfig CriWareInitializer::decrypterConfig
IL_035e: ldfld string CriWareDecrypterConfig::authenticationFile
IL_0363: stloc.2
// V_sw.WriteLine(string.Format("decrypterConfig.authenticationFile: {0}", authenticationFilePath));
ldloc.s V_sw
ldstr "decrypterConfig.authenticationFile: {0}"
ldloc.2
callvirt instance void [mscorlib]System.IO.TextWriter::WriteLine(string, class [mscorlib]System.Object)
解释一下,上面的 ldloc.2
是因为验证文件路径这个变量原来就被声明到了第2位,是一个匿名变量。
在最后不要忘记调用 Dispose()
方法:
// V_sw.Dispose();
ldloc.s V_sw
callvirt instance void [mscorlib]System.IO.TextWriter::Dispose()
// V_fs.Dispose();
ldloc.s V_fs
callvirt instance void [mscorlib]System.IO.Stream::Dispose()
最后在持久化路径下就能见到 debug.out
文件,看看内容吧。顺带一提,这个路径就是 /storage/emulated/0/Android/data/jp.co.bandainamcoent.BNEI0242/files
。
所有的 CIL 代码我都没做优化,因为如果做了会更难读,而且也没什么用,因为这里只会经历一次,这几句也花不了多少时间。总之别看这段代码简单,得出它的路程可是很艰苦的,这里我注释掉的失败尝试有一百多行,每次都是对其中的单步进行测试。特别要小心 call
/callvirt
、ldloc
/ldloca
这一对一对的指令!如果不了解,请见 CIL 指令表。
讲个笑话,还试过用 HttpWebRequest
向本机(10.0.2.2:8080)发送,然后用 Fiddler 来截获……不过并没有什么卯月。
五、重新编译
只要 CIL 没写错,就能正常编译。我们是用 ildasm
反编译的,那么编译回去就应该用 ilasm
。Unity3D 到版本 5.3.4 用的仍旧是对应于 .NET Framework 3.5 的框架,因此我们编译出的程序集也应该和它保持一致。.NET Framework 3.5 的运行时版本是 2.0,我们用这个版本的 ilasm
:
C:\Windows\Microsoft.NET\Framework64\v2.0.50727>ilasm /DLL F:\temp\cgss-managed\cracked\Assembly-CSharp-firstpass.il /output=F:\temp\cgss-managed\cracked\Assembly-CSharp-firstpass.dll
一切正常的话,就能看到编译出的 Assembly-CSharp-firstpass.dll
了。这里特意指定文件名与原文件名相同,替换的时候就不容易出错。啥?怕在 Windows 上编译出来的这个 DLL 无法运行?别忘了其中的 CIL 是中间语言而已,是可以跨平台的。
编译出的程序集,保留了原程序集的所有信息,只不过多了我们的两个探针,CGSS 依然会认,可以正常运行。我们的探针究竟能不能探测到东西并输出呢?这就得调试了。不过由于我们修改了 CGSS 的文件,在文件完整性验证上会出错,这个部分就在下一篇文章中讨论吧。