Reverse Engineering Unity Based Android Games - Part One

Overview

Lately, I have been really interested in reverse engineering Android games for fun. There is a large part of me that loves to reverse engineer software without any type of security context implied. So, I've decided to make a short blog series from my efforts around this project. After some cursory ganders into various popular Android games, I saw that many of these games were being developed on top of the Unity platform. So I decided to look into how Android games utilizing Unity actually worked under the hood. I ended up choosing Temple Run as my game of choice for this project.

temple_run.png

Before I get into this series, I want to apologize at potentially the lack of predicate information provided. I hope if the reader does not understand something, they are motivated enough to backfill their own knowledge before continuing. There is also a chance that this series just becomes a random non-coherent dump of reverse engineering output, so tailor your expectations accordingly.

Activity Flow

Ritualistically, the first thing I also do when digging into an Android application is to checkout the manifest. Basically, I am just trying to get the lay of the land and identify components that look interesting.

ImangiUnityProxyActivity

<activity android:configChanges="0x40002fff" android:label="@string/app_name" android:launchMode="2" android:name="com.imangi.unityactivity.ImangiUnityProxyActivity" android:screenOrientation="1">
  <intent-filter>
     <action android:name="android.intent.action.MAIN" />
     <category android:name="android.intent.category.LAUNCHER" />
     <category android:name="android.intent.category.LEANBACK_LAUNCHER" />
  </intent-filter>
  <meta-data android:name="unityplayer.UnityActivity" android:value="true" />
</activity>

The Temple Run application's com.imangi.unityactivity.ImangiUnityProxyActivity contains a <meta-data/> tag named unityplayer.UnityActivity, which made me think that this is probably the origination of important initialization points within the application. The com.imangi.unityactivity.ImangiUnityProxyActivity component's onCreate method had two potential code paths of interest.

00000024  sget-object         v2, ImangiUnityProxyActivity->ImangiNativeActivityClassName:String
00000028  invoke-direct       ImangiUnityProxyActivity->CreateImangiUnityActivity(String)Z, p0, v2
0000002E  move-result         v0
:30
00000030  if-nez              v0, :3E
:34
00000034  sget-object         v2, ImangiUnityProxyActivity->ImangiActivityClassName:String
00000038  invoke-direct       ImangiUnityProxyActivity->CreateImangiUnityActivity(String)Z, p0, v2
:3E

First there is an attempt to call the CreateImangiUnityActivity method with the ImangiNativeActivity class name as an argument. If that does not succeed, the method is called again, but with the ImangiActivity class name as an argument instead.

The CreateImangiUnityActivity method really only has one purpose and that is to launch the activity from the class name argument it was passed from OnCreate.

.method private CreateImangiUnityActivity(String)Z
          .registers 7
:0
00000000  new-instance        v2, Intent
00000004  invoke-static       Class->forName(String)Class, p1
0000000A  move-result-object  v3
0000000C  invoke-direct       Intent-><init>(Context, Class)V, v2, p0, v3
00000012  const/high16        v3, 0x00010000
00000016  invoke-virtual      Intent->addFlags(I)Intent, v2, v3
0000001C  invoke-virtual      ImangiUnityProxyActivity->getIntent()Intent, p0
00000022  move-result-object  v3
00000024  invoke-virtual      Intent->getExtras()Bundle, v3
0000002A  move-result-object  v1
0000002C  if-eqz              v1, :36
:30
00000030  invoke-virtual      Intent->putExtras(Bundle)Intent, v2, v1
:36
00000036  invoke-virtual      ImangiUnityProxyActivity->startActivity(Intent)V, p0, v2

ImangiUnityNativeProxyActivity

The ImangiUnityNativeProxyActivity extends the UnityPlayerNativityActivity class. All of the component's activity lifecycle methods are proxied through the ImangiUnityActivityHelper class. For example:

  • onActivityResult
  • onDestroy
  • onPause
  • onRestart
  • onResume
