在閱讀本文之前,如果你連堆棧是什麼多不知道的話,請先閱讀文章後面的基礎知識。 接觸過編程的人都知道,進階語言都能通過變數名來訪問記憶體中的資料。那麼這些變數在記憶體中是如何存放的呢?程式又是如何使用這些變數的呢?下面就會對此進行深入的討論。下文中的C語言代碼如沒有特別聲明,預設都使用VC編譯的release版。 首先,來瞭解一下 C 語言的變數是如何在記憶體分部的。C 語言有全域變數(Global)、本地變數(Local),靜態變數(Static)、寄存器變數(Regeister)。每種變數都有不同的分配方式。先來看下面這段代碼: #include <stdio.h> int g1=0, g2=0, g3=0; int main() { static int s1=0, s2=0, s3=0; int v1=0, v2=0, v3=0; //列印出各個變數的記憶體位址 printf("0x%08x\n",&v1); //列印各本地變數的記憶體位址 printf("0x%08x\n",&v2); printf("0x%08x\n\n",&v3); printf("0x%08x\n",&g1); //列印各全域變數的記憶體位址 printf("0x%08x\n",&g2); printf("0x%08x\n\n",&g3); printf("0x%08x\n",&s1); //列印各靜態變數的記憶體位址 printf("0x%08x\n",&s2); printf("0x%08x\n\n",&s3); return 0; } 編譯後的執行結果是: 0x0012ff78 0x0012ff7c 0x0012ff80 0x004068d0 0x004068d4 0x004068d8 0x004068dc 0x004068e0 0x004068e4 輸出的結果就是變數的記憶體位址。其中v1,v2,v3是本地變數,g1,g2,g3是全域變數,s1,s2,s3是靜態變數。你可以看到這些變數在記憶體是連續分布的,但是本地變數和全域變數分配的記憶體位址差了十萬八千裡,而全域變數和靜態變數分配的記憶體是連續的。這是因為本地變數和全域/靜態變數是分配在不同類型的記憶體地區中的結果。對於一個進程的記憶體空間而言,可以在邏輯上分成3個部份:代碼區,待用資料區和動態資料區。動態資料區一般就是“堆棧”。“棧(stack)”和“堆(heap)”是兩種不同的動態資料區,棧是一種線性結構,堆是一種鏈式結構。進程的每個線程都有私人的“棧”,所以每個線程雖然代碼一樣,但本地變數的資料都是互不干擾。一個堆棧可以通過“基地址”和“棧頂”地址來描述。全域變數和靜態變數分配在待用資料區,本地變數分配在動態資料區,即堆棧中。程式通過堆棧的基地址和位移量來訪問本地變數。 ├———————┤低端記憶體地區 │ …… │ ├———————┤ │ 動態資料區 │ ├———————┤ │ …… │ ├———————┤ │ 代碼區 │ ├———————┤ │ 待用資料區 │ ├———————┤ │ …… │ ├———————┤高端記憶體地區 堆棧是一個先進後出的資料結構,棧頂地址總是小於等於棧的基地址。我們可以先瞭解一下函數調用的過程,以便對堆棧在程式中的作用有更深入的瞭解。不同的語言有不同的函數調用規定,這些因素有參數的壓入規則和堆棧的平衡。windows API的調用規則和ANSI C的函數調用規則是不一樣的,前者由被調函數調整堆棧,後者由調用者調整堆棧。兩者通過“__stdcall”和“__cdecl”首碼區分。先看下面這段代碼: #include <stdio.h> void __stdcall func(int param1,int param2,int param3) { int var1=param1; int var2=param2; int var3=param3; printf("0x%08x\n",¶m1); //列印出各個變數的記憶體位址 printf("0x%08x\n",¶m2); printf("0x%08x\n\n",¶m3); printf("0x%08x\n",&var1); printf("0x%08x\n",&var2); printf("0x%08x\n\n",&var3); return; } int main() { func(1,2,3); return 0; } 編譯後的執行結果是: 0x0012ff78 0x0012ff7c 0x0012ff80 0x0012ff68 0x0012ff6c 0x0012ff70 ├———————┤<—函數執行時的棧頂(ESP)、低端記憶體地區 │ …… │ ├———————┤ │ var 1 │ ├———————┤ │ var 2 │ ├———————┤ │ var 3 │ ├———————┤ │ RET │ ├———————┤<—“__cdecl”函數返回後的棧頂(ESP) │ parameter 1 │ ├———————┤ │ parameter 2 │ ├———————┤ │ parameter 3 │ ├———————┤<—“__stdcall”函數返回後的棧頂(ESP) │ …… │ ├———————┤<—棧底(基地址 EBP)、高端記憶體地區 就是函數調用過程中堆棧的樣子了。首先,三個參數以從又到左的次序壓入堆棧,先壓“param3”,再壓“param2”,最後壓入“param1”;然後壓入函數的返回地址(RET),接著跳轉到函數地址接著執行(這裡要補充一點,介紹UNIX下的緩衝溢出原理的文章中都提到在壓入RET後,繼續壓入當前EBP,然後用當前ESP代替EBP。然而,有一篇介紹windows下函數調用的文章中說,在windows下的函數調用也有這一步驟,但根據我的實際調試,並未發現這一步,這還可以從param3和var1之間只有4位元組的間隙這點看出來);第三步,將棧頂(ESP)減去一個數,為本地變數分配記憶體空間,上例中是減去12位元組(ESP=ESP-3*4,每個int變數佔用4個位元組);接著就初始化本地變數的記憶體空間。由於“__stdcall”調用由被調函數調整堆棧,所以在函數返回前要恢複堆棧,先回收本地變數佔用的記憶體(ESP=ESP+3*4),然後取出返回地址,填入EIP寄存器,回收先前壓入參數佔用的記憶體(ESP=ESP+3*4),繼續執行調用者的代碼。參見下列彙編代碼: ;--------------func 函數的彙編代碼------------------- :00401000 83EC0C sub esp, 0000000C //建立本地變數的記憶體空間 :00401003 8B442410 mov eax, dword ptr [esp+10] :00401007 8B4C2414 mov ecx, dword ptr [esp+14] :0040100B 8B542418 mov edx, dword ptr [esp+18] :0040100F 89442400 mov dword ptr [esp], eax :00401013 8D442410 lea eax, dword ptr [esp+10] :00401017 894C2404 mov dword ptr [esp+04], ecx ……………………(省略若干代碼) :00401075 83C43C add esp, 0000003C ;恢複堆棧,回收本地變數的記憶體空間 :00401078 C3 ret 000C ;函數返回,恢複參數佔用的記憶體空間 ;如果是“__cdecl”的話,這裡是“ret”,堆棧將由調用者恢複 ;-------------------函數結束------------------------- ;--------------主程式調用func函數的代碼-------------- :00401080 6A03 push 00000003 //壓入參數param3 :00401082 6A02 push 00000002 //壓入參數param2 :00401084 6A01 push 00000001 //壓入參數param1 :00401086 E875FFFFFF call 00401000 //調用func函數 ;如果是“__cdecl”的話,將在這裡恢複堆棧,“add esp, 0000000C” 聰明的讀者看到這裡,差不多就明白緩衝溢出的原理了。先來看下面的代碼: #include <stdio.h> #include <string.h> void __stdcall func() { char lpBuff[8]="\0"; strcat(lpBuff,"AAAAAAAAAAA"); return; } int main() { func(); return 0; } 編譯後執行一下回怎麼樣?哈,“"0x00414141"指令引用的"0x00000000"記憶體。該記憶體不能為"read"。”,“非法操作”嘍!"41"就是"A"的16進位的ASCII碼了,那明顯就是strcat這句出的問題了。"lpBuff"的大小隻有8位元組,算進結尾的‘\0‘,那strcat最多隻能寫入7個"A",但程式實際寫入了11個"A"外加1個‘\0‘。再來看看上面那幅圖,多出來的4個位元組正好覆蓋了RET的所在的記憶體空間,導致函數返回到一個錯誤的記憶體位址,執行了錯誤的指令。如果能精心構造這個字串,使它分成三部分,前一部份僅僅是填充的無意義資料以達到溢出的目的,接著是一個覆蓋RET的資料,緊接著是一段shellcode,那隻要著個RET地址能指向這段shellcode的第一個指令,那函數返回時就能執行shellcode了。但是軟體的不同版本和不同的運行環境都可能影響這段shellcode在記憶體中的位置,那麼要構造這個RET是十分困難的。一般都在RET和shellcode之間填充大量的NOP指令,使得exploit有更強的通用性。 ├———————┤<—低端記憶體地區 │ …… │ ├———————┤<—由exploit填入資料的開始 │ │ │ buffer │<—填入無用的資料 │ │ ├———————┤ │ RET │<—指向shellcode,或NOP指令的範圍 ├———————┤ │ NOP │ │ …… │<—填入的NOP指令,是RET可指向的範圍 │ NOP │ ├———————┤ │ │ │ shellcode │ │ │ ├———————┤<—由exploit填入資料的結束 │ …… │ ├———————┤<—高端記憶體地區 windows下的動態資料除了可存放在棧中,還可以存放在堆中。瞭解C++的朋友都知道,C++可以使用new關鍵字來動態分配記憶體。來看下面的C++代碼: #include <stdio.h> #include #include <windows.h> void func() { char *buffer=new char[128]; char bufflocal[128]; static char buffstatic[128]; printf("0x%08x\n",buffer); //列印堆中變數的記憶體位址 printf("0x%08x\n",bufflocal); //列印本地變數的記憶體位址 printf("0x%08x\n",buffstatic); //列印靜態變數的記憶體位址 } void main() { func(); return; } 程式執行結果為: 0x004107d0 0x0012ff04 0x004068c0 可以發現用new關鍵字分配的記憶體即不在棧中,也不在待用資料區。VC編譯器是通過windows下的“堆(heap)”來實現new關鍵字的記憶體動態分配。在講“堆”之前,先來瞭解一下和“堆”有關的幾個API函數: HeapAlloc 在堆中申請記憶體空間 HeapCreate 建立一個新的堆對象 HeapDestroy 銷毀一個堆對象 HeapFree 釋放申請的記憶體 HeapWalk 枚舉堆對象的所有記憶體塊 GetProcessHeap 取得進程的預設堆對象 GetProcessHeaps 取得進程所有的堆對象 LocalAlloc GlobalAlloc 當進程初始化時,系統會自動為進程建立一個預設堆,這個堆預設所佔記憶體的大小為1M。堆對象由系統進行管理,它在記憶體中以鏈式結構存在。通過下面的代碼可以通過堆動態申請記憶體空間: HANDLE hHeap=GetProcessHeap(); char *buff=HeapAlloc(hHeap,0,8); 其中hHeap是堆對象的控制代碼,buff是指向申請的記憶體空間的地址。那這個hHeap究竟是什麼呢?它的值有什麼意義嗎?看看下面這段代碼吧: #pragma comment(linker,"/entry:main") //定義程式的入口 #include <windows.h> _CRTIMP int (__cdecl *printf)(const char *, ...); //定義STL函數printf /*--------------------------------------------------------------------------- 寫到這裡,我們順便來複習一下前面所講的知識: (*注)printf函數是C語言的標準函數庫中函數,VC的標準函數庫由msvcrt.dll模組實現。 由函數定義可見,printf的參數個數是可變的,函數內部無法預Crowdsourced Security Testing道調用者壓入的參數個數,函數只能通過分析第一個參數字串的格式來獲得壓入參數的資訊,由於這裡參數的個數是動態,所以必須由調用者來平衡堆棧,這裡便使用了__cdecl調用規則。BTW,Windows系統的API函數基本上是__stdcall調用形式,只有一個API例外,那就是wsprintf,它使用__cdecl調用規則,同printf函數一樣,這是由於它的參數個數是可變的緣故。 ---------------------------------------------------------------------------*/ void main() { HANDLE hHeap=GetProcessHeap(); char *buff=HeapAlloc(hHeap,0,0x10); char *buff2=HeapAlloc(hHeap,0,0x10); HMODULE hMsvcrt=LoadLibrary("msvcrt.dll"); printf=(void *)GetProcAddress(hMsvcrt,"printf"); printf("0x%08x\n",hHeap); printf("0x%08x\n",buff); printf("0x%08x\n\n",buff2); } 執行結果為: 0x00130000 0x00133100 0x00133118 hHeap的值怎麼和那個buff的值那麼接近呢?其實hHeap這個控制代碼就是指向HEAP首部的地址。在進程的使用者區存著一個叫PEB(進程環境塊)的結構,這個結構中存放著一些有關進程的重要訊息,其中在PEB首地址位移0x18處存放的ProcessHeap就是進程預設堆的地址,而位移0x90處存放了指向進程所有堆的地址清單的指標。windows有很多API都使用進程的預設堆來存放動態資料,如windows 2000下的所有ANSI版本的函數都是在預設堆中申請記憶體來轉換ANSI字串到Unicode字串的。對一個堆的訪問是順序進行的,同一時刻只能有一個線程訪問堆中的資料,當多個線程同時有訪問要求時,只能排隊等待,這樣便造成程式執行效率下降。 最後來說說記憶體中的資料對齊。所位元據對齊,是指資料所在的記憶體位址必須是該資料長度的整數倍,DWORD資料的記憶體起始地址能被4除盡,WORD資料的記憶體起始地址能被2除盡,x86 CPU能直接存取對齊的資料,當他試圖訪問一個未對齊的資料時,會在內部進行一系列的調整,這些調整對於程式來說是透明的,但是會降低運行速度,所以編譯器在編譯器時會盡量保證資料對齊。同樣一段代碼,我們來看看用VC、Dev-C++和lcc三個不同編譯器編譯出來的程式的執行結果: #include <stdio.h> int main() { int a; char b; int c; printf("0x%08x\n",&a); printf("0x%08x\n",&b); printf("0x%08x\n",&c); return 0; } 這是用VC編譯後的執行結果: 0x0012ff7c 0x0012ff7b 0x0012ff80 變數在記憶體中的順序:b(1位元組)-a(4位元組)-c(4位元組)。 這是用Dev-C++編譯後的執行結果: 0x0022ff7c 0x0022ff7b 0x0022ff74 變數在記憶體中的順序:c(4位元組)-中間相隔3位元組-b(佔1位元組)-a(4位元組)。 這是用lcc編譯後的執行結果: 0x0012ff6c 0x0012ff6b 0x0012ff64 變數在記憶體中的順序:同上。 三個編譯器都做到了資料對齊,但是後兩個編譯器顯然沒VC“聰明”,讓一個char佔了4位元組,浪費記憶體哦。 基礎知識: 堆棧是一種簡單的資料結構,是一種只允許在其一端進行插入或刪除的線性表。允許插入或刪除操作的一端稱為棧頂,另一端稱為棧底,對堆棧的插入和刪除操作被稱為入棧和出棧。有一組CPU指令可以實現對進程的記憶體實現堆棧訪問。其中,POP指令實現出棧操作,PUSH指令實現入棧操作。CPU的ESP寄存器存放當前線程的棧頂指標,EBP寄存器中儲存當前線程的棧底指標。CPU的EIP寄存器存放下一個CPU指令存放的記憶體位址,當CPU執行完當前的指令後,從EIP寄存器中讀取下一條指令的記憶體位址,然後繼續執行。 參考:《Windows下的HEAP溢出及其利用》by: isno 《windows核心編程》by: Jeffrey Richter |