Post

Bypassing BattlEye's GetAsyncKeyState Detection

As we all know, anti-cheat and cheat developers alike are both engaging in an ongoing cat and mouse game. With the advancements of cheats, anti-cheats in turn are required to make their own advancements. The area that I will be looking into in this post will be oriented towards internal cheat detection of code execution utilizing a method known as stack walking and how one could mitigate these detections. While stack walking is not the proper name for this specific process that BattlEye undergoes in order to detect unauthorized code execution as it is not a full stack walk, this is what it is most widely known as.

Stack Walking

BattlEye’s internal module registers an exception handler on various functions common for cheat development. While this list is in no way comprehensive, a few example functions are noted below:

1
2
3
GetAsyncKeyState
NtUserGetAsyncKeyState
sqrtf

While it is not guaranteed that cheats will utilize these functions, it is common for aimbot arithmetic to use sqrtf in order to calculate for a hypotenuse in a calculate angle function and for cheats to utilize NtUser/GetAsyncKeyState in order to determine user input.

Exception Handling

By setting the first instruction of these functions to an int3 instruction (aka 0xCC), any calls to these functions will result in an exception and thus direct to their exception handler, where they can then analyze the caller’s memory in order to determine if the memory region is valid or not. Things that are checked include but are not limited to determining if the memory region belongs to a valid module, if the caller is utilizing a basic “call spoofer”, or if the memory has undergone some type of manipulation to the region’s VAD entry. Any flags that are detected will then have the information of the memory region reported to their servers for further analysis.

Mitigating Detections

There is a multitude of ways you are able to mitigate these detections. As for GetAsyncKeyState, there have been many suggestions on online forums such as UnknownCheats to use the exported function NtUserGetAsyncKeyState which utilizes gafAsyncKeyState and returns (for the most part) results as expected from the user-level variant. However, both of these functions are susceptible to BattlEye’s stack walking as noted in the above list.

Pseudocode View

By taking a look at the export of win32u!NtUserGetAsyncKeyState, we can see it as a direct syscall to the kernel function inside of a driver of the graphical subsystem win32kbase.sys. By replicating the user exported function, we can also make a direct system call to bypass the exception trap placed by BattlEye on the user functions.

Syscalls and Windows Versions

System calls, also known as syscalls are direct calls where user code can call a function from the kernel for things such as memory management, filesystem management, etc. There is no direct constructor available to us in order to simply make system calls ourselves, and no exported definition of system call IDs, so it is required for us to programmatically find the ID of our function ourselves. Of course, you can use online resources such as this project by j00ru, but it is not promised for these IDs to stay static across varying versions of Windows.

Finding Syscall IDs

In order to dynamically find a syscall ID, we will need to find the exported syscall wrapper we are targeting. In our case, this will be win32u.dll.

Resolving the Module Address

With our module in mind, we will need to obtain its address, preferably without the usage of external functions in order for obscurity and avoidance of any potential stack walk by BattlEye. For this case, we can use the LDR_DATA_TABLE_ENTRY to parse the loaded modules and find the one that we want.

Obtaining this structure can be done using the following assembly:

1
2
3
4
mov rax, qword ptr gs:[60h] ;Obtain the Process Environment Block
mov rax, [rax + 18h] ;Obtain the PEB_LDR_DATA
mov rax, [rax + 10h] ;Obtain the loaded module linked list
ret

Then, by parsing each entry we can find the BaseDllName and compare it to our targeted module. Once found, our module’s address will be at DllBase. Pseudocode for this can be represented as follows:

1
2
3
4
5
6
7
8
9
10
11
auto lib = (PLDR_DATA_TABLE_ENTRY)(asm::__peb_ldte());
while (lib->BaseDllName.Buffer != 0x0) {
    // Obtain the unicode string of the current module
    auto current_name = lib->BaseDllName.Buffer;
	
    // Compare names to find our targeted module
	  // ...
	
	  // Once our targeted module is found, return the module address
	  return (uint64_t)(lib->DllBase);
}

