(L2.2) Best practices regarding Rust and security

There are many kinds of insecurity, however, Rust was designed to eliminate one prevalent kind, namely, memory errors. All a programmer has to do to avoid writing memory errors in Rust is not to use any unsafe Rust superpowers. Which means you have to stick to the Rust ownership model:

  • Each value in Rust has an owner.
  • There can only be one owner at a time.
  • When the owner goes out of scope, the value will be dropped.

Rust does not automatically ensure complete security in every aspect, so it is crucial to consider other security aspects, such as keeping dependencies up to date and secure. The cargo audit tool can help with this as it verifies that the libraries used in a Rust project are secure. Specifically, it examines all the project's dependencies and checks them against the RustSec Advisory Database to identify any known security vulnerabilities. Other well-known resources for helping with security are for instance the OWASP Top 10 for web security, and security by design practices.

The rest of this report will focus specifically on avoiding memory errors and their consequences in Rust.

Memory Errors

While it seems straightforward to not write any memory errors in Rust, as all a programmer has to do is not write any unsafe Rust, it is not that straightforward. It is not always possible to avoid unsafe code due to performance requirements, design choices, or the need for low-level control such as Direct Memory Access. When the design requirements cannot be met inside the stringent rules safe Rust imposes, you can use unsafe Rust, which allows the programmer to disregard all safe Rust rules but cannot give any memory safety guarantees.

This report contains the guarantees that unsafe code blocks can provide and some guidelines on the correct use of unsafe Rust. For more in depth or specific guidelines visit the Rustnomicon, the official unsafe Rust documentation.

Guarantees Provided By Unsafe Rust Blocks

Unsafe blocks, as shown below, unlock the use of unsafe Rust.

unsafe {
    let val = *ptr;
}

There are five superpowers that unsafe Rust gives a programmer access to:

  • Dereference c-style pointers
  • Call unsafe functions
  • Implement unsafe traits
  • Mutate statics
  • Access fields of unions

While unsafe blocks grant access to Rust's "unsafe superpowers," they do not disable Rust's safety checks. Code that is not directly using the five unsafe capabilities is still subject to Rust's ownership rules. For instance, the compiler will reject code that creates two mutable references to the same object simultaneously, as this violates the ownership model. If a programmer needs two pointers to the same object, they must use C-style raw pointers and apply the appropriate unsafe superpower when dereferencing them.

unsafe {
    let val = 8:
    let ref1 = &mut val;
    let ref2 = &mut val;
    safe_function(ref1, ref2);
}

Guidelines for Memory Safe Unsafe Rust

While the Rustnomicon provides more extensive documentation, here we cover how to avoid the more common causes of memory unsafe unsafe Rust.

Unsafe Standard Library APIs

There are quite a few unsafe functions and traits provided by the standard library. When using any unsafe API, the most important step to avoid undefined behavior is reading the documentation carefully. The standard library documentation provides all prerequisites that the calling code must fulfil to avoid triggering undefined behavior. When a program makes use of an unsafe function always (as recommended by the Rustnomicon) add a comment to the code explaining why the prerequisites are met. This way other collaborators can verify whether the conclusion on whether or not the call was memory safe is correct and when any code is changed it is easy to check whether all prerequisites still hold.

Transmute

Transmute allows you to change the type of a piece of data. The only restriction is that the new type must be the same size as the original. Transmute can cause undefined behavior in many different ways, not least of which are the ramifications of transmuting between two different compound types as the types may have a different internal layout. This problem is exacerbated by the not precisely defined layout of Rust structures.

Be cautious when transmuting to a reference, as failing to explicitly specify the lifetime can result in an unbounded reference. Cui et al. (2024) have shown that combining lifetimes with unsafe code can be risky, so always carefully consider the implications before creating a reference.

The most dangerous of the transmute's capabilities is the ability to change an immutable variable to a mutable reference. However, this should never be attempted because it is undefined behavior. The Rust compiler makes assumptions based on the original guarantee that the reference will never be mutable. Therefore, if you break the immutability contract there is no telling what can go wrong.

Using the Send and Sync Trait

One of the most common causes of memory errors in Rust according to Cui et al. (2024) is the incorrect use of the Send and Sync trait. If performance is not an issue, the safer option is to make use of synchronization primitives.

Optimizations

One common unsafe optimization is turning off bounds checks, however, Popescu at al. (2021) have shown that turning off bounds checks does not always improve performance. This finding is relevant for bounds checks and any optimization that introduces unsafety. Make sure that your optimization has a significant impact before offering up security. This also goes for multithreading as even in Rust it can be a hard problem, especially since race conditions, unlike data races, are not considered unsafe by Rust. This means that there are no handrails provided to avoid race conditions.

Guidelines for Safe Unsafe Rust

It is not enough to ensure that your unsafe Rust code does not exhibit undefined behavior. The unsafe code should be contained, easy to verify, and easy to find should there be undefined behavior. To contain it, it should be separated from the rest of the program in designated unsafe zones. This way, if there is an issue, you immediately know where to look.

Unsafe Zones

Ideally, the unsafe zones you created expose a completely safe API by making sure all possible checks are done inside the unsafe zone so no matter the input into the unsafe API no memory errors can be triggered. If a function can be provided inputs that trigger undefined behavior inside the function and these inputs are impossible to filter out, the function must be marked unsafe. An unsafe function must provide documentation on which prerequisites must hold when called so that no undefined behavior can occur. Every call site of an unsafe function must include an accompanying comment that explains how exactly all the prerequisites are satisfied. This makes it easier for the developer and possible collaborators to verify that the prerequisites still hold, even if there have been program changes.

Any unsafe block, not only those that call unsafe functions should provide such an explanation, even those in the designated unsafe zones.

The Reach of Memory Unsafe Unsafe Rust

While memory errors are made possible in unsafe code, they can be caused by safe code and are often triggered in safe code. For example, doing pointer arithmetic is not one of the unsafe superpowers, so it is possible in safe code. Of course, this pointer cannot be accessed without the help of unsafe code.

In the code below the invalid ptr_b is created by adding the offset 10 to ptr_a. A segmentation fault occurs in unsafe code, where most people would expect it.

{
    let ptr_a = ref to ptr;
    let ptr_b = ptr_a + 10;     // Invalid pointer creation
    unsafe {
        let val = *ptr_b;       // Segfault here
    }
}

The code below shows how undefined behavior can be triggered in safe code. If an invalid ptr_b is transformed into a reference in unsafe code, any access of that reference in safe code will result in undefined behavior. This means that your safe code can trigger something like a segmentation fault or even under the right circumstances successfully perform an out-of-bounds read.

{
    unsafe{
        let ref_b = ptr_b to ref;
    }
    let val = *ref_b;
}

In conclusion:

  • The cause of the undefined behavior a program exhibits, may not be the unsafe code. This is why adding a comment on why your unsafe block is safe is so important. If there is undefined behavior all you then have to do is search for your unsafe code blocks and figure out whether your prerequisites for safety were incomplete or whether they weren't satisfied by your safe code.
  • Undefined behavior can still occur in your safe code. This means, for example, that not because sensitive data is only ever handled by safe code this data is incorruptible with a little help from unsafe code.

Cross Language attacks

The previous section highlighted how memory-unsafe code can impact safe Rust code, but this is only the beginning of the problem. Mergendahl et al. (2022) and Papaevripides et al. (2021) have demonstrated that the interaction between unsafe code and safe Rust code can introduce unforeseen vulnerabilities.

This is an example of their newfound attack surface: If a C program, secured with perfect Control Flow Integrity (CFI), interacts with Rust, the program is less safe than if all the code was either written in secured C or Rust. This is because CFI and Rust offer memory safety at different levels. While CFI prevents exploits by allowing memory errors to occur but blocking their use, Rust prevents memory errors from occurring in the first place. Therefore, an attacker can corrupt a code pointer in the C code and pass it to the Rust code. Rust, given its defense model, assumes that all data is uncorrupted, therefore, it will treat the code pointer received from the C program as completely valid.

In a broader sense Mergendahl et al. (2022) and Papaevripides et al. (2021) show that great care must be taken when combining code with different, perhaps disjoint, memory safety guarantees. This is not only a problem in the interaction between C security techniques and safe Rust, but also raises concerns for the interaction between safe and unsafe Rust.

In short, it is vital to make sure that the protections that may already be in place, beyond just memory safety, are compatible with Rust safety model or can be effectively integrated into the Rust code.

[1] Samuel Mergendahl, Nathan Burow, and Hamed Okhravi. Cross-language attacks. In NDSS, 01 2022.

[2] Michalis Papaevripides and Elias Athanasopoulos. 2021. Exploiting Mixed Binaries. ACM Trans. Priv. Secur. 24, 2, Article 7 (May 2021), 29 pages. https://doi.org/10.1145/3418898