Windows Drivers in Rust: Hello World

Published on September 22, 2021

Introduction

In the previous article we went through a number of prerequisites that we need installed to start writing our kernel driver for Microsoft Windows. Now that we have all of those installed, we can start writing our first kernel driver.

Getting Started

To get started, we will create a new Rust project for our driver using the following command:

cargo new --lib driver

As you may have noticed, we asked cargo to create a library. Unfortunately, Rust does not know how to link a kernel driver by default, it just knows how to link executables, static libraries and dynamic libraries. Fortunately for us, a kernel driver is technically a dynamic link library or .dll file, so we can start out by creating a library.

Next, we add the following to the Cargo.toml file to tell Rust to actually build a dynamic library:

[lib]
crate-type = ["cdylib"]

In addition, we will tell Cargo that our project depends on the Rust Nightly toolchain. To do that we will run the following in our project's directory:

echo "nightly" > rust-toolchain

Passing Linker Options

Now we have set up an empty project that builds an actual .dll file. One problem with this is that Rust does not know that we are trying to create a kernel driver, so it will call the MSVC linker with the default options to link a .dll to be used by normal Windows applications. So we have to specify the options to actually pass to the MSVC linker. We can do this by writing the following to .cargo/config in your project:

[build]
target = "x86_64-pc-windows-msvc"

rustflags = [
        # Pre Link Args
        "-Z", "pre-link-arg=/NOLOGO",
        "-Z", "pre-link-arg=/NXCOMPAT",
        "-Z", "pre-link-arg=/NODEFAULTLIB",
        "-Z", "pre-link-arg=/SUBSYSTEM:NATIVE",
        "-Z", "pre-link-arg=/DRIVER",
        "-Z", "pre-link-arg=/DYNAMICBASE",
        "-Z", "pre-link-arg=/MANIFEST:NO",

        # Post Link Args
        "-C", "link-arg=/OPT:REF,ICF",
        "-C", "link-arg=/ENTRY:driver_entry",
        "-C", "link-arg=/MERGE:.edata=.rdata",
        "-C", "link-arg=/MERGE:.rustc=.data",
        "-C", "link-arg=/INTEGRITYCHECK",
]

Here is an explanation of the MSVC linker options that we are passing to the linker:

  • The /NOLOGO option simply supresses the startup banner containing the copyright message and the version number.
  • The /NXCOMPAT option marks the executable as compatible with Data Execution Prevention (DEP). DEP is a feature in Microsoft Windows that marks data pages as non-executable. This prevents an attacker from injecting malicious code into those data pages, that can then get executed.
  • The /NODEFAULTLIB option tells the linker to not link in any default libraries, since those won't be available when writing a kernel driver.
  • The /SUBSYSTEM:NATIVE option specifies the subsystem or the environment to use for the executable. NATIVE provides us with an environment suitable for building a kernel mode driver for Windows NT and derivates.
  • The /DRIVER option tells the linker that we are building a Windows NT (and derivates) kernel mode driver.
  • The /DYNAMICBASE option generates an executable image that can be randomly rebased at load time by using the Address Space Layout Randomization (ASLR) feature. ASLR is a feature that randomizes the virtual memory allocations, such that the locations of heap, stacks and the code are randomized, to effectively make buffer overflow attacks less trivial to exploit.
  • The MANIFEST:NO option tells the linker to not generate a Manifest file.
  • The /OPT:REF,ICF option specifies the linker optimizations to apply. /OPT:REF eliminates functions and data that are never referenced. /OPT:ICF performs identiical COMDAT (common data) folding.
  • The /ENTRY:driver_entry option tells the linker that the entry point of our driver will be the driver_entry function.
  • The /MERGE:.edata=.rdata option merges the .edata section into the .rdata section, while the /MERGE:.rustc=.data' option merges the .rustcsection into the.datasection. Executables consist of a number of sections such as.text(code),.rdata(read-only or constant data),.data(initialized data) and.bss` (uninitialized data).
  • The /INTEGRITYCHECK option specifies that the digital signature of the binary image must be checked at load time.

