If you’ve been following along with this series, in the last post I walked through how to build a minimum viable driver and how to load it into Windows.

That said, the previous driver simply loaded and unloaded itself, nothing more. In this post we’re going to go over how to send messages to our driver and have it respond.

The IRP

As I explained in part 2, a driver in Windows is a small piece of software that sits in kernel space and responds to requests.

These requests come in the form of a struct called an IO Request Packet, or more commonly an IRP.

A single IRP contains all of the information a driver needs to handle an IO request. Any return values from the driver are also written to the IRP, and are returned to the caller later.

The IRP is always allocated along with one or more IO Stack Locations. An IO Stack Location is another struct that contains information about the request. The IO Manager creates one IO Stack Location for each driver in the device node’s driver stack.

For example, if the devnode’s driver stack consists of four drivers:

Example devnode

The IRP would have 4 IO stack locations, like in the picture below. The IO Stack Location for the function driver is the second one from the bottom, since the function driver is second from the bottom.

IRP and irpSP

IRPs and IO Stack Locations are a detailed topic I’d recommend learning in more detail than I will provide here. For a great writeup on them see chapter 7 of Pavel Yosifovich’s Windows Kernel Programming.

Function codes

Each IO Stack Location struct has a major function code field. The major function code is an enum that indicates what kind of functionality the IRP is requesting from the driver.

Some common values are:

  • IRP_MJ_CREATE (0)
  • IRP_MJ_CLOSE (2)
  • IRP_MJ_DEVICE_CONTROL (14)

Dispatch functions

Every driver is associated with a _DRIVER_OBJECT struct when it is loaded. The _DRIVER_OBJECT struct contains information about how the driver behaves. This struct is passed into DriverEntry when the function is loaded, and DriverEntry modifies it to fit the developer’s needs.

For more information on this, see part 3 of this series.

The final field in _DRIVER_OBJECT is an array of function pointers called MajorFunction. Each of these functions has a PDRIVER_DISPATCH signature, and knows how to handle IRPs for a specific major function code. A PDRIVER_DISPATCH function signature looks like this:

NSTATUS DispatchFunction(_In_ PDEVICE_OBJECT DeviceObject, _Inout_ PIRP Irp) 

To tell the kernel which dispatch function corresponds to which major function code, we set the index in the array corresponding to the major function code to a pointer to the function we want to use.

For example, if I made a function called MyCustomDeviceControlHandler and wanted to use it to hande any device control operations sent to my driver, I would have the following line in DriverEntry.

DriverObject->MajorFunction[14] = MyCustomDeviceControlHandler;

It’s preferable to use the enum for clarity though, so the line would actually be the equivalent:

DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = MyCustomDeviceControlHandler;

Each time an IRP comes in with a major function code the dispatch function is registered to, the dispatch function is called with the IRP passed in as an argument.

Handling an IRP

So how do we “handle an IRP” in a dispatch function?

The most common operation a function driver will perform on an IRP is completing it. Completing an IRP means that the function driver has completed its operation, and the IRP is ready to propagate back to the user. In user space terms, this is “return IRP”, although the actual process is a bit more complicated here.

To complete the IRP, we must set the IRP’s IoStatus field correctly. The IoStatus field is a struct of type _IO_STATUS_BLOCK, which has two members:

  • Status - The NTSTATUS code the IRP will complete with
  • Information - A generic pointer which can mean different things depending on how the developer wants to use it.

Setting these fields in the dispatch handler function looks like this:

Irp->IoStatus.Status = STATUS_SUCCESS;
Irp->IoStatus.Information = 0;

In this case, we’re just setting the IRP to complete with a successful status and returning no information.

Once this is done, we need to call the IoCompleteRequest function on the IRP. This function actually completes the request, and sends the IRP back up to whomever created it. You can think of it as “return IRP”, even though it doesn’t end the function.

IoCompleteRequest(Irp, IO_NO_INCREMENT);