.method public onResume()V
          .registers 2
00000000  const-string        v0, "onResume"
00000004  invoke-static       ImangiUnityActivity->LogMessage(String)V, v0
0000000A  invoke-super        UnityPlayerNativeActivity->onResume()V, p0
00000010  iget-object         v0, p0, ImangiUnityNativeActivity->_ImangiHelper:ImangiUnityActivityHelper
00000014  invoke-virtual      ImangiUnityActivityHelper->onResume()V, v0
0000001A  return-void
.end method

The ImangiUnityActivityHelper class is a reflection wrapper in order to proxy calls to Unity's activities.

ImangiUnityNativityActivity

.method public onKeyDown(I, KeyEvent)Z
          .registers 5
00000000  const-string        v1, "onKeyDown"
00000004  invoke-static       ImangiUnityActivity->LogMessage(String)V, v1
0000000A  iget-object         v1, p0, ImangiUnityNativeActivity->_ImangiHelper:ImangiUnityActivityHelper
0000000E  invoke-virtual      ImangiUnityActivityHelper->onKeyDown(I, KeyEvent)Z, v1, p1, p2
00000014  move-result         v0
00000016  if-eqz              v0, :1C
:1A
0000001A  return              v0
:1C
0000001C  invoke-super        UnityPlayerNativeActivity->onKeyDown(I, KeyEvent)Z, p0, p1, p2
00000022  move-result         v0
00000024  goto                :1A
.end method

ImangiUnityActivityHelper

.method public onKeyDown(I, KeyEvent)Z
          .registers 4
00000000  const-string        v0, "onKeyDown"
00000004  invoke-static       ImangiUnityActivity->LogMessage(String)V, v0
0000000A  iget-object         v0, p0, ImangiUnityActivityHelper->Methods_onKeyDown:ArrayList
0000000E  invoke-virtual      ImangiUnityActivityHelper->CallStaticKeyMethods_(ArrayList, I, KeyEvent)Z, p0, v0, p1, p2
00000014  move-result         v0
00000016  return              v0
.end method

Here is a rough diagram of the initial 'activity' flow:

activity_flow.png

Native

In my initial exploration of the Temple Run application, I wanted to know if Unity games where predominantly implemented natively.

╭─rotlogix@carcossa ~/Downloads/Temple Run_v1.8.0_apkpure.com/lib/armeabi-v7a
╰─$ ls -la
total 388456
drwxr-xr-x  14 rotlogix  staff        448 Jun 10 15:08 .
drwxr-xr-x   5 rotlogix  staff        160 Jun  5 22:23 ..
-rw-r--r--   1 rotlogix  staff      38244 Jun  5 22:23 libadcolony.so
-rw-r--r--   1 rotlogix  staff     935488 Jun  5 22:23 libjs.so
-rw-r--r--   1 rotlogix  staff      19404 Jun  5 22:23 libmain.so
-rw-r--r--   1 rotlogix  staff    3762616 Jun  5 22:23 libmono.so
-rw-r--r--   1 rotlogix  staff   16805288 Jun  5 22:23 libunity.so

Based on the shared-libraries included within the Temple Run game, I had the following assumptions.

  • libmono.so is probably the Mono Runtime
  • libmain.so is probably the entry point into everything native
  • If we are dealing with the Mono Runtime, the libunity.so is probably loading DLL(s) somewhere within the assets or resources directories

I wasn't that far off in my initial assessment. Within assets/bin/Data/Managed, you can find a few DLL(s), which I imagined probably implemented most of Unity's core functionality.

