在現代平台上利用記憶體毀損漏洞其實相當困難,因為編譯器與 OS 都加入了各種緩解機制(exploit mitigation)。既然記憶體不安全語言寫的程式碼短期內不會消失、漏洞也難以根除,開發者索性設計技術來讓漏洞難以、乃至無法被利用。本節介紹幾種主流緩解機制,以及繞過它們的思路。
資料執行防護(DEP)#
開發 exploit 的一大目標是取得指令指標的控制權。但在現代平台上,你多半無法像前面描述的那樣輕易執行任意 shellcode,因為有 資料執行防護(Data Execution Prevention, DEP),又稱 No-Execute(NX)。
DEP 要求含可執行指令的記憶體必須由 OS 特別配置。它需要處理器支援:若行程試圖在未標記為可執行的位址執行程式碼,處理器會引發錯誤,OS 隨即終止行程。平台開發者可以輕易把可執行記憶體限縮在特定執行模組,讓堆積、堆疊等區域不可執行,因此 DEP 在很多情況下能有效阻止簡單的利用。
DEP 造成的錯誤很難辨認:幾乎所有平台都把它誤報成
Segmentation fault或Access violation,看起來像是合法程式碼在存取無效記憶體。你可能誤以為是自己 shellcode 的 bug 而白花時間除錯。判斷方法是看出錯指令所在的記憶體是否可執行(用前一節的記憶體映射指令)。
Listing 10-20:執行不可執行記憶體造成的當機
(gdb) r
Program received signal SIGSEGV, Segmentation fault.
0xbffff730 in ?? ()
(gdb) x/3i $pc
=> 0xbffff730: push $0x2a
0xbffff732: pop %eax
0xbffff733: ret乍看你可能以為是無效堆疊指標(push 指令也會造成同樣錯誤),只有查看指令所在位置、發現它落在不可執行記憶體,才能確認真正的成因。
DEP 需要硬體與軟體支援,因此仍有破口——例如攻擊簡單的網路裝置時,開發者可能根本沒啟用 DEP,或硬體不支援。若 DEP 有啟用,可用返回導向程式設計繞過。
返回導向程式設計(ROP)#
返回導向程式設計(return-oriented programming, ROP)正是為了回應 DEP 普及而發展出來的:它重新利用既有、已可執行的指令,而不是注入任意指令再執行。
最簡單的例子是 Ret2Libc:Unix 的 C 函式庫(libc)提供 system(const char *command) 這類能啟動新行程的函式。若我們知道 system 在記憶體中的位址,就把指令指標導向它、並設法在記憶體中影響其參數,即可在我們控制下啟動新行程。因為對處理器而言你執行的是「標記為可執行的合法指令」,這就繞過了 DEP。Ret2Libc 奠定了現代 ROP 的基礎。
ROP 的關鍵在於:一段指令不必照它當初被編譯的方式執行。你可以擷取程式或函式庫中一小段一小段有用的指令序列——稱為 ROP gadget——把它們串起來組合出開發者從未打算執行的行為,甚至實作出圖靈完備的系統。

圖表 10-8:呼叫 system API 的簡單 ROP

圖表 10-9:較複雜的 ROP——先呼叫 open,再用數個 gadget 寫入檔案
stack pivot:從堆積溢位轉向執行 ROP 鏈
若你有堆疊緩衝區溢位,要在堆疊上排好 ROP 操作序列很容易。但若初始執行控制來自堆積溢位等其他手段,就需要一個 stack pivot gadget,把當前堆疊指標設成已知值。例如利用漏洞後 EAX 指向你控制的緩衝區(可能是 VTable 指標),可用下列 gadget 取得堆疊指標控制、轉去執行你的 ROP 鏈:
xchg esp, eax ; 交換 EAX 與 ESP 暫存器
ret ; 返回,將執行新堆疊上的位址它把 EAX 與索引堆疊記憶體的 ESP 對調,因為 EAX 值受我們控制,就能把堆疊位置樞轉到我們安排好的操作序列上。
位址空間配置隨機化(ASLR)#
用 ROP 繞過 DEP 帶來兩個問題:你得知道 system 函式或 ROP gadget 的位置,也得知道堆疊等資料記憶體的位置。DEP 剛在 Windows XP SP2 引入時,所有系統二進位與主執行檔都載入固定位置(這也是早期 Metasploit 模組要你指定語言的原因),堆疊、堆積位置也幾乎完全可預測,因此繞過 DEP 很容易。
位址空間配置隨機化(address space layout randomization, ASLR)引入後就難多了:它隨機化行程位址空間的佈局,讓攻擊者難以預測。但 ASLR 仍有幾種繞過途徑。
記憶體資訊洩漏漏洞#
ASLR 之前,資訊洩漏(information disclosure)漏洞主要用來竊取記憶體中的受保護資訊(如密碼);如今它們有了新用途——洩漏位址空間佈局以對抗隨機化。
你不一定得找到專門的資訊洩漏漏洞,有時能從記憶體毀損漏洞造出一個。以堆積溢位為例:堆積上常見一種帶長度前綴的字串緩衝區(長度欄位在前、字串資料在後)。若能讓漏洞配置剛好落在字串緩衝區前面、只溢位覆寫長度欄位、把它從 5 改成 100,之後讀回字串時就會拿到 100 位元組而非原本的 5 位元組——多出來的資料來自其他配置,可能含 VTable 指標、堆積指標等敏感位址,足以繞過 ASLR。

