Kernel Anti-Cheat Evasion: Scrubbing, Stomping & IOCTL Hijacking
Background
Modern anti-cheats live in the kernel. To compete, so do the cheats. This post walks the toolchain that gets a cheat driver loaded, scrubs the trail behind it, and hides it inside a legitimate signed module.
This post isn’t about creating the bones of the kernel driver. For that I’ll defer to these resources from GuidedHacking:
You can also check out the driver that I used on my TBMKE write-up
The general idea is that we want a kernel driver to be able to:
- Locate the target process and its modules
- Read (and optionally write) memory inside them
- Hand that data to a user-mode component
Kernel-mode can do the memory work, but the user-facing parts, such as drawing an ESP box or managing settings, have to live in user mode. The standard kernel-user bridge is IOCTL, although more complex and stealthier methods exist, such as .DATA Section Hooking or using the Windows Registry.
Once we have our driver, we need to be able to load it. Windows doesn’t allow just any kernel driver to be loaded onto a system - every kernel driver has to be signed by Microsoft or a trusted authority before being loaded. This is enforced by Driver Signature Enforcement (DSE).
Test signing is usually off the table because most anti-cheats don’t allow the game to run if test signing is enabled.
This is where manual mapping comes into play.
Manual Mapping Your Driver
TLDR: Use kdmapper
kdmapper uses a Bring Your Own Vulnerable Driver (BYOVD) exploit by
abusing a signed Intel driver (iqvw64e.sys) to load your
unsigned driver into kernel memory, bypassing DSE.
You will need to disable HVCI (hypervisor-protected code integrity) and the Microsoft Vulnerable Driver Blocklist prior to using this tool.
How iqvw64e.sys Is Exploited
The dispatcher in iqvw64e.sys has no caller validation
on any of its IOCTL codes, so any process with a handle to
\Device\Nal (Intel’s device name) can call them.

IOCTL 0x80862007 routes into a sub-dispatcher
(NAL_MANAGE) that switches on a second tag pulled from the
input buffer. Sub-function 0x19 forwards a user-supplied
PHYSICAL_ADDRESS and length straight to the map-IO-space
wrapper.

NAL_MANAGE_MAPIOSPACE has no physical range whitelist,
no size limit, and no caller verification. It calls
MmMapIoSpace with no questions asked, allowing any
user-supplied address to be mapped into the kernel’s virtual address
space.

With arbitrary physical-memory mapping, kdmapper reads the kernel
image, resolves exports, allocates pool, copies the payload in, fixes up
imports and relocations, and calls its DriverEntry.
Scrubbing
Lots of vulnerable drivers exist, and most are signatured to death, but the idea is that we load these drivers before the anti-cheat, and clear any traces of them.
Some anti-cheats such as BattlEye and Easy Anti-Cheat are active only when the game is running, which means the cheater can load their driver before the anti-cheat does.
kdmapper removes the evidence that the vulnerable driver was loaded, by clearing the following lists:
- MmUnloadedDrivers
- PiDDBCacheTable
- g_KernelHashBucketList (
ci.dll) - WdFilter RuntimeDriversList / RuntimeDriversCount /
RuntimeDriversArray (
WdFilter.sys)
Without these scrubs, any anti-cheat walking these lists at game start finds the vulnerable driver immediately. Most ship a blacklist of known BYOVD vehicles by name.
For MmUnloadedDrivers specifically, the blacklist
comparison could be avoided by using random names:

Or deleting the entry:

However, both are still anomalous to other checks (random driver name, empty slot in the array).
kdmapper actually never allows the entry to be written. It locates
the vehicle driver’s KLDR_DATA_TABLE_ENTRY and zeros the
Length field of its BaseDllName. This means that when
MiRememberUnloadedDriver (the function responsible for
populating the MmUnloadedDrivers list) runs as part of the
unload path, it checks BaseDllName.Length > 0 before
appending, so a zero-length name causes the entry to be silently
skipped.
UNICODE_STRING us_driver_base_dll_name = { 0 };
ReadMemory(driver_section + 0x58, &us_driver_base_dll_name, sizeof(us_driver_base_dll_name));
us_driver_base_dll_name.Length = 0;
WriteMemory(driver_section + 0x58, &us_driver_base_dll_name, sizeof(us_driver_base_dll_name));
The result is an MmUnloadedDrivers list with no
anomalies.

