JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

緩衝區溢位,以及為什麼 gets() 被禁用

緩衝區溢位,就是你往一個盒子裡倒進比它裝得下還多的位元組時會發生的事——而在 C 裡,這些位元組不會只是灑在地上,它們會覆寫掉住在隔壁的任何東西。這篇導引會精確地讓你看到什麼被砸爛了、為什麼有一個古老的函式因此被從 C 標準裡追殺出去,以及一次溢位如何把一個無害的打字錯誤變成一個安全漏洞。

那個不會反抗的盒子

到現在你已經知道危險長什麼樣子了。這個階級的第三篇導引,帶你走過了越界存取這個 C 的經典陷阱之一,而你也已經正面遇過未定義行為。這篇導引把鏡頭一路推到計算史上後果最嚴重的單一越界臭蟲——緩衝區溢位——以及那個危險到如此可靠、害得 C 委員會做了一件它幾乎從不做的事的函式庫函式:把那個函式從語言裡刪掉。要看清為什麼,我們得一個位元組一個位元組地看著事情發生。

一個緩衝區不過就是你劃出來、用來裝資料的一塊記憶體——最常見的就是一個陣列。當你寫 `char buf[8];` 時,你是在要一個剛好 8 個位元組寬的盒子。緩衝區溢位,就是當程式碼寫過了那個盒子的尾端時會發生的事:第 9 個位元組、第 20 個位元組,那些從來就不屬於你的位元組。這裡有一個會把每個從較友善語言過來的人都絆倒的部分:C 完全不做邊界檢查。盒子不會反抗。沒有牆、沒有例外、也沒有「索引超出範圍」。硬體高高興興地把第 9 號位元組寫進 buf 起點之後第 9 個位元組那個位址,而那個位址完全屬於別的東西。

砸爛鄰居:近看堆疊溢位

要體會這為什麼這麼危險,你得記住區域陣列住在哪裡。一個宣告在函式裡的 `char buf[8]`,住在呼叫堆疊上、在那個函式的堆疊框架裡。而就坐在同一個框架裡、在 buf 上方僅僅幾個位元組處的,是整個程式裡最重要的值之一:返回位址——也就是這個函式返回時,CPU 將跳回去執行的那條指令的位址。框架是一個緊密堆疊的鄰里,而返回位址,就是隔壁那棟房子。

現在想像那場災難。函式把一個 20 位元組的字串複製進我們 8 位元組的 buf。第 0 到第 7 號位元組正確地填滿了盒子。第 8、第 9、第 10 號位元組往上溢進框架,而在這溢出的某處,它們不偏不倚地落在返回位址上頭,用輸入裡碰巧帶著的任何東西把它覆寫掉。當函式結束、執行它的 `ret` 指令時,CPU 把那個被破壞的值載入 rip 並跳過去。如果覆寫的位元組是垃圾,rip 就指向一團胡言亂語,你會得到一次記憶體區段錯誤。但如果一個攻擊者精心挑選了那些位元組,rip 現在就指向攻擊者要它去的任何地方——而做主的,是攻擊者。這就是數十年來「砸爛堆疊」攻擊背後的整套機制,包括 1988 年的 Morris 蠕蟲。

stack frame, low address at top:

  [ buf[0] buf[1] ... buf[7] ]   <- the 8-byte box you asked for
  [ saved rbp                 ]   <- the caller's base pointer
  [ return address            ]   <- where ret jumps back to

writing 20 bytes into buf[8] runs straight
upward through saved rbp and INTO the return address.
一個區域陣列和返回位址是鄰居;溢出前者,會悄悄重寫後者。

為什麼 gets() 被追殺出語言之外

有些函式讓這場災難變得容易;有一個函式讓它幾乎無可避免。那個經典的罪犯是 gets(),一個整份工作就是從鍵盤讀一行輸入到緩衝區裡的函式庫呼叫。你像 `gets(buf)` 這樣呼叫它。仔細看那個呼叫,你就會找出那個致命的缺陷:你交給 gets() 一個指向你緩衝區的指標,卻從來沒告訴它緩衝區有多大。gets() 沒有任何辦法知道 buf 只有 8 個位元組寬,所以它就只是一直複製輸入位元組、直到撞上一個換行符——8 個位元組、80 個位元組、8000 個位元組,一路衝過盒子的尾端、蓋過返回位址。根本沒有安全的方式去呼叫它。它需要用來保護你的那個大小資訊,甚至不是它接受的一個參數。

