A Proximity Chat Mod for “Among Us”

Among Us

Check out the project on GitHub, and join our Discord server if you want to.

Due to quarantine, several interesting and fun computer games resurfaced and got popular. One of these games is “Among Us” by InnerSloth, an online multiplayer social deduction game, similar to the board games “The Resistance” and “Mafia”.

When I play with my friends, we usually use the Discord VoIP software for discussion during in-game meetings. When no meeting is active, we all mute ourselves. However, the idea of a proximity-based voice chat came up recently, where players would be able to speak to each other depending on their in-game position. As the dedicated game server admin of the group, I set out to experiment.

Please note, I started this project back in October 2020. Since then, multiple other implementations have popped up. However, back then the only option was a paid software that was licensed to entertainers on a case-by-case basis.

Chapter 1: Experiments

To begin with, I looked at several VoIP solutions, because I really do not want to implement my own.

At first, I looked at Discord, because my group already used it. However, Discord has no support for positional audio, and even if you wanted to manually implement something like that, the API is too limited. Also, I wanted to use free and open-source software.

Next, I checked out Janus, which is a server/toolkit for WebRTC. It seemed promising from a technical point of view, but the setup is not that easy and I would have had to write quite a lot of software parts, the client just being one of them.

Then, I found Mumble. Mumble has existed for over a decade as an open-source alternative to TeamSpeak. It is a native VoIP application with low latency and plugin support. But most important, it features support for positional audio information. So Mumble it was.

The way Mumble handles positional audio is quite smart, instead of doing any resource-hungry audio mixing on the server, each client sends a stream of its own in-game position along with the audio stream. The other clients then change the volume of each peer according to the computed distance between themselves and the received positions. This audio effect can even emulate a 3D position and is readily implemented in APIs such as XAudio. Due to this approach, all the server has to do is route the audio packets. This enables even weak hardware to handle hundreds of clients.

Communicating With Mumble

To add support for a game to Mumble (in regard to the positional audio), there are two ways. One can write a plugin, which gets loaded into the mumble application and extracts player position data from the memory of a game. Alternatively, if the game itself can be modified, the Link protocol can be used to send positional data to Mumble. This uses a named memory-mapped file (works similar to a pipe) to communicate between two processes.

Originally, I tried to implement a plugin that uses memory-scanning. I found shlifedev/AmongUsMemory, where a basic memory extraction and parsing for Among Us was implemented. However, this approach turned out to be slow because the memory content of the game essentially has to be polled continuously, but each read took quite some time.

Remote Procedure Calls

It is also possible to send RPC commands to the currently running Mumble client, e.g. to mute or unmute the user. This interface was later used for automatic muting and unmuting during in-game meetings.

Chapter 2: Modifying The Game

Now that a plugin was out of the question, the only other way was to somehow get the game to voluntarily send the player position to Mumble. Unfortunately, the game is closed source and only distributed in binary form.

It turns out, the game is written in C# using the Unity Engine. Great news! Tools like dnSpy can decompile, modify and recompile .NET IL binaries (the executable C# compiled to). However, InnerSloth, in an attempt to stop hackers and cheaters, at some point opted to use IL2CPP – a proprietary technology by Unity that compiles the IL binaries to native code. The resulting game has almost no traces of managed IL code or a .NET runtime. This means that popular C#/.NET based game plugin system such as BepInEx or MelonLoader could not be used, as they struggle with IL2CPP. Nowadays, they are able to handle IL2CPP, but when I began this project, they could not (to my knowledge).

The lack of a modloader or plugin framework meant that I essentially had to do everything by hand. Open up the game in a decompiler like Ghidra or IDA Pro, try to understand the machine code, and then patch it? Oof.

Luckily, if found the awesome projects Perfare/Il2CppDumper and djkaty/Il2CppInspector. Both are capable of dumping type information, function signatures, and much more from the IL2CPP binaries. Even better, the latter is even able to automatically generate a project which compiles to a DLL (Dynamic Link Library) that can be injected into the game.

Injecting New Code

Using the IL2CPPInspector, I generated an empty “DLL injection” project. This comes with everything one needs, such as the type-information for game objects in memory and the offsets and signatures of functions.

But first, what even is DLL injection? Essentially, it is one way to execute your own code (payload) inside another third-party process. This means that all modifications occur in memory and the original game binaries remain unmodified. When the DLL is loaded into the address space of the target process by the operating system, it has permission to read and write to and from the same memory space as the target process. As noted earlier, this is also possible from another application, but then it requires administrative privileges – and it is slow. After the DLL is loaded into the process, the operating system executes its entry point, kicking things off.

