Game Hacking #4: Cheating in Unity Games

frida gamehacking binary

Yo!

Do you know the game Among Us ? It’s a multiplayer game where you have to identify impostors in a group of players. The impostor’s goal is to kill every other player without being identified throughout the game. The remaining players can use votes to kick out a specific player, while hopefully identifying the impostor correctly.

The game is based on the Unity engine and, along with other platforms, it is available for Android devices. I’ve looked into the game and I thought it would be a great idea to identify the impostors right away and without having to guess. Let’s see how that can be done with Frida.

Decompilation

After grabbing an .apk file of the game and extracting it, I’ve found that the actual game logic is not implemented in the Java code. Instead, and since it’s Unity-based game, the interesting stuff is present in a native library called libil2cpp.so. Let’s check out the documentation:

The IL2CPP (Intermediate Language To C++) scripting backend is an alternative to the Mono backend. IL2CPP provides better support for applications across a wider range of platforms. The IL2CPP backend converts MSIL (Microsoft Intermediate Language) code (for example, C# code in scripts) into C++ code, then uses the C++ code to create a native binary file (for example, .exe, .apk, or .xap) for your chosen platform.

Well that sounds interesting, especially the intermediate language aspect, since that often means that it’s possible to decompile the code back the its original form. There already exists a tool called il2cpp-dumper for this exact purpose:

$ il2cpp-dumper ./libil2cpp.so ./global-metadata.dat ./out

It uses the shared object along with some meta data to create a C# file that contains the original data structures and function prototypes. More on that code later.

Injecting Code Into Unity Games

One of the first things to happen when the Among Us game is executed, is that the Unity engine and libil2cpp.so are loaded. To avoid crashes, it is important to wait until this shared object is loaded before actually interacting with the game. Therefore, I’ve used this code to create a callback function that is executed as soon as the game engine is fully loaded:

function hax() {
    // do cool stuff at some point
}

var awaitForCondition = function (callback) {
    var int = setInterval(function () {
        var addr = Module.findBaseAddress('libil2cpp.so');
        if (addr) {
            console.log("Address found:", addr);
            clearInterval(int);
            callback();
            return;
        }
    }, 0);
}

awaitForCondition(()=>{hax();});

As a next step, the base address of the libil2cpp.so library is obtained, since we need that to actually create hooks with Frida:

const libil2cpp = Process.getModuleByName("libil2cpp.so");
const libil2cpp_base = libil2cpp.base;

This base address can then be used in combination with the code generated by il2cpp-dumper, since this code already contains annotations with attribute and function offsets that can be added to the base address to locate interesting stuff in game memory.

Data Types

Let’s have a look at some decompiled code. First, it is important to know every player of the game is assigned a random role that determines the abilities for the next game round. This happens as soon as a new game round is about to start. One of the possible roles is the Impostor role that is represented as an enum value:

public enum RoleTypes // TypeDefIndex: 12375
{
	// Fields
	public ushort value__; // 0x0
	public const RoleTypes Crewmate = 0;
	public const RoleTypes Impostor = 1; // <-- We are interested in this case
	public const RoleTypes Scientist = 2;
	public const RoleTypes Engineer = 3;
	public const RoleTypes GuardianAngel = 4;
	public const RoleTypes Shapeshifter = 5; //<-- and this one
}

You may have noticed the Shapeshifter role: It is a special version of the Impostor role with an additional ability: It allows a user to copy the visual appearance of another player. This means that we have to look for players with role 1 and 5.

The following class is responsible for assigning roles to individual players:

public class RoleManager : DestroyableSingleton<RoleManager> // TypeDefIndex: 12374
{
    [...]
	// Methods
	[...]
	// RVA: 0xF3AF64 Offset: 0xF3AF64 VA: 0xF3AF64
	public void SetRole(PlayerControl targetPlayer, RoleTypes roleType) { }
    [...]
}

This means that it’s possible to hook the SetRole() method at libil2cpp_base + 0xF3AF64 to grab the relevant information:

This is the definition of the PlayerControl class:

public class PlayerControl : InnerNetObject // TypeDefIndex: 12310
{
	// Fields
	private int LastStartCounter; // 0x30
	public byte PlayerId; // 0x34 <-- We need that
	public string FriendCode; // 0x38
	public string Puid; // 0x40
	public float MaxReportDistance; // 0x48
	[...]

	// RVA: 0xF568F0 Offset: 0xF568F0 VA: 0xF568F0
	public void SetName(string name, bool dontCensor = False) { }
    [...]
}

Note the PlayerId value: This value unique to each player, hence the name (really!). At this point, the only thing that’s missing to identify an impostor in the game is a mapping from player IDs to player names. For that, we can hook another function, namely SetName() at 0xF568F0 (see above). If you check the decompiled code above, you can see that this method accepts two parameters. In reality, it receives three parameters though:

Ghidra Output

Well okay, why is that? It turns out that for non-static functions an additional value is being passed that represents the current object (this). This ultimately allows mapping player names to player IDs.

Time to put all of this information together in a Frida script.

Getting Roles

This is the hook for SetRole():

Interceptor.attach(libil2cpp_base.add(0xF3AF64), {
    onEnter: function (args) {
        var playerControl = args[1];
        var roleType = args[2];

        if(roleType == 1 || roleType == 5) {
            // we have the impostor, let's get the player ID
            var impostorPlayerId = playerControl.add(0x34).readS8();

            console.log("---");
            console.log("impostor is: " + impostorPlayerId);
            console.log("---");
        }
    }
});

Note that we start grabbing values at index 1 instead of 0 since we don’t need this in this case. If you check the implementation of the PlayerControl class, you’ll notice that the player ID is stored at offset 0x34 as a byte value that can be read with the readS8() function of Frida. This reads a signed 8 bit value.

Mapping Player IDs to Names

Now we know which player ID is the impostor. Let’s map this ID to an actual name by hooking SetName():

Interceptor.attach(libil2cpp_base.add(0xF568F0), {
    onEnter: function (args) {
        // of type PlayerControl
        var _this = args[0];
        var strObj = args[1];
        var playerName = strObj.add(0x14).readUtf16String();
        var playerId = _this.add(0x34).readS8();

        console.log(playerName + ": " + playerId);
    }
});

The player ID can be determined in the same way as before. The following hex dump shows what string objects looks like in memory:

             0  1  2  3  4  5  6  7  8  9  A  B  C  D  E  F  0123456789ABCDEF
7a87a40930  90 84 16 26 7d 00 00 00 00 00 00 00 00 00 00 00  ...&}...........
7a87a40940  09 00 00 00 4f 00 75 00 74 00 65 00 72 00 61 00  ....O.u.t.e.r.a.
7a87a40950  77 00 65 00 64 00 00 00 41 00 41 00 41 00 41 00  w.e.d...A.A.A.A.
7a87a40960  90 84 16 26 7d 00 00 00 00 00 00 00 00 00 00 00  ...&}...........
7a87a40970  07 00 00 00 73 00 75 00 72 00 66 00 65 00 69 00  ....s.u.r.f.e.i.
7a87a40980  74 00 00 00 51 00 35 00 4e 00 6a 00 64 00 68 00  t...Q.5.N.j.d.h.

