Marsh https://0x64marsh.com Thu, 21 Aug 2025 15:45:07 +0000 en-US hourly 1 https://wordpress.org/?v=6.9.4 205946328 Game Hacks: Among Us – IL2CPP Walkthrough https://0x64marsh.com/game-hacks-among-us-il2cpp-walkthrough/ Wed, 22 Mar 2023 02:09:05 +0000 https://0x64marsh.com/?p=689

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.

]]>
689
Kernel Driver Exploit: System Mechanic https://0x64marsh.com/kernel-driver-exploit-system-mechanics/ Sat, 24 Sep 2022 11:00:00 +0000 https://0x64marsh.com/?p=314

Table of Contents

Introduction

Upon completing the Windows Kernel Exploitation by Ashfaq Ansari he provides a challenge to write a exploit for the System Mechanic driver. In this blog post we’ll dive deep into the driver recon, vulnerability discovery, and the eventual driver exploitation. I’ll also explain how I potentially discovered a new much more powerful vulnerability (Vulnerability #2) which allows for arbitrary read and write of memory. Currently all of the public exploits/blogs (that I found) reference Vulnerability #1 which is constrained to write a specific value (0xFFFFFFFE) to an arbitrary place in memory (no arbitrary read, no arbitrary write). This is why I think Vulnerability #2 has either gone undiscovered or is at least less widely known.

Driver Recon

In order for a user mode application to communicate with a kernel driver through the use of IOCTL’s (explained below) the user mode process must open up a handle to the device object using a symbol link that the driver has created. The following kernel API functions are used to create the symlink.

When reversing the System Mechanic Driver entry routine in Ghidra, I eventually came across the function that creates this symbolic link. This function I renamed to InitializeDevice and is shown below.

System Mechanic Device Initialization Function

We can verify that this symbolic link exists by using a tool called WinObj (from SysInternals).

WinObj Viewing System Mechanic Symbolic Link

Major Functions

A Driver_Object instance is provided by the windows kernel to a driver through the DriverEntry parameters when it is loaded.

NTSTATUS DriverEntry(_In_ PDRIVER_OBJECT  DriverObject,_In_ PUNICODE_STRING RegistryPath);
{
   << Driver Initialization Code>>
}

The DriverObject Structure is shown below. The member that is most important for us when reviewing IOCTL’s is the MajorFunction pointer.

typedef struct _DRIVER_OBJECT {
  CSHORT             Type;
  CSHORT             Size;
  PDEVICE_OBJECT     DeviceObject;
  ULONG              Flags;
  PVOID              DriverStart;
  ULONG              DriverSize;
  PVOID              DriverSection;
  PDRIVER_EXTENSION  DriverExtension;
  UNICODE_STRING     DriverName;
  PUNICODE_STRING    HardwareDatabase;
  PFAST_IO_DISPATCH  FastIoDispatch;
  PDRIVER_INITIALIZE DriverInit;
  PDRIVER_STARTIO    DriverStartIo;
  PDRIVER_UNLOAD     DriverUnload;
  PDRIVER_DISPATCH   MajorFunction[IRP_MJ_MAXIMUM_FUNCTION + 1];
} DRIVER_OBJECT, *PDRIVER_OBJECT;

The MajorFunction pointer is a pointer to a table of callback function pointers. The Windows I/O manager uses this callback table to direct the proper handling of I/O Request Packets (IRP’s). The callback table size is static and each index in the callback table is associated with a MajorFunction. Driver developers looking to implement functionality for a specific MajorFunction will place the callback function address into the proper table index.

Major Function Codes

When driver developers need user mode applications to communicate with the kernel driver the IRP_MJ_DEVICE_CONTROL (0x0e) is used. The user mode application uses the DeviceIOControl windows API to then communicate with the driver. When DeviceIOControl is called the kernel I/O Manger will go to the specified drivers MajorFunction table and call the function located at the index 0x0e (IRP_MJ_DEVICE_CONTROL). The callback function must support the following routine.

NTSTATUS DriverDispatch(
  [in, out] _DEVICE_OBJECT *DeviceObject,
  [in, out] _IRP *Irp
)

When viewing the System Mechanic driver, We can see the callback function for IRP_MJ_DEVICE_CONTROL being set down at the bottom of the bottom of the InitializeDevice routine. I renamed the callback function to IrpDeviceControlHandler.

System Mechanic Major Function callbacks being set

Device IOCTL’s

Input and Output Controls (IOCTL’s) is a 32bit value passed into the DeviceIOControl API. The receiving driver can obtain this IOCTL by parsing the IRP structure, once the IOCTL code is retrieved the specified function can be invoked. An example of this is shown below.

switch(IOCTL) { 
    case 0xAAAAAAAA: 
       functionA();
       break;
    case 0xBBBBBBBB:
       functionB();
       break;
}

Although it may appear that IOCTL’s would be arbitrary numbers selected by the driver developers, they are not. IOCTL’s have a specific structure that the I/O manager also interprets.

IOCTL Definition
----------------------
CTL_CODE( DeviceType, Function, Method, Access ) (((DeviceType) << 16) | ((Access) << 14) | ((Function) << 2) | (Method))

DDDD.DDDD.DDDD.DDDD.AAFF.FFFF.FFFF.FFMM

D = Device type bits 
A = Access bits
F = Function bits
M = Method bits

Device type: Are bits specifying the underlying hardware for the driver, this can be found as a parameter to IoCreateDevice.

Access bits: can be 0, 1, 2, or 3.

FILE_ANY_ACCESS        0x0 ->  anyone with a handle with the device can call the this IOCTL function
FILE_READ_ACCESS       0x1 -> the caller must have read access to the device to call the IOCTL function
FILE_WRITE_ACCESS      0x2  -> the caller must have write access to the device to call the IOCTL function
(FILE_READ_ACCESS | FILE_WRITE_ACCESS)  0x3 -> the caller must have read & write access to the device to call the IOCTL function

Function Bits: This value is defined by the driver developers however Microsoft reserves values under 0x800, thus vendors use 0x800 and greater.

Method Bits: can be 0, 1, 2, or 3. These bits are arguably the most important as they determine how the I/O manager passes the usermode buffer to the driver.

  • Buffered I/O 0x0
    • I/O manager validates that the buffer is in usermode address space
    • I/O manager copies the buffer into newly allocated memory in kernel space, this is where the driver can work on the buffer.
  • METHOD_IN_DIRECT   0x1 & METHOD_OUT_DIRECT 0x2
  • Neither I/O 0x3
    • No validation on the callers buffer (size or address)
      • Both the address & the size of the buffer are supplied by the user.
    • Driver must be in the same application context to work on this buffer

We previously saw that the IRP_MJ_DEVICE_CONTROL callback routine jumps to a function that I renamed to IrpDeviceControlHandler. The IrpDeviceControlHandler will be the function that will handle the IOCTL control codes as seen below.

System Mechanic handling of IOCTL’s

In the screenshot above we can see that the IRP is parsed and the IOCTL received equals 0x226003. If this IOCTL is not found System Mechanic returns the status 0xc0000010 (STATUS_INVALID_DEVICE_REQUEST). The IOCTL code can be parsed using the OSR Online IOCTL Decoder.

Decoded System Mechanic IOCTL

The parsed IOCTL provides two important pieces of information. First it shows that METHOD_NEITHER is used, this is useful because we know that the buffer will not be verified by the I/O Manager. Second we know that for a usermode application to send this IOCTL code, it will need to have Read privileges on the device handle. We can find who has these privileges by viewing the device in WinObj as shown below.

System Mechanic AMP Privileges

Vulnerability Discovery

Initial Fuzzing

Initial fuzzing begins with a tool called IOCTLBF. This basic fuzzer will provide a starting point for vulnerability research, the command used to fuzz the AMP driver.

IOCTLBF command to fuzz the System Mechanic Driver
  • Parameters:
    • -d
      • Device Driver Symlink
    • -i
      • IOCTL to fuzz
    • -u
      • Only fuzz the specified IOCTL

Crash Analysis

The crash we’re about to observe isn’t caused by IOCTLBF, Instead I used ioctlplus (updated by voidsec, created by Jackson T). This was used to emulate the crash that IOCTLBF would have produced but allows me to control the buffer for explanation purposes. The buffer created with ioctlplus is shown below.

Buffer That Causes System Mechanic To Crash
System Mechanic Driver Initial Crash

The first the to notice is that the crash happens while attempting to dereference the address 0x6262626262626262 that is stored is RSI. This is great because it is the same address stored in the second QWORD of our buffer, which means the value of RSI is controlled by the user. In the lines just after the initial crash we can see that RSI doesn’t just get dereferenced once, but three more times. Each time RSI increases by a QWORD and gets dereferenced. The dereferenced value is then placed into RCX,RDX,R8,R9 right before the call to RBX. Because we control RSI , we control the input parameters to the call (shown below in the screenshot on line 48). The screenshot below is of the decompiled function (referred to as VulnFunc) that the kernel crashed in. Line 40 (offset0x6C8D) is the line that crashed the kernel.

System Mechanic Initial Crash Function

Now that we know we can control the parameters to the Dynamic call on line 48, the next step is to understand how the function pointer ControlledFunctionPointer gets passed. If we can modify ControlledFunctionPointer we can modify the function called on line 48. To start we first need to understand the call stack to VulnFunc.

VulnFunc Reference Tree

If you recall IrpDeviceControlHandler will be the function that handles incoming IOCTL requests. This function will call IOCTL_FUNC and pass in 3 parameters. Is32Bit Boolean, InputBuffer pointer (pointer to our usermode buffer we supplied), InputBufferLength (user mode supplied length).

System Mechanic IrpDeviceControlHandler

The IOCTL_FUNC is shown below, The variable InputBuffer_DWORD1 will contain the first DWORD supplied in the user buffer, which acts as an index into UnknownGlobal array which points to a function pointer.

System Mechanic IOCTL_FUNC Method

The UnknownGlobal variable is a global variable that leads to a area of memory that is filled in dynamically. This memory is some sort of structure that appears to look like the following:

UnknownGlobal Struct {
   DWORD unknown_DWORD_0,
   DWORD unnown_DWORD_1,
   VOID* FunctionPointer
} 
UnknownGlobal Struct in Memory

In the IOCTL_FUNC code we can also see that InputBuffer_DWORD1 has a check that ensures it is less than 9, thus there are 9 functions (0-8) that the UnknownGlobal Array contains.

Lets review what we know, The user supplied buffer pointer is passed to from IrpDeviceControlHandler to IOCTL_FUNC. The first DWORD from the user supplied buffer is retrieved and then checked to ensure that it is less than 9. Once the check is successful it is used as in index to an array of function pointers. The function pointer in this array is retrieved and stored in a local variable (InputBuffer_DWORD1), the address of this variable (InputBuffer_DWORD1) is then passed to VulnFunc as the parameter ControlledFunctionPointer. We know that VulnFunc retrieves the second QWORD from the user supplied buffer (0x6262626262626262), because the dereference of the second QWORD (0x6262626262626262) is what causes the crash as it tries to resolve the parameters for the next call. This leaves a question, how does the code in VulnFunc retrieve the second QWORD value if it’s not passed into the function? The below picture of the stackframe just before entering VulnFunc should help provide the answer.

System Mechanic IOCTL_FUNC Just Before Execution of VulnFunc
System Mechanic VulnFunc obtaining QWORD2

Now the decompiled code makes more sense as we can see that line 39 in VulnFunc is manipulating the (InputBuffer_DWORD1) pointer to grab the 2nd QWORD stored on the previous stack frame. If this is difficult to understand right now, don’t worry, we’ll dive into it deeper in Vulnerability #1.


Now we know we can call a total of 9 (0-8) functions and control each parameter to them, we should start to do static and dynamic analysis on each function in search of vulnerabilities. The first vulnerability, which we’ll see in a second, was found this way. The second vulnerability was much more illusive and required the creation of a custom fuzzer to assist in discovery.

Vulnerability #1

For the first vulnerability we actually don’t need to dive into any of the 9 functions functions I discussed earlier, instead, we only care about their return value. The dynamic function we control returns a value and places it into the dereferencedInputBufferQword2 variable that was previously used to hold the pointer to our parameters (line 48). then it places it into the location **(undefined **)(ControlledFunctionPointer + 6). We have seen a similar pattern before when we were grabbing the 2nd QWORD from the previous stack. The question now is, where are we placing this returned value?

Lets go back to the decompiled view of IOCT_FUNC and determine where this value is placed. I believe this picture provides a good representation on what’s going on. Remember that the ControlledFunctionPointer variable in VulnFunc is actually the address of InputBuffer_DWORD1 in IOCTL_FUNC.

How VulnFunc Accesses V10 inside IOCTL_FUNC

The return value of the dynamic function call in VulnFunc is stored in V10! On line 51 V10 appears to be placed in a dereferenced location that the user supplies! We can see this because InputBuffer_DwordPointer is assigned on line 20 from the InputBuffer array at the second index (which means it will take the 3rd QWORD we provide in the user buffer). This is great, as it means we can supply a pointer and write the return value anywhere in memory.

It’s important to note, that this vulnerability is enough to write the token privilege exploit that we will be creating by the end of this write up. However, This vulnerability only allows you to write values that get returned from the dynamic function call (line 48) in VulnFunc, Vulnerability #2 allows for arbitrary read write.

Vulnerability #2

We’ll look for the second vulnerability inside the functions we can control through VulnFunc . A good place to start is to send 9 different IOCTL requests (With the supplied first DWORD being 0-8) and set a breakpoint in VulnFunc at line 48 (dereferencedInputBufferQword2 = (*pcVar4)(dereferencedInputBufferQword2,uVar1,uVar2,uVar3)). Once the breakpoint is hit we determine the address of each function you can call. You should start static analysis of those functions and determine if anything stands out. Initially this was what I did, however, some of the functions were difficult to reverse (function 5 in particular) and I didn’t come up with anything useful. My next step was to create a custom fuzzer.

Custom Fuzzer Creation

  • The custom fuzzer must be able to do the following
    • Check all 9 functions we can control
    • Control the parameters between random values (Represented by Param X [in the diagram below]) and usermode address buffer pointers (Represented by Pointer to userspace X [in the diagram below])
      • If the parameter points to a usermode address buffer and if the buffer that this address points to is modified, it should be reported
    • Supply a usermode return address
      • if the buffer that this address points to is modified, it should be reported
    • Should a usermode buffer that is pointed to by the return address or the parameters be modified we should be able to repeat the same IOCTL request again. This is to ensure we’ll be able to setup breakpoints and see the exact same request come through.

I’m not going to go through the code of the fuzzer, as it is hacky, although I’ll upload to my Github where you can view it. Instead I’ll provide a high level diagram of how this works.

Custom Fuzzer Steps 1 & 2
Custom Fuzzer Step 3
Custom Fuzzer Step 4
  • We’re interested in the following differences
    • Return Value
      • Helps us understand what values we can write using Vulnerability #1
    • Param X or Pointer to userspace X
      • This value should not be overwritten, however, if it is then this indicates something we do not understand is happening which may be interesting
    • userspace X
      • Indicates one of parameters was dereferenced and used to store information at that location
    • slack space
      • If this value is overwritten then we know that System Mechanic Driver has written a large buffer of memory

Custom Fuzzer Usage

Previously I mentioned function 5 being the most difficult to reverse, but this function also revealed the most interesting results of the fuzzer.

Custom Fuzzer detecting a change in bytes in a userland buffer

The important thing to note from the image above is that the values at userspace 3 & 4 were modified from their original values (9 & 15f31c32ca0) after the IOCTL call to system mechanic. This could mean that param3 & param4 (Pointer to userspace 3 & 4) was dereferenced by the system mechanic driver and used to place some memory into, this means that we may be able to supply an arbitrary address and have the kernel write into it.

Before starting to dig deeper into function 5, I decided to run more tests from the fuzzer and determine any consistencies that lies within.

Multiple Custom Fuzzer Runs of Function 5

There are a few takeaways from the data above:

  • param2 is always equal to 2 when the userspace buffer is modified
  • If param3 does not point to userspace 3 and param4 points to userspace 4, userspace 4 is still overwrote. This means that param3 & param4 are both dereferenced and have values placed into them. If userspace 4 was not overwrote, this could mean that only param3 was being dereferenced and the kernel was writing more than 8 bytes thus entering into the memory where the value userspace 4 is contained.

Reversing Function 5

Armed with the knowledge above, lets dig into decompiled code of function 5 (called opcode5 in the screenshot below).

Decompiled Function 5

The function starts by checking that param2 < 3 and param1 < 30 (0x1e), our fuzzer examples indicates both these conditions are met. The third conditional statement does a dynamic check that relies on param1 to determine if a value is 0, if true, execution will continue on line 13 (offset 0x8de6), otherwise execution will jump to line 50 offset (0x8dc2). We’ll rerun the fuzzer and until we hit a userspace overwrite, then we’ll setup breakpoints at these offsets (0x8de6,0x8dc2) to determine where our execution takes us. Additionally we’ll set a breakpoint at line 26 offset (0x8e98), to see where the dynamic call goes.

The following was the fuzzer reran until we had a userspace overwrite.

Custom Fuzzer Reran

Now we’ll set the breakpoints and rerun the last request done by the fuzzer.

Setting & Triggering Breakpoints in Function 5

We hit breakpoint at offset amp+0x8e98 which means we hit line 26. Lets first verify where this Call will take us (by figuring out what is stored at qword ptr [rsp+38]) and then verify our parameters.

Determining address of the function

The function that will be called is at offset 0x8730, we’ll take a look at that shortly, but first lets verify our parameters.

Verification of Parameters Before Dynamic Call in Function 5

The parameters are as we expect. RDX holds our input param3 (Pointer to userspace3) and R8 holds our input param4 (Pointer to userspace4).

Finding the Arbitrary Read/Write

The function at offset 0x8730 is shown below. From now on we’ll refer to it as dynamic_function_5_read_write.

Dynamic Call from Function 5

From the image above we can clearly see where the writes to our user buffer happens, line 12 & 20. On line 12, Param_3 is dereferenced (this is our user supplied param4) and the value of 4 is placed inside it (placing it at userspace 4). On line 20, Param_2 is dereferenced (this is our user supplied param3) and the value of dereferenced param_1 is placed inside it (placing it at userspace 3). If we can manipulate param_1 we will have an arbitrary read/write!

Lets take a look at where param1 takes us. From the registers image above, we know that param_1 in this case is 0xfffff805308c8ab4 . from the image below we can see it points to the value 0x00010000:

Value of Param_1

Lets look at decompiled function 5 (opcode5) again to see what control we have over param_1 in dynamic_function_5_read_write.

Inspecting Param_1 Origin from Function 5

We can see that param_1 inside dynamic_function_5_read_write is actually puVar2 inside opcode5. It appears that we can somewhat control puVar2 by modifying param_1 in opcode_5. lets take a look at the memory of array_of_puVar2_pointers and determine which values can be placed into puVar2 by modifying param_1.

array_of_puVar2_pointers structure in memory

The highlighted block above shows the value of param_1 from dynamic_function_5_read_write when we caught it in the debugger (see images above). The pointer values we could return from array_of_puVar2_pointers into puVar2 didn’t turn out to be that interesting.

At this point I was a little disheartened as I realized I would not be able to fully control the pointer returned from array_of_puVar2_pointers into puVar2 . The pointers I was able to store into puVar2 didn’t appear to be pointing to anything important. I took a small hiatus from the project before returning and giving it another look.

I came back to the project with a new idea. I’m unable to modify the puVar2 pointer sent to dynamic_function_5_read_write, However, perhaps I can modify the value the pointer points to. This would be like modifying the value 0x00010000that 0xfffff805308c8ab4 was pointing to. To see if we can modify this value, lets put a write breakpoint at the 0xfffff805308c8ab4 address and rerun the fuzzer.

Received a write breakpoint

Awesome we received a write breakpoint! Lets take the instruction offset 0x0884band see what the decompiled function looks like in Ghidra. We’ll refer to this function as our dynamic_function_call_store.

Function That Writes To Our Pointer Address

This function is perfect, as it is extremely simple. The next step is to evaluate the parameters passed into the function, we can view the stack for this.

Stack of dynamic_function_call_write

param_1 is the address we have seen passed to dynamic_function_5_read_write and param_2 looks like a userbuffer pointer we supplied, param3! The return address leads back to line 28 in function 5 (opcode5), as shown below.

Return Address of dynamic_call_function_store

We now know that param_1 is familiar to us, because it’s puVar2 and by viewing the values from the previous stack frame, we can determine what the values of the each parameter were that the fuzzer had input to get this result.

  • param1: 0x16
  • param2: 0x0
  • param3: 0x15f31c32c98
  • param4: 0x15f31c32ca0

This means that we should be able to specify an arbitrary address with param3 and place it into the value that puVar2 is pointing at by using dynamic_function_call_store then send another IOCTL request and read or write the value we just stored into puVar2 by calling dynamic_function_call_read_write and providing the address we want to write to in param3. We have just found an arbitrary read and arbitrary write! Lets review the IOCTL requests so we’re on the same page.

The first IOCTL request will call function 5 (opcode5) with the following parameters

  • param1: 0x16
  • param2: 0x0
  • param3: <target address to read from>
  • param4: <anything>

This will call dynamic_function_call_store with a specified puVar2 that we can read from later and param3 the target address we want to read from. This will place the value of a DWORD stored the target address (param3) into puVar2.

The second IOCTL request will call function 5 (opcode5) with the following parameters

  • param1: 0x16
  • param2: 0x2
  • param3: <target address to write to>
  • param4: <anything greater than 4>

This will call dynamic_function_call_read_write with the first parameter being the puVar2 address pointing to the value we chose with the previous IOCTL request callingdynamic_function_call_store. The second parameter being param3 in our IOCTL request, will point to the address that we want the value stored at puVar2 to be written to. The third parameter passed to dynamic_function_call_read_write is param4 of our IOCTL request. This parameter needs to be greater than 4 to satisfy a conditional statement. Once completed, the address passed in param3:<target address to write to> from our second IOCTL request will contain the DWORD that the second IOCTL’s request param3: <target address to read from> pointed to.

Now that we have an arbitrary read/write we can begin to work on the exploit.

Exploitation

This exploit will overwrite the token permission in our current process. It is possible to do this exploit only with vulnerability #1 as the token address for the current process can be found with usermode calls, therefore it does not require an arbitrary read. However, this code strictly uses Vulnerability #2 and obtains the current process token by manually enumerating kernel structures, to show off what it’s capable of doing.
The Code can be found on my Github. The steps are explained below:

  • Get the base address of the windows kernel driver ntoskrnl, by using EnumDeviceDrivers to get the base address of each driver in the kernel & GetDeviceDriverBaseNameA to find which address refers tontoskrnl.exe.
  • Next use LoadLibraryA to loadntoskrnl.exe into the current process (Yes we’re using LoadLibrary on a non .dll, but .dll and .exe files have the same PE file format so enumerating the exports will be the same routine), then use GetProcAddress to find the address of PsInitialSystemProcess. The following will give you the address of PsInitialSystemProcess in userspace. Substract the userspace address ofntoskrnl.exe from the PsInitialSystemProcess address to get the offset of PsInitialSystemProcess. Armed with the offset of PsInitialSystemProcess and the base address in the kernel of ntoskrnl we can now determine the address of PsInitialSystemProcess in the kernel by using the formula <Address of ntoskrnl> + <Offset of PsInitialSystemProcess> = <Kernel Address of PsInitialSystemProcess>
  • PsInitialSystemProcess is a pointer to the EPROCESS Structure, this structure holds information for each process. In this step we’ll supply the <Kernel Address of PsInitialSystemProcess> into Vulnerability#2 (arbitrary read), the end result is we’ll leak the address of the EPROCESS structure.
  • The next step all about enumerating the EPROCESS. EPROCESS Structure changes on different versions of windows, but below is the important parts of the structure based on Windows 10 build1909.
struct _EPROCESS
{
    struct _KPROCESS Pcb;                                                     //0x
    struct _EX_PUSH_LOCK ProcessLock;                                 //0x2e0
    VOID* UniqueProcessId;                                                   //0x2e8
    struct _LIST_ENTRY ActiveProcessLinks;                            //0x2f0
...
    struct _EX_FAST_REF Token;                                             //0x360
...
}

====================
Sub Structures are listed below
====================

struct _LIST_ENTRY
{
    struct _LIST_ENTRY* Flink;                                              //0x0
    struct _LIST_ENTRY* Blink;                                              //0x8
}; 

struct _EX_FAST_REF
{
    union
    {
        VOID* Object;                                                            //0x0
        ULONGLONG RefCnt:4;                                                //0x0
        ULONGLONG Value;                                                    //0x0
    };
}; 

We’ll use our arbitrary read vulnerability to read the UniqueProcessId (0x2e8) and determine if it matches the process PID of our exploit process. If it doesn’t we’ll read Flink (0x2f0)pointer stored in ActiveProcessLinks(0x2f0) which will bring us to the next EPROCESS structures ActiveProcessLinks(0x2f0). We can subtract 0x2f0 to get the Next EPROCESS Structure base. We’ll repeat this until we find a UniqueProcessId (0x2e8) match.

  • Once the EPROCESS Structure for our process is found we’ll leak the Token (0x360) structure. The Token Structure is _EX_FAST_REF which is essentially a pointer with the last 4 bits being a reference count. Thus the value we receive from this structure will need to be masked to get the pointer to the TOKEN Structure. The mask will look like <tokenFastRef> & 0xFFFFFFFFFFFFFFF0 = <Address of Our Process Token>.
  • The <Address of Our Process Token> points to the TOKEN Structure shown below.
struct _TOKEN
{
    struct _TOKEN_SOURCE TokenSource;                                       //0x0
    struct _LUID TokenId;                                                   //0x10
    struct _LUID AuthenticationId;                                          //0x18
    struct _LUID ParentTokenId;                                             //0x20
    union _LARGE_INTEGER ExpirationTime;                                    //0x28
    struct _ERESOURCE* TokenLock;                                           //0x30
    struct _LUID ModifiedId;                                                //0x38
    struct _SEP_TOKEN_PRIVILEGES Privileges;                                //0x40
...
}


====================
Sub Structures are listed below
====================
struct _SEP_TOKEN_PRIVILEGES
{
    ULONGLONG Present;                                                      //0x0
    ULONGLONG Enabled;                                                      //0x8
    ULONGLONG EnabledByDefault;                                             //0x10
}; 

Using write portion vulnerability #2 and with the knowledge of the address of our Token Structure we can Write 0xffffffffffffffff into _SEP_TOKEN_PRIVILEGES->Present and _SEP_TOKEN_PRIVILEGES->Enabled. With that write our process will now have full permissions and we have just escalated our privilege.

Final Notes

I appreciate you reading the blog, hope you learned something new. If you have any comments or would like to further discuss the post, feel free to reach out on twitter.

]]>
314