The second field in IoCompleteRequest is an optional temporary priority boost for the thread handling the IRP. It should be set to IO_NO_INCREMENT, which doesn’t boost the thread, unless you have a convincing reason to do otherwise.

So finally our dispatch function that will complete an IRP with a success status looks like the following:

NSTATUS DispatchFunction(_In_ PDEVICE_OBJECT DeviceObject, _Inout_ PIRP Irp) {
    UNREFERENCED_PARAMETER(DeviceObject);

    Irp->IoStatus.Status = STATUS_SUCCESS;
    Irp->IoStatus.Information = 0;

    IoCompleteRequest(Irp, IO_NO_INCREMENT);

    return STATUS_SUCCESS;
}

As a small side note, we have the first of the many footguns here. The dispatch function must complete with the same NTSTATUS as is set in the IRP. As such, you might be tempted to do the following:

Irp->IoStatus.Status = STATUS_SUCCESS;
IoCompleteRequest(Irp, IO_NO_INCREMENT);

return Irp->IoStatus.Status;

This is undefined behavior, as the IRP may be freed by IoCompleteRequest. The Irp->IoStatus pointer may be pointing to uninitialized memory.

Objects and Handles

We now have a driver that can receive an IRP and complete it. But how can we actually send an IRP to the driver?

Windows represents all system resources through an data structure called an object. An object can be a file, thread, graphical image, or even a physical device. All the object does is provide a consistent interface to access the resource.

Apps can’t directly interface with objects. Instead, they must obtain a handle to the object, which is simply a pointer that can be used to examine or modify the object.

The function used to open a handle to an object is the somewhat incongruously named CreateFile function. CreateFile doesn’t create a file by default. Instead, it looks for an object with the file name given and returns a handle to it if possible. It’s only if the object doesn’t exist that it will fall back to making a new file.

The Device Object

The Windows IO model is device centric. This means we can only obtain handles to Device Objects, not our Driver Object.

As such, we need to create a Device Object so users can obtain a handle to our driver. This is done during DriverEntry with the IoCreateDevice function. It’s definition is as follows:

NTSTATUS IoCreateDevice(
  [in]           PDRIVER_OBJECT  DriverObject,
  [in]           ULONG           DeviceExtensionSize,
  [in, optional] PUNICODE_STRING DeviceName,
  [in]           DEVICE_TYPE     DeviceType,
  [in]           ULONG           DeviceCharacteristics,
  [in]           BOOLEAN         Exclusive,
  [out]          PDEVICE_OBJECT  *DeviceObject
);

The arguments are:

  • DriverObject - the driver object the device belongs to (our driver object)
  • DeviceExtensionSize - if we should allocate any extra bytes to the Device Object
  • DeviceName - the name of the device object to be created
  • DeviceType - useful for hardware devices, for software drivers should be set to FILE_DEVICE_UNKNOWN
  • DeviceCharacteristics - flag field only relevant in some cases
  • Exclusive - should we prevent multiple handles being opened at once
  • DeviceObject - the device object (return value)

We’ll need to initialize a Unicode String for the device name, which can be done with RtlInitUnicodeString.

Putting it all together, the code for creating the Driver Object looks like this:

UNICODE_STRING devName;
RtlInitUnicodeString(&devName, L"\\Device\\MyDriver");

PDEVICE_OBJECT DeviceObject;
NTSTATUS status = IoCreateDevice(
    DriverObject,           // Driver object from earlier in DriverEntry
    0,                      // no extra bytes
    &devName,               // device name
    FILE_DEVICE_UNKNOWN,    // software device
    0,                      // no characteristics flags needed
    FALSE,                  // no need for exclusive access
    &DeviceObject           // return pointer
);

if (!NT_SUCCESS(status)) {
    KdPrint(("Failed to create device object (0x%08X)\n", status));
    return status;
}

