WINDOWS KERNEL / ARCHITECTURE · 2026

The Windows Kernel Time Machine

What Windows NT 4 let a driver touch, what modern Windows fixed, and what twenty-seven years of defensive engineering quietly took away.

In 1999, while creating a Windows NT driver course, I wrote a small teaching driver that printed a message directly onto the boot display and then waited for the student to press the space bar. It does not ask the window manager for a console. It does not open a keyboard device. It does not register a callback with an input framework. It declares an undocumented HAL routine, writes to the screen before the display driver takes possession of it, and polls the keyboard controller’s I/O port at address 0x60 until scan code 57 appears.

// 1999: a teaching driver speaks before the display stack exists.
VOID HalDisplayString(PSZ Buffer);     // undocumented

HalDisplayString("LAB1: Press space to continue\n");

PUCHAR keyboardPort = (PUCHAR)0x60;
while (READ_PORT_UCHAR(keyboardPort) != 57) { /* spin */ }
while (READ_PORT_UCHAR(keyboardPort) == 57) { /* spin */ }

This was not malware. It was lesson one.

The code is indefensible as a production design. It depends on an undocumented entry point, monopolizes a processor while it waits, bypasses the keyboard stack, assumes a PC-compatible controller and reaches directly into a hardware resource that the operating system is supposed to coordinate. Yet as a piece of teaching it has a quality that is hard to reproduce today: seven lines make the entire machine feel physically present. The student can see the boundary between the Hardware Abstraction Layer, the I/O bus, the driver and the user sitting at the keyboard.

The practical details belong to another era, but they preserve a rare view of Windows at the moment when the NT architecture was commercially mature and had not yet accumulated two decades of frameworks, signing policy, compatibility layers and virtualization-backed defence.

Read beside current Windows driver documentation, that 1999 material produces an unexpected result. Windows did not replace the NT kernel model. It kept much of the same grammar: driver objects, device objects, IRPs, I/O stack locations, MDLs, dispatcher objects, IRQLs, DPCs, pool allocation, object references and a named Object Manager namespace. Many support routines used in the laboratories still exist under the same names. What changed most was the social contract between the operating system and a third-party driver.

Windows NT 4 largely acted as though code admitted to kernel mode had joined the operating system. Modern Windows increasingly acts as though the same code may be buggy, compromised, malicious, abandoned or signed by a vendor that no longer exists. The kernel’s skeleton survived. Trust did not.

Under the abstraction — a “modern” WDF request still contains an IRP. KMDF does not replace the WDM packet model underneath it. When WDF receives an I/O request, it creates a WDFREQUEST object that encapsulates the underlying WDM IRP. A driver can still retrieve that IRP through WdfRequestWdmGetIrp when it genuinely needs to cross the abstraction boundary.[1]

1. The first shock: the fossils are still alive

The historical code calls dozens of recognizable NT and HAL support routines or port-access macros. They include IoCreateDevice, IoCompleteRequest, IoGetCurrentIrpStackLocation, KeInitializeEvent, KeWaitForSingleObject, ObReferenceObjectByHandle, PsCreateSystemThread, ZwOpenKey, RtlInitUnicodeString, ExAllocatePool and the undocumented HalDisplayString.

Not all of them should be used now. ExAllocatePool is deprecated. HalDisplayString was undocumented even then. Some routines remain appropriate only in narrow contexts, and several old patterns around them are unsafe. But a developer transported from 1999 would still recognize an extraordinary amount of a current crash dump. The object names, request packets, stack locations, status codes, list entries, events and reference counts are not historical re-enactments. They are the continuing language of the operating system.

1999-era primitive Current state What changed around it
DriverEntry Still the kernel-driver entry point Normally creates WDF state rather than manually wiring the whole driver
DRIVER_OBJECT / DEVICE_OBJECT Still fundamental WDM objects KMDF wraps them with WDFDRIVER and WDFDEVICE
IRP and I/O stack locations Still the underlying request mechanism WDF queues and WDFREQUEST hide common packet handling
IoCreateDevice Still documented PnP drivers usually expose device interfaces and stronger security metadata
IoCreateSymbolicLink Still documented Fixed DOS names are generally not the preferred discovery mechanism for PnP devices
KeInitializeEvent / KeWaitForSingleObject Still documented Framework queues, timers and object lifetimes often eliminate manual use
PsCreateSystemThread Still documented Many periodic or deferred tasks are better represented by timers, work items or queues
ExAllocatePool Deprecated ExAllocatePool2/3 add flags, mandatory tags and zero-by-default allocation
DbgPrint Still available for debugging WPP/ETW provides structured, controllable production tracing
Direct kernel modification Increasingly forbidden x64 Kernel Patch Protection and HVCI enforce new invariants

The durability is not accidental. NT’s I/O design separates a request from the drivers that process it. An IRP carries fixed request state plus one I/O stack location for each relevant layer. A filesystem, volume filter, storage class driver, port driver and miniport can all see their own part of the same logical operation. That architecture made layered drivers practical in the early 1990s and still underlies modern Windows.

The 1999 material illustrates the IRP as a fixed packet followed by a variable number of stack locations. Fields such as MdlAddress, AssociatedIrp.SystemBuffer, IoStatus, CancelRoutine and UserBuffer are shown explicitly. Modern KMDF training can postpone most of those fields for weeks. The framework retrieves the correct buffer, queues the request, serializes callbacks and completes it through methods. But when a dump shows a blocked request, a completion routine at the wrong IRQL or an MDL describing an invalid mapping, the abstraction recedes and the old diagram becomes relevant again.

Historical 1999 IRP diagram
Historical 1999 IRP diagram

The biggest architectural change, therefore, is not that the old objects disappeared. It is that a new driver can often be useful without manipulating them directly. Microsoft describes WDF as object based and event driven: the framework intercepts requests, performs default actions and invokes callbacks only where driver-specific behavior is required.[2] This reduces code, but it also changes how engineers learn. In 1999, productivity followed understanding. Today, productivity can precede understanding—and a serious failure is often the moment when the hidden WDM machinery becomes visible.

