Driver Basics
WDM vs WDF driver models, DriverEntry, device objects, IRP dispatch tables, IOCTL communication, and detecting malicious driver loading
An IR analyst discovers an unfamiliar .sys file loaded in a compromised machine's kernel. The file is signed with a revoked certificate but loaded before Windows 10's driver revocation check updated. Understanding how drivers register their dispatch routines, handle IRPs, and expose IOCTLs is essential for reverse-engineering what this driver actually does once loaded — and for understanding why the BYOVD (Bring Your Own Vulnerable Driver) attack class is so powerful: a legitimate driver that exposes dangerous IOCTLs becomes a user-mode-accessible ring-0 primitive.
WDM vs WDF
| Model | Full Name | Abstraction Level | Use Case |
|---|---|---|---|
| WDM | Windows Driver Model | Low — manual IRP handling, device stacks, power management, PnP all explicit | Legacy drivers, precise control, security tools that need deep hooks |
| WDF (KMDF) | Windows Driver Foundation (Kernel) | High — framework manages IRP lifecycle, power state machine, queue management | Modern hardware drivers; Microsoft-recommended for new drivers |
| WDF (UMDF) | Windows Driver Foundation (User) | High — runs in Ring 3, isolated from kernel; crashes don't BSOD | Less-privileged drivers: USB, HID, printers where ring 0 is unnecessary |
Rootkits and security drivers nearly always use WDM, because WDF abstractions hide the IRP details needed to intercept OS behavior. This chapter focuses on WDM since it is what you will encounter in both offensive driver code and most EDR kernel components.
DriverEntry — The Driver's Main()
DriverEntry is called by the I/O manager immediately after the driver is mapped into the kernel. It must: create a device object so applications can open a handle to the driver, set up a symbolic link so user mode can find the device, and populate the DriverObject->MajorFunction dispatch table with function pointers for each IRP type it handles.
// Minimal WDM driver skeleton
#include <ntddk.h>
#define DEVICE_NAME L"\Device\MyDriver"
#define SYMLINK_NAME L"\DosDevices\MyDriver"
VOID DriverUnload(PDRIVER_OBJECT DriverObject) {
UNICODE_STRING symLink = RTL_CONSTANT_STRING(SYMLINK_NAME);
IoDeleteSymbolicLink(&symLink);
IoDeleteDevice(DriverObject->DeviceObject);
}
NTSTATUS DispatchCreateClose(PDEVICE_OBJECT DevObj, PIRP Irp) {
Irp->IoStatus.Status = STATUS_SUCCESS;
Irp->IoStatus.Information = 0;
IoCompleteRequest(Irp, IO_NO_INCREMENT);
return STATUS_SUCCESS;
}
NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject,
PUNICODE_STRING RegistryPath) {
NTSTATUS status;
UNICODE_STRING devName = RTL_CONSTANT_STRING(DEVICE_NAME);
UNICODE_STRING symLink = RTL_CONSTANT_STRING(SYMLINK_NAME);
PDEVICE_OBJECT devObj;
// 1. Create device object (kernel-visible)
status = IoCreateDevice(DriverObject, 0, &devName,
FILE_DEVICE_UNKNOWN,
FILE_DEVICE_SECURE_OPEN,
FALSE, &devObj);
if (!NT_SUCCESS(status)) return status;
// 2. Create symbolic link (user-mode visible: \\.\MyDriver)
status = IoCreateSymbolicLink(&symLink, &devName);
if (!NT_SUCCESS(status)) {
IoDeleteDevice(devObj);
return status;
}
// 3. Populate dispatch table
DriverObject->DriverUnload = DriverUnload;
DriverObject->MajorFunction[IRP_MJ_CREATE] = DispatchCreateClose;
DriverObject->MajorFunction[IRP_MJ_CLOSE] = DispatchCreateClose;
DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = DispatchIoctl;
// 4. Use buffered I/O (copies data through system buffer)
devObj->Flags |= DO_BUFFERED_IO;
devObj->Flags &= ~DO_DEVICE_INITIALIZING;
return STATUS_SUCCESS;
}
IRP Dispatch Table
The MajorFunction array has 28 slots. Each slot is an IRP major function code. The key major codes:
| IRP_MJ Code | Value | Trigger | Security Relevance |
|---|---|---|---|
| IRP_MJ_CREATE | 0x00 | CreateFile() from user mode | Access control — who can open a handle to the driver |
| IRP_MJ_CLOSE | 0x02 | CloseHandle() on driver handle | Resource cleanup |
| IRP_MJ_READ | 0x03 | ReadFile() | Exposed by drivers that stream data (e.g., a keylogger) |
| IRP_MJ_WRITE | 0x04 | WriteFile() | Exposed by drivers that accept data from user mode |
| IRP_MJ_DEVICE_CONTROL | 0x0E | DeviceIoControl() | Main command channel; IOCTL codes select operation. Most BYOVD abuse goes through here. |
| IRP_MJ_INTERNAL_DEVICE_CONTROL | 0x0F | IoBuildDeviceIoControlRequest() (kernel callers only) | Kernel-to-kernel IRP; not accessible from user mode directly |
| IRP_MJ_SYSTEM_CONTROL | 0x17 | WMI queries | Some rootkits intercept WMI to hide artifacts |
IOCTL Interface — User-Mode to Kernel Channel
DeviceIoControl from user mode generates an IRP_MJ_DEVICE_CONTROL IRP. The IOCTL code is a 32-bit value encoding the operation, device type, access requirements, and transfer method. The driver's DispatchIoctl function receives the IRP and dispatches on the code:
// IOCTL code encoding
// CTL_CODE(DeviceType, Function, Method, Access)
#define IOCTL_READ_PHYSMEM CTL_CODE(FILE_DEVICE_UNKNOWN, 0x801, \
METHOD_BUFFERED, FILE_READ_DATA)
#define IOCTL_WRITE_PHYSMEM CTL_CODE(FILE_DEVICE_UNKNOWN, 0x802, \
METHOD_BUFFERED, FILE_WRITE_DATA)
#define IOCTL_MAP_KERNEL_MEM CTL_CODE(FILE_DEVICE_UNKNOWN, 0x803, \
METHOD_OUT_DIRECT, FILE_ANY_ACCESS)
NTSTATUS DispatchIoctl(PDEVICE_OBJECT DevObj, PIRP Irp) {
PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);
ULONG code = stack->Parameters.DeviceIoControl.IoControlCode;
ULONG inLen = stack->Parameters.DeviceIoControl.InputBufferLength;
ULONG outLen = stack->Parameters.DeviceIoControl.OutputBufferLength;
PVOID buf = Irp->AssociatedIrp.SystemBuffer; // METHOD_BUFFERED
NTSTATUS status = STATUS_INVALID_DEVICE_REQUEST;
switch (code) {
case IOCTL_READ_PHYSMEM:
status = HandleReadPhysMem(buf, inLen, outLen,
&Irp->IoStatus.Information);
break;
case IOCTL_WRITE_PHYSMEM:
status = HandleWritePhysMem(buf, inLen);
break;
default:
break;
}
Irp->IoStatus.Status = status;
IoCompleteRequest(Irp, IO_NO_INCREMENT);
return status;
}
The BYOVD (Bring Your Own Vulnerable Driver) technique exploits drivers that expose dangerous IOCTLs like physical memory read/write, arbitrary kernel virtual address read/write, or MmMapIoSpace. An attacker with admin rights loads the legitimate signed driver, opens a handle to its device, sends crafted IOCTLs to achieve arbitrary kernel read/write, and then uses that primitive to disable EDR callbacks or overwrite tokens. Well-known vulnerable drivers: rtcore64.sys (MSI Afterburner), gdrv.sys (GIGABYTE), dbutil_2_3.sys (Dell). Microsoft's Vulnerable Driver Blocklist attempts to revoke these but is frequently bypassed with older driver versions.
Driver Loading Mechanics
Driver Loading Path:
User Mode (admin required):
CreateService(SC_MANAGER, ..., SERVICE_KERNEL_DRIVER)
StartService() → calls SCM
Service Control Manager (services.exe):
Reads HKLM\SYSTEM\CurrentControlSet\Services\[name]
ImagePath: path to .sys file
Type: 0x1 = SERVICE_KERNEL_DRIVER
Start: 0=Boot, 1=System, 2=Auto, 3=Manual, 4=Disabled
I/O Manager (ntoskrnl):
NtLoadDriver() syscall
Maps .sys PE into kernel virtual address space (SEC_IMAGE)
Applies kernel ASLR (KASLR)
Relocates based on kernel image base
Calls DriverEntry()
Windows Driver Signing enforcement:
On x64: all drivers must have valid WHQL or Authenticode signature
Test signing mode: bcdedit /set testsigning on (only if SecureBoot disabled)
HVCI: additionally requires signature from verified CA chain
Alternative loading (malicious):
NtLoadDriver() directly (no SCM)
Exploiting kernel from user mode to map arbitrary code
Using BYOVD to achieve ring-0 primitives without loading new driver
The registry path for a driver's configuration is:
HKLM\SYSTEM\CurrentControlSet\Services\[DriverName]
ImagePath = \??\C:\path\to\driver.sys
Type = 1 ; SERVICE_KERNEL_DRIVER
Start = 3 ; SERVICE_DEMAND_START (manual)
ErrorControl = 1 ; SERVICE_ERROR_NORMAL
ObjectName = LocalSystem
DisplayName = Driver Display Name
Detection Signals
Event Log Signals
| Event Source | Event ID | Meaning |
|---|---|---|
| System / Service Control Manager | 7045 | New service installed (includes driver services) — critical detection signal |
| System / Service Control Manager | 7036 | Service started/stopped |
| Security | 4697 | Service installed on system (similar to 7045 but in Security log) |
| Microsoft-Windows-Kernel-PnP/Configuration | 400/410 | Driver loaded for a device |
| Microsoft-Windows-CodeIntegrity/Operational | 3077 | Driver blocked by code integrity (unsigned or revoked) |
| Microsoft-Windows-CodeIntegrity/Operational | 3033 | Driver with revoked certificate attempted to load |
Python: Detect Unsigned or Anomalous Drivers
import subprocess, json, re
def list_loaded_drivers():
"""Use sc query type= driver or wmic path Win32_SystemDriver"""
result = subprocess.run(
['powershell', '-Command',
'Get-WmiObject Win32_SystemDriver | Select-Object Name,PathName,State,Started | ConvertTo-Json'],
capture_output=True, text=True
)
return json.loads(result.stdout)
def check_driver_signature(path):
result = subprocess.run(
['powershell', '-Command',
f'Get-AuthenticodeSignature "{path}" | Select-Object Status,SignerCertificate | ConvertTo-Json'],
capture_output=True, text=True
)
try:
sig = json.loads(result.stdout)
return sig.get('Status'), sig.get('SignerCertificate')
except:
return 'Unknown', None
def audit_drivers():
drivers = list_loaded_drivers()
if not isinstance(drivers, list):
drivers = [drivers]
for drv in drivers:
path = drv.get('PathName', '')
if not path:
continue
status, cert = check_driver_signature(path)
anomalies = []
if status != 'Valid':
anomalies.append(f'sig={status}')
if path and not path.lower().startswith(r'\systemroot') \
and not path.lower().startswith(r'c:\windows'):
anomalies.append('non-system-path')
if anomalies:
print(f"[!] {drv['Name']:30s} {path}")
print(f" anomalies: {', '.join(anomalies)}")
audit_drivers()
Q & A
What is the difference between METHOD_BUFFERED, METHOD_IN_DIRECT, METHOD_OUT_DIRECT, and METHOD_NEITHER for IOCTL transfer?
The transfer method specifies how the I/O manager handles the input and output buffers between user mode and the kernel for a DeviceIoControl call. This affects what pointer the driver receives in the IRP and how safe the buffer access is:
- METHOD_BUFFERED (0): The I/O manager allocates a kernel-side buffer (Irp->AssociatedIrp.SystemBuffer) and copies the user input buffer into it before calling the driver. The driver writes its output to the same buffer, and the I/O manager copies it back to the user output buffer after completion. Safest — no risk of user buffer modifications during IRP processing. Used for small data exchange.
- METHOD_IN_DIRECT (1): Input uses SystemBuffer (copied). Output uses an MDL (Irp->MdlAddress) locking the user output buffer in memory, giving the driver direct access. Zero-copy for output. Used when the driver needs to write directly to user memory (large output).
- METHOD_OUT_DIRECT (2): Same as IN_DIRECT but the MDL is for reading — the driver reads from the locked user buffer without copy. Used for large input data streaming to the driver.
- METHOD_NEITHER (3): No buffering or MDL. The driver receives raw user-mode pointers (Type3InputBuffer for input, Irp->UserBuffer for output). Driver is responsible for probing and locking these addresses (ProbeForRead/ProbeForWrite + try/except). Dangerous if not handled carefully — many kernel vulnerabilities come from METHOD_NEITHER IOCTLs that fail to validate the user pointer. From an attacker's perspective, METHOD_NEITHER IOCTLs in a vulnerable driver are the most interesting because they may accept arbitrary kernel addresses as input or output pointers.
How does a BYOVD attack use a vulnerable driver's IOCTL to disable an EDR?
The steps are: (1) The attacker (with admin rights) loads a legitimate but exploitable signed driver — for example rtcore64.sys from MSI Afterburner, which exposes IOCTLs for reading and writing arbitrary physical memory or kernel virtual addresses (it was intended for overclocking tools). (2) The attacker opens a handle to the driver's device: CreateFile("\\\\.\\RTCore64", ...). (3) Using the read IOCTL, the attacker locates the EDR's DRIVER_OBJECT in kernel memory. One way: enumerate drivers via the PsLoadedModuleList (readable via a kernel read primitive) to find the EDR driver's base address, then parse its DRIVER_OBJECT to find the IRP_MJ_CREATE dispatch pointer. (4) Using the write IOCTL, the attacker overwrites callback registrations that the EDR kernel component set up: for instance, the PsSetCreateProcessNotifyRoutineEx table, ObRegisterCallbacks slots, or the EDR's IRP_MJ dispatch pointers. (5) With the EDR's kernel-mode detection neutered, the attacker proceeds with post-exploitation (credential dumping, lateral movement) without kernel-mode detection triggering. Detection: 7045 for a new driver being installed (the vulnerable driver), Code Integrity events if the driver is blocklisted, and anomaly detection on known-BYOVD driver hashes. The Vulnerable Driver Blocklist maintained by Microsoft and available in the Windows Defender Application Control (WDAC) policy can prevent loading known-bad drivers by hash even if the Authenticode certificate is still valid.