Home > IL2CPP > Il2CppInspector Tutorial: Working with code in IL2CPP DLL injection projects

Il2CppInspector Tutorial: Working with code in IL2CPP DLL injection projects

January 14, 2021 Leave a comment Go to comments

Once you have created a C++ scaffolding project for DLL injection with Il2CppInspector, you will likely be wondering how to interact with the injected IL2CPP application. In this article, we’ll look at how to perform common tasks such as accessing static and instance fields, creating new objects, working with strings, calling vtable functions and more.

The first part of this article is reference material covering how types, methods, properties and so on are represented, named and addressed in C++. The second part shows a number of concrete working examples, so if you want to skip the theory, feel free to jump forwards. Let’s dive in!

Note: C++ scaffolding is experimental and currently not for the faint-hearted. This document will be updated as the codebase matures and improves to reflect the current state of scaffolding projects.


PART 1: How .NET applications are represented in IL2CPP

Types

In IL2CPP, types are represented by an Il2CppClass object – one for each type in the application. This object contains many members allowing you to get RTTI (perform reflection) for the corresponding type. The most important ones for runtime use are as follows:

typedef struct Il2CppClass
{
    // ...
    void* static_fields;
    // ...
    VirtualInvokeData vtable[32];
} Il2CppClass;

These two members allow you to access a type’s static fields and vtable methods respectively. We’ll explore this more in a moment.

Object instances (.NET reference types) are represented by an Il2CppObject object. Each object starts with the following definition:

typedef struct Il2CppObject
{
    Il2CppClass *klass;
    MonitorData *monitor;
} Il2CppObject;

The klass member is the important one: it allows you to access the object’s corresponding class definition. monitor is for debugging purposes only. These fields are followed by additional .NET type-dependent fields representing the object instance members.

Info: Field definitions can occur in two flavours depending on which compiler was used to build the application. For apps compiled with MSVC (generally the case for Windows platforms), a single fields item containing a pointer to a struct containing all the fields will be present. For apps compiled with GCC or Clang (generally the case for Linux, Android and iOS), the fields will be included in the Il2CppObject class directly after klass and monitor. As we’re concerned with Windows DLL injection in this article, we’ll generally assume that MSVC is the source compiler.

While IL2CPP always uses Il2CppClass and Il2CppObject internally for every type definition and object instance, Il2CppInspector produces strongly-typed code with unique struct types for each .NET type. For example, for System.IO.FileStream, the corresponding Il2CppObject type generated for MSVC-compiled applications is:

struct FileStream {
    struct FileStream__Class *klass;
    MonitorData *monitor;
    struct FileStream__Fields fields;
};

struct FileStream__Fields {
    struct Stream__Fields _;
    struct Byte__Array *buf;
    struct String *name;
    struct SafeFileHandle *safeHandle;
    bool isExposed;
    // ...
}

FileStream here may be cast to Il2CppObject if required.

Notice that the first field “_” of FileStream__Fields encapsulates the fields from the base class System.IO.Stream:

struct Stream__Fields {
    struct MarshalByRefObject__Fields _;
    struct Stream_ReadWriteTask *_activeReadWriteTask;
    struct SemaphoreSlim *_asyncActiveSemaphore;
};

Similarly, the first field “_” of Stream__Fields encapsulates the fields from its abstract base class, System.MarshalByRefObject. This chain continues all the way back to System.Object which is the base type for all .NET reference types.

The corresponding Il2CppClass type generated is:

struct FileStream__Class {
    Il2CppClass_0 _0;
    Il2CppRuntimeInterfaceOffsetPair *interfaceOffsets;
    struct FileStream__StaticFields *static_fields;
    const Il2CppRGCTXData *rgctx_data;
    Il2CppClass_1 _1;
    struct FileStream__VTable vtable;
};

This struct is equivalent to Il2CppClass except that we have abstracted away fields that don’t require .NET type-specific strong typing into two surrogate members _0 and _1, and interposed strongly typed static_fields and vtable members. The idea behind this is to reduce the need for many ugly casts in your code. You can still cast the object to Il2CppClass if required.

Info: The fields _0 and _1 will be replaced with macro expansion in a later version of Il2CppInspector so that Visual Studio IntelliSense produces more meaningful suggestions when working with Il2CppClass types.

Value types

.NET value types are represented by structs containing their fields. Each value type has a corresponding boxed type (generated by Il2CppInspector with the “__Boxed” suffix) which is an Il2CppObject with an extra member for the field struct. For example, for System.Decimal:

struct Decimal {
    int32_t flags;
    int32_t hi;
    int32_t lo;
    int32_t mid;
};

struct Decimal__Boxed {
    struct Decimal__Class *klass;
    MonitorData *monitor;
    struct Decimal fields;
};

Decimal__Boxed here may be cast to Il2CppObject.

If the boxed struct only contains one field, the field is included by Il2CppInspector directly. The memory layout remains compatible since a struct contains no hidden header data:

struct Int32 {
    int32_t m_value;
};

struct Int32__Boxed {
    struct Int32__Class *klass;
    MonitorData *monitor;
    int32_t fields;
};
Enumerations

Enumerations are value types so they always have a boxed equivalent. Il2CppInspector defines C# enums as C++ enum classes ending in “__Enum“, with a value member for the boxed type:

enum class DateTimeFormatFlags__Enum : int32_t {
    None = 0x00000000,
    UseGenitiveMonth = 0x00000001,
    UseLeapYearMonth = 0x00000002,
    UseSpacesInMonthNames = 0x00000004,
    UseHebrewRule = 0x00000008,
    UseSpacesInDayNames = 0x00000010,
    UseDigitPrefixInTokens = 0x00000020,
    NotInitialized = -1,
};