Architectural continuity — KMDF is not a second kernel API. Microsoft’s own documentation says that WDF does not replace WDM and that a KMDF driver still needs knowledge of WDM concepts. WDF changes who performs the bookkeeping; it does not invent a different I/O universe.[3]

2. A device was an object with a name. It still is.

A typical 1999 software driver creates a device object, places dispatch function pointers into the driver object, and creates a symbolic link so a Win32 application can open it through CreateFile.

// 1999: create a named device and a fixed user-visible alias.
#define NT_DEVICE_NAME  L"\\Device\\testdrv"
#define DOS_DEVICE_NAME L"\\??\\testdrv"

RtlInitUnicodeString(&ntName, NT_DEVICE_NAME);
status = IoCreateDevice(
    DriverObject,
    sizeof(TESTDRV_DEVICE_EXTENSION),
    &ntName,
    FILE_DEVICE_UNKNOWN,
    0,
    FALSE,
    &deviceObject);

DriverObject->MajorFunction[IRP_MJ_CREATE] = TestDispatch;
DriverObject->MajorFunction[IRP_MJ_CLOSE] = TestDispatch;
DriverObject->MajorFunction[IRP_MJ_READ] = TestDispatch;
DriverObject->MajorFunction[IRP_MJ_WRITE] = TestDispatch;
DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = TestDispatch;

RtlInitUnicodeString(&dosName, DOS_DEVICE_NAME);
status = IoCreateSymbolicLink(&dosName, &ntName);

The user-mode application opens \\.\testdrv. That familiar Win32 spelling is not the device’s real kernel name. It passes through the DOS-device namespace and resolves to an object managed by the NT Object Manager. The mechanism is worth pausing over because it connects the namespace, reference counts, file objects, device objects and driver objects in one path.

Modern PnP drivers usually avoid a permanent hand-chosen alias. They register a device interface identified by a GUID. The PnP manager produces a symbolic link for each device instance, and an application enumerates matching interfaces through Configuration Manager or SetupAPI before calling CreateFile.

// Modern KMDF: let PnP create instance-aware identity and discovery.
NTSTATUS
EvtDriverDeviceAdd(
    WDFDRIVER Driver,
    PWDFDEVICE_INIT DeviceInit)
{
    WDFDEVICE device;
    WDF_IO_QUEUE_CONFIG queueConfig;
    NTSTATUS status;

    UNREFERENCED_PARAMETER(Driver);

    status = WdfDeviceCreate(
        &DeviceInit,
        WDF_NO_OBJECT_ATTRIBUTES,
        &device);
    if (!NT_SUCCESS(status)) {
        return status;
    }

    status = WdfDeviceCreateDeviceInterface(
        device,
        &GUID_DEVINTERFACE_KERNELSOFT_SAMPLE,
        NULL);
    if (!NT_SUCCESS(status)) {
        return status;
    }

    WDF_IO_QUEUE_CONFIG_INIT_DEFAULT_QUEUE(
        &queueConfig,
        WdfIoQueueDispatchSequential);
    queueConfig.EvtIoDeviceControl = EvtIoDeviceControl;

    return WdfIoQueueCreate(
        device,
        &queueConfig,
        WDF_NO_OBJECT_ATTRIBUTES,
        WDF_NO_HANDLE);
}

The modern code looks completely different, but the “device interface” is still a symbolic link to a PnP device. The old mechanism did not vanish; it was placed under system management. The important improvements are around the name:

  • identity follows a physical or virtual device instance instead of an arbitrary global string;
  • applications can discover arrival and removal;
  • multiple identical devices can coexist without invented suffixes;
  • installation metadata can define security;
  • the framework coordinates PnP and power state with request delivery;
  • object lifetimes are linked through WDF’s parent-child model.

That last point is easy to underestimate. In the 1999 code, every failure path must know whether the symbolic link was created, whether the device object exists, whether pool memory was allocated and whether referenced objects need dereferencing. The code calls an unload routine from partial initialization and hopes that every member is in a state the cleanup path understands. In WDF, objects can have parents. Deleting a parent causes the framework to delete its children in a defined order. Microsoft’s official non-PnP sample explicitly notes that its control device is deleted automatically because of the default parent-child relationship.[4]

This is a recurring theme: the capability survived, but ownership moved. The driver author once owned the name, queue, cancellation routine, PnP transitions and cleanup order. Modern Windows tries to make the author describe intent while the framework owns the state machine.

What was gained

A fixed \\.\testdrv name is wonderfully easy to explain, test and script. It is also globally shared, does not naturally represent multiple instances and can become a security liability if the named device is created without a suitable descriptor. Current Microsoft guidance says WDM PnP drivers generally do not need an MS-DOS device name and should use device interfaces. When a named device object is necessary, IoCreateDeviceSecure can apply explicit security settings.[5]

What was lost

The old path exposed the Object Manager directly. A student learned that CreateFile was not “opening a DLL-like driver”; it was causing the I/O manager to create a file object referring to a device object and to deliver an IRP_MJ_CREATE. The modern interface API is better engineering, but it can make the kernel namespace feel like an implementation detail rather than one of NT’s central organizing ideas.

Easy to miss — device interfaces did not abolish symbolic links. They automated and contextualized them. Modern user-mode discovery still ends with a symbolic-link string that an application passes to CreateFile.[6]

3. The most dangerous part of an IOCTL may be hiding inside the buffer

The strongest security lesson in the 1999 material is not the obvious use of METHOD_NEITHER. It is a subtler sample in the toy cryptographic provider.

The provider defines an ENCRYPTPARAMS structure containing a key pointer, an input pointer, an output pointer and a length. The IOCTL uses buffered I/O. At first glance that sounds safe: the I/O manager creates a kernel buffer and copies the application’s input structure into it. The driver checks that the input length equals sizeof(ENCRYPTPARAMS) before using Irp->AssociatedIrp.SystemBuffer.

// 1999: the outer structure is buffered, but its pointers are not.
typedef struct _ENCRYPTPARAMS {
    char *pKey;
    int   iSize;
    char *pInData;
    char *pOutData;
} ENCRYPTPARAMS, *PENCRYPTPARAMS;

