GCC 中的編譯器堆疊保護技術

新一發表於2017-10-29

GCC 中的編譯器堆疊保護技術

       前幾天看到的覺得不錯得部落格於是轉發了,但這裡我補充一下一些點。

      GCC通過棧保護選項-fstack-protector-all編譯時額外新增兩個符號,__stack_chk_guard和__stack_chk_fail分別是儲存canary word值的地址以及檢測棧溢位後的處理函式,這兩個符號如果是在linux上是需要Glib支援的,但如果是像核心程式碼或是一些呼叫不同的C庫像arm-none-eabi-gcc呼叫的newlib那麼你就需要自己重新實現這兩個符號,因為我在網上看到很多人搜為什麼加上棧保護選項後編譯不過,那是連結的時候連結器在C庫中找不到這兩個符號。

  GCC編譯

       以堆疊溢位為代表的緩衝區溢位攻擊已經成為一種普遍的安全漏洞和攻擊手段。本文首先對編譯器層面的堆疊保護技術作簡要介紹,然後通過例項來展示 GCC 中堆疊保護的實現方式和效果。最後介紹一些 GCC 堆疊保護的缺陷和侷限。 

       以堆疊溢位為代表的緩衝區溢位已成為最為普遍的安全漏洞。由此引發的安全問題比比皆是。早在 1988 年,美國康奈爾大學的電腦科學系研究生莫里斯 (Morris) 利用 UNIX fingered 程式的溢位漏洞,寫了一段惡意程式並傳播到其他機器上,結果造成 6000 臺 Internet 上的伺服器癱瘓,佔當時總數的 10%。各種作業系統上出現的溢位漏洞也數不勝數。為了儘可能避免緩衝區溢位漏洞被攻擊者利用,現今的編譯器設計者已經開始在編譯器層面上對堆疊進行保護。現在已經有了好幾種編譯器堆疊保護的實現,其中最著名的是 StackGuard 和 Stack-smashing Protection (SSP,又名 ProPolice)。

編譯器堆疊保護原理

我們知道攻擊者利用堆疊溢位漏洞時,通常會破壞當前的函式棧。例如,攻擊者利用清單 1 中的函式的堆疊溢位漏洞時,典型的情況是攻擊者會試圖讓程式往 name 陣列中寫超過陣列長度的資料,直到函式棧中的返回地址被覆蓋,使該函式返回時跳轉至攻擊者注入的惡意程式碼或 shellcode 處執行(關於溢位攻擊的原理參見《Linux 下緩衝區溢位攻擊的原理及對策》)。溢位攻擊後,函式棧變成了圖 2 所示的情形,與溢位前(圖 1)比較可以看出原本堆疊中的 EBP,返回地址已經被溢位字串覆蓋,即函式棧已經被破壞。

清單 1. 可能存在溢位漏洞的程式碼
int vulFunc() {
    char name[10];
    //…
    return 0;
}
圖 1. 溢位前的函式棧
圖 2. 溢位後的函式棧

如果能在執行時檢測出這種破壞,就有可能對函式棧進行保護。目前的堆疊保護實現大多使用基於 “Canaries” 的探測技術來完成對這種破壞的檢測。

“Canaries” 探測:

要檢測對函式棧的破壞,需要修改函式棧的組織,在緩衝區和控制資訊(如 EBP 等)間插入一個 canary word。這樣,當緩衝區被溢位時,在返回地址被覆蓋之前 canary word 會首先被覆蓋。通過檢查 canary word 的值是否被修改,就可以判斷是否發生了溢位攻擊。