struct DateTimeFormatFlags__Enum__Boxed {
    struct DateTimeFormatFlags__Enum__Class *klass;
    MonitorData *monitor;
    DateTimeFormatFlags__Enum value;
};
Arrays

.NET arrays are represented by the Il2CppArray class:

typedef struct Il2CppArray
{
    Il2CppObject obj;
    Il2CppArrayBounds *bounds;
    il2cpp_array_size_t max_length;
    __declspec(align(8)) void* vector[32];
} Il2CppArray;

The vector member contains the array elements themselves. Note that the value of 32 is just an artifact of how IL2CPP declares arrays and can be safely ignored. Il2CppInspector defines strongly-typed arrays as types with the “__Arrray” suffix, for example here is int[] (System.Int32[]):

struct Int32 {
    int32_t m_value;
};

struct Int32__Boxed {
    struct Int32__Class *klass;
    MonitorData *monitor;
    int32_t fields;
};

struct Int32__Array {
    struct Int32__Array__Class *klass;
    MonitorData *monitor;
    Il2CppArrayBounds *bounds;
    il2cpp_array_size_t max_length;
    int32_t vector[32];
};
Strings

IL2CPP does not use a translated equivalent of System.String for string handling – in fact, if you try to call a constructor on the C++ compilation of System.String it will deliberately throw an exception. Instead, IL2CPP uses the Il2CppString struct to represent .NET strings:

typedef struct Il2CppString
{
    Il2CppObject object;
    int32_t length;
    Il2CppChar chars[32];
} Il2CppString;

The chars member contains the string data and once again the “32” can be ignored. Il2CppChar is typedef’d as uint16_t and strings are stored with UTF-16 encoding.

However, let us take a look at the generated type definition for the real System.String type:

struct __declspec(align(8)) String__Fields {
    int32_t m_stringLength;
    uint16_t m_firstChar;
};

struct String {
    struct String__Class *klass;
    MonitorData *monitor;
    struct String__Fields fields;
};

Astute readers familiar with C++ struct memory layouts may notice that Il2CppString::length and String::m_stringLength are of the same type and have the same offset from the start of the type. Similarly, Il2CppString::chars and String::m_firstChar have the same type and the same offset.

This is not a coincidence. Il2CppString and the C++ equivalent of System.String can be cast to each other, enabling Il2CppString to be passed to C++-equivalent .NET methods, and System.Strings to be passed to IL2CPP API functions. This is a key piece of knowledge that will make your life much easier when dealing with strings!

Tip: Sometimes you may just want a C++ std::string for logging purposes or manipulation with native C++ functions. Il2CppInspector provides a helper function in scaffolding projects called il2cppi_to_string which accepts an Il2CppString* or String* and returns a std::string. Strings converted this way are re-encoded as UTF-8.

Naming conventions in scaffolding projects

All generated types and function pointers are placed in the app namespace to avoid naming conflicts with items in the global namespace. Invalid C++ identifier characters such as . (dot), < and > (chevrons enclosing generic type parameters) are replaced with underscores.

Class and object structs are given the unqualified name of the C# type. Naming conflicts are resolved by adding an underscore and a numeric suffix, eg. if there are two types in different namespaces called Foo, they will be defined as Foo and Foo_1.

Generic types produced by the C# compiler use a backtick suffix with the generic type’s arity to distinguish between them, eg. List<T> is given the name List`1 and Dictionary<TKey, TValue> is named Dictionary`2. The names are followed by the fully-qualified generic type arguments in square brackets. This means that a List<int> in C++ will take the IL name List`1[System.Int32] and become List_1_System_Int32_, and Dictionary<double, string> will take the IL name Dictionary`2[System.Double,System.String] and become Dictionary_2_System_Double_System_String_. This is fiddly at first but you will quickly grow accustomed to it.

Note: Nested types inside generic types do not inherit the arity suffix from the declaring type. The name of the nested type Foo<T1,T2>.Bar is Bar, not Bar`2, even though the C# compiler will generate Bar as the generic type Bar<T1,T2>. If a nested type adds new generic type parameters, it will once again get an arity suffix. The nested type Baz in Foo<T1,T2>.Baz<T3> will be compiled with three generic type parameters, but will be named Baz`1.

Instance field objects have the type’s struct name folowed by __Fields.

Static field objects have the type’s struct name followed by __StaticFields.

Vtable objects have the type’s struct name followed by __VTable.

Here is a complete example for System.IO.FileStream (some members are elided for brevity):

struct FileStream__Fields {
    struct Stream__Fields _;
    struct Byte__Array *buf;
    struct String *name;
    struct SafeFileHandle *safeHandle;
    // ...
};

struct FileStream {
    struct FileStream__Class *klass;
    MonitorData *monitor;
    struct FileStream__Fields fields;
};

struct FileStream__VTable {
    VirtualInvokeData Equals;
    VirtualInvokeData Finalize;
    VirtualInvokeData GetHashCode;
    VirtualInvokeData ToString;
    // ...
};

struct FileStream__StaticFields {
    struct Byte__Array *buf_recycle;
    struct Object *buf_recycle_lock;
};

struct FileStream__Class {
    Il2CppClass_0 _0;
    Il2CppRuntimeInterfaceOffsetPair *interfaceOffsets;
    struct FileStream__StaticFields *static_fields;
    const Il2CppRGCTXData *rgctx_data;
    Il2CppClass_1 _1;
    struct FileStream__VTable vtable;
};