There are two things of note here that I haven’t mentioned before:

  • The NT_SUCCESS macro simply returns false if any NTSTATUS error code is present
  • The KdPrint macro allows for debug printing within the driver. Note the double parenthesis since this is a macro, not a function. Needed for variable argument printing. We’ll go over how to view this output later.

By returning the status code if it is a failure, the driver won’t load if the IoCreateDevice function fails to create the device object, which is what we want.

There is one more step we need to take. Userland programs cannot access the \\Devices directory, so we’ll need to make a symbolic link they can access.

This is done with the IoCreateSymbolicLink function, which only takes two arguments:

NTSTATUS IoCreateSymbolicLink(
  [in] PUNICODE_STRING SymbolicLinkName,
  [in] PUNICODE_STRING DeviceName
);

Simple enough. Let’s add the code to DriverEntry:

UNICODE_STRING symLinkName;
RtlInitUnicodeString(&symLinkName, L"\\??\\MyDriver");

status = IoCreateSymbolicLink(&symLinkName, &devName);
if (!NT_SUCCESS(status)) {
    KdPrint(("Failed to create symbolic link (0x%08X)\n", status));
    IoDeleteDevice(DeviceObject);
    return status;
}

None of this should be unfamiliar by this point, but there is a design point I want to stress that is different for developers coming from user space. If you allocate something in the kernel, you must deallocate it.

The device object we made is allocated directly in the kernel - the driver doesn’t have its own “virtual space” that is automatically cleaned up after the process ends. If something is allocated in the kernel, it stays there until the next reboot of the computer. Memory leaks in kernel space don’t get cleaned up, and can eventually BSOD the computer.

As such, if the symlink creation fails, we call IoDeleteDevice and clean up the device object we created earlier before exiting. We’ll also need to update the Unload function, which now has some cleanup work to do.

void MyDriverUnload(_In_ PDRIVER_OBJECT DriverObject) {
    UNICODE_STRING symLinkName;
    RtlInitUnicodeString(&symLinkName, L"\\??\\MyDriver");

    IoDeleteSymbolicLink(&symLinkName);
    IoDeleteDevice(DriverObject->DeviceObject);
}

As I mentioned in the last post, the job of the Unload function is to clean up everything allocated in DriverEntry. We allocated a symlink and a DeviceObject, so we use unload to delete those and prevent a memory leak.

Putting it all together

Combining all of the pieces of code we looked at earlier, our driver will now look like the code below:

#include <ntddk.h>


extern "C" NTSTATUS
CreateCloseHandler(_In_ PDEVICE_OBJECT DeviceObject, _Inout_ PIRP Irp) {
    UNREFERENCED_PARAMETER(DeviceObject);

    Irp->IoStatus.Status = STATUS_SUCCESS;
    Irp->IoStatus.Information = 0;

    IoCompleteRequest(Irp, IO_NO_INCREMENT);

    return STATUS_SUCCESS;
}


void MyDriverUnload(_In_ PDRIVER_OBJECT DriverObject) {
    UNICODE_STRING symLinkName;
    RtlInitUnicodeString(&symLinkName, L"\\??\\MyDriver");

    IoDeleteSymbolicLink(&symLinkName);
    IoDeleteDevice(DriverObject->DeviceObject);
}


extern "C" NTSTATUS
DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) {
    UNREFERENCED_PARAMETER(RegistryPath);

    // Create the Device Object
    UNICODE_STRING devName;
    RtlInitUnicodeString(&devName, L"\\Device\\MyDriver");

    PDEVICE_OBJECT DeviceObject;
    NTSTATUS status = IoCreateDevice(
        DriverObject,           // Driver object from earlier in DriverEntry
        0,                      // no extra bytes
        &devName,               // device name
        FILE_DEVICE_UNKNOWN,    // software device
        0,                      // no characteristics flags needed
        FALSE,                  // no need for exclusive access
        &DeviceObject           // return pointer
    );

    if (!NT_SUCCESS(status)) {
        KdPrint(("Failed to create device object (0x%08X)\n", status));
        return status;
    }

    // Create the symbolic link
    UNICODE_STRING symLinkName;
    RtlInitUnicodeString(&symLinkName, L"\\??\\MyDriver");

    status = IoCreateSymbolicLink(&symLinkName, &devName);
    if (!NT_SUCCESS(status)) {
        KdPrint(("Failed to create symbolic link (0x%08X)\n", status));
        IoDeleteDevice(DeviceObject);
        return status;
    }

    // Register handler functions
    DriverObject->MajorFunction[IRP_MJ_CREATE] = CreateCloseHandler;
    DriverObject->MajorFunction[IRP_MJ_CLOSE] = CreateCloseHandler;
    DriverObject->DriverUnload = MyDriverUnload;

    return STATUS_SUCCESS;
}