常見的 canary word:

  • Terminator canaries
  • 由於絕大多數的溢位漏洞都是由那些不做陣列越界檢查的 C 字串處理函式引起的,而這些字串都是以 NULL 作為終結字元的。選擇 NULL, CR, LF 這樣的字元作為 canary word 就成了很自然的事情。例如,若 canary word 為 0x000aff0d,為了使溢位不被檢測到,攻擊者需要在溢位字串中包含 0x000aff0d 並精確計算 canaries 的位置,使 canaries 看上去沒有被改變。然而,0x000aff0d 中的 0x00 會使 strcpy() 結束複製從而防止返回地址被覆蓋。而 0x0a 會使 gets() 結束讀取。插入的 terminator canaries 給攻擊者製造了很大的麻煩。
  • Random canaries
  • 這種 canaries 是隨機產生的。並且這樣的隨機數通常不能被攻擊者讀取。這種隨機數在程式初始化時產生,然後儲存在一個未被隱射到虛擬地址空間的記憶體頁中。這樣當攻擊者試圖通過指標訪問儲存隨機數的記憶體時就會引發 segment fault。但是由於這個隨機數的副本最終會作為 canary word 被儲存在函式棧中,攻擊者仍有可能通過函式棧獲得 canary word 的值。
  • Random XOR canaries
  • 這種 canaries 是由一個隨機數和函式棧中的所有控制資訊、返回地址通過異或運算得到。這樣,函式棧中的 canaries 或者任何控制資訊、返回地址被修改就都能被檢測到了。

目前主要的編譯器堆疊保護實現,如 Stack Guard,Stack-smashing Protection(SSP) 均把 Canaries 探測作為主要的保護技術,但是 Canaries 的產生方式各有不同。下面以 GCC 為例,簡要介紹堆疊保護技術在 GCC 中的應用。


GCC 中的堆疊保護實現

Stack Guard 是第一個使用 Canaries 探測的堆疊保護實現,它於 1997 年作為 GCC 的一個擴充套件釋出。最初版本的 Stack Guard 使用 0x00000000 作為 canary word。儘管很多人建議把 Stack Guard 納入 GCC,作為 GCC 的一部分來提供堆疊保護。但實際上,GCC 3.x 沒有實現任何的堆疊保護。直到 GCC 4.1 堆疊保護才被加入,並且 GCC4.1 所採用的堆疊保護實現並非 Stack Guard,而是 Stack-smashing Protection(SSP,又稱 ProPolice)。

SSP 在 Stack Guard 的基礎上進行了改進和提高。它是由 IBM 的工程師 Hiroaki Rtoh 開發並維護的。與 Stack Guard 相比,SSP 保護函式返回地址的同時還保護了棧中的 EBP 等資訊。此外,SSP 還有意將區域性變數中的陣列放在函式棧的高地址,而將其他變數放在低地址。這樣就使得通過溢位一個陣列來修改其他變數(比如一個函式指標)變得更為困難。

GCC 4.1 中三個與堆疊保護有關的編譯選項

-fstack-protector:

啟用堆疊保護,不過只為區域性變數中含有 char 陣列的函式插入保護程式碼。

-fstack-protector-all:

啟用堆疊保護,為所有函式插入保護程式碼。

-fno-stack-protector:

禁用堆疊保護。

GCC 中的 Canaries 探測

下面通過一個例子分析 GCC 堆疊保護所生成的程式碼。分別使用 -fstack-protector 選項和 -fno-stack-protector 編譯清單2中的程式碼得到可執行檔案 demo_sp (-fstack-protector),demo_nosp (-fno-stack-protector)。

清單 2. demo.c
int main() {
    int i;
    char buffer[64];
    i = 1;
    buffer[0] = 'a';
    return 0;
}

然後用 gdb 分別反彙編 demo_sp,deno_nosp。

