Building the Driver Object

Rust Windows driver tutorial, creating a Windows Driver Object


Driver Object

Before we start, if you like my content please be sure to give me a follow on GitHub and give my Windows driver a star! <3

In my last post, we looked at the function prototype for DriverEntry, as a reminder:

NTSTATUS DriverEntry(
  _In_ PDRIVER_OBJECT  DriverObject,
  _In_ PUNICODE_STRING RegistryPath
);

In this post, we will look at the DRIVER_OBJECT and how we configure this in Rust.

A DRIVER_OBJECT is a structure which is passed into our driver for initialisation. With this object we want to indicate what operations our driver supports. For example, in the last post we looked at DriverUnload, this is one of such dispatch routines that the DRIVER_OBJECT supports. These are an array of function pointers, which are stored within the object under the field MajorFunction. Dispatch routines are required for handling I/O request packets (IRPs) that the operating system sends to the driver. Each routine processes a specific type of IRP, such as create, read, write, or device control requests.

You can find a list of major function codes on MSDN here, but to give an example of a few:

  1. IRP_MJ_DEVICE_CONTROL - A driver receives this I/O control code because user-mode thread has called the Microsoft Win32 DeviceIoControl function, or a higher-level kernel-mode driver has set up the request.
  2. IRP_MJ_CREATE - The operating system sends an IRP_MJ_CREATE request to open a handle to a file object or device object. For example, when a driver calls ZwCreateFile, the operating system sends an IRP_MJ_CREATE request to perform the actual open operation.

As per the last tutorial, we cannot yet communicate with the driver, but this is something we will look at later. These major functions, as you might anticipate, play a key role in communication between userland and kernel mode. In fact, we dont communicate with the driver, instead we communicate with a DEVICE_OBJECT.

Device object

A DEVICE_OBJECT is essential in a driver to enable communication with our user-mode application, and tt should be given a name to facilitate this interaction.

The CreateFile function (in usermode) as its first argument, accepts a file name. In user mode, the CreateFile function is commonly used to open handles to various types of objects, not just files as the name may imply. This function can accept a symbolic link, which is a kernel object that points to another kernel object. Symbolic links accessible from user mode through the CreateFile function are typically located in the Object Manager directory named \??. Actually, \?? is an alias for the \DosDevices directory.

Most symbolic links in the \?? directory point to a device name within the \Device directory, and that directory is not directly accessible by a usermode application.

In order for us to access the symbolic link (in the \?? directory), we prepend the name (in user mode) with \\.\.

Back to the DEVICE_OBJECT

We create the DEVICE_OBJECT with the function IoCreateDevice which creates a device object for use by a driver.

To initialise the object, we need a few things:

  1. A pointer do our DRIVER_OBJECT
  2. The device name - as mentioned this is our internal name under the \Device directory
  3. A place to store our returned pointer to the DEVICE_OBJECT

Strings are a topic we will cover another day; but the strings that the kernel generally likes to work with are a type called UNICODE_STRING. Whilst RtlInitUnicodeStringEx exists in the Rust wdk, I have implemented my own (perhaps slightly more idiomatic?) way of creating unicode strings, which you are free to use:

pub trait ToUnicodeString {
    fn to_u16_vec(&self) -> Vec<u16>;
}

impl ToUnicodeString for &str {
    fn to_u16_vec(&self) -> Vec<u16> {
        // reserve space for null terminator
        let mut buf = Vec::with_capacity(self.len() + 1);

        // iterate over each char and push the UTF-16 to the buf
        for c in self.chars() {
            let mut c_buf = [0; 2];
            let encoded = c.encode_utf16(&mut c_buf);
            buf.extend_from_slice(encoded);
        }

        buf.push(0); // add null terminator
        buf
    }
}

pub trait ToWindowsUnicodeString {
    fn to_windows_unicode_string(&self) -> Option<UNICODE_STRING>;
}

impl ToWindowsUnicodeString for Vec<u16> {
    fn to_windows_unicode_string(&self) -> Option<UNICODE_STRING> {
        create_unicode_string(self)
    }
}

And we can use this like so to create a valid unicode string (note we are declaring both together, you will see why soon):

let mut dos_name = "\\??\\SanctumEDR"
        .to_u16_vec()
        .to_windows_unicode_string()
        .expect("[sanctum] [-] unable to encode string to unicode.");

