Windows Drivers in Rust: I/O Controls

Published on October 07, 2021

Introduction

In the previous article we extended our device file to support reading and writing from userspace as a way of interacting with our driver. In this article we are going to be looking at extending our interface to be able to handle device I/O controls or ioctls, which we pronounce as I/O controls, which is another way to let userspace interace with our driver.

What is an ioctl?

Since our userspace process runs with fewer privileges than the operating system kernel, we have to rely on the services provided by the kernel to us. For instance, our userspace process does not have access to all the physical memory of the system, but we can ask the kernel to allocate some memory for us and map it into our address space using VirtualAlloc. Under the hood, a call to VirtualAlloc will end up calling NtAllocateVirtualMemory in ntdll.dll, which is responsible for actually invoking the system call that jumps from userspace to the system call handler in the kernel. So how does the system call handler know that we called NtAllocateVirtualMemory? Well, each service routine that is available to userspace does have its own unique number that ntdll.dll passes along to the system call handler, together with the arguments for that service routine.

Similarly, we have been using ReadFile to read from our device file in the previous article. This function ends up calling NtReadFile in ntdll.dll.

However, sometimes we want to provide service routines specific to our driver that do not map well to any of the existing service routines, such as reading from or writing to our device file. Fortunately, userspace has access to DeviceIoControl which accepts a control code and optionally an input and/or an output buffer. Invoking DeviceIoControl causes the I/O manager to create an IRP_MJ_DEVICE_CONTROL and send it to the topmost driver in the driver stac, which may be our driver, or it may eventually be dispatched to our driver. The driver then receives the control code and optionally the input buffer and/or the output buffer along with the IRP, and uses the control code to perform the corresponding operation.

Dispatching the Callbacks

The dispatch code is similar to the one from the previous article. We first extend the dispatch_callback function to handle the IRP_MJ_DEVICE_CONTROL major function:

        IRP_MJ_DEVICE_CONTROL => {
            let control_request = IoControlRequest {
                inner: request,
            };

            let status = data.ioctl(&device, &control_request);

            request = control_request.inner;
            status
        }

We add the ioctl callback to the DeviceOperations trait with a default implementation:

    fn ioctl(&mut self, _device: &Device, request: &IoControlRequest) -> Result<(), Error> {
        request.complete(Ok(0));
        Ok(())
    }

Finally, we implemented our IoControlRequest by wrapping the IoRequest:

pub struct IoControlRequest {
    pub(crate) inner: IoRequest,
}

impl Deref for IoControlRequest {
    type Target = IoRequest;

    fn deref(&self) -> &Self::Target {
        &self.inner
    }
}

Our IoControlRequest provides a control_code function to access the control code of the request:

impl IoControlRequest {
    pub fn control_code(&self) -> ControlCode {
        let stack_location = self.stack_location();

        unsafe {
            stack_location.Parameters.DeviceIoControl.IoControlCode.into()
        }
    }

Dissecting Control Codes

While the control codes are 32-bit values, they consist of several fields. The "Defining I/O Control Codes" - MSDN article explains how the control codes are structured. Essentially, we have four fields:

  • 0:2 - Transfer Type: whether the I/O control is buffered I/O, direct I/O or neither.
  • 2:14 - Function Code: the function code that corresponds to the operation performed by our driver.
  • 14:16 - Required Access: whether the file needs to be opened with any access, read access, write access or both read and write access.
  • 16:32 - Device Type: the device type we passed to the create_device function.

Let's first define the RequiredAccess and TransferMethod types, for which we just have to use the constants provided by the windows-kernel-sys crate:

use windows_kernel_sys::base::{
    FILE_ANY_ACCESS, FILE_READ_DATA, FILE_WRITE_DATA,
    METHOD_NEITHER, METHOD_IN_DIRECT, METHOD_OUT_DIRECT, METHOD_BUFFERED
};

bitflags! {
    pub struct RequiredAccess: u32 {
        const ANY_ACCESS = FILE_ANY_ACCESS;
        const READ_DATA = FILE_READ_DATA;
        const WRITE_DATA = FILE_WRITE_DATA;
        const READ_WRITE_DATA = FILE_READ_DATA | FILE_WRITE_DATA;
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(u32)]
pub enum TransferMethod {
    Neither = METHOD_NEITHER,
    InputDirect = METHOD_IN_DIRECT,
    OutputDirect = METHOD_OUT_DIRECT,
    Buffered = METHOD_BUFFERED,
}

We can then create a nice struct for the ControlCode with the four fields:

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ControlCode(pub DeviceType, pub RequiredAccess, pub u32, pub TransferMethod);

Then we will define all the constants we will need to extract these fields from the 32-bit value, or vice versa to pack them into a 32-bit value:

impl ControlCode {
    const METHOD_BITS: usize = 2;
    const NUM_BITS:    usize = 12;
    const ACCESS_BITS: usize = 2;
    const TYPE_BITS:   usize = 16;

    const METHOD_SHIFT: usize = 0;
    const NUM_SHIFT:    usize = Self::METHOD_SHIFT + Self::METHOD_BITS;
    const ACCESS_SHIFT: usize = Self::NUM_SHIFT + Self::NUM_BITS;
    const TYPE_SHIFT:   usize = Self::ACCESS_SHIFT + Self::ACCESS_BITS;

    const METHOD_MASK: u32 = (1 << Self::METHOD_BITS) - 1;
    const NUM_MASK:    u32 = (1 << Self::NUM_BITS) - 1;
    const ACCESS_MASK: u32 = (1 << Self::ACCESS_BITS) - 1;
    const TYPE_MASK:   u32 = (1 << Self::TYPE_BITS) - 1;

In addition, we will add some convenience functions to access the fields individually:

    pub fn device_type(&self) -> DeviceType {
        self.0
    }

    pub fn required_access(&self) -> RequiredAccess {
        self.1
    }

    pub fn number(&self) -> u32 {
        self.2
    }

    pub fn transfer_method(&self) -> TransferMethod {
        self.3
    }
}

Finally, our code to pack and unpack the fields into a 32-bit value and from a 32-bit value looks as follows:

impl From<u32> for ControlCode {
    fn from(value: u32) -> Self {
        let method = (value >> Self::METHOD_SHIFT) & Self::METHOD_MASK;
        let num    = (value >> Self::NUM_SHIFT)    & Self::NUM_MASK;
        let access = (value >> Self::ACCESS_SHIFT) & Self::ACCESS_MASK;
        let ty     = (value >> Self::TYPE_SHIFT)   & Self::TYPE_MASK;

        Self(
            ty.into(),
            RequiredAccess::from_bits(access).unwrap_or(RequiredAccess::READ_DATA),
            num,
            method.into()
        )
    }
}

impl Into<u32> for ControlCode {
    fn into(self) -> u32 {
        let method = Into::<u32>::into(self.3) << Self::METHOD_SHIFT;
        let num    = self.2 << Self::NUM_SHIFT;
        let access = self.1.bits() << Self::ACCESS_SHIFT;
        let ty     = Into::<u32>::into(self.0) << Self::TYPE_SHIFT;

        ty | access | num | method
    }
}

Extending UserPtr

Similar to how it is possible to specify whether you want to use direct I/O or buffered I/O for reads and writes, it is also possible to specify the transfer method for your ioctl as we have seen when dissecting the control codes. The different transfer methods are discussed in "Buffer Descriptions for I/O Control Codes" - MSDN. We will first start by turning our UserPtr into an enum to handle the different situations:


pub enum UserPtr {
    Buffered {
        ptr: *mut cty::c_void,
        read_size: usize,
        write_size: usize,
    },
    Direct {
        read_ptr: *const cty::c_void,
        write_ptr: *mut cty::c_void,
        read_size: usize,
        write_size: usize,
    },
    Neither,
}

We then add some convenience functions to construct the different variants of our enum:

impl UserPtr {
    pub unsafe fn new_buffered(
        ptr: *mut cty::c_void,
        read_size: usize,
        write_size: usize,
    ) -> Self {
        Self::Buffered{
            ptr,
            read_size,
            write_size,
        }
    }

    pub unsafe fn new_direct(
        read_ptr: *const cty::c_void,
        write_ptr: *mut cty::c_void,
        read_size: usize,
        write_size: usize,
    ) -> Self {
        Self::Direct {
            read_ptr,
            write_ptr,
            read_size,
            write_size,
        }
    }

    pub unsafe fn new_neither() -> Self {
        Self::Neither
    }

We then have to modify the rest of our code to grab the fields from the various variants that we need. For the read_size and write_size functions that looks as follows:

    pub fn read_size(&self) -> usize {
        match self {
            Self::Buffered { read_size, .. } => *read_size,
            Self::Direct { read_size, .. } => *read_size,
            Self::Neither => 0,
        }
    }

    pub fn write_size(&self) -> usize {
        match self {
            Self::Buffered { write_size, .. } => *write_size,
            Self::Direct { write_size, .. } => *write_size,
            Self::Neither => 0,
        }
    }

For the as_slice and as_mut_slice functions, we also have to extract the pointer of the appropriate buffer:


    pub fn as_slice(&self) -> &[u8] {
        let (ptr, size) = match self {
            Self::Buffered { ptr, read_size, .. } => (*ptr as _, *read_size),
            Self::Direct { read_ptr, read_size, .. } => (*read_ptr, *read_size),
            Self::Neither => (core::ptr::null(), 0),
        };

        if ptr.is_null() || size == 0 {
            &[]
        } else {
            unsafe {
                core::slice::from_raw_parts(ptr as *const u8, size)
            }
        }
    }

    pub fn as_mut_slice(&mut self) -> &mut [u8] {
        let (ptr, size) = match self {
            Self::Buffered { ptr, write_size, .. } => (*ptr, *write_size),
            Self::Direct { write_ptr, write_size, .. } => (*write_ptr, *write_size),
            Self::Neither => (core::ptr::null_mut(), 0),
        };

        if ptr.is_null() || size == 0 {
            &mut []
        } else {
            unsafe {
                core::slice::from_raw_parts_mut(ptr as *mut u8, size)
            }
        }
    }

Finally, we have to do the same for our read and write functions:

    pub fn read<T: Default>(&self) -> Result<T, Error> {
        let (ptr, size) = match self {
            Self::Buffered { ptr, read_size, .. } => (*ptr as _, *read_size),
            Self::Direct { read_ptr, read_size, .. } => (*read_ptr, *read_size),
            Self::Neither => (core::ptr::null(), 0),
        };

        if ptr.is_null() || size == 0 {
            return Err(Error::INVALID_PARAMETER);
        }

        if core::mem::size_of::<T>() > size {
            return Err(Error::INVALID_USER_BUFFER);
        }

        let mut obj = T::default();

        unsafe {
            core::ptr::copy_nonoverlapping(
                ptr as _,
                &mut obj,
                core::mem::size_of::<T>(),
            );
        }

        Ok(obj)
    }

    pub fn write<T>(&mut self, obj: &T) -> Result<(), Error> {
        let (ptr, size) = match self {
            Self::Buffered { ptr, write_size, .. } => (*ptr, *write_size),
            Self::Direct { write_ptr, write_size, .. } => (*write_ptr, *write_size),
            Self::Neither => (core::ptr::null_mut(), 0),
        };

        if ptr.is_null() || size == 0 {
            return Err(Error::INVALID_PARAMETER);
        }

        if core::mem::size_of::<T>() > size {
            return Err(Error::INVALID_USER_BUFFER);
        }

        unsafe {
            core::ptr::copy_nonoverlapping(
                obj,
                ptr as _,
                core::mem::size_of::<T>(),
            );
        }

        Ok(())
    }

Then in the user_ptr function of the IoControlRequest type, we first concern ourselves with grabbing all the information we need: the MDL address if available, the system buffer and the input and output size. The code looks as follows:

    pub fn user_ptr(&self) -> IoControlBuffers { 
        let stack_location = self.stack_location();
        let irp = self.irp();

        let system_buffer = unsafe { irp.AssociatedIrp.SystemBuffer };

        let mdl_address = if !irp.MdlAddress.is_null() {
            unsafe { MmGetSystemAddressForMdlSafe(irp.MdlAddress, MM_PAGE_PRIORITY::HighPagePriority as _) }
        } else {
            core::ptr::null_mut()
        };

        let input_size = unsafe {
            stack_location.Parameters.DeviceIoControl.InputBufferLength
        } as usize;
        let output_size = unsafe {
            stack_location.Parameters.DeviceIoControl.OutputBufferLength
        } as usize;

The rest of our code depends on the transfer method specified in the control code:

        match self.control_code().transfer_method() {
            TransferMethod::Buffered =>
                unsafe { UserPtr::new_buffered(system_buffer, input_size, output_size) },
            TransferMethod::InputDirect =>
                unsafe { UserPtr::new_direct(mdl_address, system_buffer, output_size, input_size) },
            TransferMethod::OutputDirect =>
                unsafe { UserPtr::new_direct(system_buffer, mdl_address, input_size, output_size) },
            TransferMethod::Neither =>
                unsafe { UserPtr::new_neither() },
        }

While this is a lot of code that we had to implement to deal with the various transfer methods, this implementation greatly improves the ergonomics of our user buffers when dealing with them in our ioctl handler.

Matching Control Code

While we already greatly improved the ergonomics of dealing with user buffers, there is another issue we want to tackle. Currently in our ioctl handler, we would have to match on the ControlCode, which is rather inconvenient. We could already perform some checks beforehand, and provide a function that just return the RequiredAccess and the function number of the ioctl instead of all four fields.

Let's first extend our DeviceExtension to keep track of the DeviceType we specified when creating our device:

#[repr(C)]
pub struct DeviceExtension {
    pub(crate) vtable: *const device_operations,
    pub(crate) data: *mut cty::c_void,
    pub(crate) device_type: DeviceType,
}

We can then add the following convenience function to the Device type:

    pub(crate) fn device_type(&self) -> DeviceType {
        self.extension().device_type
    }

Then in the create_device function, we can simply keep track of the DeviceType that the user specified when creating the device:

        let extension = device.extension_mut();
        extension.device_type = device_type;
        extension.vtable = &DeviceOperationsVtable::<T>::VTABLE;
        extension.data = Box::into_raw(data) as *mut cty::c_void;

Now we can check when handling IRP_MJ_DEVICE_CONTROL, whether the DeviceType matches or not:

        IRP_MJ_DEVICE_CONTROL => {
            let control_request = IoControlRequest {
                inner: request,
            };

            let status = if device.device_type() == control_request.control_code().device_type() {
                data.ioctl(&device, &control_request)
            } else {
                Err(Error::INVALID_PARAMETER)
            };

Since our UserPtr type already handles the transfer method, we generally don't need to deal with that field either. So we can implement the following function called function() that returns the required access and function number fields instead for IoControlRequest:

    pub fn function(&self) -> (RequiredAccess, u32) {
        let code = self.control_code();

        (code.required_access(), code.number())
    }

Example

The code for our example driver can be found on Github. For this example, we will be creating a driver that stores a 32-bit value as part of the device and provides three operations that allows userspace to print the value, to retrieve the value and to set the value. Our MyDevice struct looks as follows:

struct MyDevice {
    value: u32,
}

Then we will define the function numbers for our operations, which have to start at 0x800 since any numbers below are Microsoft-specific:

const IOCTL_PRINT_VALUE: u32 = 0x800;
const IOCTL_READ_VALUE:  u32 = 0x801;
const IOCTL_WRITE_VALUE: u32 = 0x802;

Then we can implement the ioctl callback for MyDevice:

impl DeviceOperations for MyDevice {
    fn ioctl(&mut self, _device: &Device, request: &IoControlRequest) -> Result<(), Error> {
        match request.function() {

For the first operation, we don't have to process any input or output buffers as we just need to print the value we currently have stored:

            (_, IOCTL_PRINT_VALUE) => {
                println!("value: {}", self.value);

                request.complete(Ok(0));
            }

For the other operations, we will assume buffered I/O and only handle the buffered case of IoControlBuffers to grab the user pointer. In any other case we return the Error::INVALID_PARAMETER error instead. Then we can use the user pointer to read or write the value respectively:

            (RequiredAccess::READ_DATA, IOCTL_READ_VALUE) => {
                let mut user_ptr = request.user_ptr();

                user_ptr.write(&self.value)?;

                request.complete(Ok(core::mem::size_of::<u32>() as u32))
            }
            (RequiredAccess::WRITE_DATA, IOCTL_WRITE_VALUE) => {
                let user_ptr = request.user_ptr();

                self.value = user_ptr.read()?;

                request.complete(Ok(0))
            }

In any other case, we don't know how to handle the request from userspace, so we simply return the Error::INVALID_PARAMETER error:

            _ => {
                return Err(Error::INVALID_PARAMETER);
            }
        }

        Ok(())
    }
}

That concludes our driver.

As usual, we can load our driver as follows:

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

Userspace

While we could just invoke DeviceIoControl from userspace, it has a low-level interface that requires us to write unsafe code to pass the buffers as *mut std::ffi::c_void. It would be nice if we could easily define the proper function prototypes for the control codes of our driver. We will use a similar approach as the approach used nix crate and define macros that help us define convenience wrappers to perform ioctls from userspace. To get an idea of how this works, we will look at the ioctl_read macro that we define for Microsoft Windows:

#[macro_export]
macro_rules! ioctl_read {
    ($(#[$attr:meta])* $name:ident, $dev_ty:expr, $nr:expr, $ty:ty) => {
        $(#[$attr])*
        pub unsafe fn $name(handle: *mut std::ffi::c_void, data: *mut $ty) -> Result<u32, $crate::Error> {

The code above generates an unsafe function that accepts a file and a pointer to the data of the type specified when using the macro. Then in this function, we take the device type and function number specified to the macro, and use the ControlCode type to pack everything into 32-bit value:

            let code = $crate::ControlCode(
                $dev_ty,
                $crate::RequiredAccess::READ_DATA,
                $nr,
                $crate::TransferMethod::Buffered,
            ).into();
            let mut return_value = 0;

Then we invoke the DeviceIoControl:

            let status = $crate::DeviceIoControl(
                handle as _,
                code,
                std::ptr::null_mut(),
                0, 
                data as _,
                std::mem::size_of::<$ty>() as _,
                &mut return_value,
                std::ptr::null_mut(),
            ) != 0;

Finally, we deal with the error handling:

            match status {
                true => Ok(return_value),
                _ => Err(std::io::Error::last_os_error())?,
            }
        }
    }
}

For our userspace example, we can then use the macros as follows:

use winioctl::{DeviceType, Error};
use winioctl::{ioctl_none, ioctl_read, ioctl_write};

const IOCTL_PRINT_VALUE: u32 = 0x800;
const IOCTL_READ_VALUE:  u32 = 0x801;
const IOCTL_WRITE_VALUE: u32 = 0x802;

ioctl_none!(ioctl_print_value, DeviceType::Unknown, IOCTL_PRINT_VALUE);
ioctl_read!(ioctl_read_value, DeviceType::Unknown, IOCTL_READ_VALUE, i32);
ioctl_write!(ioctl_write_value, DeviceType::Unknown, IOCTL_WRITE_VALUE, i32);

For our example, we read the value from the kernel driver, increment it by one and then store the value. Then we will ask the driver to print the current value. Thus, every time we run our example, it will increment the value stored by the driver. The code looks as follows:

fn main() -> Result<(), Error> {
    let file = OpenOptions::new()
        .read(true)
        .write(true)
        .create(false)
        .open("\\??\\Example")?;
    let mut value = 0;

    unsafe {
        ioctl_read_value(file.as_raw_handle(), &mut value)?;
    }

    value += 1;

    unsafe {
        ioctl_write_value(file.as_raw_handle(), &value)?;
    }

    unsafe {
        ioctl_print_value(file.as_raw_handle())?;
    }

    Ok(())
}

The winioctl crate and this example are both available on Github.

What's Next?

TODO

References

  1. VirtualAlloc - MSDN
  2. NtAllocateVirtualMemory - MSDN
  3. ReadFile - MSDN
  4. NtReadFile - MSDN
  5. DeviceIoControl - MSDN
  6. IRP_MJ_DEVICE_CONTROL - MSDN
  7. "Introduction to I/O Control Codes" - MSDN
  8. "Buffer Descriptions for I/O Control Codes" - MSDN
  9. "Defining I/O Control Codes" - MSDN
  10. nix crate


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!