pParams = Irp->AssociatedIrp.SystemBuffer;

Encrypt(
    pParams->pKey,
    pParams->iSize,
    pParams->pInData,
    pParams->pOutData);

Only the pointer values are copied. The memory behind those pointers remains wherever the caller placed it. If the request came from user mode, the kernel is now dereferencing caller-controlled virtual addresses. The pointer can be invalid, can refer to read-only memory, can change meaning if the request is processed in another process context, can be raced by another thread, can wrap when combined with a length, and changes size when a 32-bit protocol crosses into a 64-bit driver.

The code adds structured exception handling around the dispatch routine. It catches an access violation, writes the exception status into the IRP and completes the request.

// 1999: an exception handler is used as the last line of defence.
__try {
    Encrypt(pParams->pKey,
            pParams->iSize,
            pParams->pInData,
            pParams->pOutData);
}
__except (ExceptionFilter(GetExceptionInformation(), &status)) {
    DbgPrint("CRYPTER: Exception handler executed.\n");
}

This prevents some invalid addresses from immediately taking down the machine. It does not make the interface secure. The length can still be malicious. The output pointer can target memory the caller should not modify. A time-of-check/time-of-use race can occur. The driver can be tricked into reading or writing kernel addresses if requestor mode and validation are not handled correctly. An exception guard is a crash-containment mechanism, not a capability model.

Other 1999 examples use METHOD_NEITHER for control codes that start and stop system threads, resize kernel storage and encrypt or decrypt it. Every IOCTL is declared with FILE_ANY_ACCESS:

#define IOCTL_SETSTORAGESIZE \
    CTL_CODE(FILE_DEVICE_TEST, 2050, \
             METHOD_NEITHER, FILE_ANY_ACCESS)

For METHOD_NEITHER, the I/O manager passes the original user virtual addresses. It does not validate them, copy them or build MDLs. Microsoft’s current documentation states that safe access requires the driver to execute in the caller’s thread context and to probe and guard user buffers; it notes that this method is typically suitable only for highest-level drivers such as filesystems.[7] Current IOCTL guidance also warns that FILE_ANY_ACCESS should be selected only when unrestricted access cannot create a path for a malicious caller to compromise the system.[8]

The modern answer is not merely “replace METHOD_NEITHER with METHOD_BUFFERED.” A buffered structure containing pointers recreates the same problem one level down. A safer protocol is self-contained: fixed-width fields, explicit versioning, lengths checked against the actual buffer, offsets instead of process-specific pointers, and access bits that match the operation.

// Modern: a self-contained, versioned request with inline data.
#define IOCTL_TRANSFORM \
    CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800, \
             METHOD_BUFFERED, \
             FILE_READ_DATA | FILE_WRITE_DATA)

typedef struct _TRANSFORM_REQUEST {
    ULONG Version;
    ULONG DataLength;
    UCHAR Data[ANYSIZE_ARRAY];
} TRANSFORM_REQUEST, *PTRANSFORM_REQUEST;

For a METHOD_BUFFERED IOCTL, input and output are not independent allocations. The I/O manager uses one system buffer for both directions, and KMDF's input and output retrieval methods return the same underlying buffer. The cleanest compact example is therefore an explicitly in-place protocol.[23]

// Modern KMDF: validate one buffered request, then transform it in place.
VOID
EvtIoDeviceControl(
    WDFQUEUE Queue,
    WDFREQUEST Request,
    size_t OutputBufferLength,
    size_t InputBufferLength,
    ULONG IoControlCode)
{
    PTRANSFORM_REQUEST request = NULL;
    size_t bufferSize = 0;
    const size_t headerSize = FIELD_OFFSET(TRANSFORM_REQUEST, Data);
    NTSTATUS status = STATUS_INVALID_DEVICE_REQUEST;

    UNREFERENCED_PARAMETER(Queue);

    if (IoControlCode != IOCTL_TRANSFORM) {
        WdfRequestComplete(Request, status);
        return;
    }

    if (OutputBufferLength < headerSize ||
        InputBufferLength < headerSize) {
        WdfRequestComplete(Request, STATUS_BUFFER_TOO_SMALL);
        return;
    }

    status = WdfRequestRetrieveInputBuffer(
        Request, headerSize, (PVOID *)&request, &bufferSize);
    if (!NT_SUCCESS(status)) {
        WdfRequestComplete(Request, status);
        return;
    }

    if (request->Version != 1 ||
        request->DataLength > bufferSize - headerSize ||
        request->DataLength > OutputBufferLength - headerSize ||
        request->DataLength > MAX_TRANSFORM_BYTES) {
        WdfRequestComplete(Request, STATUS_INVALID_PARAMETER);
        return;
    }

    status = TransformBufferInPlace(
        request->Data,
        request->DataLength);

    WdfRequestCompleteWithInformation(
        Request,
        status,
        NT_SUCCESS(status) ? headerSize + request->DataLength : 0);
}

This example is deliberately compact, but its differences are structural:

  1. The caller cannot smuggle arbitrary addresses inside the request.
  2. The protocol has a version field, so a future driver can reject incompatible layouts.
  3. ULONG fixes the wire width instead of relying on int or pointer size.
  4. The payload length is checked against the buffer that actually arrived.
  5. The code acknowledges that buffered input and output alias and makes in-place behavior explicit.
  6. The IOCTL requires a handle opened for read and write access.
  7. Completion and byte count are explicit.

A production design would add an upper bound, integer-overflow-safe arithmetic, semantic validation, authorization based on the file object or caller, cancellation where work can block, and fuzz tests across native and WOW64 callers. It might avoid a custom crypto IOCTL entirely and keep cryptographic policy in user mode. The important point is that the interface is treated as a security boundary, not merely a convenient function call across privilege levels.

The hidden boundary — “buffered” does not mean “all referenced memory is safe.” Buffered I/O copies the bytes in the declared buffer. If those bytes are pointers, the pointees remain outside the copy. This is one reason pointer-free wire formats are so valuable across user/kernel and 32/64-bit boundaries.

The 32-bit ghost inside a 64-bit machine

