innica

BattlEye reverse engineer tracking


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.

Shellcode ??

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 innica 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.

Context

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.

Device driver enumeration

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);
      }
    }
  }

Window enumeration

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);
  }

Shellcode detection

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, 0i, 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;
  }
}

Shellcode dumping

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);
        }
      }
    }
  }
}

Handle enumeration

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);

Process enumeration

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.

snapshot_handle = CreateToolhelp32Snapshot( TH32CS_SNAPPROCESS, 0x00 );
if ( snapshot_handle != INVALID_HANDLE_VALUE )
{
  process_entry.dwSize = 0x130;
  if ( Process32First(snapshot_handle, &process_entry) )
  {
    report_buffer = (std::uint8_t*)malloc(0x5000);
    report_buffer[0] = 0;
    report_buffer[1] = 0xB;
    buffer_index = 2;
    
    // ITERATE PROCESSES
    do
    {
      target_process_handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, process_entry.th32ProcessID);
      
      // QUERY PROCESS IAMGE NAME
      name_length = 0x100;
      query_result = QueryFullProcessImageNameW(target_process_handle, 0, &name_buffer, &name_length);
      name_length = WideCharToMultiByte(
          CP_UTF8, 
          0x00, 
          &name_buffer, 
          name_length,
          &report_buffer[buffer_index + 5], 
          0xFF, 
          nullptr, 
          nullptr);
      
      valid_query = target_process_handle && query_result && name_length;
      
      // Query file size
      if ( valid_query )
      {
        if ( GetFileAttributesExW(&name_buffer, GetFileExInfoStandard, &file_attributes) )
          file_size = file_attributes.nFileSizeLow;
        else
          file_size = 0;
      }
      else
      {
        // TRY QUERY AGAIN WITHOUT HANDLE
        process_id_information.process_id = (void *)process_entry.th32ProcessID;
        process_id_information.image_name.Length = '\0';
        process_id_information.image_name.MaximumLength = '\x02\0';
        process_id_information.image_name.Buffer = name_buffer;
        
        if ( NtQuerySystemInformation(SystemProcessIdInformation, 
                                        &process_id_information, 
                                        24, 
                                        1) < 0 ) 
        {
          name_length = 0;
        }
        else
        {
          name_address = &report_buffer[buffer_index + 5];
          name_length = WideCharToMultiByte(
                          CP_UTF8,
                          0,
                          (__int64 *)process_id_information.image_name.Buffer,
                          process_id_information.image_name.Length / 2,
                          name_address,
                          0xFF,
                          nullptr,
                          nullptr);
        }
        file_size = 0;
      }

      // IF MANUAL QUERY WORKED
      if ( name_length )
      {
        *(_DWORD *)&report_buffer[buffer_index] = process_entry.th32ProcessID;
        report_buffer[buffer_index + 4] = name_length;
        *(_DWORD *)&report_buffer[buffer_index + 5 + name_length] = file_size;
        buffer_index += name_length + 9;
      }
      if ( target_process_handle )
        CloseHandle(target_process_handle);
      
      // CACHE LSASS HANDLE FOR LATER !!
      if ( *(_DWORD *)process_entry.szExeFile == 'sasl' )
        lsass_handle = OpenProcess(0x410, 0, process_entry.th32ProcessID);
    }
    while ( Process32Next(snapshot_handle, &process_entry) && buffer_index < 0x4EFB );

    // CLEANUP
    CloseHandle((__int64)snapshot_handle);
    battleye::send(report_buffer, buffer_index, 0);
    free(report_buffer);
  }
}
Bootkitting Windows Sandbox | innica
innica

Bootkitting Windows Sandbox


Introduction & Motivation

Windows Sandbox is a feature that Microsoft added to Windows back in May 2019. As Microsoft puts it:

Windows Sandbox provides a lightweight desktop environment to safely run applications in isolation. Software installed inside the Windows Sandbox environment remains “sandboxed” and runs separately from the host machine.

The startup is usually very fast and the user experience is great. You can configure it with a .wsb file and then double click that file to start a clean VM.

The sandbox can be useful for malware analysis and as we will show in this article, it can also be used for kernel research and driver development. We will take things a step further though and share how we can intercept the boot process and patch the kernel during startup with a bootkit.

