Hotdry.

Article

Rust 编译器无法捕获的 44 个 CVE:系统级代码的边界缺陷分析

基于 Canonical 披露的 uutils 审计案例,分析 Rust 编译器安全边界外的 TOCTOU、权限时序、字节边界等系统级缺陷模式与防御策略。

2026-04-29compilers

2026 年 4 月,Canonical 披露了 uutils 项目(Rust 重写的 GNU coreutils)中发现的 44 个 CVE。这是 Ubuntu 26.04 LTS 发布前的外部安全审计结果。值得注意的是,这些缺陷全部出现在由资深开发者编写的生产级 Rust 代码中,却未被 borrow checker、clippy lints 或 cargo audit 捕获。本文将从这批真实的工程案例出发,分析 Rust 编译器安全边界之外的缺陷模式,并给出可落地的防御参数与检查清单。

TOCTOU:两次系统调用之间的竞态窗口

TOCTOU(Time Of Check To Time Of Use)是此次审计中数量最多的缺陷类型,也是 Ubuntu 26.04 LTS 仍默认使用 GNU coreutils 的直接原因。问题本质很简单:对同一个路径执行一次检查操作,再执行一次修改操作,两次调用之间存在时间窗口,攻击者可以在此期间通过符号链接将路径替换为其他目标。

Rust 标准库的 ergonomics 设计使这个问题更容易被引入。开发者习惯性使用的 fs::metadataFile::createfs::remove_filefs::set_permissions 等 API 都接受 &Path 并在每次调用时重新解析路径,而非基于已打开的文件描述符操作。这对普通程序而言完全合理,但若编写需要抵御本地攻击者的特权工具,就必须改变编码习惯。

以 CVE-2026-35355 为例,问题代码简化如下:首先调用 fs::remove_file(to) 清理目标,然后调用 File::create(to) 创建新文件。攻击者只需在两步之间将 to 替换为指向 /etc/shadow 的符号链接,File::create 会跟随符号链接并覆盖目标文件。修复方案是使用 OpenOptions::create_new(true),该方法会拒绝目标位置已存在的任何文件(包括悬空符号链接),从而在原子操作层面消除竞态窗口。

工程参数:对于涉及特权操作的路径操作,始终使用文件描述符而非路径字符串。创建文件时优先选用 create_new(true);操作已存在的文件时,先打开父目录并基于文件描述符操作(使用 *at 系列 syscall 的 Rust 封装)。如果必须对同一路径执行两次操作,假设它存在 TOCTOU 缺陷,直到通过审计证明并非如此。

权限设置时序:创建时而非创建后

与 TOCTOU 类似,权限设置时序问题也是一个时间窗口缺陷。常见写法是先创建文件或目录,再调用 fs::set_permissions 修正权限:

fs::create_dir(&path)?;
fs::set_permissions(&path, Permissions::from_mode(0o700))?;

在这两个调用之间,文件以默认权限存在,系统上的任何其他用户都可以打开它。一旦获得文件描述符,后续的 chmod 无法剥夺已授予的访问权限。

工程参数:使用 OpenOptions::mode()DirBuilderExt::mode() 在创建时直接设置正确的权限,而不是创建后再修改。Rust 标准库在 Unix 平台提供了这些扩展 trait,应优先使用。如果需要确保精确的权限值,需要显式设置 umask

路径字符串比较与文件系统身份

路径的字符串比较不等于文件系统身份比较。chmod 原版的 --preserve-root 检查代码如下:

if recursive && preserve_root && file == Path::new("/") {
    return Err(PreserveRoot);
}

这个检查会被任何解析为 / 但字符串不等于 / 的路径绕过:/..//./、指向根目录的符号链接等。攻击者只需执行 chmod -R 000 /../ 即可绕过保护并锁定整个系统。

工程参数:使用 fs::canonicalize 将路径解析为规范化的绝对路径后再进行比较。对于需要判断两个路径是否指向同一文件的场景,应打开两个路径并比较其 (device, inode) 配对,这是 GNU coreutils 采取的方式。避免字符串比较,改为比较文件系统身份。

字节边界:Unix 接口的 UTF-8 假设

Rust 的 String&str 强制要求 UTF-8 编码,这在 99% 的场景下是正确选择。然而 Unix 路径、环境变量、命令行参数以及流式工具(cutcommtr 等)的输入都是原始字节。跨越这个边界时,开发者通常面临三种选择:使用 from_utf8_lossy 进行有损转换(实际上只是数据损坏)、使用 unwrap? 严格转换(导致崩溃或拒绝操作)、或坚持使用 OsStr&[u8](正确做法)。

审计发现了前两种选择导致的具体漏洞。以 comm 命令(CVE-2026-35346)为例,原代码使用 String::from_utf8_lossy 将输入字节转换为字符串输出。GNU comm 支持二进制文件处理,而 uutils 版本将非 UTF-8 字节替换为 U+FFFD,悄无声息地损坏了输出数据。

