Windows Drivers in Rust: Reading and Writing

Published on October 06, 2021

Introduction

In the previous article we took the first step towards an API to create devices with user data and our own callbacks. In this article we will be looking at implementing callbacks for reading from and writing to our device file.

Dispatching the Callbacks

Similar to how we handled the IRP_MJ_CREATE, IRP_MJ_CLOSE and IRP_MJ_CLEANUP major functions in the dispatch_callback function in the previous article, we will now have to add code to dispatch the IRP_MJ_READ and IRP_MJ_WRITE functions:

        IRP_MJ_READ => {
            let read_request = ReadRequest {
                inner: request,
            };

            let status = data.read(&device, &read_request);

            request = read_request.inner;
            status
        }
        IRP_MJ_WRITE => {
            let write_request = WriteRequest {
                inner: request,
            };

            let status = data.write(&device, &write_request);

            request = write_request.inner;
            status
        }

The code above is slightly different from the code we wrote before. While it still calls into the read and write functions that we will define in DeviceOperations, it wraps the IoRequest into a ReadRequest and WriteRequest respectively before passing the requests to these functions. That is because the I/O request of the ReadFile and WriteFile functions will provide us access to the data buffer that userspace supplied to these functions.

We now proceed to extend the DeviceOperations trait with the read and write callbacks, as well as some default implementation:

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

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

Extending the Requests

When implementing the ReadRequest and WriteRequest types, we first want to make sure that we can still use all the functions that we implemented for IoRequest. Fortunately, we can wrap the IoRequest into our ReadRequest and WriteRequest and then implement core::ops::Deref:

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

impl Deref for ReadRequest {
    type Target = IoRequest;

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

With ReadRequest and WriteRequest implementing Deref for IoRequest, we can now access all the functions available for IoRequest, but at the same time we can extend ReadRequest and WriteRequest with functions specific to those requests.

First, we will implement the function that gives us access to the user buffer that has been supplied to us from userspace for these requests. Fortunately, thanks to Deref we have access to the IRP and the current I/O stack location:

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

We need these to tell apart buffered I/O from direct I/O. For direct I/O, we will get a Memory Descriptor List (MDL) instead. A MDL is a set of one or more physical pages that may be discontiguous, that is they are not necessarily neighboring pages in the physical address space. However, we can use MmGetSystemAddressForMdlSafe to map these physical pages to a contiguous virtual area, such that our driver can access the user buffer. Similarly, MmGetMdlByteCount gives us the size of the MDL in bytes. The function looks like this:

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

            let size = unsafe { MmGetMdlByteCount(irp.MdlAddress) } as usize;

            unsafe { UserPtr::new(ptr, 0, size) }

For buffered I/O, we will get a SystemBuffer and the length of the buffer as a parameter:

        } else if !unsafe { irp.AssociatedIrp.SystemBuffer }.is_null() { 
            let ptr = unsafe { irp.AssociatedIrp.SystemBuffer };
            let size = unsafe { stack_location.Parameters.Read }.Length as usize;

            unsafe { UserPtr::new(ptr, 0, size) }

Alternatively, if the user did not set the DO_BUFFERED_IO or DO_DIRECT_IO flag, we would get NULL pointers in both cases, so we will just return a UserPtr that does not do anything:

        } else {
            unsafe { UserPtr::new(core::ptr::null_mut(), 0, 0) }
        }
    }

Similarly, we will implement an offset function that provides us the byte offset in the file that userspace is trying to read from or write to:

    pub fn offset(&self) -> i64 {
        let stack_location = self.stack_location();
        let irp = self.irp();

        if !irp.MdlAddress.is_null() {
            (unsafe { MmGetMdlByteOffset(irp.MdlAddress) }) as i64
        } else if !unsafe { irp.AssociatedIrp.SystemBuffer }.is_null() {
            unsafe { stack_location.Parameters.Read.ByteOffset.QuadPart } 
        } else {
            0
        }
    }
}

The implementation for WriteRequest is pretty much the same.

User Pointers

If you paid close attention, you may have noticed that we have been wrapping the pointer to the user buffer and the size of the user buffer into a UserPtr struct. We don't want to provide access to the raw pointer itself, but preferably a byte slice or some type instead Therefore, we will be providing our own interface UserPtr that owns the pointer and the size, and gives us access to a byte slice or that lets us read the data into some type T. The basic implementation looks as follows:

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

    pub fn read_size(&self) -> usize {
        self.read_size
    }

    pub fn write_size(&self) -> usize {
        self.write_size
    }

To convert the pointer and the size to an immutable or mutable slice, we can use the core::slice::from_raw_parts and core::slice::from_raw_parts_mut functions:

    pub fn as_slice(&self) -> &[u8] {
        unsafe {
            core::slice::from_raw_parts(self.ptr as *const u8, self.read_size)
        }
    }

    pub fn as_mut_slice(&mut self) -> &mut [u8] {
        unsafe {
            core::slice::from_raw_parts_mut(self.ptr as *mut u8, self.write_size)
        }
    }

In addition, we also provide functions to read the buffer into a type T or write a type T into the buffer, as long as the size of T does not exceed the size of the user buffer. We check the size of T and if it exceeds the size of the user buffer, we return Error::INVALID_USER_BUFFER. Otherwise, we use core::ptr::copy_nonoverlapping to copy over the data between T and the user buffer. The code for that looks as follows:

    pub fn read<T: Default>(&self, obj: &mut T) -> Result<T, Error> {
        if core::mem::size_of::<T>() > self.read_size {
            return Err(Error::INVALID_USER_BUFFER);
        }

        let mut obj = T::default();

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

        Ok(obj)
    }

    pub fn write<T>(&mut self, obj: &T) -> Result<(), Error> {
        if core::mem::size_of::<T>() > self.write_size {
            return Err(Error::INVALID_USER_BUFFER);
        }

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

        Ok(())
    }
}

In the implementation above we also differentiate the read_size from the write_size. We won't need that right now, but it will be useful in the next article when we implement support for I/O controls.

Example

The code for this example can be found on Github. For this example, we will be implementing the read and write callbacks of our DeviceOperations trait to read the user data into a Vec<u8> and to write whatever data is stored inside a Vec<u8> to the user buffer: First, we extend our MyDevice struct to store the user data:

struct MyDevice {
    data: Vec<u8>,
}

In the read callback, we will first make sure the user specified offset does not execeed the size of our buffer. Then we make sure the user specified size does not exceed the size of our buffer minus the offset. After limiting both the offset and the size, we copy over the bytes and return how many bytes we copied over to userspace. The code looks like this:

impl DeviceOperations for MyDevice {
    fn read(&mut self, _device: &Device, request: &ReadRequest) -> Result<(), Error> {
        let mut user_ptr = request.user_ptr();
        let slice = user_ptr.as_mut_slice();

        let offset = (request.offset() as usize).min(self.data.len());
        let size = slice.len().min(self.data.len() - offset);

        slice[0..size].copy_from_slice(&self.data[offset..offset + size]);

        request.complete(Ok(size as u32));

        Ok(())
    }

For our write callback, we simply assume the offset is always 0 and limit the user input to no more than 4096 bytes. If both constraints are met, we simply take the slice of user data and turn it into a Vec<u8> and store it:

    fn write(&mut self, _device: &Device, request: &WriteRequest) -> Result<(), Error> {
        let user_ptr = request.user_ptr();

        if request.offset() > 0 {
            return Err(Error::END_OF_FILE)?;
        }

        let slice = user_ptr.as_slice();
        let size = slice.len().min(4096);

        self.data = slice[0..size].to_vec();

        request.complete(Ok(size as u32));

        Ok(())
    }
}

That concludes the changes to our driver.

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

The code can be found on Github. To test our driver, we will open our device file with read and write permissions. Then we write a string into the device file, which our driver will store. Up next we will allocate a buffer and try to read data from the device file into that buffer. Finally, we try to decode it as an UTF-8 string and print it if it succeeds, otherwise we simply display the data in hex:

use std::fs::OpenOptions;
use std::io::{Read, Write};

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

    file.write_all("Hello, world!".as_bytes())?;

    let mut data = vec![0u8; 4096];
    let size = file.read(&mut data)?;

    match std::str::from_utf8(&data[..size]) {
        Ok(s) => println!("read {} bytes: \"{}\"", size, s),
        _ => println!("read {} bytes: {:x?}", size, &data[..size]),
    }

    Ok(())
}

What's Next?

In the next article, we will look at I/O controls which provide us with an alternative way to interact with our driver.

References

  1. ReadFile - MSDN
  2. WriteFile - MSDN
  3. core::ops::Deref - Rust documentation
  4. "Using MDLs" - MSDN
  5. MmGetSystemAddressForMdlSafe - MSDN
  6. MmGetMdlByteCount - MSDN
  7. core::slice::from_raw_parts - Rust documentation
  8. core::slice::from_raw_parts_mut - Rust documentation
  9. core::ptr::copy_nonoverlapping - Rust documentation


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!