Windows Drivers in Rust: Generating Bindings

Published on September 21, 2021

Introduction

In the previous article we got our first driver to work and print some debug messages. However, at the time of writing there aren't any crates that really cover the Windows kernel API too well. Of course, we could write the bindings ourselves whenever we need them, but that is a lot of work. Wouldn't it be nice if we could just generate the Rust bindings from the C header files? Well, that is why we are going to be using bindgen, which does exactly that!

If you just would like to use the bindings, they can be found on Github. This serves mostly as a documentation of the process, which may be useful in case you have to generate Rust bindings using bindgen yourself.

Getting Started

We will create a new crate for our unsafe bindings called windows-kernel-sys:

cargo new --lib windows-kernel-sys

For this project, we will also be using Rust Nightly:

echo nightly > rust-toolchain

In addition, we copy over the build.rs from our previous driver, but make sure that the main function is empty:

fn main() {
}

Finally, we will add the same build dependencies as before to Cargo.toml:

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

Generating Bindings

As mentioned before, we will be using bindgen, so let's add that to our build dependencies in Cargo.toml:

[build-dependencies]
bindgen = "0.59"

Then we create a file named src/wrapper.h which will include the headers for which we want to generate bindings:

#define _AMD64_

#include "ntdef.h"
#include "ntifs.h"

We will then use bindgen to generate the type definitions from this header file. We will do this in the generate_base function in build.rs:

fn generate_base() {
    // Tell Cargo to re-run this if src/wrapper.h gets changed.
    println!("cargo:rerun-if-changed=src/wrapper.h");

    // Find the include directory containing the kernel headers.
    let include_dir = get_km_dir(DirectoryType::Include).unwrap();

    // Get the build directory.
    let out_path = PathBuf::from(
        std::env::var_os("OUT_DIR")
            .expect("the environment variable OUT_DIR is undefined")
    );

    // Generate the bindings
    bindgen::Builder::default()
        .header("src/wrapper.h")
        .use_core()
        .derive_debug(false)
        .layout_tests(false)
        .ctypes_prefix("cty")
        .default_enum_style(bindgen::EnumVariation::ModuleConsts)
        .clang_arg(format!("-I{}", include_dir.to_str().unwrap()))
        .parse_callbacks(Box::new(bindgen::CargoCallbacks))
        .ignore_functions()
        .generate()
        .unwrap()
        .write_to_file(out_path.join("base.rs"))
        .unwrap();
}

