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