清單 3. demo_nosp 的彙編程式碼
(gdb) disas main
Dump of assembler code for function main:
0x08048344 <main+0>:    lea    0x4(%esp),%ecx
0x08048348 <main+4>:    and    $0xfffffff0,%esp
0x0804834b <main+7>:    pushl  0xfffffffc(%ecx)
0x0804834e <main+10>:   push   %ebp
0x0804834f <main+11>:   mov    %esp,%ebp
0x08048351 <main+13>:   push   %ecx
0x08048352 <main+14>:   sub    $0x50,%esp
0x08048355 <main+17>:   movl   $0x1,0xfffffff8(%ebp)
0x0804835c <main+24>:   movb   $0x61,0xffffffb8(%ebp)
0x08048360 <main+28>:   mov    $0x0,%eax
0x08048365 <main+33>:   add    $0x50,%esp
0x08048368 <main+36>:   pop    %ecx
0x08048369 <main+37>:   pop    %ebp
0x0804836a <main+38>:   lea    0xfffffffc(%ecx),%esp
0x0804836d <main+41>:   ret    
End of assembler dump.
清單 4. demo_sp 的彙編程式碼
(gdb) disas main
Dump of assembler code for function main:
0x08048394 <main+0>:    lea    0x4(%esp),%ecx
0x08048398 <main+4>:    and    $0xfffffff0,%esp
0x0804839b <main+7>:    pushl  0xfffffffc(%ecx)
0x0804839e <main+10>:   push   %ebp
0x0804839f <main+11>:   mov    %esp,%ebp
0x080483a1 <main+13>:   push   %ecx
0x080483a2 <main+14>:   sub    $0x54,%esp
0x080483a5 <main+17>:   mov    %gs:0x14,%eax
0x080483ab <main+23>:   mov    %eax,0xfffffff8(%ebp)
0x080483ae <main+26>:   xor    %eax,%eax
0x080483b0 <main+28>:   movl   $0x1,0xffffffb4(%ebp)
0x080483b7 <main+35>:   movb   $0x61,0xffffffb8(%ebp)
0x080483bb <main+39>:   mov    $0x0,%eax
0x080483c0 <main+44>:   mov    0xfffffff8(%ebp),%edx
0x080483c3 <main+47>:   xor    %gs:0x14,%edx
0x080483ca <main+54>:   je     0x80483d1 <main+61>
0x080483cc <main+56>:   call   0x80482fc <__stack_chk_fail@plt>
0x080483d1 <main+61>:   add    $0x54,%esp
0x080483d4 <main+64>:   pop    %ecx
0x080483d5 <main+65>:   pop    %ebp
0x080483d6 <main+66>:   lea    0xfffffffc(%ecx),%esp
0x080483d9 <main+69>:   ret    
End of assembler dump.

demo_nosp 的彙編程式碼中地址為 0x08048344 的指令將 esp+4 存入 ecx,此時 esp 指向的記憶體中儲存的是返回地址。地址為 0x0804834b 的指令將 ecx-4 所指向的記憶體壓棧,由於之前已將 esp+4 存入 ecx,所以該指令執行後原先 esp 指向的內容將被壓棧,即返回地址被再次壓棧。0x08048348 處的 and 指令使堆頂以 16 位元組對齊。從 0x0804834e 到 0x08048352 的指令是則儲存了舊的 EBP,併為函式設定了新的棧框。當函式完成時,0x08048360 處的 mov 指令將返回值放入 EAX,然後恢復原來的 EBP,ESP。不難看出,demo_nosp 的彙編程式碼中,沒有任何對堆疊進行檢查和保護的程式碼。

將用 -fstack-protector 選項編譯的 demo_sp 與沒有堆疊保護的 demo_nosp 的彙編程式碼相比較,兩者最顯著的區別就是在函式真正執行前多了 3 條語句:

0x080483a5 <main+17>:   mov    %gs:0x14,%eax
0x080483ab <main+23>:   mov    %eax,0xfffffff8(%ebp)
0x080483ae <main+26>:   xor    %eax,%eax

在函式返回前又多了 4 條語句:

0x080483c0 <main+44>:   mov    0xfffffff8(%ebp),%edx
0x080483c3 <main+47>:   xor    %gs:0x14,%edx
0x080483ca <main+54>:   je     0x80483d1 <main+61>
0x080483cc <main+56>:   call   0x80482fc <__stack_chk_fail@plt>

