Apr 08, 2025
5 min read
Rust,

“Unsafe” Rust: Common Pitfalls in Rust

This article summarizes common security pitfalls in Rust development, including numeric conversions, integer overflows, array boundary access, oversized array handling, misuse of `unsafe`, time-of-check to time-of-use (TOCTOU) attacks, FFI risks, and circular references. By correctly using type-safe methods, limiting resource sizes, reducing the scope of `unsafe`, combining atomic operations, and managing memory, these issues can be effectively avoided.

We all know that Rust’s biggest selling point is memory safety. The powerful compiler checks and guides us developers through most code security issues. However, this does not mean we can rest easy. In fact, there are still some security issues that the compiler cannot detect. Some are logical security issues, such as type conversion and boundary access; others are external system-induced security issues, such as file system access and FFI interactions.

Numeric Conversion

Using as for casting can lead to value truncation or semantic errors without any compiler warnings.

    let x: u64 = 12345678901234567890;
    let y = x as u32; // Result is 3944680146, no compiler warning  
    println!("{}", y);

The correct approach is to use TryFrom/TryInto instead of as for safe conversions.

    let x: u64 = 12345678901234567890;
    let y = std::convert::TryInto::<u32>::try_into(x);
    dbg!(y);// err

Integer Overflow

Similarly, mathematical operations without restrictions can easily overflow the type range. For example, if one of the numbers exceeds half the range of the type, this risk exists, but the compiler won’t warn you.

fn main() {
    let a: i8 = 127;
    let b: i8 = 1;
    fn sum(a: i8, b: i8) -> i8 {
        a + b
    }
    sum(a, b);
}

The correct approach is to use checked arithmetic operations. Rust has many a.checked_* methods, such as a.checked_add(b) and a.checked_sub(b).

fn main() {
    let a: i8 = 127;
    let b: i8 = 1;
    fn sum(a: i8, b: i8) -> Option<i8> {
        a.checked_add(b)
    }
    sum(a, b); // Returns None
}

Array Boundaries and Access

If an out-of-bounds index is accessed in a fixed-length array, the compiler will throw an error. However, with dynamic vecs, you need to be careful. For example:

    let mut arr = vec![];
    arr.push(1);
    let _ = arr[5]; // Runtime Panic: Out-of-bounds access

The correct approach is to use the get method, which returns None when the index does not exist.

fn main() {
    let mut arr = vec![];
    arr.push(1);
    let f = arr.get(5); // Non-existent index, returns None
}

Oversized Array Boundary Checks

When a function accepts a dynamic array as input, abnormal data might allow attackers to overwhelm the server, exhausting resources. For instance, the following function can be attacked by constructing a very large array:

    let big_array = [255; 1024 * 1024 * 1024 * 1024];
    
    fn vec_some(data:&[u8]) {
        let image = Image::decode(data); // Similar operations may cause memory leaks
        // other code
    }

It’s best to limit the size of the input. Of course, more importantly, additional constraints should be implemented in the preceding processes.

fn vec_some(data: &[u8]) -> Result<(), &'static str> {
    const MAX_DATA_SIZE: usize = 1024 * 1024; // Maximum allowed size is 1MB

    if data.len() > MAX_DATA_SIZE {
        return Err("Input data too large");
    }
    let image = Image::decode(data).map_err(|_| "Decoding failed")?;
    // other code
    Ok(())
}

Misuse of Unsafe or Excessive Unsafe Scope

The scope of unsafe code should be as small as possible to avoid having too much safe code wrapped inside unsafe blocks.

    #[repr(C)]
    union MyUnion {
        i: i32,
        f: f32,
    }

    unsafe {
        let u = MyUnion { i: 42 };
        println!("{}", u.i);
    }

The correct approach:

	#[repr(C)]
	union MyUnion {
		i: i32,
		f: f32,
	}
	let u = MyUnion { i: 42 };
	println!("{}", unsafe { u.i });

Time-of-Check to Time-of-Use (TOCTOU) Attacks

Time-of-check to time-of-use (TOCTOU) attacks exploit the time gap between checking the state of a resource and actually using the resource, allowing attackers to tamper with the resource to bypass security mechanisms. This kind of attack is common in file system operations, permission verification, or race condition scenarios.

The following is a typical example:

fn unsafe_open_file(path: &str) {
	// Check if the file exists (Check)
	if std::path::Path::new(path).exists() {
		// Time window: An attacker could replace the file here
		let file = std::fs::File::open(path).unwrap(); // Use
		// Process file...
	}
}

The reason is that the check operation and the open file operation are two separate atomic operations that have no association. The solution is to combine the check and use into a single atomic operation, eliminating the time window.

    let file = std::fs::OpenOptions::new()
    .read(true)
    .open("/tmp/user_data");

FFI (Foreign Function Interface) Risks

When interacting with C, manually managing memory but forgetting to release it can lead to memory leaks or dangling pointers.

extern "C" {
    fn create_buffer() -> *mut u8;
    fn free_buffer(ptr: *mut u8);
}

let ptr = unsafe { create_buffer() };
// Forgetting to call free_buffer(ptr) → Memory leak

The correct approach is to implement a wrapper and call the memory release method in Drop.

Circular References

Logical errors leading to memory leaks (such as circular references) can go undetected by the compiler.

use std::cell::RefCell;
use std::rc::Rc;

struct Node {
    next: Option<Rc<RefCell<Node>>>,
}
fn main() {
    let a = Rc::new(RefCell::new(Node { next: None }));
    let b = Rc::new(RefCell::new(Node {
        next: Some(Rc::clone(&a)),
    }));
    a.borrow_mut().next = Some(Rc::clone(&b)); // Circular reference, memory leak!
}