Cast aside

When an IL2CPP application loads, it takes the majority of the definitions in global-metadata.dat and turns them into runtime objects. These objects provide runtime type information to the application, and are also used to track things like whether a class’s static constructor has been called as well as many other properties.

Let’s take a look at a snippet of the first decompilation of il2cpp::vm::MetadataCache::Initialize() from the test project again:

s_TypeInfoDefinitionTable = (Il2CppClass **)il2cpp::utils::Memory::Calloc(
                                                s_GlobalMetadataHeader->typeDefinitionsCount / 0x68ui64,
                                                8ui64);
s_MethodInfoDefinitionTable = (MethodInfo **)il2cpp::utils::Memory::Calloc(
                                                 s_GlobalMetadataHeader->methodsCount / 0x38ui64,
                                                 8ui64);

Notice that for each Il2CppTypeDefinition in the metadata, one pointer to Il2CppClass is allocated. For each Il2CppMethodDefinition, one pointer to MethodInfo is allocated, and so on. We can also import these types from libil2cpp/il2cpp-class-internals.h, however it is quite a bit of hassle because of all the macros, method definitions, #includes and #defines, all of which IDA hates.

Technically, we don’t need these additional headers, but there is an excellent reason to include them: it prevents us from making really dumb mistakes. Remember earlier on when I said we’d name this function because its meaning was obvious:

pImage = pGetImage(v8, *v9);
local_ImagesTable[imageIndex] = pImage;
alt

After applying the runtime object types, this decompiles as:

pImage = pGetImage(v12, *v13);
*(&local_ImagesTable->name + imageIndex_1) = pImage;

(the last line is equivalent to local_imagesTable[imageIndex_1].name = pImage)

Why would we be storing an Il2CppImage* in a name field? Let’s re-enable casting (which is actually the default):

pImage = (char *)pGetImage(v12, *v13);
*(const char **)((char *)&local_ImagesTable->name + imageIndex_1) = pImage;

Well this is alarming. EventInfo::name is a char * (as you might expect from a string). There are two possibilities: either our assumption about the purpose of pGetImage is incorrect, or there is some data obfuscation going on. Given that pGetImage is an external call, and that we previously detected custom decryption code that the game assembly called into UnityPlayer.dll to access, we may have stumbled across more shenanigans. What does the corresponding test project decompilation look like?

v9 = &v2[local_GlobalMetadataHeader->stringOffset + *v3];
s_ImagesTable[imageindex_1].name = v9;

(v3 in the test project corresponds to v13 in the real application)

Sneaky! The string table has been cunningly replaced with a function call. We’ll come back to this later, but note that we only realize this at this point because we imported the IL2CPP runtime object definitions and saw that an assignment didn’t make sense. The moral of the story is to use as much information as possible to aid in your analysis, even if it doesn’t seem particularly directly relevant.