Notice the following things:

Demo

Upon running the game and injecting the script, the following output is printed:

[+] Got PID 28485
[+] libil2cpp.so base address: 0x7bc1d91000
[+] doing hax now

Oilunitary: 0
Streakyfur: 1
Snoozyday: 2
Busyrain: 3
BananaMan: 4
Shocksure: 5
Bridecosy: 6
Supoverarm: 7

---
[+] impostor is: 6
---

Now we can vote to kick out Bridecosy, GTFO ◉‿◉

Additional Client Side Memory Manipulation

At this point I thought: What about manipulating client side memory instead of just reading some values? Here are some things I’ve tested.

Entering Vents as Regular Player

Normally, only two player roles are permitted to enter vents on the game map: Engineers and Impostors. Vents can be used to hide, for example. However, we can make our local client think that we’re actually an Imposter:

This produces an interesting side effect: Since we’re an impostor in our local game, we can directly identify other imposters from the game UI. Unfortunately the server doesn’t accept kill operations form this spoofed role.

Becoming a Shapeshifter

Like before, I’ve tried to become a Shapeshifter by altering the role parameter in SetRole(). This works, but upon changing the player appearance, this happens:

lol

:/

Becoming Invincible, Sort of

If you’re not familiar with the Guardian Angel role in Among Us, i suggest reading this article first. I’ll wait here.

Since this role type is able to protect players, I’ve looked into what happens under the hood: There are some boolean values in players objects that control whether a player is protected or not. For some reason, if we set these to true for the local player and spoof the role to be a Guardian Angel, our player becomes sort of invincible.

In the local game, we don’t die in case an Imposter attacks the player. But on the server-side we die and other people will see a dead body on the floor. This makes playing really weird because we can still play as if nothing happened. At some point, I think some people noticed strange stuff is going on:

lol

References

BinaryGolf 2023: Building A GameBoy-Bash Polyglot

binary ctf

Game Hacking #5: Hacking Walls and Particles

reverse-engineering c++ binary gamehacking

Analysis of Satisfyer Toys: Discovering an Authentication Bypass with r2 and Frida

radare2 r2 frida r2frida reverse-engineering web vulnerability