Walking a Driver's IOCTL Dispatch by Hand
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:
// 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:
+0x070 + (0x0E × 8) = +0x070 + +0x070 = +0x0E0In a stripped driver’s disassembly, DriverEntry almost always contains a recognisable store into this offset — the compiler has no reason to obfuscate it:
; 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] ← targetJump 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:
#define CTL_CODE(DeviceType, Function, Method, Access) \
( ((DeviceType) << 16) | ((Access) << 14) | ((Function) << 2) | (Method) )Four fields:
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:
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 ← dangerousTo construct a code with known properties:
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) # 0x9C402004Inside the handler
The dispatch handler receives an IRP (I/O Request Packet). The control code lives in the current stack location:
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:
// 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/ProbeForWritebefore touchingType3InputBuffer? If not, an unaligned or kernel-space pointer triggers a fault or worse. - Does it call
MmMapIoSpaceorMmMapIoSpaceExwith a physical address from the input buffer? Physical memory mapping is the canonical BYOVD primitive. - Does it call
__readmsr/__writemsrdirectly? MSR read/write gives you arbitrary kernel R/W on some targets. - Does it use
memcpyorRtlCopyMemorywith 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
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:
# WinObj: look under \GLOBAL?? for the symbolic link name
# or enumerate with NtQueryDirectoryObject on \Device
winobj.exeOnce you have the symbolic link name:
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:
- Find all
CMPinstructions in the.textsection that compare a register against a 32-bit immediate - 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 - 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.
Verify with
dt _DRIVER_OBJECTin 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. ↩︎