這多出來的語句便是 SSP 堆疊保護的關鍵所在,通過這幾句程式碼就在函式棧框中插入了一個 Canary,並實現了通過這個 canary 來檢測函式棧是否被破壞。

%gs:0x14 中儲存是一個隨機數,0x080483a5 到 0x080483ae 處的 3 條語句將這個隨機數放入了棧中 [EBP-8] 的位置。函式返回前 0x080483c0 到 0x080483cc 處的 4 條語句則將棧中 [EBP-8] 處儲存的 Canary 取出並與 %gs:0x14 中的隨機數作比較。若不等,則說明函式執行過程中發生了溢位,函式棧框已經被破壞,此時程式會跳轉到 __stack_chk_fail 輸出錯誤訊息然後中止執行。若相等,則函式正常返回。

調整區域性變數的順序

以上程式碼揭露了 GCC 中 canary 的實現方式。仔細觀察 demo_sp 和 demo_nosp 的彙編程式碼,不難發現兩者還有一個細微的區別:開啟了堆疊保護的 semo_sp 程式中,區域性變數的順序被重新組織了。

程式中, movl $0x1,0xffffff**(%ebp) 對應於 i = 1;

movb $0x61,0xffffff**(%ebp) 對應於 buffer[0] = ‘a’;

demo_nosp 中,變數 i 的地址為 0xfffffff8(%ebp),buffer[0] 的地址為 0xffffffb8(%ebp)。可見,demo_nosp 中,變數 i 在 buffer 陣列之前,變數在記憶體中的順序與程式碼中定義的順序相同,見圖 3 左。而在 demo_sp 中,變數 i 的地址為 0xffffffb4 (%ebp),buffer[0] 的地址為 0xffffffb8(%ebp),即 buffer 陣列被挪到了變數 i 的前面,見圖 3 右。

圖 3. 調整變數順序前後的函式棧

demo_sp 中區域性變數的組織方式對防禦某些溢位攻擊是有益的。如果陣列在其他變數之後(圖 3 左),那麼即使返回地址受到 canary 的保護而無法修改,攻擊者也可能通過溢位陣列來修改其他區域性變數(如本例中的 int i)。當被修改的其他區域性變數是一個函式指標時,攻擊者就很可能利用這種溢位,將函式指標用 shellcode 的地址覆蓋,從而實施攻擊。然而如果用圖 3 右的方式來組織堆疊,就會給這類溢位攻擊帶來很大的困難。


GCC 堆疊保護效果

以上我們從實現的角度分析了 GCC 中的堆疊保護。下面將用一個小程式 overflow_test.c 來驗證 GCC 堆疊保護的實際效果。

清單 5. 溢位攻擊模擬程式 overflow_test.c
#include <stdio.h>
#include <stdlib.h>
char shellcode[] =
    "\xeb\x1f\x5e\x89\x76\x08\x31\xc0\x88\x46\x07\x89\x46\x0c\xb0\x0b"
    "\x89\xf3\x8d\x4e\x08\x8d\x56\x0c\xcd\x80\x31\xdb\x89\xd8\x40\xcd"
    "\x80\xe8\xdc\xff\xff\xff/bin/sh";

int test()
{
    int i;
    unsigned int stack[10];
    char my_str[16];
    printf("addr of shellcode in decimal: %d\n", &shellcode);
    for (i = 0; i < 10; i++)
        stack[i] = 0;

    while (1) {
        printf("index of item to fill: (-1 to quit): ");
        scanf("%d",&i);
        if (i == -1) {
            break;
        }
        printf("value of item[%d]:", i);
        scanf("%d",&stack[i]);
    }

    return 0;
}

int main()
{
    test();
    printf("Overflow Failed\n");

    return 0;
}

該程式不是一個實際的漏洞程式,也不是一個攻擊程式,它只是通過模擬溢位攻擊來驗證 GCC 堆疊保護的一個測試程式。它首先會列印出 shellcode 的地址,然後接受使用者的輸入,為 stack 陣列中指定的元素賦值,並且不會對陣列邊界進行檢查。