The Driver Entry Point

Note that we set the entry point of our driver to the driver_entry function by specifying the /ENTRY:driver_entry option. Remove any code in src/lib.rs and let's get started with that function in src/lib.rs:

#[no_mangle]
pub extern "system" fn driver_entry() -> u32 {
    0 /* NT_STATUS_SUCCESS */
}

In the code above, #[no_mangle] attribute tells the Rust compiler to not perform name mangling on the function name, such that it is simply visible as driver_entry during the linking process. "system" tells the Rust compiler that the driver_entry function should be using the "system ABI", which defaults to the "stdcall" ABI on Microsoft Windows.

Since we are writing a kernel driver, we won't have access to the Windows API that is normally available to us when writing Windows applications or libraries. Therefore, as the Rust standard library depends on the Windows API, we cannot use it from our kernel driver. Add the following line to the top of src/lib.rs to tell the Rust compiler that we don't want to use the standard library:

#![no_std]

By disabling the standard library, we also disable the panic handler that comes with it by default. Thus we have to define our own in src/lib.rs:

use core::panic::PanicInfo;

#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {}
}

By default Rust will unwind the stack whenever a panic occurs, which in our case will produce an error about Rust not being able to find eh_personality. Fortunately, we can simply change the panic behaviour to abort immediately by adding the following to Cargo.toml.

[profile.dev]
panic = "abort"

[profile.release]
panic = "abort"

Signing the Driver