工程参数:对于 Unix 系统级代码,使用 Path / PathBuf 处理文件系统路径,使用 OsString / OsStr 处理环境变量,使用 Vec<u8>&[u8] 处理流式内容。避免将原始字节轮转为 String 以简化格式化 —— 那是数据损坏的入口。

Panic 作为拒绝服务攻击向量

在处理不可信输入的 CLI 工具中,每个 unwrapexpect、切片索引、未检查算术运算和 from_utf8 调用都可能是潜在的拒绝服务攻击向量。这是因为 panic 会展开栈并中止进程。如果工具运行在 cron 作业、CI 流水线或 shell 脚本中,整个流程将停止。更糟的是,可能陷入崩溃循环导致系统瘫痪。

典型案例是 sort --files0-from(CVE-2026-35348):该标志从文件读取 NUL 分隔的文件名列表,但解析器对每个名称调用 expect 进行 UTF-8 转换。GNU sort 将文件名视为内核视角下的原始字节,而 uutils 版本要求 UTF-8 并在遇到第一个非 UTF-8 路径时中止整个进程。一个非 UTF-8 文件名即可摧毁你的周末定时任务。

工程参数:在处理不可信输入的代码中,将每个 unwrapexpect、索引操作或 as 转换视为待披露的 CVE。使用 ?getchecked_*try_from 并返回真实错误。推荐在 CI 中配置以下 clippy 规则基线:

[lints.clippy]
unwrap_used      = "warn"
expect_used      = "warn"
panic            = "warn"
indexing_slicing = "warn"
arithmetic_side_effects = "warn"

对于测试代码中合理使用 panic 的场景,在 crate 根目录使用 #![cfg_attr(test, allow(...))] 或在单个测试模块上使用 #[allow(...)] 进行作用域控制。

错误传播:勿丢弃有意义的错误信息

与前一问题密切相关,部分 CVE 源于忽略或丢失错误信息。chmod -Rchown -R 返回最后处理文件的退出码而非最严重的错误码。因此 chmod -R 600 /etc/secrets/* 可能半数文件失败却仍返回成功退出码,脚本误以为一切正常。dd 调用 Result::ok() 丢弃 set_len() 的结果以模拟 GNU 对 /dev/null 的行为,但相同代码也运行在普通文件上,导致磁盘满时静默产生半写入的目标文件。

工程参数:编写代码时若需要丢弃 Result,留下注释解释为何该特定失败可以安全忽略。推荐以下简单模式避免丢失错误信息:

let mut worst = 0;
for file in files {
    if let Err(e) = chmod_one(file) {
        worst = worst.max(e.exit_code());
    }
}
process::exit(worst);

信任边界跨越:解析后再行动

CVE-2026-35368 是审计中最严重的单一漏洞,可在 chroot 中实现本地 root 代码执行。问题模式是:chroot(new_root) 后调用 get_user_by_name(name) 解析用户名,该函数最终从新的根文件系统加载共享库来解析名称。攻击者只要能在 chroot 中植入文件,即可实现 uid 0 的代码执行。GNU chroot 在调用 chroot 之前解析用户。

工程参数:在任何信任边界操作之前完成解析。一旦跨越边界,每个库调用都可能执行攻击者控制的代码。静态链接无法解决此问题,因为 get_user_by_name 依赖 NSS,NSS 会在运行时 dlopen libnss_* 模块,与二进制是否静态链接无关。

Rust 确实阻止了什么

读到这里,可能有人会认为 “Rust 没那么安全”。这是错误的结论。需记住以下糟糕情况一个都未发生:无缓冲区溢出、无 use-after-free、无 double-free、无共享可变状态的数据竞争、无空指针解引用、无未初始化内存读取。GNU coreutils 过去数年每一类都出现过 CVE,而 Rust 重写在这段可比的活动周期内实现了零报告。这涵盖了历史上 C 代码库中绝大多数的漏洞类型。

剩余的缺陷坦率来说是更有趣的一类。它们位于受控的 Rust 环境与混乱的外部世界之间的边界 —— 路径、字节、字符串和 syscall 交织成一团永恒的难题。这是现代系统代码的新安全边界。

落地检查清单:将这份 CVE 列表视为检查清单。在代码中搜索 from_utf8_lossy、游离的 unwrap() 调用、被丢弃的 ResultFile::create 和对 "/" 的字符串比较。这些都是高风险模式的明确信号。

资料来源:本文核心案例来自 Matthias Endler 在 corrode.dev 发表的 "Bugs Rust Won't Catch"(2026-04-29),该文分析了 Canonical 对 uutils(Rust coreutils 重写)的安全审计结果。

compilers