╭─rotlogix@carcossa ~/Downloads/Temple Run_v1.8.0_apkpure.com/assets/bin/Data/Managed
╰─$ ls -la
total 7704
drwxr-xr-x   13 rotlogix  staff      416 Jun  5 22:23 .
drwxr-xr-x  461 rotlogix  staff    14752 Jun  6 13:06 ..
-rw-r--r--    1 rotlogix  staff   479232 Jun  5 22:23 Assembly-CSharp-firstpass.dll
-rw-r--r--    1 rotlogix  staff   538112 Jun  5 22:23 Assembly-CSharp.dll
-rw-r--r--    1 rotlogix  staff    29696 Jun  5 22:23 System.Core.dll
-rw-r--r--    1 rotlogix  staff   307712 Jun  5 22:23 System.Xml.dll
-rw-r--r--    1 rotlogix  staff   129536 Jun  5 22:23 System.dll
-rw-r--r--    1 rotlogix  staff    18432 Jun  5 22:23 UnityEngine.Advertisements.Android.dll
-rw-r--r--    1 rotlogix  staff   216576 Jun  5 22:23 UnityEngine.Networking.dll
-rw-r--r--    1 rotlogix  staff    28672 Jun  5 22:23 UnityEngine.PlaymodeTestsRunner.dll
-rw-r--r--    1 rotlogix  staff   231936 Jun  5 22:23 UnityEngine.UI.dll
-rw-r--r--    1 rotlogix  staff   504320 Jun  5 22:23 UnityEngine.dll
-rw-r--r--    1 rotlogix  staff  1439744 Jun  5 22:23 mscorlib.dll

libmain.so

So at this point, I feel like I am gaining some traction. However, I need to take a step back because there are a lot of moving parts. Since my assumption is that libmain.so is probably the entry point for native operations, I decided to start focusing my efforts there. First, I simply needed to identify the class that was loading libmain.so. Ideally, that class is within the activity data flow described above. So, I wrote a simple Jeb script, that would identify all methods that call into Android's load library API methods. That pointed me into the UnityPlayer class, which is at the very end of the activity flow chain.

.method static constructor <clinit>()V
          .registers 1
00000000  const/4             v0, 0
00000002  sput-object         v0, UnityPlayer->currentActivity:Activity
00000006  new-instance        v0, h
0000000A  invoke-direct       h-><init>()V, v0
00000010  invoke-virtual      h->a()Z, v0
00000016  const/4             v0, 0
00000018  sput-boolean        v0, UnityPlayer->m:Z
0000001C  const-string        v0, "main"
00000020  invoke-static       UnityPlayer->loadLibraryStatic(String)Z, v0
00000026  move-result         v0
00000028  sput-boolean        v0, UnityPlayer->m:Z
0000002C  return-void
.end method

After opening libmain.so in IDA, I noticed there wasn't very much functionality, which maybe meant that it just performs some base initialization operations. The library did implement the JNI_OnLoad function, which is called by the virtual machine when a library is loaded via a load library API i.e. System.loadLibrary. Implementing this function in your library is a good way to get immediate control of execution after your library is loaded and is a common practice when developing Android applications that rely on the Java Native Interface.

JNI_OnLoad

jni_onload.png

The first argument passed to JNI_OnLoad is a pointer to the JavaVM structure.

struct _JavaVM {
    const struct JNIInvokeInterface* functions;
#if defined(__cplusplus)
    jint DestroyJavaVM()
    { return functions->DestroyJavaVM(this); }
    jint AttachCurrentThread(JNIEnv** p_env, void* thr_args)
    { return functions->AttachCurrentThread(this, p_env, thr_args); }
    jint DetachCurrentThread()
    { return functions->DetachCurrentThread(this); }
    jint GetEnv(void** env, jint version)
    { return functions->GetEnv(this, env, version); }
    jint AttachCurrentThreadAsDaemon(JNIEnv** p_env, void* thr_args)
    { return functions->AttachCurrentThreadAsDaemon(this, p_env, thr_args); }
#endif /*__cplusplus*/
};

The first member within the JavaVM structure is a pointer to the JNIInvokeInterface structure.

