In April 2026, Canonical disclosed 44 CVEs in uutils, the Rust reimplementation of GNU coreutils that has been the default in Ubuntu since 25.10. The disclosures came out of an external audit commissioned ahead of the 26.04 LTS release. Most of the bugs were found by code review of a single Rust codebase. None of them were caught by the borrow checker, by clippy lints, or by cargo audit.
The audit is the sharpest case study available for what Rust catches and what it doesn't. The most useful read on the list is Matthias Endler's Bugs Rust Won't Catch, published April 29 on the corrode.dev blog. Endler runs corrode, a Rust consultancy; he hosts the Rust in Production podcast and recently had Canonical's VP of Engineering, Jon Seager, on it. His piece is the analytical companion to the Canonical disclosure: 44 CVEs, sorted into eight categories, with the actual git diff for the fix on most of them.
What follows takes Endler's enumeration as the spine and stacks two more arguments on top — the GNU-coreutils-maintainer testimony from the HN thread, which lands a benchmark that Endler's preferred fix doesn't survive, and the structural argument about what 40 years of accreted POSIX scars do to any rewrite, regardless of language.
What the audit caught, by category
Endler enumerates eight categories. The shape of each is the same: a Rust idiom that the type system endorses, applied in a context where the type system can't see what's wrong.
TOCTOU on path operations is the largest cluster, and the reason cp, mv, and rm are still the GNU implementations in 26.04 LTS rather than uutils. The pattern is one syscall to check, another to act, both taking a &Path. Between them, an attacker with write access to a parent directory can swap the path component for a symlink, the kernel re-resolves on the second call, and the privileged action lands on the attacker's chosen target. The clearest case is CVE-2026-35355 in install:
// 1. Clear the destination
fs::remove_file(to)?;
// ...
// 2. Re-resolves the path. Follows symlinks, truncates.
let mut dest = File::create(to)?;
copy(from, &mut dest)?;
Anyone with write access to the parent directory can plant to as a symlink to /etc/shadow between steps 1 and 2; the privileged process happily overwrites it with whatever from contains. The fix uses OpenOptions::create_new(true), which the docs explicitly say "No file is allowed to exist at the target location, **also no (dangling) symlink."
Permission-set-after-create is the close TOCTOU relative. fs::create_dir(&path)?; fs::set_permissions(&path, ...)?; exposes a window where the directory exists with default permissions and any local user can open() it for a file descriptor that survives the later chmod. The fix is OpenOptions::mode() and DirBuilderExt::mode() so the file or directory is born with the permissions you wanted.
Path string equality is not filesystem identity. The original --preserve-root check in chmod was literally if recursive && preserve_root && file == Path::new("/") { return Err(PreserveRoot); }. That comparison is bypassed by anything that resolves to / but isn't spelled / — /../, /./, a symlink, anything canonicalize would resolve. Run chmod -R 000 /../ and watch it lock down the whole system. The category's most absurd member is CVE-2026-35363:
rm . # ❌
rm .. # ❌
rm ./ # ✅
rm ./// # ✅
rm rejected the bare . and .. but happily accepted ./, deleted the current directory, then printed "Invalid input." The string comparison didn't survive a trailing slash.
UTF-8 versus raw bytes at Unix boundaries. Rust's String and &str are always UTF-8. Unix paths, environment variables, and the byte streams flowing through tools like cut, comm, and tr are not. Every place a Rust program bridges the two, it has three choices: lossy conversion (which silently rewrites invalid bytes to U+FFFD — fancy data corruption, in Endler's phrasing), strict conversion (which crashes on the first non-UTF-8 byte), or staying in OsStr / &[u8]. The audit found bugs in both of the first two. CVE-2026-35346 in comm used String::from_utf8_lossy, so passing a binary file through comm silently mangled the output. The fix replaced print! with BufWriter::write_all, staying in bytes.
Panic-as-DoS. Every unwrap, every expect, every slice index, every unchecked arithmetic operation in input-handling code is a potential denial of service if an attacker can shape the input. CVE-2026-35348 in sort --files0-from called expect() on a UTF-8 conversion of each filename:
$ python3 -c "open('list0','wb').write(b'weird\xffname\0')"
$ coreutils sort --files0-from=list0
thread 'main' panicked at uu_sort-0.2.2/src/sort.rs:1076:18:
Could not parse string from zero terminated input.
GNU sort treats filenames as raw bytes — that's what filenames are. The uutils version aborted on the first non-UTF-8 path. As Endler puts it: "Your nightly cron job is dead and there goes your weekend."
Discarded errors. chmod -R and chown -R returned the exit code of the last file processed instead of the worst one. chmod -R 600 /etc/secrets/* could fail on half the files and exit 0. dd called Result::ok() on its set_len() to mimic GNU's /dev/null behavior, except the same code ran for regular files, so a full disk silently produced a half-written destination.
Behavioral incompatibility with GNU. Endler observes that "a surprising number of these CVEs aren't 'the code does something unsafe' but 'the code does something **different* from GNU, and a shell script somewhere relied on the GNU behavior.'"* The clearest case is CVE-2026-35369 in kill: GNU reads kill -1 <PID> as "signal 1, this PID." uutils read it as "send the default signal to PID -1," which on Linux means every process you can see. A typo turns into a system-wide kill switch.
Resolve before you cross. CVE-2026-35368 is the worst single bug in the audit. It's local root in chroot. Simplified pattern:
chroot(new_root)?;
// Still uid 0, but now inside the attacker-controlled filesystem.
let user = get_user_by_name(name)?;
setgid(user.gid())?;
setuid(user.uid())?;
exec(cmd)?;
get_user_by_name goes through NSS, which dlopens libnss_* modules at runtime. After the chroot, those modules are loaded from the new root filesystem. An attacker who can plant a file in the chroot gets to run code as uid 0. GNU chroot resolves the user before chroot. The fix is the same. Static linking doesn't help; NSS is dynamic regardless.
That's eight classes, 44 CVEs, none caught by the borrow checker.
What the GNU coreutils maintainers said back
The HN thread on Endler's piece runs 361 comments deep, and the most useful single comment is the first one: a self-identified GNU coreutils maintainer disagreeing with one of Endler's rules and showing the cost.
Endler's recommended fix for path-string-equality bugs is fs::canonicalize — resolve .., ., and symlinks into a real absolute path before comparing. The maintainer's response constructs a deeply-nested directory and benchmarks both implementations:
$ mkdir -p $(yes a/ | head -n $((32 * 1024)) | tr -d '\n')
$ while cd $(yes a/ | head -n 1024 | tr -d '\n'); do :; done 2>/dev/null
$ echo a > file
$ time cp file copy
real 0m0.010s
$ time uu_cp file copy
real 0m12.857s
GNU cp finishes in 10 milliseconds. The uutils cp, with the canonicalize-style logic, takes 12.857 seconds. That's a 1,200× slowdown on a contrived but real-shaped path. The maintainer's general point is that GNU "works very hard to avoid arbitrary limits" — the kinds of inputs that look pathological are the inputs that production shell scripts hit, and a 12-second cp in a deep build tree is its own production incident.
The same comment also lands a sharper correction on Endler's strongest claim. The post asserts "The Rust rewrite has shipped zero of these [memory-safety bugs], over a comparable window of activity." The maintainer points to GHSA-w9vv-q986-vj7x, an actual memory-safety advisory in the Rust uutils. The advisory was fixed before LTS shipped. But "zero" is now contested, and Endler's framing suffers proportionally.
The thread does not end Endler's argument. The boundary categories he lists are real. The categorical claim about memory-safety wins, however, gets dialed down from "zero, full stop" to "close to zero, with caveats." That is a smaller difference than the rewrite-vs-keep-it argument turns on, but it's the difference between a slogan and a measurement.
The structural argument from the thread
A second thread of HN testimony argues that the Rust-vs-C framing is the wrong axis entirely. As one commenter put it: "the function signature is what they read, but the scars are what they need." The GNU coreutils lineage runs from the late 1980s fileutils / textutils / shellutils packages (unified into coreutils in 2002), and decades of trickle fixes — a pwd buffer overflow on deep paths longer than 2 * PATH_MAX, an od --strings -N write past a heap buffer, a b2sum --check reading unallocated memory — encode an enormous amount of "don't do this, here's why" knowledge that lives in the codebase as scars rather than as documentation. A clean-room rewrite re-derives some of those scars on the customer's machine.
Another commenter put the same argument bluntly: "They knew how to write Rust, but clearly weren't sufficiently experienced with Unix APIs, semantics, and pitfalls. Most of those mistakes are exceedingly amateur from the perspective of long-time GNU coreutils developers." A third extended it into a process critique: "A rewrite necessarily has orders of magnitude more bugs and vulnerabilities than a decades-old well-maintained codebase, so the security argument was only valid for a long-term transition, not a rushed one."
There is a counter-argument, and Endler's piece is the strongest version of it. The Rust rewrite did not ship the memory-safety class of bug. The audit-published CVE list contains no buffer overflows, no use-after-free, no double-free, no data races on shared mutable state, no null-pointer dereferences, no uninitialized memory reads. GNU coreutils has shipped CVEs in every one of those categories in the past few years alone, by Endler's enumeration:
-
pwdbuffer overflow on deep paths longer than2 * PATH_MAX(9.11, 2026) -
numfmtout-of-bounds read on trailing blanks (9.9, 2025) -
unexpand --tabsheap buffer overflow (9.9, 2025) -
od --strings -Nwrites a NUL byte past a heap buffer (9.8, 2025) -
sort1-byte read before a heap buffer with aSIZE_MAXkey offset (9.8, 2025) -
split --line-bytesheap overwrite (CVE-2024-0684, 9.5, 2024) -
tail -fstack buffer overrun with many files and a highulimit -n(9.0, 2021)
Whatever you think about the Canonical decision to ship uutils into a default Ubuntu install, the comparison Endler is asking you to make is genuine. The Rust rewrite's CVE list reads like a different kind of failure from the GNU CVE list. Same failure surface (privilege-bearing system tools); different class of bug.
Where the security boundary moved
Read all 44 CVEs side by side and the structural picture isn't "Rust failed to catch the bugs." It's "Rust caught the class of bug it catches, and the audit found the next class over."
The next class lives at the boundary between the Rust program and the operating system. Endler names the boundary explicitly: "It lives at the boundary between our controlled Rust environment and the messy, chaotic outside world, where paths, bytes, strings, and syscalls are all tangled up in one eternal ball of sadness. That's the new security boundary of modern systems code." The Rust standard library makes the path-of-least-resistance choice land on the wrong side of that boundary in several places — &Path parameters everywhere, String for things that are actually byte sequences, unwrap and expect available with no friction. The handle-based APIs (openat, fstatat, unlinkat) exist on every Unix; std::fs doesn't put them front and center.1
That's not a Rust-language criticism. It's a std::fs-design observation, and the design choice traces to the cross-platform constraints of Rust 1.0's standard library. As one HN commenter put it, "std::fs suffers from being a lowest common denominator. Rust had to have something at 1.0, and unfortunately it stayed like that." The same shape repeats in nearly every language with a portable filesystem abstraction; it's not unique to Rust. But the framing of the Rust rewrite — "we rewrote it in Rust, therefore it is more secure" — depends on the language doing more work than std::fs is currently set up to do at the syscall boundary.
The audit-list categories — TOCTOU, permission-set-after-create, path-string-equality, UTF-8-vs-bytes, panic-as-DoS, error discarding, GNU compatibility, resolve-before-cross — are the new audit checklist for any privilege-bearing Rust rewrite. None of them is caught by the compiler. All of them are catchable by code review by someone who has read this CVE list. The Rust safety story remains real and remains useful; it just stops at the syscall.
The shape of the next audit
This is the argument that survives both Endler's piece and the HN thread back-and-forth: we rewrote it in Rust is a coherent claim about one specific class of bug. It is not, on its own, a security claim about the rewritten tool. It is a security claim about the absence of the classes of bug Rust's type system catches. The classes the type system doesn't catch — eight of them, on display in 44 CVEs — have to be caught by something else.
Endler's checklist is the right shape for that something else. Grep your codebase for from_utf8_lossy, stray unwrap() calls, discarded Results, File::create, string comparisons against "/", mode-after-create directory operations. None of those is harder to find than a memory-safety bug. They take a different kind of audit and a different kind of reviewer — the kind whose 40 years of POSIX scars are the documentation that the upstream codebase didn't write down.
The 44 CVEs are not a verdict on Rust. They are a measurement of where the next audit should be looking. "We rewrote it in Rust" should be heard the way "we have unit tests" should be heard: a useful claim about a specific class of failure mode, with no claim attached about the ones it doesn't cover.
-
Endler footnotes a related observation: the path-versus-handle TOCTOU class is in some ways easier to avoid in C than in Rust, because C code naturally reaches for an open file descriptor and the
*atfamily of syscalls (openat,fstatat,unlinkat,mkdirat), and most creation syscalls take amodeargument directly. Rust's high-levelstd::fsAPIs abstract over the file descriptor and operate on&Pathvalues, which makes the path-based, re-resolving call the path of least resistance. The handle-based APIs exist on every Unix platform; Rust just doesn't put them front and center. ↩
Top comments (0)