SECRET CLUB

BattlEye shellcode updates


Anticheats change as time goes on, features come and go to maximize the efficiency of the product. I did a complete write-up of BattlEye’s shellcode a year ago on my blog, and this article will merely reflect the changes that have been made to said shellcode.

Blacklisted Timestamps

Last time I analyzed BattlEye, there were only two compile-time datestamps in the shadowban ban list, and it seems like they’ve decided to add a lot more:

0x5B12C900 (action_x64.dll)
0x5A180C35 (TerSafe.dll, Epic Games)
0xFC9B9325 (?)
0x456CED13 (d3dx9_32.dll)
0x46495AD9 (d3dx9_34.dll)
0x47CDEE2B (d3dx9_32.dll)
0x469FF22E (d3dx9_35.dll)
0x48EC3AD7 (D3DCompiler_40.dll)
0x5A8E6020 (?)
0x55C85371 (d3dx9_32.dll)
0x456CED13 (?)
0x46495AD9 (D3DCompiler_40.dll)
0x47CDEE2B (D3DX9_37.dll)
0x469FF22E (?)
0x48EC3AD7 (?)
0xFC9B9325 (?)
0x5A8E6020 (?)
0x55C85371 (?)

I’ve failed to identify the rest of the timestamps, and the two 0xF******* are hashes produced by visual studio reproducible builds. If anyone can identify the timestamps, please hit me up on twitter 🙂

Thanks to @mottikraus and T0B1 for identifying some of the timestamps.

Module checks

As the main analysis shows, module enumeration is a key feature in BattlEye, and since my last analysis one more module has been added to the list:

void battleye::misc::module_unknown1()
{
    if (!GetProcAddress(current_module, "NSPStartup"))
        return;
 
    if (optional_header.data_directory[4].size == 0x1B20 || 
        optional_header.data_directory[4].size == 0xE70 || 
        optional_header.data_directory[4].size == 0x1A38 ||
        timestamp >= 0x5C600000 && timestamp < 0x5C700000)
    {
        report_module_unknown report = {};
        report.unknown = 0;
        report.report_id = 0x35;
        report.val1 = 0x5C0;
        report.timestamp = timestamp;
        report.image_size = optional_header.size_of_image;
        report.entrypoint = optional_header.address_of_entry_point;
        report.directory_size = optional_header.data_directory[4].size;
         
        battleye::report(&report, sizeof(report), false);
    }
}

This is probably a detection of certain proxy dlls, since it checks for the size of the relocation table.

Window titles

In the previous analysis, a bunch of different cheat providers had been flagged using window names, but since then the shellcode does not check for those window titles anymore. The window title list has been completely replaced with:

Chod's
Satan5

Image names

BattlEye is infamous for doing some very primitive methods of detection, and the image name blacklist is one of them. The list of banned image names is getting longer and longer every year, and in the past 11 months these five have been added:

frAQBc8W.dll
C:\\Windows\\mscorlib.ni.dll
DxtoryMM_x64.dll
Project1.dll
OWClient.dll

Note that the presence of a module with a name corresponding to any on the list does not mean that you get instantly banned. The reporting mechanism also sends back some basic information about the module, which is most likely used to distinguish cheats from collisions on the BattlEye server.

7-zip

7-Zip has been widely used, and is still being used, by members of the cheat scene as a placeholder in memory for code-caves. BattlEye tries to combat this by doing a very bad integrity check, which has actually been altered since my previous article:

void module::check_7zip()
{
    const auto module_handle = GetModuleHandleA("..\\..\\Plugins\\ZipUtility\\ThirdParty\\7zpp\\dll\\Win64\\7z.dll");
    // --- REMOVED ---
    // if (module_handle && *(int*)(module_handle + 0x1000) != 0xFF1441C7)
     
    // --- ADDED ---
    if (module_handle && *(int*)(module_handle + 0x1008) != 0x83485348)
    {
      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), false);
    }
}

It seems like the BattlEye developers figured out that my previous article led to a bunch of users bypassing this check by simply copying the desired bytes to the location that BattlEye checks. Their fix? Shift the check eight bytes and keep using the same poor integrity check method. 🙁 The executable section is read-only, and all you have to do is load 7-Zip from disk and compare the relocated sections against each other, if there are any discrepancies something is wrong. It’s really not that hard to do integrity checks.

Network check

The TCP table enumeration is still active, but after i released the previous analysis bashing them for flagging cloud-flare IP addresses, they actually removed that check. They are still flagging the connection port that xera.ph uses to communicate, but they’ve added a new check to detect if the process with the connection is being actively protected (by a hook, presumably)

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)
        {
            // --- REMOVED ---
            // 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
  
            // +++ ADDED +++
            const auto target_process = OpenProcess(QueryLimitedInformation, 0, ip_table->table[entry_index].dwOwningPid);
            const auto protected = target_process == INVALID_HANDLE && GetLastError() == 0x57;
  
            if (!protected)
            {
                CloseHandle(target_process);
                return;
            }
  
            const auto port_match = 
                allocated_ip_table->table[entry_index].dwRemotePort == 20480;
  
            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), false);
  
                local_port_buffer[port_index] = allocated_ip_table->table[entry_index].dwLocalPort;
                break
  
            }
        }
  
cleanup:
        // FREE TABLE AND SLEEP
        free(allocated_ip_table);
        Sleep(10
    }
}

Thanks