2026 年 4 月,Canonical 披露了 uutils 中存在的 44 個 CVE 漏洞。uutils 是 GNU coreutils 的 Rust 重寫版本,自 25.10 版本起成為預設套件。其中大部分漏洞源於在 26.04 LTS 發布前委託進行的外部審計。
我仔細閱讀了這些漏洞列表,認為其中蘊含許多值得學習的經驗。
值得注意的是,所有這些錯誤都出現在一個生產級的 Rust 程式碼庫中,由經驗豐富的開發者編寫,而且沒有一個被借用檢查器 (borrow checker)、clippy lints 或 cargo audit 偵測到。
我寫這篇文章並非為了批評 uutils 團隊。恰恰相反;我實際上想感謝他們如此詳細地分享審計結果,以便我們都能從中學習。
我們最近也在我們的「Rust in Production」播客節目中邀請了 Ubuntu 工程副總裁 Jon Seager,許多聽眾都很欣賞他對 Canonical 的 Rust 發展狀況的坦誠分享。
如果您用 Rust 編寫系統程式碼,這將是目前最集中的機會,讓您了解 Rust 的安全性在何處結束。
這是審計中最大的漏洞群集。這也是為什麼在 Ubuntu 26.04 LTS 中,cp、mv 和 rm 仍然是 GNU 版本的原因。
模式總是相同的。您執行一個系統呼叫來檢查某個路徑的屬性,然後執行另一個系統呼叫來對同一個路徑執行操作。在這兩個呼叫之間,擁有父目錄寫入權限的攻擊者可以將該路徑替換為一個符號連結。核心會在第二次呼叫時重新解析路徑,而特權操作就會作用於攻擊者選擇的目標。
Rust 的標準函式庫很容易導致這個問題。您首先會想到使用的符合人體工學的 API(如 fs::metadata、File::create、fs::remove_file、fs::set_permissions)每次都會接受一個路徑並重新解析它,而不是接受一個檔案描述符並基於該描述符進行操作。對於一般程式來說這沒問題,但如果您正在編寫一個需要防範本地攻擊者的特權工具,您必須非常小心。
以下是簡化自 src/uu/install/src/install.rs 的錯誤範例。
在步驟 1 和步驟 2 之間,任何對父目錄有寫入權限的使用者都可以將 `target` 植入為一個符號連結,指向例如 `/etc/shadow`。然後 `File::create` 會跟隨符號連結,而特權進程將愉快地用 `content` 中的任何內容覆寫 `/etc/shadow`。
修復方法是使用 `OpenOptions::create_new(true)`:
`create_new()` 的文件說明(我的重點):
> 目標位置不允許存在任何檔案,甚至連(懸空的)符號連結也不允許。這樣,如果呼叫成功,返回的檔案保證是新的。
Rust 中的 `&Path` 看起來像一個值,但請記住,對核心而言它只是一個名稱。這個名稱在一次系統呼叫到下一次系統呼叫之間可能指向不同的東西。請將您的操作錨定在檔案描述符上。
`create_new()` 僅在您創建新檔案時提供幫助。對於其他所有情況,請先打開父目錄一次,並基於該句柄進行操作。
如果您對同一路徑執行兩次操作,請假設這是一個 TOCTOU(時間檢查-時間使用)錯誤,直到您證明它不是為止。
這與 TOCTOU 密切相關。您想要一個權限受限的目錄,所以您會這樣寫。
> 在短暫的瞬間,`path` 以預設權限存在。系統上的任何其他使用者都可以在該窗口期內打開它。一旦他們獲得了檔案描述符,後續的 `chmod` 就無法將其收回。
請使用 `OpenOptions::mode()` 和 `DirBuilderExt::mode()`,這樣檔案或目錄一開始就會擁有您想要的權限。核心會將您的 umask 疊加在上面,所以如果您真的在意,也請明確設定它。
原始的 `chmod` 中的 `--preserve-root` 檢查實際上是這樣的:
> 該比較會被任何解析為 `/` 但拼寫不是 `/` 的東西繞過。例如 `/../`、`/./`、`/usr/..`,或指向 `/` 的符號連結。運行 `chmod -R 000 /../` 看看它如何繞過您的檢查並鎖定整個系統。
`canonicalize` 會解析 `..`、`.` 和符號連結,得到一個真實的絕對路徑。這比字串比較好得多。
哦,如果您好奇這行程式碼:
> 我認為這只是一種花哨的說法,意思是
> 在 `--preserve-root` 的特定情況下,這是有效的,因為 `/` 沒有父目錄,所以攻擊者沒有東西可以從下面替換。然而,在比較兩個任意路徑以進行檔案系統身份驗證的更一般情況下,您需要打開兩者並比較它們的 (dev, inode) 對,就像 GNU coreutils 所做的那樣。(思考身份,而不是字串相等性。)
順帶一提,我最喜歡的這個類別的錯誤是 CVE-2026-35363:
> 它拒絕了 `.` 和 `..`,但欣然接受了 `./` 和 `.///`,然後在列印 `Invalid input` 的同時刪除了當前目錄。😅
Rust 的 `String` 和 `&str` 始終是 UTF-8。在 99% 的情況下,這是一個很棒的選擇,但 Unix 路徑、環境變數、參數以及通過 `cut`、`comm` 和 `tr` 等工具流動的輸入都存在於混亂的位元組世界中。
每次 Rust 程式在彌合這個差距時,都有三種選擇。
審計在第一類和第二類中都發現了錯誤。這是一個例子。
> 這是原始程式碼,來自 src/uu/comm/src/comm.rs。
> GNU comm 在二進位檔案上工作,因為它只是移動位元組。uutils 版本將任何不是有效 UTF-8 的內容替換為 U+FFFD,這會默默地損壞輸出。
> `print!` 會強制進行 UTF-8 的 `Display` 往返。`Write::write_all` 則不會。它直接將原始位元組寫入 stdout。
> 對於類 Unix 系統程式碼,請使用 `Path` 和 `PathBuf` 處理檔案系統路徑,使用 `OsString` 處理環境變數,使用 `Vec<u8>` 或 `&[u8]` 處理串流內容。試圖將它們通過 `String` 進行往返以方便格式化是誘人的,但這正是損壞發生的地方。
> UTF-8 對於應用程式字串來說是一個很棒的預設值,但對於 Unix 工具工作的原始位元組內容來說,它絕對是錯誤的預設值。
在 CLI 中,每一個 `unwrap`、每一個 `expect`、每一個切片索引、每一個未檢查的算術運算、每一個 `from_utf8` 都可能導致拒絕服務,如果攻擊者可以控制輸入的話。這是因為 `panic!` 會展開堆疊並中止進程。如果您的工具在 cron 作業、CI 管道或 shell 腳本中運行,這意味著整個事情就停止工作了。更糟糕的是,您可能會陷入崩潰循環,癱瘓整個系統。
審計中的一個典型案例是 `sort --files0-from` (CVE-2026-35348)。該標誌從一個檔案中讀取一個以 NUL 分隔的檔案列表,但解析器對每個名稱的 UTF-8 轉換調用了 `expect()`:
> GNU sort 將檔案名稱視為原始位元組,就像核心一樣。uutils 版本要求 UTF-8,並在遇到第一個非 UTF-8 路徑時中止整個進程:
> (我針對 coreutils 0.2.2 在 macOS 上重現了這個問題。Python 的單行程式碼是因為大多數現代 shell 都拒絕為您創建非 UTF-8 的檔案名稱。)
您的夜間 cron 作業已失效,您的週末也泡湯了。
在處理不受信任輸入的程式碼中,將每一個 `unwrap`、`expect`、索引或轉換視為一個等待被提交的 CVE。使用 `?`、`get`、`checked_*`、`try_from` 並拋出一個真實的錯誤。在您的應用程式邊界進行推動,讓呼叫者處理後果。
在 CI 中捕捉此類問題的一個良好 lint 基線是:
> 這些在測試程式碼中會產生很多噪音,因為在測試程式碼中,對錯誤數據恐慌正是您想要的。將它們限制在非測試程式碼中最乾淨的方法是在每個 crate 的根目錄頂部加上 `#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::indexing_slicing, clippy::arithmetic_side_effects))]`,或者將 `#[allow(...)]` 門控到單獨的 `#[cfg(test)]` 模組上。
與前一點密切相關的是,一些 CVE 是由於忽略或丟失錯誤資訊造成的。
`chmod -R` 和 `chown -R` 返回處理的最後一個檔案的退出碼,而不是最差的退出碼。因此,`chmod -R 600 /etc/secrets/*` 可能會在半數檔案上失敗,但仍然退出 0。您的腳本認為一切正常。
`dd` 在其 `set_len()` 呼叫上調用了 `Result::ok()` 以模仿 GNU 在 `/dev/null` 上的行為。意圖是合理的,但相同的程式碼也適用於常規檔案,因此磁碟已滿的情況下會靜默地產生一個寫入一半的目的地。
原因是有人想丟棄一個 `Result` 並使用了 `.ok()`、`.unwrap_or_default()` 或 `let _ =`。
一個非常簡單的模式可以避免這種情況:
> 同時,如果您寫了 `.ok()` 來丟棄一個 `Result`,請留下一個註解,解釋為什麼這個特定的失敗可以安全地忽略。
令人驚訝的是,許多 CVE 並非「程式碼執行了不安全的操作」,而是「程式碼的行為與 GNU 不同,而某個 shell 腳本依賴於 GNU 的行為」。
最明顯的例子是 `kill -1` (CVE-2026-35369)。GNU 將 `-1` 讀作「訊號 1」並要求一個 PID。uutils 將其讀作「向 PID -1 發送預設訊號」,在 Linux 上,這意味著您能看到的每個進程。糟糕!
一個拼寫錯誤就變成了一個系統範圍的終止開關。
如果您重新實現了一個經過實戰考驗的工具,那麼在退出碼、錯誤訊息、邊緣情況和選項語義上的逐點相容性就是一項安全功能。(你好,Hyrum's Law – 以及必備的 XKCD 1172!)
任何行為與原始版本不同的地方,都意味著某個 shell 腳本正在做出錯誤的決定。
uutils 現在在 CI 中運行上游 GNU coreutils 的測試套件。這是處理這類錯誤的正確防禦規模。
CVE-2026-35368 是審計中最嚴重的單一錯誤。這是在 `chroot` 中發生的本地 root 程式碼執行。如果您知道要尋找什麼(`chroot` 後跟一個載入動態函式庫的函式呼叫),這個錯誤就很明顯,但它是一種在初讀時不會跳出來的東西。
模式如下,簡化自 `chroot` 工具。
> 陷阱在於 `get_user_by_name` 最終會從新的根檔案系統載入共享函式庫來解析使用者名稱。能夠在 `chroot` 中植入檔案的攻擊者就可以以 uid 0 的身份執行程式碼。
GNU `chroot` 在呼叫 `chroot` 之前解析使用者。這裡的修復方法相同。
一旦您進入了新的環境,每一個函式庫呼叫都可能執行攻擊者的程式碼。而且,靜態編譯在這裡沒有幫助,因為 `get_user_by_name` 會通過 NSS,它會在執行時 `dlopen` `libnss_*` 模組,無論您的二進位檔案是否是靜態連結的。
您可能已經看到這裡,並想:「哇,這麼多錯誤!也許 Rust 並不像我想像的那麼安全?」
請記住,以下這些壞事都沒有發生:
這意味著,即使這些工具曾經(而且很可能仍然)存在錯誤,它們也從未出現過可能被利用來讀取任意記憶體的錯誤。
GNU coreutils 在這些類別中的每一個都發布過 CVE。看看 GNU NEWS 檔案的最近幾年:
> ...清單還在繼續。Rust 重寫在可比較的活動時間內,零個這類錯誤。
這就是 C 程式碼庫中歷史上大部分錯誤的來源。
剩下的,坦白說,是一類更有趣的錯誤。它存在於我們受控的 Rust 環境與混亂的外部世界之間的邊界,在那裡路徑、位元組、字串和系統呼叫糾纏在一起,形成一個永恆的悲傷之球。這就是現代系統程式碼的新安全邊界。
如果您用 Rust 編寫系統程式碼,請將此 CVE 清單視為一個檢查表。在您的程式碼庫中搜尋 `from_utf8_lossy`、隨意的 `unwrap()` 呼叫、被丟棄的 `Result`、`File::create` 和針對 `/` 的字串比較。
我還寫了一篇配套文章,標題是「Rust 中的防禦性程式設計模式」。
當我想到「慣用的 Rust」時,正確性並不是我首先想到的。畢竟,這不是編譯器的職責嗎?相反,我想到的是優雅的迭代器模式、符合人體工學的方法簽名、不可變性或巧妙的表達式使用。但如果程式碼沒有做正確的事情,這一切都無關緊要,而且編譯器在強制執行正確性方面遠非完美。這就是為什麼我們不僅有編寫更優雅程式碼的慣用法;我們也有編寫正確程式碼的慣用法。它們是社群經驗的結晶,社群已經學會(通常是痛苦地)哪些程式碼形式能夠在現實世界中生存下來,哪些不能。
現實很少像我們希望強加給它的抽象那樣整潔。任何語言中穩健系統的標誌是願意反映這種混亂,而不是掩蓋它。Rust 為我們提供了非凡的工具來做到這一點,編譯器也會為我們處理很多事情。但是它無法處理的部分,也就是我們的程式與其他一切之間的邊界,仍然由我們來處理。類型系統可以編碼很多東西,但它無法編碼其控制之外的條件,例如兩次系統呼叫之間的時間流逝。
因此,慣用的 Rust 不僅僅是借用檢查器接受或 clippy 忽略的程式碼。它是其類型、名稱和控制流真實反映其運行系統的程式碼。而這種真實有時是醜陋的。它可能意味著使用檔案描述符而不是路徑,使用 `OsStr` 而不是 `String`,使用 `?` 而不是 `unwrap`,以及使用逐點相容性而不是乾淨的語義。沒有哪一種比您在白板上寫的版本更漂亮。但它更誠實。
將 Rust 部署到生產環境,並想確保您沒有陷入編譯器無法捕捉的陷阱?
公平地說 GNU:GNU coreutils 已經有 40 年的歷史,有很長的時間來暴露和修復這類錯誤。而且我們不知道 Rust 重寫中沒有記憶體安全錯誤,只知道審計沒有發現任何。儘管如此,在比較相同開發活動時間時,差異是顯而易見的。
值得注意的是,在某些方面,`Path / PathBuf` TOCTOU 類型的錯誤比在 C 中更容易避免。C 程式碼自然會傾向於使用已開啟的檔案描述符和 `*at` 系列系統呼叫(`openat`、`fstatat`、`unlinkat`、`mkdirat`),並且大多數創建系統呼叫直接接受模式參數。Rust 的高階 `std::fs` API 抽象了檔案描述符並操作 `&Path` 值,這使得基於路徑的、重新解析的呼叫成為最簡單的路徑。基於句柄的 API 在每個 Unix 平台上都存在;Rust 只是沒有將它們放在最前面。
關於 rust-coreutils 的更新:Canonical 發布審計結果的公告
Rust 中的防禦性程式設計模式:關於編寫更穩健的 Rust 程式碼的配套文章
安全 Rust 的陷阱:即使是安全的 Rust 程式碼也可能犯的常見錯誤
加固生產環境的 Rust 程式碼:運行時故障模式和生產環境加固
Rust 標準函式庫的鋒利邊緣:標準庫中令人驚訝的行為
Rust 防止資料競爭,而非競爭條件:Rust 並發安全性的終點
GitHub 上的 uutils/coreutils:GNU coreutils 的 Rust 重寫