Unity3D protection in “AU2” dance games

kao

Today's story is about dancing games. Specifically, about

These games employ some tricks in the APK file structure as well as modified libmono.so. I will go through each of the protection mechanisms step-by-step and explain how it works. In the end, you will have all the necessary information to implement your own decryption tool that can decrypt AU2 protected DLL files.


Since this research builds on my previous work, I strongly suggest that you read at least Part 1 and Part 2 of my series about Unity3D protections.

Let's get started!

Modified APK/ZIP file

As you probably know, APK file is actually a ZIP file. Game authors have taken the ZIP file and modified some of ZIP internal structures. Depending on which tool you use to decompress the APK, it may or may not cause problems.

For example, the very popular ICSharpCode.SharpZipLib library fails with an error:

Unhandled Exception: ICSharpCode.SharpZipLib.Zip.ZipException: Version required to extract this entry not supported (788)
   at ICSharpCode.SharpZipLib.Zip.ZipFile.TestLocalHeader(ZipEntry entry, HeaderTest tests)
   at ICSharpCode.SharpZipLib.Zip.ZipFile.GetInputStream(Int64 entryIndex)
   at ICSharpCode.SharpZipLib.Zip.ZipFile.GetInputStream(ZipEntry entry)

This is an easy thing to fix, since SharpZipLib is an open-source project. Just find the offending check and comment it out.

Unpack libmono.so

Once the APK is unpacked and you open Assembly-CSharp.dll in the hex editor, you'll see that DOS and PE signatures are changed to lowercase "mz" and "pe". And the entire PE section table is encrypted.

So, it's very likely that libmono.so was modified. But you can't analyze it right away because it's encrypted with some protection I can't recognize.

I'll be very honest - I don't have a good solution for this part. The cryptor itself is not particularly difficult. However, these games also use libjiagu.so which employs some anti-debug protection. In the end I just made a memory dump from running process and did static analysis of dumped libmono.so in IDA. If I figure out a better way, I'll update the post.

Modifications of libmono.so

Once the libmono.so is unpacked, you can get to the good stuff. Authors of this protection did not go the usual way of changing mono_image_open_from_data_with_name, they changed several methods instead.

First change is in mono_image_load_pe_data - there is an additional check for "mz" signature:

...
memcpy (&msdos, image->raw_data + offset, sizeof (msdos));
if (!(msdos.msdos_sig [0] == 'M' && msdos.msdos_sig [1] == 'Z') && 
    !(msdos.msdos_sig [0] == 'm' && msdos.msdos_sig [1] == 'z'))
   goto invalid_image;

msdos.pe_offset = GUINT32_FROM_LE (msdos.pe_offset);
offset = msdos.pe_offset;
offset = do_load_header (image, header, offset);
...

Similarly, do_load_header is modified to support "pe" signature:

