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 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) & 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 <-- 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 a private static analysis tool:
- 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 <0x800, 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. ↩︎