Obtaining A Protected Process’s Directory Table Base Using IPIs
Kernel-level anti-cheats — for example Easy Anti-Cheat — implement defenses intended to prevent attackers from trivially reading or writing a protected process’s memory from a kernel context. Yet many adversaries operate at the kernel level too and will attempt physical-memory reads/writes to bypass normal protections. It’s therefore critical for anti-cheat authors to understand how physical memory access functions and attempt to put in place mechanisms to thwart attempts to read/write to the backing physical memory of a protected process.
In this article I describe the mechanics of physical memory reads/writes, outline Easy Anti-Cheat’s defensive approach, and discuss a mechanism that can bypass those protections.
Key Terms
EPROCESS – Windows’ per-process executive structure containing metadata about each process.
IPI (Inter-Processor Interrupt) – A mechanism allowing one logical processor to interrupt another, requesting it to run an interrupt or callback routine.
Context Switch (A.K.A Context Swap) – A scheduler operation that moves CPU execution from one thread to another. When switching to a thread executing under a different context, the kernel updates registers — including CR3 — so the CPU executes in the new thread’s address space.
Virtual Memory vs Physical Memory
Modern operating systems use virtual memory to give each process the illusion of a private, contiguous address space, even though the underlying physical memory (RAM) may be fragmented or shared among processes. Virtual memory allows:
- Isolation: Each process sees its own address space, preventing accidental or malicious interference.
- Flexibility: Processes can use more memory than is physically installed via paging to disk.
- Security: Access controls and permissions can be enforced at the page level.
Physical memory refers to the actual RAM modules installed in the system — the hardware where data is ultimately stored and retrieved.
The CPU itself never works directly with a virtual address. Instead, before any instruction can access memory, the processor must translate that virtual address into a physical address.
The Translation Process
The CPU uses page tables to map virtual addresses to physical addresses:
- Page Table Base (CR3/DTB): Each process has a Directory Table Base that points to the top-level page table.
- Multi-level lookup: The CPU walks a hierarchy of page tables (PML4 → PDPT → PD → PT on x64) to resolve the virtual address.
- Physical access: Once the translation completes, the CPU accesses the corresponding physical memory location.
As seen below, a virtual address can have it’s bits split up to clearly indicate the indices in the page tables it should correspond to.

