從閃避未定義行為到從設計上防住它
到目前為止,這個階級一直是一趟危害巡禮。你認識了抽象機器,學到未定義行為並不是「編譯器愛怎樣就怎樣」,而是給最佳化器的一張許可證,讓它假設那件壞事永遠不會發生——這正是為什麼一個臭蟲能在 -O0 下消失、卻在 -O2 下悄悄毀掉一切。你看過有號溢位、越界讀取、未初始化讀取,以及那個讓 gets() 變得不可用的經典緩衝區溢位。每一篇都在回答「什麼會出錯」。這最後一篇回答的是一個不同的問題:你要怎麼寫 C,才能讓壞事根本沒有機會開始?
這個轉變是從反應變成設計。一個防禦式的程式不會信任它的輸入會乖乖聽話,不會假設一次呼叫成功了,也不會讓一個被破壞的值在任何人注意到之前就漫遊進系統深處。防禦式編程就是這種姿態的名字:你寫每一個函式時,都當作另一頭有一個懷有敵意或粗心的呼叫者,因為在一個長壽的系統裡,遲早真的會有一個。代價是一點額外的程式碼——這裡一個檢查、那裡一個邊界。回報則是:你在前四篇看到的那些失敗,會從「靠運氣抓到」變成「從建構上就不可能」。
信任邊界:把那條線劃出來
安全編程裡最有用的單一觀念,就是信任邊界。把你的程式想成一間有圍牆的房子。牆內住著你建立並掌控的值:一個你剛算出來的長度、一個常數、一個你自己組起來的字串。牆外則是你沒建立的值:來自 read() 的位元組、argv 裡的一個引數、在提示符輸入的一行、網路封包裡的一個欄位、一個環境變數的內容、一個別人寫的檔案。一條信任邊界就是那道牆本身——外部資料第一次進入你程式碼的那個確切位置。牆外的一切,預設都是不可信的:可能是空的、可能大得離譜、可能是攻擊者精心打造來讓你溢位緩衝區或索引超出陣列的。
這個紀律說起來簡單、遵守起來困難:對不可信的資料,在邊界上、在它碰到任何其他東西之前,驗證一次。不要把半套的檢查灑滿你程式的內部,指望每一個用到這個值的人都記得要小心——這正是一個差一錯誤或一個沒檢查的長度溜過去的方式。相反,資料一跨過牆,你就停下來問:這個長度在我有的緩衝區範圍內嗎?這個數字在我預期的區間裡嗎?這個字串真的放得下、而且在我以為的地方有空字元結尾嗎?如果它通過了,從這一刻起它就變成可信的,內部的程式碼就能依賴它。如果它失敗了,你就在那裡當場拒絕它,不讓它有機會造成越界存取或更糟的事。
把它想成牆上的一道閘門。在不可信的那一側站著 argv、read() 來的位元組、環境變數、封包欄位、檔案內容,以及任何在標準輸入打進來的東西。這一切都被漏斗般匯進邊界上單一的驗證點,壞輸入在那裡被拒絕。只有通過的才抵達可信的那一側,從那裡開始,內部函式就被允許假設資料是健全的——它的長度放得下、它的範圍如預期、它的字串有結尾。劃出這一道閘門、並拒絕讓任何路徑偷偷繞過它,這就是 C 裡安全編程的絕大部分。
驗證輸入而不自欺
具體來說,C 裡的驗證大多是關於長度與範圍,因為那正是抽象機器懲罰你的地方。假設你從網路讀進一個長度 n,然後把 n 個位元組複製進一個 256 位元組的緩衝區。天真的程式碼複製 n 個位元組;防禦式的程式碼會先問 n 是不是頂多等於你有的空間。這是差一錯誤與溢位那幾課的活生生版本:一個少掉的邊界,就是攻擊者開著卡車衝過去的那道缺口。也別忘了先前的有號溢位與無號回繞——一個在某個算術步驟之後才比較的長度,可能回繞成一個通過你檢查、然後照樣溢位的小值,所以要在你對原始輸入做算術之前就驗證它。
- 辨認來源。說清楚資料是從哪來的:argv、read()、一個檔案、一個環境變數。如果它來自你的行程之外,它就是不可信的——沒有例外。
- 先檢查長度,對照你目的地的真實大小。如果你有一個 256 位元組的緩衝區,問題永遠是「n <= 256 嗎?」(或者 255,替空字元結尾留一格)——而且要在複製任何一個位元組之前就回答它。
- 檢查範圍與形狀。這個數字在你能處理的值之內嗎?一個被解析的字串只含有你允許的字元嗎?對任何你不理解的東西,拒絕它,而不是去猜。
- 用有邊界的函式庫呼叫。優先選會收一個大小引數、並停在那裡的函式——read(fd, buf, sizeof buf)、用 snprintf() 取代 sprintf()——好讓設定上限的是你緩衝區的大小,而不是攻擊者的輸入。
- 決定在這裡失敗意味著什麼,並依此行動。回傳一個錯誤碼、記錄下來、或拒絕這個請求——但絕不要往下掉、去用一個驗證失敗的資料。
還有一個更微妙、值得用一句話講的驗證危害:一個危險的字串函式像 strcpy() 或 strcat(),會信任它的來源有空字元結尾、而且目的地夠大——這兩個假設正是不可信輸入最愛打破的。一個沒有空位元組的網路緩衝區,會讓 strlen() 一路走出邊界。所以在邊界驗證的一部分,就是建立內部程式碼所假設的那些不變式:如果你的程式碼稍後會呼叫一個字串函式,邊界就是你保證那個字串確實在範圍內被結尾掉的地方。
檢查每一次呼叫,並複製你必須留住的東西
驗證守的是進來的資料;檢查回傳值守的是出去的操作。檢查每一次呼叫這條規則正如它字面的意思,也是把安全的系統程式碼跟其餘的區分開來的那個習慣。在 C 裡沒有例外會替你接住——一次失敗的 malloc() 回傳一個空指標、一次失敗的 open() 回傳 -1 並設定 errno、一次短讀的 read() 回傳的位元組比你要求的少,而如果你忽略其中任何一個,你就會帶著一個謊言般的值繼續航行下去。解參考 malloc() 給的空指標,好一點是個區段錯誤;把 open() 給的 -1 當成一個檔案描述符來用,則會破壞掉 fd 0xFFFFFFFF 碰巧別名到的任何東西。前四篇展示的是你自己造成的未定義行為;沒檢查的呼叫,則是環境遞給你的未定義行為。
char *buf = malloc(n);
if (buf == NULL) { /* never skip this branch */
perror("malloc");
return -1; /* propagate, do not press on */
}
/* only here is buf safe to dereference */最後一招補上一個更安靜的漏洞:防禦性複製。當你收到一個指向你並不擁有的資料的指標——一個呼叫者仍握著的緩衝區、一個來自共享結構的字串——而你需要把它留住或往下傳時,別存那個借來的指標;把位元組複製進一塊你掌控的儲存裡。信任一個借來的指標的危險在於:擁有者可能在你背後把它釋放掉或覆寫掉,讓你存下的指標變成一個懸盪指標、讓你下一次讀取變成一次釋放後使用。一次防禦性複製斬斷那個命運:現在生命週期是你的、位元組不會在你底下改變,而一個在你檢查之後去竄改原件的攻擊者也繞不過你——也就是那個檢查時機與使用時機之間的陷阱。
快速失敗,以及替 Rust 講的誠實理由
當一個檢查真的失敗時,你面對一個快速失敗對優雅降級那篇先前框出來的選擇。對一個預期中的問題——使用者打錯了一個檔名——你優雅地處理它:回傳一個錯誤、印出一個訊息、再問一次。對一個不可能發生、代表你自己的邏輯壞了的狀態——一個算出來是負的長度、一個本來絕不該為空的指標——最安全的一步是快速失敗:立刻停下來,而不是一拐一拐地撐下去、破壞更多狀態。一個像 assert(n <= capacity) 的斷言,就是替第二種情況準備的工具:它記錄一個不變式,而如果現實違反了它,就大聲地中止、留下一份你讀得懂的核心傾印,而不是讓一個被破壞的值擴散成一個你一小時後、一千行外才會去除錯的症狀。
這裡是替這個階級收尾的誠實清算。上面講的一切都是紀律——而紀律正是人類在期限壓力下、凌晨三點會忘掉的東西。你可以完美地驗證每一個邊界、檢查每一次呼叫好幾年,然後在某個星期二漏掉某一個長度,而這單單一次的疏忽就是一個記憶體損毀的臭蟲。這就是 C 之所以困難的結構性原因,也是激發 Rust 的那個對比:在安全的 Rust 裡,編譯器在編譯期就替你強制執行了許多你在這裡一直靠手在強制的事——邊界會被檢查、一個值不能在它的擁有者釋放它之後被使用、一次未初始化的讀取根本編譯不過。這個階級裡一整類的臭蟲,會變得在安全程式碼裡不可能、而且不需要垃圾回收器。
但 Rust 不是魔法,而把它當成所有臭蟲的終結來推銷,會背叛這個階級所代表的一切。借用檢查器有一道真實的學習曲線,會在你把所有權內化之前一直拒絕看起來正確的程式碼;邏輯臭蟲、死結、以及整數溢位變成錯誤答案,這些都在搬家後存活下來;而一個 unsafe 區塊,在你必須的時候仍讓你用手去做那些危險的事,帶著你在 C 裡背負的同一份責任。誠實的框架不是「C 壞、Rust 好」,而是一組不同的取捨:C 給你完全的控制與完全的責任,而這篇導引的防禦式紀律,就是你好好背負那份責任的方式。Rust 把那份責任的大半移進編譯器——代價是一個你必須學會去滿足的、更嚴格的語言。先精通這份紀律;它讓你今天在 C 裡更安全,也讓你明天成為一個更好的 Rust 程式設計師。