Methods are named with the unqualified type name followed by an underscore and the method name. Naming conflicts are resolved by adding an underscore and a numeric suffix. For example, System.IO.Path contains several overloads of the Combine method; these are named Path_Combine, Path_Combine_1, Path_Combine_2 etc.

Methods and Properties

Compiled C++ code has no concept of classes so all methods are global. The first argument to a non-static class method will always be the instance of the type on which the method is being called – this is normal for C++ compiled code. In addition, IL2CPP adds a MethodInfo* parameter as the final argument to each method (including static methods) – we’ll come back to this in a moment.

For example, here is how System.IO.FileStream.WriteByte is defined by Il2CppInspector:

void Stream_WriteByte(Stream *__this, uint8_t value, MethodInfo *method);

Instance constructors in .NET are compiled with the special name .ctor, and static constructors are named .cctor. The System.IO.StreamReader constructors are defined as follows:

// equivalent of StreamReader()
void StreamReader__ctor(StreamReader *__this, MethodInfo *method);

// equivalent of StreamReader(Stream)
void StreamReader__ctor_1(StreamReader *__this, Stream *stream, MethodInfo *method);

// ...

Properties are accessed via getter and setter methods. These are named as follows, using System.IO.FileStream.Position as an example:

int64_t FileStream_get_Position(FileStream *__this, MethodInfo *method);
void FileStream_set_Position(FileStream *__this, int64_t value, MethodInfo *method);

Some methods share the same compiled code; these are called shared methods. If the method you are calling is a shared method, you must provide a MethodInfo* – a reference to the original method definition – so that the compiled code knows which implementation you want. We demonstrate how to do this in part 2.

Vtable methods

.NET supports polymorphism, which allows methods declared as virtual or abstract in a base class – or methods declared in an interface – to be overridden and reimplemented in derived classes. When a method with overrides is called, the correct implementation for the object’s type is executed regardless of the type reference we are using. Consider the following classical example:

class Actor
{
    public virtual string Name() => "An in-game actor";
}

class Player : Actor
{
    public override string Name() => "The player";
}

class Enemy : Actor
{
    public override string Name() => "An enemy";
}

class DoSomething
{
    public void DoWork() {
        Actor player = new Player();
        Actor enemy = new Enemy();

        Console.WriteLine(player.Name()); // Outputs "The player"
        Console.WriteLine(enemy.Name());  // Outputs "An enemy"
    }
}

Despite the fact we have declared player and enemy as Actor objects, when we call Name(), the true underlying type’s Name method is called. This is the essence of polymorphism, but how does this magic work? The answer lies in vtables.

When Name() is invoked, Actor.Name is not called directly. Under the hood, each type has a table – the vtable – of all of the virtual methods defined, inherited or overridden by the type, with pointers to the correct implementations for the type. When a virtual method is invoked, the vtable is consulted to find the correct implementation to call.

Prior to Unity 5.3.6, the vtable for a type was a simple list of function addresses. From Unity 5.3.6 onwards, this was changed to a VirtualInvokeData for each vtable entry:

typedef void(*Il2CppMethodPointer)();

typedef struct VirtualInvokeData
{
    Il2CppMethodPointer methodPtr;
    const MethodInfo* method;
} VirtualInvokeData;

methodPtr contains the function address of the vtable method, and method contains its corresponding MethodInfo*. This allows vtable methods to also be shared methods.

The vtable for Actor in the example above looks like this when compiled with IL2CPP:

struct Actor__VTable {
    VirtualInvokeData Equals;
    VirtualInvokeData Finalize;
    VirtualInvokeData GetHashCode;
    VirtualInvokeData ToString;
    VirtualInvokeData Name;
};

The first four vtable entries refer to method overrides inherited by every class from System.Object. The final vtable entry refers to our Name method. The vtables for Player and Enemy look exactly the same, as you would expect. A type’s vtable can be accessed via the Il2CppClass::vtable field. This is normally an array of VirtualInvokeData, but Il2CppInspector replaces it with individual named entries as shown above so that you don’t need to know the vtable index for a method. These vtable container types are given the suffix __VTable.

About icalls

IL2CPP does not re-compile everything in the .NET base class library. Some types and methods are substituted with native implementations known as icalls. You can find these in the libil2cpp/icalls folder of any IL2CPP installation. For example, IL2CPP provides a native implementation of System.Net.Dns.GetHostByName:

Generally, the process of making icalls is seamless – you merely call a .NET method implementation as normal, and the .NET methods are compiled as thunks which make the icall for you. However, it’s useful to know about this behaviour for two reasons: first, calling a static method on a type which is substituted with an icall will not result in the type’s static constructor (if any) being executed if it hasn’t already. For example, the Mono method System.Environment.GetOSVersionString is implemented in libil2cpp/icalls/mscorlib/System/Environment.cpp as:

Il2CppString * Environment::GetOSVersionString(void)
{
    return il2cpp::vm::String::New(il2cpp::os::Environment::GetOsVersionString().c_str());
}

Calling Environment_GetOSVersionString() will redirect to this call, bypassing any static constructors in Environment.

Secondly, some methods are purposely disabled by IL2CPP, such as string constructors mentioned earlier. For example, IL2CPP prevents you from creating a new AppDomain:

Il2CppAppDomain* AppDomain::createDomain(Il2CppString*, mscorlib_System_AppDomainSetup*)
{
    NOT_SUPPORTED_IL2CPP(AppDomain::createDomain, "IL2CPP only supports one app domain, others cannot be created.");
    return NULL;
}

This will throw an unhandled exception in your DLL and cause the application to crash, so it can be very perplexing if you’re not aware of it! If you get unexpected crashes when calling specific methods, check the IL2CPP source code to see if there is a native implementation.

