Chapter 42

Driver Basics

WDM vs WDF driver models, DriverEntry, device objects, IRP dispatch tables, IOCTL communication, and detecting malicious driver loading

Scenario

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

ModelFull NameAbstraction LevelUse 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 CodeValueTriggerSecurity 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;
}
BYOVD attack primitive

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 SourceEvent IDMeaning
System / Service Control Manager7045New service installed (includes driver services) — critical detection signal
System / Service Control Manager7036Service started/stopped
Security4697Service installed on system (similar to 7045 but in Security log)
Microsoft-Windows-Kernel-PnP/Configuration400/410Driver loaded for a device
Microsoft-Windows-CodeIntegrity/Operational3077Driver blocked by code integrity (unsigned or revoked)
Microsoft-Windows-CodeIntegrity/Operational3033Driver 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.