Now the lists are clean and we evade any initial checks designed to identify vulnerable drivers previously loaded. However, we’re faced with a problem. Our driver is sitting in a suspicious, non-image-backed RWX region of kernel virtual address space.
Legitimate kernel modules live in virtual address ranges that map
back to a signed PE file on disk (and have a
PsLoadedModuleList entry to prove it). Unsurprisingly,
anti-cheat often scans these areas of memory to identify suspicious
code.
One solution to this problem is module stomping.
Stomping
Stomping into a legitimate, signed Microsoft driver gives our payload the cover it needs.
To do this, we first need SizeOfImage to know how many
bytes to copy and how many pages to write:
PIMAGE_NT_HEADERS selfNt = RtlImageNtHeader(selfBase);
ULONG imageSize = selfNt->OptionalHeader.SizeOfImage;
We then need to locate the victim by walking the
PsLoadedModuleList for its name. Good candidates are
legitimate, digitally signed by Microsoft, big enough for your payload,
and rarely used.
PLDR_DATA_TABLE_ENTRY victimLdr = FindModuleLdrEntry(ntBase, L"VictimDriver.sys");
PVOID victimBase = victimLdr->DllBase;
Capture the victim’s PE headers:
PIMAGE_NT_HEADERS origNt = RtlImageNtHeader(victimBase);
Stage the payload in a pool buffer, so we can patch the headers in place before committing the bytes to the victim’s pages:
PVOID stage = ExAllocatePoolWithTag(NonPagedPool, imageSize, 'gtsP');
RtlCopyMemory(stage, selfBase, imageSize);
Walk the payload’s .reloc directory and patch every
absolute address by the delta.
LONGLONG delta = (LONGLONG)victimBase - (LONGLONG)selfBase;
ApplyBaseRelocations(stage, delta);
Then, we’ll make the staged image’s PE metadata match what the victim has on disk.
PIMAGE_NT_HEADERS stNt = RtlImageNtHeader(stage);
stNt->FileHeader.TimeDateStamp = origNt->FileHeader.TimeDateStamp;
stNt->OptionalHeader.ImageBase = origNt->OptionalHeader.ImageBase;
stNt->OptionalHeader.SizeOfImage = origNt->OptionalHeader.SizeOfImage;
stNt->OptionalHeader.CheckSum = origNt->OptionalHeader.CheckSum;
OverlaySectionTable(stNt, origNt);
Before the overwrite, the victim has to be silenced. Anything that
could fire into its .text mid-write such as
MajorFunction slots, registered DPCs and timer callbacks
need to be neutralised first. The exact sequence depends on the victim,
but at minimum we need to null out the dispatch slots that won’t be
reused, cancel any queued DPCs targeting the victim’s image, and don’t
proceed until in-flight IRPs (I/O Request Packets) have drained.
We can’t just memcpy(victimBase, stage, imageSize) the
victim’s pages as they are read only, so this goes via a physical-memory
mapping, page by page.
for (SIZE_T off = 0; off < imageSize; off += PAGE_SIZE)
WritePhysPage((PUCHAR)victimBase + off, (PUCHAR)stage + off, PAGE_SIZE);
ExFreePoolWithTag(stage, 'gtsP');
return victimBase;
There’s additional post-stomp work required: PE-section protection mismatches, exception-directory collisions, leftover linker thunks. This has to be cleaned up before the new bytes are safe to execute. Additionally, the original kdmapper-allocated pool region has to be wiped.
The result. Our payload:

is now executing under a legitimate, signed Microsoft driver:

We’re not quite out of the woods yet. Depending on the module picked and the anti-cheat in play, an in-memory vs on-disk comparison would flag this as anomalous.
With that, we’ve solved the problem of moving our payload into an image-backed memory space. Now we need a way to talk to it.
IOCTL Hijacking
The standard answer is to register a device. Call
IoCreateDriver, create a \Device\Cheat, wire
up IRP_MJ_DEVICE_CONTROL, point it at your handler.
However, all of these things are easily enumerable by an anti-cheat and
increase your fingerprint.
One alternative is to reuse a dispatch path that already exists.
Every kernel driver has a MajorFunction table inside its
DRIVER_OBJECT, one slot per IRP type.

Slot 14 (IRP_MJ_DEVICE_CONTROL) is the IOCTL handler.
When user-mode calls DeviceIoControl, the kernel follows
that slot’s pointer and jumps. If we can put our own address in there
without anti-cheat noticing, we own the IOCTL path.
Hijacking The CFG Dispatch Chokepoint
Every kernel module built with Control Flow Guard (CFG) comes with a tiny helper the linker drops in:


_guard_xfg_dispatch_icall_nop is a six-byte chunk of
.text that does one thing - jump through a qword.
__guard_dispatch_icall_fptr is that qword, sitting in a
writable data section. The kernel loader sets it at boot:
- CFG not enforcing: points to
_guard_dispatch_icall_nop(a stub that just does the jump) - CFG enforcing: points to ntoskrnl’s validation routine
These symbols exist in every CFG-built driver, but in a lot of them they’re dead boilerplate, no code actually calls through them. So we install a dispatch path that does.
Two writes:
Stage 1: Redirect dispatch into the chokepoint
host->MajorFunction[IRP_MJ_DEVICE_CONTROL] = (PDRIVER_DISPATCH)(hostBase + 0x1234); // Offset to _guard_xfg_dispatch_icall_nop
Stage 2: Stomp the chokepoint’s fptr to land in our handler
PVOID* slot = (PVOID*)(hostBase + 0x5678); // Offset to __guard_dispatch_icall_fptr
PVOID saved = *slot;
InterlockedExchangePointer(slot, (PVOID)MyHandler);
MyHandler lives inside VictimDriver
(stomped earlier). When anti-cheat verifies that
MajorFunction[14] points inside the host’s image, it
passes. The chokepoint’s bytes match disk. The only differences from a
clean boot are one pool pointer (the MajorFunction slot)
and one qword in a writable data section.
![Diagram of the two-stage CFG chokepoint hijack inside a Microsoft-signed VictimDriver: MajorFunction[14] points at _guard_xfg_dispatch_icall_nop at 0x1234, which jumps through __guard_dispatch_icall_fptr at 0x5678, now stomped from 0xABCDE to 0xEDCAB to land in MyHandler in the cheat payload](/assets/img/kernel-anti-cheat-cfg-chokepoint-hijack.png)
The classic alternative is a trampoline hook, where we overwrite the
first five bytes of the target with E9 XX XX XX XX (near
jmp + 32-bit relative offset). The hook redirects every
call into our handler. The problem is those five bytes live in
.text, part of the image’s on-disk hash. Anti-cheat running
image-vs-disk catches this.
Our approach leaves .text byte-identical to disk, as we
never write to this section.
Now, user-mode can reach the handler through the host’s existing
device. Open \Device\X, fire an IOCTL, the dispatch lands
in MyHandler via the two-stage hijack.
Wrapping Up
The techniques listed in this blog aren’t bulletproof. A determined
anti-cheat can still compare the victim’s .text against
disk, walk the dispatch chain to confirm where it lands, or audit
__guard_dispatch_icall_fptr against known-good values. The
bar to detect is meaningfully higher than “your driver is in
PsLoadedModuleList”, but it’s far from zero.
I’ve had a lot of fun researching and creating content related to kernel level anti-cheat. If you’ve made it this far and want to learn more I’d highly recommend signing up to GuidedHacking. They have a bunch of talented content creators and informative/educational posts and courses.