這讓 gets() 成了危險字串函式這個家族裡一個獨一無二、無從辯護的成員。委員會的回應異常嚴厲。gets() 被正式宣告為棄用,然後在 C11 標準(2011 年)裡,它被徹底地從語言裡移除——這是幾乎沒有任何其他標準函式庫函式曾遭受過的命運。委員會得出的教訓、也是你要帶在身上的那一個,很尖銳:任何不被告知緩衝區大小、就往緩衝區裡寫的函式,都是一次等著錯誤輸入上門的緩衝區溢位。目的地的大小,必須跟著呼叫一起傳遞。

替代品是 fgets(),而差別正是那個缺失的部分:`fgets(buf, sizeof buf, stdin)`。中間那個引數是一個硬性上限——fgets() 至多讀 sizeof buf 減一個位元組然後就停,不論輸入多長,都替那個收尾的空位元組留下空間。契約就是那帖解藥:藉由傳入大小,你把「複製到有東西攔住我為止」變成了「複製到盒子滿了為止」。注意,連 fgets() 都倚賴你對陣列本身使用 sizeof;傳錯大小,你就又回到了危險之中,這是我們在最後一節會回頭談的、那個誠實的陷阱。

那個安靜的表親:差一錯誤與消失的空位元組

並不是每一次溢位都是一場壯觀的二十位元組砲擊。真實程式碼裡最常見的那種,只超出一個位元組——這就是差一錯誤——而它藏在 C 最常用的慣用語之一裡頭。記得在 C 裡,字串是一個空字元結尾字串:文字加上一個額外的、裝著數值 0x00、標記著結尾的位元組。陷阱在於,新手把緩衝區照看得見的字元去定大小,卻忘了那個看不見的結尾符。「system」這個字是 6 個字元,所以一個誘人的 `char name[6]` 感覺對極了——但要儲存它需要 7 個位元組,六個字母加上那個空位元組,而那第 7 個位元組落在尾端外的一個位置。

一個只差一個位元組的差一錯誤,擺在一次砸爛堆疊的攻擊旁邊聽起來無害,而程式往往好端端地跑了好幾年——這正是它危險的地方。它仍然是越界存取、仍然是未定義行為;它只是碰巧落在一個暫時無所謂的位元組上。換個編譯器、換個最佳化等級、重排一下變數,那一個脫靶的位元組就開始砸壞某個活著的東西。這是 C 裡記憶體更深一層的真相:它是一個記憶體安全的問題,而 C 不會免費給你任何記憶體安全。一個程式可以是錯的、卻仍然看似能跑,一路到它不能跑的那一天為止。

一次溢位真正教會你的事

退一步,整個樣貌就清楚了。緩衝區溢位不是一個被栓在 C 側邊的、奇異的安全主題——它是你早已知道的三件事撞在一起時,直接且無可避免的後果:陣列就只是沒有內建長度的赤裸位元組、C 不做任何邊界檢查、而寫過尾端是未定義行為。把這些湊在一起,你就得到一個語言,在裡頭一個被遺忘的大小,就把輸入變成了程式的指令。攻擊者的輸入變成了你的控制流。並沒有什麼高明的把戲在上演;那扇門,只不過是被留著沒關。

誠實面對這些防禦能走多遠,是值得的。現代系統加上了好幾層——堆疊金絲雀(一個放在返回位址前頭、返回時會被檢查的祕密值)、不可執行的堆疊,以及位址空間隨機化——它們讓那個經典的砸爛堆疊比 1988 年時難得多。但這些是減速丘、不是牆:它們抬高了一次攻擊的成本,卻沒讓底下那次溢位變得安全,而攻擊者對每一樣都有答案。唯一真正的修復,是一開始就別溢位。這也正是 Rust 在它安全子集裡被打造來廢除的那一類臭蟲,靠著替每一個切片附上一個長度並加以檢查——那是我們之後會權衡的、一組不同的取捨,而不是一根魔杖。

這一切都指向一個習慣,而這個階級的最後一篇導引,把它當成了整個主題:把每一個不是你自己產生的輸入位元組,在被證明無辜之前,都當成有敵意的。溢位之所以開火,只因為某個輸入比緩衝區假設的還長;解藥是在你複製之前、在不受信任的資料進來的那道邊界上,驗證長度。下一篇導引把那份直覺變成一門紀律——防禦性程式設計與不受信任輸入的處理——而你現在帶著一幅具體的、位元組層級的圖,知道你究竟在防的是什麼。