Windows IOCTL Buffer Contracts: Buffered, Direct, and Neither Methods
Choose Windows IOCTL transfer methods safely by tracing SystemBuffer, MDL, and raw user pointers through validation, access checks, and completion.
An IOCTL’s CTL_CODE encodes device type, function, required handle access, and transfer method. For user-mode DeviceIoControl, the I/O manager normally sends an IRP_MJ_DEVICE_CONTROL request and lays out buffers per that method. Microsoft also documents a Fast I/O form of IRP_MJ_DEVICE_CONTROL; that path uses neither buffered nor direct I/O regardless of the IOCTL’s transfer type. This guide covers the IRP-based WDM dispatch path; incorrect lengths or pointers can corrupt or disclose kernel data.
The four transfer methods are METHOD_BUFFERED, METHOD_IN_DIRECT, METHOD_OUT_DIRECT, and METHOD_NEITHER, selected per IOCTL in CTL_CODE. They are not the same settings as DO_BUFFERED_IO and DO_DIRECT_IO, which select how IRP_MJ_READ and IRP_MJ_WRITE requests are buffered.
Define the code as an access-controlled protocol
Microsoft recommends using the system CTL_CODE macro rather than constructing a 32-bit IOCTL by hand. A vendor-defined code needs a vendor device-type value, a function in the vendor range, an explicit access policy, and the transfer type that matches the data contract. For example:
#define FILE_DEVICE_ACME_SAMPLE 0x8000
#define IOCTL_ACME_QUERY_STATE \
CTL_CODE(FILE_DEVICE_ACME_SAMPLE, 0x800, METHOD_BUFFERED, FILE_READ_DATA)
Here 0x8000 and 0x800 illustrate the documented vendor ranges; production code should use values assigned to that driver’s interface. Microsoft’s IOCTL contract requires the encoded device type to match the driver’s DEVICE_OBJECT type. The access field means the I/O manager sends the request only when the caller opened the device handle with the required access. It does not replace the device object’s security descriptor or application-level authorization. Avoid FILE_ANY_ACCESS unless any process that can open the device is truly allowed to issue the operation.
Dispatch code must compare the full 32-bit IoControlCode, not just the function field or the low method bits. Microsoft also recommends checking every input and output length against the actual buffer size and zeroing driver-allocated output that will be returned to user mode. Never expose an IOCTL that reads or writes arbitrary kernel memory.
METHOD_BUFFERED uses one shared system buffer
For METHOD_BUFFERED, the I/O manager allocates a nonpaged system buffer for the request. The same Irp->AssociatedIrp.SystemBuffer represents both the caller’s input and output buffers; its allocation size is the larger of InputBufferLength and OutputBufferLength. The driver reads the input first and writes the result back into that same storage, which the I/O manager copies to the caller after completion. Consequently, copy input into local validated state before overwriting the output area when the response reuses the input buffer.
A minimal dispatch outline is:
PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);
ULONG inLength = stack->Parameters.DeviceIoControl.InputBufferLength;
ULONG outLength = stack->Parameters.DeviceIoControl.OutputBufferLength;
PVOID systemBuffer = Irp->AssociatedIrp.SystemBuffer;
NTSTATUS status = STATUS_BUFFER_TOO_SMALL;
ULONG_PTR information = 0;
if (systemBuffer != NULL &&
inLength >= sizeof(QUERY_REQUEST) &&
outLength >= sizeof(QUERY_REPLY)) {
QUERY_REQUEST request;
RtlCopyMemory(&request, systemBuffer, sizeof(request));
RtlZeroMemory(systemBuffer, sizeof(QUERY_REPLY));
/* Validate request fields, then fill a bounded QUERY_REPLY. */
/* Synchronous helper contract: QueryDeviceState must never return STATUS_PENDING. */
status = QueryDeviceState(&request, (QUERY_REPLY *)systemBuffer);
if (status == STATUS_PENDING)
status = STATUS_INVALID_DEVICE_STATE;
if (NT_SUCCESS(status))
information = sizeof(QUERY_REPLY);
}
Irp->IoStatus.Status = status;
Irp->IoStatus.Information = information;
IoCompleteRequest(Irp, IO_NO_INCREMENT);
return status;
The type names and device routine are illustrative. In real code, copy only after checking lengths, validate each field and version, and set Information only to initialized output bytes. For variable-size requests, validate the header, offsets, integer-overflow bounds, and total length before following data.
Buffered I/O is usually a good fit for small control messages. It simplifies lifetime management because the I/O manager owns the system buffer until the IRP completes, but large transfers incur nonpaged-pool allocation and copying. It is not a general-purpose choice for arbitrarily large payloads.
Direct IOCTLs are a two-buffer contract
METHOD_IN_DIRECT and METHOD_OUT_DIRECT are intended for larger transfers where the second buffer should be locked and described by a memory descriptor list (MDL). The IOCTL still has two logical buffers:
- The first, input buffer is represented by
Irp->AssociatedIrp.SystemBufferand sized byInputBufferLength. - The second buffer is represented by
Irp->MdlAddressand sized byOutputBufferLength.
The names are easy to misread. With METHOD_IN_DIRECT, the driver receives data from the second buffer and needs read access to it. With METHOD_OUT_DIRECT, the driver writes data into that second buffer and needs write access. The first buffer remains the control/header buffer in SystemBuffer for both methods. A so-called direct IOCTL is therefore not two direct buffers.
The I/O manager locks the pages described by the MDL for the request, but it does not create an MDL for a zero-length second buffer. Check Irp->MdlAddress before using it. A driver that needs a system virtual address for the MDL-described memory can use MmGetSystemAddressForMdlSafe and must handle a NULL result. Respect the MDL and buffer lifetime: do not complete the IRP and then continue using its mapping. Page locking prevents paging, not concurrent modification through another mapping in the caller’s process. Copy small control data into driver-owned state and do not rely on a one-time validation remaining true for a mutable bulk buffer. Direct I/O avoids the system-buffer copy for the large buffer, but it does not make the data protocol, size checks, or device DMA programming automatic.
Use the input buffer for a bounded request header, such as an operation version and transfer parameters; use the MDL buffer for the bulk payload. Validate InputBufferLength before reading the header, validate OutputBufferLength against every transfer length, and ensure the requested transfer cannot exceed the locked range. If hardware performs DMA, use the appropriate Windows DMA APIs and device-specific synchronization rules; an MDL is not itself a device address or a complete DMA mapping.
METHOD_NEITHER leaves pointer management to the driver
For METHOD_NEITHER, the I/O manager supplies no system buffer and no MDL. On a user-mode DeviceIoControl request, the input address is in Type3InputBuffer and the output address is in Irp->UserBuffer. These user virtual addresses are not validated, mapped, or locked by the I/O manager. They are usable only while the driver is executing in the requesting thread’s process context unless the driver first captures or locks the data safely.
Microsoft cautions that only a highest-level driver is guaranteed to run in the originating thread’s context. For new WDM code, use ProbeForRead at IRQL <= APC_LEVEL inside structured exception handling to validate the user-range and alignment; it does not establish that an output buffer is writable. Microsoft does not recommend ProbeForWrite for new software. Keep every later read or write inside exception handling as well: probing does not pin the pages, and a caller can change protection before the actual access, so an output write can still fault. If work will be queued to another thread or continue after leaving request context, copy the data into driver-owned memory or create and manage an MDL and locked-page lifecycle. Do not save a raw user pointer for deferred work.
METHOD_NEITHER can avoid copies and page locking in a narrow synchronous path, but it transfers the most security and lifetime work to the driver. A malformed length, a pointer overflow, a time-of-check/time-of-use change, or an exception outside the protected access path can turn a fast path into a kernel fault or memory-corruption defect. Prefer buffered or direct transfer unless a measured design needs neither and can prove the context and lifetime guarantees.
Select the method from the data shape, not habit
Choose buffered I/O for small control messages, direct I/O for a large MDL-described second buffer, and neither only when request context and lifetime are proven. The transfer method is part of the IOCTL ABI: changing it changes where the driver expects buffers, so coordinate every caller and driver version.
Review each request from user arguments through IO_STACK_LOCATION. Record the length bounding each access, buffer owner, page-lock lifetime, reuse point, and completion path. Test zero-length and short buffers, mismatched lengths, maximum sizes, invalid versions and offsets, access-denied handles, and pending cancellation. Confirm output is initialized and asynchronous work retains no unlocked user pointer. Transfer methods provide memory access, not semantic validation or authorization; full-code dispatch, least-required access, bounded lengths, safe pointer handling, and correct completion remain the driver’s responsibility.
Related:
- Driver Verifier: A Controlled Windows Driver Test Workflow
- Windows PnP Runtime Diagnostics: Devnodes, Problem Codes, and Device State
Sources:
- Microsoft Learn: Defining I/O Control Codes
- Microsoft Learn: Buffer Descriptions for I/O Control Codes
- Microsoft Learn: Security Issues for I/O Control Codes
- Microsoft Learn: Methods for Accessing Data Buffers
- Microsoft Learn: IRP-Based IOCTL and FSCTL Operations
- Microsoft Learn: Using Direct I/O with DMA
- Microsoft Learn: Using Neither Buffered Nor Direct I/O
- Microsoft Learn: ProbeForRead
- Microsoft Learn: ProbeForWrite (not recommended for new software)
- Microsoft Learn: Handling Exceptions
- Microsoft Learn: Using NTSTATUS values
- Microsoft Learn: Errors in Direct I/O