Resolving the Function Address

With our module address in hand, we can then get the IMAGE_DIRECTORY_ENTRY_EXPORT in order to find our targeted syscall wrapper. The pseudocode of this operation would look as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
auto dos_header = (PIMAGE_DOS_HEADERS)(module_address);
auto nt_header = (PIMAGE_NT_HEADERS64S)((uint8_t*)(module_address) + dos_header->e_lfanew);

auto image_export_va = nt_header->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress;
auto image_export_dir = (PIMAGE_EXPORT_DIRECTORYS)((uint8_t*)(module_address) + image_export_va);

auto address_functions = (uint32_t*)((uint8_t*)(module_address) + image_export_dir->AddressOfFunctions);
auto address_names = (uint32_t*)((uint8_t*)(module_address) + image_export_dir->AddressOfNames);
auto address_ordinals = (uint16_t*)((uint8_t*)(module_address) + image_export_dir->AddressOfNameOrdinals);

// Loop the export directory functions
for (auto i = 0; i < image_export_dir->NumberOfNames; i++) {
    // Obtain the char array of the current function
    char* current_name = (char*)(module_address) + address_names[i];
	
    // Compare names to find our targeted function
    // ...
	
    // Once our targeted function is found, return the function address
    return (uint64_t)((uint8_t*)module_address + address_functions[address_ordinals[i]]);
}

Resolving the Syscall ID

IDA View

From the IDA View, we can see at exported_function + 0x4 the syscall ID and thus extract it by dereferencing at this address. For my specific Windows version (Windows 11 21H2), this ID can be seen as 0x103F.

1
*(uint32_t*)(exported_function + 4);

Now with our syscall ID in hand, we can put it to use.

Building a Syscall Wrapper

In order to use our syscall ID, we will need to build a wrapper around it, just like how the wrapper does inside of win32u!NtUserGetAsyncKeyState. For this, assembly can be used in order to execute the syscall instruction.

1
2
3
4
5
6
fnNtUserGetAsyncKeyState proc
    mov r10, rcx ;Load the VK Keycode (rcx) into r10
    mov rax, rdx ;Load the syscall ID (rdx) into rax
    syscall
    ret
fnNtUserGetAsyncKeyState endp

For explanation, when wrapping the above assembly inside of a function prototype preface, our first argument will be the VK Keycode used in standard NtUser/GetAsyncKeyState which will be loaded from the first function argument register rcx into the register which will be utilized during the syscall r10. Likewise, the syscall instruction will use register rax as the syscall ID so this must be moved from the second argument register rdx into rax.

With this, our prototype function preface will look as follows. Then we can use our fnNtUserGetAsyncKeyState function just like how you would use NtUser/GetAsyncKeyState, just with the additional argument of the syscall ID.

1
extern "C" int16_t fnNtUserGetAsyncKeyState(int vk_keycode, int syscall_id);

Resolution

Of course, there are a multitude of other ways to get around BattlEye stack walks, but this was just an example of one route that is available to you which introduces no additional external function calls and can be utilized in projects of different scopes. If you would like to see a PoC code example of the above, I wrote Syskey which does everything explained in this post.

The PoC in this post was released on UnknownCheats and can be found here which subsequently led to the award of November 2021 Member of the Month.

Alternative Methods

As stated above, this is only one of many methods to bypass BattlEye’s stack walk conducted on certain targeted functions inside of a game process. If you were wondering, “Can’t I just skip BattlEye’s int3 instruction?”, so was my friend weak when he wrote SkipHook, which does exactly that.

Disclaimer

This method executes a direct syscall which will have the return address land inside of your memory region. This can be detected via instrumentation callbacks, which while are not currently utilized by BattlEye at the time of writing, are currently utilized by EasyAntiCheat. If they detect a syscall returning outside of ntdll.dll or win32u.dll it is immediate cause for suspicion for them.

Resources

This post is licensed under CC BY 4.0 by the author.