Dynamically analyzing the EasyAntiCheat driver’s external call paths with revhv
Using EPT to dynamically log and analyze a highly obfuscated anti-cheat driver
When it comes to highly obfuscated and virtualized code, it’s often best to combine dynamic and static analysis. Static analysis alone can be both blind and time-consuming against such targets. Today, we’re going to focus on the dynamic-analysis side and on how I developed revhv to support it, with some real-world findings.
Since revhv’s README already explains a lot, this post won’t focus too much on the technical implementation details. Everyone is welcome to take a look at the source and contribute as well.
I first tried to implement the concept you’re going to see in jonomango’s hypervisor before committing to the development of a hypervisor from scratch. I was satisfied with the initial findings, so I decided to build revhv. Full credit to jonomango for a well-made hypervisor that made testing such concepts easy.
To be clear going forward, the core mechanism is not novel in the broad sense. EPT/NPT-based hooking, hypervisor assisted debugging, etc. have been explored extensively. What revhv focuses on is a narrower workflow: tracing all control-flow transitions across a target boundary to make heavily obfuscated code, particularly kernel code, easier to triage.
Disclaimer
This post is solely intended for educational and research purposes. I don’t intend to harm or discredit EasyAntiCheat in any way, and I have the utmost respect for its developers. I won’t reverse-engineer or share information about any portion of their driver here. Instead, I will focus on a generalized approach that uses EPT to log jumps and calls into outside modules made by a driver, and on how to analyze those findings.
Note: Logged data subjected to analysis in this write-up intentionally dates back to August 2025, therefore it may not contain the most recent behavior.
Goal and initial thinking process
Knowing which imports a driver has, or more generally which functions it calls from other modules, lets us infer a lot about what the driver cares about or what it’s trying to accomplish. Without that information, you usually won’t have much idea where to begin your static analysis or where any deeper reverse engineering should take place. Finding this out is difficult when dealing with a highly obfuscated driver like the EAC driver, because no competent anti-cheat developer would leave the imports they use out in the open. What the EAC driver does to parse and store these imports has already been documented by reverse engineers in the past, such as 0AVX, so today we’ll approach the problem differently and work toward a more generalized solution.
From now on, I’ll refer to the EAC driver as “the driver”.
First of all, imagine this: whenever the CPU is executing code that resides in one of the pages belonging to the driver, and it suddenly starts executing code in a page that doesn’t belong to the driver, what could cause that change? Here are the two main possibilities:
- The driver executes a call, jump, return, or similar control transfer into outside code, such as calling an import or returning from a callback.
- Execution comes from an interrupt or exception path.
Obviously we’re interested in number 1, and we want to filter out number 2 later because those paths are usually not voluntarily planned by the driver and happen very often, which crowds the logs. Following that line of thinking, we now have a high-level goal: log every time the CPU jumps from executing a page of the driver to executing any other page.
+------------------------+ +------------------------+
| | | |
| target_driver.sys | | ntoskrnl.exe |
| | | |
+------------------------+ +------------------------+
| ^
| |
|-------- log this transition -------->|
| |
|<------------- return ----------------|
Approach and design
Luckily for us, EPT has already been used in similar ways before, so we already have a rough idea of what a good approach might look like. First, we want to cause a VMEXIT any time the CPU tries to fetch an instruction from a page belonging to the driver. This is easy, because we can clear the Execute flag from all EPT pages that correspond to the driver. That results in an EPT-violation VMEXIT whenever the CPU tries to fetch an instruction there. In our VMEXIT handler, we want to reset the Execute flag so the driver can execute normally, but then we would like to see a VMEXIT if the CPU tries to fetch an instruction from any page not belonging to the driver. This way, we’ll be able to VMREAD RIP, which is the virtual address where the CPU is trying to fetch an instruction, and log it. Let’s quickly visualize what that might look like:
void handle_ept_violation(vcpu* const cpu)
{
if (cpu->target_no_execute)
{
// placeholder: set all other EPT pages as No-Execute
// placeholder: set all target EPT pages as Executable
}
else
{
auto const guest_rip = vmx_vmread(VMCS_GUEST_RIP);
// this is a control transition such as target+some_rva -> nt!MmCopyMemory
// we could also log any other info like guest registers here for a deeper post-analysis. (revhv supports dynamic configuration of which data points to log here)
LOG(guest_rip);
// placeholder: set all target EPT pages as No-Execute
// placeholder: set all other EPT pages as Executable
}
cpu->target_no_execute = !cpu->target_no_execute;
vmx_invept(invept_all_context, {});
// don't skip the instruction, let it retry.
}
Refining
Obviously, iterating through the entire EPT at each VMEXIT wasn’t viable. Walking the whole table, changing permissions individually, and then invalidating the cache causes a massive performance hit. However, there’s a simple and common solution: keep two sets of EPT tables and EPTPs (EPT pointers). One of them marks the driver as No-Execute, and the other marks all EPT pages as No-Execute except the driver pages. Then we only have to swap between the two EPTPs, drastically reducing the overhead and effectively ping-ponging the driver between two sets of EPT tables. Let’s re-visualize the design with that change:
void handle_ept_violation(vcpu* const cpu)
{
if (cpu->target_no_execute)
{
vmx_vmwrite(VMCS_CTRL_EPT_POINTER, cpu->others_no_execute_eptp.flags);
}
else
{
auto const guest_rip = vmx_vmread(VMCS_GUEST_RIP);
// this is a control transition such as target+some_rva -> nt!MmCopyMemory
// we could also log any other info like guest registers here for a deeper post-analysis. (revhv supports dynamic configuration of which data points to log here)
LOG(guest_rip);
vmx_vmwrite(VMCS_CTRL_EPT_POINTER, cpu->target_no_execute_eptp.flags);
}
cpu->target_no_execute = !cpu->target_no_execute;
// we don't even need to INVEPT here since all internal cache is tagged with the effective EPTP.
// don't skip the instruction, let it retry.
}
If you’re interested in how the two EPTPs are set up in detail, you can check the revhv source.
Possible issues
Keep in mind that while the EAC driver doesn’t have any pageable code sections, a lot of drivers do, and that needs to be handled differently if you intend to analyze them like this. You could lazily acquire the PFN whenever the OS pages the driver in, or simply try to lock the pages.
Remember the part where we said the transition might occur due to interrupts or exceptions as well? You will naturally see a lot of logs related to those, as well as logs that are just the driver returning from a callback. However, since there are finite number of IDT entries and usually a few specific callback addresses, identifying and filtering those are more of an annoyance as a reader rather than an actual issue. It takes a couple of minutes to identify and filter them out.
The need for a high-performance logger becomes apparent at this stage, because thousands of VMEXITs can fire per second. You can check the implementation to see how revhv’s high-performance trace engine works.
Some info from the analysis
After launching the hypervisor and getting ready to collect logs, I got into Rust (as this is the game where EAC is most visible in practice) and captured activity from entry until unload for around 20 minutes. I created a basic Python script to summarize the logs generated by revhv and export them to a CSV file. Let’s check out some portions of it, keeping in mind that these logs are the result of one specific run of the hypervisor and that call counts are a highly volatile metric. My comments here are purely educated guesses, because I did not reverse engineer the actual driver code and only logged function calls and return addresses. Feel free to correct me if you think I’m wrong.
revhv was configured to log only the guest RIP and return address for all trace entries; no guest registers were recorded.
All module names have the “.sys” suffix omitted for cleanliness.
Let’s start with some unusual stuff and comment on why:
| Rank | Function | Call Count | Percentage |
|---|---|---|---|
| 110 | ntoskrnl!MiTrimOrAgeWorkingSet+2a4 | 1234 | 0.02% |
| 166 | ntoskrnl!MiAllocateVirtualMemory+499 | 98 | 0.00% |
| 168 | ntoskrnl!MmCopyVirtualMemory+28f | 86 | 0.00% |
As you can see, these do not look like regular function calls because they land in the middle of the respective functions. What these addresses have in common is that they are either the return address from a call to KiStackAttachProcess, or the address of the next instruction after __writecr3(DirectoryTableBase).
Following are the IDA decompilation snippets for ntoskrnl!MiTrimOrAgeWorkingSet+2a4 and ntoskrnl!MmCopyVirtualMemory+28f respectively:
// ...
else
{
__writecr3(DirectoryTableBase); // Writes to CR3 right before our logged line
}
if ( !KiFlushPcid && KiKvaShadow ) // This line is at ntoskrnl!MiTrimOrAgeWorkingSet+2a4 (next instruction after __writecr3)
// ...
// ...
KiStackAttachProcess((_KPROCESS *)BugCheckParameter1, 0, (__int64)v57); // Calls KiStackAttachProcess right before our logged line
v21 = Src; // This line is at ntoskrnl!MmCopyVirtualMemory+28f (return address of the previous call)
// ...
But, why is the EAC driver seemingly calling the middle of some NT functions? From previous reverse engineering by 0AVX, we know that EAC uses a clever technique to cause an exception whenever the game’s DirectoryTableBase in the EPROCESS structure is written to CR3. This is what __writecr3 does, and what KiStackAttachProcess fundamentally does. That write is then intercepted and replaced with the actual DirectoryTableBase after verifying the caller. In other words, the EAC driver has hooked the __writecr3 instruction and is returning back to its original call site, which is why it appears in the logs as if the driver randomly called into the middle of those NT functions.
I’ve included these logs in the post as a reminder that relying on this type of dynamic analysis still requires good intuition and technical knowledge. Even without the aforementioned post, we could still chase down why calling KiStackAttachProcess or executing __writecr3(DirectoryTableBase) results in execution transferring to the EAC driver, and we would likely reach a similar conclusion. However, to do that, you first need to notice that these are actually logs of returning from a hook or exception.
Apart from requiring the right experience and intuition, this is also something we could support more directly by expanding revhv to log incoming control transfers to the target as well. In that case, we would be logging all returns and interrupt returns, but most importantly any control transfers coming from a hook implemented by the target.
We can also see them emulating PspAllocateProcess from raw logs:
[1899826] ntoskrnl!PoEnergyEstimationEnabled+0x0 <- Return: EasyAntiCheat_EOS+some_RVA
[1899827] ntoskrnl!KeQueryMaximumGroupCount+0x0 <- Return: EasyAntiCheat_EOS+some_RVA
[1899829] ntoskrnl!ObCreateObject+0x0 <- Return: EasyAntiCheat_EOS+some_RVA
[1899830] ntoskrnl!ObfReferenceObjectWithTag+0x0 <- Return: EasyAntiCheat_EOS+some_RVA
[1899831] ntoskrnl!ObfDereferenceObjectWithTag+0x0 <- Return: EasyAntiCheat_EOS+some_RVA
[1899832] ntoskrnl!memset+0x0 <- Return: EasyAntiCheat_EOS+some_RVA
[1899833] ntoskrnl!LpcInitializeProcess+0x0 <- Return: EasyAntiCheat_EOS+some_RVA
[1899834] ntoskrnl!KzInitializeSpinLock+0x0 <- Return: EasyAntiCheat_EOS+some_RVA
[1899835] ntoskrnl!PspInitializeProcessLock+0x0 <- Return: EasyAntiCheat_EOS+some_RVA
...
Comparing to the relevant part of the IDA decompilation of PspAllocateProcess:
if ( (unsigned __int8)PoEnergyEstimationEnabled() )
{
NumberOfBytes_4 = (v21 + 7) & 0xFFFFFFF8;
v21 = NumberOfBytes_4 + 480;
v19 |= 0x2000u;
v16 = (int)v132;
}
MaximumGroupCount = KeQueryMaximumGroupCount();
LODWORD(v132) = MaximumGroupCount;
v25 = 0;
if ( (unsigned __int16)MaximumGroupCount > 1u )
{
v25 = (v21 + 7) & 0xFFFFFFF8;
v21 = 16 * (unsigned __int16)MaximumGroupCount + v25;
}
LOBYTE(v24) = a2;
LOBYTE(v23) = a2;
result = ObCreateObject(v23, (_DWORD)PsProcessType, v16, v24, 0, v21, 0, v21, (__int64)&Object);
if ( (int)result >= 0 )
{
v26 = (char *)Object;
ObfReferenceObjectWithTag(Object, 0x72437350u);
ObfDereferenceObjectWithTag(v26, 0x746C6644u);
memset(v26, 0, v21);
LpcInitializeProcess(v26);
ExInitializePushLock((PKSPIN_LOCK)v26 + 139);
PspInitializeProcessLock(v26);
...
Let’s move on with some actual function calls likely related to DMA and device fingerprinting:
| Rank | Function Name | Call Count | Percentage |
|---|---|---|---|
| 86 | ntoskrnl!HalpPCIConfig+0 | 2646 | 0.04% |
| 170 | ntoskrnl!MmMapIoSpaceEx+0 | 72 | 0.00% |
| 218 | ntoskrnl!HalAcpiGetTableEx+0 | 12 | 0.00% |
| 272 | ntoskrnl!VslGetSecurePciEnabled+0 | 2 | 0.00% |
| 174 | tbs!Tbsip_Submit_Command+0 | 62 | 0.00% |
| 217 | tbs!Tbsi_GetDeviceInfo+0 | 12 | 0.00% |
| 225 | tbs!Tbsi_Context_Create+0 | 10 | 0.00% |
| 226 | tbs!Tbsip_Context_Close+0 | 10 | 0.00% |
| 412 | tbs!Tbsi_Get_TCG_Log+0 | 2 | 0.00% |
| 270 | NETIO!GetIfTable2+0 | 2 | 0.00% |
| 258 | ntoskrnl!NtQueryVolumeInformationFile+0 | 4 | 0.00% |
| 196 | ntoskrnl!IoWMIQueryAllData+0 | 20 | 0.00 |
The tbs functions are related to TPM, which is quite a good way of fingerprinting on modern hardware. TCG logs also can reveal measured-boot / Secure Boot state. GetIfTable2 can be used to retrieve MAC addresses, NtQueryVolumeInformationFile can retrieve the volume serial, and so on.
Here are some of the callbacks that the driver registers and utilizes:
| Rank | Function Name | Call Count | Percentage |
|---|---|---|---|
| 128 | ntoskrnl!ObRegisterCallbacks+0 | 494 | 0.01% |
| 145 | ntoskrnl!KeRegisterNmiCallback+0 | 202 | 0.00% |
| 244 | ntoskrnl!DbgSetDebugPrintCallback+0 | 6 | 0.00% |
| 255 | ntoskrnl!PsSetCreateProcessNotifyRoutineEx+0 | 4 | 0.00% |
| 286 | ntoskrnl!PsSetCreateThreadNotifyRoutine+0 | 2 | 0.00% |
| 287 | ntoskrnl!PsSetLoadImageNotifyRoutine+0 | 2 | 0.00% |
| 256 | ntoskrnl!KeRegisterBugCheckReasonCallback+0 | 4 | 0.00% |
| 289 | ntoskrnl!SeRegisterImageVerificationCallback+0 | 2 | 0.00% |
| 290 | ntoskrnl!CmRegisterCallbackEx+0 | 2 | 0.00% |
| 291 | FLTMGR!FltRegisterFilter+0 | 2 | 0.00% |
| 248 | TDI!TdiRegisterPnPHandlers+0 | 4 | 0.00% |
Most of these are quite common, as you probably know. DbgSetDebugPrintCallback was interesting to me because I had been wondering why my DbgPrint calls from a manually mapped driver, which I was using to get a list of hooks installed by the EAC driver, were not showing up on DebugView. After seeing this callback registration, it’s clear that they are doing something with those debug prints in their callback, more than likely checking where the log call originates from. TdiRegisterPnPHandlers is also interesting, as it lets them receive notifications about various dynamic PnP events.
If you’re familiar with anti-cheats, you know that KeRegisterNmiCallback has long been used in their notorious NMI pipeline to discover unsigned code executing and more. It hasn’t been trivial for cheaters to bypass, and we can see that it fired a few times while I was logging:
| Rank | Function Name | Call Count | Percentage |
|---|---|---|---|
| 153 | ntoskrnl!HalSendNMI+0 | 200 | 0.00% |
They also naturally use lots of cryptographic functions from the CNG(Cryptography Next Generation) API:
| Rank | Function Name | Call Count | Percentage |
|---|---|---|---|
| 27 | cng!BCryptHashData+0 | 29882 | 0.46% |
| 28 | cng!BCryptOpenAlgorithmProvider+0 | 24732 | 0.38% |
| 29 | cng!BCryptGetProperty+0 | 24732 | 0.38% |
| 30 | cng!BCryptCreateHash+0 | 24730 | 0.38% |
| 31 | cng!BCryptCloseAlgorithmProvider+0 | 24730 | 0.38% |
| 32 | cng!BCryptDestroyHash+0 | 24728 | 0.38% |
| 33 | cng!BCryptFinishHash+0 | 24724 | 0.38% |
However, I didn’t see any BCryptEncrypt or BCryptDecrypt used, which suggests they encrypt/decrypt communication data differently.
Conclusion
There is a lot of interesting material in the logs. However, to keep this post from getting any longer, I’ll stop here because the point is clear. We did absolutely zero static analysis and completely ignored the EasyAntiCheat driver’s complex code virtualization and obfuscation techniques, yet we still obtained enough information to base our static analysis on. Keep in mind that the analysis shown here barely scratched the surface, as you could extend dynamic analysis to obtain much more information if you focus on a specific function.
I intend to develop revhv further, extending it to memory-access analysis as well. That will open up a whole other window, letting us see every memory read and write from the target. Until then, take care.
You’re welcome to contribute in any way, whether to correct anything you’ve seen in this write-up or in the actual development.