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.
Furthermore there are hints within the Among Us_Data
Folder that this is a IL2CPP game.
Now that we have verified this is a IL2CPP game we can boot up il2CppInspector.
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.
The next folder you want to drag in is GameAssembly.dll
located under Root Among Us
Directory
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.
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.
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.
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.
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.
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.
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.
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.
Once we jump into init_il2cpp
we can see a lot is going on.
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.
Lets start with the first one 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.
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.
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.
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.
Lets take a look back at our Run
function
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.
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.
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
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.
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.
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.
Previously we mentioned the GameData__Class
is defined as the following:
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.
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:
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.
where the GameData__VTable
is defined as the following:
Finally VirtualInvokeData
is defined as the following:
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:
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:
Lets take a look at a real example, the image below showcases this with the 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.
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:
lets take get_IsImpostor
method from the RoleBehaviour
class as an example:
#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:
- The Scaffolding project has parsed out a pointer to the
GameData__Class
definition from theglobal-metadata.dat
file GameData__Class
contains a static pointer to an instance of itself, thus this is pointer to aGameData
object- The
GameData
object contains a field calledAllPlayers
which is a List ofPlayerInfo
objects PlayerInfo
contains a field namedRole
which is a concrete implementation ofRoleBehaviour
.RoleBehaviour
defines a getter methodIsImpostor
which we will call to determine if the player is the impostor. Iterate throughAllPlayers
and callIsImpostor
.- If the Impostor is found, call the
PlayerName
getter method that is defined byPlayerInfo
The Exploit Creation
Exploit Creation Steps 1-2
Not much to say here that we haven’t already explained in IL2CPP Classes & Objects
section.
Exploit Creation Step 3
AllPlayers
is a List object that has the following Fields:
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.
Grab the specified player we want to check
Exploit Creation Step 4
Using the GameData_PlayerInfo
object we can call the RoleBehaviour
function get_IsImpostor
.
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:
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.
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.
We’re done 🙂
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.
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.