關閉堆疊保護,編譯程式

aktoon@aktoon-thinkpad:~/SCAD/overflow_test$ gcc –fno-stack-protector -o 
overflow_test ./overflow_test.c

不難算出關閉堆疊保護時,stack[12] 指向的位置就是棧中存放返回地址的地方。在 stack [10],stack[11],stack[12] 處填入 shellcode 的地址來模擬通常的溢位攻擊:

aktoon@aktoon-thinkpad:~/SCAD/overflow_test$ ./overflow_test 
addr of shellcode in decimal: 134518560
index of item to fill: (-1 to quit): 10
value of item[11]: 134518560
index of item to fill: (-1 to quit): 11
value of item[11]: 134518560
index of item to fill: (-1 to quit): 12
value of item[12]:134518560
index of item to fill: (-1 to quit): -1
$ ps
  PID TTY          TIME CMD
15035 pts/4    00:00:00 bash
29757 pts/4    00:00:00 sh
29858 pts/4    00:00:00 ps

程式被成功溢位轉而執行 shellcode 獲得了一個 shell。由於沒有開啟堆疊保護,溢位得以成功。

然後開啟堆疊保護,再次編譯並執行程式。

aktoon@aktoon-thinkpad:~/SCAD/overflow_test$ gcc –fno-stack-protector -o 
overflow_test ./overflow_test.c

通過 gdb 反彙編,不難算出,開啟堆疊保護後,返回地址位於 stack[17] 的位置,而 canary 位於 stack[16] 處。在 stack[10],stack[11]…stack[17] 處填入 shellcode 的地址來模擬溢位攻擊:

aktoon@aktoon-thinkpad:~/SCAD/overflow_test$ ./overflow_test 
addr of shellcode in decimal: 134518688
index of item to fill: (-1 to quit): 10
value of item[11]: 134518688
index of item to fill: (-1 to quit): 11
value of item[11]: 134518688
index of item to fill: (-1 to quit): 12
value of item[11]: 134518688
index of item to fill: (-1 to quit): 13
value of item[11]: 134518688
index of item to fill: (-1 to quit): 14
value of item[11]: 134518688
index of item to fill: (-1 to quit): 15
value of item[11]: 134518688
index of item to fill: (-1 to quit): 16
value of item[12]: 134518688
index of item to fill: (-1 to quit): 17
value of item[12]: 134518688
index of item to fill: (-1 to quit): -1
Overflow Failed
*** stack smashing detected ***: ./overflow_test terminated
Aborted

這次溢位攻擊失敗了,提示 ”stack smashing detected”,表明溢位被檢測到了。按照之前對 GCC 生成的堆疊保護程式碼的分析,失敗應該是由於 canary 被改變引起的。通過反彙編和計算我們已經知道返回地址位於 stack[17],而 canary 位於 stack[16]。接下來嘗試繞過 canary,只對返回地址進行修改。

aktoon@aktoon-thinkpad:~/SCAD/overflow_test$ ./overflow_test 
addr of shellcode in decimal: 134518688
index of item to fill: (-1 to quit): 17
value of item[17]:134518688
index of item to fill: (-1 to quit): -1
$ ls *.c
bypass.c  exe.c  exp1.c  of_demo.c  overflow.c  overflow_test.c  toto.c  vul1.c
$

這次只把 stack[17] 用 shellcode 的地址覆蓋了,由於沒有修改 canary,返回地址的修改沒有被檢測到,shellcode 被成功執行了。同樣的道理,即使我們沒有修改函式的返回地址,只要 canary 被修改了(stack[16]),程式就會被保護程式碼中止。

