← ret / Kernel Anti-Cheat Evasion
← ret

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:

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

CVE-2015-2291

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.

Disassembly of iqvw64e.sys IOCTL dispatcher showing no caller validation on any IOCTL codes

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 sub-function forwarding a user-supplied physical address straight to MmMapIoSpace without validation

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.

Flow diagram showing kdmapper in user mode sending an IOCTL via DeviceIoControl to iqvw64e.sys, which calls MmMapIoSpace to drop unsigned .sys bytes into kernel memory and reach DriverEntry

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:

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:

MmUnloadedDrivers list populated with randomly generated driver names to evade blacklist matching

Or deleting the entry:

MmUnloadedDrivers with the vehicle driver entry zeroed out, leaving an anomalous empty slot in the array

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.

Clean MmUnloadedDrivers list with no entry for the vehicle driver after BaseDllName.Length was zeroed before unload

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:

Cheat payload running in a non-image-backed kernel pool allocation before module stomping is applied

is now executing under a legitimate, signed Microsoft driver:

Same payload now executing inside a legitimate, signed Microsoft driver after the stomp

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.

DRIVER_OBJECT MajorFunction table showing one dispatch slot per IRP type, including slot 14 for IRP_MJ_DEVICE_CONTROL

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:

Six-byte _guard_xfg_dispatch_icall_nop helper in .text that jumps through __guard_dispatch_icall_fptr

__guard_dispatch_icall_fptr in a writable data section, the qword the CFG dispatch helper jumps through

_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:

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

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.