Bugs Rust won't catch

Bugs Rust won’t catch

Rust 无法捕获的 Bug

Idiomatic Rust Bugs Rust Won’t Catch by Matthias Endler Published: 2026-04-29 作者:Matthias Endler | 发布日期:2026-04-29

In April 2026, Canonical disclosed 44 CVEs in uutils, the Rust reimplementation of GNU coreutils that ships by default since 25.10. Most of them came out of an external audit commissioned ahead of the 26.04 LTS. I read through the list and thought there’s a lot to learn from it. What’s notable is that all of these bugs landed in a production Rust codebase, written by people who knew what they were doing, and none of them were caught by the borrow checker, clippy lints, or cargo audit. 2026 年 4 月,Canonical 公布了 uutils 中的 44 个 CVE。uutils 是 GNU coreutils 的 Rust 重写版本,自 25.10 版本起成为默认组件。其中大部分漏洞源于 26.04 LTS 发布前委托进行的外部审计。我通读了这份列表,认为其中有很多值得学习的地方。值得注意的是,所有这些 Bug 都出现在生产环境的 Rust 代码库中,且由经验丰富的开发者编写,但无论是借用检查器(borrow checker)、Clippy 代码检查工具还是 cargo audit,都没能捕获到它们。

I’m not writing this to criticize the uutils team. Quite the contrary; I actually want to thank them for sharing the audit results in such detail so that we can all learn from them. We also had Jon Seager, VP Engineering for Ubuntu, on our ‘Rust in Production’ podcast recently and a lot of listeners appreciated his honesty about the state of Rust at Canonical. If you write systems code in Rust, this is the most concentrated look at where Rust’s safety ends that you’ll likely find anywhere right now. 我写这篇文章并非为了批评 uutils 团队。恰恰相反,我非常感谢他们如此详细地分享审计结果,让我们都能从中受益。我们最近还在“Rust in Production”播客中邀请了 Ubuntu 工程副总裁 Jon Seager,许多听众对他坦诚分享 Canonical 内部 Rust 的现状表示赞赏。如果你正在用 Rust 编写系统代码,这将是你目前能找到的、关于 Rust 安全边界最集中的分析。

Don’t Trust a Path Across Two Syscalls

不要信任跨越两次系统调用的路径

