Secure77

Techblog and Security

Il2Cpp Injection and Hooking via Scaffolding

Development
Scripts / Tools

A Il2cpp scaffold is a ready to use (and to inject) DLL, which give us access to every type definition, object and method from the game and also provides a il2cpp helper API.

As I was playing around with some game manipulations for the Game A Tainted Grail – The Fall of Avalon I got the „opportunity“ to dive into the world of Il2Cpp injections.

You can read more about the background and the Mod I created in this blog post:Tainted Grail – FoA – Unlock The Hidden Secrets


Setup a Scaffold Project

The il2cppInspector (which provides the scaffold) was initial developed by djkaty, but she discontinued the development and the tool will not longer work for the latest il2cpp and unity versions. I figured out that the Il2CppInspectorRedux by LukeFZ has some recent commits and also supports the latest versions.

First we need to build the project, in my case it was also necessary to install pnpm.

  • Make sure Visual Studio is installed with c++ desktop environment
  • Install pnpm Invoke-WebRequest https://get.pnpm.io/install.ps1 -UseBasicParsing | Invoke-Expression
  • Clone and build the project
git clone --recursive https://github.com/LukeFZ/Il2CppInspectorRedux
cd Il2CppInspectorRedux
dotnet publish -c Release

No we can use the GUI to load the metadata.dat and gameassembly.dll and create the scaffold project