Usually, applications only load the specific set of DLLs they require for operation. Somehow, we have to trick the game to load the payload. There are several ways to do this:

  • Runtime injection: A third-party application remotely accesses the memory of the target process and places the payload DLL inside. I tested saeedirha/DLL-Injector, and e.g. Cheat Engine should be able to do that too.
  • Load-time injection: Convince the operating system that the payload DLL is required by the game. Because Windows is as stupid as it is, there are multiple approaches:
    • AppInit Entries: A compatibility mechanism that allows specifying additional DLLs for an application using the Registry. Because most uses were malicious, Microsoft chose to discontinue support.
    • PE Import Table Modification: It is possible to add or modify linker entries in the PE (Windows executable) headers using tools like PeNet. The operating system then assumes that the payload DLL is needed by the application and loads it.
    • DLL Search Path Hijacking: Due to how Windows searches for required DLLs, it is possible to load a DLL of the same name instead of the original DLL. DLL names are encoded as names only in the PE header, such that the absolute file path is not known. This makes perfect sense because otherwise, the application would be very specific to one setup (who knows where the user installed DLLs?). However, the DLL is first searched for in the same directory as the application, and then in other directories such as system32. If we now find a Windows DLL from there and provide a replacement with the same name in the application directory, this payload will be loaded instead.

I opted to use load-time injection, as a did not want to rely on external tools, and I hijacked the DLL search path because this provides an easy way of installing the mod.

DLL Proxying

To choose which DLL to replace, I looked at the PE import table of the game. winhttp.dll seemed to be a solid choice, as this is a common Windows DLL used by the Unity Engine. Looking at NeighTools/UnityDoorstop, this was confirmed.

Now, the obvious problem: If we replace a Windows DLL with our own code, then how will the original, very much needed functions be called? This is where a technique called DLL Proxying comes in. In addition to providing the payload code, the new DLL also re-implements all the functions of the original DLL and passed on any calls to those functions to the original. This requires some creative use of assembly code, but luckily I found a tool that automatically generates proxy code for a given DLL: maluramichael/dll-proxy-generator . After some fine-tuning, I had a mostly empty DLL that would pass on calls to winhttp and also attach to Unity objects and functions.

First, the original DLL is loaded by specifying the absolute path (to avoid recursion):

struct winhttp_dll { 
	HMODULE dll;
	// [...]
	FARPROC OrignalWinHttpAddRequestHeaders;
	// [...]
} winhttp;

char path[MAX_PATH];
CopyMemory(path + GetSystemDirectory(path, MAX_PATH - 13), "\\winhttp.dll", 13);
winhttp.dll = LoadLibrary(path);

Then each exported function is loaded and stored in a function pointer:

__declspec(naked) void FakeWinHttpAddRequestHeaders() { 
	_asm { jmp[winhttp.OrignalWinHttpAddRequestHeaders] } 
}
// [...]

winhttp.OrignalWinHttpAddRequestHeaders = GetProcAddress(winhttp.dll, "WinHttpAddRequestHeaders");
// [...]

These pointers are then exported under the same name to provide the proxy interface:

LIBRARY winhttp
EXPORTS
	[...]
	WinHttpAddRequestHeaders=FakeWinHttpAddRequestHeaders @5
	[...]

Chapter 3: Capturing Game Events

Now that we have a way into the game, we can actually implement the core logic. Extract the needed values like player position from the correct objects in memory, and write them to the Mumble Link pipe.

However, a piece is missing: We do not know when to update the values. We also do not know when a meeting occurs, or any other event for that matter. Of course, we could just poll the memory at a fast rate just like before, but this is quite a performance hit. Instead, I chose to use function hooks to automatically get notified.

Function Hooking

In the von Neumann architecture we use today, both code and data are stored in the same memory space. This means, given the required permission, it is possible to modify the machine code of a running process during runtime.

Hooking an existing function (the hooked function) entails overwriting its entry point in memory with a jump instruction that leads to one of our new payload functions (the hook function). Each time the original function is then called, program flow is redirected to the hook function.

This kind of memory patching is not that easy, especially on modern instruction sets and CPUs. Luckily there are several existing libraries such as microsoft/Detours or TsudaKageyu/minhook. These also provide a way to calling the original hooked function using a so-called trampoline. Thanks to this, we can jump back to where the hook was called from and thus transparently insert our custom functions into the program flow of the game.

Using Detours, this process can look like this:

LONG errDetour = DetourTransacLONG errDetour = DetourTransactioDetourTransactionBegin();
DetourUpdateThread(GetCurrentThread());
DetourAttach(&(PVOID&)0xDEADC0DE, PlayerControl_FixedUpdate_Hook);
errDetour = DetourTransactionCommit();

In this example, the function at the address 0xDEADC0DE is redirected to a custom function called PlayerControl_FixedUpdate_Hook (See where this is going?). Luckily, we do not have to remember the raw memory address of each function, because IL2CPPInspector generates function pointers and signatures.

