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 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 bits)Acc15:14C13Functionbits 12:2 (11 bits)Methodbits 1:0C=Custom (bit 13): vendor-defined IOCTL when set | Access: 0=ANY 1=READ 2=WRITE 3=R+WMethod: 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) & 0x7FF
    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 a private static analysis tool:

  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 < 0x800, 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. ↩︎