Example 4: Going in dry (Genshin Impact)

I love miHoYo, they troll us so badly with their IL2CPP obfuscation, but I won’t lie to your dear reader, unravelling this is the kind of nightmare fuel that keeps self-respecting hackers awake at night.

Fortunately, although the encryption itself is a pain, finding the code of interest is pretty straightforward. The string global-metadata.dat exists in the application binary and leads us to this function:

void sub_1857C29C0()
{
  v0 = sub_1857C2DC0(v15);
  v11 = "Metadata";
  v12 = 8i64;

sub_18576E440(v17, v0, &v11);

if ( v16 >= 0x10 ) { v1 = v15[0]; if ( v16 + 1 >= 0x1000 ) { if ( (v15[0] & 0x1F) != 0 ) invalid_parameter_noinfo_noreturn(); v2 = *(v15[0] - 8); if ( v2 >= v15[0] ) invalid_parameter_noinfo_noreturn(); if ( v15[0] - v2 < 8 ) invalid_parameter_noinfo_noreturn(); if ( v15[0] - v2 > 0x27 ) invalid_parameter_noinfo_noreturn(); v1 = *(v15[0] - 8); } j_free_0(v1); } v16 = 15i64; v15[2] = 0i64; LOBYTE(v15[0]) = 0; v11 = "global-metadata.dat"; v12 = 19i64;

sub_18576E440(v13, v17, &v11);

v19 = 0; v3 = sub_185793850(v13, 3i64, 1i64); v4 = v3; if ( !v19 ) {

v5 = sub_1857935F0(v3, &v19);

if ( !v19 ) { v6 = sub_1857C0E80(v4, 0i64, 0);

xmmword_187AEF530(v6, v5);

} } // ...

Comparing to the source code for MetadataLoader::LoadMetadataFromFile, we might guess that the calls to sub_18576E440 (lines 6 and 30) are utils::PathUtils::Combine – because there are two of them and they both take filenames in v11 as an argument – and if we click through this function we find string append calls, which lends credence to this theory. Clicking on sub_1857935F0 (line 36) reveals a function resembling os::File::Open, so we can now be pretty certain that we’ve found the correct function, albeit modified from the original. Notably, the filename argument to LoadMetadataFromFile has been removed and replaced with a hardcoded reference to global-metadata.dat in the function body itself. Plus, there is an additional call to a mystery function pointer on line 40 – after the call to sub_1857C0E80 or utils::MemoryMappedFile::Map on line 39 – which is not present in the original. This may be some kind of decryption function.

We find one cross-reference to this function pointer:

.data:0000000187AEF530 xmmword_187AEF530 xmmword ?             ; DATA XREF: il2cpp_init_security+3↑w

By looking at the original source code of the defined IL2CPP API calls – il2cpp-api.h – we can determine that il2cpp_init_security is not an API that exists in the standard IL2CPP API. The decompilation gives:

void __fastcall il2cpp_init_security(__int64 a1)
{
  *&xmmword_187AEF530 = *a1;
  qword_187AEF540 = *(a1 + 16);
}

The function takes a single argument – a pointer to 24 bytes of data – and stores it at the function pointer address we just identified.

We can examine UnityPlayer.dll to find the argument passed to il2cpp_init_security and thus identify the entry point of this mystery extra function call. We first find LoadIl2Cpp by performing a string search for il2cpp_init_security and searching for cross-references as we did for the standard il2cpp_init function previously:

v183 = 0i64;
v185 = 0i64;
v186 = 68;
LOBYTE(v184) = 0;
sub_1805C99D0(&v183, "il2cpp_init_security", 20i64);
v180 = (__int64 (__fastcall *)(_QWORD))sub_180AD0930(qword_181BFA680, &v183, 0i64);
qword_181BFA848 = v180;
if ( v183 && v184 )
{
  sub_18078C090(v183, v186);
  v180 = qword_181BFA848;
}

Assuming that sub_1805C99D0 loads the symbol il2cpp_init_security into the pointer v183 and sub_180AD0930 equates to LookupSymbol – as we saw earlier in our look at the PDB-annotated player – it’s reasonable to assume that qword_181BFA848 points to the entry point of il2cpp_init_security. We search for cross-references to this pointer to find the call site. It turns out to be in Il2CppInitializeFromMain:

*(_QWORD *)&v7 = qword_181C02810; *((_QWORD *)&v7 + 1) = qword_181C02820; v8 = v7; v9 = qword_181C02830; qword_181BFA848(&v8);

qword_181BFA850(sub_180ABD230); sub_180B46A70(); sub_180B13060(); qword_181BFA720(0i64); qword_181BFA888(a3, a4, 0i64); qword_181BFA870(); qword_181BFA878(); sub_180ABD2E0(); il2cpp_init("IL2CPP Root Domain");

The call to il2cpp_init_security occurs on line 5, passing in values set on lines 1 and 2 – qword_181C02810 and qword_181C02820. Once again, we search for cross-references to determine what these values are set to, and find it in some hitherto unknown function, the rest of which doesn’t matter right now:

__int64 (__fastcall *sub_180E951A0())(int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, char)
{
  __int64 (__fastcall *result)(int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, char); // rax
 
  qword_181C02808 = (__int64)sub_1801A62E0;

qword_181C02810 = (__int64)sub_1801A6830;

qword_181C02818 = (__int64)sub_18012ED60;

qword_181C02820 = (__int64)sub_18012F170;

qword_181C02828 = (__int64)sub_18012EF30; qword_181C02830 = (__int64)sub_18012F390; qword_181C02838 = (__int64)sub_1801A4D70;

Finally we have identified the function called by MetadataLoader::LoadMetadataFromFile in the game binary as sub_1801A6830 in UnityPlayer.dll – the decryption code. To recap:

  • The function above stores the address of sub_1801A6830 in qword_181C02810
  • LoadIl2Cpp finds the entry point of il2cpp_init_security in the application binary and stores it in qword_181BFA848
  • Il2CppInitializeFromMain calls il2cpp_init_security via the pointer set in step 2 using the fetched pointer from step 1 as the argument
  • il2cpp_init_security stores the function pointer passed in step 3 to xmmword_187AEF530
  • MetadataLoader::LoadMetadataFile calls the function pointer set in step 4 immediately after mapping global-metadata.dat into memory, essentially calling sub_1801A6830 in the Unity player

The actual function at sub_1801A6830 is a monster of obfuscated assembly code, but once again the point is to find where decryption occurs so that we can begin the process of reverse engineering it.

Going back to the game binary for a moment, we step up the call stack from MetadataLoader::LoadMetadataFromFile to the function which calls it. We expect to find MetadataCache::Initialize, and indeed we do:

void __fastcall sub_185756110(__int64 a1)
{

sub_1857C29C0();

qword_187AEF000 = v1; if ( !v1 ) { sub_18575C4F0("########is NULL########\n"); v1 = qword_187AEF000; }

qword_187AEF008 = v1;

v2 = (v1 + *(v1 + 120)); v62 = v2; v3 = 0; if ( *(v1 + 124) / 0x44ui64 ) { v4 = 0i64; do { sub_1857B1610(&v2[17 * v4]); v4 = ++v3; } while ( v3 < *(qword_187AEF008 + 124) / 0x44ui64 ); } qword_187AEEED8 = j_j__calloc_base(*(qword_187AEEFE0 + 48), 8ui64);

qword_187AEEEE0 = j_j__calloc_base(*(qword_187AEF008 + 84) / 0x68ui64, 8ui64); qword_187AEEEE8 = j_j__calloc_base(*(qword_187AEF008 + 300) >> 6, 8ui64);

qword_187AEEEF8 = j_j__calloc_base(*(qword_187AEEFE0 + 64), 8ui64);

dword_187AEEF00 = *(qword_187AEF008 + 116) >> 5;

v5 = j_j__calloc_base(dword_187AEEF00, 0x38ui64); qword_187AEEF08 = v5; v6 = qword_187AEF000;

v7 = (qword_187AEF000 + *(qword_187AEF008 + 112));

The first call (line 3) calls the MetadataLoader::LoadMetadataFile function we examined earlier, but no pointer to the decrypted metadata is returned. The uninitialized value v1 is used instead. The decompiler has slipped up in this case, and if we look at the highlighted lines which set (line 10) and access qword_187AEF008, it’s a pretty safe bet that this is the true pointer to the decrypted metadata. However, there is a problem. A closer examination of the header offsets referenced (eg. 84, 300 and 116) indicates that even after decryption, the header fields are not in their normal order – they have been rearranged as a form of obfuscation! Untangling this will require a more thorough dissection of the binary file’s code.

>>Conclusion

I’ve only illustrated a small sampling of the wide variety of schemes currently being used to foil the acquisition of global-metadata.dat here, but as you can see the process of reverse engineering them all starts in more or less the same way:

  • Check global-metadata.dat in a hex editor to see if it is present, or encrypted. If not present, check other files in the application folder that could be candidates.
  • Find MetadataCache::Initialize and MetadataLoader::LoadMetadataFile in the application binary using the techniques above, either via a string cross-reference lookup for global-metadata.dat or by tracing the code from il2cpp_init down the call chain if the string is unavailable.
  • If you need to trace the code but the il2cpp_init export is not present, examine UnityPlayer.dll or libunity.so to find the entry point.
  • Compare MetadataCache::Initialize and MetadataLoader::LoadMetadataFile with the original source code to identify changes and additions made by the developers. These changes are likely to be where decryption and deobfuscation take place.
  • If the decryption code can be called externally, write a small program to call the decryption function and save the resulting file (see the Sharpen your knives section at the bottom of this article on the blog for a complete walkthrough of how to do this).
  • Otherwise, reverse engineer the discovered changed code thoroughly to determine how the obfuscation works and how to defeat it.
  • Consider writing a plugin for Il2CppInspector so that the target application can be loaded as normal without having to edit the tooling’s source code directly.

I hope you found these walkthroughs interesting and helpful – now get out there and be a shark!