MLTD 逆向实录:提取 HCA 密钥

文章目录
  1. 1. 一、背景
  2. 2. 二、思路
    1. 2.1. 2.1 抽象部分
    2. 2.2. 2.2 细节
  3. 3. 三、修改流程
    1. 3.1. 3.1 验证导入表
    2. 3.2. 3.2 编写代理库
    3. 3.3. 3.3 打包
    4. 3.4. 3.4 调试和收集输出
  4. 4. 四、结果

Click here to view the English version.

MLTD(THE iDOLM@STER Million Live Theater Days,偶像大师百万演唱会剧场时光,俗称“麻辣土豆”)在6月28日(欧洲时间)正式上线了。在音频上,它使用的还是 CGSS 那一套 CRI Middleware 的技术,音频编码为 HCA。自然,这个 HCA 也是加密的,需要密钥解密。在昨天下午(欧洲时间,国内时间为6月30日凌晨),我成为了世界上第一个取得其密钥的人,然后这个密钥扩散到了日本和美国(似乎吧;我忘了triangle是在哪里的了)加拿大(感谢caimiao说明)的开发者手中。嗯,“第一个攻破”,这个感觉不错。上世纪六七十时代的先锋黑客们感受到的应该就是这种心情吧。

在这篇文章里,我就讲一下这次我是怎么取得密钥的。


一、背景

在 CGSS 版本3.0.3(后来撤回了,3.0.1不更新到此版本,直接跳到3.0.4)这个“小”更新中,Cygames 引入了重磅的功能:Unity 版本大更新,同时编译器后端换成了 IL2CPP。(semver:你在逗我吗!)前者不打紧,后者是要命了。原来修改方便的程序集,突然就成了原生动态库。稍微有点两边经验的人都知道,汇编的阅读和修改难度都比 CIL 高到不知哪里去了。原因包括优化跳转、(全局)堆访问(这是 CIL 不具备的)和紧缩的代码空间。CGSS 在3.0.3推出了这个更新,紧随其后发布的 MLTD 一上来就这么干了。嘛,这是手游界的趋势,IL2CPP 运行效率更高,查看和修改更困难,同时不需要修改源代码,有源代码的那一方不会有额外的麻烦,对于开发公司来说有百利而无一害。为什么不用呢,对不。

还记得之前我们是怎么提取密钥的吗?对,加 Debug.Log() 的调用。而且写那篇文章的时候我只知道 ILSpy,所以才要用到底层的 CIL 修改;后来我知道了 dnSpy,借助 Roslyn,修改的时候直接写 C# 代码就可以,门槛一下子就没有了。门槛没有了,方便的还是修改器的制作者,所以 CGSS 的外挂会那么猖獗——懂一点 C#,用个 dnSpy 都可以改。所以你看,滤网口太宽,有些渣渣就混进来了。在 IL2CPP 应用之后,哀鸿遍野,里面许多人就是这个表情:

渣渣三连

所以说,作弊有意思吗?没有。

现在问题来了,加 Debug.Log() 这条路,因为机器码的紧凑而基本上被堵死了。该怎么办?

二、思路

思路这东西是最重要的,它是经验的结晶,同时又是迈向下一个高峰的路。我能第一个提取出密钥,我认为有三个条件:

  1. 内在的技术理论体系;
  2. 敢开脑洞;
  3. 开完脑洞要有能力将其实现。

听起来好像是自恋的人在自吹……算了不吐槽这个。讲正题。

2.1 抽象部分

或许你会说,密钥,会不会嵌入在代码中呢?答案是,不会。我之前也有这样的疑问:为什么代码中明明没设置 enableAtomDecryption,解密却能正常进行呢?后来结合一点点 Unity 的知识、ADX2LE SDK 中的示例工程和代码中提示了的结构,在原工程中,一定存在着一个 GameObject。这个 GameObject 上附加的是控制逻辑(由此 Atom 之类的组件能随着游戏每帧正常更新)和设置信息。也就是说,那几个选项是附加在这个对象上的,在游戏初始化的时候随着 Unity 加载场景而自动反序列化字段,设置对应值。所以插件代码中不会出现这些值的设置逻辑。

既然 Debug.Log() 不行了,那就找其他的日志记录方式嘛。不管是哪一种方法,核心的原理都是拦截对 criWareUnity_SetDecryptionKey 这个 API 的调用,从而得到密钥。当然,前提也是 libcri_ware_unity 内部不会对这个值进行二次加/解密。

方法呢,按照修改的部分,分为三种:

  1. 修改主程序(libil2cpp.so);
  2. 修改音频库(libcri_ware_unity.so);
  3. 不在任何一边。

