從臭蟲到武器:「損毀」究竟是什麼意思
在前面幾級裡,你把一整個家族的記憶體錯誤當成臭蟲來認識——一個寫過陣列邊界的緩衝區溢位、一個在 free() 之後還碰那塊區塊的釋放後使用、一個多走一步的差一錯誤。你學到它們是會讓自己的程式以區段錯誤當掉、或行為怪異的東西。這一級把它們每一個都改用攻擊者的眼睛重新框定。那個有時會當掉的「寫過尾端」,在攻擊者眼中是一次對程式從沒打算暴露的記憶體所做的、受控的寫入——而那正是漏洞利用的原料。這個轉變不是新理論;是同一套機制,只是換了問法:「當這件事出錯時,還有誰得益?」
記憶體損毀之所以這麼有威力,原因可以一路追溯到你已經牢牢握著的一件事:記憶體是一個扁平的位元組陣列,而同一個位元組依讀它的人不同而意義不同。一個位元組可以是你字串的一部分、可以是 CPU 即將跳去的一個返回位址的一部分、可以是某段稍後的程式碼會跟隨的指標的一部分、也可以是堆積所信任的配置器中介資料的一部分。硬體並不取締哪些位元組是「資料」、哪些是「控制」。所以當一個溢位讓攻擊者改掉了恰好是返回位址或指標的位元組時,他們就不再只是在損毀資料——他們正伸手去做控制流劫持,去操縱程式本身。
堆疊溢位:寫到回家的路上
回想組合語言那級裡堆疊框的佈局。當一個函式執行時,CPU 已經把一個返回位址推上了堆疊——也就是函式返回時它會跳回去的位址——而函式的區域變數就在附近,包括任何固定大小的緩衝區,例如一個 `char buf[64]`。在大多數架構上,緩衝區朝較高的位址生長,而返回位址就坐在它上方。所以如果某段程式碼把攻擊者控制的位元組複製進那個緩衝區、卻不檢查長度,這次寫入就會溢出 `buf` 的尾端、繼續往前——一路蓋過那個存起來的返回位址。這是一個經典的堆疊緩衝區溢位,而危險是幾何級數的:攻擊者正在覆寫那些決定 CPU 下一步去哪裡的、確切的位元組。
higher addresses +----------------------+ | saved return addr | <-- CPU jumps HERE on return +----------------------+ | saved rbp | +----------------------+ | char buf[64] | <-- copy starts here... | ... | ...and a too-long copy | ... | keeps writing UPWARD, +----------------------+ over rbp, over the lower addresses return address.
教科書上的觸發源是那些危險的字串函式之一——strcpy()、gets()、sprintf()——它們一路複製到一個終結用的 NUL 位元組為止,完全不曉得目的地有多大。如果來源比 `buf` 長,這次複製就會跑出尾端;那個函式沒有任何長度來叫停它。修正之道平淡無奇、而且你早就知道:用目的地的大小去限制每一次複製、偏好有長度上限的呼叫、並把任何跨越信任邊界的輸入都當成有敵意的。這一級後面講漏洞利用的章節正是建立在這個原語上——覆寫返回位址——但防禦就是你從 C 那幾級起就一直在練習的紀律。
堆積溢位:損毀配置器的帳本
堆積更為微妙。當你呼叫 malloc() 時,配置器交還一個區塊,但它也保留自己的帳本——區塊大小、空閒串列的連結、各種旗標——通常就存在你那個區塊的正旁邊,存在一份配置中介資料裡,而那份資料就住在跟你資料同一個位元組陣列中。一個堆積溢位發生在你寫過了一個堆積區塊的尾端:你踐踏的不是返回位址,而是配置器自己的資料結構。接下來對 malloc() 或 free() 的呼叫就會走過一條被損毀的空閒串列,可能被誘騙去回傳一個攻擊者選定的位址,從而把一次緩衝區越界變成「能寫到任何地方」的能力。
溢位的兩個表親完全活在你管理堆積區塊的方式裡。一次重複釋放——對同一個指標呼叫 free() 兩次——之所以危險,是因為第二次釋放把一個已經釋放的區塊重新插回空閒串列,使串列落入「同一塊可以被發出兩次」的狀態;一次重複釋放是配置器狀態的損毀,即使你的資料一個位元組都沒溢出。一個整數錯誤也餵養著它們:如果一個大小計算繞回——比方說 `count * size` 超過了一個 `size_t` 所能容納的值、繞回成一個很小的數字——malloc() 就回傳一個遠比呼叫者所相信的還小的緩衝區,而緊接著的第一次寫入就會溢出它。前面幾級反覆操練的教訓帶著利齒回來了:檢查 malloc() 究竟給了你什麼,而且絕不信任一個你沒有設限的大小。
釋放後使用:活得比區塊還久的指標
一個釋放後使用是溢位那個被時間錯開了的手足。你配置一個區塊、你 free() 掉它、但你留著那個指標的一份副本,之後又透過它讀或寫——一個仍然瞄準著「配置器現在認為是空閒的記憶體」的懸空指標。沒有東西立刻當掉,因為那些位元組還在;危險來臨於配置器重新使用那同一塊區域去做一次新的配置時。現在程式裡兩個不同的部分都相信自己擁有同一批位元組。那個過時的指標讀或寫一個物件,而新的擁有者看到的是另一個——而一個能控制什麼被配置進那個被重用槽位的攻擊者,就控制了那個過時指標所碰的東西。
為什麼這是如此被珍視的漏洞利用原語?因為真實程式裡的物件常常包含指標——一個釋放後使用漏洞利用經常瞄準一個持有函式指標或虛擬表指標(在 C++ 裡)的物件。如果攻擊者能釋放那個物件、然後用他們選定的位元組對那個被釋放的槽位做堆積噴灑,那個過時的參考就會把他們偽造的指標當成真的來讀,而跟隨它便導向控制流劫持、或對記憶體的一次任意讀寫。溢位損毀的是空間;釋放後使用損毀的是時間——但兩者都收束在同一處:攻擊者影響了一個程式將當成指標或位址來信任的值。
- 用 malloc() 在堆積上配置一個物件,它持有一個程式稍後會呼叫或跟隨的指標。
- free() 掉那個物件,但讓一份指向它的懸空指標副本仍在使用中。
- 觸發一次相同大小的新配置,使配置器重用那個被釋放的槽位,並用攻擊者選定的位元組填滿它。
- 讓程式使用那個懸空指標:它現在讀到攻擊者偽造的內層指標,並把它跟隨到攻擊者想去的地方。
格式字串,以及貫穿這一級的主線
還有一類值得擺在溢位們旁邊,因為它會嚇到人:格式字串臭蟲。當程式碼寫成 `printf(user_input)` 而不是 `printf("%s", user_input)` 時,它把攻擊者控制的文字當成格式字串交給了 printf()。現在是攻擊者在寫那些格式指令。一個 `%x` 會洩漏堆疊上的位元組;一串它們就傾印出記憶體,擊潰後面緩解措施所倚賴的位址保密;而那個冷僻的 `%n` 指令會把「到目前為止印出的位元組數」寫進一個指標引數——把一次記錄呼叫變成一次任意寫入。這是這一級主題的一個純粹案例:不受信任的資料流進了一個被程式當成受信任控制的位置。
退一步,看出這些全都共享的形狀。一個堆疊溢位在空間上損毀一個返回位址;一個堆積溢位損毀配置器中介資料;一個釋放後使用在時間上損毀所有權;一個格式字串臭蟲損毀資料與指令之間的邊界。每一個都收束於攻擊者放進一個程式將當成指標、位址、或長度來信任的值。那就是整場遊戲,也是為什麼下一篇能把「覆寫一個返回位址」當成已解的問題、轉而問更難的:位址既然知道了,你把它指向哪裡?返回導向程式設計就是那個答案,而它正是直接建立在這個基礎上。