The 1999 material was written for a world in which mainstream NT driver development meant 32-bit x86. Several structures pass addresses through DWORD, int or native pointers. On x64, a pointer doubles in width, structure padding changes and a 32-bit application running under WOW64 still sends a 32-bit layout unless the protocol is explicitly designed for both sides.

Windows provides guidance for handling 32-bit IOCTL callers, but the cleanest approach is often to avoid pointers in public structures altogether. Use fixed-width integers for values, offsets for regions within the same buffer, and separate 32/64-bit definitions only where an external ABI makes them unavoidable. A protocol that was “just a C struct” in 1999 becomes a durable binary contract once customers, tools and firmware depend on it.

The security descriptor is only the outer gate

Restricting who can open the device is necessary, but it cannot compensate for unsafe IOCTL semantics. Administrators, privileged services and signed vendor tools can still be compromised. Modern attacks frequently chain a vulnerable signed driver with ordinary user-mode code: the signature gets the driver through the load gate; the permissive IOCTL supplies the primitive. The defensive objective is therefore two-layered:

  • expose the device only to callers that need it;
  • assume every byte from an allowed caller may still be hostile.

That second assumption is almost absent from the tone of the 1999 code. It is central to current Microsoft driver-security guidance.[9]

4. Memory used to arrive dirty. Now zero is the default.

The laboratories allocate pool memory with ExAllocatePool, often without a tag:

// 1999: allocate bytes; contents are unspecified.
pDevExt->Storage = ExAllocatePool(PagedPool, STORAGESIZE);

strInfo.Buffer = ExAllocatePool(
    PagedPool,
    strInfo.MaximumLength);

This API is historically important and dangerous in several ways. Untagged allocations are hard to attribute in pool diagnostics. Uninitialized bytes may contain remnants of previous kernel allocations. If a driver later copies more data to user mode than it initialized, it can disclose kernel information. Old pool types also predate the modern emphasis on non-executable data allocations.

For drivers targeting Windows 10 version 2004 and later, Microsoft directs developers to ExAllocatePool2 or ExAllocatePool3. ExAllocatePool2 requires a non-zero tag, expresses properties as flags and zero-initializes the allocation unless the driver explicitly opts out.[10]

// Modern: tagged, nonpaged and zero-initialized by default.
PVOID buffer = ExAllocatePool2(
    POOL_FLAG_NON_PAGED,
    length,
    'fSKK');

if (buffer == NULL) {
    return STATUS_INSUFFICIENT_RESOURCES;
}

Zero-by-default is a small API change with a large philosophical meaning. In 1999, speed and flexibility were the default, and the driver author was expected to initialize what mattered. In modern Windows, the allocator spends cycles to reduce an entire class of accidental information disclosures. A driver can request POOL_FLAG_UNINITIALIZED when performance justifies it, but the unsafe choice must now be explicit.

The same inversion appears elsewhere:

Old default Modern tendency
Uninitialized memory unless the driver clears it Zeroed memory unless the driver opts out
Driver-managed cleanup Framework parent-child cleanup where possible
Driver-chosen global device name PnP-managed device-interface identity
Kernel implementation for custom USB communication Generic WinUSB or UMDF where sufficient
Writable/executable assumptions tolerated by old systems HVCI requires executable kernel pages not to be writable
Manual testing as the main safety net Driver Verifier, CodeQL and certification pipelines

This is not simply “more bureaucracy.” It is a systematic transfer of safety obligations from conventions into defaults and enforcement.

A safer default — a pool API now protects secrets by default. ExAllocatePool2 returns zero-initialized memory, and Microsoft explicitly warns drivers not to opt out when an allocation may be copied to an untrusted destination.[10]

5. A system thread you had to raise, track and bury yourself

One laboratory adds a new capability in stages. An IOCTL creates a kernel system thread. Every second, the thread writes its number and lifetime into the driver’s memory-backed storage. Another IOCTL stops the most recently created thread; a third stops all of them.

The implementation is an excellent map of NT execution primitives. It allocates a thread-control record from nonpaged pool, initializes an event, creates a system thread, converts the returned handle into a referenced thread object, closes the handle, links the record into a list, signals the event during shutdown, waits on the thread object, dereferences it and frees the record.

// 1999: create and retain a system thread explicitly.
pThreadData = ExAllocatePool(
    NonPagedPool,
    sizeof(THREAD_LIST_ENTRY));

KeInitializeEvent(
    &pThreadData->StopEvent,
    NotificationEvent,
    FALSE);

status = PsCreateSystemThread(
    &threadHandle,
    0,
    NULL,
    0,
    NULL,
    ThreadRoutine,
    pThreadData);

status = ObReferenceObjectByHandle(
    threadHandle,
    THREAD_ALL_ACCESS,
    NULL,
    KernelMode,
    &pThreadData->ThreadObject,
    NULL);

ZwClose(threadHandle);
InsertTailList(
    &devExt->ThreadsList,
    &pThreadData->NextThread);

Stopping it is equally explicit:

KeSetEvent(&pThreadData->StopEvent, 0, TRUE);
KeWaitForSingleObject(
    pThreadData->ThreadObject,
    Executive,
    KernelMode,
    FALSE,
    NULL);

ObDereferenceObject(pThreadData->ThreadObject);
ExFreePool(pThreadData);

Every line teaches something real: handles are not object pointers; references govern lifetime; dispatcher objects can be waited on; an event communicates shutdown; the control record must reside in nonpaged memory because it contains an object touched by kernel synchronization; list linkage is recovered through CONTAINING_RECORD.

It also creates a large failure surface. What if object referencing fails after the thread starts? What if the list is modified concurrently? What if unload races with the IOCTL? What if the thread accesses pageable memory at the wrong IRQL? What if it never observes the event? What if the driver waits while holding a lock needed by the thread? The old material eventually exposes those issues because it has to.

For the laboratory’s actual job—run something once per second—a modern driver generally does not need a dedicated thread. A framework timer can express periodic intent directly:

// Modern KMDF: represent periodic work as a timer, not a thread.
WDF_TIMER_CONFIG timerConfig;
WDF_OBJECT_ATTRIBUTES attributes;
WDFTIMER timer;