Running cargo build has a number of problems: First, it produces a .dll file, but the file extension for a Windows kernel driver should be .sys, so we have to rename it. Second, in order to be able to load our driver, the binary file has to be signed. We will be using cargo-make to automate these processes. Install it by running the following command (if you don't have it yet):

cargo install --force cargo-make

Then we can start writing our Makefile.toml file for our project. We will first add a task named build-driver to build our project:

[tasks.build-driver]
script = '''
cargo b %BUILD_FLAGS%
'''

In order to rename the binary file, we need to know where it is located based on whether we are building it in debug or in release mode. So let's add some environment variables to the top of our Makefile.toml:

[env.development]
TARGET_PATH = "target/x86_64-pc-windows-msvc/debug"

[env.production]
TARGET_PATH = "target/x86_64-pc-windows-msvc/release"
BUILD_RELEASE = "--release"

Now we can write the task that actually renames the file:

[tasks.rename]
ignore_errors = true
script = '''
cd %TARGET_PATH%
rename driver.dll driver.sys
'''

Finally, we will need to sign our driver, but to do that we need access to the MSVC build tools. To set up the build environment, we will need to load the vcvars64.bat file. However, where that file is located depends on what version of Microsoft Visual Studio you have installed:

  • Microsoft Visual Studio Build Tools: C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\VC\Auxiliary\Build\vcvars64.bat
  • Microsoft Visual Studio 2019 Community Edition: C:\Program Files(x86)\Microsoft Visual Studio\2019\Community\VC\Auxiliary\Build\vcvars64.bat

Rather than trying to automatically detect this path, we will just use an environment variable at the top of our Makefile.toml:

[env]
VC_BUILD_DIR="C:\\Program Files (x86)\\Microsoft Visual Studio\\2019\\BuildTools\\VC\\Auxiliary\\Build\\vcvars64.bat"

Now that the path to the vcvars64.bat file has been defined, we can write our task to sign the driver. The task will simply load up the build environment, create a certificate if we don't have one, and then sign the driver with our certificate:

[tasks.sign]
dependencies = ["build-driver", "rename"]
script = '''
call "%VC_BUILD_DIR%"
if not exist DriverCertificate.cer ( makecert -r -pe -ss PrivateCertStore -n CN=DriverCertificate DriverCertificate.cer ) else ( echo Certificate already exi
sts. )
signtool sign /a /v /s PrivateCertStore /n DriverCertificate /fd certHash /t http://timestamp.digicert.com %TARGET_PATH%/driver.sys
'''

At this point, we should be able to run the following command to build and sign our driver:

cargo make sign

Accessing the Kernel API

Currently our driver isn't doing anything useful, but we cannot access the kernel libraries yet as we need bindings to the kernel API, i.e. function and type definitions in Rust, and as we have to link our kernel driver against the kernel libraries providing those functions. To solve the bindings issue, we will simply use Trantect's fork of winapi-rs for now. Add the following to Cargo.toml:

[dependencies.winapi]
git = "https://github.com/Trantect/winapi-rs.git"
branch = "feature/km"
features = [
    "wdm",
    "ntstatus",
]

The next problem is that we have to tell our linker where to find the kernel libraries to link against. These can normally be found in C:\Program Files (x86)\Windows Kit\10\lib\10.\*\km, but instead of defining the path ourselves, we will write a build.rs to find the correct path for us. We will be using the thiserror and winreg crates for this, so we will add the following to Cargo.toml:

[build-dependencies]
thiserror = "1.0"
winreg = "0.10"

Our build.rs starts with a number of things we want to import:

use std::path::PathBuf;
use thiserror::Error;
use winreg::RegKey;
use winreg::enums::HKEY_LOCAL_MACHINE;

Then we use thiserror to define our own Error type:

#[derive(Debug, Error)]
pub enum Error {
    #[error(transparent)]
    IoError(#[from] std::io::Error),
    #[error("cannot find the directory")]
    DirectoryNotFound,
}

We can get the C:\Program Files (x86)\Windows Kits\10 path from the Windows registry from HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows Kits\Installed Roots\KitsRoot10. We will use the winreg crate to get the value of this key:

/// Retrieves the path to the Windows Kits directory. The default should be
/// `C:\Program Files (x86)\Windows Kits\10`.
pub fn get_windows_kits_dir() -> Result<PathBuf, Error> {
    let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
    let key = r"SOFTWARE\Microsoft\Windows Kits\Installed Roots";
    let dir: String = hklm.open_subkey(key)?.get_value("KitsRoot10")?;

    Ok(dir.into())
}

While we are only interested in the location of the libraries for now, we will need the location of header files later. Therefore we define a DirectoryType type to pass to the get_km_dir function:

pub enum DirectoryType {
    Include,
    Library,
}

We can then call the get_windows_kit_dir function to get the Windows Kits directory. Based on the DirectoryType that we want, we can then append Lib or Include to this path. At this path we can find one or more directory versions containing the version number, so we will just pick the highest version number. Finally, we need to append km to the path. The following code does all of this:

/// Retrieves the path to the kernel mode libraries. The path may look something like:
/// `C:\Program Files (x86)\Windows Kits\10\lib\10.0.18362.0\km`.
pub fn get_km_dir(dir_type: DirectoryType) -> Result<PathBuf, Error> {
    // We first append lib to the path and read the directory..
    let dir = get_windows_kits_dir()?
        .join(match dir_type {
            DirectoryType::Include => "Include",
            DirectoryType::Library => "Lib",
        })
        .read_dir()?;

    // In the lib directory we may have one or more directories named after the version of Windows,
    // we will be looking for the highest version number.
    let dir = dir
        .filter_map(|dir| dir.ok())
        .map(|dir| dir.path())
        .filter(|dir| {
            dir.components()
                .last()
                .and_then(|c| c.as_os_str().to_str())
                .map(|c| c.starts_with("10.") && dir.join("km").is_dir())
                .unwrap_or(false)
        })
        .max()
        .ok_or_else(|| Error::DirectoryNotFound)?;

    // Finally append km to the path to get the path to the kernel mode libraries.
    Ok(dir.join("km"))
}

Then finally in our main function, we use the get_km_dir function to get the path to the kernel libraries. Then based on the target we are building, we append the architecture to this path and emit this path as a linker path:

fn main() {
    // Get the path to the kernel libraries.
    let dir = get_km_dir(DirectoryType::Library).unwrap();

    // Append the architecture based on our target.
    let target = std::env::var("TARGET").unwrap();

    let arch = if target.contains("x86_64") {
        "x64"
    } else if target.contains("i686") {
        "x86"
    } else {
        panic!("The target {} is currently not supported.", target);
    };

    let dir = dir.join(arch);

    // Specify the link path.
    println!("cargo:rustc-link-search=native={}", dir.to_str().unwrap());
}

Hello, world!

With access to the kernel API, we can now extend our driver to print "Hello, world!":

use winapi::km::wdm::DbgPrint;
use winapi::shared::ntdef::NTSTATUS;
use winapi::shared::ntstatus::STATUS_SUCCESS;

#[no_mangle]
pub extern "system" fn driver_entry() -> NTSTATUS {
    unsafe {
        DbgPrint("Hello, world!\0".as_ptr());
    }

    STATUS_SUCCESS
}

When calling the DbgPrint function, make sure to add the \0 add the end of the string, as the functions expects null-terminated or C-style strings. Later on we will implement the print! and println! macros that we normally expect in Rust.

Finally, our driver is still a bit inconvenient as we cannot unload the driver without rebooting Microsoft Windows. The driver_entry function actually gets a number of arguments including a DRIVER_OBJECT struct which has a function pointer called DriverUnload. We will define a function driver_exit that simply prints "Bye bye!" and point DriverUnload to this function:

use winapi::km::wdm::{DbgPrint, DRIVER_OBJECT};
use winapi::shared::ntdef::{NT_SUCCESS, UNICODE_STRING};
use winapi::shared::ntstatus::STATUS_SUCCESS;

#[no_mangle]
pub extern "system" fn driver_entry(driver: &mut DRIVER_OBJECT, _: *const UNICODE_STRING) -> NTSTATUS {
    unsafe {
        DbgPrint("Hello, world!\0".as_ptr());
    }

    driver.DriverUnload = Some(driver_exit);

    STATUS_SUCCESS
}

pub extern "system" fn driver_exit(driver: &mut DRIVER_OBJECT) {
    unsafe {
        DbgPrint("Bye bye!\0".as_ptr());
    }
}

Loading the Driver

Now that we have our driver code set up, we can simply build it as follows:

cargo make sign

The driver can be found at target/x86_64-pc-windows-msvc/debug/driver.sys, but how do we actually load our driver and see the debug messages? Well, let's first run the DbgView.exe utility as an administrator so we can actually capture our debug messages. In the Capture submenu, make sure that Capture Kernel and Enable Verbose Kernel Output are enabled.

Now that we are capturing the debug messages, we can actually load our driver and to do that we will be using the service controller or sc.exe. Run the following command to create the service for our driver:

sc create example binPath=$(realpath target/x86_64-pc-windows-msvc/debug/driver.sys) type=kernel

We can then start the service as follows, which loads our driver:

sc start example

We can then stop the service as follows, which unloads our driver:

sc stop example

Then after stopping it, we can delete the service:

sc delete example

If you closely watch DebugView, you should see the "Hello, world!" and "Bye bye!" messages from our kernel driver.

The code can be found on Github.

What's Next?

We have now programmed our very first kernel driver for Microsoft Windows that is capable of printing some debug messages. In the next article we will look at how to actually generate more complete bindings for the kernel API, so we can drop the winapi-rs crate that we are currently using and don't have to define the functions and types ourselves.

Acknowledgements

Thanks to Matthias for taking the time to explore how to write kernel drivers for Microsoft Windows and especially taking the time to write up the details of the process.

References

  1. https://docs.microsoft.com/en-us/cpp/build/reference/linker-options?view=msvc-160
  2. https://docs.microsoft.com/en-us/windows/win32/SbsCs/manifest-files-reference
  3. https://devblogs.microsoft.com/oldnewthing/20161024-00/?p=94575
  4. https://doc.rust-lang.org/book/ch09-01-unrecoverable-errors-with-panic.html


If you like my work or if my work has been useful to you in any way, then feel free to donate me a cup of coffee. Any donation is much appreciated!