5 Proven Bugs Rust Won't Catch in Systems Programming

A
Admin
·3 min read
0 views
Bugs Rust Won't CatchToctou VulnerabilitySystems Programming In RustHow To Prevent Race ConditionsRust Filesystem SecurityHandling Raw Bytes In Rust

If you think the borrow checker is a silver bullet for security, you’re setting yourself up for a rude awakening. We’ve all seen the marketing: "Rust prevents memory safety bugs." That’s true, but it doesn’t mean your code is secure. The recent audit of uutils—the Rust reimplementation of GNU coreutils—is a masterclass in the bugs Rust won't catch. These aren't memory leaks or data races; they are logic flaws that exist entirely within the realm of "safe" Rust.

The most dangerous category of these bugs is the Time-of-Check to Time-of-Use (TOCTOU) vulnerability. When you write systems code, you’re constantly interacting with the kernel. If you check a path and then act on it, you’re assuming the world hasn't changed in the microsecond between those two syscalls. It has. An attacker with write access to a parent directory can swap a file for a symlink between your check and your action.

Here’s where most people get tripped up: they reach for fs::metadata or File::create because the API is ergonomic. But these functions re-resolve the path from scratch every time. If you’re building a privileged tool, you must anchor your operations on a file descriptor instead. Open the parent directory once, get a handle, and perform your operations relative to that handle. If you find yourself calling two syscalls on the same path, treat it as a potential TOCTOU bug until you’ve proven otherwise.

Diagram showing the TOCTOU race condition between two syscalls

Another common failure mode is setting permissions after creation. You might write fs::create_dir followed by fs::set_permissions. In that tiny window between the two calls, the directory exists with default permissions, allowing any local user to open it. Once they have a file descriptor, your subsequent chmod is useless. The fix is simple but often overlooked: use OpenOptions::mode() or DirBuilderExt::mode() to ensure the file or directory is born with the correct permissions.

That said, there’s a catch regarding how we handle data. Rust’s String and &str are strictly UTF-8. This is a fantastic default for application logic, but it’s a liability when you’re building Unix tools. Unix paths, environment variables, and stream inputs are just raw bytes. When you force these into a String via from_utf8_lossy, you aren't just converting data—you’re silently corrupting it.

I’ve seen developers use print! for binary data, which forces a UTF-8 round-trip through Display. This is exactly how comm ended up with a CVE. Instead, stay in the byte domain. Use OsStr for environment variables and Vec<u8> or &[u8] for stream contents. Use Write::write_all to push raw bytes to stdout rather than relying on formatting macros that assume valid UTF-8.

This next part matters more than it looks: string equality is not filesystem identity. Comparing paths as strings is a recipe for bypasses. If you check file == Path::new("/"), an attacker can just pass /../ or a symlink to bypass your logic. Always resolve paths to their canonical form before comparing them. If you need to verify identity, compare the device and inode pairs.

Writing secure systems code in Rust requires moving beyond the borrow checker. You have to think like a kernel developer. You have to assume the filesystem is a hostile environment where every path is a lie and every byte is a potential exploit. Stop trusting the standard library’s ergonomic defaults when you’re operating at the system boundary.

If you’re currently building a tool that touches the filesystem, audit your path handling today. Are you using Path as a value, or are you anchoring on file descriptors? Share your findings or the weirdest edge case you’ve hit in the comments.

A

Written by Admin

Sharing insights on software engineering, system design, and modern development practices on ByteSprint.io.

See all posts →