WDF_TIMER_CONFIG_INIT_PERIODIC(
    &timerConfig,
    EvtStatusTimer,
    1000);                       // milliseconds

WDF_OBJECT_ATTRIBUTES_INIT(&attributes);
attributes.ParentObject = device;

status = WdfTimerCreate(
    &timerConfig,
    &attributes,
    &timer);

if (NT_SUCCESS(status)) {
    WdfTimerStart(timer, WDF_REL_TIMEOUT_IN_MS(1000));
}

The callback receives a framework timer object. Its lifetime can be tied to the device. The framework coordinates deletion and waits for callbacks according to documented rules. If the task can block, the timer can enqueue a work item rather than performing the work at an unsuitable execution level. If the goal is to serialize I/O, a sequential WDF queue may remove the need for a background worker altogether.

PsCreateSystemThread did not disappear; it remains documented. A persistent dedicated thread is still appropriate for some designs. The improvement is that it is no longer the first tool for every asynchronous requirement. The framework offers objects that encode common intent more narrowly and therefore leave less room for lifecycle mistakes.

What was lost is educational density. The old thread example makes events, object references, handles, wait semantics, lists and teardown visible in one place. The timer sample is safer and shorter precisely because it hides most of those mechanisms. A developer can ship it without learning why a thread object is waitable or why a handle must become a referenced pointer before the handle closes.

The primitive survived; the routine obligation did not. PsCreateSystemThread and KeWaitForSingleObject are still part of current driver programming. Framework timers and queues did not remove them; they removed the need to deploy them for routine scheduling.

6. When one driver called another, it built a packet and waited

The most architecturally ambitious laboratory splits encryption into a second driver. The storage driver locates the provider’s device object by name, constructs a device-control IRP, calls the lower driver and waits on an event if the request is pending.

// 1999: construct an IRP for another driver and wait for completion.
KeInitializeEvent(&completeEvent, NotificationEvent, FALSE);

irp = IoBuildDeviceIoControlRequest(
    ioctlCode,
    devExt->CryptoProvider,
    params,
    sizeof(*params),
    NULL,
    0,
    FALSE,
    &completeEvent,
    &ioStatus);

status = IoCallDriver(devExt->CryptoProvider, irp);
if (status == STATUS_PENDING) {
    KeWaitForSingleObject(
        &completeEvent,
        Executive,
        KernelMode,
        FALSE,
        NULL);
    status = ioStatus.Status;
}

This is NT’s packet architecture in miniature. A request is not a C function call. The target can complete synchronously or asynchronously. The caller must retain completion state and honor STATUS_PENDING. The same mechanism scales from a toy provider to deeply layered storage and network stacks.

A KMDF driver can express the same relationship through a framework I/O target. The framework builds and tracks the request, while the driver supplies buffers, control code and timeout policy.

// Modern WDF: send a synchronous IOCTL to a managed I/O target.
WDF_MEMORY_DESCRIPTOR inputDescriptor;
WDF_REQUEST_SEND_OPTIONS options;

WDF_MEMORY_DESCRIPTOR_INIT_BUFFER(
    &inputDescriptor,
    &params,
    sizeof(params));

WDF_REQUEST_SEND_OPTIONS_INIT(
    &options,
    WDF_REQUEST_SEND_OPTION_TIMEOUT);
WDF_REQUEST_SEND_OPTIONS_SET_TIMEOUT(
    &options,
    WDF_REL_TIMEOUT_IN_SEC(2));

status = WdfIoTargetSendIoctlSynchronously(
    ioTarget,
    NULL,
    IOCTL_TRANSFORM,
    &inputDescriptor,
    NULL,
    &options,
    NULL);

The modern method still builds a device-control request and still may wait for completion. It can be used in KMDF and UMDF. But a WDFIOTARGET can also carry removal callbacks and framework state, reducing the amount of hand-written lifetime code when the remote device disappears.[11]

The philosophical difference is again visible. The old example teaches the packet. The new API teaches the relationship.

7. USB changed from “write a minidriver” to “prove that you need one”

The 1999 material on USB is not superficial. It starts below the driver model with NRZI encoding, bit stuffing, token packets, DATA0/DATA1 toggles, frames, transactions, descriptors, hubs, host-controller structures and error recovery. It then climbs through WDM PnP and power IRPs, INF selection rules, URBs and the USB driver stack.

That depth made sense in 1999. USB itself was new. Windows 98 compatibility mattered. A vendor with a custom device often expected to ship a custom kernel minidriver because the generic paths were limited and the framework ecosystem did not yet exist.

Current guidance starts from a different question: can Windows already drive this device?

For standard USB classes, an inbox class driver may remove the need for vendor kernel code. For a custom device used by a companion application, Microsoft calls WinUSB a simpler alternative and identifies it as the preferred approach for examples such as an electronic weather station, diagnostic communication or firmware flashing.[12] A device can even advertise compatible identifiers that allow Windows to load Winusb.sys automatically without a vendor-specific function driver—and in some configurations without distributing a custom INF.[13]

A modern application can open the device interface and communicate through WinUSB from user mode:

// Modern user mode: talk to a custom USB device through winusb.sys.
HANDLE deviceHandle = CreateFileW(
    devicePath,
    GENERIC_READ | GENERIC_WRITE,
    FILE_SHARE_READ | FILE_SHARE_WRITE,
    NULL,
    OPEN_EXISTING,
    FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OVERLAPPED,
    NULL);

WINUSB_INTERFACE_HANDLE usbHandle;
if (WinUsb_Initialize(deviceHandle, &usbHandle)) {
    ULONG transferred = 0;
    WinUsb_WritePipe(
        usbHandle,
        bulkOutPipeId,
        buffer,
        bufferLength,
        &transferred,
        NULL);
}

This reduces the amount of privileged code, the signing burden, the attack surface and the number of OS-version-specific behaviors a vendor must maintain. When custom policy is necessary but hard real-time or kernel-only access is not, UMDF 2 may keep the driver process outside the kernel. KMDF remains available when the hardware or stack requires kernel execution.

