Configuring a Rust Windows driver

Rust Windows driver tutorial, creating a basic Windows Driver in Rust


Driver Configuration

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

Hopefully you have followed my previous post and you should have a working, albeit basic and possibly slightly malfunctioning driver.

I want to spend a little more detail talking about DriverEntry, and a new concept, DriverExit.

DriverEntry

As a userland developer, you will be used to calling the main() symbol in your code, this is an important function which allows your code to start executing. Under the hood, this actually isn’t the first routine to be executed when your binary starts, the compiler includes a runtime which is initialised before your main code is run - in the cases of C and Rust (no_std) this runtime is very small. The actual entrypoint to your code is managed by the runtime, so you could in fact specify main() to be anything you like, just making sure you link correctly.

DriverEntry is the driver equivalent of main(), and the symbol is called by a system thread. MSDN is our friend when doing anything related to Windows programming, and we can find the function here.

DriverEntry can be thought of as where you initialise the driver, there’s a certain amount of things we need to set up correctly at runtime in order for the driver to run cleanly.

The function prototype for DriverEntry is:

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

So, we need to make sure our Rust Windows kernel driver starts with the DriverEntry symbol. Thanks to the work being done in the wdk crates, we can use those libraries and then define our entry point like so:

#[export_name = "DriverEntry"]
pub unsafe extern "system" fn driver_entry(
    driver: &mut DRIVER_OBJECT,
    registry_path: PCUNICODE_STRING,
) -> NTSTATUS { }

The function name driver_entry doesn’t match the expected symbol DriverEntry, but this is fine as we are making this with the export_name attribute. Thankfully, the wdk crates define our types so we don’t need to worry about implementing Foreign Function Interfaces for symbols found in the kernel (though we aren’t fully in the clear, there are a lot of functions missing in the WDK crates - so we will have to use FFI later in the project).

As an aside, I'd really like to contribute to the WDK Rust project as I'm really excited for it to mature, so hopefully through doing this project I may be able to contribute my own FFI / implementations for missing API's!

DriverUnload

Now we have covered the entry, there’s 1 other key concept we need to think about - DriverUnload. Anything we set up in the entry, we need to clean up. In our case now - we haven’t yet initialised anything.

The DriverUnload function is passed to the driver as a callback and the function itself can be named anything you like, but it needs to match a specific signature, which is:

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

The function is marked extern "C" to specify the ABI calling convention of parameters - we don’t want to risk the compiler implementing its own calling convention which could lead to a blue screen!

So, how do we call the DriverUnload functionality?

Kernel Callbacks

The way a driver works in the kernel is to register a number of callback functions which are function pointers. That is, the DRIVER_OBJECT will keep track of (via its fields) a number of addresses where functions are defined, so when the kernel is ready to execute that code, it jumps to the memory location specified at the driver object.

For functions as critical as a DriverUnload routine, the DRIVER_OBJECT has a field for a function pointer to the DriverUnload function, and this field is conveniently called DriverUnload.

There are other important callbacks, which we will explore in a later post, and go into more detail about what these are.

Driver Setup

So, lets go ahead and set up a basic driver initialising the DRIVER_OBJECT and defining the DriverUnload functionality. We will use the println!() macro, but remember this isn’t the Rust standard macro, its defined in the wdk crates.

First, lets call our driver entry and define our exit routine (don’t forget to bring types and APIs into scope..):

#[export_name = "DriverEntry"]
pub unsafe extern "system" fn driver_entry(
    driver: &mut DRIVER_OBJECT,
    registry_path: PCUNICODE_STRING,
) -> NTSTATUS {
    println!("[test_driver] [i] Hello world!");

    STATUS_SUCCESS
}

extern "C" fn driver_exit(driver: *mut DRIVER_OBJECT) {
    println!("[test_driver] [i] Driver exiting!");
}

Now, we can use the driver passed into the function to set a function pointer to our driver_exit function like so:

#[export_name = "DriverEntry"]
pub unsafe extern "system" fn driver_entry(
    driver: &mut DRIVER_OBJECT,
    registry_path: PCUNICODE_STRING,
) -> NTSTATUS {
    println!("[test_driver] [i] Hello world!");

    (*driver).DriverUnload = Some(driver_exit);

    STATUS_SUCCESS
}

If the syntax looks unusual to you, it is possibly the inclusion of a pointer dereference (*). In safe Rust we don’t have to worry about pointers much further than understanding a reference is really just a pointer. Without giving a whole lecture on pointers, as there are better places to learn about those, a pointer is basically a variable who points to another address where data is stored. So in our case, where we pass in a DRIVER_OBJECT, we aren’t actually passing in the driver copied locally onto the stack, we are passing in a reference to the DRIVER_OBJECT, note, a reference is just a pointer. So, our variable driver actually will store something like: 0xffffd00d151ece30, in fact, in my case, thats exactly what it stores!

Once dereferenced, we can modify or access fields like DriverUnload.

To prove the point, I have added the following kernel print, which you are more than welcome to do:

println!("[sanctum] [i] Variable address: {:p}, 'driver' variable content: {:p}", &driver, driver);

This prints out first the address of the driver variable, and then the content of the variable - which is the address of where the DRIVER_OBJECT structure is in memory. You can see this:

Memory address of DRIVER_OBJECT in Rust Windows driver

So, back to it - what we need to do is dereference the pointer, which means - go to the data the pointer points to, and then access the field called DriverUnload, and set its value to the address of driver_exit. If this is totally alien to you - I’d recommend taking some basic C courses to understand memory and pointers a little better. If you really want to get into systems programming, the CS50 is a great intro to C, even if you stop there! That will give you enough of a primer to switch back to Rust and understand pointers in better detail.

A final note

Before we build, make sure your INX file has the following settings to make sure you can start and stop it correctly:

[Sanctum_WDM_Service_Install]
DisplayName    = %ServiceDesc%
ServiceType    = 1               ; SERVICE_KERNEL_DRIVER
StartType      = 3               ; SERVICE_DEMAND_START
ErrorControl   = 1               ; SERVICE_ERROR_NORMAL

Building

And with that, you can run cargo make in the project root from an admin Developer Command Prompt, drag your .sys file over to your testing virtual machine (make sure its configured as per my previous guide), and run the driver whilst using DebugView and you should see the print! When you stop the driver, you should see the unload function print!

Awesome!

Next steps

In the next blog post, we will look in more detail about setting up the DRIVER_OBJECT, dealing with strings, and defining symbolic links so we can communicate with the driver from userland!