Introduction

To combat masses of video game hackers, anti cheat systems need to collect and process a lot of information from clients. This is usually usually done by sending everything to the servers for further analysis, which allows the attackers to circumvent these systems through interesting means, one of them being hijack of the communication routine.

If an anti cheat is trying to detect a certain cheat by, for example, the name of the process that hosts the cheat code, it will usually parse the entire process list and send it to the server. This way of outsourcing the processing prevents cheaters from reverse engineering the blacklisted process names, as all they can see is that the entire process list is sent to the anti cheat server. This is actually becoming more and more prevalent in the anti cheat community, which raises some serious privacy concerns, simply due to the sheer amount of information being sent to a foreign server.

BattlEye, one of the world’s most installed anti cheats, uses such a routine to send data to their master server over UDP. This function is usually referred to as battleye::send or battleye::report (as in my previous articles). It takes two parameters: buffer and size. Every single piece of information sent to the BattlEye servers is passed through this function, making it very lucrative for hackers to intercept, possibly circumventing every single protection as the game can’t report the anomalies if a hacker is the middleman of communcations. Few cheat developers are actively using this method, as most of them lack the technical skills to reverse engineer and deobfuscate the dynamically streamed modules that BattlEye heavily relies on, but in this post i will shed some light on how this communication routine is being actively exploited, and how BattlEye has tried to mitigate it.

Abuse

BattlEye’s communication routine resides in the module BEClient, which is loaded dynamically by the protected game process. This routine takes “packets” with a two byte header and varying content length, encrypts and afterwards transmits them to the BattlEye server over UDP. Detection routines such as the timing detection or the single stepper rely on this communication, as the results of these tests are sent unfiltered to the BattlEye server for processing. What would happen if you were to hook this function, and simply modify the raw data being sent to prevent the server from banning you? Absolutely nothing, for many years up until last year, when BattlEye added integrity checks of battleye::report to the obfuscated module BEClient2. This integrity check is described in the following section.

Mitigation

It seems to have been desperate times for the BattlEye developers, as they discovered the prevalence of battleye::report hooking in private, highly exclusive cheats in games such as Rainbow Six: Siege and PUBG. Ideally, you would run complete integrity checks of the respective module to ensure fair play, but BattlEye has a very long history of low effort, band-aid fixes, and this is no exception. If you are willing to spend hours on end to devirtualize the module BEClient2, you will notice this integrity check:

*(std::uint64_t*)&report_table[0x3A] = battleye::report;

if ( *(std::uint32_t*)(battleye::report + 5) == 0xCCCCCCCC && 
     *(std::uint32_t*)(battleye::report + 0x1506CA) == 0xFFF3BF25 &&
    (*(std::uint32_t*)(battleye::report + 1) != 0x1506C9 || *((std::uint8_t*)battleye::report + 0x1506CE) != 0x68) )
{
    report_table[0x43] = 1;
}

Instead of doing a full integrity check by, for example running hash comparisons of the module’s code, they’ve decided to simply test certain portions of the function and set a boolean if the conditions are met. If we ignore the poor attempt at integrity checks for a second, how is report_table transmitted to BattlEye’s servers?

battleye::report(report_table, sizeof(report_table));
return;

Holy shit! Not only are the integrity checks very primitive, it relies on its own integrity! This means that any hacker that is already hooking the communication routine and triggering the integrity check, can control the result of the same exact check facepalm. This is actually really trivial to bypass:

Circumvention

As you can see from the integrity check, the result is stored in the report data array + 0x43. With a hook, this can be trivially bypassed as nothing stops you from setting the integrity check result to fit the bill:

void battleye_report_hook(const std::uint8_t* buffer, const std::size_t size)
{
    // ? BECLIENT2 PACKET ?
    if (buffer[1] == PACKET_BECLIENT2) // 0x39
    {
        // SET INTEGRITY CHECK RESULT
        buffer[0x43] = true;
    }
 
    // SEND THE INFORMATION TO BATTLEYE SERVERS
    original_fn(buffer, size);
}

Exercise for the reader: the buffer is actually encrypted with a simple xor key (literally the tick count of when the packet was generated 🙂, it’s stored in plaintext at

(std::uint32_t)report_table[0x1FC]