struct JNIInvokeInterface {
    void*       reserved0;
    void*       reserved1;
    void*       reserved2;
    jint        (*DestroyJavaVM)(JavaVM*);
    jint        (*AttachCurrentThread)(JavaVM*, JNIEnv**, void*);
    jint        (*DetachCurrentThread)(JavaVM*);
    jint        (*GetEnv)(JavaVM*, void**, jint);
    jint        (*AttachCurrentThreadAsDaemon)(JavaVM*, JNIEnv**, void*);
};

The JNIInvokeInterface structure contains important JNI functions for performing operations within the virtual machine. If we take a look at the following instructions, it should be clear that this code is looking up a function within the JNIInvokeInterface and branching to it.

.text:000026C0                 LDR             R1, [R0] ; Java_VM
.text:000026C4                 LDR             R3, [R1,#16] ; R1 := JNIInvokeInterface
.text:000026C8                 ADD             R1, SP, #0x18+var_14
.text:000026CC                 BLX             R3      ; AttachCurrentThread

The second argument passed to the AttachCurrentThread function is a local variable, which will be filled out with the content of the JNIEnv structure. This structure is of type JNINativeInterface, which is a table including pointers to all of the JNI functions that can be utilized when importing jni.h into your Android NDK project. I ended up extracting the definition for that structure into a new header and loaded into IDA. With this structure now defined, and we should be able to clearly understand what's going on in JNI_OnLoad.

LDR             R0, [SP,#0x18+var_14] ; JNIEnv**
LDR             R2, =(_GLOBAL_OFFSET_TABLE_ - 0x26E8)
LDR             R6, =(aComUnity3dPlay - 0x5578)
LDR             R1, [R0] ; JNIEnv
ADD             R5, PC, R2 ; _GLOBAL_OFFSET_TABLE_
LDR             R2, [R1,#JavaNativeInterface.FindClass]
ADD             R1, R6, R5 ; "com/unity3d/player/NativeLoader"
BLX             R2      ; FindClass
MOV             R1, R0
LDR             R0, [SP,#0x18+var_14]
LDR             R3, =(off_5514 - 0x5578) ; load
LDR             R2, [R0]
LDR             R4, [R2,#JavaNativeInterface.RegisterNatives]
ADD             R2, R3, R5 ; off_5514
MOV             R3, #2
BLX             R4
CMP             R0, #0
BLT             loc_2728

Essentially the JNI_OnLoad function is calling RegisterNatives in order to register the native method load within the com.unity3d.player.NativeLoader class.

static jint RegisterNatives(JNIEnv* env, jclass java_class, const JNINativeMethod* methods, jint method_count)

RegisterNatives third argument is essentially a table of type JNINativeMethod. In the following snippet you can see the function iterating over the elements within this table.

[Snippet]
for (jint i = 0; i < method_count; ++i) {
    const char* name = methods[i].name;
    const char* sig = methods[i].signature;
    const void* fnPtr = methods[i].fnPtr;
[Snippet]

Going back to IDA, I can see the assembly referencing an offset into the data section which has been annotated as 'load'.

LDR             R3, =(off_5514 - 0x5578) ; load
LDR             R2, [R0]
LDR             R4, [R2,#JavaNativeInterface.RegisterNatives]
ADD             R2, R3, R5 ; off_5514
MOV             R3, #2
BLX             R4

This offset is actually the beginning of the JNINativeMethod structure for the NativeLoader class method > load.

.data:00005514 off_5514        DCD aLoad               ; DATA XREF: JNI_OnLoad+58↑o
.data:00005514                                         ; .text:off_2758↑o
.data:00005514                                         ; "load"
.data:00005518                 DCD aLjavaLangStrin     ; "(Ljava/lang/String;)Z"
.data:0000551C                 DCD sub_2760
  • The first member in the structure is the method's name
  • The second member in the structure is the method's signature
  • The third member in the structure is the method's native function pointer

Now that I have the load method's native function address, I can pivot my reverse engineering focus.

Thanks for reading, stay tuned for part two!