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

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.


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:

{{< highlight cpp >}} 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… {{< /highlight >}}

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


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


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

{{< highlight cpp >}} 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 );


} {{< /highlight >}}

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.

{{< highlight cpp >}} __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);
		// ....

{{< /highlight >}}

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

{{< highlight cpp >}} __int64 __usercall apc_callback(__int64 *a3) { __int64 v4 = *a3; *(_DWORD *)(v4 + 0x870) = RtlWalkFrameChain(*a3 + 0x70, 256i64, 0i64); return KeSetEvent(v4 + 0x58, 0i64, 0i64); } {{< /highlight >}}

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.


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.

Back to Blog

Related Posts

View All Posts »
Voyager - A Hyper-V Hacking Framework

Voyager - A Hyper-V Hacking Framework

Voyager is a Hyper-V hijacking project based upon existing Hyper-V hijacking work by cr4sh which aims to extend the usability to AMD and earlier Windows 10 versions.

VDM - Vulnerable Driver Manipulation

VDM - Vulnerable Driver Manipulation

Exploiting vulnerable Windows drivers to leverage kernel execution is not a new concept. Although software that exploits vulnerable drivers has been around for a long time, there has yet to be a highly modular library of code that can be used to exploit multiple drivers exposing the same vulnerability...