Apr 08, 2025
3 min read
Rust,

“不安全”的 Rust: 一些常见的 Rust 陷阱

本文总结了 Rust 开发中常见的安全性陷阱,包括数值转换、整数溢出、数组边界访问、超大数组处理、滥用 `unsafe`、时间攻击(TOCTOU)、FFI 隐患及循环引用等问题。通过正确使用类型安全方法、限制资源大小、减少 `unsafe` 范围、合并原子操作及管理内存等方式,可有效避免这些问题。

我们都知道,Rust 的最大卖点是内存安全。强大的编译器为我们开发者检查以及指导了大多数代码的安全问题。但是,这并不意味着就高枕无忧了。事实上,编译器还存在一些无法检测到的安全问题。有一些是逻辑上的安全问题,如类型转换,边界访问;有一些则是外部系统引发的安全问题,如访问文件系统,FFI 交互等。

数值转换

as 会强制转换导致值截断或语义错误,此时并不会有编译器警告。

    let x: u64 = 12345678901234567890;
    let y = x as u32; // 结果为 3944680146,无编译警告  
    println!("{}", y);

正确的做法是使用 TryFrom/TryInto 替代 as 进行安全转换。

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

整数溢出

同样地,数学运算如果不加以限制,很容易直接溢出类型范围。如下面,只要其中一个数超过类型范围的地一半,就会存在这个风险,但是编译器不会提示你。

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

正确的做法是,最好使用带检查的运算操作。Rust 中存在大量 a.checked_* 系列方法,如a.checked_add(b)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); //得到 None
}

数组边界与访问

定长的数组如果访问越界的下标,编译器会给出错误提示。但是,如果是动态 vec,则要小心了。比如下面:

    let mut arr = vec![];
    arr.push(1);
    let _ = arr[5]; // 运行时 Panic:越界访问

正确的做法是,使用 get 方法,当下标不存在时,返回 None。

fn main() {
    let mut arr = vec![];
    arr.push(1);
    let f = arr.get(5); //不存在的下标,返回None
}

超大数组边界检查

当参数接受一个动态数组作为入参时,不正常的 data 可能会让服务器受到攻击,造成资源耗尽。例如下面这个函数,可以构造一个超大的数组进行攻击:

    let big_array = [255; 1024 * 1024 * 1024 * 1024];
    
    fn vec_some(data:&[u8]) {
        let image = Image::decode(data); //类似的操作会可能会造成内存泄漏
        // other code
    }

最好是限制入参的大小。当然,更重要的是应该在前置流程中做更多的限制。

fn vec_some(data: &[u8]) -> Result<(), &'static str> {
    const MAX_DATA_SIZE: usize = 1024 * 1024; // 最大允许 1MB

    if data.len() > MAX_DATA_SIZE {
        return Err("输入数据过大");
    }
    let image = Image::decode(data).map_err(|_| "解码失败")?;
    // other code
    Ok(())
}

滥用 unsafe 或者unsafe 边界过大

unsafe 代码的边界应该尽可能地小,以避免 safe 代码过多的被包裹在 unsafe 代码里。

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

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

正确的做法:

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

时间(TOCTOU)攻击

检查时间到使用时间攻击,是一种利用程序在检查资源状态实际使用资源之间的时间差,通过篡改资源来绕过安全机制的漏洞攻击方式。这种攻击常见于文件系统操作、权限验证或竞态条件(Race Condition)场景中。

下面这个就比较典型的案例:

fn unsafe_open_file(path: &str) {
	// 检查文件是否存在(Check)
	if std::path::Path::new(path).exists() {
		// 时间窗口:攻击者可在此替换文件
		let file = std::fs::File::open(path).unwrap(); // Use
		// 处理文件...
	}
}

原因是检查操作与打开文件操作是两个原子操作,这两个操作并不存在关联性。解决的办法是,将检查和使用合并为一个原子操作,消除时间窗口。

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

FFI(外部函数接口)隐患

Rust 与 C 交互时,手动管理内存,但是忘记释放内在导致泄漏或悬垂指针。

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

let ptr = unsafe { create_buffer() };
// 忘记调用 free_buffer(ptr) → 内存泄漏

正确的做法应该是实现一个封装器,并在 Drop 中调用释放内存方法。

循环引用

如何逻辑性错误出现导致的内存泄漏(如循环引用),会让编译器无法检测出来。

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)); // 循环引用,内存泄漏!
}