What was improved is obvious: fewer blue screens, easier deployment and a smaller trusted computing base. What was lost is subtler. The 1999 USB student could explain a request all the way from an application, through an IRP and URB, into host-controller scheduling and packets on the wire. The modern application developer may see only WinUsb_WritePipe. The system is easier to use because the journey became somebody else’s responsibility.

The counter-intuitive result — the best modern Windows USB driver can be no new driver at all. For many custom devices, the supported design is a user-mode application over Microsoft’s generic winusb.sys, not a vendor kernel module.[12]

8. Debugging moved from a second PC and a cable to a programmable data model

The 1999 debugging material assumes a world of checked builds, symbol files, map files, serial connections, i386kd, early WinDbg, PoolMon, Dr. Watson, SoftICE and deliberate bugchecks. It teaches students to read a blue screen, inspect control space, examine I/O ports, walk structures, diagnose pool corruption and write a debugger extension DLL.

One extension adds commands named encrypt and decrypt to WinDbg. It parses an address and length, reads memory from the target, transforms the bytes and writes them back:

// 1999 WinDbg extension: read and modify arbitrary target memory.
DECLARE_API(encrypt)
{
    DWORD address = GetExpression(args);
    DWORD size = GetExpression(strchr(args, ' '));
    PVOID block = malloc(size);

    if (ReadMemory(address, block, size, &bytesRead)) {
        Encrypt(key, size, block, block);
        WriteMemory(address, block, size, &bytesWritten);
    }

    free(block);
}

This is a remarkable design choice for teaching. The student is not merely using a debugger; the student is extending it and treating a stopped kernel as a programmable database. That idea survived better than almost anything else in the 1999 material.

Current WinDbg still supports native extensions, but it also exposes an extensible debugger data model available from C++, JavaScript, NatVis and the dx expression evaluator.[14] JavaScript scripts can add concepts to debugger objects, traverse processes and devices, and publish reusable commands. Recent WinDbg releases can surface extension views directly in the UI. The 1999 extension was procedural—read these bytes, print this structure. The modern data model can attach semantic views to objects and let multiple extensions compose them.

The transport changed as dramatically as the extension model. The old default mental image is two physical machines connected by a serial cable. Current Microsoft guidance recommends network kernel debugging through KDNET and provides tooling to configure it automatically.[15] USB 3 and Ethernet-emulation transports are available for targets where ordinary network debugging is unsuitable. On high-end servers, Microsoft even documents a two-physical-function KDNET design in which one PCIe function carries normal networking and another is reserved for the debugger, avoiding performance interference between the two paths.[16]

Modern diagnostics also became intentionally adversarial. Driver Verifier monitors drivers in real time and deliberately detects illegal calls and destabilizing behavior before corruption silently propagates.[17] As of July 2026, Microsoft’s current WHCP guidance requires CodeQL analysis for driver submissions, including the mustrun.qls checks and a Driver Verification Log containing the results.[18]

The older workflow often began after corruption had occurred: reproduce the crash, stop the machine and reconstruct the failure from the dump. The modern workflow also tries to provoke the mistake in a laboratory, identify it in source and prevent the binary from reaching customers.

Logging: from DbgPrint to events that can survive production

The 1999 examples use DbgPrint extensively and also teach the classic error-log packet mechanism with message compiler resources and Event Viewer registration. Modern drivers can still use debug prints, but production instrumentation commonly uses WPP or structured ETW events. WPP is a build-time instrumentation layer that emits through ETW and is well suited to efficient printf-style diagnostic tracing; manifested or TraceLogging ETW is better when stable, structured events must be consumed by operational tools. Microsoft’s framework sample initializes WPP after creating the WDF driver object so cleanup follows object lifetime.[4][24]

// Modern WPP-style trace point.
TraceEvents(
    TRACE_LEVEL_ERROR,
    TRACE_IOCTL,
    "IOCTL %!ULONG! failed: %!STATUS!",
    IoControlCode,
    status);

This is less immediate than DbgPrint("failed\n"), but far more useful across a fleet. Trace sessions can be enabled by provider, level and keyword, correlated with other ETW activity, collected without an attached debugger and decoded using generated metadata.

What debugging lost

SoftICE famously let a developer break into the running system on a single machine and inspect it from inside. That experience was dangerously powerful and often incompatible with the security assumptions of modern x64 Windows. Current debugging usually needs an explicitly configured target, boot settings, signing arrangements and a host connection. The friction is intentional: a mechanism that can stop the kernel, read physical memory and rewrite execution state is also a security boundary.

Yet something real disappeared. In the older environment, the debugger felt like a front panel on the computer. Modern tooling is richer, more automatable and more scalable, but the machine is less willing to reveal itself casually.

A continuity worth noticing — WinDbg extensions were already teachable in 1999. The material does not treat debugger extensibility as an expert afterthought; students build commands that read and rewrite target memory. Modern WinDbg’s JavaScript and data-model extensions are a direct continuation of that culture.

9. The kernel stopped trusting its guests

The deepest difference between NT 4 and modern Windows is not KMDF. It is the assumption behind loading code into ring 0.

An NT 4 driver could be installed as a service, loaded early, call undocumented functions, access I/O ports and rely on internal behavior with relatively little resistance from the platform. Once loaded, the driver was effectively part of the operating system. NT had access checks, a Security Reference Monitor and object security, but those mechanisms mainly governed user-mode access to kernel-managed resources. They were not designed to defend the kernel from an already admitted kernel component.

Modern Windows has spent years breaking that assumption into smaller, enforceable claims.

9.1 A driver must establish provenance before it runs

On current x64 Windows, kernel-mode software must be digitally signed. Starting with Windows 10 version 1607, newly published kernel drivers generally need a Microsoft signature obtained through the Hardware Dev Center process.[19] Development remains possible through test signing and controlled preproduction paths, but “compile a .sys, copy it to the machine and load it” is no longer the normal production story.

A signature does not prove that a driver is correct. It provides publisher identity and a revocation/distribution control point. This matters because a vulnerable but legitimately signed driver can be more useful to an attacker than an unsigned rootkit: the trusted signature opens the kernel door, and the vulnerable IOCTL supplies arbitrary memory access or privileged hardware control.

