SECRET CLUB

BattlEye stack walking


With game-hacking being a continuous cat and mouse game, rumours about new techniques spread like fire. As such in this blog post we will take a look into one of the new heuristic techniques that BattlEye, a large anti-cheat provider, has recently added to its arsenal. Most widely known as stack walking This is usually done by hooking a function and traversing the stack to find out who exactly is calling said function. Why would one do this? Just like any other program, video game hacks have a set of well known functions that they utilize to get keyboard information, print to the console or calculate certain mathematical expressions. Video game hacks also like to attempt to hide their existence, be it in memory or on disk, so that the anti-cheat software does not find it. What these cheat programs forget is that they regularly call functions in other libraries, and this can be exploited to heuristically detect unknown cheats. By implementing a stack walking engine on prevalent functions like std::print, you will be able to find these cheats even if they disguise themselves.

BattlEye has implemented “stack walking”, even though this has not been publicly proved and prior to this article was just rumors. Note the quotes around stack walking, because what you will see here is not true stack walking, this is merely a return address check and a caller dump combined. A true stack walker would traverse the stack and generate a proper callstack.

As I have explained in the other BattlEye articles, the anti-cheat system dynamically streams shellcode to the game process when you are playing. These shellcodes have different sizes and different purposes, and are not streamed at the same time. The great thing about a system like this, is that it requires researches to dynamically analyze the anti-cheat while a competitive match is ongoing, making it harder to determine the features of said anti-cheat. This will also allow the anti-cheat do apply different measures to different users, like only streaming a more invasive module to a person with a abnormal kill/death ratio, etc.

One of these BattlEye shellcodes is responsible for doing this stack analysis, and we refer to it as shellcode8kb, due to its marginally smaller size compared to shellcodemain, that i have documented here. This small shellcode will setup a vectored exception handler using the function AddVectoredExceptionHandler, and then set up interrupt traps on the following functions:

GetAsyncKeyState
GetCursorPos
IsBadReadPtr
NtUserGetAsyncKeyState
GetForegroundWindow
CallWindowProcW
NtUserPeekMessage
NtSetEvent
sqrtf
__stdio_common_vsprintf_s
CDXGIFactory::TakeLock
TppTimerpExecuteCallback

It does so by iterating through this list of commonly used functions, setting the first instruction of the respective function to int3, which acts as a breakpoint. When this breakpoint has been set, all calls to the respective function will go through the exception handler, which has full register and stack access. With this access, the exception handler will dump the caller address from the top of the stack, and if any of the heuristic conditions are met, 32 bytes of the calling function will be dumped and sent to BattlEye servers with the report id 0x31:

__int64 battleye::exception_handler(_EXCEPTION_POINTERS *exception)
{
    if (exception->ExceptionRecord->ExceptionCode != STATUS_BREAKPOINT)
        return 0;
 
    const auto caller_function = *(__int64 **)exception->ContextRecord->Rsp;
    MEMORY_BASIC_INFORMATION caller_memory_information = {};
    auto desired_size = 0;
 
    // QUERY THE MEMORY PAGE OF THE CALLER
    const auto call_failed = NtQueryVirtualMemory(
                                GetCurrentProcess(), 
                                caller_function, 
                                MemoryBasicInformation, 
                                &caller_memory_information, 
                                sizeof(caller_memory_information), 
                                &desired_size) < 0;
     
    // IS THE MEMORY SOMEHOW NOT COMMITTED? (WOULD SUGGEST VAD MANIPULATIUON)
    const auto non_commit = caller_memory_information.State != MEM_COMMIT;
    // IS THE PAGE EXECUTABLE BUT DOES NOT BELONG TO A PROPERLY LOADED MODULE?
    const auto foreign_image = caller_memory_information.Type != MEM_IMAGE && caller_memory_information.RegionSize > 0x2000;
    // IS THE CALL BEING SPOOFED BY NAMAZSO?
    const auto spoof = *(_WORD *)caller_function == 0x23FF; // jmp qword ptr [rbx]
 
    // FLAG ALL ANBORMALITIES
    if (call_failed || non_commit || foreign_image || spoof)
    {
        report_stack.unknown = 0;
        report_stack.report_id = 0x31;
        report_stack.hook_id = hook_id;
        report_stack.caller = (__int64)caller_function;
        report_stack.function_dump[0] = *caller_function;
        report_stack.function_dump[1] = caller_function[1];
        report_stack.function_dump[2] = caller_function[2];
        report_stack.function_dump[3] = caller_function[3];
        if (!call_failed)
        {
            report_stack.allocation_base = caller_memory_information.AllocationBase;
            report_stack.base_address = caller_memory_information.BaseAddress;
            report_stack.region_size = caller_memory_information.RegionSize;
            report_stack.type_protect_state = caller_memory_information.Type | caller_memory_information.Protect | caller_memory_information.State;
        }
         
        battleye::report(&report_stack, sizeof(report_stack), false);
        return -1;
    }
}

As we can see, the exception handler will dump any callers where the memory page has been blatantly modified, or does not belong to a known process module (the MEM_IMAGE memory page type is not set by manualmappers). It will also dump callers where the NtQueryVirtualMemory call fails entirely, to prevent cheats from hooking that system call and hiding their module from the stack dumper. The last condition is actually pretty interesting, it flags any callers using the gadget jmp qword ptr [rbx], which is a method used to “spoof return addresses” released by fellow secret club member namazso. It seems like the BattlEye developers saw people using that spoofing method in their games and decided to directly target it. What should be mentioned here is that the method namazsos describes works just fine, you just have to use another gadget, whether it is entirely different or just a different register doesn’t matter.

To the BattlEye developers: the pattern you’re using to find CDXGIFactory::TakeLock in memory is wrong, since you (accidentally or not) included CC padding, which is very different for each compilation. For maximum compatibility, remove the padding (first byte in the signature) and you will most likely catch more cheaters 🙂

The complete structure sent to BattlEye’s server is:

struct __unaligned battleye_stack_report
{
  __int8  unknown;
  __int8  report_id;
  __int8  val0;
  __int64 caller;
  __int64 function_dump[4];
  __int64 allocation_base;
  __int64 base_address;
  __int32 region_size;
  __int32 type_protect_state;
};