緩衝區溢位(buffer overflow)弱點自電腦早期就存在,至今仍然如此。大多數網路蠕蟲靠緩衝區溢位傳播,連 Internet Explorer 最近的 zero-day VML 弱點也源於緩衝區溢位。

C 把資料完整性的責任丟給程式設計師#

C 是高階語言,但它假設程式設計師負責維護資料完整性。若把這個責任轉給編譯器,因為要對每個變數做完整性檢查,產生的二進位檔會顯著變慢,也會奪走程式設計師大量的控制權並讓語言變複雜。

C 的簡潔提升了控制力與效率,但也意味著:一旦變數被配置了記憶體,沒有任何內建保護機制確保變數內容塞得進被配置的空間。若程式設計師想把 10 位元組的資料放進只配置了 8 位元組的緩衝區,這個動作被允許——即使它極可能讓程式崩潰。多出的 2 位元組會溢出、覆寫掉緊接在後的任何東西。

// overflow_example.c 摘要
int value = 5;
char buffer_one[8], buffer_two[8];
strcpy(buffer_two, argv[1]); /* Copy first argument into buffer_two. */

當把 10 位元組複製進只有 8 位元組的 buffer_two

[AFTER] buffer_two is at 0xbffff7f0 and contains '1234567890'
[AFTER] buffer_one is at 0xbffff7f8 and contains '90'
[AFTER] value is at 0xbffff804 and is 5 (0x00000005)

buffer_one 就在 buffer_two 之後,所以最後兩個位元組 90 溢進了 buffer_one。緩衝區夠大時,程式甚至會直接崩潰:

[AFTER] value is at 0xbffff7f4 and is 1094795585 (0x41414141)
Segmentation fault (core dumped)

這類崩潰相當常見——想想你遇過多少次程式崩潰或藍屏。程式設計師犯的是疏漏之錯:本該對使用者提供的輸入做長度檢查或限制。這種錯誤容易犯、也難以察覺。前一章的 notesearch.c 程式其實就含有一個緩衝區溢位 bug——即使你熟悉 C,也可能到現在才注意到。

從崩潰到 root shell#

程式崩潰很惱人,但在駭客手中會變得相當危險。exploit_notesearch.c 展示了危險所在:它生成一個帶單引號的命令列引數,去執行 notesearch 程式。單引號之間生成的緩衝區才是漏洞利用的真正核心,其餘只是這顆「毒藥丸」的遞送方式。

reader@hacking:~/booksrc $ ./a.out
[DEBUG] found a 34 byte note for user id 999
[DEBUG] found a 41 byte note for user id 999
-------[ end of note data ]-------
sh-3.2#

這個漏洞利用能藉溢位端出一個 root shell——提供對電腦的完整控制。這是一個**堆疊型緩衝區溢位(stack-based buffer overflow)**利用的範例。

堆疊型緩衝區溢位弱點#

notesearch 漏洞利用的原理是毀損記憶體以控制執行流程auth_overflow.c 示範了這個概念:

int check_authentication(char *password) {
   int auth_flag = 0;
   char password_buffer[16];

   strcpy(password_buffer, password);

   if(strcmp(password_buffer, "brillig") == 0)
      auth_flag = 1;
   if(strcmp(password_buffer, "outgrabe") == 0)
      auth_flag = 1;

   return auth_flag;
}

正常情況下,兩個密碼之一才會讓函式回傳 1、授予存取。但一個溢位就能導致意外甚至矛盾的行為——無需正確密碼就取得存取

reader@hacking:~/booksrc $ ./auth_overflow AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
-=-=-=-=-=-=-=-=-=-=-=-=-=-
      Access Granted.
-=-=-=-=-=-=-=-=-=-=-=-=-=-

用除錯器檢視細節:auth_flag 位於 password_buffer 之後 28 位元組處。當 30 個 A 複製進 16 位元組的 password_buffer,就溢出並覆寫了 auth_flag

(gdb) x/x &auth_flag
0xbffff7bc:     0x00004141
(gdb) x/dw &auth_flag
0xbffff7bc:     16705

溢位後,check_authentication() 回傳 16705 而非 0。由於 if 敘述把任何非零值都視為已認證,執行流程就被導進了已授權的區塊。此例中,auth_flag 變數就是執行控制點(execution control point)——覆寫它的值正是控制的來源。

更通用的控制點:返回位址#

上面是個依賴變數記憶體排列的刻意例子。auth_overflow2.c 把變數宣告順序反過來,讓 auth_flag 排在 password_buffer 之前——這樣它就無法被溢位覆寫了。

但即使 C 程式碼裡看不到它,另一個執行控制點依然存在:它方便地位於所有堆疊變數之後,很容易被覆寫。這塊記憶體是所有程式運作不可或缺的一部分,覆寫它通常導致崩潰:

(gdb) c
Program received signal SIGSEGV, Segmentation fault.
0x08004141 in ?? ()

回想上一章:呼叫函式時,一個堆疊框架被推入堆疊,EIP 跳到函式的第一條指令;每個堆疊框架含有區域變數與一個返回位址(return address),好讓函式結束時能還原 EIP。這些全由架構內建、通常由編譯器而非程式設計師處理。

圖 0x300-1:check_authentication() 的堆疊框架,含區域變數、SFP、返回位址與函式引數

在除錯器中定位返回位址

理解返回位址如何被建立,就能定位它。這個過程始於 main()——甚至在函式呼叫之前:

0x080484b3 <main+63>:   mov    DWORD PTR [esp],eax
0x080484b6 <main+66>:   call   0x8048414 <check_authentication>
0x080484bb <main+71>:   test   eax,eax
  • 第一條指令把 EAX(指向第一個命令列引數,也就是 check_authentication() 的引數)寫到 ESP 所指處,開始建立堆疊框架。
  • 第二條 call 指令把下一條指令的位址推入堆疊、並把 EIP 移到函式起點。這個被推入的位址就是堆疊框架的返回位址——此例是 0x080484bb

check_authentication() 的函式序言接著完成堆疊框架:前兩條處理已存框架指標,第三條 sub esp,0x38 為區域變數保留 56 位元組;加上已推入的返回位址與已存框架指標各 4 位元組,湊成 64 位元組的堆疊框架。

函式結束時,leaveret 移除堆疊框架,並把 EIP 設回堆疊框架中已存的返回位址,讓程式回到 main() 中呼叫之後的下一條指令。這個過程在任何程式中每次呼叫函式都會發生。

當已存返回位址的部分位元組被覆寫,程式仍會試圖用那個值來還原 EIP,通常導致崩潰——因為執行等於跳到隨機位置。但這個值不必是隨機的:若覆寫是受控的,執行流程也能被控制,跳往特定位置。

問題只剩一個:我們該叫它跳去哪裡?