Native types

Similarly to Il2CppString, IL2CPP implements a considerable number of other native C++ structs to represent certain key .NET types. As in the examples of Environment.GetOSVersionString and AppDomain.CreateDomain above where Il2CppString* is substituted in place of System.String in the method arguments, these native structs replace various other .NET types in compiled methods.

Some key objects are:

There are numerous others, they all begin with Il2Cpp and can be found at the top of your generated appdata/il2cpp-types.h file before any application-specific types are defined.

Generic types

When a .NET application is compiled, the IL metadata contains all the generic type definitions defined in the source code. This enables you to use .NET Reflection’s MakeGenericType() method to create new concrete instantiations of a generic type with specified generic type arguments.

Compiled C++ code does not work like this. When C++ templates (the equivalent of .NET generics) are processed by the compiler, only the actual concrete types used by the source code are compiled. IL2CPP handles .NET generics in a similar way. For example, if an application uses two instantiations of List<T> – say List<int> and List<float> – IL2CPP will generic concrete non-generic types for each of these, and they will be the only instantiations of List<T> available in the application by default.

The IL2CPP metadata does contain all of the generic type definitions from the source code, and IL2CPP substitutes MakeGenericType with an icall providing support to generate concrete types not in the original application, however you will not be able to use Il2CppInspector’s auto-generated type definitions and method prototypes to access these types.

Visibility (access modifiers)

Compiled code has no concept of access modifiers like private or protected, so every field, property and method is considered public and available to use.

Bytecode stripping

By default, applications compiled with Unity are run through a so-called bytecode stripper which removes all of the types not used by the application. This means that you may find some types present in the .NET base class library are unavailable. Generally this is of limited impact, but it is something to be aware of if you discover a type you wish to use is not present. There is not much you can do about this besides implementing replacement code yourself.

Metadata usages

One final concept is key to understanding how an IL2CPP application works: metadata usages.

IL2CPP maintains a table of various kinds of items that have been initialized: type definitions (TypeInfo), type references (Il2CppType), method definitions and references (MethodInfo), field definitions (FieldInfo) and string literals (StringLiteral). Each entry in the table is represented by a metadata token. When the application starts, nothing is initialized. Each time a .NET method equivalent is called for the first time, IL2CPP consults the list of items that the method requires to be initialized before it can execute (the method’s metadata usage range), initializes them, and stores a pointer to each initialized item. This is called lazy initialization and improves the startup time of the application at the expense of slightly slower first-time execution of each method.

The two most important items are the type definitions and method references. When you access a type’s static members, you look up the TypeInfo entry of the type in the metadata usages table to get its Il2CppClass object. You may also recall that the final parameter of each method is a MethodInfo*. To find this object, you look up the MethodInfo entry of the corresponding method.

The upshot of this is you cannot access static members of a class, or methods which require a MethodInfo* argument, until the corresponding metadata usages have been initialized. If you attempt to do this, the application will crash. This is not normally a problem, but depending on how early you inject the DLL, some items may not be initialized yet, so it is something to bear in mind as it can catch you out.

Tip: Il2CppInspector generates named pointer variables to all of these items so you don’t actually have to perform the lookup yourself – for example if you want the Il2CppClass* for a type Foo you can just use *Foo__TypeInfo and so on. If you need the MethodInfo* for BigInteger.ToString, use *BigInteger_ToString__MethodInfo.

Tip: Prior to Unity 2020.2 (metadata v27), the metadata usage tokens were stored in global-metadata.dat and the initialized pointers were created in memory at runtime. Starting with Unity 2020.2, metadata usages are stored in a table in the binary, and the initialization step replaces the token in-place with a pointer to the relevant struct; IL2CPP sets the bottom bit of every token so it can tell which table entries are initialized pointers and which are tokens. Il2CppInspector provides a helper method bool il2cppi_is_initialized(T* metadataItem) which will accept a pointer to a metadata usage (token, pointer or uninitialized memory) and determine whether it is initialized, regardless of Unity version. You can use this to check for uninitialized items before attempting to use them.


PART 2: IL2CPP scaffolding code examples

Now we’ve got the theory under our belt, let’s crack on with the good stuff – some working code!

Before you begin

Many IL2CPP methods require some back-end thread structures to be set correctly or they will throw an access violation exception. Place the following at the start of your code to make sure everything is set up:

il2cpp_thread_attach(il2cpp_domain_get());

Finding the starting point

Usually when we want to access something in the target application, we have to find it via a pointer chain which starts with a static field or property. We don’t know where the object instances have been allocated in memory yet, but we can use the TypeInfo metadata usages – which have a fixed address relative to the image base – to get the static fields of any initialized type.

Let’s suppose we have decided we want infinite health in the beautiful yet grueling single-player open-world survival game The Long Dark. We’ve identified a type containing the player’s health:

public class Condition : MonoBehaviour
{
	public float m_CurrentHP;
	public float m_MaxHP;
	public float m_CriticalHP;
	public float m_TimeToDisplayConditionWhenChanged;
	public float m_TimeToDisplayConditionDelta;
    // ...
}

We don’t know where this object is in memory, but we can skim the C# or DLL output of Il2CppInspector to find every type which uses Condition. A quick search for static Condition gives us a fast answer:

public class GameManager : MonoBehaviour
{
    // ...
    private static BrokenRib m_BrokenRib;
	private static CabinFever m_CabinFever;
	private static Condition m_Condition;
	private static ConditionTableManager m_ConditionTableManager;
	private static DownsampleAurora m_DownsampleAurora;
    // ...
}

Excellent! Likely all we need to do is set m_CurrentHP to m_MaxHP periodically. How?

