When triaging a third-party kernel driver for privilege escalation, the first real question is always the same: what does its IRP_MJ_DEVICE_CONTROL handler actually expose? No symbols, no docs — just the binary and a dispatch table you have to find manually. This is the methodology I’ve settled on, including the static heuristics I built into DriverDigger for automating the boring parts.

The entry point and the MajorFunction array

DriverEntry is the kernel’s entry point for a driver — equivalent to main, but receiving a PDRIVER_OBJECT as its first argument. The driver object is a well-known structure; on x64 Windows its layout has been stable for a long time1:

c
// from wdm.h — _DRIVER_OBJECT (x64)
+0x000  Type             : CSHORT
+0x002  Size             : CSHORT
+0x008  DeviceObject     : PDEVICE_OBJECT
+0x010  Flags            : ULONG
+0x018  DriverStart      : PVOID
+0x020  DriverSize       : ULONG
+0x028  DriverSection    : PVOID
+0x030  DriverExtension  : PDRIVER_EXTENSION
+0x038  DriverName       : UNICODE_STRING    // 0x10 bytes
+0x048  HardwareDatabase : PUNICODE_STRING
+0x050  FastIoDispatch   : PFAST_IO_DISPATCH
+0x058  DriverInit       : PDRIVER_INITIALIZE
+0x060  DriverStartIo    : PDRIVER_STARTIO
+0x068  DriverUnload     : PDRIVER_UNLOAD
+0x070  MajorFunction    : PDRIVER_DISPATCH[28]   // ← the table

MajorFunction is an array of 28 function pointers, one per IRP major function code. The slot for device I/O control is IRP_MJ_DEVICE_CONTROL, defined as 0x0E in wdm.h. On x64, each pointer is 8 bytes, so the target slot lives at:

text
+0x070 + (0x0E × 8) = +0x070 + +0x070 = +0x0E0

In a stripped driver’s disassembly, DriverEntry almost always contains a recognisable store into this offset — the compiler has no reason to obfuscate it:

nasm
; typical DriverEntry prologue
sub     rsp, 28h
mov     [rsp+38h+DriverObject], rcx    ; save first arg (PDRIVER_OBJECT)

; register callbacks
lea     rax, DispatchCreateClose
mov     rcx, [rsp+38h+DriverObject]
mov     [rcx+70h], rax                 ; MajorFunction[IRP_MJ_CREATE]
mov     [rcx+78h], rax                 ; MajorFunction[IRP_MJ_CLOSE]

lea     rax, DispatchDeviceControl
mov     [rcx+0E0h], rax                ; MajorFunction[IRP_MJ_DEVICE_CONTROL] ← target

Jump to whatever lands at +0xE0 and that’s your attack surface.

The CTL_CODE layout

The Windows I/O control code is a 32-bit value with a fixed field layout defined in wdm.h:

c
#define CTL_CODE(DeviceType, Function, Method, Access) \
    ( ((DeviceType) << 16) | ((Access) << 14) | ((Function) << 2) | (Method) )

Four fields:

32-bit IOCTL control code (CTL_CODE)DeviceTypebits 31:16 — 16 bitsAcc15:14Functionbits 13:2 — 12 bitsMethodbits 1:0Access: 0=FILE_ANY_ACCESS 1=FILE_READ_DATA 2=FILE_WRITE_DATA 3=READ+WRITEMethod: 0=BUFFERED 1=IN_DIRECT 2=OUT_DIRECT 3=NEITHER (raw user pointers)

The two fields that drive triage priority:

  • Access FILE_ANY_ACCESS (0) — any process that can open the device symbolic link can send this IOCTL. No read or write permission required.
  • Method METHOD_NEITHER (3) — the kernel passes raw user-space pointers directly to the handler. No kernel-managed copy, no length validation by the I/O manager. Whatever your handler does with that pointer is entirely its problem.

A handler that’s reachable via FILE_ANY_ACCESS and takes METHOD_NEITHER input is the canonical BYOVD primitive. If it also does something interesting (maps physical memory, reads/writes an MSR, modifies kernel structures), you’ve found your entry point.

A quick Python decoder for any code you pull from a binary:

python
def decode_ioctl(code):
    method   = code & 0x3
    function = (code >> 2) & 0xFFF
    access   = (code >> 14) & 0x3
    devtype  = (code >> 16) & 0xFFFF
    methods  = {0: "BUFFERED", 1: "IN_DIRECT", 2: "OUT_DIRECT", 3: "NEITHER"}
    accesses = {0: "FILE_ANY_ACCESS", 1: "FILE_READ_DATA",
                2: "FILE_WRITE_DATA", 3: "READ+WRITE"}
    print(f"  DevType:  0x{devtype:04X}")
    print(f"  Function: 0x{function:03X}")
    print(f"  Access:   {accesses.get(access)}")
    print(f"  Method:   {methods.get(method)}")

decode_ioctl(0x9C402003)
# DevType:  0x9C40
# Function: 0x800
# Access:   FILE_ANY_ACCESS
# Method:   NEITHER        ← dangerous

To construct a code with known properties:

python
def ctl_code(dev_type, function, method, access):
    return (dev_type << 16) | (access << 14) | (function << 2) | method

# Physical memory mapping — any access, raw pointers
IOCTL_MAP_PHYSMEM = ctl_code(0x9C40, 0x800, method=3, access=0)  # 0x9C402003
# MSR read — any access, buffered
IOCTL_READ_MSR    = ctl_code(0x9C40, 0x801, method=0, access=0)  # 0x9C402004

