Real-world examples

Now we have a rough understanding of how it all hangs together on paper, let’s have some fun and look at some real examples found in the wild! Note that I’m not going to explain the actual deobfuscation here – indeed, I haven’t deobfuscated all of them anyway; the idea is to give you a starting point and help you recognize what you’re looking for. I highlight a few simple techniques below that you can use to find the code of interest.

Example 1: No strings, alternative il2cpp_init export (League of Legends: Wild Rift)

We search for the string global-metadata.dat and other strings from the initialization code path in the binary but don’t find anything, so we look for il2cpp_init to trace the code path.

This application does not have an il2cpp_init export, so we look at the exports list:

alt

A quick glance shows that the export names are encoded with ROT-5, so nq2huu_nsny is the export il2cpp_init. If we don’t spot this encoding, we could also find the function by examining LoadIl2Cpp in libunity.so to find the loaded symbols.

We trace the code path down to Runtime::Init as normal:

v2 = sub_18FB4A0();
v3 = nullsub_1(v2);
v4 = sub_18F6C88(v3);
qword_79B75D8 = (__int64)"4.0";

v5 = sub_18F8B04(v4); sub_18D63F8(v5); sub_18F3F2C(); sub_188A6EC(); v7 = nullsub_3(v6); v8 = sub_18D52B4(v7); v9 = sub_18B15BC(v8); sub_18A9B54(v9); sub_18F7B70(nq2huu_fqqth_0);

memset(&qword_79B72B0, 0, 0x310uLL); v10 = sub_18E49BC("mscorlib.dll"); qword_79B72B0 = nq2huu_fxxjrgqd_ljy_nrflj_0(v10); qword_79B72B8 = nq2huu_hqfxx_kwtr_sfrj_0(qword_79B72B0, "System", "Object"); qword_79B72C8 = nq2huu_hqfxx_kwtr_sfrj_0(qword_79B72B0, "System", "Void"); qword_79B72D0 = nq2huu_hqfxx_kwtr_sfrj_0(qword_79B72B0, "System", "Boolean");

We click on each function in the highlighted names until we find one that looks like MetadataCache::Initialize. This turns out to be sub_188A6EC:

void sub_188A6EC()
{
  sub_18F3B04((__int64)aV, aV, 0x14u, dword_76D6C74, dword_76D6C74, 0LL);
  qword_79B7160 = sub_18F5C34(aV);
  qword_79B7168 = qword_79B7160;
  

qword_79B7170 = sub_18F4F60(*(int *)(qword_79B7150 + 64), 8LL); qword_79B7178 = sub_18F4F60(*(int *)(qword_79B7168 + 164) / 0x64uLL, 8LL); qword_79B7180 = sub_18F4F60(*(int *)(qword_79B7168 + 52) / 0x34uLL, 8LL); qword_79B7188 = sub_18F4F60(*(int *)(qword_79B7150 + 48), 8LL);

dword_79B7190 = *(int *)(qword_79B7168 + 180) / 0x28uLL; v0 = &unk_79B7000; qword_79B7198 = sub_18F4F60(dword_79B7190, 72LL); dword_79B71A0 = *(int *)(qword_79B7168 + 188) / 0x44uLL; qword_79B71A8 = sub_18F4F60(dword_79B71A0, 96LL);

We can tell this is the correct function because of the various data structure accesses in the highlighted lines, which match those in the IL2CPP source code for this function.

Now we can home in on the three key lines at the start of the function:

sub_18F3B04((__int64)aV, aV, 0x14u, dword_76D6C74, dword_76D6C74, 0LL);
qword_79B7160 = sub_18F5C34(aV);
qword_79B7168 = qword_79B7160;

We can initially assume that line 2 is the call to MetadataLoader::LoadMetadataFromFile with the fulename passed as aV. The unknown call in line 1 is not present in the original source file, the string literal global-metadata.dat has been replaced by aV, and the call in line 1 receives a pointer to this variable ((__int64)aV) as its first argument. We assume that line 1 decrypts the filename and line 2 passes it to the loader, storing it in qword_79B7160 and qword_79B7168 – our s_GlobalMetadata static pointer variable.