Static fields

This is quite straightforward:

  1. Use the TypeInfo metadata usage entry to fetch GameManager (an Il2CppClass* typed as GameManager__Class*)
  2. Use the static_fields field to fetch GameManager‘s static fields (typed as GameManager__StaticFields*)
  3. Fetch the player’s condition object (an Il2CppObject* typed as Condition*) from the static field GameManager.m_Condition

The final code is a one-liner:

auto condition = (*GameManager__TypeInfo)->static_fields->m_Condition;

Instance fields

Set one instance field to the value of another instance field to replenish the player’s health, by using the fields field (typed as Condition__Fields – note, not a pointer) of Il2CppObject:

condition->fields.m_CurrentHP = condition->fields.m_MaxHP;

The complete example looks like this:

while (true) {
  if (il2cppi_is_initialized(GameManager__TypeInfo)) {
    auto condition = (*GameManager__TypeInfo)->static_fields->m_Condition;

    condition->fields.m_CurrentHP = condition->fields.MaxHP;

    // Let's give ourselves infinite sprint while we're at it!
    if (il2cppi_is_initialized(PlayerMovement__TypeInfo)) {
      (*PlayerMovement_TypeInfo)->static_fields->m_UnlimitedSprint = true;
    }
  }
  Sleep(100);
}

This resets the player’s health to the maximum possible amount every 100ms. Note that when the application is updated, you can regenerate the scaffolding project in place – main.cpp where this code goes will not be replaced – and recompile without any code changes. You don’t need to konw anything about the memory layout of the application. This is the beauty of the scaffolding project.

Calling methods

Non-shared methods do not require an instance of MethodInfo* so you can just pass nullptr:

auto osVersionString = Environment_GetOSVersionString(nullptr);

For instance methods, the first argument should be the object we are calling the method on, for example if we have a Vector3__Boxed called myVector3:

auto vectorAsString = Vector3_ToString(myVector3, nullptr);

Shared methods require a MethodInfo* argument as outlined in part 1. You can use the global variables defined with the suffix __MethodInfo to retrieve these structs. For example, to create an empty array of double (System.Double):

auto emptyDoubleArray = Array_Empty_12(*Array_Empty__MethodInfo);

(in this specific example project, Array_Empty_12 returns a Double__Array*. Different projects will have different numerical suffixes for their overloads – unfortunately)

If we look at il2cpp-functions.h we can see that many methods share this same code:

// ...
DO_APP_FUNC(0x00D20990, KeyValuePair_2_System_Guid_System_Object___Array *, Array_Empty_10, (MethodInfo * method));
DO_APP_FUNC(0x00D20990, KeyValuePair_2_System_Object_System_Object___Array *, Array_Empty_11, (MethodInfo * method));
DO_APP_FUNC(0x00D20990, Double__Array *, Array_Empty_12, (MethodInfo * method));
DO_APP_FUNC(0x00D20990, Int32__Array *, Array_Empty_13, (MethodInfo * method));
DO_APP_FUNC(0x00D20990, UInt32__Array *, Array_Empty_14, (MethodInfo * method));
DO_APP_FUNC(0x00D20990, SequenceNode_SequenceConstructPosContext__Array *, Array_Empty_15, (MethodInfo * method));
DO_APP_FUNC(0x00D20990, EventDispatcher_DispatchContext__Array *, Array_Empty_16, (MethodInfo * method));
// ...

Notice how all of these methods have the same function pointer. Hence, we must supply the MethodInfo* to differentiate them. If you call a method which does not share its function with any other methods, the method is not shared and MethodInfo* can be set to nullptr.

Creating an object

Creating a .NET-equivalent object in IL2CPP is a two-step process. First you must allocate memory for the object and initialize it using the API call il2cpp_object_new, then call its constructor:

auto myVector3 = (Vector3__Boxed*) il2cpp_object_new((Il2CppClass *) *Vector3__TypeInfo);

Here we create a new Vector3 object. il2cpp_object_new takes an Il2CppClass* and returns an Il2CppObject*, so we must cast the type definition and the returned struct. Since Vector3 is a value type, we will receive the boxed object instance, therefore we must cast to Vector3__Boxed*.

Now we call whichever constructor on the object we want to use:

Vector3__ctor(myVector3, 1.0f, 2.0f, 3.0f, nullptr);

Notice that .NET method arguments with primitive types such as float get translated directly to their C++ equivalents. The constructor does not need a MethodInfo* so we once again pass nullptr.

We can now prove the object has been created correctly by accessing its instance fields:

std::cout << "x: " << std::to_string(myVector3->fields.x)
        << ", y: " << std::to_string(myVector3->fields.y)
        << ", z: " << std::to_string(myVector3->fields.z)
        << std::endl;

Working with strings

As mentioned in part 1, String* and Il2CppString* are tacitly equivalent, so you can cast between them with reinterpret_cast:

// Get an example string that will definitely be present in every IL2CPP app
auto systemString = Environment_GetOSVersionString(nullptr); // returns app::String*

// The two are somewhat interchangeable
auto il2cppString = reinterpret_cast<Il2CppString*>(systemString);

The following statements are therefore semantically equivalent and access the same memory location to retrieve the string’s length:

// Both of these will give the correct length regardless of the true underlying type
std::cout << "System.String " << systemString->fields.m_stringLength << std::endl;
std::cout << "IL2CPPString " << il2cppString->length << std::endl;

The il2cppi_to_string helper function provided by Il2CppInspector can be called with either type:

// Conversion functions are provided for both Il2CppString* and System.String* (app::String*)
std::cout << il2cppi_to_string(systemString) << std::endl;
std::cout << il2cppi_to_string(il2cppString) << std::endl;