After we created the project we can open it with Visual Studio and want to upgrade it to some recent version (if it will not ask you, do it manually via the Project menu.

In my case, visual studio complained about some problems, which prevents me from building the Il2cppDLL. For example there were some methods in the il2cpp-api-functions.h which can’t be resolved, so I just comment them out (we don’t need them though).

// gchandle

DO_API(Il2CppGCHandle, il2cpp_gchandle_new, (Il2CppObject* obj, bool pinned));

DO_API(Il2CppGCHandle, il2cpp_gchandle_new_weakref, (Il2CppObject* obj, bool track_resurrection));

DO_API(Il2CppObject*, il2cpp_gchandle_get_target, (Il2CppGCHandle gchandle));

DO_API(void, il2cpp_gchandle_free, (Il2CppGCHandle gchandle));

The il2cpp-functions.h holds all of the custom and unity game methods, these are a lot and compiling the DLL with all of them will take between 5 and 20 minutes, so I would propose to save them in a il2cpp-functions.h.bak and only include these methods which could be interesting to call or hook.


Setup miniHook

Talking about hooking, the il2cpp injector can’t hook function by default (as far as I know), so we also want to setup minHook to hook into game function calls at runtime.

  • Download the lib release and extract it into your project folder
  • inlcude the lib into the main.cpp
#pragma comment(lib, "./lib/libMinHook.x64.lib")
#include "MinHook.h"
  • use static linking in Visual Studio


Basic usage

To get familiar with the Il2cpp scaffolding concepts and API methods I can recommend the following sources:

Even when some of these tutorials are a little bit outdated or not working for every case, they served as a great reference for me and helped a lot get the basics.

As always, there is more then one way to achieve something, which is great but also costs you time in case something is not working and you don’t know if your „approach“ is wrong or just something else is failing.

So I will leave some basic instructions here, which worked reliable for me


Methods

In general we need to decide between three different kind of methods:

  • global methods
  • instance methods
  • inherited methods or virtual methods (vtable)

All of them have at least one parameter, the MethodInfo * method, which defines the type of the method. Even when you don’t need to provide a MethodInfo in every case, I accustomed to provide them always, as its only one more line.

Global methods don’t need any reference or instance and can be called every time (or from everywhere), you can identify them (in the il2cpp-functions.h) that they don’t have a __this parameter.

DO_APP_FUNC(0x064E1290, Hero *, Hero_get_Current, (MethodInfo * method));

A call to this one would look like the following

const MethodInfo* mi = il2cpp_class_get_method_from_name((Il2CppClass*)*Hero__TypeInfo, "get_Current", 0);
Hero* myHero = Hero_get_Current(const_cast<MethodInfo*>(mi));

instance methods are the more common ones, these need to be called from an object (an instance of a class). The instance need to be passed as the first argument via __this

DO_APP_FUNC(0x064E1220, Domain, Hero_get_DefaultDomain, (Hero * __this, MethodInfo * method));

A call will look like

// as we need an Hero object fist, we can use the myHero object from our first call

const MethodInfo* mi = il2cpp_class_get_method_from_name((Il2CppClass*)*Hero__TypeInfo, "get_DefaultDomain", 0);
Domain myDomain = Hero_get_DefaultDomain(myHero, const_cast<MethodInfo*>(mi));

Virtual methods of an object are not directly available in the il2cpp-functions.h file as they belong to some parent class. If you want to know, which (virtual) methods are generally available for an instance but you can find them in the il2cpp-types.h file.

However based on this you don’t know from which class, these methods are coming from, but you can identify this by lookup the class in dnspy and investigate the methods of the parent functions, for example the Hero Class is based on the Model Class, so you can most likely also use all of the Model methods on it and these you can find in the il2cpp-functions.h again.

So for calling a virtual Hero method we only need to convert our Hero object to a Model object and then call the Model method

const MethodInfo* mi = il2cpp_class_get_method_from_name((Il2CppClass*)*Model__TypeInfo, "get_ID", 0);
String* myHeroID = Model_get_ID(reinterpret_cast<Model*>(myHero), const_cast<MethodInfo*>(mi));

If you know there is a virtual method for an object but can’t figure where it comes from you can also use the following code to „recreate“ the method, but at least we need to now the return type (if there is one).

 // get the Method Info from our class    
 const MethodInfo* hero_get_ID_MethodInfo = myHero->klass->vtable.get_ID.method;
 
 // get the pointer to the method from our class, as this objects returns a String*, we will cast it to this
 auto hero_getID_function = (String * (*)(Object*, MethodInfo*)) myHero->klass->vtable.get_ID.methodPtr;
 
 // call the method, we need to provide a generic object, so we need to cast myHero to an object
 String* heroId = hero_getID_function(reinterpret_cast<Object*>(myHero), const_cast<MethodInfo*>(hero_get_ID_MethodInfo));

Static Field and Properties

As soon we have our object, we can simple access it properties via ->fields


Accessing Items

If we want to enumerate a List of objects, we can use the items and vector properties of it

open example
ModelElements* myModelElements = __this; //we got somehwere an object which holds a list


// get the elements object of the ModelElements
auto elementsList = myModelElements->_elements;
    
    if (elementsList) {

       // get the items (list / array) of the elements
        if (elementsList->fields._items) {
            
            //get the size of the list
            il2cpp_array_size_t elementcount = elementsList->fields._items->max_length;

            if (elementcount > 0) {

                for (size_t i = 0; i < elementcount; ++i) {
                   
                    // get one element from the list by using -> vector[i]
                    Element* elementItem = elementsList->fields._items->vector[i];

                    if (elementItem) {


                        // do something with it (cast it to an model and print the id)
                        Model* parentModel = reinterpret_cast<Model*>(elementItem);

                        if (parentModel) {

                            auto output = Model_get_ID(parentModel, const_cast<MethodInfo*>(mi));
                            
                            if (output) {
                                auto targetModel = il2cppi_to_string(output);
                                std::cout << "[*] Element Object ID: " << output << std::endl;
                            }
                        }
                    }
                }
            }
        }
    }


Strings and output

As debugging is very time consuming (even when you attach visual studio to the application) you want to print out as much as possible. For this you can init a new console window and then use std::cout

void Run()
{
    // Initialize thread data - DO NOT REMOVE
    il2cpp_thread_attach(il2cpp_domain_get());

    // (Create a console window using Il2CppInspector helper API)
    il2cppi_new_console();

    std::cout << "DLL injected" << std::endl;
}

Or log it to a file (wich is very useful when you want to dump a lot of objects)

// Set the name of your log file here
extern const LPCWSTR LOG_FILE = L"C:\\Users\\admin\\Desktop\\il2cpp-log.txt";

void Run()
{
   // Initialize thread data - DO NOT REMOVE
   il2cpp_thread_attach(il2cpp_domain_get());
   
   il2cppi_log_write("Dll injected");

}

Also important is the helper method il2cppi_to_string(). which can be used to cast an app::String* (pointer) to an std::string object, so you can print it out correctly.

app::String* output = Model_get_ID(elementModel, const_cast<MethodInfo*>(mi2));

std::string targetModel_Name = il2cppi_to_string(output);

Boilerplate

This is an easy example of using our mentioned methods all together and print the ID and parent Domain of our Hero Object, it also invokes the AllElements method of the Hero object and print out every Element_ID. Make sure that the il2cpp-functions.h contains the following methods

DO_APP_FUNC(0x064E1290, Hero *, Hero_get_Current, (MethodInfo * method));
DO_APP_FUNC(0x064E1220, Domain, Hero_get_DefaultDomain, (Hero * __this, MethodInfo * method));
DO_APP_FUNC(0x00D16770, String *, Model_get_ID, (Model * __this, MethodInfo * method));

Open main.cpp
#include "pch-il2cpp.h"

#define WIN32_LEAN_AND_MEAN
#include <Windows.h>
#include <iostream>
#include "il2cpp-appdata.h"
#include "helpers.h"

using namespace app;

// Set the name of your log file here
extern const LPCWSTR LOG_FILE = L"C:\\Users\\admin\\Desktop\\il2cpp-log_model_elements.txt";


void Run()
{
    // Initialize thread data - DO NOT REMOVE
    il2cpp_thread_attach(il2cpp_domain_get());

    // (Create a console window using Il2CppInspector helper API)
    il2cppi_new_console();

    while (true)
    {
        if (GetAsyncKeyState(VK_F2) & 0x8000)  // F2 key
        {

            // call a global Method
            const MethodInfo* mi = il2cpp_class_get_method_from_name((Il2CppClass*)*Hero__TypeInfo, "get_Current", 0);
            Hero* myHero = Hero_get_Current(const_cast<MethodInfo*>(mi));

            if (myHero) {

                std::cout << "[*] Found hero object at: " << myHero << std::endl;

                // call instance Method
                mi = il2cpp_class_get_method_from_name((Il2CppClass*)*Hero__TypeInfo, "get_DefaultDomain", 0);
                Domain myDomain = Hero_get_DefaultDomain(myHero, const_cast<MethodInfo*>(mi));

                // retrieving a property (_FullName_k__BackingField) from an object
                String* myDomainFullName = myDomain._FullName_k__BackingField;

                // convert app::String to cpp String and print it out
                std::string myDomainOut = il2cppi_to_string(myDomainFullName);
                std::cout << "[*] Domain Name: " << myDomainOut << std::endl;

                // call inherited Method Model.get_ID() directly (virtual Method)
                mi = il2cpp_class_get_method_from_name((Il2CppClass*)*Model__TypeInfo, "get_ID", 0);
                String* myHeroID = Model_get_ID(reinterpret_cast<Model*>(myHero), const_cast<MethodInfo*>(mi));

                // convert app::String to cpp String and print it out
                std::string myHeroIDOut = il2cppi_to_string(myHeroID);
                std::cout << "[*] Hero Object ID: " << myHeroIDOut << std::endl;

                // call inherited Method Hero.AllElements() via vtable (virtual Method)
                const MethodInfo* mi = myHero->klass->vtable.AllElements.method;
                auto heroElements = (List_1_Awaken_TG_MVC_Elements_Element_ * (*)(Object*, MethodInfo*)) myHero->klass->vtable.AllElements.methodPtr;
                auto heroElements_List = heroElements(reinterpret_cast<Object*>(myHero), const_cast<MethodInfo*>(mi));

                // try to get all Hero Elements
                std::cout << "[*] List enum called " << std::endl;

                const MethodInfo* mi2 = il2cpp_class_get_method_from_name((Il2CppClass*)*Model__TypeInfo, "get_ID", 0);
                
                if (heroElements_List) {

                    // get the items (list / array) of the elements
                    if (heroElements_List->fields._items) {

                        //get the size of the list
                        il2cpp_array_size_t elementcount = heroElements_List->fields._items->max_length;

                        if (elementcount > 0) {

                            for (size_t i = 0; i < elementcount; ++i) {

                                // get one element from the list by using -> vector[i]
                                Element* elementItem = heroElements_List->fields._items->vector[i];

                                if (elementItem) {

                                    // do something with it (cast it to an model and print the id)
                                    Model* elementModel = reinterpret_cast<Model*>(elementItem);

                                    if (elementModel) {

                                        auto output = Model_get_ID(elementModel, const_cast<MethodInfo*>(mi2));

                                        if (output) {
                                            auto targetModel_Name = il2cppi_to_string(output);
                                            
                                            // log all Elements to a file
                                            il2cppi_log_write(targetModel_Name);

                                            if (targetModel_Name == "Hero:0:HeroOffHandCutOff:0") {
                                                std::cout << "[*] Found Object ID: " << targetModel_Name << " at:" << elementItem << std::endl;
                                            }
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }
            Sleep(200);
        }
        Sleep(10);
    }
}

After we load a save game, and press F2 we can see our output

and also our log file was created with all of our dumped Hero objects


Pitfalls and Tips

I spend many many hours figuring out, why sometimes my game crashes when I invoked a function. The reason was the usage of Threads, std::thread(KeyboardThread).detach(); I don’t know why exactly but I guess some functions will check the calling source or have events which are not triggered if the function is called from a different thread and then something will crash, so if possible try to avoid new threads.

Other common things why the game can crash or you get a wrong output:

  • try to cast an empty object
  • cast of an object to something what is not possible
  • calling function with the wrong type or an empty parameter
  • thread the function return value as a wrong type (usually happens when you call a vtable method)
  • not using the correct type when printing something out

Some general Tips

  • Try to find as early as possible some method which will give you and ID and Name of the object, so you can identify it. for example: get_Name, get_ID, get_Description etc.
  • use if statements to check if the object exists before you work with it, especially in lists I learned that not every item even when its available in the List is really usable.
  • Already mentioned in the setup chapter but I will repeat it here, remove every function which you are not using from the il2cpp-functions.h file to speed up the building process of your DLL.

Hooking a function

Finding a (game) object is not always trivial, especially when we don’t have a starting point or can’t enumerate the child elements and also when we have way to much methods available from our class (and parents) it can be too time consuming figuring out, which one give us our desired object. For example, our Hero and the parent Model class has a lot of methods, which could potentially return our desired object.

When we already now, that a specific method is called from our object it can be faster to just hook into this function and capture the instance instead of searching it based on some other object.


MiniHook Code

To setup a hook we simple need to provide the correct function location (pointer) and signature, both we can get from our il2cpp-functions.h. For Example, if we now that the OnRestore() method is called for the HeroOffHandCutOff object, we will create a hook for this one to capture the instance.

First we create the function signature, this based on the function we find in the il2cpp-functions.h.

using fn_HeroOffHandCutOff_OnRestore = void(*)(HeroOffHandCutOff* __this, MethodInfo* method);
static fn_HeroOffHandCutOff_OnRestore orig_HeroOffHandCutOff_OnRestore = nullptr;

Next we create the hook

void Hooked_HeroOffHandCutOff_OnRestore(HeroOffHandCutOff* __this, MethodInfo* method)
{
    std::cout << "[onRestore] Captured HeroOffHandCutOff.onRestore() instance: " << __this << std::endl;
    g_HeroInstance = __this;  // store instance globally or do something with it

    //call the original function
    if (orig_HeroOffHandCutOff_OnRestore) {
        orig_HeroOffHandCutOff_OnRestore(__this, method);
        std::cout << "[onRestore] original OnRestore call executed" << std::endl;
    }
}

And finally we need to initialize the hook

void initHook_HeroOffHandCutOff_OnRestore(){

    // get the address of the function 
    void* target = HeroOffHandCutOff_OnRestore;

    if (MH_CreateHook(target,
        reinterpret_cast<LPVOID>(&Hooked_HeroOffHandCutOff_OnRestore),
        reinterpret_cast<LPVOID*>(&orig_HeroOffHandCutOff_OnRestore)) == MH_OK)
    {
        MH_EnableHook(target);
    }
    else {
        std::cout << "[!] Failed to create hook HeroOffHandCutOff_OnRestore" << std::endl;
    }        
}

Our final hook script will look like the following. I also included a getOffset function, which prints out the file offset, where the hook is installed, so I can compare this with the dnSpy offsets or put a breakpoint in x64dbg on it.

open hook script
#include "pch-il2cpp.h"

#define WIN32_LEAN_AND_MEAN
#include <Windows.h>
#include <iostream>
#include "il2cpp-appdata.h"
#include "helpers.h"
#pragma comment(lib, "./lib/libMinHook.x64.lib")
#include "MinHook.h"

using fn_HeroOffHandCutOff_OnRestore = void(*)(HeroOffHandCutOff* __this, MethodInfo* method);
static fn_HeroOffHandCutOff_OnRestore orig_HeroOffHandCutOff_OnRestore = nullptr;

void getOffset(void* adr) {

    HMODULE gameAsm = GetModuleHandleA("GameAssembly.dll");
    uintptr_t base = reinterpret_cast<uintptr_t>(gameAsm);
    uintptr_t rtadr = reinterpret_cast<uintptr_t>(adr) - base;
    std::cout << "[*] Hook / Method at offset " << reinterpret_cast<void*>(rtadr) << std::endl;
}


void Hooked_HeroOffHandCutOff_OnRestore(HeroOffHandCutOff* __this, MethodInfo* method)
{
    std::cout << "[onRestore] Captured HeroOffHandCutOff.onRestore() instance: " << __this << std::endl;
    g_HeroInstance = __this;  // store instance globally or do something with it

    //call the original function
    if (orig_HeroOffHandCutOff_OnRestore) {
        orig_HeroOffHandCutOff_OnRestore(__this, method);
        std::cout << "[onRestore] original OnRestore call executed" << std::endl;
    }
}

void initHook_HeroOffHandCutOff_OnRestore(){

    // get the address of the function 
    void* target = HeroOffHandCutOff_OnRestore;

    if (MH_CreateHook(target,
        reinterpret_cast<LPVOID>(&Hooked_HeroOffHandCutOff_OnRestore),
        reinterpret_cast<LPVOID*>(&orig_HeroOffHandCutOff_OnRestore)) == MH_OK)
    {
        MH_EnableHook(target);
        getOffset(target);
    }
    else {
        std::cout << "[!] Failed to create hook HeroOffHandCutOff_OnRestore" << std::endl;
    }      
}

void Run()
{
    // Initialize thread data - DO NOT REMOVE
    il2cpp_thread_attach(il2cpp_domain_get());

    // (Create a console window using Il2CppInspector helper API)
    il2cppi_new_console();

    // Initialize MinHook
    if (MH_Initialize() != MH_OK) {
        std::cout << "[!] MinHook initialization failed" << std::endl;
        return;
    }

    initHook_HeroOffHandCutOff_OnRestore();
}


Injecting the DLL

There are different ways how you can inject the final DLL to the game, you can write your own injector (there are many source available) our you just use one of the following tools:

x64dbg

  • get ScyllaHide and copy it to the plugins folder of x64dbg
  • (optional) Load the Profile „Disabled“ to avoid Anti-Debug injections, when you don’t need them
  • Run the Game and Attach x64dbg to it, then select Plugins -> inject DLL

Cheat Engine

  • Attach to the Game Process
  • Open the Memory View and select Tools-> Inject DLL

Other Modding Frameworks

There are two other popular modding frameworks which I wanted to mention, maybe I will create another blog posts about these in the near future


Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert