The source of the final project can be found here.

Disclaimer

You know the drill. This article is not an attack on the EasyAntiCheat company in particular but rather a call to action to make the necessary improvements. This research was compounded through private research to entice EasyAntiCheat to fix potential attack vectors to maintain game security, meaning that this article is simply for educational purposes. I do not associate with any game hack publishers, nor am I an active writer of game hacks for monetary gain.

For those who wish to contact me for questions/concerns unrelated to cheating, my public email is [email protected].

However, it may be easier to find me on the linked social media accounts on the home page of this blog site.

Introduction

EasyAntiCheat.sys, the anti-cheat’s kernel mode driver, is a primary factor in protecting and assessing the integrity of a system. It utilizes the lowest level of privileges available to the system (apart from using VT-X/AMD-V, which has yet to reach its share of requisites), making it the utmost necessity that this driver is not compromised or manipulated by any ulterior factors. An attacker can do as he wills as long he can control the anti-cheat’s code, leaving everything up to the attacker’s imagination. EasyAntiCheat has understood this quite well, implementing various methods to verify its driver’s code for integrity.

Today, we will look at the most effective (and most recent) method, some background surrounding how it works, and why it’s ultimately doomed to fail.

Previous Works

This article will reference previous works in its efforts to map out the history of this particular integrity check, and how it has evolved to the way it now exists.

The following will be referenced at some point during this article:

https://git.back.engineering/_xeroxz/vmhook-eac - Abusing VMProtect 2.x vulnerabilities to gain control of the virtual machine’s virtual instruction handlers.

https://secret.club/2020/04/08/eac_integrity_check_bypass.html - The most dated article explaining how EasyAntiCheat evaluates its self integrity.

https://github.com/thesecretclub/CVEAC-2020 - The github repository created by the author of the aforementioned article.

The Problem

As any hacker/cheater might tell you, trying to deliberately modify EasyAntiCheat will not end well unless you know what you are doing. This statement holds out quite well, as any attempt to alter the driver’s code sections leads to an inevitable system crash leaving the owner of the system baffled and utterly confused. It turns out that when you try to modify the contents of the driver in 2022, EasyAntiCheat responds to this by making a call to the invalid address, 0xEAC, which is quite a play on their part.

Additionally, they have quite a variety of these “crashing” methods and tend to switch things up depending on some unknown factors that exist in the driver, where they sometimes bring your system to a halt by attempting to divide an integer by 0, or executing int 0x29 in its driver, the instruction equivalent to the intrinsic __fastfail().

Figure 1: EasyAntiCheat system crash after memory patch

seg008:0000000140702458 66 48 0F 7E F1  movq    rcx, xmm6 ; rcx=0xEAC
seg008:000000014070245D CD 29           int     29h  ; Win8: RtlFailFast(ecx)

The previous demonstration illustrates what happens after patching the EasyAntiCheat driver, even before its entry point is invoked. In this case, the driver crashes the system by executing __fastfail( 0xEAC );

While in the past, iPower (author of the secret.club article) was able to circumvent the driver integrity using the previously linked repository, his code no longer functions as intended (even when updated). This happens because to EasyAntiCheat, the integrity check overcome by iPower is a thing of the past; the anti-cheat currently employs more integrity checks to validate the result of the one he was able to delude.

In early June of 2021, EasyAntiCheat took a pre-emptive measure to stop malicious users from bypassing the anti-cheat by introducing a virtual machine (virtual machine here refers to code obfuscation, not system virtualization) protected function that serves as a second attempt to attest to the integrity of its code. That brings us to the work of my pal, _xeroxz, who was able to apply his VMProtect research to manipulate the integrity check at its root by using a function that looks somewhat like this lambda:

auto vm_memcpy = [](uint8_t* address, uint8_t* src, uint32_t len)
{
   for (auto i = 0UL; i < len; ++i)
      address[i] = src[i];
	
    return address;
};

Note: Because this lambda function is virtualized under the virtual machine, we are only left with the final behavior to make sense of, so of course, the above is an approximation at best. It may not be a lambda but an inlined replication of memcpy.

EasyAntiCheat has relied on this function for very superficial reasons. For one, the integrity cannot be bypassed by hooking the standard library function memcpy, commonly used to copy chunks of data, as this is an entirely different function. Furthermore, it makes it all the more difficult to locate where the driver’s integrity is being evaluated, making the analysis of the methods used very difficult.

_xeroxz, however, was able to take advantage of this approach, and a VMProtect targeted attack to filter through the VREADQ operations (i.e., the virtual machine’s method of reading a qword as opposed to a native read instruction) and modify the address of the read register to a ‘spoofed’ value of a copy of EasyAntiCheat.sys with no patches to work with when the virtual machine attempts to access driver memory that should initially have been untampered with.

This event, as usual, was not taken lightly by EasyAntiCheat, as they immediately made efforts to make this technique severely more challenging to perform. Unfortunately, however, after struggling with the underlying vulnerabilities in VMProtect 2. x, the company decided a new solution was the only remaining option.

Since I wrote this post, it has been roughly ten months since this new solution, a private virtual machine obfuscator, was implemented.

The hurdle: a new virtual machine

With the development (or perhaps the acquisition, I’m not entirely sure what the origins of this new obfuscator are) of this protection solution, EasyAntiCheat has gained more freedom with how the virtual machine operates and the type of virtual operations (or vmhandlers for those who use such terminology) that exist. However, this brings us to a new hurdle: How do we hook these obfuscated operations in this novel virtual machine?

Well, if we were to get technical, brief virtual machine analysis reveals that there are currently 6 virtual operations used to read memory (and in particular, the driver’s memory): VMOVQ, VADDQ, VORQ, VSUBQ, VXORQ, VANDQ. Moreover, these operations are virtually the same, as they always operate on an empty destination register, thus replicating the behavior of a simple mov operation in 5 more unique ways.

In MASM, these instructions would look like this:

; movq
mov reg1, [reg2]
	
; addq
add reg1, [reg2] ; where reg1=0, always
	
; orq
or reg1, [reg2] ; where reg1=0, always
	
; subq
sub reg1, [reg2] ; where reg1=0, always
neg reg1 ; get the positive value, i.e the actual intended read
	
; xorq
xor reg1, [reg2] ; where reg1=0, always
	
; andq
and reg1, [reg2] ; where reg1=0, always

Note: Reg1 and Reg2 are substitutions for actual native registers, but they vary between each update.

This begs the question: What if we were to replicate _xeroxz’s work into this virtual machine and hijack these operations? While this approach is certainly feasible, it requires extensive work and repetitiveness because this new obfuscator rotates the usage of registers in virtual machine operations every update, and uses multiple copies of these instructions in memory, making this a quite unstable and challenging solution to maintain.

If we can’t simply remodel the solution _xeroxz provided, how can we manipulate this integrity check without dealing with extensive changes?

If you remember the title of this article, you’d know that the answer is to abuse: call hierarchy.

Exploration

As Nicholas Negroponte once said, “Programming allows you to think about thinking, and while debugging you learn learning.”

So, lets start learning to learn by debugging the kernel driver 😁

We can immediately get started with a WinDbg session and the preferred virtual machine (here, virtual machine refers to virtualization/emulation of a system). For this article’s purpose, I’m using WinDbg combined with VMWare, a popular software available online. As for the game, I will be running Rust for all our debugging purposes.

The first step is to breakpoint on the kernel function, nt!PnpCallDriverEntry, the ntoskrnl function responsible for invoking the entry point of a kernel image. This allows us to prepare any future breakpoints / obtain the base of the anti-cheat before it gets a chance to load fully.

As soon as the breakpoint is invoked, I can obtain the rcx register using the command r rcx, and then display the structure of this register (RCX is the first parameter of the function of type _DRIVER_OBJECT) by using the command dt rcx _DRIVER_OBJECT.

We do this to get the driver’s base address to set a break on access breakpoint. The corresponding command for our target would be: ba r 1 EasyAntiCheat.sys+0x1000, where EasyAntiCheat is the base address of the driver, and +0x1000 is the offset to reach the base address of the .TEXT section. With this breakpoint, we can see every time the linear address is used for a read operation by utilizing debug registers in the kernel.

Keep in mind our end goal here is to figure out what happens after a read takes place by looking at the call hierarchy. The best way to track this behavior is to use the trace command in WinDbg, and single step through a large number of instructions like, say, 10,000

For those who are performing this exercise, the proceeding clip is a demonstration of all the steps we have just outlined. Figure 2: Debugging

Note: To clarify, I did poorly explaining why I skipped the “shl ecx, 8” breakpoints in this clip. This breakpoint pertains to the check bypassed previously by iPower, not the virtual machine code we are looking for.

What’s left after running this command is to begin searching for call instructions to determine whether or not the virtual machine is calling any intermediate functions as part of the integrity check. To do this, you can use the Ctrl+F shortcut in WinDbg to search for the text “call.” and investigate the results. What we end up discovering is this bizarre code:

seg008:0000000140427410 mov     [rsi], rbx
seg008:0000000140427413 call    rbp
seg008:0000000140427415 add     rsp, 118h
seg008:000000014042741C call    qword ptr [rsp+10h]
seg008:0000000140427420 sub     rsp, 118h
seg008:0000000140427427 mov     [rsp], rdi
seg008:000000014042742B push    r12
seg008:000000014042742D lea     rdi, cs:14B94F730h
seg008:0000000140427434 mov     r12, 0FFFFFFFFF47F5F70h
seg008:000000014042743E add     rdi, r12
seg008:0000000140427441 pop     r12
seg008:0000000140427443 xchg    rdi, [rsp]
seg008:0000000140427447 call    qword ptr [rsp]

VMCALL - A virtual operation

As you may have noticed, this is no ordinary code but the virtual machine of this driver.

Let’s unpack these instructions and see what they do:

; vstack = virtual stack...
mov     [rsi], rbx
call    rbp ; A function that reads registers from the virtual stack 
			(sort of like the WINAPI `RtlRestoreContext` but for a VM)
add     rsp, 118h ; 0x118 is presumably the size of the virtual machine context
call    qword ptr [rsp+10h] ; the offset where the call address is stored
sub     rsp, 118h ; return the stack pointer is now back to the vm context
mov     [rsp], rdi
push    r12
lea     rdi, cs:14B94F730h
mov     r12, 0FFFFFFFFF47F5F70h ; Calculate the address of the next routine
add     rdi, r12
pop     r12
xchg    rdi, [rsp] 
call    qword ptr [rsp] ; A function that reads native registers into the vstack
				; (again, sort of like the WINAPI `RtlCaptureContext` but for a VM)

In other words, this is what we call a VMCALL operation, denoted by its ability to call exterior routines while running code within the virtual machine. Because we created a trace with WinDbg, we know what function this vm operation was trying to execute.

Thankfully, since the executed function is not obfuscated, we can easily understand what it does. It currently implements this check:

// THE IMAGE BASE OF THE CURRENT MODULE, NAMELY EASYANTICHEAT.SYS
extern "C" IMAGE_DOS_HEADER __ImageBase;

// address is always some address inside EasyAntiCheat.sys...
bool __fastcall is_reloc( __int64 address )
{
	 unsigned int iterated_len;
	 unsigned int rva;
	 unsigned int directory_size;
	 unsigned int offset_align;
	 unsigned int reloc_rva;
	 __int32 directory_rva;
	 IMAGE_NT_HEADERS *nt_headers;
	 IMAGE_BASE_RELOCATION *reloc_data;
	 int idx;
	 unsigned __int64 num_entries;

	 iterated_len = 0;
	 nt_headers = ( PIMAGE_NT_HEADERS )( ( __int64 )&__ImageBase + __ImageBase.e_lfanew );
	 rva = address - ( long long )&__ImageBase;
	 directory_size = nt_headers->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].Size;

	 if ( directory_size )
	 {
		  directory_rva = nt_headers->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].VirtualAddress;
		  offset_align = rva & 0xFFFFF000; // ALIGN THE OFFSET TO A PAGE BOUNDARY 

		  do
		  {
			   // PAGE OFFSET OF THE RELOCATION ENTRY...
			   reloc_rva = *( unsigned int * )( ( char * )&__ImageBase + directory_rva + iterated_len );

			   // HAVE WE ALREADY SURPASSED THE PAGE WHERE THE RVA OF THE INPUT WAS?
			   if ( reloc_rva > offset_align )
					break;

			   // ARE WE LOOKING AT THE RIGHT PAGE IN THE RELOCATION TABLE?
			   if ( reloc_rva + PAGE_SIZE == offset_align || reloc_rva == offset_align )
			   {
					reloc_data = ( IMAGE_BASE_RELOCATION * )( ( char * )&__ImageBase + directory_rva + iterated_len +
															  sizeof( long long ) );
					idx = 0;
					num_entries =
						( ( ( IMAGE_BASE_RELOCATION * )( ( char * )&__ImageBase + directory_rva + iterated_len ) )->SizeOfBlock -
						  sizeof( long long ) ) >> 1;

					if ( num_entries )
					{
						 //
						 // IS THE RELOC TYPE IMAGE_REL_BASED_DIR64?
						 // DOES THE RVA OF THIS RELOC ITEM MATCH THE INPUT ADDRESS?
						 // 0xA000 >> 12 = 10 (IMAGE_REL_BASED_DIR64)
						 //
						 
						 while ( ( reloc_data->VirtualAddress & 0xF000 ) != 0xA000 ||
								 ( reloc_rva + ( reloc_data->VirtualAddress & 0xFFF ) ) != rva )
						 {
							  // OBTAIN THE NEXT ITEM IN THE VIRTUAL PAGE LIST OF THE
							  // RELOCATION TABLE...

							  reloc_data =
								  ( IMAGE_BASE_RELOCATION * )( ( char * )reloc_data + sizeof( unsigned short ) );
								  
							  if ( ++idx >= num_entries )
								   goto jcc_continue;
						 }

						 // THIS MEANS THAT THE INPUT ADDRESS HAS A RELOCATION
						 return true;
					}
			   }

		  jcc_continue:
			   iterated_len += ( ( IMAGE_BASE_RELOCATION * )( ( char * )&__ImageBase + directory_rva + iterated_len +
															  sizeof( long ) ) )->SizeOfBlock;

		  } while ( iterated_len < directory_size );
	 }

	 // THIS MEANS THAT THE INPUT ADDRESS WAS NOT RELOCATED
	 return false;
}

If you have difficulty reading the code on site, feel free to click “COPY” and read the code elsewhere.

To the naked eye, this routine doesn’t exactly make sense in the context of an integrity check. Relocations are what get performed on a PE binary during map time to relocate all absolute addresses to the allocated base address, so shouldn’t EasyAntiCheat perform this beforehand?

Why would the driver invoke this routine before every single read on the driver’s memory? The hypothesis is this: EasyAntiCheat compares the driver’s code section (.TEXT) by accessing a refreshed copy of the driver that corresponds to the raw image of the driver on disk before mapping. This means that they cannot simply use memcmp or similar to compare the entire sections, because discrepancies in the relocations have yet to be resolved.

To test this hypothesis, you’d have to set a hardware breakpoint on this function, invert the return value (even if just once), and observe the system for any adverse effects.

The result is as anticipated: EasyAntiCheat executes __fastfail( 0xEAC ), ending the system session. An explanation for why this happens is simple. By modifying the return value, EasyAntiCheat fails to account for or falsely relocates an address in its comparison buffer, causing the final comparison to fail. Even though the driver’s code was never modified, the integrity check failed.

This does two things for us. Firstly, we have just associated an unobfuscated function with this mysterious integrity check, and gained some insight as to how the check works.

The Exploit – Manipulating the Virtual Machine

Now that we are aware of an existing function – that we can easily relocate with a new update using an automated signature, it’s time to figure out how we can use our knowledge to manipulate the integrity check.

If you’ve read a research topic or reversed a virtual machine before, you’d know that virtual machines tend to store their unique virtual registers in a structure, which is stored particularly on the stack. The virtual machine can dictate how many virtual registers there are. The registers don’t necessarily have to be in the same numbers as the number of native registers of a typical executing CPU. As the virtual machine continues executing, the context structure gets continually updated with the actual register values that you would otherwise find in the native registers of the CPU.

This helps us because we can poke its values as long as we can iterate the stack with the virtual machine context. Recall the explanation of how a VMCALL works above. The virtual machine calls a function semantically similar to the WINAPI function RtlRestoreContext, the procedure used to restore native registers to values stored in a provided structure. In a sense, this function prepares the function arguments of the target function of this VMCALL, which means we can look at this routine to locate the offset in the stack where the address provided is the one passed to RCX, the register containing the first parameter of any x64 function.

Rather than trying to spoof only this one field, a field likely to change by the next update–thus defeating the purpose of our goal, we can scan the entire stack for the address provided and make the required changes. It is also a good idea to sanitize the native registers to make sure the virtual machine has no way to recover the address once it’s modified.

Fortunately, kernel WINAPI provides us with an API function to feed us just the information to iterate through the stack: IoGetStackLimits.

The culmination of this research brings us to this final code:

extern is_reloc:proc

vm_is_reloc PROC
	
	push rax
	push rcx
	push rbx
	push rdx
	push rsi
	push rdi
	push r8
	push r9
	push r10
	push r11
	push r12
	push r13
	push r14
	push r15

	mov rcx, rsp
	pushfq

	sub rsp, 20h
	call is_reloc
	add rsp, 20h

	popfq

	pop r15
	pop r14
	pop r13
	pop r12
	pop r11
	pop r10
	pop r9
	pop r8
	pop rdi
	pop rsi
	pop rdx
	pop rbx
	pop rcx
	pop rax

	ret

vm_is_reloc ENDP
END
void is_reloc( context_t *context )
{

	 const auto address = reinterpret_cast<uint8_t *>( context->rcx );
	 const auto read_addr = context->rcx;

	 if ( g_eac_instance->mem_in_bounds( address ) && g_eac_instance->mem_in_scn( address, IMAGE_SCN_MEM_EXECUTE ) )
	 {
		  const auto rebased = reinterpret_cast<uint64_t>( address - g_eac_instance->get_image_base() +
														   g_eac_instance->get_raw_image() );

		  //
		  // To 'spoof' all driver reads, we use IoGetStackLimits to
		  // get the start-end address of the stack then proceed to
		  // iterate through the entire stack looking for instances of the target
		  // address then modifying it to our 'cleaned' address
		  //
		  // Moreover, spoofing registers are necessary to make sure the address
		  // is no longer reachable from the current registers
		  //

		  uint64_t stack_low = 0, stack_high = 0;
		  IoGetStackLimits( &stack_low, &stack_high );

		  while ( stack_low < stack_high )
		  {
			   const auto current_offset = reinterpret_cast<uint64_t *>( stack_low );
			   if ( *current_offset == read_addr )
					*current_offset = rebased;

			   stack_low += sizeof( uint64_t );
		  }

		  const auto ctx_array = reinterpret_cast<uint64_t *>( context );
		  for ( auto i = 0UL; i < sizeof( *context ); i++ )
		  {
			   if ( ctx_array[i] == read_addr )
					ctx_array[i] = rebased;
		  }

		  const auto rva = read_addr - reinterpret_cast<uint64_t>( g_eac_instance->get_image_base() );
		  if ( rva == reinterpret_cast<uint64_t>( PAGE_ALIGN( rva ) ) )
		  {
			   //
			   // Only log if EAC is reading the start of a page to prevent excess logging....
			   //

			   kprint( "is_reloc(tid: %i) reading -> EasyAntiCheat.sys+0x%x\n", CURRENT_THREAD_ID, rva );
		  }
	 }

	 //
	 // this isn't a traditional hook; we must call the original and update the result register
	 // with the return value, otherwise we can expect undefined behavior...
	 //

	 context->rax = reinterpret_cast<bool ( * )( uint64_t )>( g_is_reloc )( read_addr );
}

The MASM code provided takes care of register collection, then passes the structure to our ACTUAL hook, the is_reloc() function. This function loops through the entire stack and the context structure and spoofs all addresses that match the parameter the VM intended to provide with the VMCALL operation, to an address of a copied EasyAntiCheat.sys with no patches.

This solution, together with a hook on the SHA1 function briefly seen in the clip earlier in this article, can be used to manipulate the driver freely. However, this will not bypass all the checks the driver has. For instance, EasyAntiCheat also copies all pages of its driver using MmCopyMemory for integrity, so keep this in mind before deploying this to cheat. I intentionally did not include the solution here to prevent any cheating outbreaks.

Conclusion

By looking at call hierarchy, one can manipulate the state of a virtual machine to change its behavior. There are a couple of solutions to this I was able to think of here:

  • Encrypt the VM context before a VMCALL operation
  • Don’t call an intermediate function during critical code (Not ideal, but a solution)
  • Solve the relocations before performing any reads

As always, here is the final result: Figure 4: Final result