The only change I made was to rename DispatchFunction to CreateCloseHandler for more clarity.

As a note, having a dispatch function to successfully complete all Create and Close IRPs that are sent to the driver is a very common design pattern.

This is due to a small idiosyncracy of the Windows driver model - we need to be able to open and close a handle to our driver. This is done by sending Create and Close IRPs to the driver respectively. If IRP_MJ_CREATE and IRP_MJ_CLOSE aren’t handled, we won’t be able to obtain a handle even if we made the Device Object.

The most common way to do this is simply to compete the IRP as a success, which is what the above function does.

Client code

We’re finally at a point where a client can interact with our driver!

We’ll open a handle to the device with CreateFileW. The options for CreateFileW are:

HANDLE CreateFileW(
  [in]           LPCSTR                lpFileName,
  [in]           DWORD                 dwDesiredAccess,
  [in]           DWORD                 dwShareMode,
  [in, optional] LPSECURITY_ATTRIBUTES lpSecurityAttributes,
  [in]           DWORD                 dwCreationDisposition,
  [in]           DWORD                 dwFlagsAndAttributes,
  [in, optional] HANDLE                hTemplateFile
);

Yes, CreateFile is roughly equivalent to CreateFileW. The W just means use UTF-16LE, which is Windows default.

The source for the client is fairly self explanatory.

#include <windows.h>
#include <stdio.h>


int main() {
    HANDLE hDevice = CreateFileW(
        L"\\\\.\\MyDriver",             // Name of the file or device to be opened
        GENERIC_READ | GENERIC_WRITE,   // Permissions to open with
        0,                              // Whether the handle can be shared
        nullptr,                        // Additional security attributes
        OPEN_EXISTING,                  // Whether to open an existing handle or create a new file
        0,                              // Optional flags field
        nullptr                         // Optional template file for new file creation
    );

    if (hDevice == INVALID_HANDLE_VALUE) {
        printf("Failed to open device: %lu\n", GetLastError());
        return 1;
    }

    printf("Device opened successfully.\n");

    CloseHandle(hDevice);
    return 0;
}

As usual, a couple of things to look for:

  • Make sure to import windows.h
  • The symbolic link is has the ?? replaced with a .
  • The L in front of the symbolic link name just means use a wide char string

To compile the code, open the Developer Command Prompt for Visual Studio 2022 (this was installed along with Visual Studio). Once it’s open, navigate to the directory with the client code, and compile with:

cl driverClient.cpp

Testing it out

Now, compile the driver code and load the driver on to a VM. If you don’t know how to do this, please refer to the instructions in part 3 of this series.

First, run the client before starting the driver, and you’ll see:

C:\Users\jeremy\Desktop> driverClient.exe
Failed to open device: 2

Then, start the driver, and run the client again:

C:\Users\jeremy\Desktop>driverClient.exe
Device opened successfully.

Congratulations, you’ve successfully sent an IRP to your driver and it responded!

Next steps

In the next post, we’ll finally be able to add some functionality to the driver by making some more detailed dispatch functions.

The next post will be the last “development” post for those of you only interested in the VR aspect of this. After that, I’ll move on to debugging, and then to actual bugs and exploit code.

More reading

Series Index