In our own function, we can then read and write function arguments, look at the stack and so much more. This is very useful because many interesting objects or pointers to them only exist on the stack, and thus require a function context to access them:

// Fixed loop for a player object, but only get called when a player moves
void PlayerControl_FixedUpdate_Hook(PlayerControl* __this, MethodInfo* method)
{
    PlayerControl_FixedUpdate_Trampoline(__this, method);
    // This is a "hacky" but very fast check to see if this event is from the local player
    bool isClient = __this->fields.LightPrefab != nullptr;
    if (isClient)
    {
        // Cache position
        Vector2 pos = PlayerControl_GetTruePosition_Trampoline(__this, method);
        mumblePlayer.SetPosX(pos.x);
        mumblePlayer.SetPosY(pos.y);

        // Cache network ID
        mumblePlayer.SetNetID(__this->fields._.NetId);

        // From Player Control, get the Player Data
        PlayerData* Data = PlayerControl_GetData_Trampoline(__this, NULL);
        // And now we can get if we are imposter.
        bool isImposter = Data->fields.*IsImposter;
        mumblePlayer.SetImposter(isImposter);

        // Set if player is using radio
        mumblePlayer.SetUsingRadio(inputSingleton.GetKey(appSettings.radioKey));

        // Check if player is imposter and using radio
        if (mumblePlayer.IsImposter() && mumblePlayer.IsUsingRadio())
        {
            logger.Log(LOG_CODE::MSG, "Imposter Radio");
            // Location is moved to internal value in update player next loop.
        }
    }

}

This code snippet is the current implementation of the player update hook. You can see how some attributes of the current PlayerControl are accessed.

Chapter 4: Circumventing Anti-Cheat

Honestly, I feel kind of bad for breaking all the anti-cheat protections put in place by the developers, and then also documenting and open-sourcing my code. However, it is my firm belief that information must be free and as long as this information is used for good, I don’t see any moral problems.

If InnerSloth really wants to improve the security of the game, I recommend moving away from a client-authoritative network model to a server-authoritative one. Clients should not be trusted, because people will find ways to modify them.

Symbol Deobfuscation

At some point during development, InnserSloth decided to roll out yet another way of cheating protection: Obfuscation. As they cannot obfuscate the entire game binary because that would probably cause all kinds of issues with Unity, they instead obfuscate the names of all functions and types by replacing them with random strings.

This is not the end of the world, using our brain and quite a bit of manual work we can deduce the types and function names by comparing the generated function signatures to previous versions, and by looking at the memory layout of the exported types. A fellow developer and C# genious wrote a rule-based deobfuscator, which automates this process. It uses the output of IL2CPPInspector. Some functions still require some dynamic analysis because there are multiple candidates at times, but this is easily done using Detours and some Python for code generation. All in all, we were able to deobfuscate a new version of the game in about five to ten minutes, which enabled us to push out new versions of the mod in no time.

Unpacking From Memory

At a later point in time, InnserSloth decided they wanted to implement yet another anti-cheat protection. They chose to pack the main game DLL in order to make static reverse engineering (like we do) harder. A packed DLL cannot be analyzed in the usual way, because the majority of its contents are compressed and/or encrypted.

However, in order to actually execute the code contained in the DLL, the code has to be loaded into memory unencrypted. So we just use a tool like glmcdona/Process-Dump to dump the DLLs loaded by the game during runtime to the disk. It goes without saying that packing your binaries offers no protection.

Chapter 5: Putting It All Together

Using all those techniques, I put together the AmongUs-Mumble Mod. It is free and open-source under the GPL-3 license on GitHub.

What started as a personal project for my friends and me has since gained some traction and attracted a community of over 5000 people. More importantly, quite a few very talented and kind people joined the development. Together we were able to add lots of new features (e.g. an in-game GUI by injecting Dear IMGUI, radar, and an installer), fix bugs, and provide server and community support. Without those people, the project would not be where it is now, and my huge thanks go out to them. They also manage our community on Discord.

Check out the project on GitHub, and join our Discord server if you want to.

The Future

Quite a few mods that do the same thing have popped up since I started, and I am happy to see people working on them. More choices mean more power to the user.

Now that modding frameworks officially support IL2CPP, we will probably look into how we can use those and rewrite the mod. This has been requested by users, such that this mod becomes compatible with other mods using the same framework. I will still provide updates from the current version, at least until the new version reaches feature-parity.

Also, please don’t cheat. If I see you using my code to develop a cheat with malicious intent, I can’t stop you due to the license – but I will be very disappointed in you.

That said, have fun playing!

Thanks

Special thanks to @alisenai, @BillyDaBongo, @LelouBil, @ShumWengSang, and @Mogster7 for helping with the development and managing the community.

Image source & copyright: Innersloth http://www.innersloth.com/gameAmongUs.php

Leave a Reply

Your email address will not be published. Required fields are marked *