Bugs Rust won't catch

Canonical recently disclosed 44 CVEs in uutils, a Rust reimplementation of GNU coreutils, revealing critical security gaps that Rust's safety features like the borrow checker cannot prevent.
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.
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.
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.
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.
Here’s the bug, simplified from src/uu/install/src/install.rs.
// 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.
The fix uses 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: "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."
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.
This is a close relative of TOCTOU. You want a directory with restrictive permissions, so you write something like this.
// 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.
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.
The original --preserve-root check in chmod was literally this:
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.
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);
}
canonicalize resolves .., ., and symlinks into a real absolute path. That’s a lot better than string comparison. 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.
By the way, my favorite bug in this group is CVE-2026-35363: It refused . and .. but happily accepted ./ and .///, then deleted the current directory while printing Invalid input.
Rust’s String and &str are always UTF-8. That’s a great choice in 99% of all cases, but Unix paths, environment variables, arguments, and the inputs flowing through tools like cut, comm, and tr live in the messy world of bytes.
Every time a Rust program bridges that gap, it has three options:
- Lossy conversion with
from_utf8_lossysilently rewrites invalid bytes to U+FFFD. That’s just fancy data corruption. - Strict conversion with
unwrapor?crashes or refuses to operate. - Staying in bytes with
OsStror&[u8]is what you should usually do.
The audit found bugs in both of the first two categories. Here’s an example from src/uu/comm/src/comm.rs.
// ra, rb are &[u8], raw bytes from the input files.
print!("{}", String::from_utf8_lossy(ra));
print!("{delim}{}", String::from_utf8_lossy(rb));
GNU comm works on binary files because it just shuffles bytes around. The uutils version replaced anything that wasn’t valid UTF-8 with U+FFFD, which silently corrupted the output.
Here’s the fix: stay in bytes.
let mut out = BufWriter::new(io::stdout().lock());
out.write_all(ra)?;
out.write_all(delim)?;
out.write_all(rb)?;
print! forces a UTF-8 round-trip through Display. Write::write_all does not. It writes the raw bytes directly to stdout.
For Unix-flavored systems code, use Path and PathBuf for filesystem paths, OsString for environment variables, and Vec<u8> or &[u8] for stream contents. It’s tempting to round-trip them through String for easier formatting, but that’s where the corruption creeps in.
In a CLI, every unwrap, every expect, every slice index, every unchecked arithmetic operation, every from_utf8 is a potential denial of service if an attacker can shape the input. That’s because a panic! unwinds the stack and aborts the process.
Source: Hacker News















