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 \\.\namedpipe\Battleye
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 anticheat, 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 hardcoded 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, whitelisted 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 hardcoded:
[05 18] ojects\PUBGChinese
[05 17] BattleGroundsPrivate_CheatESP
[05 17] [%.0fm] %s
[05 3E] \00\00\00\00Neck\00\00\00\00Chest\00\00\00\00\00\00\00Mouse 1\00
[05 3F] PlayerESPColor
[05 40] Aimbot: %d\00\2D\3E\20\41
[05 36] HackMachine
[05 4A] VisualHacks.net
[05 50] \3E\23\2F\65\3E\31\31\4E\4E\56\3D\42\76\28\2A\3A\2E\46\3F\75\75\23\28\67\52\55\2E\6F\30\58\47\48
[05 4F] DLLInjection-master\\x64\\Release\\
[05 52] NameESP
[05 48] Skullhack
[05 55] .rdata$zzzdbg
[05 39] AimBot
[05 39] \EB\49\41\80\3C\12\3F\75\05\C6\02\3F\EB\38\8D\41\D0\0F\BE\C9\3C\09\77\05\83\E9\30\EB\06\83\E1\DF
[05 5F] \55\E9
[05 5F] \57\E9
[05 5F] \60\E9
[05 68] D3D11Present initialised
[05 6E] [ %.0fM ]
[05 74] [hp:%d]%dm
[05 36] \48\83\64\24\38\00\48\8D\4C\24\58\48\8B\54\24\50\4C\8B\C8\48\89\4C\24\30\4C\8B\C7\48\8D\4C\24\60
[05 36] \74\1F\BA\80\00\00\00\FF\15\60\7E\00\00\85\C0\75\10\F2\0F\10\87\80\01\00\00\8B\87\88\01\00\00\EB
[05 36] \40\F2\AA\15\6F\08\D2\89\4E\9A\B4\48\95\35\D3\4F\9CPOSITION\00\00\00\00COL
[05 7A] \FF\E0\90
[05 79] %s\00\00%d\00\00POSITION\00\00\00\00COLOR\00\00\00\00\00\00\00
[05 36] \8E\85\76\5D\CD\DA\45\2E\75\BA\12\B4\C7\B9\48\72\11\6D\B9\48\A1\DA\A6\B9\48\A7\67\6B\B9\48\90\2C
[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 \FF\E0\90 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 atleast 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 "\A0\E7\FF\FF\FF\81"
Image name contains "RNG "
Image name contains "\90\E5\43\55"
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
// \FF\FF\83\C4\08\C3\00\00\00\00\00\00\00\00\00\00
// \\.\pipe\%s
// \C7\06\00\00\00\00\C6\47\03\00
// \60\C0\18\01\00\00\33\D2
// ...
// 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 anominalies 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] == \00\8B\E5\5D\C3\CC\CC\B8\??\??\??\??\C3\CC\CC\CC
, 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, "\6A\00\8B\31\FF\56\1C\8B\0D\??\??\??\??\??\FF\96\??\??\??\??\8B\0D\??\??\??\??\8B\01\FF\90"))
{
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, "\6A\04\6A\00\6A\02\6A"))
{
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:
- ....\Content\Paks\TslGame-WindowsNoEditor_assets_world.pak
- ....\Content\Paks\TslGame-WindowsNoEditor_ui.pak
- ....\Content\Paks\TslGame-WindowsNoEditor_sound.pak
- Contents of game specific files:
- ....\BLGame\CookedContent\Script\BLGame.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 tickcount 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 behaviour, 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 situtation 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,
}