This is the largest cluster of bugs in the audit. It’s also the reason cp, mv, and rm are still GNU in Ubuntu 26.04 LTS. :( The pattern is always the same. You do one syscall to check something about a path, then another syscall to act on the same path. Between those two calls, an attacker with write access to a parent directory can swap the path component for a symbolic link. The kernel re-resolves the path from scratch on the second call, and the privileged action lands on the attacker’s chosen target. 这是审计中发现的最大一类 Bug。这也是为什么 Ubuntu 26.04 LTS 中 cp、mv 和 rm 命令依然使用 GNU 版本的原因 :(。其模式总是相同的:你先执行一次系统调用来检查路径的某些信息,然后再执行另一次系统调用对同一路径进行操作。在这两次调用之间,拥有父目录写权限的攻击者可以将路径组件替换为符号链接。内核在第二次调用时会重新解析路径,从而导致特权操作作用于攻击者指定的目标上。

Rust’s standard library makes this easy to get wrong. The ergonomic APIs you reach for first (fs::metadata, File::create, fs::remove_file, fs::set_permissions) all take a path and re-resolve it every time, rather than taking a file descriptor and operating relative to that. That’s fine for a normal program, but if you’re writing a privileged tool that needs to be secure against local attackers, you have to be careful. Rust 的标准库很容易让人犯这种错。你最先想到的那些便捷 API(如 fs::metadataFile::createfs::remove_filefs::set_permissions)每次都会接收路径并重新解析,而不是接收文件描述符并基于该描述符进行操作。对于普通程序来说这没问题,但如果你编写的是需要防范本地攻击者的特权工具,就必须格外小心。

Case Study: CVE-2026-35355

案例研究:CVE-2026-35355

Here’s the bug, simplified from src/uu/install/src/install.rs. 以下是简化自 src/uu/install/src/install.rs 的 Bug 代码:

// 1. Clear the destination
fs::remove_file(to)?;
// ...
// 2. Create the destination. The path is re-resolved here!
let mut dest = File::create(to)?; // follows symlinks, truncates
copy(from, &mut dest)?;

Between step 1 and step 2, anyone with write access to the parent directory can plant to as a symlink to, say, /etc/shadow. Then File::create follows the symlink and the privileged process happily overwrites /etc/shadow with whatever from happened to contain. 在第 1 步和第 2 步之间,任何拥有父目录写权限的人都可以将 to 替换为一个指向(例如)/etc/shadow 的符号链接。随后 File::create 会跟随该符号链接,特权进程就会愉快地用 from 的内容覆盖掉 /etc/shadow

The fix uses OpenOptions::create_new(true): 修复方法是使用 OpenOptions::create_new(true)

fs::remove_file(to)?;
let mut dest = OpenOptions::new()
    .write(true)
    .create_new(true)
    .open(to)?;
copy(from, &mut dest)?;

The docs for create_new say (emphasis mine): create_new 的文档说明如下(重点部分):

No file is allowed to exist at the target location, also no (dangling) symlink. In this way, if the call succeeds, the file returned is guaranteed to be new. 目标位置不允许存在任何文件,也不允许存在(悬空的)符号链接。因此,如果调用成功,返回的文件保证是全新的。

Rule: Anchor on a File Descriptor Instead 规则:改用文件描述符作为锚点

A &Path in Rust looks like a value, but remember that to the kernel it’s just a name. That name can point to different things from one syscall to the next. Anchor your operations on a file descriptor instead. create_new() only helps with that when you’re creating a new file. For everything else, open the parent directory once and work relative to that handle. If you act on the same path twice, assume it’s a TOCTOU (Time Of Check To Time Of Use) bug until you’ve proven otherwise. Rust 中的 &Path 看起来像一个值,但请记住,对内核而言它只是一个名称。这个名称在两次系统调用之间可能指向不同的事物。请改用文件描述符来锚定你的操作。create_new() 仅在创建新文件时有效。对于其他所有操作,请打开父目录一次,并基于该句柄进行操作。如果你对同一个路径操作了两次,在证明其安全之前,请默认它是一个 TOCTOU(检查时刻到使用时刻)漏洞。

Set Permissions at Creation Time, Not After

在创建时设置权限,而不是之后

This is a close relative of TOCTOU. You want a directory with restrictive permissions, so you write something like this: 这是 TOCTOU 的一个近亲。如果你想要一个具有严格权限的目录,你可能会这样写:

// Create with default permissions
fs::create_dir(&path)?;
// Fix up permissions
fs::set_permissions(&path, Permissions::from_mode(0o700))?;

For a brief moment, path exists with the default permissions. Any other user on the system can open() it during that window. Once they have a file descriptor, the later chmod doesn’t take it away from them. 在短暂的时间内,path 以默认权限存在。系统上的任何其他用户都可以在此窗口期内对其进行 open() 操作。一旦他们获得了文件描述符,后续的 chmod 也无法将其收回。

Rule: Set Permissions at Creation, Never After 规则:在创建时设置权限,永远不要事后设置

Reach for OpenOptions::mode() and DirBuilderExt::mode() so the file or directory is born with the permissions you want. The kernel will apply your umask on top, so set that explicitly too if you really care. 请使用 OpenOptions::mode()DirBuilderExt::mode(),让文件或目录在创建时就具备你想要的权限。内核会在其之上应用你的 umask,所以如果你非常在意,请显式设置它。

String Equality on Paths Is Not the Same as Filesystem Identity

路径的字符串相等性不等同于文件系统标识

The original —preserve-root check in chmod was literally this: chmod 中原始的 --preserve-root 检查代码实际上是这样的:

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

That comparison is bypassed by anything that resolves to / but isn’t spelled /. So /../, /./, /usr/.., or a symlink that points to /. Run chmod -R 000 /../ and see it rip right past your check and lock down the whole system. 这种比较会被任何解析为 / 但拼写不是 / 的路径绕过。例如 /..//.//usr/..,或者指向 / 的符号链接。运行 chmod -R 000 /../,你会发现它直接跳过了检查并锁定了整个系统。

Here’s the fix: 修复方法如下:

fn is_root(file: &Path) -> bool {
    matches!(fs::canonicalize(file), Ok(p) if p == Path::new("/"))
}

if recursive && preserve_root && is_root(file) {
    return Err(PreserveRoot);
}

Rule: Resolve Paths Before Comparing Them 规则:在比较路径之前先解析它们

canonicalize resolves .., ., and symlinks into a real absolute path. That’s a lot better than string comparison. canonicalize 会将 ... 和符号链接解析为真实的绝对路径。这比字符串比较要好得多。

Oh and if you were wondering about this line: matches!(fs::canonicalize(file), Ok(p) if p == Path::new("/")) 顺便说一下,如果你对这一行感到好奇:matches!(fs::canonicalize(file), Ok(p) if p == Path::new("/"))

I think that’s just a fancy way of saying: 我认为这只是一种更花哨的写法,等同于:

// First, resolve the path to its canonical form
if let Ok(p) = fs::canonicalize(file) {
    // If that succeeded, check if the canonical path is "/"
    p == Path::new("/")
} else {
    false
}

In the specific case of —preserve-root, this works because / has no parent directory, so there’s nothing for an attacker to swap from underneath you. In the more general case of comparing two arbitrary paths for filesystem identity, however, you’d want to open both and compare their (dev, inode) pairs, the way GNU coreutils does. (Think identity, not string equality.) 在 --preserve-root 这个特定案例中,这种方法有效,因为 / 没有父目录,所以攻击者无法在下方进行替换。然而,在比较两个任意路径是否指向同一文件系统对象的更通用场景中,你应该打开两者并比较它们的 (dev, inode) 对,就像 GNU coreutils 所做的那样。(要考虑标识,而不是字符串相等性。)

By the way, my favorite bug in this group is CVE-2026-35363: 顺便提一下,这一组中我最喜欢的 Bug 是 CVE-2026-35363:

rm . # ❌ rm .. # ❌ rm ./ # ✅ rm ./// # ✅

It refused . and .. but happily accepted ./ and .///, then deleted the current directory while printing Invalid input. 😅 它拒绝了 ...,但愉快地接受了 ././//,然后删除了当前目录,同时打印出“Invalid input”。😅

Stay in Bytes at Unix. 在 Unix 中请保持使用字节。