TLDR: Visit the SandboxBootkit repository to try out the bootkit for yourself.

Windows Sandbox for driver development

A few years back Jonas L tweeted about the undocumented command CmDiag. It turns out that it is almost trivial to enable test signing and kernel debugging in the sandbox (this part was copied straight from my StackOverflow answer).

First you need to enable development mode (everything needs to be run from an Administrator command prompt):

CmDiag DevelopmentMode -On

Then enable network debugging (you can see additional options with CmDiag Debug):

CmDiag Debug -On -Net

This should give you the connection string:

Debugging successfully enabled.

Connection string: -k net:port=50100,key=cl.ea.rt.ext,target=<ContainerHostIp> -v

Now start WinDbg and connect to 127.0.0.1:

windbg.exe -k net:port=50100,key=cl.ea.rt.ext,target=127.0.0.1 -v

Then you start Windows Sandbox and it should connect:

Microsoft (R) Windows Debugger Version 10.0.22621.1 AMD64
Copyright (c) Microsoft Corporation. All rights reserved.

Using NET for debugging
Opened WinSock 2.0
Using IPv4 only.
Waiting to reconnect...
Connected to target 127.0.0.1 on port 50100 on local IP <xxx.xxx.xxx.xxx>.
You can get the target MAC address by running .kdtargetmac command.
Connected to Windows 10 19041 x64 target at (Sun Aug  7 10:32:11.311 2022 (UTC + 2:00)), ptr64 TRUE
Kernel Debugger connection established.

Now in order to load your driver you have to copy it into the sandbox and you can use sc create and sc start to run it. Obviously most device drivers will not work/freeze the VM but this can certainly be helpful for research.

The downside of course is that you need to do quite a bit of manual work and this is not exactly a smooth development experience. Likely you can improve it with the <MappedFolder> and <LogonCommand> options in your .wsb file.

PatchGuard & DSE

Running Windows Sandbox with a debugger attached will disable PatchGuard and with test signing enabled you can run your own kernel code. Attaching a debugger every time is not ideal though. Startup times are increased by a lot and software might detect kernel debugging and refuse to run. Additionally it seems that the network connection is not necessarily stable across host reboots and you need to restart WinDbg every time to attach the debugger to the sandbox.

Tooling similar to EfiGuard would be ideal for our purposes and in the rest of the post we will look at implementing our own bootkit with equivalent functionality.

Windows Sandbox internals recap

Back in March 2021 a great article called Playing in the (Windows) Sandbox came out. This article has a lot of information about the internals and a lot of the information below comes from there. Another good resource is Microsoft's official Windows Sandbox architecture page.

Windows Sandbox uses VHDx layering and NTFS magic to allow the VM to be extremely lightweight. Most of the system files are actually NTFS reparse points that point to the host file system. For our purposes the relevant file is BaseLayer.vhdx (more details in the references above).

What the article did not mention is that there is a folder called BaseLayer pointing directly inside the mounted BaseLayer.vhdx at the following path on the host:

C:\ProgramData\Microsoft\Windows\Containers\BaseImages\<GUID>\BaseLayer

This is handy because it allows us to read/write to the Windows Sandbox file system without having to stop/restart CmService every time we want to try something. The only catch is that you need to run as TrustedInstaller and you need to enable development mode to modify files there.

When you enable development mode there will also be an additional folder called DebugLayer in the same location. This folder exists on the host file system and allows us to overwrite certain files (BCD, registry hives) without having to modify the BaseLayer. The configuration for the DebugLayer appears to be in BaseLayer\Bindings\Debug, but no further time was spent investigating. The downside of enabling development mode is that snapshots are disabled and as a result startup times are significantly increased. After modifying something in the BaseLayer and disabling development mode you also need to delete the Snapshots folder and restart CmService to apply the changes.

Getting code execution at boot time

To understand how to get code execution at boot time you need some background on UEFI. We released Introduction to UEFI a few years back and there is also a very informative series called Geeking out with the UEFI boot manager that is useful for our purposes.

In our case it is enough to know that the firmware will try to load EFI\Boot\bootx64.efi from the default boot device first. You can override this behavior by setting the BootOrder UEFI variable. To find out how Windows Sandbox boots you can run the following PowerShell commands:

> Set-ExecutionPolicy -ExecutionPolicy Unrestricted
> Install-Module UEFI
> Get-UEFIVariable -VariableName BootOrder -AsByteArray
0
0
> Get-UEFIVariable -VariableName Boot0000
�VMBus File SystemVMBus\EFI\Microsoft\Boot\bootmgfw.efi

From this we can derive that Windows Sandbox first loads:

\EFI\Microsoft\Boot\bootmgfw.efi

As described in the previous section we can access this file on the host (as TrustedInstaller) via the following path:

C:\ProgramData\Microsoft\Windows\Containers\BaseImages\<GUID>\BaseLayer\Files\EFI\Microsoft\Boot\bootmgfw.efi

To verify our assumption we can rename the file and try to start Windows Sandbox. If you check in Process Monitor you will see vmwp.exe fails to open bootmgfw.efi and nothing happens after that.

Perhaps it is possible to modify UEFI variables and change Boot0000 (Hyper-V Manager can do this for regular VMs so probably there is a way), but for now it will be easier to modify bootmgfw.efi directly.

Bootkit overview

To gain code execution we embed a copy of our payload inside bootmgfw and then we modify the entry point to our payload.

Our EfiEntry does the following:

To simplify the injection of SandboxBootkit.efi into the .bootkit section we use the linker flags /FILEALIGN:0x1000 /ALIGN:0x1000. This sets the FileAlignment and SectionAlignment to PAGE_SIZE, which means the file on disk and in-memory are mapped one-to-one.

Bootkit hooks

Note: Many of the ideas presented here come from the DmaBackdoorHv project by Dmytro Oleksiuk, go check it out!

The first issue you run into when modifying bootmgfw.efi on disk is that the self integrity checks will fail. The function responsible for this is called BmFwVerifySelfIntegrity and it directly reads the file from the device (e.g. it does not use the UEFI BootServices API). To bypass this there are two options:

  1. Hook BmFwVerifySelfIntegrity to return STATUS_SUCCESS
  2. Use bcdedit /set {bootmgr} nointegritychecks on to skip the integrity checks. Likely it is possible to inject this option dynamically by modifying the LoadOptions, but this was not explored further

Initially we opted to use bcdedit, but this can be detected from within the sandbox so instead we patch BmFwVerifySelfIntegrity.

We are able to hook into winload.efi by replacing the boot services OpenProtocol function pointer. This function gets called by EfiOpenProtocol, which gets executed as part of winload!BlInitializeLibrary.

In the hook we walk from the return address to the ImageBase and check if the image exports BlImgLoadPEImageEx. The OpenProtocol hook is then restored and the BlImgLoadPEImageEx function is detoured. This function is nice because it allows us to modify ntoskrnl.exe right after it is loaded (and before the entry point is called).

If we detect the loaded image is ntoskrnl.exe we call HookNtoskrnl where we disable PatchGuard and DSE. EfiGuard patches very similar locations so we will not go into much detail here, but here is a quick overview:

Bonus: Logging from Windows Sandbox

To debug the bootkit on a regular Hyper-V VM there is a great guide by tansadat. Unfortunately there is no known way to enable serial port output for Windows Sandbox (please reach out if you know of one) and we have to find a different way of getting logs out.

Luckily for us Process Monitor allows us to see sandbox file system accesses (filter for vmwp.exe), which allows for a neat trick: accessing a file called \EFI\my log string. As long as we keep the path length under 256 characters and exclude certain characters this works great!

Procmon showing log strings from the bootkit

A more primitive way of debugging is to just kill the VM at certain points to test if code is executing as expected:

void Die() {
    // At least one of these should kill the VM
    __fastfail(1);
    __int2c();
    __ud2();
    *(UINT8*)0xFFFFFFFFFFFFFFFFull = 1;
}

Bonus: Getting started with UEFI

The SandboxBootkit project only uses the headers of the EDK2 project. This might not be convenient when starting out (we had to implement our own EfiQueryDevicePath for instance) and it might be easier to get started with the VisualUefi project.

Final words

That is all for now. You should now be able to load a driver like TitanHide without having to worry about enabling test signing or disabling PatchGuard! With a bit of registry modifications you should also be able to load DTrace (or the more hackable implementation STrace) to monitor syscalls happening inside the sandbox.