Finding the metadata loader: What if there is no il2cpp_init?

You may come across files with no il2cpp_init export. The Unity player must somehow call into the main application binary, so we can resort to looking in UnityPlayer.dll or libunity.so to find this entry point. The Unity players do not have source code available, but they are relatively easy to follow and we can use an unmodified copy of the player for reference. Additionally, you can easily create an empty Unity project and enable PDB generation, enabling you to see all of the function names and other symbols in the disassembly of the player.

When there is no il2cpp_init export, there are a few immediate possibilities:

  • The export name is obfuscated / encrypted
  • The player calls a different export to perform the initialization
  • The init function’s RVA (relative virtual address) is hardcoded in the player
  • The player calls an export which retrieves the function’s address from the application binary
  • The application binary calls an export on the player in its load hooks when the OS loads the file to provide the function address

There are two main points of interest in the player: where the reference to il2cpp_init is acquired, and where it is called. The normal flow of execution in an unobfuscated player looks like this:

alt

UnityMainImpl is rather long with a great many function calls, but we can use various string literals to guide the way:

winutils::DisplayErrorMessagesAndQuit("Data folder not found");
}
DetectIL2CPPVersion();
v78.m_data = 0i64;
v78.m_size = 0i64;
v78.m_label.identifier = 68;
v78.m_internal[0] = 0;
core::StringStorageDefault<char>::assign(&v78, "GameAssembly.dll", 0x10ui64);
v27 = !LoadIl2Cpp(&v78);
if ( v78.m_data && v78.m_capacity > 0 )
  operator delete(v78.m_data, v78.m_label);
if ( v27 )
  winutils::DisplayErrorMessagesAndQuit("Failed to load il2cpp");
v78.m_data = 0i64;
v78.m_size = 0i64;
v78.m_label.identifier = 68;
v78.m_internal[0] = 0;
core::StringStorageDefault<char>::assign(&v78, "il2cpp_data", 0xBui64);

This decompilation is from a player with symbols via a PDB file, but we can easily search a non-annotated player binary for string literals such as Failed to load il2cpp (or the others shown above) and move around until we find LoadIl2Cpp.

LoadIl2Cpp itself is quite straightforward and contains dozens of these:

v2 = 1;
 

il2cpp_init = LookupSymbol(v1, "il2cpp_init", kSymbolRequired);

if ( !il2cpp_init ) { v2 = 0; printf_console("il2cpp: function il2cpp_init not found\n"); } il2cpp_init_utf16 = LookupSymbol(gIl2CppModule, "il2cpp_init_utf16", kSymbolRequired); if ( !il2cpp_init_utf16 ) { v2 = 0; printf_console("il2cpp: function il2cpp_init_utf16 not found\n"); } il2cpp_shutdown = LookupSymbol(gIl2CppModule, "il2cpp_shutdown", kSymbolRequired); if ( !il2cpp_shutdown ) { v2 = 0; printf_console("il2cpp: function il2cpp_shutdown not found\n"); }

As you can see, each export is looked up in the loaded binary file and its addressed stored in a series of static global function pointers (il2cpp_init, il2cpp_init_utf16 etc.). This is the acquisition phase, so if the export is not present, look for changes here to see if a different export is called, or some other code is executed. Once again, if the strings are not obfuscated, we can search on these to find LoadIl2Cpp more easily.

What if there is no import? InitializeIl2CppFromMain looks something like this:

char __fastcall InitializeIl2CppFromMain(const core::basic_string<char,core::StringStorageDefault<char> > *monoConfigPath, const core::basic_string<char,core::StringStorageDefault<char> > *dataPath, int argc, const char **argv)
{
  v4 = argv;
  v5 = argc;
  v6 = dataPath;
  v7 = monoConfigPath;
  RegisterAllInternalCalls();
  il2cpp_runtime_unhandled_exception_policy_set(IL2CPP_UNHANDLED_POLICY_LEGACY);
  il2cpp_set_commandline_arguments(v5, v4, 0i64);
  v8 = v7->m_data;
  if ( !v7->m_data )
    v8 = &v7->8;
  il2cpp_set_config_dir(v8);
  v9 = v6->m_data;
  if ( !v6->m_data )
    v9 = &v6->8;
  il2cpp_set_data_dir(v9);
  v10 = GetMonoDebuggerAgentOptions(&result, 0);
  v11 = v10->m_data;
  if ( !v10->m_data )
    v11 = &v10->8;
  il2cpp_debugger_set_agent_options(v11);
  if ( result.m_data && result.m_capacity )
    operator delete(result.m_data, result.m_label);
  il2cpp_init("IL2CPP Root Domain");
  il2cpp_set_config("unused_application_configuration");
  profiling::ScriptingProfiler::Initialize();
  return 1;
}

There is considerable variance between versions since the Unity developers add new APIs like we’re about to have a world shortage. What they all have in common is the call to il2cpp_init, and the strings IL2CPP Root Domain and unused application configuration are a giveaway to finding this function even if the code path to it is obfuscated.

If il2cpp_init wasn’t imported in LoadIl2Cpp, it’s quite likely there will be a change in the above code to execute the initialization call. If the call looks the same as above, it’s quite likely that the il2cpp_init function pointer has been set elsewhere in the player.

Once we’ve determined the RVA of il2cpp_init or its equivalent, we can once again backtrack to the main application binary and trace our way through the code path as described earlier.