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!
}