Inside the handler

The dispatch handler receives an IRP (I/O Request Packet). The control code lives in the current stack location:

c
NTSTATUS DispatchDeviceControl(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
    PIO_STACK_LOCATION sp = IoGetCurrentIrpStackLocation(Irp);
    ULONG code = sp->Parameters.DeviceIoControl.IoControlCode;

    switch (code) {
        case 0x9C402003: return handler_map_physmem(Irp, sp);
        case 0x9C402004: return handler_read_msr(Irp, sp);
        default:
            Irp->IoStatus.Status = STATUS_INVALID_DEVICE_REQUEST;
            IoCompleteRequest(Irp, IO_NO_INCREMENT);
            return STATUS_INVALID_DEVICE_REQUEST;
    }
}

For METHOD_NEITHER, the input and output buffers come from the stack location directly:

c
// METHOD_NEITHER: raw user-space pointers, no I/O manager involvement
PVOID input  = sp->Parameters.DeviceIoControl.Type3InputBuffer;
ULONG inLen  = sp->Parameters.DeviceIoControl.InputBufferLength;
PVOID output = Irp->UserBuffer;
ULONG outLen = sp->Parameters.DeviceIoControl.OutputBufferLength;

What to audit in the handler body:

  • Does it call ProbeForRead / ProbeForWrite before touching Type3InputBuffer? If not, an unaligned or kernel-space pointer triggers a fault or worse.
  • Does it call MmMapIoSpace or MmMapIoSpaceEx with a physical address from the input buffer? Physical memory mapping is the canonical BYOVD primitive.
  • Does it call __readmsr / __writemsr directly? MSR read/write gives you arbitrary kernel R/W on some targets.
  • Does it use memcpy or RtlCopyMemory with a size taken directly from user input without bounds checking?

Any of the above with FILE_ANY_ACCESS and METHOD_NEITHER is worth reporting.

The IRP journey

User modeDeviceIoControl(h, code, ...)syscall → NtDeviceIoControlFileI/O Managerbuilds IRP, copies input (if BUFFERED)MajorFunction[0x0E] at +0xE0DispatchDeviceControlswitch (sp→Parameters.DeviceIoControl.IoControlCode)0x9C402003map_physmem0x9C402004read_msrdefaultINVALID

Reaching it from user mode

Opening the device and firing the IOCTL from user mode requires only a symbolic link. Use Sysinternals WinObj or the \\.\\ Win32 device namespace to find it:

bash
# WinObj: look under \GLOBAL?? for the symbolic link name
# or enumerate with NtQueryDirectoryObject on \Device
winobj.exe

Once you have the symbolic link name:

python
import ctypes, ctypes.wintypes as wt

GENERIC_READ  = 0x80000000
GENERIC_WRITE = 0x40000000
OPEN_EXISTING = 3

k32 = ctypes.windll.kernel32
h = k32.CreateFileW(
    r"\\.\TargetDevice",
    GENERIC_READ | GENERIC_WRITE,
    0, None, OPEN_EXISTING, 0, None)
assert h != wt.HANDLE(-1).value, f"CreateFile failed: {k32.GetLastError()}"

# Build an input buffer for the physical memory mapping handler
# (illustrative — actual layout depends on the target driver)
class PhysMapIn(ctypes.Structure):
    _fields_ = [("phys_addr", ctypes.c_uint64),
                ("size",      ctypes.c_uint32)]

inp = PhysMapIn(phys_addr=0x1000, size=0x1000)
out = ctypes.create_string_buffer(8)
returned = wt.DWORD(0)

ok = k32.DeviceIoControl(
    h,
    0x9C402003,               # IOCTL_MAP_PHYSMEM — METHOD_NEITHER
    inp, ctypes.sizeof(inp),
    out, len(out),
    ctypes.byref(returned),
    None)

print("ok" if ok else f"err {k32.GetLastError()}")
k32.CloseHandle(h)

Static triage at scale

When triaging a batch of drivers (say, every third-party driver in a vendor’s installer), you want to extract IOCTL codes statically without running anything. The heuristic I use in DriverDigger:

  1. Find all CMP instructions in the .text section that compare a register against a 32-bit immediate
  2. Filter the immediates: a valid CTL_CODE has Method in {0,1,2,3}, Access in {0,1,2,3}, Function < 0x1000, DeviceType > 0 — anything with bits 31:16 == 0 or bits 1:0 == 0b11 and DeviceType == 0 is probably noise
  3. Decode the survivors and flag by (Access == FILE_ANY_ACCESS AND Method == METHOD_NEITHER) — that’s your highest-priority bucket

The density of IOCTL codes in the text section, combined with the surrounding instructions (a switch table jump or a chain of CMP/JE pairs), gives you high confidence even without a full symbolic execution pass.

A secondary signal: the DACL on the device object. If the driver creates its device with SDDL_DEVOBJ_SYS_ALL_ADM_RWX_WORLD_RWX_RES_RWX or equivalent, an unelevated user can open it. Combine that with a FILE_ANY_ACCESS IOCTL and you have the full chain.

From here it’s the usual work: map the attack surface, check whether the handler validates anything at all, and decide if it’s a real finding or a scary-looking dead end.


  1. Verify with dt _DRIVER_OBJECT in WinDbg against your specific target. The layout has been stable since Vista x64, but field offsets change only rarely and a sanity check costs seconds. ↩︎