Rust is a programming
language guaranteeing memory and thread safety while still being able to
access raw memory and hardware. This sounds impossible, and it is,
that’s why Rust has an unsafe
keyword which allows a
programmer to dereference a raw pointer and perform some other dangerous
operations. The dangerous code is effectively contained to
unsafe
blocks, which makes Rust an interesting option for
embedded and system programming, where it could potentially be used to
replace C, which has a long history of memory safety
vulnerabilities.
The Linux Kernel, like many other operating system kernels, has a long history of memory and thread safety vulnerabilities. The Rust for Linux project aims to add Rust language support to the Linux Kernel and try to improve the security situation. Serious efforts to bring Rust into the mainline started in April 2021 and after many patch iterations, minimal Rust support has been merged into Linux 6.1.
With rudimentary Rust support now existing in the Linux Kernel, we expect some developers will try writing new device drivers in Rust, and some will port existing ones (e.g., Google has already developed a prototype of the Binder driver). This blog series explores security aspects of porting a Linux device driver from C to Rust. We have created five vulnerable drivers in C, ported them to Rust, explored a few porting variants that make use of different Rust APIs, and discussed how plausible it is for vulnerabilities to persist across the porting process.
This exploration was done from the perspective of a C programmer who is a beginner in Rust. My Rust code might not be idiomatic Rust, and could probably be described as “path of least resistance” Rust, which might not be too far off from what the other developers will attempt.
The blog series is made of four parts:
Let’s get started with a very simple
device driver. It only implements one ioctl
command,
VULN_PRINT_ADDR
, which prints the address of the handling
function itself and an address of a stack variable.
While printing a kernel address seems rather innocent – it’s just a
number after all – these values are useful sources of information
leakage that assists an attacker in bypassing KASLR when developing an
exploit for a memory safety vulnerability. The Linux kernel has aimed to
reduce such infoleaks for quite some time as per KSPP
Kernel pointer leak page. Eventually the "%p"
format
string in the Linux Kernel was changed to print a hashed pointer value
instead of leaking sensitive memory layout information.
The "%p"
printing restriction was bypassed in our driver
with a printk
specific format string "%px"
that can be used when you really want to print the address.
When porting
this code to Rust, one can see some boilerplate changes. Rust’s
module_misc_device!
macro neatly merged some
miscdevice/module boilerplate, and struct file_operations
is now a set of methods that are implemented on the driver struct. The
problematic lines themselves have few changes.
The original C version is shown below:
pr_info("%s is at address %px\n", __func__, &__func__); pr_info("stack is at address %px\n", &stack_dummy);
Original C version
And the Rust version looks quite similar:
pr_info!("RustVuln::ioctl is at address {:p}\n", Self::ioctl as *const ()); pr_info!("stack is at address {:p}\n", &stack_dummy);
Rust version
In Rust, format strings are different, but that is easy to figure out.
Surprisingly, despite the vast efforts to eliminate pointer infoleaks
in the Linux kernel, the Rust frameworks seemingly take a step backwards
on this front. In Rust, one can actually just use "{:p}"
as
an equivalent to "%p"
to print pointer values. To be
crystal clear: The Rust pr_info!
macro does not hash the
pointer value like the C pr_info
function does. We have
reported this to the Rust for Linux maintainers.
It might be surprising to some that the raw addresses are easily
accessible. Sure there’s a somewhat odd looking
Self::ioctl as *const ()
(basically a cast to a void
pointer), but no unsafe
or anything like that.
Developers who feel the need to print pointer values in C, will probably continue to do so after porting their drivers to Rust.
First, some backstory: The C standards do not require that memory covered by the struct but not belonging to any of its members is initialised to any value. That is, padding between structure members is not guaranteed to be initialised. This can be a bit surprising, since one does not expect uninitialised data in an initialised data container.
When such structures are copied across kernel trust boundaries (for
example, as syscall outputs) small portions of kernel memory may be
revealed to user space, constituting an info leak. This problem
has apparently
happened often enough that the
Linux kernel now has an automatic stack initialisation feature. To fix these types
of problems, enable CONFIG_INIT_STACK_ALL_PATTERN
or
CONFIG_INIT_STACK_ALL_ZERO
(there’s also the
init_on_alloc
boot parameter that zero initialises SLAB
memory).
We have created an example
driver to demonstrate how stack contents can be leaked accidentally.
Note: for this demonstration to work, the kernel needs to be
configured with CONFIG_INIT_STACK_NONE=y
, which disables
automatic stack variable initialisation. The interesting part
follows:
struct vuln_info { u8 version; u64 id; u8 _reserved; }; static long vuln_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { struct vuln_info info; switch (cmd) { case VULN_GET_INFO: info = (struct vuln_info) { .version = 1, .id = 0x1122334455667788, }; if (copy_to_user((void __user *)arg, &info, sizeof(info)) != 0)
ioctl
leaking stack memory contents
As described, the entire struct is not initialised. While the
_reserved
member is implicitly initialised by the compiler,
the padding bytes are not.
In memory, this data structure will be represented as:
Offset \ Byte | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
---|---|---|---|---|---|---|---|---|
0 | Padding | 01 | ||||||
8 | 11 | 22 | 33 | 44 | 55 | 66 | 77 | 88 |
16 | Padding | 00 |
Memory layout of struct vuln_info
We can test the transferred padding values with a simple
PoC, whose output is shown below. We see padding bytes at offset 0
and 16 are leaked, and the 0xffff...
looks very much like
an address from the kernel address space.
root@(none):/# /xxx/rustproofing-linux/poc/test.sh vuln_stack_leak [ 24.367962] vuln_stack_leak: loading out-of-tree module taints kernel. value at offset 0 differs: 0xbe4b0a10dd2f9a01 vs 0x1 value at offset 16 differs: 0xffffffffb2fcd300 vs 0
PoC demonstrating data has been leaked
The Linux kernel uses copy_from_user
to copy data from
userspace, and copy_to_user
to copy kernel buffers to
userspace. However, in Rust for Linux, these same copy operations can be
achieved with UserSlicePtrReader
and
UserSlicePtrWriter
. In this section, we will only describe
the “writer”, since both the reader and writer follow the same design
patterns, only the direction is reversed.
UserSlicePtrWriter
consists of a pointer and a length, and any write operation will
increment the pointer while decrementing the length until the whole
buffer is consumed. Note how this is a nice way of eliminating TOCTOU
style bugs – that is, the writer only allows the data to be written
once. It has an unsafe
write_raw
operation
which is a copy_to_user
wrapper with the mentioned pointer
and length change. Here, write_raw
is unsafe
because the programmer needs to guarantee the destination kernel buffer
pointer is safe to be dereferenced and “length” number of bytes can be
read from it.
It also “inherits” the following convenient and safe IoBufferWriter
methods:
write_slice
– Write slice
into a userspace
buffer.write<T: WritableFromBytes>
– Write a
WritableFromBytes
type into a userspace buffer. It’s worth
pointing out that WritableFromBytes
is defined for the
integer types, but one could define it for any type, and then be able to
use write
on that type, like we do below.The UserSlicePtrReader
has all the above semantics (just reverse read and write) with the added
method:
read_all
– read the whole userspace buffer into a newly
allocated Vec<u8>
Rust IOCTL handling uses an UserSlicePtrReader
and/or
UserSlicePtrWriter
, depending on IOCTL direction.
The Rust version has a bit more involved IOCTL handling compared to the above C version, but it should still be easy to follow. The relevant bits from above translate to:
#[repr(C)] // same struct layout as in C, since we are sending it to userspace struct VulnInfo { version: u8, id: u64, _reserved: u8, } match cmd { VULN_GET_INFO => { let info = VulnInfo { version: 1, id: 0x1122334455667788, _reserved: 0, // compiler requires an initialiser }; // pointer weakening coercion + cast let info_ptr = &info as *const _ as *const u8; // SAFETY: "info" is declared above and is local unsafe { writer.write_raw(info_ptr, size_of_val(&info))? };
Rust implementation that leaks stack memory contents
The main differences between C and Rust are:
#[repr(C)]
is required, because the Rust compiler is
otherwise free to reorder struct members._reserved
member needs to be explicitly
initialised, whereas in C unspecified members are implicitly
initialised.unsafe
block required for
write_raw
, which is just a fancy copy_to_user
wrapper.When we try the PoC, we observe that the kernel stack contents were leaked once again:
root@(none):/# /xxx/rustproofing-linux/poc/test.sh rust_vuln_stack_leak value at offset 0 differs: 0xffffffffb2fcd501 vs 0x1 value at offset 16 differs: 0xffff888003d77f00 vs 0
PoC demonstrating data has been leaked
In a way, this is somewhat surprising, but maybe it shouldn’t be.
After all, the Rust version looks a lot like the C version, and there’s
even an unsafe
block which should make the developer or
auditor think twice that there might be something unsafe about this
code.
WritableToBytes
VariantThe unsafe
write_raw
that was used above
takes a raw pointer, but there’s actually a nicer API we can leverage
here. A safe write
could be used, if we implement the
unsafe trait WritableToBytes
. The new
Rust variant then becomes:
#[repr(C)] // same struct layout as in C, since we are sending it to userspace struct VulnInfo { version: u8, id: u64, _reserved: u8, } unsafe impl WritableToBytes for VulnInfo { } [...] let info = VulnInfo { version: 1, id: 0x1122334455667788, _reserved: 0, // compiler requires an initialiser }; writer.write(&info)?;
Nicer Rust variant with
WritableToBytes
There is the following informational comment
above WritableToBytes
, which explains exactly what is wrong
with our code. We feel somewhat uneasy about this comment. The correct
usage of this API requires that developers notice the comment:
/// A type must not include padding bytes and must be fully initialised to safely implement /// [`WritableToBytes`] (i.e., it doesn't contain [`MaybeUninit`] fields). A composition of /// writable types in a structure is not necessarily writable because it may result in padding /// bytes.
Very relevant warning
unsafe
While playing around with this we have discovered we can actually do
this without any unsafe
in our code:
// pr_info! can be used to hide unsafe pr_info!("writing data to userspace {:?}\n", writer.write_raw(info_ptr, size_of_val(&info))?);
Trick to hide unsafe
And the PoC works:
root@(none):/# /xxx/rustproofing-linux/poc/test.sh rust_vuln_stack_leak_v3 [ 178.302849] vuln_stack_leak: writing data to userspace () value at offset 0 differs: 0xffff88800420ff01 vs 0x1 value at offset 16 differs: 0xffffff00 vs 0
PoC demonstrating data has been leaked
But what is happening here? pr_info!
has an internal
unsafe
in its implementation, so the arguments are
interpreted as unsafe
. This was rather surprising to us, so
we reported it to the Rust for Linux maintainers. We have later
discovered they track this issue already,
and our report served as a reminder. A fix
is due to be merged with the next release.
The idiomatic way in a C-based Linux kernel is to explicitly zero the structure’s memory before initialising it:
memset(&info, 0, sizeof(info)); // explicitly clear all "info" memory info = (struct vuln_info) { .version = 1, .id = 0x1122334455667788, };
Idiomatic fix is with a memset
In Rust this can be accomplished with MaybeUninit
porting
variation, as shown below:
let mut info = MaybeUninit::<VulnInfo>::zeroed(); let info = info.write(VulnInfo { version: 1, id: 0x1122334455667788, _reserved: 0, // compiler requires an initialiser });
Fixing Rust version with MaybeUninit
Note that while MaybeUninit::<T>::zeroed()
will
fix the issue, there’s still that unsafe
required for
WritableToBytes
. To eliminate this undesirable
unsafe
block, one can explicitly write all individual
struct members as shown in this example.
This is a common enough problem that automatic stack initialisation
feature was merged recently. When compiling the driver, if the
CONFIG_INIT_STACK_ALL_ZERO
config option is enabled, the
issue can no longer be reproduced in the C-based driver:
root@(none):/# /xxx/rustproofing-linux/poc/test.sh vuln_stack_leak [ 36.900538] vuln_stack_leak: loading out-of-tree module taints kernel. root@(none):/#
PoC shows no data leaked when mitigation applied
Oddly, the problem can still be reproduced in the Rust port of the vulnerable code:
root@(none):/# /xxx/rustproofing-linux/poc/test.sh rust_vuln_stack_leak value at offset 0 differs: 0xffffffffaa7d8b01 vs 0x1 value at offset 16 differs: 0xffff8880047d7f00 vs 0 root@(none):/#
PoC shows data leaked in Rust port even with mitigation
In other words, the CONFIG_INIT_STACK_ALL_ZERO
build
option does nothing for Rust code! Developers must be cautious to avoid
shooting themselves in the foot when porting a driver from C to Rust,
especially if they previously relied on this config option to mitigate
this class of vulnerability. It seems that kernel info leaks and KASLR
bypasses might be here to stay, at least, for a little while longer.
This surprising behaviour has been reported to the maintainers of the Rust for Linux project.
Part 2 of this blog series talks about race conditions and the caveats one needs to pay attention to.