按照修改的手段,分为三种:

  1. 替换指令;
  2. 插入指令;
  3. 其他。

明眼人应该一眼就知道哪些组合是走不通的。不是真的走不通,而是工程上绝对的不划算。

最后剩下了一个方案,选项3×选项3。可以吗?可以!

得到的结果是什么呢?中间人攻击在动态库加载上的应用。

方法不算太偏门。而且,只要没有二次加/解密和动态库完整性校验,就是畅通无阻的。工程量和 API 数量、参数复杂度成正比,libcri_ware_unity.so 相对简单,所以可以手工写。更复杂的话我觉得可以借助 IDA 脚本来生成。(待学)

2.2 细节

我们已知,在游戏运行时会加载 libcri_ware_unity.so 这个动态库。是怎么加载的呢?是通过名称找到的。这个名称指定在 DllImport 特性(attribute)中,是编译后不可变的。那么,我们只要伪造一个 libcri_ware_unity.so,在我们自己的库中暴露原有的 API,对这些接口的调用则转接到真正的动态库上。然后在对应的 criWareUnity_SetDecryptionKey() 中插入自己的日志代码,记录密钥,就可以了。

流程图如下:

调用流程

为什么不用代码注入呢?嗯……要注入的是一个 ARMv7a 下的动态库(因为工具限制,见下文),我不会。╮(╯▽╰)╭好像也没有自动化工具来做这事儿吧。(要是谁知道请告诉我,谢谢!)

三、修改流程

虽然思路有了,但是实际的操作过程仍然是很麻烦的。下面我慢慢讲解。

3.1 验证导入表

我有个担心,libil2cpp.so 既然是原生库,那么其 P/Invoke 就有可能添加导入表信息。我们可能需要修改导入表,让其加载的是我们自己的动态库而不是真正的那个。不过这个担心是完全不必要的:

1
2
3
4
5
6
7
8
9
10
admin@MACHINE:/mnt/c/Users/admin/Desktop/mltd/v106$ readelf -d libil2cpp.so

Dynamic section at offset 0x21c3df4 contains 23 entries:
标记 类型 名称/值
0x00000001 (NEEDED) 共享库:[libstdc++.so]
0x00000001 (NEEDED) 共享库:[libm.so]
0x00000001 (NEEDED) 共享库:[libdl.so]
0x00000001 (NEEDED) 共享库:[libc.so]
0x0000000e (SONAME) Library soname: [libil2cpp.so]
...

(赞美 WSL!)

可以看到,它的导入表中的依赖项并没有发生变化,所以我们就不用改了。

不过正好,来都来了,我们就来看看其中的 P/Invoke 长什么样子吧。

说到 P/Invoke,Unity 的博客上是对此有一些讲解的。其实稍微了解点 Unix 系统就会知道,这样的特性,是离不开 dlopen() 的。好,我们就以 dlopen 这个符号为切入点,看看能挖到什么。打开 IDA,在导出符号中选择 dlopen,用 Xref 菜单项(键盘 x 键)跳转几次,就会发现我们的目标函数,位于 01C2A218h。从它的调用规范和后面的逻辑来看,原型是这样的:

1
int32_t call_lib_function(LPCSTR name);

在这个函数上继续 Xref,展现出了一大片的调用者。中大奖了!