9.2 A driver may no longer rewrite the host it joined

On x64 systems, Windows protects kernel code and selected critical structures from modification. Microsoft’s driver restrictions explicitly prohibit runtime modification of kernel code and other operations that trigger the CRITICAL_STRUCTURE_CORRUPTION bugcheck.[20] This is the public face of Kernel Patch Protection, commonly called PatchGuard.

The practical target is not only malware. Historically, legitimate security, monitoring and compatibility products patched kernel tables or inserted hooks into undocumented paths. Those products could work brilliantly on one build and destabilize the next. By making such techniques unsupported and actively detected, Windows forced extensions toward documented callbacks, filters and provider interfaces.

The cost is real. There are classes of research, instrumentation and compatibility work that are harder or impossible without hypervisor assistance. The benefit is also real: one vendor can no longer assume ownership of shared kernel internals and silently corrupt another vendor’s assumptions.

9.3 Code integrity moved outside the ordinary kernel trust domain

Memory Integrity, also called Hypervisor-protected Code Integrity (HVCI), uses virtualization-based security to isolate code-integrity decisions. Microsoft describes an important invariant: kernel memory can become executable only after code-integrity validation, and executable kernel pages themselves are never writable.[21]

This is a profound reversal of the 1999 laboratory atmosphere. The old driver is free to treat the machine as a mutable environment. HVCI tries to make kernel code a verified artifact whose memory properties cannot be casually changed even by other kernel code.

For driver authors, this affects more than intentional patching. Old techniques that allocate executable pool, generate code dynamically, map sections with incompatible permissions or modify code after loading can fail on protected systems. Driver Verifier includes code-integrity checks specifically to find incompatible patterns.[22]

9.4 The trusted computing base became layered

Virtualization also changed where “the bottom” is. In the original NT diagram, applications and environment subsystems sit above native services, the executive, kernel and HAL. The HAL abstracts hardware differences, but the NT kernel remains the privileged foundation.

Modern Windows can place a hypervisor below the ordinary kernel and use isolated virtual trust levels for security functions. The kernel is still privileged relative to applications, but it is no longer necessarily the final authority over every security decision. This is one of the most important architectural changes since NT 4, and it happened without replacing the familiar driver object/IRP model above it.

Historical subsystem-to-native-services diagram
Historical subsystem-to-native-services diagram

The result is a system with two personalities:

  • Architecturally conservative above: IRPs, device stacks, object references and status codes persist for decades.
  • Defensively radical below and around them: signing, secure boot, hypervisor isolation, code-integrity policy, protected structures and certification constrain what admitted drivers may do.

9.5 Static analysis became part of permission to ship

The 1999 debugging material deliberately contains allocation failures, leaks, races, invalid frees and bugchecks so students can learn to diagnose them. Modern development tries to detect the same classes before runtime. CodeQL analyses driver source semantically; current WHCP documentation defines required query suites and integrates results into certification.[18]

That requirement has an interesting implication. A Windows driver is no longer judged only by whether it builds, loads and passes functional tests. The source must also survive machine-checkable security reasoning. The operating system vendor is effectively pushing part of its kernel threat model into the driver vendor’s development pipeline.

The largest trust-model change — the hypervisor can protect the kernel from kernel code. With HVCI, code-integrity decisions run in an isolated environment, and executable kernel pages cannot be writable. Ring 0 is still powerful, but it is no longer the only ring that matters.[21]

10. The original NT dream that quietly disappeared

The 1999 material presents Windows NT as a native core supporting multiple environment subsystems: Win32, POSIX, OS/2, VDM and WOW. Applications link to client-side libraries and communicate with protected subsystem servers, which ultimately invoke native system services.

This was more than backward compatibility. It reflected an architectural ambition: NT would be the neutral operating-system substrate, while user-visible personalities could vary above it. Win32 won decisively. The original POSIX subsystem was intentionally minimal and lacked features such as networking; the OS/2 personality was tied to 16-bit applications. The original POSIX subsystem was no longer present in Windows XP and Windows Server 2003.[26] Both became historical branches rather than equal futures.

Windows eventually returned to Unix compatibility through the Windows Subsystem for Linux. WSL 1 used a translation layer for Linux system calls, which resembled the old subsystem idea in spirit. WSL 2 changed direction and runs a real Linux kernel in a managed lightweight virtual machine.[25] The universal substrate is no longer necessarily the NT native API. It is increasingly the hypervisor and the integration layer around multiple kernels.

That shift mirrors the driver story. NT 4 tried to expose one coherent architecture and teach developers how every layer fit. Modern Windows more often solves incompatibility by placing a managed boundary around it: WDF around WDM, WinUSB around device-specific USB transport, Filter Manager around filesystem interception, VBS around code integrity, and virtualization around a Linux kernel.

The pattern is not “abstraction for its own sake.” It is containment. A component is allowed to be complex, different or even untrusted, but the platform tries to narrow the interface through which it can affect everything else.

What was lost is the elegance of the single-kernel dream. What was gained is the ability to run software ecosystems whose assumptions cannot realistically be translated into one native model.

11. The bargain Windows made

Nostalgia is easy when old code is read on a safe machine twenty-seven years later. It is harder when the same design must sleep and resume across unpredictable firmware, share devices with virtualization, accept requests from hostile processes and survive on millions of customer systems.

Modern Windows made a rational trade: it moved recurring correctness obligations into frameworks and platform policy, then restricted the undocumented freedoms that made one vendor’s clever extension another vendor’s crash. The result can be summarized without pretending that either side of the bargain is free.

What users and vendors gained What engineers lost
Framework-managed PnP, power and object lifetime Direct visibility of the state machine
Standard queues, cancellation and ownership rules Educational exposure to the races those rules prevent
Signing, HVCI, safer allocation defaults and source analysis Friction for legitimate experimentation and low-level instrumentation
WinUSB, class drivers and user-mode frameworks Fewer reasons to understand the complete request path
Repeatable diagnostics and certification artifacts Some of the immediacy of direct kernel exploration

