在当今的编程世界中,Rust以其内存安全特性而闻名。然而,内存安全只是构建健壮应用的一个起点。即使在安全的Rust代码中,开发者仍需处理各种风险和边缘情况,如输入验证和业务逻辑的正确性。本文将探讨安全Rust中的一些常见陷阱,并提供避免这些陷阱的方法。
为什么Rust不能总是提供帮助?
尽管Rust在内存安全方面表现出色,但它并不能保护你免受所有类型的错误。以下是一些Rust不保护你免受的错误类型:
- 类型转换错误(如溢出)
- 逻辑错误
- 因使用unwrap或expect导致的恐慌
- 第三方crate中的恶意或不正确的build.rs脚本
- 第三方库中的不安全代码
- 竞态条件
接下来,我们将探讨如何避免这些常见问题。
避免整数溢出
整数溢出错误可能很容易发生。例如:
// 不要这样做:使用未检查的算术运算
fn calculate_total(price: u32, quantity: u32) -> u32 {
price * quantity // 可能溢出!
}
如果price和quantity足够大,结果将溢出。在调试模式下,Rust会恐慌,但在发布模式下,它会静默地环绕。
为了避免这种情况,使用检查过的算术运算:
// 做:使用检查过的算术运算
fn calculate_total(price: u32, quantity: u32) -> Result<u32, ArithmeticError> {
price.checked_mul(quantity)
.ok_or(ArithmeticError::Overflow)
}
静态检查不会被移除,因为它们不会影响生成代码的性能。如果编译器能够在编译时检测到问题,它将会这样做:
fn main() {
let x: u8 = 2;
let y: u8 = 128;
let z = x * y; // 编译时错误!
}
错误信息将是:
error: this arithmetic operation will overflow
--> src/main.rs:4:13
|
4 | let z = x * y; // Compile-time error!
| ^^^^^ attempt to compute `2_u8 * 128_u8`, which would overflow
|
= note: `#[deny(arithmetic_overflow)]` on by default
对于所有其他情况,使用checked_add、checked_sub、checked_mul和checked_div,它们在下溢或溢出时返回None。
避免使用as进行数值转换
在讨论整数算术的同时,让我们谈谈类型转换。使用as进行值的转换很方便,但如果你不知道自己在做什么,这是有风险的。
let x: i32 = 42;
let y: i8 = x as i8; // 可能溢出!
在Rust中,有三种主要的数值类型转换方法:
- 使用as关键字:这种方法适用于无损和有损转换。在可能会发生数据丢失的情况下(如从i64转换到i32),它会简单地截断值。
- 使用From::from():此方法仅允许无损转换。例如,你可以从i32转换到i64,因为所有32位整数都可以放入64位中。然而,你不能使用此方法从i64转换到i32,因为这可能会丢失数据。
- 使用TryFrom:此方法类似于From::from(),但返回一个Result而不是恐慌。这在你需要优雅地处理潜在的数据丢失时非常有用。
如果不确定,优先使用From::from()和TryFrom而不是as。
- 当你能保证没有数据丢失时,使用From::from()。
- 当你需要优雅地处理潜在的数据丢失时,使用TryFrom。
- 只有在你对潜在截断感到满意,或者知道值将适合目标类型的范围,并且性能至关重要时,才使用as。
as操作符对于缩小转换来说是不安全的。它会默默地截断值,导致意外的结果。
什么是缩小转换?当你将一个较大的类型转换为一个较小的类型时,例如从i32转换到i8。
例如,看看as是如何从我们的值中切掉高位的:
fn main() {
let a: u16 = 0x1234;
let b: u8 = a as u8;
println!("0x{:04x}, 0x{:02x}", a, b); // 输出:0x1234, 0x34
}
因此,回到我们之前的第一个例子,与其写:
let x: i32 = 42;
let y: i8 = x as i8; // 可能溢出!
不如使用TryFrom并优雅地处理错误:
let y = i8::try_from(x).ok_or("Number is too big to be used here")?;
使用有界类型进行数值转换
有界类型使表达不变式和避免无效状态变得更加容易。
例如,如果你有一个数值类型,0永远不是正确的值,使用std::num::NonZeroUsize。
你还可以创建自己的有界类型:
// 不要这样做:为域值使用原始数值类型
struct Measurement {
distance: f64, // 可能为负数!
}
// 做:创建有界类型
#[derive(Debug, Clone, Copy)]
struct Distance(f64);
impl Distance {
pub fn new(value: f64) -> Result<Self, DistanceError> {
if value < 0.0 || !value.is_finite() {
return Err(DistanceError::Invalid);
}
Ok(Distance(value))
}
}
struct Measurement {
distance: Distance,
}
不要在没有边界检查的情况下对数组进行索引
每当我看到以下代码时,我都会感到不寒而栗:
let arr = [1, 2, 3];
let elem = arr[3]; // 恐慌!
这是一个常见的错误来源。与C不同,Rust确实会检查数组边界并防止安全漏洞,但它仍然会在运行时恐慌。
相反,使用get方法:
let elem = arr.get(3);
它返回一个Option,你可以优雅地处理它。
更多关于这个主题的信息,请参阅这篇博客文章。
使用split_at_checked而不是split_at
这个问题与前面的问题有关。假设你有一个切片,想要在某个索引处分割它:
let mid = 4;
let arr = [1, 2, 3];
let (left, right) = arr.split_at(mid);
你可能期望这返回一个元组,其中第一个切片包含所有元素,第二个切片为空。
相反,上述代码会因为mid索引超出范围而恐慌!
为了更优雅地处理这种情况,使用split_at_checked:
let arr = [1, 2, 3];
// 这返回一个Option
match arr.split_at_checked(mid) {
Some((left, right)) => {
对left和right进行操作
}
None => {
处理错误
}
}
这返回一个Option,允许你处理错误情况。
更多关于split_at_checked的信息,请参阅这里。
避免在业务逻辑中使用原始类型
使用原始类型来处理所有事情是非常诱人的。尤其是Rust初学者容易掉入这个陷阱。
// 不要这样做:为用户名使用原始类型
fn authenticate_user(username: String) {
原始字符串可以是任何内容 - 空、太长或包含无效字符
}
然而,你真的接受任何字符串作为有效的用户名吗?如果它是空的呢?如果它包含表情符号或特殊字符呢?
你可以为你的域创建一个自定义类型:
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct Username(String);
impl Username {
pub fn new(name: &str) -> Result<Self, UsernameError> {
if name.is_empty() {
return Err(UsernameError::Empty);
}
if name.len() > 30 {
return Err(UsernameError::TooLong);
}
if !name.chars().all(|c| c.is_alphanumeric() || c == '_') {
return Err(UsernameError::InvalidCharacters);
}
Ok(Username(name.to_string()))
}
允许获取对内部字符串的引用
pub fn as_str(&self) -> &str {
&self.0
}
}
fn authenticate_user(username: Username) {
我们知道这始终是一个有效的用户名!
没有空字符串,没有表情符号,没有空格等。
}
使无效状态无法表示
下一个要点与前面的点密切相关。
你能发现以下代码中的错误吗?
// 不要这样做:允许无效组合
struct Configuration {
port: u16,
host: String,
ssl: bool,
ssl_cert: Option<String>,
}
问题是,你可以将ssl设置为true,但ssl_cert设置为None。这是一个无效状态!如果你尝试使用SSL连接,你不能,因为没有证书。这个问题可以在编译时被检测到:
使用类型来强制有效的状态:
// 首先,让我们定义连接的可能状态
enum ConnectionSecurity {
Insecure,
我们不能在没有证书的情况下使用SSL连接!
Ssl { cert_path: String },
}
struct Configuration {
port: u16,
host: String,
现在我们不能有无效状态!
要么我们有一个带有证书的SSL连接
要么我们根本没有SSL。
security: ConnectionSecurity,
}
与前一节相比,错误是由密切相关字段的无效组合引起的。为了防止这种情况,清楚地映射出所有可能的状态及其转换。一个简单的方法是为每个状态定义一个枚举,并为其提供可选的元数据。
如果你对这个主题感兴趣,这里有一篇更深入的博客文章。
小心处理默认值
为你的类型添加一个通用的Default实现是很常见的。但这可能导致不可预见的问题。
例如,这里是一个情况,端口默认设置为0,这不是一个有效的端口号。
// 不要这样做:在没有考虑的情况下实现`Default`
#[derive(Default)] // 可能创建无效状态!
struct ServerConfig {
port: u16, // 将是0,这不是一个有效的端口!
max_connections: usize,
timeout_seconds: u64,
}
相反,考虑为你的类型实现一个有意义的默认值。
// 做:使默认值有意义或不实现它
struct ServerConfig {
port: Port,
max_connections: NonZeroUsize,
timeout_seconds: Duration,
}
impl ServerConfig {
pub fn new(port: Port) -> Self {
Self {
port,
max_connections: NonZeroUsize::new(100).unwrap(),
timeout_seconds: Duration::from_secs(30),
}
}
}
安全地实现Debug
如果你盲目地为你的类型派生Debug,你可能会暴露敏感数据。相反,为包含敏感信息的类型手动实现Debug。
// 不要这样做:在调试输出中暴露敏感数据
#[derive(Debug)]
struct User {
username: String,
password: String, // 将在调试输出中打印!
}
相反,你可以这样做:
// 做:手动实现Debug
#[derive(Debug)]
struct User {
username: String,
password: Password,
}
struct Password(String);
impl std::fmt::Debug for Password {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("[REDACTED]")
}
}
fn main() {
let user = User {
username: String::from(""),
password: Password(String::from("")),
};
println!("{user:#?}");
}
这将打印:
User {
username: "",
password: [REDACTED],
}
在生产代码中,使用像secrecy这样的crate。
然而,这也不是非黑即白的:如果你手动实现Debug,你可能会在结构体变化时忘记更新实现。一个常见的模式是在Debug实现中解构结构体以捕获此类错误。
与其这样做:
// 不要这样做
impl std::fmt::Debug for DatabaseURI {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}://{}:[REDACTED]@{}/{}", self.scheme, self.user, self.host, self.database)
}
}
不如解构结构体以捕获变化:
// 做
impl std::fmt::Debug for DatabaseURI {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let DatabaseURI { scheme, user, password: _, host, database, } = self;
write!(f, "{scheme}://{user}:[REDACTED]@{host}/{database}")?;
Ok(())
}
}
小心处理序列化
不要盲目地派生Serialize和Deserialize,特别是对于敏感数据。你读取/写入的值可能不是你期望的!
// 不要这样做:盲目地派生Serialize和Deserialize
#[derive(Serialize, Deserialize)]
struct UserCredentials {
#[serde(default)] // 在反序列化时接受空字符串!
username: String,
#[serde(default)]
password: String, // 在序列化时泄露密码!
}
在反序列化时,字段可能是空的。空凭据可能通过验证检查,如果处理不当的话。
此外,序列化行为也可能泄露敏感数据。默认情况下,Serialize会将密码字段包含在序列化输出中,这可能会在日志、API响应或调试输出中暴露敏感凭据。
一个常见的修复方法是通过使用impl<'de> Deserialize<'de> for UserCredentials来自定义自己的序列化和反序列化方法。
一个替代策略是使用#[serde(try_from = "FromType")]属性。
以Password字段为例。首先,使用newtype模式包装标准类型并添加自定义验证:
#[derive(Deserialize)]
// 告诉serde使用`Password::try_from`与`String`
#[serde(try_from = "String")]
pub struct Password(String);
现在为Password实现TryFrom:
impl TryFrom<String> for Password {
type Error = PasswordError;
创建一个新密码
如果密码太短,则抛出错误。
你可以在这里添加更多的检查。
fn try_from(value: String) -> Result<Self, Self::Error> {
if value.len() < 8 {
return Err(PasswordError::TooShort);
}
Ok(Password(value))
}
}
使用这个技巧,你不能再反序列化无效密码:
// 恐慌:密码太短!
let password: Password = serde_json::from_str(r#""pass""#).unwrap();
防范时间检查到时间使用(TOCTOU)漏洞
这是一个更高级的主题,但了解它是很重要的。TOCTOU(time-of-check to time-of-use)是一类软件错误,由于在你检查条件和使用资源之间发生的更改。
// 不要这样做:带有单独检查和使用的易受攻击方法
fn remove_dir(path: &Path) -> io::Result<()> {
首先检查它是否是一个目录
if !path.is_dir() {
return Err(io::Error::new(
io::ErrorKind::NotADirectory,
"not a directory"
));
}
TOCTOU漏洞:在上面的检查和下面的使用之间,
路径可能被替换为指向我们不应该访问的目录的符号链接!
remove_dir_impl(path)
}
更安全的方法首先打开目录,确保我们操作的是我们检查过的那个:
// 做:更安全的方法,先打开,然后检查
fn remove_dir(path: &Path) -> io::Result<()> {
打开目录,不要跟随符号链接
let handle = OpenOptions::new()
.read(true)
.custom_flags(O_NOFOLLOW | O_DIRECTORY) // 如果不是目录或是一个符号链接,则失败
.open(path)?;
现在我们可以使用打开的句柄安全地删除目录内容
remove_dir_impl(&handle)
}
这里为什么更安全:在我们持有句柄时,目录不能被替换为符号链接。这样,我们正在操作的目录就是我们检查过的那个。任何替换它的尝试都不会影响我们,因为句柄已经打开。
如果你之前没有注意到这个问题,那是可以理解的。事实上,即使是Rust核心团队也在标准库中错过了这一点。你所看到的是std::fs::remove_dir_all函数中一个实际漏洞的简化版本。更多关于CVE-2022-21658的信息,请参阅这篇博客文章。
使用恒定时间比较来处理敏感数据
定时攻击是一种巧妙的方法,可以从你的应用程序中提取信息。其想法是,比较两个值所需的时间可以泄露有关它们的信息。例如,比较两个字符串所需的时间可以揭示有多少个字符是正确的。因此,在处理敏感数据(如密码)时,对于生产代码,要小心常规的相等性检查。
// 不要这样做:对敏感比较使用常规相等性
fn verify_password(stored: &[u8], provided: &[u8]) -> bool {
stored == provided // 易受定时攻击!
}
// 做:使用恒定时间比较
use subtle::{ConstantTimeEq, Choice};
fn verify_password(stored: &[u8], provided: &[u8]) -> bool {
stored.ct_eq(provided).unwrap_u8() == 1
}
不要接受无界输入
通过设置资源限制来防范拒绝服务攻击。这些攻击发生在你接受无界输入时,例如一个巨大的请求体可能无法放入内存。
// 不要这样做:接受无界输入
fn process_request(data: &[u8]) -> Result<(), Error> {
let decoded = decode_data(data)?; // 可能非常大!
处理解码后的数据
Ok(())
}
相反,为你的接受的有效载荷设置明确的限制:
const MAX_REQUEST_SIZE: usize = 1024 * 1024; // 1MiB
fn process_request(data: &[u8]) -> Result<(), Error> {
if data.len() > MAX_REQUEST_SIZE {
return Err(Error::RequestTooLarge);
}
let decoded = decode_data(data)?;
处理解码后的数据
Ok(())
}
Path::join与绝对路径的意外行为
如果你使用Path::join将相对路径与绝对路径连接,它将默默地用绝对路径替换相对路径。
use std::path::Path;
fn main() {
let path = Path::new("/usr").join("/local/bin");
println!("{path:?}"); // 打印"/local/bin"
}
这是因为如果第二个路径是绝对路径,Path::join将返回第二个路径。
尽管如此,我仍然认为这是一个容易出错的地方。在使用用户提供的路径时,很容易忽略这种行为。也许join应该返回一个Result?无论如何,要意识到这种行为。
使用cargo-geiger检查依赖项中的不安全代码
到目前为止,我们只讨论了你自己的代码中的问题。对于生产代码,你还需要检查你的依赖项。特别是不安全代码可能是一个问题。如果你有很多依赖项,这可能相当具有挑战性。
cargo-geiger是一个很酷的工具,可以检查你的依赖项中的不安全代码。它可以帮助你识别项目中的潜在安全风险。
cargo install cargo-geiger
cargo geiger
这将给你一个报告,说明你的依赖项中有多少不安全函数。基于此,你可以决定是否要保留一个依赖项。
Clippy可以帮助预防这些问题
以下是一组Clippy lints,可以帮助你在编译时捕获这些问题。在Rust playground上亲自查看。
以下是重点:
- cargo check将不会报告任何问题。
- cargo run将恐慌或静默失败。
- cargo clippy将在编译时捕获所有问题!
// 算术运算
#![deny(arithmetic_overflow)] // 防止导致整数溢出的操作
#![deny(clippy::checked_conversions)] // 建议在数值类型之间使用检查过的转换
#![deny(clippy::cast_possible_truncation)] // 检测当转换可能导致值截断时
#![deny(clippy::cast_sign_loss)] // 检测当转换可能导致丢失符号信息时
#![deny(clippy::cast_possible_wrap)] // 检测当转换可能导致值环绕时
#![deny(clippy::cast_precision_loss)] // 检测当转换可能导致丢失精度时
#![deny(clippy::integer_division)] // 突出显示由于整数除法截断可能导致的潜在错误
#![deny(clippy::arithmetic_side_effects)] // 检测具有潜在副作用的算术运算
#![deny(clippy::unchecked_duration_subtraction)] // 确保持续时间减法不会导致下溢
// 解包操作
#![warn(clippy::unwrap_used)] // 不鼓励使用.unwrap(),这可能导致恐慌
#![warn(clippy::expect_used)] // 不鼓励使用.expect(),这可能导致恐慌
#![deny(clippy::panicking_unwrap)] // 防止在已知会导致恐慌的情况下使用unwrap
#![deny(clippy::option_env_unwrap)] // 防止在环境变量可能不存在时使用unwrap
// 数组索引
#![deny(clippy::indexing_slicing)] // 避免直接数组索引,改用更安全的方法
// 路径处理
#![deny(clippy::join_absolute_paths)] // 防止在连接绝对路径时出现问题
// 序列化问题
#![deny(clippy::serde_api_misuse)] // 防止错误使用Serde的序列化/反序列化API
// 无界输入
#![deny(clippy::uninit_vec)] // 防止创建未初始化的向量,这可能是不安全的
// 不安全代码检测
#![deny(clippy::transmute_int_to_char)] // 防止从整数到字符的不安全转换
#![deny(clippy::transmute_int_to_float)] // 防止从整数到浮点数的不安全转换
#![deny(clippy::transmute_ptr_to_ref)] // 防止从指针到引用的不安全转换
#![deny(clippy::transmute_undefined_repr)] // 检测具有潜在未定义表示的转换
use std::path::Path;
use std::time::Duration;
fn main() {
算术问题
整数溢出:这将在调试模式下恐慌,在发布模式下静默环绕
let a: u8 = 255;
let _b = a + 1;
不安全的转换:可能会截断值
let large_number: i64 = 1_000_000_000_000;
let _small_number: i32 = large_number as i32;
转换时符号丢失
let negative: i32 = -5;
let _unsigned: u32 = negative as u32;
整数除法可能会截断结果
let _result = 5 / 2; // 结果为2,而不是2.5
持续时间减法可能会下溢
let short = Duration::from_secs(1);
let long = Duration::from_secs(2);
let _negative = short - long; // 这将下溢
解包问题
对可能为None的Option使用unwrap
let data: Option<i32> = None;
let _value = data.unwrap();
对可能为Err的Result使用expect
let result: Result<i32, &str> = Err("error occurred");
let _value = result.expect("This will panic");
尝试获取可能不存在的环境变量
let _api_key = std::env::var("API_KEY").unwrap();
数组索引问题
没有边界检查的直接索引
let numbers = vec![1, 2, 3];
let _fourth = numbers[3]; // 这将恐慌
安全替代方法,使用.get()
if let Some(fourth) = numbers.get(3) {
println!("{fourth}");
}
路径处理问题
连接绝对路径会丢弃基路径
let base = Path::new("/home/user");
let _full_path = base.join("/etc/config"); // 结果为"/etc/config",基路径被忽略
安全替代方法
let base = Path::new("/home/user");
let relative = Path::new("config");
let full_path = base.join(relative);
println!("Safe path joining: {:?}", full_path);
不安全代码问题
创建未初始化的向量(可能导致未定义行为)
let mut vec: Vec<String> = Vec::with_capacity(10);
unsafe {
vec.set_len(10); }
}
结论
哇,这么多陷阱!你知道其中的多少?
即使Rust是一个编写安全、可靠代码的伟大语言,开发者仍然需要自律以避免错误。
我们看到的许多常见错误与Rust作为一个系统编程语言有关:在计算系统中,许多操作是性能关键的,本质上是不安全的。我们正在处理外部系统,这些系统不受我们的控制,如操作系统、硬件或网络。目标是在不安全的世界之上构建安全的抽象。
Rust与C共享一个FFI接口,这意味着它可以做C能做的一切。因此,虽然Rust允许的一些操作在理论上是可能的,但它们可能导致意外的结果。
但并非一切都失去了!如果你知道这些陷阱,你可以避免它们,使用上述Clippy lints,你可以在编译时捕获大多数问题。
这就是为什么在Rust中测试、检查和模糊测试仍然很重要。
关于Allthinker 敖行客:
公司专注于通过先进的理念与技术,为开发者打造开放、自由、高效且安全的研发空间,期待与你一起创造一个更美好的研发新世界。
关于AT Work:
AT Work是敖行客打造的下一代研发智能体,基于自主研发的"思链"认知引擎构建,实现云原生研发场景的全面智能化革新。作为业内首个搭载多模态AI中台的云端研发平台,通过深度学习模型重构需求分析、代码生成、质量管控、知识管理四大核心模块,深度融合云IDE、敏捷看板、共享云盘、云文档、云端知识库等数字工具链,形成"需求-设计-开发-测试-交付"的智能闭环。
科技脉搏,每日跳动。
与敖行客 Allthinker一起,创造属于开发者的多彩世界。
- 智慧链接 思想协作 -