This blog post describes my lockdown project of (partially) reversing the popular 2019 videogame Age of Empires 2: Definitive Edition. My efforts did not only educate me about lock-step simulation and 90s coding practices, but also lead to various multiplayer hacks.
First I’ll give a brief background on the game’s multiplayer architecture. Then I’ll explain how I interactively explored the game’s internals, until I could do things that should not be possible. A proof of concept that lets you instantly win every online match is provided at the end of the post.
Background
Age of Empires (AoE) is a very popular real-time strategy game series with roots in the late 90s. Numerous expansion packs and remakes have been published since. Exactly twenty years after the original Age of Empires 2 was released, the “Definitive Edition” remake was published in 2019. Like its predecessors it let’s you play online matches against real opponents.
While looking for a way to connect with nature during the corona lockdown (without leaving my basement ofc.), I stumbled upon AoE on steam. Naturally, I sucked at the game but got interested in how it works internally. I stumbled upon an interesting gamasutra article about how the first Age of Empire games managed to accomplish a (at the time) daunting task. They had to simulate several hundred units for up to 8 (!) players on the internet.
The gamasutra article also involved a curious paragraph:
Because the game's outcome depended on all of the users executing exactly the same simulation, it was extremely difficult to hack a client (or client communication stream) and cheat. [...] but these few leaks were relatively easy to secure in subsequent patches and revisions. Security was a huge win.
This sounds like a fun challenge!
The AoE network architecture was designed in the LAN party era, commonly referred to as the 90s. It is based on a lock-step simulation.
To make an interesting story short:
- the game logic runs in “turns”
- each client simulates the game on its own, there is no central server holding state
- any command a player sends (such as moving a unit) is sheduled to be executed two “turns” later
- every command sent needs to be verified and acknowledged by all players
- if clients disagree about anything (e.g. the validity of a command or the position of a unit) the simulation is in a “desynced” state and the match gets terminated
This architecture implies that clients have to carefully inspect incoming commands and perform sanity checks on them. Missing or broken sanity checks would make it possible to send invalid commands that alter the game’s state in unintended ways. And that would be devastating, wouldn’t it?
Exploring the game
What does a 90s networking architecture have to do with “Age of Empires II: Definitive Edition” released in November 2019?
Quite a lot actually, since the game is mostly a graphic overhaul. Much of the underlying code is exactly the same as in the original.
First, I wanted to see if I can perform some actions through code. Using CheatEngine I was able to find a pointer to a unit object, just by manual searching based on changing a units position. Thanks to Runtime Type Information (RTTI) this also gives insights about its inheritance hierarchie. ReClassEx reports this RTTI about our unit object:
AVTRIBE_Combat_Object : AVRGE_Combat_Object : AVRGE_Action_Object : AVRGE_Moving_Object : AVGRGE_Animated_Object : AVRGE_Static_Object
So our unit is a AVTRIBE_Combat_Object which inherits from everything else to the right.
Especially interesting is the inherited AVRGE_Moving_Object
.
Its existence suggest that movement may be a virtual function that is then overwritten.
To find the move function, I set breakpoints on all functions in our units virtual function table and then remove all breakpoints that trigger for unknown reasons.
By trial and error I could get rid of those “random” breakpoints until
moving the unit around triggered only one breakpoint.
This way I could identify the movement
function inside the virtual function table.
To verify that this was indeed the function triggering movement, I wrote a small bot. This bot automatically dodges (my own) catapult attacks by moving all units from the impact location using their virtual move function.
The bot is injected into the Age of Empires process via a dynamic library (.dll), directly calling the movement
function.
Unfortunately, there is a tiny issue with this method of moving units through code. It causes online games to desync and immediately terminate. This is expected since I’m just moving a unit, without sending the appropriate move command to other players. To synchronize the state, that 90s networking code must be used…
Digging deeper
Games tend to have huge code bases. Finding and identifying something like a command handler can be tedious work…
This is where RTTI comes in handy once again.
After some digging and working my way up the callstack of the movement
function, I was able to uncover several classes of interest.
Here is a ReClassEx screenshot of the structures:
Thanks to RTTI I could find the command handler (called AVTRIBE_Command
in the screenshot).
It is now possible to set a breakpoint, which triggers every time the handler is accessed.
Just by joining a multiplayer lobby and performing the actions I’m interested in (e.g. move a unit), I can identify the function for sending the corresponding command to the other players.
Using this method it was quite easy to identify the function responsible for sending a move unit
command to other players.
Here is an IDA screenshot of that exact function:
In order to keep a synced state between players, I have to call this function. As I only want to be able to call this function directly, I’m not terribly interested in its inner workings. Hence I did not reverse engineeer it.
To directly call a function, one needs to know the programs calling convention and which parameters to pass.
Since the game is a 64bit windows binary, the fastcall
calling convention is pretty much a given and IDA Pro happens to agree.
IDA also determined that the function has 8 parameters…
Luckily, dynamic analysis combined with RTTI makes the process of reverse engeneering these parameters quite easy.
I could simply set a breakpoint at the start of the function and analyse the parameters when moving units in a multiplayer match.
The reversed parameters with types are:
- CommandHandler:
AVTRIBE_Command
- Unit we want to move:
AVTRIBE_Combat_Object : AVRGE_Combat_Object : AVRGE_Action_Object : AVRGE_Moving_Object : AVGRGE_Animated_Object : AVRGE_Static_Object
- number of units we want to move:
uint64_t
- some parameter that always seems to be zero:
uint64_t
- target x position on the map:
float
- target y position on the map:
float
- indicator if movement should be queued as a waypoint:
uint8_t
- some parameter that always seemed to be one:
uint8_t
With this information and the function’s offset within the binary, I can define the function’s prototype and call it directly from my dynamic library injected into the AoE process:
typedef int64_t(__thiscall* MoveUnit)(int64_t* command, int64_t* unit, int64_t unitCount, int64_t unknownZero, float x, float y, char asWaypoint, char unknownOne);
//int64_t)GetModuleHandle(NULL) is used to get the ASLR base address of the age of empires process
static MoveUnit moveUnit = (MoveUnit)((int64_t)GetModuleHandle(NULL) + 0xE2CAE0); //this offset is valid as of 12 June 2021.
By calling this function I can move my units from code. That’s pretty cool, but what would happen if I try to move enemy units? Surely that’s where the afromentioned sanity checks would stop me from doing bad things, right?
Losing sanity
Turns out there is no sanity check preventing me from moving my enemies’ units. The game’s user interface only lets you select your own units. So it seems that we can only move our own units.
But since I’m directly calling functions via my injected library, I can just write the pointer to an enemy unit into my own current unit selection. This way I can control the enemy units directly via the user interface. The following GIF shows how I can move my own (blue) and my enemy’s (red) units.
The game does not really visually indicate the movement action of enemy units, as this is not something that should ever happen.
To be honest I did not really expect this to be possible. But since I had so much fun until now, I wanted to see what else is possible. After all disallowing a player to move units owned by other players would be one of the first sanity checks that comes to my mind when dealing with a real time strategy game. Turns out the sky is the limit. There seem to be no sanity checks whatsoever.
We can use the same method to work our way back from breakpoints on the command handler to any action in a multiplayer match we are interested in. One action that seems especially interesting is killing your own units to free up supply (usually done by selecting one of your own units and then pressing the “delete” key). It would be quite severe if we could just instantly kill enemy units with the press of a button. As expected, we can do that, thereby making Age of Empires 2: Definitive Edition my favourite clicker game:
To state the obvious: Enemy units usually don’t come with “kill” buttons attached to them. This is also a good time to remember that this works in multiplayer and also ranked matches. Just for the funsies I also implemented a social distancing functionality that kills all enemy units that get to close to one of my units. After all, whats life without a little whimsy?
Win the game (proof of concept)
I guess every good security related blog post should end with a proof of concept and demo video.
So here is a “instantly win the game” proof of concept for the latest version (2021-12-06 on Steam) of Age of Empires 2: Definitive Edition. It lets you enter a player’s number then loops over all of his units and kills them, instantly defeating the target player.
#include <windows.h>
#include <cstdint>
#include <cstdio>
//working as of 12/06/2021.
/* NOT YET PUBLISHED - MAYBE SOON */
(We decided to not publish the PoC at this very moment. However, if the bugs won’t get fixed, we might publish it via our Twitter at a later time. We just want to make sure that fair play stays possible.)
This also works for ranked matches and could come in handy for tournaments (just kidding ;)). Be aware that the function offsets are hard coded and could change once there is a new patch for the game. The PoC would still work but would need updated offsets.
Showcase
Here is a video showing the PoC in action, killing both enemies’ instantly. In the lower right corner you can see the enemy player’s screen.
Conclusion
Due to hardware and bandwidth limitations of the 90s, Age of Empires uses a distributed state, employing a lock-step approach. Each client simulates the game by itself. There is no central server that holds the ground truth. As it usually is, legacy software lives a long live. In case of “Age of Empires 2: Definitve Edition” the networking architecture and code seem to still be heavily based on the original game from the 90s.
While this network architecture comes with benefits and has a certain appeal to it, it’s security relies purely on sanity checks that each communication partner has to perform. Unfortunately these sanity checks seem to be missing.
As a rewrite of the game seems unlikely, the way to fix this vulnerability would be to introduce sanity checks for every command received from another player.
While I haven’t looked into other “Age of Empires” games it seems plausable that other versions may have similar issues.