Windows中進程的內存結構


 基礎知識:堆棧是一種簡單的數據結構,是一種只允許在其一端進行插入或刪除的線性表。允許插入或刪除操作的一端稱為棧頂,另一端稱為棧底,對堆棧的插入和刪除操作被稱為入棧和出棧。有一組CPU指令可以實現對進程的內存實現堆棧訪問。其中,POP指令實現出棧操作,PUSH指令實現入棧操作。CPU的ESP寄存器存放當前線程的棧頂指針,EBP寄存器中保存當前線程的棧底指針。CPU的EIP寄存器存放下一個CPU指令存放的內存地址,當CPU執行完當前的指令后,從EIP寄存器中讀取下一條指令的內存地址,然后繼續執行。

        接觸過編程的人都知道,高級語言都能通過變量名來訪問內存中的數據。那么這些變量在內存中是如何存放的呢?程序又是如何使用這些變量的呢?下面就會對此進行深入的討論。下文中的C語言代碼如沒有特別聲明,默認都使用VC編譯的release版。 
        首先,來了解一下 C 語言的變量是如何在內存分部的。C 語言有全局變量(Global)、本地變量(Local),靜態變量(Static)、寄存器變量(Register)。每種變量都有不同的分配方式。先來看下面這段代碼:
#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 
(在BC++/TC++中結果是
0x0012ff88 
0x0012ff84 
0x0012ff80 
這樣,這就說明在BC++/TC++中變量地址是倒序存放的。
而在Turbo C 2.0中,變量的存放是順序的,也就是和VC++相同。它的輸出結果類似於
0x0012ffdc 
0x0012ffde 
0x0012ffe0 
注:Turbo C++ 3.x及以前的版本,包括TC2.0中,int型占兩個字節。以后的版本,比如BC++ 5.x是四個字節。


0x004068d0 
0x004068d4 
0x004068d8 
0x004068dc 
0x004068e0 
0x004068e4 
(對於全局變量和靜態變量,三種編譯器的存放順序都一樣。是順序的。而且全局和靜態變量是放在同一塊區域,表面上看就是說他們是連續存放的。)
        輸出的結果就是變量的內存地址。其中v1,v2,v3是本地變量(自動變量),g1,g2,g3是全局變量,s1,s2,s3是靜態變量。你可以看到這些變量在內存是連續分布的,但是本地變量和全局變量分配的內存地址差了十萬八千里,而全局變量和靜態變量分配的內存是連續的。這是因為本地變量和全局/靜態變量是分配在不同類型的內存區域中的結果。對於一個進程的內存空間而言,可以在邏輯上分成3個部份:代碼區,靜態數據區和動態數據區。動態數據區一般就是“堆棧”。“棧(stack)”和“堆(heap)”是兩種不同的動態數據區,棧是一種線性結構,堆是一種鏈式結構。進程的每個線程都有私有的“棧”,所以每個線程雖然代碼一樣,但本地變量的數據都是互不干擾。一個堆棧可以通過“基地址”和“棧頂”地址來描述。全局變量和靜態變量分配在靜態數據區,本地變量分配在動態數據區,即堆棧中。程序通過堆棧的基地址和偏移量來訪問本地變量。 
├————————————┤低端內存區域(低地址) 
│ ……                            │ 
├————————————┤ 
│ 動態數據區                   │ 
├————————————┤ 
│ ……                            │ 
├————————————┤ 
│ 代碼區                    │ 
├————————————┤ 
│ 靜態數據區                   │ 
├————————————┤ 
│ ……                            │ 
├————————————┤高端內存區域(高地址) 
(補注:對於基於Intel x86架構的計算機,系統采用小端字節序來存放數據,所謂小端字節序是指低序字節低地址,高序字節高地址(內存地址增大方向),大端字節序反之,給定系統所用的字節序稱為主機字節序;CPU也以小端字節序形式讀取數據。)


        堆棧是一個先進后出的數據結構,棧頂地址總是小於等於棧的基地址(棧是倒着長的)。我們可以先了解一下函數調用的過程,以便對堆棧在程序中的作用有更深入的了解。不同的語言有不同的函數調用規定,這些因素有參數的壓入規則和堆棧的平衡。Windows API的調用規則和ANSI C的函數調用規則是不一樣的,前者由被調函數調整堆棧,后者由調用者調整堆棧。兩者通過“__stdcall”和“__cdecl”前綴區分。先看下面這段代碼: 
#i nclude <stdio.h> 
void __stdcall func(int param1,int param2,int param3) 

int var1=param1; 
int var2=param2; 
int var3=param3; 
printf("0x%08x\n",&param1); //打印出各個變量的內存地址 
printf("0x%08x\n",&param2); 
printf("0x%08x\n\n",&param3); 
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”(在BC++/TC++則是順序壓棧,情況剛好相反,剛才地址上的倒序就是由此造成的);然后壓入函數的返回地址(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” 
        聰明的讀者看到這里,差不多就明白緩沖溢出的原理了。先來看下面的代碼: 
#i nclude <stdio.h> 
#i nclude <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++代碼: 
#i nclude <stdio.h> 
#i nclude <iostream.h> 
#i nclude <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") //定義程序的入口 
#i nclude <windows.h> 
_CRTIMP int (__cdecl *printf)(const char *, ...); //定義STL函數printf 
/*--------------------------------------------------------------------------- 
        寫到這里,我們順便來復習一下前面所講的知識: 
        (*注)printf函數是C語言的標准函數庫中函數,VC的標准函數庫由msvcrt.dll模塊實現。 
        由函數定義可見,printf的參數個數是可變的,函數內部無法預先知道調用者壓入的參數個數,函數只能通過分析第一個參數字符串的格式來獲得壓入參數的信息,由於這里參數的個數是動態的,所以必須由調用者來平衡堆棧,這里便使用了__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除盡,BYTE數據占用一個字節,因此對內存起始地址沒有特殊要求。x86 CPU能直接訪問對齊的數據,當他試圖訪問一個未對齊的數據時,會在內部進行一系列的調整,這些調整對於程序來說是透明的,但是會降低運行速度,所以編譯器在編譯程序時會盡量保證數據對齊。同樣一段代碼,我們來看看用VC、Dev-C++和lcc三個不同編譯器編譯出來的程序的執行結果: 
#i nclude <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字節,浪費內存哦。
        在<windef.h>中有這樣的代碼:
typedef unsigned char BYTE;
typedef unsigned long DWORD;
typedef unsigned short WORD;
        對於字符數組,有的編譯器統一用WORD來存儲,可能會導致浪費內存,但效率較高(如TC2.0和TC++3.0,針對16位處理器);有的采用DWORD來存儲,同樣浪費內存;有的則采用BYTE,但可能會導致浪費時間(因為數據不一定對齊,如BC++5.0)。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM