-14 C
New York
Saturday, February 4, 2023

Rust Software Security: A Current State Assessment


Rust is a programming language that is growing in popularity. While its user base remains small, it is widely regarded as a cool language. According to the Stack Overflow Developer Survey 2022, Rust has been the most-loved language for seven straight years. Rust boasts a unique security model, which promises memory safety and concurrency safety, while providing the performance of C/C++. Being a young language, it has not been subjected to the widespread scrutiny afforded to older languages, such as Java. Consequently, in this blog post, we would like to assess Rust’s security promises.

Every language provides its own security model, which can be defined as the set of security and safety guarantees that are promoted by experts in the language. For example, C has a very rudimentary security model because the language favors performance over security. There have been several attempts to rein in C’s memory safety issues, from ISO C’s Analyzability Annex to Checked C, but none have achieved widespread popularity yet.

Of course, any language may fail to live up to its security model due to bugs in its implementation, such as in a compiler or interpreter. A language’s security model is thus best viewed as what its compiler or interpreter is expected to support rather than what it currently supports. By definition, bugs that violate a language’s security model should be treated very seriously by the language’s developers, who should strive to quickly repair any violations and prevent new ones.

Rust’s security model includes its concept of ownership and its type system. A large part of Rust’s security model is enforced by its borrow checker, which is a core component of the Rust compiler (rustc). The borrow checker is responsible for ensuring that Rust code is memory-safe and has no data races. Java also enforces memory safety but does so by adding runtime garbage collection and runtime checks, which impede performance. The borrow checker, in theory, guarantees that at runtime Rust imposes almost no performance overhead with memory checks (excluding checks done explicitly by the source code). As a result, the performance of compiled Rust code appears comparable to C and C++ code and faster than Java code.

Developers also have their own mental security models that embody the policies they expect of their code. For example, these policies typically include assurances that programs will not crash or leak sensitive data such as passwords. Rust’s security model is intended to satisfy developers’ security models with varying degrees of success.

This blog post is the first of two related posts. In the first post, we examine the features of Rust that make it a safer language than older systems programming languages like C. We then examine limitations to the security of Rust, such as what secure-coding errors can occur in Rust code. In a future post, we will examine Rust security from the standpoints of users and analysts of Rust-based software. We will also address how Rust security should be regarded by non-developers, e.g., how many common vulnerabilities and exposures (CVEs) pertain to Rust software. In addition, this future post will focus on the stability and maturity of Rust itself.

The Rust Security Model

Traditional programming languages, such as C and C++, are memory-unsafe. As a consequence, programming mistakes can result in memory corruption that often results in security vulnerabilities. For example, OpenSSL’s Heartbleed vulnerability would not have occurred had the code been written in a memory-safe language.

The biggest advantage of Rust is that it catches mistakes at compile time that would have resulted in memory corruption and other undefined behaviors at runtime in C or C++, without sacrificing the performance or low-level control of these languages. This section illustrates some examples of these sorts of mistakes and shows how Rust prevents them.

First, consider this C++ code example that uses a C++ Standard Template Library (STL) iterator after it has been invalidated (a violation of CERT rule CTR51-CPP. Use valid references, pointers, and iterators to reference elements of a container), which results in undefined behavior:

#include <cassert>
#include <iostream>
#include <vector>

int main() {
    std::vector<int> v{1,2,3};
    std::vector<int>::iterator it = v.begin();
    assert(*it++ == 1);
    v.push_back(4);
    assert(*it++ == 2);
}

Compiling the above code (using GCC 12.2 and Clang 15.0.0, with -Wall) produces no errors or warnings. At runtime, it may exhibit undefined behavior because appending to a vector may cause the reallocation of its internal memory. Reallocation invalidates all iterators into it, and the final line of main uses such an iterator.

Now consider this Rust code, written to be a straightforward transliteration of the above C++ code:

fn main() {
    let mut v = vec![1, 2, 3];
    let mut it = v.iter();
    assert_eq!(*it.next().unwrap(), 1);
    v.push(4);
    assert_eq!(*it.next().unwrap(), 2);
}

When trying to compile it, rustc 1.64 produces this error:

error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
 --> rs.rs:5:5
  |
3 |     let mut it = v.iter();
  |                  -------- immutable borrow occurs here
4 |     assert_eq!(*it.next().unwrap(), 1);
5 |     v.push(4);
  |     ^^^^^^^^^ mutable borrow occurs here
6 |     assert_eq!(*it.next().unwrap(), 2);
  |                 --------- immutable borrow later used here

error: aborting due to previous error

For more information about this error, try `rustc --explain E0502`.

Rust introduces the concept of borrowing to catch this sort of mistake. Taking a reference to an object borrows it for as long as the reference exists. When an object is modified, the borrow must be mutable, and mutable borrows are allowed only when no other borrows are active. In this case, the iterator it takes a reference to, and so borrows, v from its creation on line 3 until after its last use on line 6, so the mutable borrow on line 5 that push() needs to modify v is rejected by Rust’s borrow checker.

To summarize, Rust’s borrow checker does not prevent the use of invalid iterators; it prevents iterators from becoming invalid during their lifetime, by disallowing modification of a vector that has iterators subsequently referencing it.

Use After Free

Here is another example, this time of a simple use-after-free error in C (a violation of CERT rule MEM30-C. Do not access freed memory), which also results in undefined behavior:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(void) {
    char *x = strdup("Hello");
    free(x);
    printf("%s\n", x);
}

Again, the above code has no errors or warnings at compile time but exhibits undefined behavior at runtime since x is used after it was freed.

Now consider this transliteration of the above into Rust:

fn main() {
    let x = String::from("Hello");
    drop(x);
    println!("{}", x);
}

Compiling with rustc 1.64 produces this error:

error[E0382]: borrow of moved value: `x`
 --> src/main.rs:4:20
  |
2 |     let x = String::from("Hello");
  |         - move occurs because `x` has type `String`, which does not implement the `Copy` trait
3 |     drop(x);
  |          - value moved here
4 |     println!("{}", x);
  |                    ^ value borrowed here after move
  |
  = note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)

For more information about this error, try `rustc --explain E0382`.

Rust’s borrow checker noticed this mistake too since calling drop on something to free it rescinds ownership of it. This implies that such an object cannot be borrowed anymore.

There are other kinds of mistakes that also lead to undefined behavior or other runtime bugs in C and C++ that cannot even be written in Rust. For example, a lot of crashes in C and C++ are caused by dereferencing null pointers. Rust’s references can never be null, and instead require a type like Option to express the lack of a value. This paradigm is safe at both ends: if a reference is wrapped in Option, then code that uses it needs to account for None, or the compiler will give an error. Moreover, if a reference is not wrapped in Option then code that sets it always needs to point it at something valid or the compiler will give an error.

Java and C both provide support for multi-threaded programs, but both languages are subject to many concurrency bugs including race conditions, data races, and deadlocks. Unlike Java and C, Rust provides some concurrency safety over multi-threaded programs by detecting data races at compile time. A race condition occurs when two (or more) threads race to access or modify a shared resource, such that the program behavior depends on which thread wins the race. A data race is a race condition where the shared resource is a memory address. Rust’s memory model requires that any used memory address is owned by only one variable, and it may have one mutable borrower that may write to it, or it may have multiple non-mutable borrowers that may only read it. The use of mutexes and other thread-safety features enables Rust code to protect against other types of race conditions at compile time. C and Java have similar thread-safety features, but Rust’s borrow checker offers stronger compile-time protection.

Limitations of the Rust Security Model

The Rust borrow checker has its limitations. For example, memory leaks are outside of its scope; a memory leak is not considered unsafe in Rust because it does not lead to undefined behavior. However, memory leaks can cause a program to crash if they should exhaust all available memory, and consequently memory leaks are forbidden in CERT rule MEM31-C. Free dynamically allocated memory when no longer needed.

To enforce memory safety, Rust’s borrow checker normally prohibits actions like accessing a particular address of memory (e.g., as the value at memory address 0x400). This prohibition is sensible because specific memory addresses are abstracted away by modern computing platforms. However, embedded code and many low-level system functions need to interact directly with hardware, and so might need to read memory address 0x400, possibly because that address has special significance on a particular piece of hardware. Such code can also provide memory-safe wrapper abstractions that encapsulate memory-unsafe interactions.

To support these possible use cases, the Rust language provides the unsafe keyword, which enables local code to perform operations that might be memory-unsafe but are not reported by the borrow checker. A function that is not declared unsafe could have unsafe code within it, which indicates the function encapsulates unsafe code in a safe manner. However, the developer(s) of that function assert that the function is safe because the borrow checker cannot vouch that code in an unsafe block is actually safe.

Supporting the unsafe keyword was an intentional design decision in the Rust project. Consequently, usage of Rust’s unsafe keyword puts the onus of safety on the developer, rather than on the borrow checker. In essence, the unsafe keyword gives Rust developers the same power that C developers have, along with the same responsibility of ensuring memory safety without the borrow checker.

Rust’s borrow checker’s scope is memory safety and concurrency safety. It thus addresses only seven of the 2022 CWE Top 25 Most Dangerous Software Weaknesses. Consequently, Rust developers must remain vigilant for addressing many other kinds of security in Rust.

Rust’s borrow checker can identify programs with memory-safety violations or data races as unsafe, so the Rust programming community often uses the term “safe” to refer specifically to programs that are recognized as valid by the borrow checker. This usage is further codified by Rust’s unsafe keyword. It is therefore easy to assume the safety Rust promises includes all notions of safety that developers might conceive, although Rust only promises memory-safety and concurrency safety. Consequently, several programs considered unsafe by developers may be considered safe by Rust’s definition of “safe”.

For example, a program that has floating-point numeric errors is not considered unsafe by Rust, but might be considered unsafe by its developers, depending on what the erroneous numbers represent. Likewise, some programs with race conditions but no data races might not be considered unsafe in Rust. Two Rust threads can easily have a race condition by simultaneously trying to write to the same open file, for example.

The notion of what is safe for a program should be documented and known to developers as the program’s security policy. A program’s security policy can often depend on factors external to the program. For example, programs typically run by system administrators will have more stringent safety requirements, such as not allowing untrusted users to open arbitrary files.

Like many other languages, Rust provides many features as third-party packages (crates in Rust parlance). Rust does not and cannot prevent bad usage of many libraries. For example, the popular crate RustCrypto provides hashing algorithms, such as MD5. The MD5 algorithm has been catastrophically broken, and many standards, including FIPS, prohibit its use. RustCrypto also provides other, more reliable, cryptography algorithms, such as SHA256.

Borrow Checker Limitations

While the Rust security model strives to detect all memory safety violations, it sometimes errs by rejecting code that is actually memory-safe. As an engineering tradeoff, the language designers considered it better to reject some memory-safe programs than to accept some memory-unsafe programs. Here is one such memory-safe program, very similar to an example from The Rust Security Model section above:

fn main() {
    let mut v = vec![1, 2, 5];
    let mut it = v.iter();
    assert_eq!(*it.next().unwrap(), 1);
    v[2] = 3;     /* rejected by borrow checker, but still memory-safe */
    assert_eq!(*it.next().unwrap(), 2);
}

As with that example, this example fails to compile because v is borrowed mutably (e.g., modified by the assignment) while being borrowed immutably (e.g., used by the iterator before and after the assignment). The danger is that modifying v could invalidate any iterators (like it) that reference v; however modifying a single element of v would not invalidate its iterators. The analogous code in C++ compiles, runs cleanly, and is memory-safe:

#include <cassert>
#include <iostream>
#include <vector>

int main() {
    std::vector<int> v{1,2,5};
    std::vector<int>::iterator it = v.begin();
    assert(*it++ == 1);
    v[2] = 3;   /* memory-safe */
    assert(*it++ == 2);
}

Rust does provide workarounds to this problem, such as the split_at_mut() method, using indices instead of iterators, and wrapping the contents of the vector in types from the std::cell module, but these solutions do result in more complicated code.

In contrast to the borrow checker, Rust has no mechanism to enforce protection against injection attacks. We will next assess Rust’s protections against injection attacks.

Injection Attacks

Rust’s security model offers the same degree of protection against injection attacks as do other languages, such as Java. For example, to prevent SQL injection, Rust offers prepared statements, but so do many other languages. See CERT Rule IDS00-J for examples of SQL injection vulnerabilities and mitigations in Java.

However, Rust does provide some extra protection against OS command injection attacks. To understand this protection, consider Java’s Runtime.exec() function, which takes a string representing a shell command and executes it. The following Java code

Runtime rt = Runtime.getRuntime();
Process proc = rt.exec("ls " + dir);

would create a process to list the contents of dir. But if an attacker can control the value of dir, the program can do a lot more. For example, if dir is the following:

dummy & echo bad

then the program prints the word bad to the Java console. See CERT rule IDS07-J. Sanitize untrusted data passed to the Runtime.exec() method for more information.

Rust sidesteps this problem by simply not providing any functions analogous to Runtime.exec(). Every standard Rust function that executes a system command takes the command arguments as an array of strings. Here is an example that uses the std::process::Command object:

Command::new("ls")
        .args([dir])
        .output()
        .expect("failed to execute process")

The Rust crate nix::unistd provides a family of exec() functions that support the POSIX exec(3) functions, but again, they all accept an array of arguments. None of the POSIX functions that automatically tokenize a string into command arguments is supported by Rust. Withholding these POSIX functions from Rust’s nix::unistd API offers protection from command injection attacks. The protection is not complete, however, as shown by the following example of Rust code that permits OS command injection:

Command::new("sh")
         .arg("-c")
         .arg(format!("ls {dir}"))
         .output()
         .expect("failed to execute process")

It is therefore still possible to write Rust code that permits OS command injection. However, such code is more complex than code that prevents injection.

Rust Protection in Context

The following table compares Rust against other languages with regard to what protection against software vulnerabilities each language provides:








Protection


C


Java


Python


Rust


Memory corruption


None


Full


Full


Full*


Integer overflow


None


None


Full


Optional


Data races


None


Some


None


Full*


Injection attacks


None


Some


Some


Some


Misuse of 3rd-party code


None


None


None


None

*Full protection is offered for Rust code that does not use the unsafe keyword.

As the table shows, Rust offers more protections than the other languages, while striving for the performance of C and C++. However, the protections offered by Rust are only a subset of the overall software security that developers need, and developers must continue to prevent other security attacks the same way in Rust as they do in other languages.

Rust: A Safer Language

This blog post should have provided you with a realistic assessment of the security that Rust provides. We have explained that Rust does indeed provide a degree of memory and concurrency safety, while enabling programs to achieve C/C++ levels of performance. We would categorize Rust as a safer language, rather than a safe language, because the safety Rust provides is limited, and Rust developers still must worry about many aspects of software security, such as command injection.

As stated previously, a future post will examine Rust security from the standpoints of users and security analysts of Rust-based software, and we will try to address how Rust security should be regarded by non-developers. For example, how many CVEs pertain to Rust software? This future post will also examine the stability and maturity of Rust itself.

Related Articles

LEAVE A REPLY

Please enter your comment!
Please enter your name here

Latest Articles