aktoon@aktoon-thinkpad:~/SCAD/overflow_test$ ./overflow_test 
addr of shellcode in decimal: 134518688
index of item to fill: (-1 to quit): 16
value of item[16]:134518688
index of item to fill: (-1 to quit): -1
Overflow Failed
*** stack smashing detected ***: ./overflow_test terminated
Aborted

在上面的測試中,我們看到編譯器的插入的保護程式碼阻止了通常的溢位攻擊。實際上,現在編譯器堆疊保護技術確實使堆疊溢位攻擊變得困難了。


GCC 堆疊保護的侷限

在上面的例子中,我們發現假如攻擊者能夠購繞過 canary,仍然有成功實施溢位攻擊的可能。除此以外,還有一些其他的方法能夠突破編譯器的保護,當然這些方法需要更多的技巧,應用起來也較為困難。下面對突破編譯器堆疊保護的方法做一簡介。

Canary 探測方法僅對函式堆中的控制資訊 (canary word, EBP) 和返回地址進行了保護,沒有對區域性變數進行保護。通過溢位覆蓋某些區域性變數也可能實施溢位攻擊。此外,Stack Guard 和 SSP 都只提供了針對棧的溢位保護,不能防禦堆中的溢位攻擊。

在某些情況下,攻擊者還可以利用函式引數來實現溢位攻擊。我們用下面的例子來說明這種攻擊的原理。

清單 6. 漏洞程式碼 vul.c
int func(char *msg) {
    char buf[80];
    strcpy(buf,msg);
    strcpy(msg,buf);
}
int main(int argv, char** argc) {
    func(argc[1]);
}

執行時,func 函式的棧框如下圖所示。

圖 4. func 的函式棧

通過 strcpy(buf,msg),我們可以將 buf 陣列溢位,直至將引數 msg 覆蓋。接下來的 strcpy(msg,buf) 會向 msg 所指向的記憶體中寫入 buf 中的內容。由於第一步的溢位中,我們已經控制了 msg 的內容,所以實際上通過上面兩步我們可以向任何不受保護的記憶體中寫入任何資料。雖然在以上兩步中,canaries 已經被破壞,但是這並不影響我們完成溢位攻擊,因為針對 canaries 的檢查只在函式返回前才進行。通過構造合適的溢位字串,我們可以修改記憶體中程式 ELF 映像的 GOT(Global Offset Table)。假如我們通過溢位字串修改了 GOT 中 _exit() 的入口,使其指向我們的 shellcode,當函式返回前檢查到 canary 被修改後,會提示出錯並呼叫 _exit() 中止程式。而此時的的 _exit() 已經指向了我們的 shellcode,所以程式不會退出,並且 shellcode 會被執行,這樣就達到了溢位攻擊的目的。

上面的例子展示了利用引數避開保護進行溢位攻擊的原理。此外,由於返回地址是根據 EBP 來定位的,即使我們不能修改返回地址,假如我們能夠修改 EBP 的值,那麼就修改了存放返回地址的位置,相當於間接的修改了返回地址。可見,GCC 的堆疊保護並不是萬能的,它仍有一定的侷限性,並不能完全杜絕堆疊溢位攻擊。雖然面對編譯器的堆疊保護,我們仍可能有一些技巧來突破這些保護,但是這些技巧通常受到很多條件的制約,實際應用起來有一定的難度。


結束語

本文介紹了編譯器所採用的以 Canaries 探測為主的堆疊保護技術,並且以 GCC 為例展示了 SSP 的實現方式和實際效果。最後又簡單介紹了突破編譯器保護的一些方法。儘管攻擊者仍能通過一些技巧來突破編譯器的保護,但編譯器加入的堆疊保護機制確實給溢位攻擊造成了很大的困難。本文僅從編譯器的角度討論了防禦溢位攻擊的方法。要真正防止堆疊溢位攻擊,單從編譯器入手還是不夠的,完善的系統安全策略也相當重要,此外,良好的程式設計習慣,使用帶有陣列越界檢查的 libc 也會對防止溢位攻擊起到重要作用。

參考資料

相關文章