· 12 min read
Using BattlEye to Bypass EasyAntiCheat
To those of you who may not know, BattlEye is a popular anti cheat referred to as the “Golden Standard”; it is used to protect competitive play games such as Tom Clancy's Rainbow Six Siege, Fortnite, as well as Escape from Tarkov. In this write up I will be discussing the work I have done throughout this past month on their driver.
Download links: BadEye, GoodEye
EasyAntiCheat has now blacklisted BattlEye.
Introduction
To those of you who may not know, BattlEye is a popular anti cheat referred to as the “Golden Standard”; it is used to protect competitive play games such as Tom Clancy’s Rainbow Six Siege, Fortnite, as well as Escape from Tarkov. In this write up I will be discussing the work I have done throughout this past month on their driver. Throughout this write up I will be referring to BattlEye’s driver as “BEDaisy” since this is the name of the driver on disk. You can find more information about the other working components of BattlEye below.
Handle Elevation Exploit
BEDaisy places inline hooks on both NtWriteVirtualMemory and NtReadVirtualMemory inside of lsass.exe and csrss.exe. The reason for these hooks are because csrss.exe and lsass.exe need handles with PROCESS_VM_OPERATION in order to function properly. The handles that csrss.exe and lsass.exe would have to BEDaisy’s protected processes are stripped of PROCESS_VM_OPERATION via BEDaisy’s enumeration of the protected processes handle table by calling ExEnumHandleTable. In order to allow for csrss.exe and lsass.exe to read/write to the games memory BEDaisy proxies their read/write calls. The issue is BEDaisy never checks the privilege on the handle and just assumes it has PROCESS_VM_OPERATION. This allows an attacker to open a handle with very little privilege such as PROCESS_QUERY_LIMITED_INFORMATION and elevate it to full on PROCESS_VM_READ/PROCESS_VM_WRITE.
01301313 118.65435028 [GoodEye]MmCopyVirtualMemory called from: 0xFFFFF804DEFE2D64
01301314 118.65435028 [GoodEye] - SourceProcess: csrss.exe
01301315 118.65435028 [GoodEye] - SourceAddress: 0x0000005A7B5DEF38
01301316 118.65435028 [GoodEye] - TargetProcess: DiscordHookHel
01301317 118.65435028 [GoodEye] - TargetAddress: 0x00000074452CE308
01301318 118.65435028 [GoodEye] - BufferSize: 0x0000000000000008
01301319 118.65442657 [GoodEye]IofCompleteRequest called from: 0xFFFFF804DEFE2E3D
01301320 118.65442657 [GoodEye] - Request Called From: csrss.exe
01301321 118.65444183 [GoodEye] - IRP_MJ_DEVICE_CONTROL!
01301322 118.65444183 [GoodEye] - IoControlCode: 0x0000000000222004
01301323 118.65444183 [GoodEye] - InputBufferLength: 0x0000000000000030
01301324 118.65444183 [GoodEye] - OutputBufferLength: 0x0000000000000000
01301325 118.65444183 [GoodEye] - UserBuffer: 0x0000000000000000
01301326 118.65444183 [GoodEye] - MdlAddress: 0x0000000000000000
01301327 118.65444183 [GoodEye] - SystemBuffer: 0xFFFFB7875C8D0EC0
The inline hooks are a simple jmp [rip], which jump to a singular RWX page of memory. The shellcode on this RWX page simply loads all the values passed to NtReadVirtualMemory/NtWriteVirtualMemory onto the stack which is then passed by reference as the DeviceIoControl buffer. The structure of this buffer is as follows:
struct beioctl
{
void* ret_addr; // this must land inside of lsasrv.dll...
void* handle;
void* base_addr;
void* buffer;
std::size_t buffer_size;
std::size_t* bytes_read;
}; // 0x30 bytes in size as seen above in the runtime log, and below in the shellcode...
This is what the shellcode looks like. It simply moves all parameters passed to NtWriteVirtualMemory/NtReadVirtualMemory onto the stack, and passes the information along to DeviceIoControl.
Handle Elevation Limitations
TL;DR:
- cannot read/write the process that is being protected by BEDaisy…
- cannot read/write kernel memory…
- must be inside of lsass.exe due to hardcoded driver handle embedded into shellcode…
- can only write to writeable memory, and can only read readable memory…
Although this is a handle elevation exploit, this cannot be used to read or write to the process that BEDaisy is protecting, this can only be used to read or write other processes such as “Registry”, “Memory Compression”, csrss.exe, services.exe, and even “System Process (PID 4)”, or any process you can open a handle to. This handle elevation also cannot be used to read or write kernel memory due to the fact BEDaisy passes “UserMode” as their KPROCESSOR_MODE to MmCopyVirtualMemory meaning no reads or writes above usermode. BTBD had the idea of seeing if we could get an arbitrary write by passing a kernel address as the “NumberOfBytesRead” pointer but this was checked via ProbeForWrite. As of now it doesn’t seem possible to get from system level access to kernel execution without another exploit. Although the arbitrary writes to “Registry” can easily be used to crash the system by changing a cell index to something invalid (causing the hive address to linear virtual address translation to fail with ). Another limitation of using this is the need to be inside of lsass.exe. BEDaisy hard codes the handle to the driver into the shellcode itself. You will also need to specify a return address in the ioctl buffer that points to somewhere in lsasrv.dll.
BEDaisy.sys
If you take a look at BEDaisy.sys’s import address table you can see this nice little import by the name of MmGetSystemRoutineAddress, This function is used to dynamically resolve imports at runtime. We can easily IAT hook this import by using PsSetLoadImageNotifyRoutine to get a callback when the driver is loaded. Note: if you are trying to do this with a manually mapped driver it is not patch guard compatible by default. You are going to need to register a callback to a jump instruction inside of a legitimate module (find a code cave and put jmp [rip] 0xaddress).
VOID LoadImageNotifyRoutine
(
PUNICODE_STRING FullImageName,
HANDLE ProcessId,
PIMAGE_INFO ImageInfo
)
{
// pid == 0 if the module is being loaded into the kernel...
if (!ProcessId && wcsstr(FullImageName->Buffer, L"BEDaisy.sys"))
{
DBG_PRINT("> ============= Driver %ws ================", FullImageName->Buffer);
DriverUtil::IATHook
(
ImageInfo->ImageBase,
"MmGetSystemRoutineAddress",
&gh_MmGetSystemRoutineAddress
);
DriverUtil::IATHook
(
ImageInfo->ImageBase,
"FltGetRoutineAddress",
&gh_FltGetRoutineAddress
);
}
}
Here is a list of all of BEDaisy’s imports including imports from FltMgr.sys. The subsequent function pointers returned from MmGetSystemRoutineAddress are then stored in the data section of the driver, unencrypted and never verified before being executed. These function pointers are also static off the base of the module meaning they will always be a set offset from the base address of the driver making it even easier for an attacker to swap these pointers at runtime.
Loaded Kernel Module Enumeration
BEDaisy enumerates all loaded modules by calling NtQuerySystemInformation with SystemModuleInformation. If a black listed driver is found, the game will not run, drivers like the notorious intel lan driver, capcom, and gdrv are all blocked by BEDaisy. PsSetLoadImageNotifyRoutine is also used to prevent these vulnerable drivers from being loaded while BattlEye is running. To get around this one can simply hook NtQuerySystemInformation by swapping the pointer to it in BEDaisy’s data section. Same applies to PsSetLoadImageNotifyRoutine. Another thing to note is this enumeration of loaded modules is actually only done once. After the first enumeration the responsibility of blocking black listed drivers is handed over to PsSetLoadImageNotifyRoutine solely.
// the first call to ZwQuerySystemInformation is to get the size of the buffer needed for the next call
00035521 91.08318329 [GoodEye]ZwQuerySystemInformation called
00035522 91.08318329 [GoodEye] - SystemInformationClass: 0x000000000000000B
00035523 91.08318329 [GoodEye] - SystemInformation: 0xFFFFB507A5183C5C
00035524 91.08319092 [GoodEye] - SystemInformationLength: 0x0000000000000000
// as you can see the size was 0xd5f0
00035525 91.08322144 [GoodEye]ExAllocatePoolWithTag called from: 0xFFFFF804DEFDD5ED
00035526 91.08322144 [GoodEye] - PoolType: 0x1
00035527 91.08322144 [GoodEye] - NumberOfBytes: 0xd5f0
00035528 91.08322144 [GoodEye] - Tag: 0x4542
00035529 91.08322906 [GoodEye] - Allocate Pool at: 0xFFFFC8081D088000
// the second calls is used to get the information
00035530 91.08323669 [GoodEye]ZwQuerySystemInformation called
00035531 91.08323669 [GoodEye] - SystemInformationClass: 0x000000000000000B
00035532 91.08325195 [GoodEye] - SystemInformation: 0xFFFFC8081D088000
00035533 91.08325195 [GoodEye] - SystemInformationLength: 0x000000000000D5F0
// note that ntoskrnl.exe is always going to be the first module when querying SystemModuleInformation
00035534 91.08338165 [GoodEye]RtlInitAnsiString Called From: 0xFFFFF804DEFDD610
00035535 91.08338165 [GoodEye] - SourceString: 0x\SystemRoot\system32\ntoskrnl.exe
00035536 91.08341217 [GoodEye]ZwOpenFile called from: 0xFFFFF804DEFDD645
00035537 91.08341980 [GoodEye] - ZwOpenFile(\SystemRoot\system32\ntoskrnl.exe)
00035538 91.08346558 [GoodEye] - ZwOpenFile handle result: 0xFFFFFFFF80004260
// rest of the kernel drivers are opened down here....
Running Processes Enumeration
BEDaisy also constantly enumorates running processes using NtQuerySystemInformation except with SystemProcessInformation, this can also be easily hooked to filter out specific executables from BEDaisy’s queries. This constant enumeration of running processes and subsequent evaluation of these running processes is what gets most people banned. Their aggressive scanning of running usermode processes is very lethal to those who are trying to subvert BattlEye. Do not underestimate these scans.
// first call is always to query the size of data going to be returned by the second call...
00879607 97.59895325 [GoodEye]ZwQuerySystemInformation called
00879608 97.59895325 [GoodEye] - SystemInformationClass: 0x0000000000000005
00879609 97.59895325 [GoodEye] - SystemInformation: 0xFFFFB507A550E8E4
00879610 97.59895325 [GoodEye] - SystemInformationLength: 0x0000000000000000
// allocate memory big enough for second call to ZwQuerySystemInformation.
00879611 97.59963989 [GoodEye]ExAllocatePoolWithTag called from: 0xFFFFF804DEFD87E0
00879612 97.59964752 [GoodEye] - PoolType: 0x1
00879613 97.59964752 [GoodEye] - NumberOfBytes: 0x46ab8 // note that this is going to be the same size returned from the second call to ZwQuerySystemInformation...
00879614 97.59964752 [GoodEye] - Tag: 0x4542
00879615 97.59964752 [GoodEye] - Allocate Pool at: 0xFFFFC8081D149000
// get running process information and install APC's on every thread...
00879616 97.59964752 [GoodEye]ZwQuerySystemInformation called
00879617 97.59964752 [GoodEye] - SystemInformationClass: 0x0000000000000005
00879618 97.59965515 [GoodEye] - SystemInformation: 0xFFFFC8081D149000
00879619 97.59965515 [GoodEye] - SystemInformationLength: 0x0000000000046AB8 // indeed it is the same size...
This enumeration of processes is also used to install APC’s on all usermode processes threads. As seen below in the runtime logs of GoodEye.
__int64 __usercall setup_apc(int _EAX, unsigned int a2, __int64 a3, char a4, __int64 a5, __int64 a6, char a7, char a8)
{
passed_threadid = *(_QWORD *)(a3 + 80i64 * a2 + 0x130);
current_thread_id = PsGetCurrentThreadId(a3);
if ( passed_threadid != current_thread_id )
{
current_thread_id = PsLookupThreadByThreadId(passed_threadid, &some_pethread);
if ( (signed int)current_thread_id >= 0 )
{
allocated_pool = ExAllocatePool(0x200i64, 0x878i64);
if ( allocated_pool )
{
event_object = allocated_pool + 0x58;
KeInitializeEvent((PRKEVENT)(allocated_pool + 0x58), 0, 0);
// this line of code is going to catch alottttt of people!
KeInitializeApc(allocated_pool, some_pethread, 0i64, j_apc_callback, 0i64, 0i64, v82, 0i64);
if ( (unsigned __int8)KeInsertQueueApc(allocated_pool, allocated_pool, 0i64, 2i64) )
{
v91 = 0xFFFFFFFFFFF0BDC0i64;
v81 = (__int64)&v91;
v29 = KeWaitForSingleObject(event_object, 0i64, 0i64);
if ( v29 )
{
if ( v29 != 258 )
return ObfDereferenceObject(some_pethread);
if ( !((unsigned __int8 (__fastcall *)(__int64))((char *)&loc_FFFFF8007E2E677A + 1))(allocated_pool) )
{
__asm { rcl ecx, 22h }
v81 = (__int64)&v91;
if ( (unsigned int)KeWaitForSingleObject(event_object, 0i64, 0i64) )
return ObfDereferenceObject(some_pethread);
}
// ....
Asynchronous Procedure Call (APC)
BEDaisy registers APCs on all user mode threads in every process, the APC code that is executed simply calls RtlWalkFrameChain which inturn provides BEDaisy with all of the stack frames on the thread that executed the APC (256 of the stack frames).
__int64 __usercall apc_callback(__int64 *a3)
{
__int64 v4 = *a3;
*(_DWORD *)(v4 + 0x870) = RtlWalkFrameChain(*a3 + 0x70, 256i64, 0i64);
return KeSetEvent(v4 + 0x58, 0i64, 0i64);
}
The APC finishes by calling KeSetEvent which is used to set an event object to a specified signal state. This event object is used in combination with KeWaitForSingleObject to process your call stack. This is perfect for detecting manually mapped drivers and dlls. Although this is great for finding code execution where it shouldn’t be, things that are JIT compiled will show a lot of false positives since those executable pages are not backed by an image on disk nor in the LDR so i’m not entirely sure how useful this is.
To prevent these APCs from detecting code execution where it shouldn’t be in your thread, you can simply swap the pointer to RtlWalkFrameChain and KeSetEvent to point to your implementations of these functions. You could also disable APC’s all together on the thread executing your code, although only for small periods of time. Doing so would require kernel level privilege nonetheless. Another set of hooks could also be placed on KeInitializeApc, and KeInsertQueueApc.
Hardware Identification
BEDaisy is responsible for getting your hardware identifiers. First it does this by opening a handle to DR0 (disk.sys), then it calls MmGetSystemRoutineAddress for ZwDeviceIoControlFile as seen below.
02646022 190.98799133 [GoodEye]ZwOpenFile called from: 0xFFFFF804DEFDB904
02646023 190.98799133 [GoodEye] - ZwOpenFile(\Device\Harddisk0\DR0)
02646024 190.98869324 [GoodEye] - ZwOpenFile handle result: 0xFFFFFFFF80003E28
02646025 190.98876953 [GoodEye]MmGetSystemRoutineAddress: ZwDeviceIoControlFile
02646026 190.98876953 [GoodEye]Hooking ZwDeviceIoControlFile....
02646049 190.99142456 [GoodEye]ZwDeviceIoControlFile Called From 0xFFFFF804DEFDB94A
02646050 190.99143982 [GoodEye] - FileHandle: 0xFFFFFFFF80003E28
02646051 190.99143982 [GoodEye] - IoControlCode: 0x00000000002D1400
02646052 190.99143982 [GoodEye] - OutputBufferLength: 0x0000000000000008
02646053 190.99143982 [GoodEye] - InoutBufferLength: 0x000000000000000C
02646059 190.99192810 [GoodEye]ZwDeviceIoControlFile Called From 0xFFFFF804DEFDB960
02646060 190.99192810 [GoodEye] - FileHandle: 0xFFFFFFFF80003E28
02646061 190.99192810 [GoodEye] - IoControlCode: 0x00000000002D1400
02646062 190.99192810 [GoodEye] - OutputBufferLength: 0x0000000000000000
02646063 190.99194336 [GoodEye] - InoutBufferLength: 0x000000000000000C
02646072 190.99209595 [GoodEye]ZwDeviceIoControlFile Called From 0xFFFFF804DEFDB9B1
02646073 190.99211121 [GoodEye] - FileHandle: 0xFFFFFFFF80003E28
02646074 190.99211121 [GoodEye] - IoControlCode: 0x000000000007C088
02646075 190.99211121 [GoodEye] - OutputBufferLength: 0x0000000000000211
02646076 190.99211121 [GoodEye] - InoutBufferLength: 0x0000000000000021
02646082 191.00819397 [GoodEye]IofCompleteRequest called from: 0xFFFFF804DEFDB515
02646083 191.00819397 [GoodEye] - Request Called From: BEService.exe
02646084 191.00819397 [GoodEye] - IRP_MJ_READ!
02646085 191.00819397 [GoodEye] - ReadSize: 0x00000000000003FC
02646086 191.00819397 [GoodEye] - UserBuffer: 0x00007FF7BBAB8066
02646087 191.00820923 [GoodEye] - MdlAddress: 0x0000000000000000
02646088 191.00820923 [GoodEye] - SystemBuffer: 0x0000000000000000
It then makes three IOCTL requests via ZwDeviceIoControlFile to disk.sys. The first two requests are forwarded out of disk.sys and to classpnp.sys.
The final IOCTL request sent to disk.sys is for receiving S.M.A.R.T data. You can read more about S.M.A.R.T here.
Although ZwDeviceIoControlFile is resolved when it’s needed, one can still easily hook ZwOpenFile and make it return a handle to a malicious driver. Another useful hook can be placed on IofCompleteRequest, this would allow easy alteration of any data being returned back from BEDaisy.
Conclusion
For a proof of concept I made a tiny rust cheat to set FOV to 120, just to demonstrate that such reads/writes were possible to “bypass” other anti cheats. I assume this also works for valorant but I dont have the game. You can find the PoC for this code here.