static int do_load_header (MonoImage *image, MonoDotNetHeader *header, int offset)
{
   MonoDotNetHeader64 header64;
#ifdef HOST_WIN32
   if (!image->is_module_handle)
#endif
   if (offset + sizeof (MonoDotNetHeader32) > image->raw_data_len)
      return -1;

   memcpy (header, image->raw_data + offset, sizeof (MonoDotNetHeader));
   if (!(header->pesig [0] == 'P' && header->pesig [1] == 'E') &&
       !(header->pesig [0] == 'p' && header->pesig [1] == 'e') &&)
      return -1;
...

And the section headers are decrypted in the load_section_tables:


// initialize decryption key
decrypt_init();

for (i = 0; i < top; i++){
   MonoSectionTable *t = &iinfo->cli_section_tables ;

   if (offset + sizeof (MonoSectionTable) > image->raw_data_len)
      return FALSE;

   // additional check for "mz" header
   if ( image->raw_data[0] == 'm' && image->raw_data[1] == 'z' )
   {
      // special treatment of sections in "mz" file
      // copy section header
      memcpy (t2, image->raw_data + offset, sizeof (MonoSectionTable));

      // decrypt each field separately
      decrypt(&t2Name, 8, i);
      decrypt(&t2.Misc, 4, i);
      decrypt(&t2.VirtualAddress, 4, i);
      decrypt(&t2.SizeOfRawData, 4, i);
      decrypt(&t2.PointerToRawData, 4, i);
      decrypt(&t2.PointerToRelocations, 4, i);
      decrypt(&t2.PointerToLinenumbers, 4, i);
      decrypt(&t2.NumberOfRelocations, 2, i);
      decrypt(&t2.NumberOfLinenumbers, 2, i);
      decrypt(&t2.Characteristics, 4, i);

      // copy decrypted header to proper place
      memcpy (t, t2, sizeof (MonoSectionTable));
   }
   else
   {
      // original code
      memcpy (t, image->raw_data + offset, sizeof (MonoSectionTable));
   }
   offset += sizeof (MonoSectionTable);
...
}

The decryption method is an overly-complicated XOR:

void decrypt_init()
{
   m_decryptionKey[0] = 'm';
   m_decryptionKey[1] = 'p';
   m_decryptionKey[2] = 'z';
   m_decryptionKey[3] = 'e';
   m_decryptionKey[4] = 'e';
   m_decryptionKey[5] = 'z';
   m_decryptionKey[6] = 'p';
   m_decryptionKey[7] = 'm';
   m_decryptionKeySize = 8;
}

void decrypt(_BYTE *a1_data, int a2_size, unsigned __int8 a3)
{
   // XOR data with the key.
   int keyIdx = 0;
   for ( int i = 0; i < a2_size; ++i )
   {
      a1_data[i] ^= m_decryptionKey[keyIdx++];
      if ( keyIdx == m_decryptionKeySize )
         keyIdx = 0;
   }

   // "Generate" key for next call. Nightmare! :)
   for ( int i = 0; i < a2_size; i++ )
      m_decryptionKey[i] = a1_data[i] ^ a3;
   m_decryptionKeySize = a2_size;
}

Broken IL method headers

Now we can open file in dnSpy. However, all methods have empty method bodies. In fact, all the method headers are invalid, both header size and code size is shown as zero.

// Token: 0x06000001 RID: 1 RVA: 0x00002050 File Offset: 0x00000250
.method public hidebysig specialname rtspecialname instance void .ctor () cil managed 
{
   // Header Size: 0 bytes
   // Code Size: 0 (0x0) bytes
   .maxstack 0
} // end of method HideAndroidButtons::.ctor

So, there is still something missing. πŸ™‚

This looks like a result of a JIT-hooking protection. But there is no JIT hooking in Mono framework! So, the protection authors must have invented another trick..

It took me some time to figure this one out.

First, there is some additional code in load_tables. It generates a decryption key from 2 fields in the #~ stream header. See ECMA-335, II.24.2.6 #~ stream for the description of the structure.

/*
 * Load representation of logical metadata tables, from the "#~" stream
 */
static gboolean
load_tables (MonoImage *image)
{
   const char *heap_tables = image->heap_tables.data;
...
   protectedModuleInfo = NIL;
   if ( LOBYTE(heap_tables->reserved0)
        || BYTE1(heap_tables->reserved0)
        || BYTE2(heap_tables->reserved0)
        || HIBYTE(heap_tables->reserved0) )
   {
      // Find next unused slot. Protection supports max 50 protected modules per process.
      for ( i = 0; i <= 49; ++i )
      {
         protectedModule = &m_ptrProtectedModules[i];
         if ( !protectedModule->moduleId )
            break;
      }

      protectedModule->key[0] = heap_tables->reserved0;
      protectedModule->key[1] = BYTE1(heap_tables->reserved0);
      protectedModule->key[2] = BYTE2(heap_tables->reserved0);
      protectedModule->key[3] = HIBYTE(heap_tables->reserved0);
...
      protectedModule->key[4] = heap_tables->valid[0];
      protectedModule->key[8] = heap_tables->valid[4];
      protectedModule->key[6] = heap_tables->valid[2];
      protectedModule->key[9] = heap_tables->valid[5];
      protectedModule->key[11] = heap_tables->valid[7];
      protectedModule->key[5] = heap_tables->valid[1];
      protectedModule->key[7] = heap_tables->valid[3];
      protectedModule->key[10] = heap_tables->valid[6];
...
   }

   heap_sizes = heap_tables [6];
   image->idx_string_wide = ((heap_sizes & 0x01) == 1);
   image->idx_guid_wide   = ((heap_sizes & 0x02) == 2);
   image->idx_blob_wide   = ((heap_sizes & 0x04) == 4);
...

Second, the decryption of method header is done in mono_metadata_parse_mh_full. Tiny and fat headers are handled separately but the idea is the same.

MonoMethodHeader *
mono_metadata_parse_mh_full (MonoImage *m, MonoGenericContainer *container, const char *ptr)
{
...
   switch (format) {
   case METHOD_HEADER_TINY_FORMAT:
      mh = (MonoMethodHeader *)g_malloc0 (MONO_SIZEOF_METHOD_HEADER);
      ptr++;
      mh->max_stack = 8;
      mh->is_transient = TRUE;
      local_var_sig_tok = 0;
      mh->code_size = flags >> 2;
      mh->code = (unsigned char*)ptr;
      return mh;

   case 0: // encrypted code, tiny header
      mh = (MonoMethodHeader *)g_malloc0 (MONO_SIZEOF_METHOD_HEADER);
      *ptr |= 2;                     // change method header flags from 0 to 2 -> tiny header
      origCode = (BYTE *)(ptr + 1);  // tiny method header is 1 byte long, followed by IL code
      mh->max_stack = 8;
      local_var_sig_tok = 0;
      mh->code_size = flags >> 2;

      // try to allocate buffer for decrypted code. If allocation fails, code will be decrypted in-place.
      buffer = (BYTE *)g_malloc0 (mh->code_size);
      if ( buffer )
      {
         memcpy(buffer, origCode, mh->code_size);
         decryptMethod(buffer, mh->code_size, m->name);
         mh->code = buffer;
      }
      else
      {
         decryptMethod(origCode, mh->code_size, m->name);
         mh->code = origCode;
      }
      mh->code_size |= 0x80000000;   // set highest bit of code_size. It will be used later.
      return mh;
...

The decryption routine is just a simple XOR again:

void __cdecl decryptMethod(BYTE *data, int dataSize, char *moduleName)
{
   ProtectedModule *pm;
   int idx;
   int i;

   pm = getProtectedModule(moduleName);
   idx = 0;
   for ( i = 0; i < dataSize; ++i )
   {
      switch ( idx )
      {
         case 0:
            data[i] ^= pm->key[0];
            break;
         case 1:
            data ^= pm->key[1];
            break;
         case 2:
            data ^= pm->key[2];
            break;
         case 3:
            data ^= pm->key[3];
            break;
         case 4:
            data ^= pm->key[4];
            break;
         case 5:
            data ^= pm->key[5];
            break;
         case 6:
            data ^= pm->key[6];
            break;
         case 7:
            data ^= pm->key[7];
            break;
         case 8:
            data ^= pm->key[8];
            break;
         case 9:
            data ^= pm->key[9];
            break;
         case 10:
            data ^= pm->key[10];
            break;
         case 11:
            data ^= pm->key[11];
            break;
         default:
            break;
      }
      if ( ++idx == 12 )
         idx = 0;
   }
}

To fix this problem, you'll need parser for most .NET metadata structures. I used Asmex source as a base for my tool.

More IL code encryption

You make a tool that fixes method headers and decrypts IL code and run it - but the result still looks like junk:

// Token: 0x06000001 RID: 1 RVA: 0x00002050 File Offset: 0x00000250
.method public hidebysig specialname rtspecialname instance void .ctor () cil managed 
{
   // Header Size: 1 byte
   // Code Size: 7 (0x7) bytes
   .maxstack 8
   /* 0x00000251 3514         */ IL_0000: bgt.un.s  
   /* 0x00000253 3130         */ IL_0002: ble.s     
   /* 0x00000255 303A         */ IL_0004: bgt.s     
   /* 0x00000257 1A           */ IL_0006: ldc.i4.4
} // end of method HideAndroidButtons::.ctor

That's because there's another XOR encryption hidden inside mini_method_compile and mono_method_get_header. Remember, how highest bit of IL code size was set during previous decryption routine? Now this bit is checked and decryption loop is executed. Something like this:

MonoMethodHeader*
mono_method_get_header (MonoMethod *method)
{
...
   header = mono_metadata_parse_mh_full (img, container, loc);
   if ( header->code_size < 0 )
   {
      header->code_size &= 0x7FFFFFFF;
      for ( i = 0; i < header->code_size; ++i )
         header->code ^= 0x30;
   }
...

Why? Probably because 2 XOR encryptions are much stronger than just one. Oh, wait, what?! πŸ˜€ Adding one extra line to the tool is an easy fix.

Changed IL opcodes.

Now the decrypted IL code looks makes some sense. There is a ret instruction in the end and some methods actually look correct. Not this one, however:

// Token: 0x06000001 RID: 1 RVA: 0x00002050 File Offset: 0x00000250
.method public hidebysig specialname rtspecialname instance void .ctor () cil managed 
{
   // Header Size: 1 byte
   // Code Size: 7 (0x7) bytes
   .maxstack 8
   /* 0x00000251 05           */ IL_0000: ldarg.3
   /* 0x00000252 24           */ IL_0001: UNKNOWN1
   /* 0x00000253 01           */ IL_0002: break
   /* 0x00000254 00           */ IL_0003: nop
   /* 0x00000255 00           */ IL_0004: nop
   /* 0x00000256 0A           */ IL_0005: stloc.0
   /* 0x00000257 2A           */ IL_0006: ret
} // end of method HideAndroidButtons::.ctor

That's because protection authors have swapped around some IL opcodes. Normally, CIL opcode table looks like this:

  • 0x02 - ldarg.0
  • 0x03 - ldarg.1
  • 0x04 - ldarg.2
  • 0x05 - ldarg.3
  • 0x06 - ldloc.0
  • 0x07 - ldloc.1
  • 0x08 - ldloc.2
  • 0x09 - ldloc.3
  • 0x24 - UNUSED
  • 0x28 - call

In this modified version, ldarg.* and ldloc.* opcodes have been switched around and the call opcode is changed from 0x28 to 0x24:

  • 0x02 - ldarg.3
  • 0x03 - ldarg.2
  • 0x04 - ldarg.1
  • 0x05 - ldarg.0
  • 0x06 - ldloc.3
  • 0x07 - ldloc.2
  • 0x08 - ldloc.1
  • 0x09 - ldloc.0
  • 0x24 - call
  • 0x28 - call

The change is done in the methods mini_method_compile and inline_method. There's an extra check which tells if the module is protected. For protected module, an alternative implementation of mono_method_to_ri is called. If you're interested in the finer details, I suggest you study the code yourself.

To fix that, you'll need to make a simple .NET disassembler. You don't need a full-featured engine, simple length disassembler with lookup tables is more than enough for task in hand. I reused almost 10-years old code stored in my "old projects" folder - and now it is available on my bitbucket repo as well. πŸ™‚

The code for fixing the opcodes is very simple:

static void FixAu2Opcodes(byte[] ilCode)
{
    int i = 0;
    while (i < ilCode.Length)
    {
        byte opcode = ilCode[i];
        switch (opcode)
        {
            case 2:
                ilCode[i] = 5;
                break;
            case 3:
                ilCode[i] = 4;
                break;
            case 4:
                ilCode[i] = 3;
                break;
            case 5:
                ilCode[i] = 2;
                break;
            case 6:
                ilCode[i] = 9;
                break;
            case 7:
                ilCode[i] = 8;
                break;
            case 8:
                ilCode[i] = 7;
                break;
            case 9:
                ilCode[i] = 6;
                break;
            case 0x24:
                ilCode[i] = 0x28;
                break;
            default:
                break;
        }
        i += DotnetLengthDisasm.GetInstructionSize(ilCode, i);
    }
}

The end result looks like this:

Conclusion

At the time of writing, if you search for modified versions of these 2 games, you'll find out that there are zero mods or cheats available. Zero!

But there are also Thai and Indonesian versions of the same game, as well as an older English version of the game. They do not use custom libmono.so:

There are plenty of cheats and mods available for all of them. From the quick Google search:

Such large difference in number of available mods suggests that this protection is good enough to stop most wanna-be-hackers of the Android MOD groups. Nice work!


10 thoughts on “Unity3D protection in “AU2” dance games

  1. Hi, great article!
    With all of these custom modification to the mono library, it kind of hard to see what inside without knowing the structure of the .NET binary.
    I think it more important for me to learn it so I could know what is wrong with the file structure. Could you recommend where I should learn it? is the one from ntcore good enough? or do you have article about it that easy to read? πŸ™‚

    Thanks.

    1. Every person is different and learns in a different way. So, for me it's hard to guess which is the best way for you. πŸ™‚

      NTCore has a good article, it's a little bit out of date but definitely worth reading. "Coding with Spike" has good series about making a disassembler. He starts from the very beginning end explains how the .NET structures work together. But it was never finished. Some people also like Vijay Mukhi's book.

      When you start digging deeper into .NET structures, you will need to read ECMA-335 specification. It is big and boring - but it is a definitive reference for everything.

  2. Do you mind explaining how to do memory dump on Android?

    I discovered other game that have encrypted libmono.so

    1. It depends on what tools you prefer. πŸ™‚ Personally, I'm trying to use IDA as much as possible. IDA's debugger can take a process memory snapshot which contains the unpacked library as well.

      As for other tools... If you are most comfortable with Android, GameGuardian should work. If you are comfortable with command-line, then pmdump or memdump should work.

      Of course, if game uses some anti-debug or anti-dump protection, you'll need to bypass that somehow. But from what I've seen so far, it's not that common.

      You can always send me a link to that game and I'll take a look. No promises, however.

    1. I looked at it, and it's using the tricks I've mentioned in previous articles. Please read Part 1
      and Part 2 for details.

      Here's a short rundown and which bytes you need to change to fix it. I will not post a public link to fixed file, it might cause your account to be banned. πŸ˜‰

      1) Invalid PE signature

      Offset    Old New
      00000082: 2E  00
      00000083: 2E  00
      

      2) Invalid NumberOfRvasAndSizes value

      Offset    Old New
      000000F4: 0F  10
      

      3) Invalid values ch_size, ch_runtime_major, ch_runtime_minor in CLI header

      Offset    Old New
      00000208: F8  48
      00000209: F1  00
      0000020A: 35  00
      0000020B: 09  00
      
      0000020C: 3A  02
      0000020D: AC  00
      
      0000020E: 1B  05
      

      4) Invalid values md_version_major and md_version_minor in MetaDataRoot

      Offset    Old New
      003CC424: 02  01
      
      003CC426: 02  01
      

      5) Invalid length of version string in MetaDataRoot

      Offset    Old New
      003CC42C: 0B  0C
      

      6) Invalid number of .NET streams

      Offset    Old New
      003CC43E: 86  05
      003CC43F: 92  00
      

      7) Invalid sizes of .NET streams

      Offset    Old New
      003CC444: 1F  68
      003CC445: 5F  02
      003CC446: 93  1F
      
      003CC450: 5F  14
      003CC451: 4E  3A
      003CC452: F7  0A
      
      003CC464: F1  2C
      003CC465: D5  0F
      003CC466: 3C  0A
      
      003CC470: 32  10
      003CC471: C5  00
      003CC472: A0  00
      
      003CC480: 1D  3C
      003CC481: 72  14
      003CC482: 8A  04
      

      Once you fix all that, your file will open in dnSpy just fine:

Leave a Reply

  • Be nice to me and everyone else.
  • If you are reporting a problem in my tool, please upload the file which causes the problem.
    I can`t help you without seeing the file.
  • Links in comments are visible only to me. Other visitors cannot see them.

one  ×   =  seven