CGSS 核心反向过程实录 | 六、获取密钥

文章目录
  1. 1. 一、声明
  2. 2. 二、插桩
  3. 3. 三、插入点
  4. 4. 四、输出信息
  5. 5. 五、重新编译

索引

2017年5月追记:

我探索这些的目的,是以一个玩家的身份,让 CGSS 变得更好玩。私下里我类比过普罗米修斯,将天火——谱面的制作和玩的能力从“天上”分一点出来。其实,修改 CGSS 去作弊是很简单的,无论是客户端还是 MITM。但是我不会去作弊,也不希望这系列文章的读者们将文章内容用在歪门邪道上,不希望去作弊、破坏平衡性。玩过 LLSIFSB69GF(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的加入使得 eip2eip1 的差值超限,状态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.StreamWriterStreamWriter 的构造需要一个 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/callvirtldloc/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 的文件,在文件完整性验证上会出错,这个部分就在下一篇文章中讨论吧。

分享到 评论