Note: Caching – CPUs use a Translation Lookaside Buffer (TLB) to cache recent translations, speeding up virtual-to-physical lookups. When CR3 changes (switching processes), the TLB is flushed to ensure the new process sees its own address space.
In order for an attacker to perform physical reads/writes to/from a protected process, they must be able to locate the Directory Table Base of the process and walk the page tables themselves.
Example flow of physical memory reading
To access a process’s virtual memory by way of physical memory you typically follow the below steps:
- Obtain the process’s Directory Table Base (DTB / CR3).
- Translate the target virtual address into a physical address by walking the page table layers, starting from the obtained DTB.
- Perform the physical read or write using mappings or MmCopyMemory.
Consider the below kernel-level pseudocode (Written outside of editor – may contain typos or omit critical functions):
uintptr_t TranslateVirtAddress(uintptr_t dtb, uintptr_t virt){
/*Consult and walk the page tables using physical reads to obtain the backing physical PFN of the specified virtual address then add the PAGE4KB offset and return the computed value*/
}
/// <summary>
/// Get the directory table base by eprocess offset.
/// </summary>
/// <param name="proc"></param>
/// <returns></returns>
uintpr_t GetProcDirectoryTableBase(PEPROCESS proc){
return *((uintptr_t*)(proc + 0x28 /*_KPROCESS -> DirectoryTablebase (Win11 24H2)*/);
}
void Example(){
//Get the target process.
PEPROCESS targetProc = FindProcByName("Protected.exe");
if(!targetProc){
print("Unable to locate target process");
return;
}
//Get the target process's directory table base.
uintptr_t dtb = GetProcDirectoryTableBase(targetProc);
if(!dtb){
print("Unable to locate target process's directory table base");
return;
}
//Print it.
print("Target process 0x%llx directory base located => 0x%llx", targetProc, dtb);
//Get the process's base address
uintptr_t baseAddr = PsGetProcessSectionbaseAddress(targetProc);
if(!baseAddr){
print("Unable to locate process's base address!");
return;
}
//Translate it to physical memory.
uintptr_t physAddr = TranslateVirtAddress(dtb, baseAddr);
if(!physAddr){
print("Unable to translate base address to physical address!");
return;
}
//Buffer to hold the contents.
uintptr_t pool = ExAllocatePoolWithTag(NonPagedPoolNx, 0x1000, 'p');
if(!pool){
print("Unable to allocate a pool for reading conents at base address!");
return;
}
//Copy the memory.
SIZE_T bytesRead;
NTSTATUS resCopy = MmCopyMemory((PVOID)pool, (PVOID)physAddr, 0x1000, MM_COPY_MEMORY_PHYSICAL, &bytesRead);
if(resCopy != STATUS_SUCCESS)
print("Unable to copy physical page contents!");
/*Print or analyze the page contents.*/
//Free the conents.
ExFreePoolWithTag((PVOID)pool, 'p');
}How does EAC protect against KM physical reads/writes?
As shown with the previous code, a critical step to performing physical reads/writes is to obtain a process’s directory table base. EAC knows this and has crafted a system to obscure the true value.
Inside the EPROCESS structure of an EAC protected process, you’ll find a directory table base that’s quite visibly different than that of other processes. Specifically, the 63rd bit in the value is set. Whenever the system performs a context swap, an exception will be thrown due to the 63rd being set (since that bit is reserved).
Given that nt!_SwapContext is patchguard protected, EAC crafts and leverages hooking Hal pointers to achieve the necessary execution during SwapContext.
When a context swap occurs to a thread executing under or attached to the protected process, an exception will be thrown and caught by their Hal hooks due to the invalid 63rd bit. Inside the hook they check if the thread is whitelisted (like it is for game threads). If it’s whitelisted, it decrypts and writes in the real directory table base to the CPU directly and resumes execution. If the thread isn’t whitelisted, it internally records data about the attempt and selectively throttles access. It’s assumed that if you make too many attempts to attach to the game’s process, it will raise and submit reports to the server. Furthermore, they may perform a quick stack walk to detect illegal memory regions containing executable code.
It’s worth noting that MmCopyVIrtualMemory internally attaches to the target process for each call so you’ll automatically trigger this detection by using virtual reads/writes.
Alternatives to attaching to the protected process
Now that you know attaching to the process is going to execute an EAC hook, you should avoid it at all costs. However, you still need to get the real directory table base in order to read/write the backing physical memory of the target process.
We for sure know that a valid directory table base must exist somewhere in the system as it’s required for the game threads to run.
There are many possible solutions to find this value, you can:
- Scan all physical memory ranges and see if each physical page can translate a known virtual address in the game when used as a dtb, such as the process’s base address. (Most common approach)
- Fire an NMI to asynchronously read the dtb of all executing processes until you find the true value for the target – as I introduced on Unknowncheats.
- Fire an IPI to synchronously check for the value (not discussed previously).
Using IPIs to obtain the true directory table base
Using an IPI, we can force the system to read the cr3 register across all cores and report back to our handler routine.
Consider the below code:
#pragma region Windows Includes
#include <ntifs.h>
#include <ntddk.h>
#include <windef.h>
#include <ntimage.h>
#include <ntdef.h>
#include <stdio.h>
#include <stdarg.h>
#include <wdm.h>
#include <pmmintrin.h>
#include <intrin.h>
#include <math.h>
#pragma endregion
#pragma region Typedefs
typedef unsigned char uint8_t;
typedef unsigned int uint32_t;
#pragma endregion
#define print(fmt, ...) DbgPrintEx(0, 0, fmt, __VA_ARGS__)
/// <summary>
/// Sleep a specified number of miliseconds.
/// </summary>
/// <param name="milliseconds"></param>
/// <returns></returns>
VOID Sleep(LONG milliseconds)
{
LARGE_INTEGER interval;
interval.QuadPart = -(10 * 1000 * milliseconds);
KeDelayExecutionThread(KernelMode, FALSE, &interval);
}
/// <summary>
/// Compare two strings without regards to case sensitivity
/// </summary>
/// <param name="str1"></param>
/// <param name="str2"></param>
/// <returns></returns>
int StrCmpI(const char* str1, const char* str2) {
while (*str1 && *str2) {
char ch1 = tolower(*str1);
char ch2 = tolower(*str2);
if (ch1 != ch2) {
return (ch1 < ch2) ? -1 : 1;
}
str1++;
str2++;
}
// If both strings are equal so far, check their lengths
if (*str1 == *str2) {
return 0; // Strings are equal
}
else if (*str1) {
return 1; // str1 is longer
}
else {
return -1; // str2 is longer
}
}
/// <summary>
/// Loop active process links to locate EPROCESS without increasing the reference count in the process.
/// </summary>
/// <param name="processName"></param>
/// <param name="outProc"></param>
/// <param name="outProcId"></param>
/// <returns></returns>
NTSTATUS FindProcByName(char* processName, PEPROCESS* outProc, PULONG outProcId) {
//By default, out values are null.
*outProc = NULL;
*outProcId = NULL;
//Get the current process.
PEPROCESS currProc = IoGetCurrentProcess();
if (!currProc)
return STATUS_NOT_FOUND;
//Eprocess offsets (hardcoded for testing purposes - win 11 24H2)
uintptr_t offsetImageName = 0x338;
uintptr_t offsetActiveThreads = 0x380;
uintptr_t offsetActiveProcessLinks = 0x1D8;
//Get the address of the list head.
uintptr_t listHead = *(uintptr_t*)((uintptr_t)currProc + offsetActiveProcessLinks);
//Instantiate a iterator which by default is the head.
uintptr_t listIterator = listHead;
//Counter to check for overrun.
uint32_t counter = 0;
//Iterate the linked list.
do {
/*
* Get the process for this entry. Do so by subtracting the offset
* of active process links from the current list entry address.
*/
PEPROCESS indexedProcess = (PEPROCESS)(listIterator - offsetActiveProcessLinks);
if (!indexedProcess)
goto _NEXT;
//Get pointer to name of this indexed process.
char* indexedName = (char*)((uintptr_t)indexedProcess + offsetImageName);
if (!indexedName)
goto _NEXT;
//if the indexed process's name is the name we're searching for...
if (!StrCmpI(processName, indexedName)) {
//Get the number of active threads.
ULONG activeThreads = *(ULONG*)((uintptr_t)indexedProcess + offsetActiveThreads);
//If the process has active threads and a matching name, we've found our process.
if (activeThreads) {
//Some threads are active, return our found entry.
*outProc = indexedProcess;
*outProcId = (ULONG)PsGetProcessId(indexedProcess);
//Entry found, no need to continue iterating.
break;
}
}
_NEXT:
listIterator = *(uintptr_t*)listIterator + 0 /*Offset of flink*/;
counter++;
} while (!(listIterator == listHead || counter > 1000));
//Return result for iteration.
return *outProc && *outProcId ? STATUS_SUCCESS : STATUS_NOT_FOUND;
}
/// <summary>
/// Result of an an IPI.
/// </summary>
typedef struct _IPI_RESULT {
/// <summary>
/// Process found.
/// </summary>
PEPROCESS proc;
/// <summary>
/// Resulting dtb.
/// </summary>
uintptr_t dtb;
} IPI_RESULT, * PIPI_RESULT;
/// <summary>
/// Array to hold ipi results.
/// </summary>
IPI_RESULT IpiResults[32] = { NULL };
/// <summary>
/// Routine to be ran by the IPI.
/// </summary>
/// <param name="ctx"></param>
/// <returns></returns>
ULONG_PTR IpiRoutine(ULONG_PTR ctx) {
//Cast the context to an ipiResult array.
PIPI_RESULT resultArr = (PIPI_RESULT)ctx;
//Query the current processor number.
PROCESSOR_NUMBER procNumber;
KeGetCurrentProcessorNumberEx(&procNumber);
//Set the data.
resultArr[procNumber.Number].proc = PsGetCurrentProcess();
resultArr[procNumber.Number].dtb = __readcr3();
//Success.
return NULL;
}
/// <summary>
/// Get a process's directory table base using IPIs.
/// </summary>
/// <param name="proc"></param>
/// <returns></returns>
ULONG_PTR GetProcessDtbUsingIPIs(PEPROCESS proc) {
//Query the number of processors.
USHORT processorCount = KeQueryActiveProcessorCountEx(ALL_PROCESSOR_GROUPS);
//We only hardcoded enough space for 32 results (32 cores). Cap it out.
if (processorCount > 32)
processorCount = 32;
//Fire IPIs until we find the desired process.
while (TRUE) {
//Zero initialize the ipi results.
memset(&IpiResults, 0, sizeof(IPI_RESULT) * processorCount);
//Fire the IPI.
KeIpiGenericCall(&IpiRoutine, (ULONG_PTR)&IpiResults);
//Loop each core.
for (USHORT x = 0; x < processorCount; x++) {
//Get the IPI result.
IPI_RESULT res = IpiResults[x];
//Did the IPI execute while the target process was executing on that core?
if (res.proc != proc)
continue;
//The target process was executing, validate it's DTB by checking for invalid bits EAC sets. (Most significant 8 bits: 0100 0000)
if ((res.dtb >> 0x38) == 0x40)
continue;
//This is a valid result.
return res.dtb;
}
//Wasn't successful, wait a bit before trying again.
Sleep(300);
}
}
/// <summary>
/// Driver entry.
/// </summary>
/// <returns></returns>
NTSTATUS DriverEntry() {
//Find the target process.
PEPROCESS targetProc = NULL;
ULONG targetProcId = NULL;
NTSTATUS statusFindProcess = FindProcByName("Protected.exe", &targetProc, &targetProcId);
if (statusFindProcess != STATUS_SUCCESS) {
print("Could not find target process!");
return statusFindProcess;
}
//Get it's directory table base.
print("Found target process's dtb => 0x%llx", GetProcessDtbUsingIPIs(targetProc));
//Done processing.
print("Driver entry completed.");
return STATUS_SUCCESS;
}
Acknowledgements regarding this method
- As with executing any unsigned instructions, you’re susceptible to being hit with one of EAC’s NMIs which can even occur as your IPI is executing. If this happens as your IPI handler is executing and the IPI handler resides in illegal memory (not backed by a signed kernel driver), the instruction pointer will be deemed illegal as well as possibly return addresses in your thread’s callstack.
- EAC may directly monitor for IPIs as they now do for NMIs (After I released the method on Uknowncheats).
Conclusion
In conclusion, we can deduce that although EAC’s SwapContext hook has raised the bar slightly to attackers, it’s completely ineffective for skilled attackers who will leverage physical memory scanning mechanisms or some type of interrupt/hook themselves to get the valid value that must be present for the game to execute.
The question they should pose to themselves is “I know that a high percentage of attackers are leveraging mechanisms such as these to obtain the directory table base. Given that I as the anti-cheat have complete control over context swaps, what I can do to honeypot or monitor such attempts?”
Disclaimer
Portions of this article were written with the assistance of AI tools. All prompt instructions and pseudocode snippets are original. All content thoroughly reviewed and modified as necessary for accuracy.