举个例子感受一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
.text:0152BF9C CriFsUtility$$criFsUnity_SetProxyServer ; CODE XREF: CriFsUtility$$SetProxyServer+90p
.text:0152BF9C ; DATA XREF: .data.rel.ro:021751C4o
.text:0152BF9C STMFD SP!, {R4-R7,R11,LR}
.text:0152BFA0 ADD R11, SP, #0x10
.text:0152BFA4 SUB SP, SP, #0x20
.text:0152BFA8 LDR R0, =(_GLOBAL_OFFSET_TABLE_ - 0x152BFC0)
.text:0152BFAC MOV R4, R2
.text:0152BFB0 LDR R6, =0xD8924
.text:0152BFB4 MOV R5, R1
.text:0152BFB8 ADD R0, PC, R0 ; _GLOBAL_OFFSET_TABLE_
.text:0152BFBC ADD R0, R6, R0
.text:0152BFC0 LDR R0, [R0,#(dword_229C954 - 0x229C7F0)]
.text:0152BFC4 CMP R0, #0
.text:0152BFC8 BNE loc_152C030
.text:0152BFCC LDR R0, =(_GLOBAL_OFFSET_TABLE_ - 0x152BFDC)
.text:0152BFD0 LDR R1, =0xFFBD9A3E
.text:0152BFD4 ADD R0, PC, R0 ; _GLOBAL_OFFSET_TABLE_
.text:0152BFD8 LDR R2, =0xFFBDA143
.text:0152BFDC ADD R1, R1, R0
.text:0152BFE0 STR R1, [SP]
.text:0152BFE4 MOV R1, #0xE
.text:0152BFE8 ADD R7, R6, R0
.text:0152BFEC STR R1, [SP,#4]
.text:0152BFF0 ADD R1, R2, R0
.text:0152BFF4 STR R1, [SP,#8]
.text:0152BFF8 MOV R1, #0x19
.text:0152BFFC STR R1, [SP,#0xC]
.text:0152C000 MOV R1, #0
.text:0152C004 MOV R2, #1
.text:0152C008 STR R1, [SP,#0x10]
.text:0152C00C STR R2, [SP,#0x14]
.text:0152C010 MOV R2, #8
.text:0152C014 MOV R0, SP
.text:0152C018 STR R2, [SP,#0x18]
.text:0152C01C STRB R1, [SP,#0x1C]
.text:0152C020 BL call_lib_function
.text:0152C024 STR R0, [R7,#(dword_229C954 - 0x229C7F0)]
.text:0152C028 CMP R0, #0
.text:0152C02C BEQ loc_152C074

(问:为什么你能看到部分的名称呢?答:自然是这个啦!不过它只能用于 ARM 部分,不能用于其他架构。)

注意这是 ARM 汇编,BL 指令相当于 X86 汇编的 CALL 指令。每个 P/Invoke 函数都有类似的桩(stub),而 call_lib_function() 中有一个完整的 dlopen()-dlsym()-dlclose() 操作,这样就可以放心下结论了:加载动态库是每次 P/Invoke 时进行的,是一个动态过程。导入表真的不用管了。

3.2 编写代理库

这一步难度不大,就是个体力活。

由于要代理的是 libcri_ware_unity.so,就要知道它的 API。嘛,只看着 IDA 来的话,数量众多的函数会吓到人的。于是我打开2.9.0的程序集,根据里面的声明抄了一份。

声明:

1
CA(uint32_t) criAtomUnity_Initialize();

实现:

1
2
3
4
uint32_t criAtomUnity_Initialize() {
CR(criAtomUnity_Initialize);
return 0;
}

CR 宏的定义:

1
2
3
4
5
6
7
8
#define CR(method, ...) \
do { \
try_load_lib(__func__); \
DEBUG_LOG("Function call: %s", __func__); \
if (CW_API_OBJECT_NAME . method) { \
return CW_API_OBJECT_NAME . method(__VA_ARGS__); \
} \
} while (false)

这里面有几个 C++ 的技巧,就不细讲了。可以参考源代码来理解。

底层的调用其实就是每个函数通过 dlsym() 得到地址,然后调用。初始化参考 hacklib.cpp 中的 r 宏。

为什么每次都要有个 try_load_lib() 呢?因为我必须在第一次调用时加载动态库,并初始化代理。而我并不能保证一开始被调用的是哪个函数,所以每次都有初始化的流程。当然是有缓存的。而 DEBUG_LOG 宏的作用是调试,因为 MLTD 所使用的 libcri_ware_unity.so 版本比 CGSS 的2.9.0要新,在调用过程中可能会有错误,造成程序崩溃。因此打印最后调用的函数名,可以让我猜测是哪个函数出的问题,然后结合 IDA 进行修正。(实际过程中,必须尽快结束进程,否则会被 criWareUnity_GetFirstError() 给刷屏。)都是血与泪的经验。

调试过程也是挺艰苦的,从一开始直接崩溃(其实是我一个解引用写错了),到开始有输出,到最后获得想要的东西,其中的酸甜苦辣得亲身体验才能知晓。有些函数还真的是发生了变化,或者添加了新的函数,这些就要看着 IDA 来改了。

如果你阅读一下源代码,并和 IDA 与 P/Invoke 的版本做个比较,可能会发现这样一个问题:有些函数的声明返回类型不一致。就拿上面的 criAtomUnity_Initialize() 函数来说,它在 P/Invoke 中的返回类型是 void。这个有影响吗?全以 P/Invoke 为标准的话,是没有问题的;P/Invoke 要 void 实际返回一个32位整数也是没问题的。因为 Android 使用的是 cdecl 调用标准,调用者清理栈;而函数的返回值,如果是32位以内,是通过 R0 传递的(注意我们讨论的是 ARM,X86 中是 EAX)。P/Invoke 声明 void,实际上就是不管 R0 的值的意思。所以我们这边返回还是不返回 R0,都没有影响。不过要是 P/Invoke 需要返回值,那就得按照它那个来。

3.3 打包

打包这一步以前也讲过了,不再赘述。

3.4 调试和收集输出

开始我还用的是 adb logcat,不过不久之后我就发现 Android Studio 的 Android Monitor 明显更好用。总之,设置合适的过滤器,等着就行了。要注意的是 Unity 的消息(如找不到入口点,需要补充)和我们自己的库的消息。要是程序在某处抛出异常了,根据最后调用记录去找,并修改为合适的定义。

四、结果

结果输出,各位感受一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
06-29 19:35:25.846 28259-28287/? I/fakecri: CriWare hack: loaded.
06-29 19:35:25.853 28259-28287/? I/fakecri: On load: lib handle = 0xae6ae004
06-29 19:35:25.853 28259-28287/? I/fakecri: Resolving symbol JNI_OnLoad ...
06-29 19:35:25.853 28259-28287/? I/fakecri: Resolved symbol JNI_OnLoad: address = 0x939bf601
06-29 19:35:25.853 28259-28287/? I/fakecri: Resolving symbol JNI_OnUnload ...
06-29 19:35:25.853 28259-28287/? I/fakecri: Resolved symbol JNI_OnUnload: address = 0x0
06-29 19:35:25.853 28259-28287/? I/fakecri: Resolving symbol criAtomUnity_SetConfigParameters ...
06-29 19:35:25.853 28259-28287/? I/fakecri: Resolved symbol criAtomUnity_SetConfigParameters: address = 0x939bf681
06-29 19:35:25.853 28259-28287/? I/fakecri: Resolving symbol criAtomUnity_SetConfigAdditionalParameters_IOS ...
06-29 19:35:25.853 28259-28287/? I/fakecri: Resolved symbol criAtomUnity_SetConfigAdditionalParameters_IOS: address = 0x939bf6fd
06-29 19:35:25.853 28259-28287/? I/fakecri: Resolving symbol criAtomUnity_SetConfigAdditionalParameters_ANDROID ...
06-29 19:35:25.853 28259-28287/? I/fakecri: Resolved symbol criAtomUnity_SetConfigAdditionalParameters_ANDROID: address = 0x939bf701
...
06-29 19:35:25.866 28259-28287/? I/fakecri: Function call: criWareUnity_GetVersionNumber
06-29 19:35:25.867 28259-28287/? I/fakecri: Function call: criFsUnity_SetConfigParameters
06-29 19:35:25.867 28259-28287/? I/fakecri: Function call: criFsUnity_SetConfigAdditionalParameters_ANDROID
06-29 19:35:25.867 28259-28287/? I/fakecri: Function call: criFsUnity_Initialize
06-29 19:35:25.877 28259-28287/? I/fakecri: Function call: criAtomUnity_SetConfigParameters
06-29 19:35:25.878 28259-28287/? I/fakecri: Function call: criAtomUnity_SetConfigAdditionalParameters_PC
06-29 19:35:25.879 28259-28287/? I/fakecri: Function call: criAtomUnity_SetConfigAdditionalParameters_IOS
06-29 19:35:25.896 28259-28287/? I/fakecri: Function call: criAtomUnity_SetConfigAdditionalParameters_ANDROID
06-29 19:35:25.896 28259-28287/? I/fakecri: Function call: criAtomUnity_Initialize
06-29 19:35:25.908 28259-28287/? I/fakecri: Function call: criAtomEx3dListener_Create
06-29 19:35:25.909 28259-28287/? I/fakecri: Function call: criWareUnity_SetRenderingEventOffsetForMana
06-29 19:35:25.909 28259-28287/? I/fakecri: Function call: criManaUnity_SetConfigParameters
06-29 19:35:25.912 28259-28287/? I/fakecri: Function call: criManaUnity_SetConfigAdditionalParameters_ANDROID
06-29 19:35:25.912 28259-28287/? I/fakecri: Function call: criManaUnity_Initialize
06-29 19:35:25.921 28259-28287/? I/fakecri: Intercepted decryption key: (嗯)
06-29 19:35:25.936 28259-28287/? I/fakecri: Function call: criWareUnity_Initialize
06-29 19:35:25.937 28259-28287/? I/fakecri: Function call: criWareUnity_SetForceCrashFlagOnError
...

虽然最后还是崩溃了(接下来就是 criWareUnity_GetFirstError 大军),但是我已经得到了想要的东西。撤退!


密钥是一个挺有意思的数字。当我把密钥给开发群里的人看的时候,他们都笑出了声。


后记:

CGSS 3.0.4 已经有了自动全p的挂了。原因是改这一项只需要改一个数字,能在汇编的限制下进行。

听说 MLTD 在某宝上已经有刷初始的工具了。啧啧啧,高效率啊,你们。

分享到 评论