BattlEye anticheat: analysis and mitigation

BattlEye

BattlEye is a prevalent German third-party anti-cheat primarily developed by the 32-year-old founder Bastian Heiko Suter. It provides game publishers easy-to-use anti-cheat solutions, using generic protection mechanisms and game-specific detections to provide optimal security, or at least tries to. As their website states, they are always staying on top of state-of-the-art technologies and utilizing innovative methods of protection and detection, evidently due to their nationality: QUALITY MADE IN GERMANY. BattlEye consists of multiple organs that work together to catch and prevent cheaters in the respective games that pay them. The four main entities are:

  • BEService
    • Windows system service that communicates with the BattlEye server BEServer, which provides BEDaisy and BEClient server-client-communication capabilities.
  • BEDaisy
    • Windows kernel driver that registers preventive callbacks and minifilters to prevent cheaters from modifying the game illicitly.
  • BEClient
    • Windows dynamic link library that is responsible for most of the detection vectors, including the ones in this article. It is mapped into the game process after initialization.
  • BEServer
    • Proprietary backend-server that is responsible for collecting information and taking concrete actions against cheaters.

Shellcode

Recently, a dump of BattlEye’s shellcode surfaced on the internet, and we decided to make a write-up of what exactly the current iteration of BattlEye is actively looking for. We have not worked on BattlEye for the past 6 months, so the last piece of shellcode we have dumped is most likely obsolete. Miscellaneous parts of code were recognized completely from memory in this recent dump, suggesting that BattlEye only appends to the shellcode and does not remove previous detection procedures.

How?

BattlEye presumably streams its shellcode from their server to the windows service, known as BEService. This service communicates with the battleye module located inside of the game process, known as BEClient. The communication is done over the named pipe \.namedpipeBattleye and up until last year was unencrypted. Now, all communication is encrypted through a xor cipher with very small keys, making known plaintext attacks trivial. When the shellcode has been streamed to the client, it is allocated and executed outside of any known modules, making distinction easy. To dump the shellcode, you can either hook prevalent windows-API functions like CreateFile, ReadFile, et cetera, and dump any caller’s respective memory section (query memory information on the return address) that is outside of any known module, or periodically scan the game’s virtual memory space for executable memory outside of any known module, and dump it to disk. Make sure to keep track of which sections you have dumped so you do not end up with thousands of identical dumps.

Disclaimer

