β

MLTD 逆向实录:提取 HCA 密钥

初春飾利の部屋 76 阅读

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 就有可能添加导入表信息。我们可能需要修改导入表,让其加载的是我们自己的动态库而不是真正的那个。不过这个担心是完全不必要的:

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 。从它的调用规范和后面的逻辑来看,原型是这样的:

int32_t call_lib_function(LPCSTR name);

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

举个例子感受一下:

.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的程序集,根据里面的声明抄了一份。

声明:

CA(uint32_t) criAtomUnity_Initialize();

实现:

uint32_t criAtomUnity_Initialize() {
CR(criAtomUnity_Initialize);
return 0;
}

CR 宏的定义:

#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 的消息(如找不到入口点,需要补充)和我们自己的库的消息。要是程序在某处抛出异常了,根据最后调用记录去找,并修改为合适的定义。

四、结果

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

-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 在某宝上已经有刷初始的工具了。啧啧啧,高效率啊,你们。

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说明)的开发者手中。嗯,“第一个攻破”,这个感觉不错。上世纪六七十时代的先锋黑客们感受到的应该就是这种心情吧。

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

作者:初春飾利の部屋
技术博客……或者一些私货。
原文地址:MLTD 逆向实录:提取 HCA 密钥, 感谢原作者分享。

发表评论