The function above tells Cargo to re-run build.rs whenever src/wrapper.h changes. Then we look up the include directory that contains the kernel headers as well as our build directory. Finally, we pass a lot of options to bindgen::Builder:

  • .header("src/wrapper.h"): we want to generate the bindings from this header.
  • .use_core(): use core instead of std.
  • .derive_debug(false): don't derive Debug for our types.
  • .layout_tests(false): don't generate layout tests.
  • .ctypes_prefix("cty"): we will be importing the C-types from the cty crate.
  • .default_enum_style(bindgen::EnumVariation::ModuleConsts): generate enums as a module containing the constants.
  • .clang_arg(format!("-I{}", include_dir.to_str().unwrap()): tell Clang where to find the kernel headers.
  • .parse_callbacks(Box::new(bindgen::CargoCallbacks)): outputs re-run if changed for the files included by our header, such that when any of the headers change, build.rs gets executed to regenerate the bindings.
  • .ignore_functions(): ignore any function declarations, we will just generate the types for now.

Then in our main function, we can simply call this function:

fn main() {
    generate_base();
}

Unfortunately, if we run cargo build, then bindgen will panic with "Unable to get layout information?". This is caused by union _KGDTENTRY64 *GdtBase; and union _KIDTENTRY64 *IdtBase; in ntddk.h, which are forward declarations of these unions without any declaration of the actual union. Fortunately, the definitions of these unions can be found online for _KGDTENTRY64 and _KIDTENTRY64, so we can just copy them over into src/wrapper.h:

typedef union _KGDTENTRY64
{
	struct
	{
		unsigned short LimitLow;
		unsigned short BaseLow;
		union
		{
			struct
			{
				unsigned char BaseMiddle;
				unsigned char Flags1;
				unsigned char Flags2;
				unsigned char BaseHigh;
			} Bytes;
			struct
			{
				unsigned long BaseMiddle : 8;
				unsigned long Type : 5;
				unsigned long Dpl : 2;
				unsigned long Present : 1;
				unsigned long LimitHigh : 4;
				unsigned long System : 1;
				unsigned long LongMode : 1;
				unsigned long DefaultBig : 1;
				unsigned long Granularity : 1;
				unsigned long BaseHigh : 8;
			} Bits;
		};
		unsigned long BaseUpper;
		unsigned long MustBeZero;
	};
	unsigned __int64 Alignment;
} KGDTENTRY64, *PKGDTENTRY64;

typedef union _KIDTENTRY64
{
	struct
	{
		unsigned short OffsetLow;
		unsigned short Selector;
		unsigned short IstIndex : 3;
		unsigned short Reserved0 : 5;
		unsigned short Type : 5;
		unsigned short Dpl : 2;
		unsigned short Present : 1;
		unsigned short OffsetMiddle;
		unsigned long OffsetHigh;
		unsigned long Reserved1;
	};
	unsigned __int64 Alignment;
} KIDTENTRY64, *PKIDTENTRY64;

Now if we call cargo build, the generation actually succeeds.

Importing Bindings

First, we will add the cty crate as a dependency for our crate:

[dependencies]
cty = "0.2"

Then in src/base.rs, we can include the generated bindings:

#![allow(non_upper_case_globals)]
#![allow(non_camel_case_types)]
#![allow(non_snake_case)]

pub use cty::*;

include!(concat!(env!("OUT_DIR"), "/base.rs"));

pub const STATUS_SUCCESS: NTSTATUS = 0x00000000;

We can then export the module in src/lib.rs:

#![no_std]

#![feature(untagged_unions)]

pub mod base;

pub use cty::*;

Generating Functions

Now that we have the base modules with all the types, we can proceed with generating the function declarations for ntoskrnl.lib. As generating bindings is rather slow, we are going to feature gate the different kernel libraries that can be used. Let's define the features in Cargo.toml:

[features]
default = ["ntoskrnl"]
ntoskrnl = []

We will now implement the generate_ntoskrnl function in our build.rs to generate the function bindings:

fn generate_ntoskrnl() {
    // Tell Cargo to re-run this if src/wrapper.h gets changed.
    println!("cargo:rerun-if-changed=src/wrapper.h");

    // Link against ntoskrnl.
    println!("cargo:rustc-link-lib=ntoskrnl");

    // Find the include directory containing the kernel headers.
    let include_dir = get_km_dir(DirectoryType::Include).unwrap();

    // Get the build directory.
    let out_path = PathBuf::from(
        std::env::var_os("OUT_DIR")
            .expect("the environment variable OUT_DIR is undefined")
    );

    // Generate the bindings
    bindgen::Builder::default()
        .header("src/wrapper.h")
        .use_core()
        .derive_debug(false)
        .layout_tests(false)
        .ctypes_prefix("cty")
        .default_enum_style(bindgen::EnumVariation::ModuleConsts)
        .clang_arg(format!("-I{}", include_dir.to_str().unwrap()))
        .parse_callbacks(Box::new(bindgen::CargoCallbacks))
        .blocklist_type(".*")
        .allowlist_function(".*")
        .allowlist_recursively(false)
        .generate()
        .unwrap()
        .write_to_file(out_path.join("base.rs"))
        .unwrap();
}

The code above is pretty much the same as the generate_base function, except that we tell Cargo to link against ntoskrnl and that we now generate the function declarations rather than the type declarations. Then we can change the main function in build.rs to generate the bindings:

fn main() {
    generate_base();

    #[cfg(feature = "ntoskrnl")]
    generate_ntoskrnl();
}

We can then include the ntoskrnl bindings in our crate from src/ntoskrnl.rs:

#![allow(non_upper_case_globals)]
#![allow(non_camel_case_types)]
#![allow(non_snake_case)]

use crate::base::*;

include!(concat!(env!("OUT_DIR"), "/ntoskrnl.rs"));

Then in src/lib.rs, we can export the ntoskrnl module:

#[cfg(feature = "ntoskrnl")]
pub mod ntoskrnl;

Dealing with Inline Functions

One limitation of bindgen is that it does not generate bindings to inline functions by default. Fortunately, this is easy to solve by creating a C file that contains non-inline functions that simply call the inline functions. For instance, to wrap IoGetCurrentIrpStackLocation, we can write the following src/wrapper.c:

#include "wrapper.h"

PIO_STACK_LOCATION _IoGetCurrentIrpStackLocation(irp: PIRP) {
	return IoGetCurrentIrpStackLocation();
}

Then we can use the cc crate to compile the C source file. Let's add the build dependency to our Cargo.toml:

[build-dependencies]
cc = "1.0"

Then we can use the cc crate from the generate_ntoskrnl function in build.rs to build the wrapper:

    cc::Build::new()
        .flag("/kernel")
        .include(include_dir)
        .file("src/wrapper.c")
        .compile("wrapper_ntoskrnl");

Then we can define the function in src/ntoskrnl.rs:

#[link(name = "wrapper_ntoskrnl")]
extern "C" {
    pub fn _IoGetCurrentIrpStackLocation(irp: PIRP) -> PIO_STACK_LOCATION;
}

pub use self::_IoGetCurrentIrpStackLocation as IoGetCurrentIrpStackLocation;

Using the Bindings

If we go back to our driver, we can now drop the winapi-rs crate and use our own bindings as a dependency. Let's add them to the Cargo.toml:

[dependencies]
windows-kernel-sys = { path = "../windows-kernel-sys" }

Then we have to change our src/lib.rs as follows:

use windows_kernel_sys::base::{DRIVER_OBJECT, NTSTATUS, UNICODE_STRING, STATUS_SUCCESS};
use windows_kernel_sys::ntoskrnl::DbgPrint;

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

    driver.DriverUnload = Some(driver_exit);

    STATUS_SUCCESS
}

pub unsafe extern "C" fn driver_exit(driver: *mut DRIVER_OBJECT) {
    DbgPrint("Bye bye!\0".as_ptr() as _);
}

If everything went well, the above will build against our own bindings:

cargo make sign

The code can be found on Github. More complete bindings can also be found on Github.

What's Next?

Now that we have our own bindings for the Windows kernel API, we can start implementing safe abstractions in Rust on top of our unsafe bindings. In the next article we will be looking at how to implement a safe framework for writing Windows kernel drivers in Rust. More specifically, we will implement a global allocator such that we can use the heap-based types from the alloc crate such as strings and Vec, we will implement the print! and println! macros and a macro to produce the unsafe boilerplate code for the Windows kernel driver.

Acknowledgements

Thanks to Hussein Aitlahcen for his tremendous work on figuring out how to get bindgen to work with the Windows kernel headers.

References

  1. https://github.com/hussein-aitlahcen/windows-kernel-rs
  2. https://rust-lang.github.io/rust-bindgen/
  3. http://librecrops.github.io/lost-sdk/files/KGDTENTRY64.h.html
  4. http://librecrops.github.io/lost-sdk/files/KIDTENTRY64.h.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!