The following pseudo-code snippets are heavily beautified. You will not be able to dump the BattlEye shellcode and instantly recognize some of these parts; the shellcode does not contain any function calls, and many algorithms are unrolled. That doesn’t really matter, as when you’re finished reading about this atrocious anti-cheat, you will have a field day bypassing it (:

Memory enumeration

The most common detection mechanism anti-cheat solutions utilize is memory enumeration and memory scanning, to detect known cheat images. It’s easy to implement and quite effective when done correctly, as long as you don’t forget basic assembly and blacklist a common function prologue, as we’ve seen in the past.

Battleye enumerates the entire address space of the game process (current process in the following context) and runs various checks whenever a page is executable and outside of the respective shellcode memory space.

This is their implementation:

// MEMORY ENUMERATION
for (current_address = 0
    // QUERY MEMORY_BASIC_INFORMATION
    NtQueryVirtualMemory(GetCurrentProcess(), current_address, 0, &memory_information, 0x30, &return_length) >= 0 
    current_address = memory_information.base_address + memory_information.region_size) 
{

    const auto outside_of_shellcode = 
        memory_information.base_address > shellcode_entry || 
        memory_information.base_address + memory_information.region_size <= shellcode_entry

    const auto executable_memory = 
        memory_information.state == MEM_COMMIT &&
        (memory_information.protect == PAGE_EXECUTE ||
        memory_information.protect == PAGE_EXECUTE_READ ||
        memory_information.protect == PAGE_EXECUTE_READWRITE

    const auto unknown_whitelist = 
        memory_information.protect != PAGE_EXECUTE_READWRITE || 
        memory_information.region_size != 100000000

    if (!executable_memory || !outside_of_shellcode || !unknown_whitelist)
        continue

    // RUN CHECKS
    memory::anomaly_check(memory_information

    memory::pattern_check(current_address, memory_information

    memory::module_specific_check_microsoft(memory_information

    memory::guard_check(current_address, memory_information

    memory::module_specific_check_unknown(memory_information
}

Memory anomaly

BattlEye will flag any anomalies in the memory address space, primarily executable memory that does not correspond to a loaded image:

void memory::anomaly_check(MEMORY_BASIC_INFORMATION memory_information)
{
    // REPORT ANY EXECUTABLE PAGE OUTSIDE OF KNOWN MODULES
    if (memory_information.type == MEM_PRIVATE || memory_information.type == MEM_MAPPED) 
    {
        if ((memory_information.base_address & 0xFF0000000000) != 0x7F0000000000 && // UPPER EQUALS 0x7F
            (memory_information.base_address & 0xFFF000000000) != 0x7F000000000 &&  // UPPER EQUALS 0x7F0
            (memory_information.base_address & 0xFFFFF0000000) != 0x70000000 && // UPPER EQUALS 0x70000
            memory_information.base_address != 0x3E0000))
        {
            memory_report.unknown = 0
            memory_report.report_id = 0x2F
            memory_report.base_address = memory_information.base_address
            memory_report.region_size = memory_information.region_size
            memory_report.memory_info = 
                memory_information.type | 
                memory_information.protect | 
                memory_information.state
            
            battleye::report(&memory_report, sizeof(memory_report), 0
        }
    }
}

Pattern scans

As we previously mentioned, BattlEye also scans memory of the local process for various hard-coded patterns, as the following implementation shows.
What you might realize when reading this pseudo-code is that you can bypass these checks by overwriting any loaded module’s code section, as they will not run any pattern scans on known images. To prevent being hit by integrity checks, load any packed, white-listed module and overwrite code sections marked as RWX, as you can’t run integrity checks without emulating the packer. The current iteration of BattlEye’s shellcode has these memory patterns hard-coded:

[05 18] ojectsPUBGChinese
[05 17] BattleGroundsPrivate_CheatESP
[05 17] [%.0fm] %s
[05 3E] 0000Neck0000Chest0000000Mouse 10
[05 3F] PlayerESPColor
[05 40]  Aimbot: %d02D3E2041
[05 36] HackMachine
[05 4A] VisualHacks.net
[05 50] 3E232F653E31314E4E563D4276282A3A2E463F757523286752552E6F30584748
[05 4F] DLLInjection-master\x64\Release\
[05 52] NameESP
[05 48] Skullhack
[05 55] .rdata$zzzdbg
[05 39] AimBot
[05 39] EB4941803C123F755C623FEB388D41D0FBEC93C977583E930EB683E1DF
[05 5F] 55E9
[05 5F] 57E9
[05 5F] 60E9
[05 68] D3D11Present initialised
[05 6E] [ %.0fM ]
[05 74] [hp:%d]%dm
[05 36] 48836424380488D4C2458488B5424504C8BC848894C24304C8BC7488D4C2460
[05 36] 741FBA80000FF15607E0085C07510F2F1087801008B8788100EB
[05 36] 40F2AA156F8D2894E9AB4489535D34F9CPOSITION0000COL
[05 7A] FFE090
[05 79] %s00%d00POSITION0000COLOR0000000
[05 36] 8E85765DCDDA452E75BA12B4C7B94872116DB948A1DAA6B948A7676BB948902C
[05 8A] n<assembly xmlsn='urn:schemas-mi

These memory patterns also contain a two-byte header, respectively an unknown static value 05 and an unique identifier.
What you won’t see here is that BattlEye also dynamically streams patterns from BEServer and sends them to BEClient, but we won’t be covering those in this article.

These are iteratively scanned for by the following algorithm:


void memory::pattern_check(void* current_address, MEMORY_BASIC_INFORMATION memory_information)
{
    const auto is_user32 = memory_information.allocation_base == GetModuleHandleA("user32.dll"
    
    // ONLY SCAN PRIVATE MEMORY AND USER32 CODE SECTION
    if (memory_information.type != MEM_PRIVATE && !is_user32) 
        continue

    for (address = current_address 
         address != memory_information.base_address + memory_information.region_size 
         address += PAGE_SIZE) // PAGE_SIZE
    {
        // READ ENTIRE PAGE FROM LOCAL PROCESS INTO BUFFER
        if (NtReadVirtualMemory(GetCurrentProcess(), address, buffer, PAGE_SIZE, 0) < 0)
            continue

        for (pattern_index = 0 pattern_index < 0x1C/*PATTERN COUNT*/ ++pattern_index)
        {
            if (pattern[pattern_index].header == 0x57A && !is_user32) // ONLY DO FFE090 SEARCHES WHEN IN USER32
                continue

            for (offset = 0 pattern[pattern_index].length + offset <= PAGE_SIZE ++offset) 
            {
                const auto pattern_matches = 
                    memory::pattern_match(&address[offset], pattern[pattern_index //    BASIC PATTERN MATCH

                if (pattern_matches) 
                {
                    // PATTERN FOUND IN MEMORY
                    pattern_report.unknown = 0
                    pattern_report.report_id = 0x35
                    pattern_report.type = pattern[index].header
                    pattern_report.data = &address[offset
                    pattern_report.base_address = memory_information.base_address
                    pattern_report.region_size = memory_information.region_size
                    pattern_report.memory_info = 
                        memory_information.type | 
                        memory_information.protect | 
                        memory_information.state

                    battleye::report(&pattern_report, sizeof(pattern_report), 0
                }
            }
        }
    }
}

Module specific (Microsoft)

The module specific checks will report you for having specific modules loaded into the game process:

void memory::module_specific_check_microsoft(MEMORY_BASIC_INFORMATION memory_information)
{
    auto executable = 
        memory_information.protect == PAGE_EXECUTE || 
        memory_information.protect == PAGE_EXECUTE_READ || 
        memory_information.protect == PAGE_EXECUTE_READWRITE

    auto allocated = 
        memory_information.state == MEM_COMMIT

    if (!allocated || !executable)
        continue

    auto mmres_handle = GetModuleHandleA("mmres.dll"
    auto mshtml_handle = GetModuleHandleA("mshtml.dll"

    if (mmres_handle && mmres_handle == memory_information.allocation_base)
    {
        battleye_module_anomaly_report module_anomaly_report
        module_anomaly_report.unknown = 0
        module_anomaly_report.report_id = 0x5B
        module_anomaly_report.identifier = 0x3480
        module_anomaly_report.region_size = memory_information.region_size
        battleye::report(&module_anomaly_report, sizeof(module_anomaly_report), 0   
    }
    else if (mshtml_handle && mshtml_handle == memory_information.allocation_base)
    {
        battleye_module_anomaly_report module_anomaly_report
        module_anomaly_report.unknown = 0
        module_anomaly_report.report_id = 0x5B
        module_anomaly_report.identifier = 0xB480
        module_anomaly_report.region_size = memory_information.region_size
        battleye::report(&module_anomaly_report, sizeof(module_anomaly_report), 0  
    }
}

Module specific (Unknown)

A very specific module check has been added that will report you to the server if your loaded module meets any of these criteria:

void memory::module_specific_check_unknown(MEMORY_BASIC_INFORMATION memory_information)
{
    const auto dos_header = (DOS_HEADER*)module_handle
    const auto pe_header = (PE_HEADER*)(module_handle + dos_header->e_lfanew

    const auto is_image = memory_information.state == MEM_COMMIT && memory_information.type == MEM_IMAGE
    if (!is_image)
        return

    const auto is_base = memory_information.base_address == memory_information.allocation_base
    if (!is_base)
        return

    const auto match_1 = 
        time_date_stamp == 0x5B12C900 && 
        *(__int8*)(memory_information.base_address + 0x1000) == 0x00 &&
        *(__int32*)(memory_information.base_address + 0x501000) != 0x353E900

    const auto match_2 = 
        time_date_stamp == 0x5A180C35 && 
        *(__int8*)(memory_information.base_address + 0x1000) != 0x00

    const auto match_2 = 
        time_date_stamp == 0xFC9B9325 && 
        *(__int8*)(memory_information.base_address + 0x6D3000) != 0x00

    if (!match_1 && !match_2 && !match_3)
        return
    
    const auto buffer_offset = 0x00 // OFFSET DEPENDS ON WHICH MODULE MATCHES, RESPECTIVELY 0x501000, 0x1000 AND 0x6D3000

    unknown_module_report.unknown1 = 0
    unknown_module_report.report_id = 0x46
    unknown_module_report.unknown2 = 1
    unknown_module_report.data = *(__int128*)(memory_information.base_address + buffer_offset 
    battleye::report(&unknown_module_report, sizeof(unknown_module_report), 0
}

We do not know which modules meet these criteria, but suspect it is an attempt to detect very few, specific cheat modules.

Edit: @how02 alerted us that the module action_x64.dll has the timestamp 0x5B12C900, and contains a code section that is writeable, which could be exploitable as previously mentioned.

Memory guard

BattlEye has also incorporated a very questionable detection routine that we believe is seeking out memory with the flag PAGE_GUARD set, without actually checking if the PAGE_GUARD flag is set:

void memory::guard_check(void* current_address, MEMORY_BASIC_INFORMATION memory_information)
{
    if (memory_information.protect != PAGE_NOACCESS)
    {
        auto bad_ptr = IsBadReadPtr(current_address, sizeof(temporary_buffer
        auto read = NtReadVirtualMemory(
            GetCurrentProcess(), 
            current_address, 
            temporary_buffer, sizeof(temporary_buffer), 
            0

        if (read < 0 || bad_ptr)
        {
            auto query = NtQueryVirtualMemory(
                GetCurrentProcess(), 
                current_address, 
                0, 
                &new_memory_information, sizeof(new_memory_information), 
                &return_length

            memory_guard_report.guard = 
                    query < 0 || 
                    new_memory_information.state != memory_information.state || 
                    new_memory_information.protect != memory_information.protect

            if (memory_guard_report.guard)
            {
                memory_guard_report.unknown = 0
                memory_guard_report.report_id = 0x21
                memory_guard_report.base_address = memory_information.base_address
                memory_guard_report.region_size = (int)memory_information.region_size
                memory_guard_report.memory_info = 
                    memory_information.type | 
                    memory_information.protect | 
                    memory_information.state

                battleye::report(&memory_guard_report, sizeof(memory_guard_report), 0
            }
        }
    }
}

Window enumeration

BattlEye’s shellcode enumerates every single window that is currently visible while the game is running, which it does by iterating windows from the top-down (z-value). Window handles inside of the game process are excluded from the aforementioned enumeration, as determined by a GetWindowThreadProcessId call. You can therefore hook the respective function to spoof ownership of the window and prevent BattlEye from enumerating your window.

void window_handler::enumerate()
{
    for (auto window_handle = GetTopWindow
         window_handle 
         window_handle = GetWindow(window_handle, GW_HWNDNEXT), // GET WINDOW BELOW
         ++window_handler::windows_enumerated)                  // INCREMENT GLOBAL COUNT FOR LATER USAGE
    {
        auto window_process_pid = 0
        GetWindowThreadProcessId(window_handle, &window_process_pid

        if (window_process_pid == GetCurrentProcessId())
            continue

        // APPEND INFORMATION TO THE MISC. REPORT, THIS IS EXPLAINED LATER IN THE ARTICLE
        window_handler::handle_summary(window_handle

        constexpr auto max_character_count = 0x80
        const auto length = GetWindowTextA(window_handle, window_title_report.window_title, max_character_count

        // DOES WINDOW TITLE MATCH ANY OF THE BLACKLISTED TITLES?
        if (!contains(window_title_report.window_title, "CheatAut") &&
            !contains(window_title_report.window_title, "pubg_kh") &&
            !contains(window_title_report.window_title, "conl -") &&
            !contains(window_title_report.window_title, "PerfectA") &&
            !contains(window_title_report.window_title, "AIMWA") &&
            !contains(window_title_report.window_title, "PUBG AIM") &&
            !contains(window_title_report.window_title, "HyperChe"))
            continue

        // REPORT WINDOW
        window_title_report.unknown_1 = 0
        window_title_report.report_id = 0x33
        battleye::report(&window_title_report, sizeof(window_title_report) + length, 0
    }
}

Anomaly in enumeration

If fewer than two windows were enumerated, the server gets notified. This is probably done to prevent someone from patching the respective functions, preventing any windows from being looked at by BattlEye’s shellcode:

void window_handler::check_count()
{
    if (window_handler::windows_enumerated > 1)
        return
    
    // WINDOW ENUMERATION FAILED, MOST LIKELY DUE TO HOOK
    window_anomaly_report.unknown_1 = 0
    window_anomaly_report.report_id = 0x44
    window_anomaly_report.enumerated_windows = windows_enumerated
    battleye::report(&window_anomaly_report, sizeof(window_anomaly_report), 0
    
}

Process enumeration

BattlEye enumerates all running processes with a CreateToolhelp32Snapshot call, but does not handle any errors, making it very easy to patch and prevent any of the following detection routines:

Path check

If image is inside of at least two sub directories (from disk root), it will flag processes if the respective image path contains at least one of these strings:

Desktop
Temp
FileRec
Documents
Downloads
Roaming
tmp.ex
notepad.
...\.
cmd.ex

If your executable path matches one of these strings, the server will get notified of your executable path, as well as information on whether or not the parent process is one of the following (contains respective flag bit sent to server):

steam.exe       [0x01]
explorer.exe    [0x02]
lsass.exe       [0x08]
cmd.exe         [0x10]

If the client cannot open a handle with the respective QueryLimitedInformation rights, it will set the flag bit 0x04 if error reason for the OpenProcess call fail does not equal ERROR_ACCESS_DENIED, which gives us the final enumeration container for the respective flag value:

enum BATTLEYE_PROCESS_FLAG
{
  STEAM     = 0x1,
  EXPLORER  = 0x2,
  ERROR     = 0x4,
  LSASS     = 0x8,
  CMD       = 0x10
}

If steam is the parent process, you will get instantly flagged and reported to the server with report id 0x40

Image name

If your process matches any of the miscellaneous criteria below, you will get instantly flagged and reported to the server with report id 0x38

Image name contains "Loadlibr"
Image name contains "Rng "
Image name contains "A0E7FFFFFF81"
Image name contains "RNG "
Image name contains "90E54355"
Image name contains "2.6.ex"
Image name contains "TempFile.exe"

Steam game overlay

BattlEye is keeping its eye out on the steam game overlay process, which is responsible for the in-game overlay most steam users know. The full image name of the steam game overlay host is gameoverlayui.exe and is known to be exploited for rendering purposes, as it is quite trivial to hijack and maliciously draw to the game window. The condition for the check is:

file size != 0 && image name contains (case insensitive) gameoverlayu

The following checks specific to the steam game overlay are almost identical to the routines being ran on the game process itself, therefore they have been omitted from the pseudo code.

Steam Game Overlay memory scan

The steam game overlay process will have its memory scanned for patterns and anomalies. We were unable to go further down the rabbit hole and find out what these patterns are for, as they are very generic and are probably cheat-module related.

void gameoverlay::pattern_scan(MEMORY_BASIC_INFORMATION memory_information)
{
    // PATTERNS:
    // Home
    // F1
    // FFFF83C48C30000000000
    // \.pipe%s
    // C760000C64730
    // 60C01810033D2

    // ... 
    // PATTERN SCAN, ALMOST IDENTICAL CODE TO THE AFOREMENTIONED PATTERN SCANNING ROUTINE

    gameoverlay_memory_report.unknown_1 = 0
    gameoverlay_memory_report.report_id = 0x35
    gameoverlay_memory_report.identifier = 0x56C
    gameoverlay_memory_report.data = &buffer[offset
    gameoverlay_memory_report.base_address = memory_information.base_address
    gameoverlay_memory_report.region_size = (int)memory_information.region_size
    gameoverlay_memory_report.memory_info = 
        memory_information.type | 
        memory_information.protect | 
        memory_information.state

    battleye::report(&gameoverlay_memory_report, sizeof(gameoverlay_memory_report), 0
}

The scan routine also looks for any anomalies in the form of executable memory outside of loaded images, suggesting intruders have injected code into the overlay process:

void gameoverlay::memory_anomaly_scan(MEMORY_BASIC_INFORMATION memory_information)
{  
    // ...
    // ALMOST IDENTICAL ANOMALY SCAN COMPARED TO MEMORY ENUMERATION ROUTINE OF GAME PROCESS

    gameoverlay_report.unknown = 0
    gameoverlay_report.report_id = 0x3B
    gameoverlay_report.base_address = memory_information.base_address
    gameoverlay_report.region_size = memory_information.region_size
    gameoverlay_report.memory_info = memory_information.type | memory_information.protect | memory_information.state
    battleye::report(&gameoverlay_report, sizeof(gameoverlay_report), 0
}

Steam Game Overlay process protection

If the steam game overlay process has been protected using any windows process protection like Light (WinTcb), the server will get notified.

void gameoverlay::protection_check(HANDLE process_handle)
{
    auto process_protection = 0

    NtQueryInformationProcess(
        process_handle, ProcessProtectionInformation, 
        &process_protection, sizeof(process_protection), nullptr

    if (process_protection == 0) // NO PROTECTION
        return

    gameoverlay_protected_report.unknown = 0
    gameoverlay_protected_report.report_id = 0x35
    gameoverlay_protected_report.identifier = 0x5B1
    gameoverlay_protected_report.data = process_protection
    battleye::report(&gameoverlay_protected_report, sizeof(gameoverlay_protected_report), 0
}

You will also get reported with report id 3B if the respective OpenProcess call to the aforementioned game overlay process returns ERROR_ACCESS_DENIED.

Module enumeration

Modules of the steam game overlay process are also enumerated, specifically looking for vgui2_s.dll and gameoverlayui.dll. Certain checks have been put in place for these respective modules, beginning with gameoverlayui.dll.

If this condition matches: [gameoverlayui.dll+6C779] == 08BE55DC3CCCCB8????????C3CCCCCC, the shellcode will scan a vtable at the address stored in the bytes ????????. If any of these vtable entries are outside of the original gameoverlayui.dll module or point to an int 3 instruction, you get reported with the report id 3B.

void gameoverlay::scan_vtable(HANDLE process_handle, char* buffer, MODULEENTRY32 module_entry)
{
    char function_buffer[16

    for (vtable_index = 0 vtable_index < 20 vtable_index += 4)
    {
        NtReadVirtualMemory(
          process_handle,
          *(int*)&buffer[vtable_index],
          &function_buffer,
          sizeof(function_buffer),
          0

        if (*(int*)&buffer[vtable_index] < module_entry.modBaseAddr ||
            *(int*)&buffer[vtable_index] >= module_entry.modBaseAddr + module_entry.modBaseSize ||
            function_buffer[0] == 0xCC )    // FUNCTION PADDING
        {
            gameoverlay_vtable_report.report_id = 0x3B
            gameoverlay_vtable_report.vtable_index = vtable_index
            gameoverlay_vtable_report.address = buffer[vtable_index
            battleye::report(&gameoverlay_vtable_report, sizeof(gameoverlay_vtable_report), 0
        }
    }
}

The vgui2_s.dll module also has a specific check routine set in place:

void vgui::scan()
{
    if (!equals(vgui_buffer, "6A08B31FF561C8BD??????????FF96????????8BD????????8B1FF90"))
    {
        auto could_read = NtReadVirtualMemory(
            process_handle, module_entry.modBaseAddr + 0x48338, vgui_buffer, 8, 0) >= 0

        constexpr auto pattern_offset = 0x48378

        // IF READ DID NOT FAIL AND PATTERN IS FOUND
        if (could_read && equals(vgui_buffer, "6A46A06A26A"))
        {
            vgui_report.unknown_1 = 0
            vgui_report.report_id = 0x3B
            vgui_report.unknown_2 = 0
            vgui_report.address = LODWORD(module_entry.modBaseAddr) + pattern_offset

            // READ TARGET BUFFER INTO REPORT
            NtReadVirtualMemory(
              process_handle,
              module_entry.modBaseAddr + pattern_offset,
              vgui_report.buffer,
              sizeof(vgui_report.buffer),
              0

            battleye::report(&vgui_report, sizeof(vgui_report), 0
        }
    }
    else if (
            // READ ADDRESS FROM CODE
            NtReadVirtualMemory(process_handle, *(int*)&vgui_buffer[9], vgui_buffer, 4, 0) >= 0 &&
            // READ POINTER TO CLASS
            NtReadVirtualMemory(process_handle, *(int*)vgui_buffer, vgui_buffer, 4, 0) >= 0 && 
            // READ POINTER TO VIRTUAL TABLE
            NtReadVirtualMemory(process_handle, *(int*)vgui_buffer, vgui_buffer, sizeof(vgui_buffer), 0) >= 0)
    {
        for (vtable_index = 0 vtable_index < 984 vtable_index += 4 )      // 984/4 VTABLE ENTRY COUNT
        {
            NtReadVirtualMemory(process_handle, *(int*)&vgui_buffer[vtable_index], &vtable_entry, sizeof(vtable_entry), 0

            if (*(int*)&vgui_buffer[vtable_index] < module_entry.modBaseAddr ||
                *(int*)&vgui_buffer[vtable_index] >= module_entry.modBaseAddr + module_entry.modBaseSize ||
                vtable_entry == 0xCC )
            {
                vgui_vtable_report.unknown = 0
                vgui_vtable_report.report_id = 0x3B
                vgui_vtable_report.vtable_index = vtable_index
                vgui_vtable_report.address = *(int*)&vgui_buffer[vtable_index
                battleye::report(&vgui_vtable_report, sizeof(vgui_vtable_report), 0
            }
        }
    }
}

The previous routine checks for a modification at 48378, which is a location in the code section:

push    04
push    offset aCBuildslaveSte_4 ; "c:\buildslave\steam_rel_client_win32"...
push    offset aAssertionFaile_7 ; "Assertion Failed: IsValidIndex(elem)"

The routine then checks for a very specific and seemingly garbage modification:

push    04
push    00
push    02
push    ??

We were unable to obtain a copy of vgui2_s.dll that did not match the first of the two aforementioned checks, so we can’t discuss which vtable it is checking.

Steam Game Overlay threads

Threads in the steam game overlay process are also enumerated:

void gameoverlay::check_thread(THREADENTRY32 thread_entry)
{
    const auto tread_handle = OpenThread(THREAD_SUSPEND_RESUME|THREAD_GET_CONTEXT, 0, thread_entry.th32ThreadID
    if (thread_handle)
    {
        suspend_count = ResumeThread(thread_handle
        if (suspend_count > 0)
        {
            SuspendThread(thread_handle
            gameoverlay_thread_report.unknown = 0
            gameoverlay_thread_report.report_id = 0x3B
            gameoverlay_thread_report.suspend_count = suspend_count
            battleye::report(&gameoverlay_thread_report, sizeof(gameoverlay_thread_report), 0
        }

        if (GetThreadContext(thread_handle, &context) && context.Dr7)
        {
            gameoverlay_debug_report.unknown = 0
            gameoverlay_debug_report.report_id = 0x3B
            gameoverlay_debug_report.debug_register = context.Dr0
            battleye::report(&gameoverlay_debug_report, sizeof(gameoverlay_debug_report), 0
        }
    }
}

LSASS

The memory address space of the windows process lsass.exe, also known as the Local Security Authority process, is enumerated and any anomalies will be reported to the server, just like we’ve seen in the two previous checks:

if (equals(process_entry.executable_path, "lsass.exe"))
{
    auto lsass_handle = OpenProcess(QueryInformation, 0, (unsigned int)process_entry.th32ProcessID
    if (lsass_handle)
    {
        for (address = 0
              NtQueryVirtualMemory(lsass_handle, address, 0, &lsass_memory_info, 0x30, &bytes_needed) >= 0
              address = lsass_memory_info.base_address + lsass_memory_info.region_size)
        {
            if (lsass_memory_info.state == MEM_COMMIT
              && lsass_memory_info.type == MEM_PRIVATE
              && (lsass_memory_info.protect == PAGE_EXECUTE
               || lsass_memory_info.protect == PAGE_EXECUTE_READ
               || lsass_memory_info.protect == PAGE_EXECUTE_READWRITE))
            {
                // FOUND EXECUTABLE MEMORY OUTSIDE OF MODULES
                lsass_report.unknown = 0
                lsass_report.report_id = 0x42
                lsass_report.base_address = lsass_memory_info.base_address
                lsass_report.region_size = lsass_memory_info.region_size
                lsass_report.memory_info = 
                    lsass_memory_info.type | lsass_memory_info.protect | lsass_memory_info.state
                battleye::report(&lsass_report, sizeof(lsass_report), 0
            }
        }
        CloseHandle(lsass_handle
    }
}

LSASS has previously been exploited to perform memory operations, as any process that would like an internet connection needs to let LSASS have access to it. BattlEye has currently mitigated this issue by manually stripping the process handle of read/write access and then hooking ReadProcessMemory/WriteProcessMemory, redirecting the calls to their driver, BEDaisy. BEDaisy then decides whether or not the memory operation is a legit operation. If it determines that the operation is legitimate, it will continue it, else, they will deliberately blue-screen the machine.

Misc. report

BattlEye gathers miscellaneous information and sends it back to the server with the report id 3C. This information consists of:

  • Any window with WS_EX_TOPMOST flag or equivalent alternatives:
    • Window text (Unicode)
    • Window class name (Unicode)
    • Window style
    • Window extended style
    • Window rectangle
    • Owner process image path
    • Owner process image size
  • Any process with an open process handle (VM_WRITE|VM_READ) to the game
    • Image name
    • Image path
    • Image size
    • Handle access
  • File size of game specific files:
    • ….ContentPaksTslGame-WindowsNoEditor_assets_world.pak
    • ….ContentPaksTslGame-WindowsNoEditor_ui.pak
    • ….ContentPaksTslGame-WindowsNoEditor_sound.pak
  • Contents of game specific files:
    • ….BLGameCookedContentScriptBLGame.u
  • Detour information of NtGetContextThread
    • Any jump instructions (E9) are followed and the final address get’s logged

NoEye

BattlEye has implemented a specific and rather lazy check to detect the presence of the public bypass known as NoEye, by checking the file size of any file found by GetFileAttributesExA with the name of BE_DLL.dll, suggesting the library file can be found on disk.

void noeye::detect()
{
    WIN32_FILE_ATTRIBUTE_DATA file_information
    if (GetFileAttributesExA("BE_DLL.dll", 0, &file_information))
    {
      noeye_report.unknown = 0
      noeye_report.report_id = 0x3D
      noeye_report.file_size = file_information.nFileSizeLow
      battleye::report(&noeye_report, sizeof(noeye_report), 0
    }
}

Driver presence

The devices Beep and Null are checked, and reported if present. These two are not normally available on any system, which would indicate someone manually enabled a device, also known as driver device hijacking. This is done to enable IOCTL communication with a malicious driver without requiring an independent driver object for said driver.

void driver::check_beep()
{
    auto handle = CreateFileA("\\.\Beep", GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, 0, OPEN_EXISTING, 0, 0
    if (handle != INVALID_HANDLE_VALUE)
    {
      beep_report.unknown = 0
      beep_report.report_id = 0x3E
      battleye::report(&beep_report, sizeof(beep_report), 0
      CloseHandle(handle
    }
}
void driver::check_null()
{
    auto handle = CreateFileA("\\.\Null", GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, 0, OPEN_EXISTING, 0, 0
    if (handle != INVALID_HANDLE_VALUE)
    {
      null_report.unknown = 0
      null_report.report_id = 0x3E
      battleye::report(&null_report, sizeof(null_report), 0
      CloseHandle(handle
    }
}

Sleep delta

BattlEye will also queue the current thread for a one second sleep and measure the difference in tick-count from before and after the sleep:

void sleep::check_delta()
{
    const auto tick_count = GetTickCount
    Sleep(1000
    const auto tick_delta = GetTickCount() - tick_count
    if (tick_delta >= 1200)
    {
        sleep_report.unknown = 0
        sleep_report.report_id = 0x45
        sleep_report.delta = tick_delta
        battleye::report(&sleep_report, sizeof(sleep_report), 0
    }
}

7zip

BattlEye has added a very lazy integrity check to prevent people loading the 7zip library into game processes and overwriting the sections. This was done to mitigate the previous pattern scans and anomaly detections, and battleye decided to only add integrity checks for this specific 7zip library.

void module::check_7zip()
{
    constexpr auto sz_7zipdll = "..\..\Plugins\ZipUtility\ThirdParty\7zpp\dll\Win64\7z.dll"
    const auto module_handle = GetModuleHandleA(sz_7zipdll
    if (module_handle && *(int*)(module_handle + 0x1000) != 0xFF1441C7)
    {
      sevenzip_report.unknown_1 = 0
      sevenzip_report.report_id = 0x46
      sevenzip_report.unknown_2 = 0
      sevenzip_report.data1 = *(__int64*)(module_handle + 0x1000
      sevenzip_report.data2 = *(__int64*)(module_handle + 0x1008
      battleye::report(&sevenzip_report, sizeof(sevenzip_report), 0
    }
}

Hardware abstraction layer

BattlEye checks the presence of the windows hardware abstraction layer dynamic link library (hal.dll), and reports to server if it is loaded inside of the game process.

void module::check_hal()
{
    const auto module_handle = GetModuleHandleA("hal.dll"
    if (module_handle)
    {
        hal_report.unknown_1 = 0
        hal_report.report_id = 0x46
        hal_report.unknown_2 = 2
        hal_report.data1 = *(__int64*)(module_handle + 0x1000
        hal_report.data2 = *(__int64*)(module_handle + 0x1008
        battleye::report(&hal_report, sizeof(hal_report), 0
    }
}

Image checks

BattlEye also checks for various images loaded into the game process. These modules are presumably signed images that are somehow manipulated into abusive behavior, but we can’t comment on the full extent of these modules, only the detections:

nvToolsExt64_1

void module::check_nvtoolsext64_1
{
    const auto module_handle = GetModuleHandleA("nvToolsExt64_1.dll"
    if (module_handle)
    {
      nvtools_report.unknown = 0
      nvtools_report.report_id = 0x48
      nvtools_report.module_id = 0x5A8
      nvtools_report.size_of_image = (PE_HEADER*)(module_handle + (DOS_HEADER*)(module_handle)->e_lfanew))->SizeOfImage
      battleye::report(&nvtools_report, sizeof(nvtools_report), 0
    }
}

ws2detour_x96

void module::check_ws2detour_x96
{
    const auto module_handle = GetModuleHandleA("ws2detour_x96.dll"
    if (module_handle)
    {
      ws2detour_report.unknown = 0
      ws2detour_report.report_id = 0x48
      ws2detour_report.module_id = 0x5B5
      ws2detour_report.size_of_image = (PE_HEADER*)(module_handle + (DOS_HEADER*)(module_handle)->e_lfanew))->SizeOfImage
      battleye::report(&ws2detour_report, sizeof(ws2detour_report), 0
    }
}

networkdllx64

void module::check_networkdllx64
{
    const auto module_handle = GetModuleHandleA("networkdllx64.dll"
    if (module_handle)
    {
        const auto dos_header = (DOS_HEADER*)module_handle
        const auto pe_header = (PE_HEADER*)(module_handle + dos_header->e_lfanew
        const auto size_of_image = pe_header->SizeOfImage

        if (size_of_image < 0x200000 || size_of_image >= 0x400000)
        {
            if (pe_header->sections[DEBUG_DIRECTORY].size == 0x1B20)
            {
                networkdll64_report.unknown = 0
                networkdll64_report.report_id = 0x48
                networkdll64_report.module_id = 0x5B7
                networkdll64_report.data = pe_header->TimeDatestamp
                battleye::report(&networkdll64_report, sizeof(networkdll64_report), 0
            }
        }
        else
        {
            networkdll64_report.unknown = 0
            networkdll64_report.report_id = 0x48
            networkdll64_report.module_id = 0x5B7
            networkdll64_report.data = pe_header->sections[DEBUG_DIRECTORY].size
            battleye::report(&networkdll64_report, sizeof(networkdll64_report), 0
        }
    }
}

nxdetours_64

void module::check_nxdetours_64
{
    const auto module_handle = GetModuleHandleA("nxdetours_64.dll"
    if (module_handle)
    {
      nxdetours64_report.unknown = 0
      nxdetours64_report.report_id = 0x48
      nxdetours64_report.module_id = 0x5B8
      nxdetours64_report.size_of_image = (PE_HEADER*)(module_handle + (DOS_HEADER*)(module_handle)->e_lfanew))->SizeOfImage
      battleye::report(&nxdetours64_report, sizeof(nxdetours64_report), 0
    }
}

nvcompiler

void module::check_nvcompiler
{
    const auto module_handle = GetModuleHandleA("nvcompiler.dll"
    if (module_handle)
    {
      nvcompiler_report.unknown = 0
      nvcompiler_report.report_id = 0x48
      nvcompiler_report.module_id = 0x5BC
      nvcompiler_report.data = *(int*)(module_handle + 0x1000
      battleye::report(&nvcompiler_report, sizeof(nvcompiler_report), 0
    }
}

wmp

void module::check_wmp
{
    const auto module_handle = GetModuleHandleA("wmp.dll"
    if (module_handle)
    {
      wmp_report.unknown = 0
      wmp_report.report_id = 0x48
      wmp_report.module_id = 0x5BE
      wmp_report.data = *(int*)(module_handle + 0x1000
      battleye::report(&wmp_report, sizeof(wmp_report), 0
    }
}

Module id enumeration

For reference, here are the enumerative ids for the modules:

enum module_id
{
    nvtoolsext64    = 0x5A8,
    ws2detour_x96   = 0x5B5,
    networkdll64    = 0x5B7,
    nxdetours_64    = 0x5B8,
    nvcompiler      = 0x5BC,
    wmp             = 0x5BE

TCP table scan

The BattlEye shellcode will also search the system wide list of TCP connections (known as the TCP table), and report you for being connected to at least one of the specific Cloudflare-gateway IP addresses belonging to the German pay-to-cheat website https://xera.ph/. This detection mechanism was added to the shellcode to detect any user using their launcher while the game is running, making them easily identifiable. The only problem with this mechanism is that the Cloudflare-gateway IP addresses might switch hands later on and if the new owner of the respective IP addresses distribute software connecting to their servers on that specific port, false positives will without a doubt occur.

Users of the pay-to-cheat provider xera.ph have been reporting detections for a long time, without the developers being able to mitigate. When we contacted the responsible developers from xera.ph to make them aware of their stupidity, they misread the situation and handed a free copy to us without thinking twice that we would crack it and release it. We won’t, but you probably shouldn’t send proprietary, licensed binaries for free to reverse engineers without the slightest expectation of piracy. 😉

void network::scan_tcp_table
{
    memset(local_port_buffer, 0, sizeof(local_port_buffer

    for (iteration_index = 0 iteration_index < 500 ++iteration_index)
    {
        // GET NECESSARY SIZE OF TCP TABLE
        auto table_size = 0
        GetExtendedTcpTable(0, &table_size, false, AF_INET, TCP_TABLE_OWNER_MODULE_ALL, 0

        // ALLOCATE BUFFER OF PROPER SIZE FOR TCP TABLE
        auto allocated_ip_table = (MIB_TCPTABLE_OWNER_MODULE*)malloc(table_size

        if (GetExtendedTcpTable(allocated_ip_table, &table_size, false, AF_INET, TCP_TABLE_OWNER_MODULE_ALL, 0) != NO_ERROR)
            goto cleanup

        for (entry_index = 0 entry_index < allocated_ip_table->dwNumEntries ++entry_index)
        {
            const auto ip_address_match_1 = 
                allocated_ip_table->table[entry_index].dwRemoteAddr == 0x656B1468 // 104.20.107.101

            const auto ip_address_match_2 = 
                allocated_ip_table->table[entry_index].dwRemoteAddr == 0x656C1468 // 104.20.108.101

            const auto port_match = 
                allocated_ip_table->table[entry_index].dwRemotePort == 20480

            if ( (!ip_address_match_1 && !ip_address_match_2) || !port_match)
                continue

            for (port_index = 0 
                 port_index < 10 && 
                 allocated_ip_table->table[entry_index].dwLocalPort != 
                    local_port_buffer[port_index 
                 ++port_index)
            {
                if (local_port_buffer[port_index])
                    continue

                tcp_table_report.unknown = 0
                tcp_table_report.report_id = 0x48
                tcp_table_report.module_id = 0x5B9
                tcp_table_report.data = 
                    BYTE1(allocated_ip_table->table[entry_index].dwLocalPort) | 
                    (LOBYTE(allocated_ip_table->table[entry_index.dwLocalPort) << 8

                battleye::report(&tcp_table_report, sizeof(tcp_table_report), 0

                local_port_buffer[port_index] = allocated_ip_table->table[entry_index].dwLocalPort
                break

            }
        }

cleanup:
        // FREE TABLE AND SLEEP
        free(allocated_ip_table
        Sleep(10
    }
}

Report types

For reference, here are the known report types from the shellcode:

enum BATTLEYE_REPORT_ID
{
    MEMORY_GUARD            = 0x21,
    MEMORY_SUSPICIOUS       = 0x2F,
    WINDOW_TITLE            = 0x33,
    MEMORY                  = 0x35,
    PROCESS_ANOMALY         = 0x38,
    DRIVER_BEEP_PRESENCE    = 0x3E,
    DRIVER_NULL_PRESENCE    = 0x3F,
    MISCELLANEOUS_ANOMALY   = 0x3B,
    PROCESS_SUSPICIOUS      = 0x40,
    LSASS_MEMORY            = 0x42,
    SLEEP_ANOMALY           = 0x45,
    MEMORY_MODULE_SPECIFIC  = 0x46,
    GENERIC_ANOMALY         = 0x48,
    MEMORY_MODULE_SPECIFIC2 = 0x5B,
}

One thought on “BattlEye anticheat: analysis and mitigation

  • It is in reality a nice and useful piece of info.

    I’m happy that you simply shared this useful info with us.
    Please stay us up to date like this. Thank you for sharing.

Leave a Reply

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