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 implementingCharacterDevice
) - 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.