Reading a file, sending a packet, talking to a USB device, and issuing a command to a disk controller look like completely different operations from user mode, yet inside the kernel they all flow through one subsystem in the same shape. The I/O manager gives Windows a uniform, packet-driven, layered model for input and output: every request is turned into a standard data structure, handed to a stack of cooperating drivers, and completed back up that same stack. Understanding this model explains how drivers fit together, how a CreateFile call reaches a disk, and why device drivers are one of the richest kernel attack surfaces on the platform.
Driver and Device Objects
Two kernel structures anchor the whole model. A DRIVER_OBJECT represents a loaded driver — there is exactly one per driver image. Its most important member is a table of dispatch routines indexed by major function code (IRP_MJ_CREATE, IRP_MJ_READ, and so on). When a driver initializes in its DriverEntry routine, it fills in this MajorFunction array with pointers to the handlers it supports; requests it does not handle fall through to a default that simply completes them.
A DEVICE_OBJECT represents an instance the driver manages — a driver can create many. Named device objects live in the object namespace under \Device (for example \Device\HarddiskVolume2), and are reached from user mode through symbolic links in \GLOBAL??. Each device object points back at the driver object that owns it, so once the I/O manager has a device object it knows which driver’s dispatch routines to call.
Device Stacks and Attachment
Real hardware is rarely served by a single driver. Instead, device objects are stacked so that several drivers cooperate on the same physical device. From the bottom up, the common roles are:
- The Physical Device Object (PDO), created by the bus driver that enumerated the device (PCI, USB, and so on).
- The Functional Device Object (FDO), created by the function driver that actually knows how to operate the device.
- One or more Filter Device Objects, created by filter drivers that sit above or below the FDO to observe or modify requests.
A driver inserts itself by calling IoAttachDeviceToDeviceStack, which links its device object on top of the existing stack and returns the device below it. The result is a device stack: a request enters at the top and travels downward, each layer doing its part, until it reaches the driver that can satisfy it. This is why an antivirus or encryption product can transparently inspect file operations — it attaches a filter into the storage stack.
I/O Request Packets (IRPs)
The “packet” in packet-driven I/O is the I/O Request Packet (IRP). When the I/O manager receives a request, it allocates an IRP describing it and sends that single structure down the device stack. The IRP carries a fixed header (status, buffers, the requesting thread) plus an array of IO_STACK_LOCATION structures — one slot per device in the stack. Each driver reads and writes only its own stack location, which is where the current MajorFunction, MinorFunction, and per-request parameters (such as the read length and offset) live.
Movement through the stack is driven by two calls. IoCallDriver advances to the next-lower stack location and invokes that driver’s dispatch routine, passing the same IRP down. When a driver finishes, it calls IoCompleteRequest, which walks the IRP back up the stack, invoking any completion routines that higher-level drivers registered on the way down. A driver that needs more time can return STATUS_PENDING and complete the IRP later, asynchronously — the foundation of overlapped I/O. This down-then-up journey, with each layer getting a chance on both passes, is the heart of the model.
Major Functions
The major function code selects which dispatch routine handles an IRP. The ones you meet constantly are:
IRP_MJ_CREATE— a handle is being opened (fromCreateFile).IRP_MJ_READ/IRP_MJ_WRITE— data transfer (fromReadFile/WriteFile).IRP_MJ_DEVICE_CONTROL— a control operation, or IOCTL (fromDeviceIoControl).IRP_MJ_CLEANUP— the last handle to a file object is closing.IRP_MJ_CLOSE— the file object itself is being destroyed.
The connection to user mode runs through the object manager. When CreateFile opens \Device\HarddiskVolume2\Windows\notepad.exe, the object manager parses the path only as far as the device object, then invokes the I/O manager’s parse method, which builds an IRP_MJ_CREATE IRP and sends it into the device stack. That single hand-off is where the generic object namespace becomes a concrete file-system request.
IOCTLs
Beyond read and write, drivers expose custom operations through IOCTLs (I/O control codes) invoked with DeviceIoControl. Each control code is not an arbitrary number: it is built by the CTL_CODE macro from four fields — the device type, a driver-specific function number, the transfer method, and the required access. The transfer method, encoded in the low two bits, tells the I/O manager how the caller’s input and output buffers should be delivered to the driver, and that choice has direct security consequences.
// CTL_CODE(DeviceType, Function, Method, Access)
// Low 2 bits of the code select the buffering method:
// METHOD_BUFFERED (0) - I/O manager copies buffers
// METHOD_IN_DIRECT (1) - MDL for the input buffer
// METHOD_OUT_DIRECT (2) - MDL for the output buffer
// METHOD_NEITHER (3) - raw user pointers, no help
#define IOCTL_MYDRV_DO_THING \
CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800, METHOD_BUFFERED, FILE_ANY_ACCESS)
Buffered vs Direct vs Neither I/O
The three transfer methods differ in how much the I/O manager does on the driver’s behalf:
- Buffered I/O — the I/O manager allocates a kernel buffer, copies the caller’s input into it before the call and copies the driver’s output back afterward. The driver only ever touches a safe, kernel-owned buffer, and the request length is captured by the I/O manager.
- Direct I/O — the caller’s buffer is probed, locked into physical memory, and described by a Memory Descriptor List (MDL). The driver maps the MDL to access the data; the pages cannot be freed or paged out mid-operation. Efficient for large transfers with no copy.
- Neither I/O — the I/O manager passes the raw user-mode pointers straight through and does nothing to validate them. The driver must itself call
ProbeForRead/ProbeForWrite, verify lengths, and handle the address being invalid, mid-operation, or racing with another thread.
Because buffered I/O hands the driver a captured, kernel-owned copy, it is the safest default. METHOD_NEITHER is the fastest but shifts the entire burden of validation onto driver code — and driver code is exactly where that validation is most often gotten wrong.
Inspecting the I/O System
All of these structures are directly observable in a kernel debugger, which is the fastest way to make the model concrete:
# Show a driver object: its dispatch routine table and devices
!drvobj \Driver\disk 7
# Inspect a device object (flags, driver, attached device)
!devobj <device-object-address>
# Print the full device stack for a device (who is layered on whom)
!devstack <device-object-address>
# Decode an in-flight IRP: its stack locations, major function, buffers
!irp <irp-address>
!drvobj reveals which routine handles each major function; !devstack shows the layering that IoAttachDeviceToDeviceStack produced, including any filter drivers; and !irp lets you follow a live request through the stack. On a running system without a debugger, driverquery lists loaded drivers, and WinObj from Sysinternals browses the named device objects under \Device and their symbolic links.
Security Relevance
The I/O model is one of the largest kernel attack surfaces on Windows, and IOCTL handlers are the reason. Every IRP_MJ_DEVICE_CONTROL handler is code running in ring 0 that consumes attacker-influenced input, so any missing check becomes a kernel-level bug:
- Unvalidated
METHOD_NEITHERpointers. A handler that dereferences the raw user pointers withoutProbeForRead/ProbeForWritecan be tricked into reading or writing arbitrary kernel addresses — a classic path to an elevation-of-privilege primitive. - Unchecked lengths and integer math. Trusting a caller-supplied size, or overflowing an offset/length calculation, leads to kernel buffer overflows and out-of-bounds access.
- Bring Your Own Vulnerable Driver (BYOVD). Attackers load a legitimately signed but buggy driver purely to reach its powerful IOCTLs — for example one that offers raw physical-memory or MSR access — and use it as a ready-made kernel read/write.
The same machinery powers defense. Antivirus and EDR products are largely built from filter drivers attached into the file-system and network stacks, letting them inspect and block operations as IRPs flow past. Understanding device stacks, IRPs, and the buffering methods is therefore essential on both sides: it is where kernel exploitation happens and where much of modern endpoint monitoring lives. (Everything here is for defensive understanding and authorized testing.)
Conclusion
The I/O manager turns every input/output operation into the same story: a driver object with a dispatch table, a stack of device objects, and an IRP that travels down and completes back up. IOCTLs and the buffered/direct/neither methods determine how user data reaches ring 0 — and how carefully it is checked on the way. With the request model in hand, the natural next step is the drivers themselves: in the next article we look at the Windows driver model, WDM and KMDF, and how a driver is structured and loaded.
You Might Also Like
This article is part of the Windows Internals series. These related deep-dives cover adjacent parts of the system:



Comments