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:
- A
PlayerControl
instance is directly linked to a player in the game. It contains attributes that can be used to uniquely identify a player. - The
RoleTypes
enum value determines the role to be assigned to a player.
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:
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:
- The name is stored as an UTF-16 wide string.
- We need to add an offset of
0x14
to the beginning of thestring
object to grab the player name.
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:
- We Become an Impostor by changing the role value in passed in
SetRole()
for our local player object. - Now, the server thinks we’re a regular player. But our local client thinks we’re an Imposter.
- We can now enter vents and hide and move in there.
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:
:/
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:
References
- GitHub Gist by eybisi
- Frida API
- frida-il2cpp-bridge: A Frida library to interact with Unity-based games
- Thanks to @oleavr for telling me how to assign
NativePointers
with Frida :D