About Me

Game Hacks: Among Us – IL2CPP Walkthrough

Table of Contents

Introduction

Among Us is a game developed using Unity and compiled with IL2CPP, a tool that converts the intermediate language (C#) into native C++ code. This process has the benefits of increased efficiency and allows the game to more easily support a wider range of platforms. In this blog post, we will discuss how to reverse engineer IL2CPP games and create an exploit that reveals the impostor in Among Us. While we will delve into multiple technical aspects of IL2CPP, we won’t cover all of its intricacies. If you want to understand how IL2CPP works, I highly recommend reading Katy’s blog as she created il2CppInspector, a tool we’ll be relying on in this post.

Reversing Among Us

CPP2IL

We know that Among Us is built on Unity, you can verify this by checking the directory where the executable resides.

Root Among Us Directory

Furthermore there are hints within the Among Us_Data Folder that this is a IL2CPP game.

Among Us_Data Directory

Now that we have verified this is a IL2CPP game we can boot up il2CppInspector.

il2CppInspector Launch Screen

There are two files you want to drag into the Option 1 box. The first file located under Root Among Us\Among Us_Data\il2cpp_data\Metadata is called global-metadata.dat. Note: Some games may encrypt or obfuscate their global-metadata.dat file, in such case you will need to reverse the decryption stub to obtain the plaintext global-metadata.dat file to be able to continue.

Global Metadata
il2CppInspector after Dragging in Global Metadata

The next folder you want to drag in is GameAssembly.dll located under Root Among Us Directory

IL2CPP binary File

So why these two files? The GameAssembly.dll the heart of the game. This is where all the logic to Among us is stored. When Among Us.exe is executed, it will load the UnityPlayer.dll which will Initialize IL2CPP, load GameAssembly.dll and run the game. global-metadata.dat includes the definitions for types, methods, properties, and fields which is a core staple of how .NET runs (and by extension IL2CPP games).

Once il2CppInsepctor parses the binary you should see the screen below. Change the option to generate a “.NET assembly shim DLLs” and click “Export” button.

il2CppInspector .NET assembly shim DLLs

The exported folder should now be filled with .NET DLL’s.

Reversing C# DLL’s

Now that the exported folder is filled with .NET DLLs, drag all of them into a .NET decompiler. In this tutorial, we’ll be using DnSpy. Our objective in this section is to understand how the game works, specifically for our exploit, we need to determine if there’s a way to reveal the impostor. Let’s begin by doing some basic enumeration and searching for “impostor” in the search bar. This search will reveal an interesting function within the RoleBehaviour class.

IsImpostor Function within RoleBehviour Defined in the Assembly-CSharp.dll

Perfect! The IsImpostor function appears to be exactly what we need. Lets analyze RoleBehaviour class further to understand how it’s being used. To do this, we can use the Analyzer function within DnSpy and determine which class exposes the RoleBehaviour instances.

Analysis of RoleBehaviour

The PlayerInfo class immediately catches my attention. Although we’ll provide evidence for this later, at this point, I can reasonably assume that every player has an associated PlayerInfo instance, and therefore also has an instance of RoleBehaviour. This means that if we can enumerate each PlayerInfo instance, we can call PlayerInfo.Role.IsImpostor to determine if the player is indeed the impostor. Furthermore, the PlayerInfo class has a public PlayerName String that we can use to retrieve each player’s name. This information will be helpful in linking a PlayerInfo instance back to a player in game.

PlayerName String within PlayerInfo Class

Before we proceed, let’s gather some evidence to confirm that every player is assigned a PlayerInfo instance. To do this we’ll use our Analyzer tool on the PlayerInfo class and manually examine some function names to determine whether multiple PlayerInfo instances are being created or if it’s just our local player that gets populated.

A List Containing all PlayerInfo Instances
Grab A PlayerInfo Class by ID

The property and method within the GameData class support the theory that each player has a PlayerInfo instance associated with them, and it’s not just used for the local player.

The next step is to figure out how we’re going to enumerate each PlayerInfo instance. Fortunately, in the previous step, we already identified a suitable property for doing so. The GameData.AllPlayers property will point us to a List containing all the instances of PlayerInfo. With that list, we can loop through each PlayerInfo instance, call PlayerInfo.Role.IsImpostor to determine if the player is an impostor, and if so, we can print the name using the PlayerInfo.PlayerName property.

The final piece of the puzzle is to identify how we’re going to find the GameData instance allows us to access the GameData.AllPlayers property. For this, we need to find a static pointer that will eventually lead us to the GameData instance. Since the GameData instance will be loaded somewhere in the heap, we cannot predict its location when we write the exploit. Therefore, we need to find a static pointer that always points to the GameData instance. Fortunately, the GameData class has a static pointer to its own instance, and this pointer is located at a static offset within the GameAssembly.dll image every time it loads.

Static Pointer to GameData instance within the GameData Class

Deep Dive Into C++ Scaffolding

Creating the Scaffolding Project

Once again load up il2Cppinspector and check off the “C++ Scaffolding / DLL injection project” option, as shown below.

il2CppInspector after parsing Among Us

Now click “Export” button, save it to a location and a folder called “C++ Scaffolding” should be created. This folder will contain the Visual Studio Project template that we’ll use for our exploit.

Initialization

To begin our analysis, we will navigate to the entry point of the project located at dllmain.cpp within the framework folder. Upon inspecting the code, we can see that the first function executed is init_il2cpp. Once this function completes, the DLL will create a new thread on the Run function located in main. Finally, the DLL will return with a value of True, indicating that the DLL load was successful.

Entry point of Scaffolding

Once we jump into init_il2cpp we can see a lot is going on.

il2cpp-init.h

To better understand what’s going on here, it’s easier to skip the macros. We’ll dive into what some of these macros, such as DO_API and DO_TYPEDEF, set up for us later. For now, the main takeaway from the screenshot above is the method used to get the base address. In this case, the il2cppi_get_base_address method calls the GetModuleHandleW function, a Windows function within the Kernel32.dll library that is loaded into every process, with the hard-coded parameter of GameAssembly.dll. We know that GameAssembly.dll holds all the game logic for Among Us, and GetModuleHandleW will return the virtual address to the base of GameAssembly.dll if it’s loaded into the process. We’ll explain why this is important later, but for now, let’s move on to the Run function in main.cpp.

Calling GameAssembly.dll Exported Functions

Within the Run function we can see three functions.

Run Function Within main.cpp

Lets start with the first one il2cpp_domain_get.

il2cpp_domain_get

Based on the image above, we can see that il2cpp_domain_get is defined as an external function that returns a Il2CppDomain pointer, and takes no parameters. At first glance, this information may seem confusing as il2cpp_domain_get does not appear to be defined anywhere. However, upon closer inspection of the init_il2cpp function, which we skipped over earlier, we can understand what is happening.

il2cpp-api-function Definition In init_il2cpp Function Located in il2cpp_init.cpp

From the image above we can see that the the name (the n variable in DO_API) il2cpp_domain_get actually gets defined as (*(Il2CppDomain* (*) ())(baseAddress + il2cpp_domain_get_ptr)) (Note: ## are concatenation operators). When combined with the DO_API function from il2cpp-api-functions.h we can get the line defining the function: extern Il2CppDomain* (*(Il2CppDomain* (*) ())(baseAddress + il2cpp_domain_get_ptr)) () This definition might seem a bit confusing at first, but it tells us how the function is being defined. Now that we know how the function is defined, let’s move on and figure out where this function is in memory.

As mentioned earlier we already know that be baseAddress variable will contain the base address to the GameAssembly.dll. This leaves us with il2cpp_domain_get_ptr, how exactly is that defined? Well we can take a look at the file il2cpp-api-functions-ptr.h and see the following.

il2cpp_domain_get_ptr Defined in il2cpp-api-function-ptr.h

From the image above we can see that the il2cpp_domain_get_ptr is defined as the hex value 0x00209810. This number is the offset from GameAssembly.dll base address where the function il2cpp_domain_get is located. We know this because il2cpp_domain_get is actually exported by GameAssembly.dll. Lets verify using Ghidra.

Locating il2cpp_domain_get in Ghidra

il2cpp_domain_get is just one function that’s exported by GameAssembly.dll, but in fact, there are many functions that exported by GameAssembly.dll. These exported functions (shown below) are functions that Unity has provided to help debug IL2CPP games.

Exported Functions from GameAssembly.dll

Lets take a look back at our Run function

Run Function Within main.cpp

il2cpp_thread_attach is called in the same way as il2cpp_domain_get, so we’re going to skip how it’s called. The reason for calling il2cpp_thread_attach appears to be for garbage collection. I’ll point to a thread, but a quote from a Unity engineer explains, “The il2cpp_thread_attach function attaches the thread to the virtual machine so that the garbage collector can track it. That function does not interact with Unity Engine code at all.” How this function accomplishes this is a bit of a mystery to me, but if you read the thread, it appears that I’m not alone. We’ll just accept that it’s needed and move on.

Finally using il2cppi_new_console a new console is created.

il2cppi_new_console Defined In Helpers.cpp

AllocConsole creates a new console window, while freopen_s takes in four parameters. Essentially, the function closes the file associated with the previous stdout stream and instead redirects stdout to the CONOUT$ file. This special filename refers to the previously allocated console’s screen buffer.

IL2CPP Structures

Finding the Location of the Class Definition

To build our exploit, it is necessary to identify the memory location of the GameData structure (class). As we have discussed earlier, a static pointer to the GameData object is defined within this class. The C++ Scaffolding project already has knowledge of where to locate the GameData class.

Definition of GameData Within il2cpp-types-ptr.h

The DO_TYPEDEF macros expanded for GameData are defined below.

GameData__Class** GameData__TypeInfo;
GameData__TypeInfo = (GameData__Class**) (baseAddress + 0x0206224C);  // baseAddress == GameAssembly.dll base address
extern GameData__Class** GameData__TypeInfo;

where GameData__Class is defined as

GameData__Class Definition in il2cpp-types.h

We’ll circle back to the GameData__Class and GameData in the next section, but first I want to explain where I believe the offset 0x0206224C comes from. Part of this offset appears in global-metadata.dat file.

Among Us global-meadata.dat file reveals offset

We can see that 0x06224c is located in the global-metadata.dat file. My assumption is that this file provides information on where the class definitions should be stored. In this case, they should be stored at a predefined base offset of 0x02000000 plus the class offset of 0x062224c, which gives us 0x02000000 + 0x062224C = 0x0206224C. When this offset is added to the baseAddress, we can see that it points to the .data section of memory.

GameData** Definition in .data Section

IL2CPP Classes & Objects

In her blog post, Katy provides a detailed explanation of how Classes and Objects are represented in IL2CPP. However, I will attempt to provide a different perspective on what’s happening in this section.

IL2CPP defines two classes: Il2CppClass and Il2CppObject in which all classes and objects are defined and worked with. Below you will find the definition in the Scaffolding for both of these classes.

Il2CppObject & Il2CppClass

Previously we mentioned the GameData__Class is defined as the following:

GameData__Class Definition in il2cpp-types.h

So if Il2CPP only uses Il2CppClass to work with classes, how did we get this GameData_Class? This is actually part of the magic of Il2Cpp Scaffolding. The GameData_Class is equivalent to the Il2CppClass, but it is parsed for us to better understand. For instance, static fields (represented by the GameData__StaticFields) and overridden or reimplemented methods(GameData__VTable) are structures that are equivalent to the Il2CppClass but have their variables renamed so it makes more sense in the context of GameData. To show this equivalence, we’ll grab the static Instance pointer that will point to the GameData (we previously saw this pointer when reversing the GameData class in the C# files) instance using both the GameData_Class and Il2CppClass from the same pointer. Below you will find a debug picture of code that does just this.

Showing Equivalence of GameData_Class & Il2CppClass

I mentioned the Il2CppClass & GameData__Class are equivalent, so you would expect them to have the same size, however, when we look at their sizes in the debugger, we get the following:

Showing Size of GameData__Class vs Il2CppClass

So what gives, why are the class sizes so vastly different? As it turns out the difference comes from the vtable array being cut short in the GameData__Class. VTables are a list of overridden or reimplemented functions that the class has defined that have come from an interface or parent class. The vtable is the last property in GameData__Class and thus the Il2CppClass as well.

Il2CppClass
GameData__Class

where the GameData__VTable is defined as the following:

GameData__VTable

Finally VirtualInvokeData is defined as the following:

VirtualInvokeData

The Il2CppMethodPointer points to the implementation of the function (the assembly) while the MethodInfo is some metadata object about the method, such as the function name.

In the photo above we can see that Il2CppClass defines an array of 32 VirtualInvokeData objects, however, GameData__Class only defines 13. This is where the size difference comes in. Each VirtualInvokeData object takes up 8 bytes of space (Among Us is 32 bit process so pointers are each 4 bytes) lets see if this is the missing space 8 * (32 - 13) = 152, 152 + 292 = 444 and we’re still 4 bytes off, so what gives? I believe the last 4 bytes comes down to stack alignment (structure padding).

Now that we understand how to work with classes within the IL2CPP Scaffolding project, lets look at how we interact with objects. Similar to classes, the Scaffolding project creates a parsed object class which is equivalent to its Il2CppObject representation. I previously mentioned that every Il2CppObject looks like the following:

IL2CppObject

However, this is only partially true. In reality, Il2CppObject is the base class in which all actual objects inherit from. Non-static fields that each object contains are then listed below Il2CppObject fields like so:

GameObject Example

Lets take a look at a real example, the image below showcases this with the GameData object.

GameData Object

The Scaffolding project parses each class that inherits from Il2CppObject for us and creates a nice structure that we can use throughout our project. In the case of GameData, the structure that the Scaffolding creates for us is shown below.

GameData Object Class

From the image above we can see how the GameData Object class has the Il2CppObject fields then the class specific fields listed below.

IL2CPP Class Methods

We previously that Il2CppClass holds a VTable for methods that have been overridden or reimplemented, but what all the other methods that are not overridden/reimplemented?

All methods defined by the C# classes are not found within the Il2CppClass structure, instead each method is globally available and defined with the DO_APP_FUNC macros. Those macros are defined as the following:

DO_APP_FUNC Macros

lets take get_IsImpostor method from the RoleBehaviour class as an example:

RoleBehaviour_GetIsImpostor Definition
#define DO_APP_FUNC(a, r, n, p) extern r (*n) p
// Expands to:
extern bool (*RoleBehaviour_get_IsImpostor) (RoleBehaviour * __this, MethodInfo * method);

#define DO_APP_FUNC(a, r, n, p) r (*n) p
// Expands to:
bool (*RoleBehaviour_get_IsImpostor) (RoleBehaviour * __this, MethodInfo * method);

#define DO_APP_FUNC(a, r, n, p) n = (r (*) p)(baseAddress + a)
// Expands to:
RoleBehaviour_get_IsImpostor = (bool (*)(RoleBehaviour * __this, MethodInfo * method))(baseAddress + 0x0054F4B0);

The most important thing to understand from the macros above is that the scaffolding identifies function location and parameters to get_IsImpostor. There are two parameters __this and method. __this is a reference to the instance of the class on which the method is being called, __this is required for all non-static methods. method is required for all shared methods. A shared method is a method where two or more methods share the same compiled code. An example of the IsImpostor method being called is shown below in the section Exploit Creation Step 4.

Now that we have an understanding on how to work with classes, objects, and methods lets start creating our exploit.

Creating the Exploit

The Exploit Plan

Lets review our plan to write our exploit:

  1. The Scaffolding project has parsed out a pointer to the GameData__Class definition from the global-metadata.dat file
  2. GameData__Class contains a static pointer to an instance of itself, thus this is pointer to a GameData object
  3. The GameData object contains a field called AllPlayers which is a List of PlayerInfo objects
  4. PlayerInfo contains a field named Role which is a concrete implementation of RoleBehaviour. RoleBehaviour defines a getter method IsImpostor which we will call to determine if the player is the impostor. Iterate through AllPlayers and call IsImpostor.
  5. If the Impostor is found, call the PlayerName getter method that is defined by PlayerInfo

The Exploit Creation

Exploit Creation Steps 1-2

Retrieving the GameData Instance

Not much to say here that we haven’t already explained in IL2CPP Classes & Objects section.

Exploit Creation Step 3

Retrieving the AllPlayerList

AllPlayers is a List object that has the following Fields:

List Object

The List object contains the _items and a _size property. The _items property is a pointer to a Il2CppArray. The Il2CppArray contains a vector field, this vector field contains a pointer to the objects contained, which in this case is the PlayerInfo object.

PlayerInfo Array Instance (An Il2CppArray Object)

Grab the specified player we want to check

Obtaining the Player from the Array

Exploit Creation Step 4

Using the GameData_PlayerInfo object we can call the RoleBehaviour function get_IsImpostor.

Calling the IsImpostor function

If you remember from the previous section, all methods are global and take at least two parameter. The first parameter is the object on which this method is being called (in this case it is the RoleBehaviour object) and the second is a MethodInfo pointer (only required for shared methods).

Putting it all together we get:

Asking each PlayerInfo Object if it is the Impostor.

Exploit Creation Step 5

We’ll finish our exploit by checking if the player is the impostor & printing out the players name. If you recall, there is a get_PlayerName function defined with the PlayerInfo class. We’ll call this function the same way we call the get_IsImpostor function, except we’ll be calling this on the PlayerInfo object.

Obtaining the Players name

Finally we’ll pipe out this name to the standard output stream. If you remember, the IL2CPP boilerplate code creates a new console which redirects the standard output stream to it.

Printing the playerName to Standard Output Stream

We’re done 🙂

Final Exploit Code

Final Exploit Code

Execution

To utilize the exploit we have created, we need to compile the code from Visual Studio into a DLL. Once the DLL is created, we can inject it into the process, which will trigger the DllMain entry point and execute our code.

Reveal the Impostor!

Next Steps

The current exploit code has several flaws that limit its effectiveness. Firstly, the code only works once since it is executed only when the DLL is injected. This means that the exploit cannot be used repeatedly, reducing its usability. Secondly, there is a risk of crashing the game if the code is not injected when the GameData is initialized. But, this blog post is already longer than it should be, so I’ll leave that exercise up to the reader.

Conclusions

I would like to express my gratitude to all the contributors of il2CppInspector for their hard work on the project. I would like to give a special shoutout to Katy for her detailed blog posts on Il2CPP internals. If you have made it to the end of this blog post and have any questions or suggestions, please feel free to reach out to me on Twitter. As a final note, I must ask that you do not use the exploit we created in actual matches. Our work the Among Us exploit was undertaken as a technical challenge and this blog post is intended solely as a learning resource. That is why I have decided not to release the finished project.