圖表 10-10:把記憶體毀損轉化為資訊洩漏
利用 ASLR 實作瑕疵#
受效能與可用記憶體限制,ASLR 的實作從來不完美,這些瑕疵也可用來洩漏隨機化後的位置。
- 最常見的是兩個獨立行程之間的執行檔位置未必被重新隨機化。在 Unix 上,這通常發生在被利用的行程是從某個主行程 fork 出來時——fork 會建立包含所有載入程式碼在內的完整副本;Apache 之類的伺服器常用 fork 模型服務新連線。你可以先從一次連線洩漏位址(即使那會讓該行程當機),再把位址用於後續 exploit。
- Windows 不太支援 fork,但一旦某執行檔的載入位址被隨機化,在系統重開機前它都會載到同一位址(否則 OS 無法在行程間共享唯讀記憶體)。因此只要洩漏一次位址,直到重開機前都可沿用。
用 partial overwrite 繞過 ASLR
記憶體通常以頁(如 4096 位元組)為單位切分,OS 因此限制了隨機佈局的粒度(例如 Windows 在 64KB 邊界上配置)。這造成一個弱點:隨機指標的低位元往往可預測,高位元才是完全隨機。
在小端序架構上,值的低位元組存在較低位址,而堆疊/堆積溢位通常由低位址往高位址寫。因此若你能控制覆寫長度,就能只覆寫可預測的低位元、不動隨機的高位元,把一個指標改指向另一個記憶體位置(例如某個 ROP gadget)。
例如原位址 0x07060504,已知因 ASLR 高 16 位元(0x0706)隨機、低 16 位元不隨機;只覆寫低 16 位元即可把它改成 0x0706BBAA。

圖表 10-11:短覆寫的範例
以記憶體金絲雀偵測堆疊溢位#
記憶體金絲雀(memory canary),又稱 cookie,用來偵測記憶體毀損並在發現時立刻終止程式。它最常見於防護堆疊記憶體毀損,但也用來保護堆積標頭、虛擬表指標等結構。
其原理是:應用程式啟動時產生一個隨機數存在全域位置,進入函式時把它推入堆疊、離開函式時彈出並與全域值比對。金絲雀擺在返回位址下方,因此任何會覆寫返回位址的溢位都必然先毀損金絲雀;只要金絲雀值夠難猜,攻擊者就無法在不被察覺下控制返回位址——一旦比對不符,程式立即當掉(如同礦坑裡的金絲雀示警)。但金絲雀並非無懈可擊。

圖表 10-12:帶堆疊金絲雀的堆疊溢位
繞過金絲雀的兩種手法
毀損區域變數:金絲雀通常只保護當前函式的返回位址,但堆疊上還有別的可利用目標——函式指標、帶虛擬表的類別物件指標,甚至一個整數變數。若溢位長度可控,或許能在不毀損金絲雀的前提下覆寫這些變數;即使金絲雀被毀損,只要那個變數在金絲雀被檢查之前就被使用(例如透過被覆寫的函式指標 f 取得執行),毀損就永遠不會被偵測到。現代編譯器可藉由重排變數(把緩衝區排在所有單一變數之上)來防範。

圖表 10-13:在不觸發堆疊金絲雀的情況下毀損區域變數
堆疊緩衝區下溢(underflow):基於效能考量,不是每個函式都會放金絲雀——若函式不操作堆疊上的緩衝區,編譯器可能判定安全而省略。但有些漏洞是下溢而非上溢:DoSomething() 設置了受保護的緩衝區並呼叫 Process(),而 Process() 卻寫到緩衝區下方(例如 p[-1]),毀損的是 Process() 自己那個沒有金絲雀保護的堆疊框返回位址,於是 Process() 返回時就跳去執行 shellcode。

圖表 10-14:堆疊緩衝區下溢
結語#
在網路應用程式中找出並利用漏洞並不容易,本章介紹了一整套技術:用除錯器分級漏洞、追出根本成因,據此撰寫簡單的 shellcode,再用 ROP 開發 payload 繞過 DEP,最後說明 ASLR、記憶體金絲雀等現代緩解機制及其繞過手法。這也是全書最後一章——至此你應已具備擷取、分析、逆向與利用網路應用程式的知識。精進的最好方法,就是盡量多找網路應用程式與協定來練習;經驗累積後,你會越來越容易一眼認出常見結構,並辨識出安全漏洞常藏身的協定行為模式。