To create a string, you cannot use System.String‘s constructors – these are redirected to icalls that throw exceptions. Instead, you should use the internal Mono function String.CreateString. This function has many overloads accepting various types of pointer and array; an easy one to use accepts a uint16_t* to a Unicode string and can be called as follows:

// Creating a System.String*
auto new_system_str = String_CreateString_2(nullptr, (uint16_t *) u"a new System.String", nullptr);

(the source code linked above states that the this pointer should not be used, so we set it to nullptr)

However, if you don’t need any special functionality from the overloads, there is an easier way. Just call the IL2CPP API il2cpp_string_new:

// Creating an Il2CppString*
auto new_il2cpp_str = il2cpp_string_new("a new Il2CppString");

Since String and Il2CppString are equivalent, you can use the return value of il2cpp_string_new in .NET-equivalent String methods. Both of the following lines are equivalent to calling myString.Replace('S', 'X'):

// You can use either type as a System.String argument (or write to a System.String field in a class)
new_il2cpp_str = reinterpret_cast<Il2CppString*>(String_Replace(reinterpret_cast<String*>(new_il2cpp_str), u'S', u'X', nullptr));
new_system_str = String_Replace(new_system_str, u'S', u'X', nullptr);

// Output is the same
std::cout << il2cppi_to_string(new_system_str) << std::endl;
std::cout << il2cppi_to_string(new_il2cpp_str) << std::endl;

Working with generic types

Generic types behave like regular types in IL2CPP. Let’s start by creating an IEnumerable<int> with the static method Enumerable.Range:

// Returns IEnumerable_1_System_Int32_ *
// Equivalent to IEnumerable<int> enumerable = Enumerable.Range(1, 10);
auto enumerable = Enumerable_Range(1, 10, nullptr);

Let’s now pass this IEnumerable<int> to the constructor of List<int> to copy it into a new list. First, initialize the object:

// Create a List<int>
auto myList = (List_1_System_Int32_*) il2cpp_object_new((Il2CppClass*) *List_1_System_Int32___TypeInfo);

Now we need to call the constructor. This is a shared method, so this time we’ll need the corresponding MethodInfo* as the last argument:

// This is equivalent to var myList = new List<int>(enumerable);
List_1_System_Int32___ctor_1(myList, enumerable, *List_1_System_Int32___ctor_1__MethodInfo);

If all went well, our list should now contain 10 items:

std::cout << myList->fields._size << std::endl; // should be 10

We use a normally private field _size here for speed, but if you prefer you can instead fetch the Count property as you normally would in C#:

std::cout << List_1_System_Int32__get_Count(myList, nullptr) << std::endl;

Iterating over collections

The standard foreach loop we use in C# for collection iteration does quite a lot of magic behind the scenes, but in a nutshell, a loop like:

foreach (var item in myCollection)
  Console.WriteLine(item);

is approximately (when ignoring IDisposable and exceptions) semantically equivalent to:

using (var enumerator = myCollection.GetEnumerator()) {
  while (enumerator.MoveNext()) {
    item = enumerator.Current;
    Console.WriteLine(item);
  }
}

This is therefore the mechanism we need to use in C++ to iterate collections that implement IEnumerable. Unfortunately, MoveNext is frequently inlined during compilation so there is not always an easy way for us to call it. Although this can’t be worked around for every possible collection type, for commonly used collections such as List<T> which are backed by an array, there is – luckily – an easier way. We can simply fetch the backing array an iterate over it with a simple for loop:

auto items = myList->fields._items->vector;

for (int i = 0; i < myList->fields._size; i++)
    std::cout << items[i] << std::endl;

For the above list example, this will print the numbers 1-10.

Calling vtable methods

Let’s go back to our Vector3 example. Naturally, if you know exactly which implementation of a virtual method you want, and you know the underlying type, you can just call the method directly. For example, Vector3.ToString():

String* pszVector3 = Vector3_ToString(myVector3, nullptr);

std::cout << il2cppi_to_string(pszVector3) << std::endl;

Let’s pretend we don’t know we are handling a Vector3, but we want to call ToString() on the object. Here is how we call the ToString vtable method:

// Pretend we are dealing with an unknown type derived from System.Object
auto someObject = (Object*) myVector3;

// Call object.ToString() (which will actually invoke Vector3.ToString())
auto toString = (String*(*)(Object*, MethodInfo*)) someObject->klass->vtable.ToString.methodPtr;
auto toString_MethodInfo = (MethodInfo *) someObject->klass->vtable.ToString.method;
auto pszSomeObject = toString(someObject, toString_MethodInfo);

First, we fetch the pointer to the object’s ToString implementation and cast it to the correct method signature, storing it in toString.

Secondly, we fetch the MethodInfo* for the call into toString_MethodInfo.

Finally, we call the function, passing in our object and the MethodInfo*, which will ultimately invoke Vector3.ToString().

Info: We know this is rather unruly! A future version of Il2CppInspector will generate strongly-typed vtable function pointers and helpers to clean this up.

Managed exception handling

You can enclose C++ code in try...catch blocks as you would in C#. If an exception is thrown, IL2CPP will package it up into an Il2CppExceptionWrapper which you can catch as follows in this example which attempts to call File.OpenRead on a file that doesn’t exist, throwing a System.IO.FileNotFoundException:

try {
    auto someFile = (String*) il2cpp_string_new("some file that doesn't exist");
    File_OpenRead(someFile, nullptr);
}
catch (Il2CppExceptionWrapper& e) {
    // do something with the exception
}

