Rust安全编程的陷阱与应对策略

在当今的编程世界中,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中,有三种主要的数值类型转换方法:

  1. 使用as关键字:这种方法适用于无损和有损转换。在可能会发生数据丢失的情况下(如从i64转换到i32),它会简单地截断值。
  2. 使用From::from():此方法仅允许无损转换。例如,你可以从i32转换到i64,因为所有32位整数都可以放入64位中。然而,你不能使用此方法从i64转换到i32,因为这可能会丢失数据。
  3. 使用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一起,创造属于开发者的多彩世界。

- 智慧链接 思想协作 -

原文链接:,转发请注明来源!