October 5, 2024
BattlEye: sponsored by caller-controlled input
A ban pipeline that trusted the wrong side of the room, and somehow that room included DayZ, Tarkov, PUBG, and a fake server with confidence issues.
BattlEye advertises itself as the gold standard. Kernel driver, service, client DLL, server DLL, backend, scary capital letters, the whole anti-cheat charcuterie board. Very serious. Very enterprise. Very "please ignore the raccoon in the server closet holding a clipboard."
This post is about the part where the gold standard looked less like gold and more like a gas station bathroom with a SOC 2 badge taped to the mirror. The fact that this company still has major contracts is one of those mysteries that makes you briefly understand why people move into the woods.
The bug is not a memory corruption trick. It is not a packet crypto break. It is not some eleven-stage race condition where the moon has to be in the right place and your NIC driver has to be feeling emotionally available.
It is much dumber than that, which is usually how you know it is going to be good.
The normal pipeline
On the client side, BEDaisy and BEService feed data into BEClient.dll, which is loaded into the game process. The game calls Init() with some game-provided callbacks, and BattlEye hands back callbacks for the game to call during runtime.
Roughly, the client side looks like this:
struct GameData_Client {
const char* gameName;
uint32_t ipAddr;
uint16_t port;
void* printPtr;
void (*requestRestart)(int reason);
void (*sendPacketFunc)(const void* data, int length);
void* disconnectPeer;
};
struct RunData_Client {
void (*Exit)();
void (*Run)();
void* Command;
void (*ReceivedPacket)(void* packet, int len);
void (*ReceiveAuthTicket)(void* ticket, int len);
void* AddPeer;
void* RemovePeer;
void* EncKey;
int EncKeyLen;
void* EncryptPacket;
void* DecryptPacket;
};
int version = 2;
Init(version, &gameData, &runData);
The game receives the callback table, calls into BattlEye when packets arrive, and forwards BattlEye packets to the game server. The game server then passes them to BEServer.dll, which talks to the BattlEye backend. In theory, this is where the adults enter the room.
Server side, the shape is similar:
struct GameData_Server {
const char* gameName;
void* printPtr;
void (*kickPlayerFunc)(int iPID, const char* msg);
void (*sendPacketFunc)(int iPID, void* data, int dataLen);
};
struct RunData_Server {
void* Exit;
void (*Run)();
void* RunCommand;
void (*AddPlayer)(int iPID, uint32_t ulAddress, uint16_t usPort, const char* playerName);
void* RemovePlayer;
void (*ReceivedGuid)(int iPID, const void* guid, uint64_t guidSize);
void* IsGuidValid;
void (*ReceivedPacket)(int iPID, void* packet, int len);
};
The server authenticates players, forwards client data, and receives ban/kick decisions from the backend. That sounds sensible. The game server is supposed to be trusted, the client is supposed to be a crime scene, and the anti-cheat backend is supposed to know which is which.
That last sentence is where the floor starts making noises.
The trust boundary that went on vacation
Some games ship server binaries. DayZ and Unturned are obvious examples. That means you can get a real BEServer.dll, load it yourself, and call its exported init path. At that point the question becomes: how does the server DLL decide which BattlEye backend to connect to?
Surely there is a per-game secret. Surely the module is pinned to the game it shipped with. Surely the backend checks that the server module for Game A is not suddenly pretending to be Game B.
lol. lmao, even.
The DayZ server module selected the backend based on the gameName field passed into Init(). In other words, if you loaded DayZ's BEServer.dll and initialized it with another game's name, it would connect to that other game's backend and behave like a server for that game.
The relevant bit is basically a lookup table of game display names and backend identifiers:
"PLAYERUNKNOWN'S BATTLEGROUNDS" -> "pubg"
"BlackSquad" -> "bsquad"
"Fortnite" -> "fn"
"Zula" -> "zula"
"The Crew 2" -> "tc2"
"Heroes & Generals" -> "hng"
That is the whole joke. The server identity was effectively coming from caller-controlled input. The expensive anti-cheat backend was trusting a name tag.
Becoming the server
Once a server module accepts a different gameName, you are in an extremely cursed place: your fake server can talk to the BattlEye backend for a game it does not actually belong to.
I am not going to paste a turnkey griefer kit here. The interesting part is the trust failure, not the packet-forwarding glue. The short version is that you can create a BattlEye client session, connect it to your own server shim, and forward BattlEye messages between the client side and the server module. The server module then forwards them to the backend while believing it is operating for the chosen game.
This is the sort of bug that makes you check the decompiler twice because surely there has to be another check somewhere. And then there is not. Just vibes, invoices, and production infrastructure.
The account identity problem
The server side has a callback for telling BattlEye which account identity belongs to a player. In the public header-shaped version, it looks like this:
void (*ReceivedGuid)(int iPID, const void* guid, uint64_t guidSize);
Different games use different formats. Some pass an ASCII account identifier. Some use a Steam ID-shaped value. BattlEye also sends enough data back during a normal connection that figuring out your own format is not exactly an archaeological dig.
So now the fake server gets to say, in effect, "this session belongs to this account." If the backend accepts that statement from the server module, the backend's future decisions get attributed to that account.
You can probably see where this is going, and I am sorry.
The ban attribution bug
If you control the account identity for a session, then trigger detections in that session, the backend can attribute those detections to the account ID you supplied. In testing, the result was a permanent BattlEye global ban landing on an account that had not actually logged in for over a year.
That is not "anti-cheat caught a cheater." That is "the wrong identity got stapled to the paperwork and the paperwork had a guillotine attached."
The fake server output ended like this:
[BE] PushNetworkMessage length 1028
[BE] PushNetworkMessage length 1028
[BE] PopNetworkMessage 2
[BE] PushNetworkMessage length 2
[BE] PopNetworkMessage 3
[BE] PushNetworkMessage length 281
[BE] PopNetworkMessage 170
[BE] KickPlayer 1, Global Ban #803d99
The target profile, ta4422802, was level 0, offline, and had PUBG in recent activity. Afterward it showed the lovely little red text:
1 game ban on record | Info
0 day(s) since last ban
Not pictured: the part where everyone involved stares at the screen for a second and quietly wonders if computers were a mistake.
Disclosure, somehow
When I found this, the responsible version was pretty obvious: prove the trust issue, do not ship a griefer kit, and explain why the backend was accepting identity claims from the wrong place. Very normal disclosure stuff, except the bug sounded like something you would make up to bully an anti-cheat vendor in a group chat.
The annoying part is cleanup. If a fake server looks enough like a real server in the backend logs, then you do not get a nice clean grep for "evil fake server, please delete these bans." You get archaeology. Which bans came from borrowed server identity? Which sessions were legitimate? What did the backend actually record at the time? Hope someone logged the right boring field three years ago. Historically, computers love doing that.
The fix is boring and should have existed already: bind each game integration to a secret or credential that cannot be borrowed from another title's server DLL. Legacy EAC had an X-Secret-Key-style parameter for this exact category of problem. This is not wizardry. This is putting a lock on the door instead of a sticky note that says "server."
Aftermath
At the time of writing, this has not been publicly addressed. No clean advisory, no satisfying postmortem, no little paragraph explaining how many accounts are affected or how they plan to unwind the mess. Just the usual security limbo where everyone knows the bug is real and the vendor gets to quietly decide how much oxygen it deserves.