薛丁格的 TOCTOU:編譯器偷偷幫你多讀一次記憶體
xoreaxeaxeax (Chris Domas) · GitHub · 2026-08-05
以「單指令 C 編譯器」movfuscator 聞名的資安研究者 Chris Domas,在新專案 schrodingers-toctou 中指出一個容易被忽略的事實:即使原始碼把共享記憶體先複製到區域變數再驗證,編譯器最佳化仍可能在驗證通過後、真正使用資料前,偷偷回頭重讀一次原始記憶體。這讓程式設計師以為已經關閉的 TOCTOU(time-of-check to time-of-use)競態視窗,在編出來的二進位檔裡又被重新打開。專案標題「你執行的二進位檔,不是你寫的那個程式」正是這個發現的核心。
核心機制
典型的防禦寫法長這樣:struct message local = *shared; if (local.len <= 20) { slot = local; }。程式設計師的意圖是「只讀一次、驗證、再用驗證過的副本」,但 C 抽象機器允許編譯器假設兩次讀取之間記憶體不會改變,因此最佳化器可能把邊界檢查用的讀取,跟後續整個結構體搬移時用的讀取,拆成兩個獨立的 load。攻擊者只要在這兩次 load 之間改掉 shared->len,驗證通過的是小值,實際被複製進 slot 的卻是超過緩衝區大小的資料。
以下是專案給出的 ARM gcc 14.2.0 -O2 範例,原始碼只有一次讀取,組語卻讀了兩次:
g:
ldrh r2, [r0] ; load *p (READ #1)
ldrsh r0, [r0] ; load *p again (READ #2, invented)
subs r0, r2, r0
bx lr作者把這類現象整理成八種「憑空發明的讀取」模式,包括暫存器重新物化(rematerialization)、寬度不匹配時的重新載入、整批搬移與純量搬移重疊時的讀取拆分等,橫跨 GCC、Clang、ICC、MSVC 與 x86-64、ARM、MIPS、RISC-V、s390x 等多種架構。
影響範圍
團隊用 Compiler Explorer 掃描編譯器與架構組合、用 Unicorn 模擬引擎實際執行二進位檔偵測同位址的重複讀取、再以 delta-debugging 縮小觸發問題的最小編譯旗標組合,最後跑一個「矩陣掃描器」把編譯器 × 架構 × flag 全部跑過一輪。這套流程目前已經在100 多個資安關鍵專案裡找出超過 300 個潛在漏洞,涵蓋:
- 虛擬化層:QEMU、Xen、KVM、bhyve、ACRN
- 韌體:edk2、coreboot、U-Boot、OpenSBI
- 可信賴執行環境:Intel SGX、Keystone、OpenEnclave、OP-TEE
- 作業系統核心與函式庫:Linux 各子系統、seL4、glibc、systemd、git、SQLite、FreeType、libtiff
作者強調這個漏洞類別無法單看原始碼判斷,同一行程式碼是否安全,取決於當下用的編譯器、版本、架構與最佳化旗標——這也是為什麼專案名稱借用「薛丁格」:漏洞的存在與否,要打開編譯後的二進位檔才知道。
Zig 用「訊號 + 旗標」硬是打斷了阻塞式系統呼叫
matklad (Aleksey Kladov) · matklad.github.io · 2026-08-06
Rust-analyzer 前作者、部落格作者 matklad 在最新文章中拆解了 Zig 標準函式庫新推出的 std.Io.Threaded——一種刻意「就用執行緒」的並行後端實作。Zig 的 Io 介面設計成可替換的並行抽象層,讓同一段程式碼能在事件迴圈、綠色執行緒等不同後端之間切換,而 Io.Threaded 選擇最直白的做法:每個並行任務對應一個作業系統執行緒、直接呼叫阻塞式系統呼叫。matklad 認為真正精妙之處,在於它如何取消一個正卡在阻塞系統呼叫裡的執行緒。
核心機制
取消一個在核心裡睡著的執行緒一直是老問題:執行緒卡在 read()、write() 這類系統呼叫時,使用者層根本沒有機會檢查「該不該取消」的旗標。Io.Threaded 在 POSIX 系統上的解法是共享記憶體旗標搭配訊號:想取消任務的一方先設定一個旗標,然後反覆對目標執行緒送訊號,直到對方確認收到取消請求為止。被打斷的系統呼叫會因為收到訊號而回傳 EINTR,此時執行緒才有機會檢查旗標,決定要重新呼叫系統呼叫還是直接展開堆疊、結束任務。這個協定具體實作在 signalCanceledSyscall 與 fileReadPositionalPosix 等函式裡。
與既有方案比較
matklad 把這個設計拿來跟其他語言的做法對照,凸顯 Zig 把「取消」跟語言內建的錯誤處理(try、defer)整合在一起的優勢:
| 方案 | 能否打斷阻塞式系統呼叫 | 與錯誤處理整合 |
|---|---|---|
Java Thread.interrupt | 不行,I/O 呼叫不受影響 | 否,IOException 與 InterruptedException 是分開的例外 |
POSIX pthread_cancel | 可以(cancellation point) | 否,需要額外的 cleanup handler,不接語言層 defer |
Zig Io.Threaded | 可以,靠訊號逼出 EINTR | 是,取消會走正常的錯誤傳播與 defer |
文章也指出,這種設計把「可能並行執行」跟「必須並行執行」的語意分開,讓執行緒池之類的排程細節可以被抽換,同時保留精確的函式簽章,呼叫端不需要猜測某個函式是否真的會佔用一條執行緒。
Google Crubit:不是包一層,而是把 C++ 轉成中介語言再生成 Rust
Google · crubit.rs / github.com/google/crubit · 持續開發中,2026-08-06 仍有新 commit
Crubit 是 Google 開發的雙向 C++/Rust 綁定產生器,目標是讓兩個語言生態系可以互相直接呼叫,不必手寫大量膠水程式碼。跟社群常見的 cxx crate 需要雙方都寫一份共同介面定義不同,Crubit 選擇從既有原始碼自動分析產生綁定——這個專案從 2022 年建立至今仍在持續開發,Lobsters 上的討論當天(2026-08-06)程式碼庫本身也還有新的 commit 進來。
核心機制
Crubit 的綁定流程走IDL(介面定義語言)中介層,而非直接在編譯器裡插入產生程式碼的邏輯,管線大致是:idl_from_cc 把 C++ 標頭轉成語言無關的 IDL 表示,再分別交給 rs_bindings_from_idl 產生 Rust 端綁定、cc_bindings_from_rs 產生給 C++ 呼叫 Rust 用的綁定。文件裡的例子顯示,一個實作了 Display 的 Rust Account struct 可以直接在 C++ 端當一般物件用;反過來,C++ 函式回傳的 std::optional<User>、std::unique_ptr<User> 也會分別轉成 Rust 的 Option<User>、unique_ptr<User> 包裝型別。
技術限制
Crubit 官方的「Are We Crubit Yet?」現況頁誠實列出目前還沒解決的部分。C++ 樣板目前只支援具體型別特化,例如 vector<int> 會變成一個叫 __crubit_mangled_vector_i 的具體 struct,而不是通用的泛型 Rust 型別;C++ 的生命週期標註目前被不安全地對映成 Rust 參照,也就是說跨語言邊界的借用檢查目前拿不到 Rust 編譯器的安全保證,這部分仍標記為實驗性。其餘已知缺口包括:
- 不支援 C++ 多載決議(overload resolution)
- 不支援巨集(macro)
- 物件導向特性(如虛擬繼承)支援有限
- 跨語言的安全參照傳遞尚未完成
換句話說,Crubit 現階段換來的是比手寫膠水程式碼少、比 bindgen 更懂 C++ 語意的綁定,但代價是跨界的生命週期安全暫時得靠人工把關,而不是編譯器保證。
原始來源:Crubit Documentation、google/crubit (GitHub)、Lobsters 討論串