The gain is not only fewer lines of code. A sequential WDF queue states a serialization policy instead of asking each driver to invent locks, cancellation rules and teardown ordering. A device interface identifies an instance instead of reserving a global alias. A generic USB stack removes an entire vendor binary from the trusted computing base. ExAllocatePool2 turns zeroing and tagging into the default rather than a convention.

The loss is not simply nostalgia. A developer can become productive with KMDF while remaining uncertain about IRP propagation, MDLs, process context or the Object Manager namespace. That gap appears when a project needs an escape hatch, interacts with a legacy WDM stack or fails below the framework level. The best modern engineers therefore need two views at once: the supported framework path for implementation and the NT machinery for diagnosis.

The same is true of experimentation. Test signing, Secure Boot, HVCI and a separate target machine are sensible barriers around ring-0 code. They also make a seven-line hardware experiment far less spontaneous. Unsupported hooks were replaced by narrower documented extension points—minifilters, WFP, ETW, framework callbacks, debugger data models and virtualization interfaces. Freedom did not disappear; it became permissioned and domain-specific.

The trade in one sentence: NT 4 forced a developer to understand the operating system before becoming productive. Modern Windows lets a developer become productive earlier, but may reveal the operating system only when something goes badly wrong.

12. What modern driver training should recover

The answer is not to revive port polling, raw user pointers or undocumented hooks. It is to restore the habit of tracing a request through every boundary beneath the approved API.

A modern engineer should first build the supported path—a KMDF or UMDF device, a device interface, a versioned IOCTL, WDF queues and production tracing—then use WinDbg to reveal the DEVICE_OBJECT, underlying IRP, file object, MDL and I/O stack location beneath those framework handles. Deliberate fault injection should follow: malformed lengths, WOW64 layouts, cancellation, surprise removal, low-memory paths and Driver Verifier.

The important questions have not changed. Where did this pointer come from? Which process owns the address? At what IRQL does the callback run? Who owns the request now? What happens if removal begins? Which object keeps the target alive? Which memory may become executable? Which component is trusted to make that decision?

Those questions matter more in modern Windows, not less. The system has more layers, and each layer can make an unsafe assumption look respectable until the request reaches the kernel.

Conclusion: a better fortress can still teach us how it is built

Windows NT 4 was not safer than modern Windows. Its driver environment was easier to crash, easier to subvert and more likely to depend on a particular machine or undocumented implementation detail. Many historical patterns shown here should never enter a current production driver.

But NT 4 was unusually explicit about what a driver was doing. A request was an IRP. A device was an object. A name lived in a namespace. A wait involved a dispatcher object. A thread had a reference count. A user pointer was dangerous because it belonged to another address space. The educational path followed the architecture.

Modern Windows preserved much of that architecture while reducing how often developers must touch it directly. WDF owns common state machines. WinUSB can remove custom kernel code. Device interfaces replace improvised global names. ExAllocatePool2 zeroes by default. Driver Verifier and CodeQL move failures earlier. PatchGuard and HVCI enforce boundaries that NT 4 mostly trusted vendors to respect.

The result is unquestionably better for users. It is also easier for developers to mistake the framework for the system.

Beneath the signing portal, the device-interface GUID, the WDF queue and the hypervisor-protected code-integrity policy, the old NT grammar is still speaking. The words survived. The operating system simply became far less willing to believe that everyone who speaks them is a friend.

References and source notes

[1] Microsoft, “Porting I/O Dispatch Routines to I/O Event Callback Functions.” A WDFREQUEST encapsulates the underlying WDM IRP.

[2] Microsoft, “Differences Between WDM and WDF.”

[3] Microsoft, “Writing WDM Drivers.”

[4] Microsoft Windows Driver Samples, general/ioctl/kmdf/sys/nonpnp.c.

[5] Microsoft, “Named Device Objects” and IoCreateDeviceSecure.

[6] Microsoft, “Using Device Interfaces (WDF).”

[7] Microsoft, “Using Neither Buffered Nor Direct I/O” and “Errors in Referencing User-Space Addresses.”

[8] Microsoft, “Defining I/O Control Codes.”

[9] Microsoft, “Driver Security Checklist.”

[10] Microsoft, ExAllocatePool2 and “Updating Deprecated ExAllocatePool Calls.”

[11] Microsoft, WdfIoTargetSendIoctlSynchronously.

[12] Microsoft, “Choosing a driver model for developing a USB client driver.”

[13] Microsoft, “WinUSB Device” and “Introduction to WinUSB for Developers.”

[14] Microsoft, “Debugger Data Model C++ Interfaces Overview” and “JavaScript Debugger Scripting.”

[15] Microsoft, “Setting Up Kernel-Mode Debugging.”

[16] Microsoft, “Setting Up 2PF Kernel-Mode Debugging Using KDNET.”

[17] Microsoft, “How to Use Driver Verifier for Driver Testing.”

[18] Microsoft, “CodeQL Queries and Suites for Windows Driver Testing” and “Run CodeQL Analysis for Windows Driver Certification.”

[19] Microsoft, “Driver Signing Policy” and “Windows Driver Signing Tutorial.”

[20] Microsoft, “Driver x64 Restrictions.”

[21] Microsoft, “Driver compatibility with memory integrity and VBS.”

[22] Microsoft, “Code Integrity Checks.”

[23] Microsoft, “Porting I/O Dispatch Routines to I/O Event Callback Functions.” For buffered IOCTLs, the input and output retrieval methods return the same system buffer.

[24] Microsoft, “WPP Software Tracing.” WPP is an instrumentation and preprocessing model that emits trace events through ETW.

[25] Microsoft, “Comparing WSL Versions.”

[26] Microsoft, Security Bulletin MS04-020. Notes that the original POSIX subsystem was absent from Windows XP and Windows Server 2003.

Architecture reconstruction, IOCTL and interface review, dump analysis, and runtime tracing for Windows drivers whose design knowledge has been lost.

Need to understand an unknown or undocumented driver?

KernelSoft analyzes Windows driver architecture, interfaces, crash dumps, and runtime behavior—including projects whose current documentation no longer exists.

Discuss the problem