Il2CppExceptionWrapper contains a single field ex which is a pointer to Il2CppException. The message field contains the message normally found in System.Exception.Message:

catch (Il2CppExceptionWrapper& e) {
    auto ex = e.ex;
    std::cout << il2cppi_to_string(ex->message) << std::endl;
}

If you need fields from the underlying .NET exception, you have to inspect the exception name and perform a cast to the corresponding managed exception class. The following example outputs the name of the missing file (System.IO.FileNotFoundException._fileName):

auto exName = ex->object.Il2CppClass.klass->name;

if (!strcmp(exName, "FileNotFoundException")) {
    auto exFNF = (FileNotFoundException*) (&ex->object);
    auto missingFile = exFNF->fields._fileName;
    std::cout << il2cppi_to_string(missingFile) << std::endl;
}

Il2CppException.object contains the managed exception object. Note that we use strcmp to compare for equality because we are comparing a const char * to a std::string and a pointer comparison with == will always return false.

Inspecting class definitions

We have already looked at a few fields of Il2CppClass such as static_fields, vtable and name. The fields of Il2CppClass are somewhat reminiscent of those in the managed type System.Type, but with extra C++ and IL2CPP-specific information.

You can always retrieve the class definition of an object (Il2CppObject) via its klass field.

The definition of Il2CppClass is too long to reproduce here so I recommend you check out the definition near the top of the generated appdata/il2cpp-types.h file if you’re interested. Among other things, you can access the type’s name, namespace, source assembly, underlying element type (for arrays, pointers and references), declaring type (for nested types), parent type (for derived types), generic type definition (for concrete generics), and inspect the list of fields, properties, methods, events, nested types, implemented interfaces and so on. This can be very useful for walking the type hierarchy at runtime.

Using the IL2CPP APIs

IL2CPP provides a suite of APIs that can be used to simplify certain tasks. All of the API names begin with il2cpp_ and we’ve already seen a few of them such as il2cpp_object_new and il2cpp_string_new. The available APIs differ between Unity versions and individual applications; the APIs available in your project will be defined in the generated file appdata/il2cpp-api-functions.h.

The scope of what can be performed with these APIs is too large to cover in one article, but we can kickstart with a couple of examples.

Here is how to iterate over all of the assemblies in the application:

Il2CppDomain* domain = il2cpp_domain_get();
size_t size = 0;
const Il2CppAssembly** assemblies = il2cpp_domain_get_assemblies(domain, &size);

for (auto i = 0; i < size; i++) {
    std::cout << assemblies[i]->aname.name << '\n';
}

See https://github.com/djkaty/Il2CppInspector/issues/39#issuecomment-661808065 for a more complete example of this.

This code from beatsaber-hook is a more complex example which shows how to create generic types with IL2CPP APIs (the APIs have been abstracted into the il2cpp_functions namespace and stripped of the leading il2cpp_ prefix in this example).

Note: Some applications strip most or all API exports besides il2cpp_init which is required by UnityPlayer.dll. Others will encrypt the API export names to defeat casual hackers. Different Unity versions have also occasionally changed the API arguments. Il2CppInspector will make a best effort to decrypt API exports and remove unavailable exports from the header files to avoid compilation failures. It will also try to determine the Unity version from the file contents and generate the correct method signatures for you. You can guarantee the correct API headers are generated by telling Il2CppInspector exactly which Unity version was used to compile the application.

Some applications also replace API exports with dummy functions that do nothing, so be aware of this. Note that almost none of the APIs are required and you can almost always accomplish the same tasks using the generated type and metadata usage headers.

The final word

Scaffolding is a powerful tool but requires some finesse to get right. Much more can be said about the techniques and possibilities available, but I hope this whirlwind tour provided a handy crash course on your path to interacting with IL2CPP applications from C++. Happy hacking!