let mut nt_name = "\\Device\\SanctumEDR"
    .to_u16_vec()
    .to_windows_unicode_string()
    .expect("[sanctum] [-] unable to encode string to unicode.");

And now, we can call IoCreateDevice:

pub unsafe extern "C" fn configure_driver(
    driver: *mut DRIVER_OBJECT,
    _registry_path: PUNICODE_STRING,
) -> NTSTATUS {

    // ...

    let mut device_object: PDEVICE_OBJECT = null_mut();
        
    let res = IoCreateDevice(
        driver,
        0,
        &mut nt_name,
        FILE_DEVICE_UNKNOWN, // If a type of hardware does not match any of the defined types, specify a value of either FILE_DEVICE_UNKNOWN
        FILE_DEVICE_SECURE_OPEN,
        0,
        &mut device_object,
    );
    if !nt_success(res) {
        println!("[sanctum] [-] Unable to create device via IoCreateDevice. Failed with code: {res}.");
        return res;
    }
    // ...

    STATUS_SUCCESS // return value
}

If all went well, you now have a pointer to the device object!

Note: assigning a name to the device object is optional; unnamed device objects can be used when the driver does not need to expose the device to usermode applications.

The next step is to configure the symbolic link so we can access the object from usermode.

This is as simple as:

let res = IoCreateSymbolicLink(&mut dos_name, &mut nt_name);
if res != 0 {
    println!("[sanctum] [-] Failed to create driver symbolic link. Error: {res}");

    driver_exit(driver); // cleanup any resources before returning
    return STATUS_UNSUCCESSFUL;
}

Managing memory

Whilst this section is probably worth its own post - one thing I want to touch on is how important it is to manage memory correctly when writing drivers.

Memory in the kernel is one large continuous virtual address space - my driver shares the same memory as an Nvidia driver, the same memory as my wifi driver, and the same memory as the kernel itself. This means I can access memory set by the kernel, view it, change it, delete it - this is why kernel drivers have to be trusted in order to load. Lots of bad things can happen when writing drivers. In contrast - in usermode each application has its own virtual memory space - if you want to access the virtual memory of another process, you have to ask the kernel for permission. Each process is neatly packed in its own little box.

Therefore, the following bad things can happen in kernelmode compared to usermode:

Oopsie Usermode Kernelmode
Dereference a null pointer The kernel catches this and the application crashes The system will crash, resulting in a blue screen
Memory Cleanup The memory automatically gets cleaned up when the process ends The memory remains allocated, and as such causes a memory leak
Access an address outside of your process Not allowed by default Allowed by default

So, you’ll notice I make a call to driver_exit() if the symbolic link wasn’t create correctly - we need to do this if the state of our driver becomes unstable, or something does not initialise as expected. If you return an invalid status code and do not handle driver cleanup, the driver will not automatically call the unload routine. In fact, I should move this to a higher level than within my configure_driver function, but I am leaving it there for now for demonstration purposes. Within the unload function we should have:

extern "C" fn driver_exit(driver: *mut DRIVER_OBJECT) {

    // rm symbolic link
    let mut device_name = DOS_DEVICE_NAME
        .to_u16_vec()
        .to_windows_unicode_string()
        .expect("[sanctum] [-] unable to encode string to unicode.");
    let _ = unsafe { IoDeleteSymbolicLink(&mut device_name) };

    // delete the device
    unsafe { IoDeleteDevice((*driver).DeviceObject);}

    println!("[sanctum] driver unloaded successfully...");
}

So, why doesn’t a drivers memory get cleaned up on exit? Well, the reason is quite simple - the kernel doesn’t know whether you have left an object in memory on purpose - given that kernel memory is one large interconnected blob, drivers may leave objects at one address, so another driver (either just after, or minutes / hours) in the future can access that object.

Basically - anything you allocate, make sure to deallocate!

Rust is of course an excellent candidate for writing Windows Drivers thanks to it’s ownership model. Whilst this can help manage memory leaks and pointers, there are cases where you still need to do these things manually.

Memory best practices

  1. Always validate pointers before dereferencing them to prevent null pointer dereferences.
  2. Ensure that every allocation has a corresponding deallocation to maintain memory integrity.
  3. Use synchronisation mechanisms to manage concurrent access to shared resources, preventing race conditions and ensuring data consistency.

Next steps

That’s it for this post, be sure to check out my driver project on GitHub, and try implementing the above yourself!

Next time, we will look at starting and stopping the driver from usermode now we have it configured!