Reverse Engineering on MLTD: HCA Key Extraction

Catalogue
  1. 1. 1. Prelude
  2. 2. 2. Problem Analysis
  3. 3. 3. Technical Analysis
  4. 4. 4. Precondition: Import Table
  5. 5. 5. Implementation
  6. 6. 6. Packing Up
  7. 7. 7. Debugging and Collecting the Outputs

中文版见这里

THE iDOLM@STER Million Live Theater Days (abbreviation: MLTD) went online on June 28 (UTC +01:00). Similar to its sister app CGSS, it uses CRI Middleware’s audio solution. More specifically, encrypted HCA audio. We must obtain the decryption key to reveal the contents of those audio files. In this article, we discuss the concept and procedure of obtaining the key.

Beware, MLTD does not have a game library for X86 processors, and it requires OpenGL ES 3.0. Don’t be surprised. So we cannot run it on normal Android emulators (there is one but it brings other restrictions), and we have to face ARM assembly language instead of usual X86 assembly language.

I wrote this article to share the thoughts and the method I used. I appreciate the way of hackers in 1960s-1970s. Challenging and sharing. Exciting. Hope this article lights up more inspirations.


1. Prelude

In the old times, we can easily use Debug.Log() function provided by Unity to print out the key. This method is based on modifications on CLR assemblies. However, beginning from version 3.0.3, CGSS chose IL2CPP backend instead of Mono backend for code generation. So, bye-bye, CIL assemblies, and hello, libil2cpp.so. The latter contains pseudo-native machine code, which is much harder to read (in ARM assembly code), and nearly impossible to modify (unless some rare conditions are met). The initial release date of MLTD is later than the release of CGSS 3.0.3, and MLTD, unfortunately, also chose IL2CPP as its backend. Therefore, we can’t play the old trick anymore. We need a new weak spot.

Now let’s begin our journey to take down the fortress.

2. Problem Analysis

The audio part, CRI Middleware’s library, is independent. It is MLTD who integrates the library as one of its external components, instead of performing joint compilation of the library’s source code and the game’s code (after being processed by IL2CPP). So the CRI library will stay independent as a native library, and the game, developed in Unity running on CLR, must interop with the CRI library. Unity is cross-platform so C++/CLI is not a choice. The winner is: P/Invoke.

In a P/Invoke procedure, CLR loads target native library, finds the external function, marshals data types from the CIL program, calls the function, and marshals back the return value and other values on stack. Now you see, the main program and the external library are separated, and CLR has no idea what the target library looks like. Compared to web communications, it is intuitive that an MITM attack should work.

Figure 1 MITM Concept

The people in Figure 1 are all famous ones appeared in cryptography. Cute little symbols.

Now think about this: where should Eve be? Independent, inside Alice, or inside Bob? If the MITM part is not independent, we have to modify the code of the one it is in. It will be very, very painful. Remember, they are all native libraries. Actually we have no choice, but to make Eve independent.

3. Technical Analysis

Now that we have the concept, and what the technology it will be used against, let’s find a proper technical solution. It is quite straightfoward, per se.

Eve will become a proxy library. As a hardworking eardropper, Eve will forward all function calls to the geniune library. She monitors the functions in which she’s interested, and secretly records the information passed on to these functions. In our case, the target function is criWareUnity_SetDecryptionKey().

Figure 2 Call Flow

Thanks to CRI Middleware, it provides a free SDK (with limited functionalities and a grace period) in which there are plenty of documents. Most definitions of the APIs of libcri_ware_unity.so can be found in there. For the rest, they can be completed by looking through IDA.

Why don’t you use auto injection?

I don’t think there is such a framework for ARM EABI programs for Android.

4. Precondition: Import Table

Since the assembly is processed by IL2CPP, I wondered if the P/Invoke functions are converted to direct calls to target libraries, via a standard compile-link procedure. If this is the case, IL2CPP will definitely write the information into the import table and the entries may cause some troubles. This is proved to be false simply by checking it by readelf. No extra entries are found. Later when I digged into the assembly code, it appears that a dlopen()-dlsym()-dlclose() process is used in every P/Invoke function call.

5. Implementation

Well, if you have enough programming experience, this is the easiest step. Just make a proxy library that conforms all APIs of the geniune library. Pay attention to library file names and that’s all.

For example, criAtomUnity_Initialize().

Declaration:

1
CA(uint32_t) criAtomUnity_Initialize();

Definition:

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

Definition of CR macro:

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)

CA macro is a shorthand to declare API.

A global function object is used in order to unify call attachment statements to one macro.

All the information can be found in the source code.

If you compare the declaration of criAtomUnity_Initialize() in the documents with its real form in IDA, you will probably notice that they have different return types (void vs. int32). This difference does not matter if the return value is not needed in game. Yes, conform with the game, and optionally, external libraries. This is because those functions follow CDECL calling convention: call stack is cleaned up by the caller. Also, note that the program is designed for ARM architecture, so the return value is passed by register R0 (and R1 if the value is 64-bit). So if we call a function returning int32 as if it returns void, we are simply ignoring the value of R0. Don’t worry, any conservative compiler will save the value of R0 and restore it after the call, so chained int32-void calls will not become a mess.

6. Packing Up

Collect the libcri_ware_unity.so we compiled, rename the geniune one according to what it is expected to be, and place the proxy at the original position of the geniune one. After that, pack up and sign the APK.

7. Debugging and Collecting the Outputs

Use adb logcat. I also recommend Android Monitor in Android Studio because it saves lots of efforts and have a much friendlier UI.

Here it goes. Hacking to the Gate.

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: (yep, here)
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
...

Then it crashes. But hey, we already got what we came for. Time to retreat. :P

Writing detailed logs will help you in debugging. For example, the exact location of loading or calling failures. The default try-catch in IL2CPP’s P/Invoke template also helps. (Sweet!)


The key turned out to be an interesting number. You will be amused if you are an iDOLM@STER fan.


In the comments under the Chinese version of this article, logchan said another guy retrieved the key by only proxying the target call (i.e. criWareUnity_SetDecryptionKey()). I proxied all the APIs the game used. Since I don’t have much experience in remote debugging, especially a ARM library on IDA, I don’t quite understand how he did it, if he didn’t modify the constants table and some code in the game library.

Share Comments