Advertisement
Categories: IL2CPP Tags:
  1. Jesse
    March 22, 2021 at 19:17

    Hi katy!

    I used Riru-Il2CppDumper(https://github.com/Perfare/Riru-Il2CppDumper) to create a dump.cs while game is runnng, one of the classes is defined as follows:
    public class DialogTeamStadiumRaceResultList : DialogTeamStadiumRaceInfo // TypeDefIndex: 12843
    {
    // Fields
    private RectTransform _itemParent; // 0x48
    private RaceResultContainer _item; // 0x50
    private ButtonCommon _closeButton; // 0x58
    private ButtonCommon _replayButton; // 0x60
    private readonly List`1 _itemList; // 0x68
    private Int32 _roundNum; // 0x70
    private static DialogCommon _dialog; // 0x0

    // Methods
    // RVA: 0x4e5440 VA: 0x7fffde835440 Slot: 8
    protected override Data CreateDialogData() { }
    // RVA: 0x4e54b0 VA: 0x7fffde8354b0 Slot: 4
    public override FormType GetFormType() { }
    // RVA: 0x4e5c40 VA: 0x7fffde835c40
    public static Void PushDialog(RaceResult raceResult, ITrainedCharaDataAccessor accessor, Int32 classId, Int32 roundNum, Boolean withReplay, Action`1 onClickReplay) { }
    // RVA: 0x4e6090 VA: 0x7fffde836090
    private Void Setup(RaceResult raceResult, ITrainedCharaDataAccessor accessor, Int32 classId, Int32 roundNum, Boolean withReplay, Action`1 onClickReplay) { }
    // RVA: 0x4e5d60 VA: 0x7fffde835d60
    private Void SetRaceHorseList(List`1 raceHorseList, Boolean isDispUserName) { }
    // RVA: 0x4e54c0 VA: 0x7fffde8354c0
    private static List`1 GetHorseDataList(RaceResult raceResult, ITrainedCharaDataAccessor accessor) { }
    // RVA: 0x4e5af0 VA: 0x7fffde835af0
    private Void OnClickReplay(Action`1 onClickReplay) { }
    // RVA: 0x4e5aa0 VA: 0x7fffde835aa0
    private Void OnClickClose() { }
    // RVA: 0x4e62e0 VA: 0x7fffde8362e0
    public Void .ctor() { }
    

    }

    But what I expected was a C ++ scaffolding code. my question is have you ever written any tools that can convert such C# source code -only contains declaration of class and methods- into CPP source code with c++ scaffolding format ? Or what part of the IL2CppInspector repo should I refer to if I want to implement such tool?

  2. Able
    April 2, 2021 at 06:35

    Wow! Browsing this has really blown my mind. Something I don’t yet understand from the docs is how to handle base types. Consider this (C#):

    class Sequence : IBufferWriter { … }

    class MessagePackWriter
    {
    MessagePackWriter(IBufferWriter writer) { … }

    }

    var sequence = new Sequence<System.Byte>();
    var mpw = new MessagePackWriter(sequence);

    In C# this just works.

    The equivalent in a C++ DLL injection project looks like this:

    auto sequence = (Sequence_1_System_Byte_*) il2cpp_object_new(…
    Sequence_1_System_Byte___ctor_1(sequence,…

    auto mpw = (MessagePackWriter__Boxed*) il2cpp_object_new(…
    MessagePackWriter__ctor(mpw, sequence, nullptr);

    That last line doesn’t compile, as it expects a IBufferWriter_1_System_Byte_ as the second argument. If I cast sequence to the expected type I get a memory access violation.

    Given the sequence object, how do I get the correct memory address for the base type which I can pass to MessagePack’s ctor?

    • Able
      April 3, 2021 at 18:02

      So, after looking at the generated source code, it seems casting the IL2CPP representation of the derived type to its IL2CPP representation of the derived type should work.

      Not sure where the access violation is coming from. Will have to dig deeper.

      • Able
        April 3, 2021 at 20:06

        Okay, so I don’t understand what is going on. Even this throws a memory access violation:

        void Run()
        {
        Byte__Array* emptyByteArray =
        app::Array_Empty_7(*app::Array_Empty_7__MethodInfo);
        }

        Any insight as to what might be going wrong is very much appreciated. Next step would be to get out a disassembler which I don’t have.

      • Able
        April 3, 2021 at 21:46

        Wish I could edit. Should read:

        So, after looking at the generated source code, it seems casting the IL2CPP representation of the derived type to the IL2CPP representation of its BASE type should work.

        Not sure where the access violation is coming from. Will have to dig deeper.

    • Flafy
      July 27, 2021 at 00:26

      Did you manage to do it?

      • Flafy
        July 27, 2021 at 00:34

        Nevermind I couldn’t cast because I didn’t cast to a pointer

  3. Doza123
    April 2, 2021 at 18:28

    Hello, I’m confused. I have a BaseEntity class and in this class there is a variable array: public static IDictionary <long, BaseEntity> Entitys; I tried to restore the class and it kind of worked out for me.
    static IDictionary_2_System_Int64_BaseEntity_ * GetEntitys ()
    {
    if (il2cppi_is_initialized (BaseEntity__TypeInfo))
    {
    return (* BaseEntity__TypeInfo) -> static_fields-> Entitys;
    }
    }

    IDictionary_2_System_Int64_BaseEntity_ * entitysMassive = BaseEntity_SOTA :: GetEntitys ();
    but if I access the pointer to my entitysMassive->, then there are no fields, there is only monitor and class, but where can I get the fields I don’t understand

    • Doza
      April 2, 2021 at 18:30

      I also tried this:
      for (int i = 0; i <25; i ++)
      {
      Transform * tr = BaseEntityClass [i] .fields._Eya_k__BackingField;
      }
      but how can I refer to Transform :: Position I don’t understand, I need from the resulting Transform Eya; get position

  4. Flafy
    July 22, 2021 at 16:22

    Hey, thanks this was very helpful.
    Is it possible to monkey patch a method of a class?
    Thanks

  5. kaster
    October 18, 2021 at 00:21

    I noticed some Unity functions are also implemented as icalls:

    SceneManager.GetActiveScene();
    

    translates to

    il2cpp_codegen_resolve_icall ("UnityEngine.SceneManagement.SceneManager::INTERNAL_CALL_GetActiveScene(UnityEngine.SceneManagement.Scene&)");
    

    I can’t seem to find where the icall is registered in the Il2Cpp output project. Is it called from UnityPlayer.dll (since il2cpp_add_internal_call is an export)?

    Also what does UnityPlayer.dll usually contain, and is it also compiled by Il2Cpp? If so, where is the Il2Cpp output for UnityPlayer.dll placed when I build the project in Unity?

    BTW very awesome work w/ Il2cppinspector and great explanation on the blog.

  6. bobh
    October 20, 2021 at 11:25

    Hi,Katy. I want to know if I want to use equivalent code like typeof(GameObject) in il2cpp scaffold, how can I get the type?

  7. bbk
    October 23, 2021 at 06:35

    Then is it possible to replace a method in class like hook or something?

  1. January 14, 2021 at 03:40
  2. January 21, 2021 at 21:54
  3. March 22, 2023 at 04:30

Share your thoughts! Note: to post source code, enclose it in [code lang=...] [/code] tags. Valid values for 'lang' are cpp, csharp, xml, javascript, php etc. To post compiler errors or other text that is best read monospaced, use 'text' as the value for lang.

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.

%d bloggers like this: