Saltar a la navegación Saltar al contenido principal Ir al pie de página

Writing FreeBSD Kernel Modules in Rust

31 agosto 2022

By David Young

At present all major operating system kernels are written in C/C++, languages which provide no or minimal assistance in avoiding common security problems. Modern languages such as Rust provide better security guarantees by default and prevent many of the common classes of memory safety security bugs. In this post we will take a brief look at existing community efforts towards this goal and build a basic “Hello World” proof-of-concept kernel module for FreeBSD.

It is generally accepted that a large proportion of security issues in complex software stem from memory safety problems. A well-known blog post from Microsoft attributes approximately 70% of vulnerabilities in their products to memory safety issues. And the 70% figure comes up again from Chromium’s research into the root causes of high and critical severity security bugs in their browser engine.

Enter Rust.

Rust is a programming language empowering everyone to build reliable and efficient software. It achieves this goal primarily by bringing as much error-checking and validation as possible forward to compilation time. Additionally, for operations that may fail due to external factors, its robust mechanisms for handling runtime errors ensure that applications won’t enter unexpected states.

As an example of the type of support that Rust provides, consider memory management. In “traditional” languages, it is expected that the programmer will correctly handle all aspects of the memory management lifecycle:

  • Ensure that the size of an allocation requested from malloc is correct, accounting for, e.g. off-by-one errors from mishandling the null byte at the end of a string
  • Ensure that the allocation was successful – not usually a concern in userspace code, however kernel and embedded code should safely handle allocation failures
  • For library functions, in the absence of a compiler-enforced contract around memory ownership, ensure that memory management obligations are clearly documented and implemented accordingly to avoid use-after-free or double-free issues
  • If two threads are accessing a shared area of memory, ensure that they do not try to write to it at the same time and that any read-modify-write sequences are consistent
  • Ensure that all allocations are freed at most once
  • After an allocation has been freed, ensure that the no function attempts to use the freed memory

Rust prevents these bug types at compile time by strictly tracking memory ownership and lifetimes automatically. For example, if a function accepts a reference to an object then that object will not be freed until after that function has returned – preventing use-after-free vulnerabilities. Similarly, if a function takes ownership of an object then only that function will be able to free it and it is no longer available to the calling context – preventing double-free vulnerabilities.

In addition, safe Rust prevents out-of-bounds memory accesses at runtime, either by panicking or with methods that return Option::None when given an out-of-bounds index.

#![no_std] and GlobalAlloc

When developing for embedded systems or kernels we can’t generally rely on the system giving us access to a heap memory allocator – often because embedded systems typically have limited memory capacity don’t generally have separate heap space.

To account for this, the Rust standard library is split into two components: core and alloc. The core crate contains all the standard library functions that don’t rely on an allocator, and alloc provides functions that do rely on heap memory.

By default, the standard library uses the operating system’s default allocator (e.g. glibc malloc(3)). After telling the compiler that we don’t want to use Rust’s standard library (by putting in the #![no_std] annotation) we may indicate that we wish to use a specific allocator – this is where the GlobalAlloc trait comes in.

In order to use alloc, we must provide a implementation of GlobalAlloc and register it with the #[global_allocator] attribute. For example, on an embedded system we may wish to write a custom allocator to hand out sections of an SRAM chip, or in an operating system we may need to allow programs to requests blocks of memory. For kernel modules, the Linux and BSD kernels provide us with allocators to use (e.g. kmalloc), so our implementation can be a relatively simple wrapper around these system library calls.

For example, we use the following code to tell Rust how to use the FreeBSD kernel’s memory allocator (where kernel_sys is a wrapper library around the kernel headers):

use core::alloc::{GlobalAlloc, Layout};

pub struct KernelAllocator;

unsafe impl GlobalAlloc for KernelAllocator {
    unsafe fn alloc( self, layout: Layout) -> *mut u8 {
        kernel_sys::malloc(
            layout.size(),
             mut kernel_sys::M_DEVBUF[0],
            kernel_sys::M_WAITOK,
        ) as *mut u8
    }

    unsafe fn dealloc( self, ptr: *mut u8, _layout: Layout) {
        kernel_sys::free(
            ptr as *mut libc::c_void,
             mut kernel_sys::M_DEVBUF[0],
        );
    }
}

Implementors of GlobalAlloc must provide an alloc_error_handler function which is called on allocation failure, and is usually used to halt or reboot the system or panic (note that the return type for this function is ! – the Never type – indicating that this function will never return.

This isn’t always useful behaviour, and the ability to handle allocation failures is important in several contexts (e.g. kernels, embedded, garbage-collected runtimes, database engines). There has been a lot of work towards support for fallible allocations, now collected in the allocators working group, and several types have experimental try_reserve methods that return an error on allocation failure instead of calling the gloabl error handler.

Features and Nightly Rust

What is “Nightly Rust” all about? This section intends to provide a brief background on the Rust compiler development process, why it’s sometimes necessary to use the Nightly compiler and why that can cause us problems.

One of the core goals of Rust is to be fully backwards compatible, in the sense that if a codebase compiles with a stable version of the compiler then it is also guaranteed to compile with all future versions (unless a particular piece of code only compiled as a result of a compiler bug). This is achieved by rigorously testing new features before they get accepted into the stable compiler or standard library. Consequently, we have the Stable compiler branch for production use, and the Nightly branch for experimenting with new features.

One of the advantages of this system is that it’s easy to write code that uses new and exciting features, however there is a risk that these features might get removed or considerably changed between Nightly releases and there isn’t the support guarantee that Stable provides. Unfortunately, a number of the features required for low-level development are still in this experimental phase – our BSD module uses both the alloc_error_handler and default_alloc_error_handler features because the allocation error handling behaviour is still in development. Ultimately, this means that our code is not guaranteed to compile on future Rust releases (indeed part of the motivation for this project was to update an example from 2017 which no longer compiles).

Linux

Over the last couple of years there has been significant effort put into developing a framework for building Linux kernel modules with Rust. This started with the fishinabarrel project and is now progressing on a dedicated fork of Linux with first-class support for Rust as a language for kernel development.

The project currently uses a recent stable version of Rust – 1.62. This is possible, despite the need for fallible allocation, because it includes a customised version of Rust’s alloc crate. The customised version can be modified more readily than the version bundled with Rust and has allowed the Rust for Linux team to mark the necessary methods as stable.

The patches have been submitted to the Linux maintainers for consideration, and Linus Torvalds has recently suggested that they are very close to getting merged – possibly in time for the upcoming 6.0 release.

Further information can be found in the Rust for Linux documentation and in the recent Linux Foundation webinar delivered by Wedson Almeida Filho.

The Rust interface is fairly straightforward, even to someone new to kernel development. A minimal example consists of the following:

// SPDX-License-Identifier: GPL-2.0
//! Rust minimal sample.
use kernel::prelude::*;

module! {
    type: RustMinimal,
    name: b"rust_minimal",
    author: b"Rust for Linux Contributors",
    description: b"Rust minimal sample",
    license: b"GPL",
}

struct RustMinimal {
    message: String,
}

impl kernel::Module for RustMinimal {
    fn init(_name:  'static CStr, _module:  'static ThisModule) -> Result<Self> {
        pr_info!("Rust minimal sample (init)n");
        pr_info!("Am I built-in? {}n", !cfg!(MODULE));

        Ok(RustMinimal {
            message: "on the heap!".try_to_owned()?,
        })
    }
}

impl Drop for RustMinimal {
    fn drop( mut self) {
        pr_info!("My message is {}n", self.message);
        pr_info!("Rust minimal sample (exit)n");
    }
}

The low-level code to interface with the kernel APIs is generated with bindgen. To provide a somewhat friendlier interface, higher-level wrappers have been written which abstract away the direct calls into C functions.

The above example demonstrates some of these higher-level features. The module! macro generates the necessary code for registering a module with the kernel, and the kernel::Module trait defines the signature of the init method that a type must implement in order to be loaded as a kernel module.

Further examples can be found in the Rust for Linux project’s samples directory and the Rust interface documentation can be viewed on the rust-for-linux docs site. Instructions for building out-of-tree modules are also available.

FreeBSD

For FreeBSD, there doesn’t seem to be any active work in this space, with the main prior works being Johannes Lundberg’s example and Master’s Thesis from 2017/18 and some follow-up work by Anatol Ulrich. As the Rust language has evolved since Lundberg’s early work a bit of effort is required to bring the up to date and ready to compile on recent compilers.

As a proof of concept, we produced fresh bindings to the FreeBSD kernel headers with bindgen and separated out the echo code into a safe wrapper crate around the bindings and the driver itself. There’s too much code to reasonably include directly in this blog post, so the complete source can be found on GitHub. The kernel-sys crate contains the bindings to the kernel headers (kernel-sys/wrapper.h) we need for building modules, bsd-kernel contains the safe abstraction layer, and module-hello contains the example module.

The abstractions used here are not as advanced as those available for Linux kernel modules, so the process involves building a Rust library that exports the relevant symbols and then statically linking it to a C program (hello.c) that calls the module initialisation function.

Module interface

There are two main jobs a kernel module always has to be able to do:

  • Declare itself; and
  • Handle events.

In this example we only consider the second of these – the first is handled by a C wrapper.

There are four module events, which we represent in bsd-kernel/src/module.rs with an enum:

/// The module event types
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
#[repr(u32)]
pub enum ModuleEventType {
    /// Module is being loaded
    Load = modeventtype_MOD_LOAD,
    /// Module is being unloaded
    Unload = modeventtype_MOD_UNLOAD,
    /// The system is shutting down
    Shutdown = modeventtype_MOD_SHUTDOWN,
    /// The module is about to be unloaded - returning an error from the
    /// QUIESCE event causes kldunload to cancel the unload (unless forced
    /// with -f)
    Quiesce = modeventtype_MOD_QUIESCE,
}

impl TryFrom<i32> for ModuleEventType {
    type Error = Error;
    fn try_from(input: i32) -> Result<Self, Self::Error> {
        use ModuleEventType::*;
        #[allow(non_upper_case_globals)]
        match input.try_into()? {
            modeventtype_MOD_LOAD => Ok(Load),
            modeventtype_MOD_UNLOAD => Ok(Unload),
            modeventtype_MOD_SHUTDOWN => Ok(Shutdown),
            modeventtype_MOD_QUIESCE => Ok(Quiesce),
            _ => Err(Error::ConversionError("Invalid value for modeventtype")),
        }
    }
}

For our module, we expose a C ABI-compatible function module_event to handle these events:

#[no_mangle]
pub extern "C" fn module_event(
    _module: bsd_kernel::Module,
    event: c_int,
    _arg: *mut c_void,
) -> c_int {
    if let Some(ev) = ModuleEventType::from_i32(event) {
        use ModuleEventType::*;
        match ev {
            Load => { /* ... */ }
            Unload => { /* ... */ }
            Quiesce => { /* ... */ }
            Shutdown => { /* ... */ }
        }
    } else {
        debugln!("[interface.rs] Undefined event");
    }
    0
}

The wrapper simply declares the existence of our module, and registers it with the kernel:

#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

extern int module_event(struct module *, int, void *);

static moduledata_t module_data = {
    "hello",        /* module name */
     module_event,  /* event handler */
     NULL           /* extra data */
};

DECLARE_MODULE(hello, module_data, SI_SUB_DRIVERS, SI_ORDER_MIDDLE);

Device interface

A simple type of kernel module is a character device. This is a device which behaves a bit like a regular file – supporting open, close, read and write operations that pass bytes in or out. To represent this we define a CharacterDevice trait with methods corresponding to the actions that the device should be able to perform:

pub trait CharacterDevice {
    fn open( mut self);
    fn close( mut self);
    fn read( mut self, uio:  mut UioWriter);
    fn write( mut self, uio:  mut UioReader);
}

The interface code in src/character_device.rs provides a wrapper type CDev that protects our device behind a mutex and stores a pointer to the character device structure that the kernel gives us via make_dev:

pub struct CDev<T>
where
    T: CharacterDevice,
{
    _cdev: ptr::NonNull<kernel_sys::cdev>,
    delegate: SharedModule<T>,
}

Creating a character device requires us to give the kernel a function pointer for each operation we wish to support – in this case open, close, read, and write. To do this, we create C-ABI functions for a generic CharacterDevice – from these the compiler will produce a concrete set of functions for each device we create.

The SharedModule struct internally uses a mutex to protect concurrent access to the module’s data. The wrapper functions must then lock this mutex before they can call the device methods.

For example, the close wrapper looks like this:

extern "C" fn cdev_close<T>(
    dev: *mut kernel_sys::cdev,
    _fflag: c_int,
    _devtype: c_int,
    _td: *mut kernel_sys::thread,
) -> c_int
where
    T: CharacterDevice,
{
    let cdev:  CDev<T> = unsafe {  *((*dev).si_drv1 as *const CDev<T>) };
    if let Some(mut m) = cdev.delegate.lock() {
        m.close();
    }
    0
}

The module initialisation code creates concrete implementations of these wrappers specific to the CharacterDevice we provide and stores their addresses in a kernel_sys::cdevsw struct to pass to the kernel:

impl<T> CDev<T>
where
    T: CharacterDevice,
{
    pub fn new_with_delegate(
        name:  'static str,
        delegate: SharedModule<T>,
    ) -> Option<Box<Self>> {
        let cdevsw_raw: *mut kernel_sys::cdevsw = {
            let mut c: kernel_sys::cdevsw = unsafe { mem::zeroed() };
            c.d_open = Some(cdev_open::<T>);
            c.d_close = Some(cdev_close::<T>);
            c.d_read = Some(cdev_read::<T>);
            c.d_write = Some(cdev_write::<T>);
            c.d_version = kernel_sys::D_VERSION as i32;
            c.d_name = "helloworld".as_ptr() as *mut i8;
            Box::into_raw(Box::new(c))
        };
        ...
    }
}

Implementing a Character Device

To implement a character device, all that’s left is to create a struct and implement the CharacterDevice trait on it:

lazy_static! {
    /// static instance of `SharedModule` that is automatically
    /// initialised on first use. This allows the module initialisation
    /// code to obtain separate handles to the same data
    pub static ref MODULE:
        SharedModule<Hello> = SharedModule::new(Hello::new());
}

#[derive(Debug)]
pub struct HelloInner {
    data: String,
    _cdev: Box<CDev<Hello>>,
}

#[derive(Default, Debug)]
pub struct Hello {
    inner: Option<HelloInner>,
}
impl Hello {
    fn new() -> Self {
        Hello::default()
    }
}

impl ModuleEvents for Hello {
    fn load( mut self) {
        debugln!("[module.rs] Hello::load");

        // Obtain a handle to the `Hello` module
        let m = MODULE.clone();

        if let Some(cdev) = CDev::new_with_delegate("rustmodule", m) {
            self.inner = Some(HelloInner {
                data: "Default hello messagen".to_string(),
                _cdev: cdev,
            });
        } else {
            debugln!(
                "[module.rs] Hello::load: Failed to create character device"
            );
        }
    }

    fn unload( mut self) {
        debugln!("[module.rs] Hello::unload");
    }
}


impl CharacterDevice for Hello {
    fn open( mut self) {
        debugln!("[module.rs] Hello::open");
    }
    fn close( mut self) {
        debugln!("[module.rs] Hello::close");
    }
    fn read( mut self, uio:  mut UioWriter) {
        debugln!("[module.rs] Hello::read");

        if let Some(ref h) = self.inner {
            match uio.write_all( h.data.as_bytes()) {
                Ok(()) => (),
                Err(e) => debugln!("{}", e),
            }
        }
    }
    fn write( mut self, uio:  mut UioReader) {
        debugln!("[module.rs] Hello::write");
        if let Some(ref mut inner) = self.inner {
            inner.data.clear();
            match uio.read_to_string( mut inner.data) {
                Ok(x) => {
                    debugln!(
                        "Read {} bytes. Setting new message to `{}`",
                        x,
                        inner.data
                    )
                }
                Err(e) => debugln!("{:?}", e),
            }
        }
    }
}

This device manages a String buffer, storing user-supplied data when written to and returning it when read.

The module can then be compiled and loaded as follows:

./build.sh
sudo make load
echo "hi rust" > /dev/rustmodule
cat /dev/rustmodule
sudo make unload

Conclusion

In this post we’ve shown that it is possible to write a simple kernel module for FreeBSD in Rust. More complete integration of Rust into existing operating system kernels is going to take a lot more time and effort, but on Linux these efforts are progressing quickly and it’s surely only a matter of time before other operating systems start to give low-level Rust serious consideration. The loadable kernel module interface is a good starting point for this work because it’s relatively isolated from the core kernel code and is on the boundary where external actors may interact with the kernel. Rust’s safety guarantees are an excellent match for this security boundary.

In the future we may start to see experimental rewrites of core kernel components into Rust, bringing stronger security guarantees to the networking layers or filesystem operations.

Some further topics which may help progress towards Rust in operating system kernels are the following:

  • Build a larger set of abstractions to mirror the Rust for Linux efforts on FreeBSD
  • Improve the abstractions used here to make them less leaky (i.e. remove the requirement to store a CDev object in the struct implementing CharacterDevice)
  • A similar exercise for Illumos
  • Design a set of abstractions for common behaviour between Linux, BSD, and Illumos (or demonstrate that this activity is impossible, or possible but only for a limited set of functionality)
  • Implement something useful, e.g. a driver for an SPI device or an interface layer onto embedded-hal traits

  • Full source code for this kernel module, including bindings to the FreeBSD kernel headers, is available on GitHub.