1. 前述 可執行文件的格式是操作系統本身執行機制的反映,理解它有助於對操作系統的深刻理解,掌握可執行文件的數據結構及其一些機理,是研究軟體安全的必修課。`PE(Portable Executable File Format)`是目前 windows 平臺上的主流可執行文件格式。PE 文件衍生於早期 ...
1. 前述
可執行文件的格式是操作系統本身執行機制的反映,理解它有助於對操作系統的深刻理解,掌握可執行文件的數據結構及其一些機理,是研究軟體安全的必修課。`PE(Portable Executable File Format)`是目前 windows 平臺上的主流可執行文件格式。PE 文件衍生於早期的 COFF 文件格式,描述 PE 格式及 COFF 文件的主要地方在 winnt.h 這個頭文件,其中有一節叫 Image Format,如下:
該節給出了 DOS MZ 格式和 windows 3.1 的 NE 格式文件頭,之後就是 PE 文件的內容,在這個頭文件中,幾乎能找到關於 PE 文件的每一個數據結構的定義、枚舉類型、常量定義。winnt.h 這個頭文件是 PE 文件定義的最終決定者。DLL 和 EXE 文件之間的區別完全是語義上的,它們使用完全相同的 PE 格式。唯一的區別就是用一個欄位標識出這個文件是 EXE 還是 DLL。同時也包括其它的 DLL 擴展,比如 OCX 控制項和控制面板程式(CPL 文件)。另外,64 位 windows 只是對 PE 格式做了一些簡單的修飾,新格式叫 PE32+,沒有新的結構加進去,其餘的改變只是簡單地將以前的 32 位欄位擴展成64位,比如 `IMAGE_NT_HEADERS`,如下:
2. PE 文件大體結構
結構的選擇依賴於用戶正在編譯的模式(尤其是 `_WIN64` 是否被定義),在具體學習 PE 之前,先大概清楚下 PE 格式佈局是怎樣子的,如下:
PE 文件使用的是一個平面地址空間,所有代碼和數據都被合併在一起,組成一個很大的結構,文件的內容被分割為不同的區塊,區塊包含代碼和數據,各個區塊按頁邊界來對齊,區塊沒有大小限制,是一個連續結構,每個塊都有它自己在記憶體中的一套屬性。PE 文件是由 PE 載入器載入到記憶體中的,這個 PE 載入器也就是 windows 載入器,它並不是將 PE 文件作為單一記憶體映射文件裝入到記憶體中,而是去遍歷 PE 文件,決定將哪一部分進行映射,這種映射方式是將文件較高的偏移位置映射到較高的記憶體地址,當磁碟文件裝入到記憶體中,其數據結構佈局是一致的,但是數據之間的相對位置可能會改變,如下:
3. 模塊和基地址
下麵需要理清兩個概念,那就是 **模塊** 和 **基地址**,當 PE 文件通過 windows 載入器載入到記憶體後,記憶體中的版本被稱為模塊(Module),映射文件的起始地址被稱為模塊句柄(hModule),可以通過模塊句柄來訪問在記憶體中其它的數據結構,這個初始地址也被稱為基地址(ImageBase)。在 32 位 windows 系統中可以直接調用 `GetModuleHandle` 以取得指向 DLL 的指針,通過指針訪問該 DLL Module 的內容,函數原型為:`HMODULE WINAPI GetModuleHandle(LPCTSTR lpModuleName)`
功能:獲取一個應用程式或動態鏈接庫的模塊句柄。
參數:傳遞一個可執行文件或 DLL 文件名字元串
返回值:若執行成功,則返回模塊的句柄,也就是載入的基地址,若返回零,則表示失敗。如果傳遞參數為 NULL,則返回調用的可執行文件的基地址。
註意事項:只有在當前進程中,這個句柄才會有效,也就是說已映射到調用該函數的進程內,才會正確得到模塊句柄。
1 #include <windows.h> 2 #include <iostream> 3 4 int main() 5 6 { 8 HMODULE hModule = GetModuleHandle(NULL); 9 10 std::cout << hModule << std::endl; 11 12 return 0; 14 }
PE文件載入的基地址(ImageBase):EXE 預設基地址為 `0x00400000H`,DLL 預設基地址為 `0x10000000H`,這個值可以在鏈接應用時使用鏈接程式的 `/BASE` 選項設定,或者通過 REBASE 應用程式進行設置。說完基地址,再來說下相對虛擬地址,由於 PE 文件中里的東西可以載入到空間的任何位置,所以不能依賴於 PE 的載入點,必須有一個方法來指定地址而不依賴於 PE 載入點的地址,所以出現相對虛擬地址(RVA)概念,RVA 只是記憶體中的一個簡單的相對於 PE 文件裝入地址的偏移位置,例如,假設一個 EXE 文件從地址 `0x400000H` 處裝入,並且它的代碼區塊開始於 `0x401000H`,代碼區塊的 RVA 就是:`0x401000H - 0x400000H = 0x1000H`,在這裡,`0x401000H` 是實際的記憶體地址,這個地址被稱為虛擬記憶體地址(VA),另外也可以把虛擬地址想象為加上首選裝入地址的RVA。
4. 文件偏移地址
當PE文件儲存在磁碟上,某個數據的位置相對於文件頭的偏移量,稱為文件偏移地址或物理地址。文件偏移地址從PE文件的第一個位元組開始計數,起始值為0,用十六進位文本編輯器打開文件,裡頭顯示的就是文件偏移地址。
5. IMAGE_DOS_HEADER 結構
在這個結構體中,有兩個欄位非常重要,分別是第一個和最後一個,其它的不重要,其中第一個 e_magic 欄位需要被設置為 0x5A4DH。它也被稱為魔術數字。
這個值有個巨集定義,名為 `IMAGE_DOS_SIGNATURE`,它的 ASCII 值為 MZ,是 MS-DOS 的最初創建者之一 `Mark Zbikowski` 字母的縮寫。
e_lfanew 欄位是真正PE文件頭的相對偏移(RVA),那麼,這個欄位在哪呢?
上圖已經說明瞭,為了驗證是否正確,如下:
在 3CH 偏移處,顯示 0x00000110H(由於 Intel CPU 屬於 Little-Endian 類,字元存儲時低位在前,高位在後,反序排列,將順序恢復後便是 0x00000110H),這個是 e_lfanew 欄位所存儲的值,它占4 個位元組。後面就是 PE 頭了。
6. IMAGE_NT_HEADERS 結構
在一個有效的 PE 文件里,Signature 欄位被設置為 0x00004550H,ASCII 碼字元是 PE00
巨集定義為 `IMAGE_NT_SIGNATURE`
那麼這兩個重要的欄位(e_lfanew 和 Signature)有什麼用呢?這個在以後解析PE文件,判斷一個文件是否是一個 PE 文件時提供重要依據,即判斷這兩個欄位的值是否為 0x5A4DH 和 0x00004550H,你也可以用它們的巨集定義,分別為 `IMAGE_DOS_SIGNATURE` 和 `IMAGE_NT_SIGNATURE`,如果相等,則為一個 PE 文件,如果不相等,則不是一個 PE 文件。
1 #include <windows.h> 2 #include <iostream> 3 4 int main() 5 { 6 // 1.首先須打開一個文件 7 HANDLE hFile = CreateFile( 8 TEXT("test.png"), 9 GENERIC_ALL, 10 NULL, 11 NULL, 12 OPEN_EXISTING, 13 NULL, 14 NULL 15 ); 16 // 2.判斷文件句柄是否有效,若無效則提示打開文件失敗並退出 17 if (hFile == INVALID_HANDLE_VALUE) 18 { 19 std::cout << "打開文件失敗!" << std::endl; 20 CloseHandle(hFile); 21 exit(EXIT_SUCCESS); 22 } 23 // 3.若打開文件成功,則獲取文件的大小 24 DWORD dwFileSize = GetFileSize(hFile, NULL); 25 // 4.申請記憶體空間,用於存放文件數據 26 BYTE * FileBuffer = new BYTE[dwFileSize]; 27 // 5.讀取文件內容 28 DWORD dwReadFile = 0; 29 ReadFile(hFile, FileBuffer, dwFileSize, &dwReadFile, NULL); 30 // 6.判斷這個文件是不是一個有效的PE文件 31 // 6.1 先檢查DOS頭中的MZ標記,判斷e_magic欄位是否為0x5A4D,或者是IMAGE_DOS_SIGNATURE 32 DWORD dwFileAddr = (DWORD)FileBuffer; 33 auto DosHeader = (PIMAGE_DOS_HEADER)dwFileAddr; 34 if (DosHeader->e_magic != IMAGE_DOS_SIGNATURE) 35 { 36 // 如果不是則提示用戶,並立即結束 37 MessageBox(NULL, TEXT("這不是一個有效PE文件"), TEXT("提示"), MB_OK); 38 delete FileBuffer; 39 CloseHandle(hFile); 40 exit(EXIT_SUCCESS); 41 } 42 // 6.2 若都通過的話再獲取NT頭所在的位置,並判斷e_lfanew欄位是否為0x00004550,或者是IMAGE_NT_SIGNATURE 43 auto NtHeader = (PIMAGE_NT_HEADERS)(dwFileAddr + DosHeader->e_lfanew); 44 if (NtHeader->Signature != IMAGE_NT_SIGNATURE) 45 { 46 // 如果不是則提示用戶,並立即結束 47 MessageBox(NULL, TEXT("這不是一個有效PE文件"), TEXT("提示"), MB_OK); 48 delete FileBuffer; 49 CloseHandle(hFile); 50 exit(EXIT_SUCCESS); 51 } 52 // 7.若上述都通過,則為一個有效的PE文件 53 MessageBox(NULL, TEXT("這是一個有效PE文件"), TEXT("提示"), MB_OK); 54 delete FileBuffer; 55 CloseHandle(hFile); 56 // 8.結束程式 57 return 0; 58 }
以上代碼就是簡單實現判斷一個文件是不是有效的 PE 文件。在上述代碼中,運用了 CreateFile()、GetFileSize()、ReadFile() 來獲取文件內容,得到文件的基址 dwFileAddr,只需將該變數轉換成 `PIMAGE_DOS_HEADER` 類型,那麼就能獲取到NT頭的開始位置,NT頭的位置可同 (PIMAGE_NT_HEADERS)((PIMAGE_DOS_HEADER)dwFileAddr->e_lfanew + dwFileAddr) 獲取,有了這個,後面的工作就變得簡單多了。
7. IMAGE_FILE_HEADER 結構
該結構體描述的是文件的一般性質,有 7 個欄位,共占 20 個位元組,20 相當於十六進位的 14H,下圖已標出實際位置,如下:
- 這裡標記的是 Machine 欄位,占兩個位元組,它的值為 0x014CH,代表的是 Intel i386 平臺。
- 這裡標記的是 NumberOfSections 欄位,占兩個位元組,它的值為 0x0006H,代表的是有 6 個區塊,也可以說有 6 個節。
- 這裡標記的是 TimeDateStamp 欄位,占四個位元組,它的值為 0x5C0748D5H,代表的是文件創建日期和時間。
由上圖可以看出,該文件創建時間為 2018-12-05 / 11:41:09。
- 這個值以0填充,用不到。
- 這個值以0填充,用不到。
- 這個欄位就比較重要,劃重點,SizeOfOptionalHeader,占兩個位元組,它的值為 0x00E0H,代表的是 `IMAGE_OPTIONAL_HEADER32` 結構的大小,在 32 位系統,它的值為 0x00E0H,在 64 位系統,它的值為 0x00F0H,
- 最後一個欄位 Characteristics,占兩個位元組,它的值為 0x0102H,代表的是文件的屬性。這個值是由 0x0100H 和 0x0002H 兩者之和,0x0100H 這個值代表的是目標平臺為 32 位機器,0x0002H 這個值代表文件可執行,如果為0,一般是鏈接出現了問題。
1 // 獲取文件頭 2 auto FileHeader = NtHeader->FileHeader; 3 // 接下來就是解析各欄位 4 std::cout << "運行平臺:0x" << std::hex << FileHeader.Machine << std::endl; 5 std::cout << "區塊數目:0x" << std::hex << FileHeader.NumberOfSections << std::endl; 6 std::cout << "文件創建日期和時間:0x" << std::hex << FileHeader.TimeDateStamp << std::endl; 7 std::cout << "IMAGE_OPTIONAL_HEADER32結構大小:0x" << std::hex << FileHeader.SizeOfOptionalHeader << std::endl; 8 std::cout << "文件屬性:0x" << std::hex << FileHeader.Characteristics << std::endl;
將上述代碼插入到 return 0; 之前,運行如下:
再將上面文件創建日期和時間進行轉換,代碼如下:
1 // 獲取文件頭 2 auto FileHeader = NtHeader->FileHeader; 3 // 進行時間轉換 4 tm * FileCreateTime = gmtime((time_t*)&FileHeader.TimeDateStamp); 5 // 接下來就是解析各欄位 6 std::cout << "運行平臺:0x" << std::hex << FileHeader.Machine << std::endl; 7 std::cout << "區塊數目:0x" << std::hex << FileHeader.NumberOfSections << std::endl; 8 std::cout << "文件創建日期和時間:" << std::dec << FileCreateTime->tm_year + 1900 << "-" 9 << FileCreateTime->tm_mon + 1<< "-" 10 << FileCreateTime->tm_mday << " " 11 << FileCreateTime->tm_hour + 8 << ":" 12 << FileCreateTime->tm_min << ":" 13 << FileCreateTime->tm_sec << std::endl; 14 std::cout << "IMAGE_OPTIONAL_HEADER32結構大小:0x" << std::hex << FileHeader.SizeOfOptionalHeader << std::endl;
上面是用到了tm的結構,以及 gmtime 這個函數進行轉換,在用之前需要包含頭文件 time.h,運行如下:
不過還是要註意下,首先 tm_year 這個值為十六進位,需轉成十進位,而且要加上 1900,因為時間是從 1900 開始算,它的值為偏移,其次月是從 0 開始算的,所以要加 1,最後是時區問題,因為我這裡位於東八區,所以小時需加上 8。另外關於調試的那兩個欄位,沒有必要去對它深究,因為微軟的VS已用了新的 Debug 格式,這個只是用來設置 COFF 符號,跟 COFF 符號有關,一般這個值都為 0,所以不探討它。關於運行平臺代碼和文件屬性代碼可以去網上查表就行,這裡就省略。
8. IMAGE_OPTIONAL_HEADER 結構
上圖展示的是 `IMAGE_OPTIONAL_HEADER32` 結構體各欄位,這個結構體相對來說就比較大,我已經分析好了,這個是 32 位的,64 位的大體結構沒變,只是有幾個欄位改成的 ULONGLONG 類型,那麼它在實際內部是怎麼樣的呢?下麵這張圖是驗證上面圖片所敘述的。
上圖所標記的,為 `IMAGE_OPTIONAL_HEADER32` 結構所有成員,你也註意到了,在結尾處,有 .text,說明已經到了該結構體的末尾了,算了一下,恰好占了 224 個位元組,這個值其實在 `IMAGE_FILE_HEADER` 中倒數第二個欄位已經指出了,值為 0xE0,這個值相當於十進位中的 224。為了更好的說明,我用序號標記了各個欄位,其中有一些為透明,一是沒地方標,二是能看清實際數值大小,這樣便於分析。
以下是各欄位解析:
- Magic:這個是一個標記,它的值為 0x010BH,代表的是普通的可執行映象,一般是 0x010BH,如果是 64 位,則為 0x020BH,如果為 ROM 映象,該值為 0x0107H。
- MajorLinkerVersion:鏈接程式主版本號,值為 0x0EH。
- MinorLinkerVersion:鏈接程式次版本號,值為 0x00H。
- SizeOfCode:所有含有代碼區塊的總大小,該值為 0x0031D000H,這個代碼區塊是帶有 `IMAGE_SCN_CNT_CODE` 屬性,這個值是向上對齊某一個值的整數倍。通常情況下,多數文件只有一個 Code 塊,所以這個欄位和 .text 塊的大小匹配。
- SizeOfInitializedData:所有初始化數據區塊總大小,該值為 0x000B4000H,這個是在編譯時所構成的塊的大小(不包括代碼段),一般這個值是不准確的。
- SizeOfUninitializedData:所有未初始化數據區塊總大小,該值為 0,這些塊在程式開始運行時沒有指定值,未初始化的數據通常在 .bss 塊中。
- AddressOfEntryPoint:程式執行入口 RVA,該值為 0x002B56D0H。在大多數可執行文件中,這個地址並不直接指向 Main、WinMain 或者是 DllMain,而是指向運行庫代碼並由它來調用上述函數。對於 DLL 來說,這個入口點是在程式初始化和關閉時以及線程創建和毀滅時被調用。
- BaseOfCode:代碼段的起始 RVA,該值為 0x00001000H,如果是用微軟的鏈接器生成的,則該值通常是 0x00001000H。
- BaseOfData:數據段的起始 RVA,該值為 0x0031E000H,數據段通常在記憶體的末尾,對於不同版本的微軟鏈接器,這個值是不一致的,在64位可執行文件中是不出現的。
- ImageBase:程式預設裝入地址,該值為 0x00400000H,載入器試圖在這個地址表裝入 PE 文件,如果可執行文件是在這個地址裝入的,那麼載入器將跳過應用基址重定位的步驟。
- SectionAlignment:記憶體中區塊對齊大小,值為 0x00001000H,預設對齊尺寸是目標 CPU 的頁尺寸,最小的對齊尺寸是一頁 1000H(4KB),在 IA-64 上,這個值是 8KB。每個區塊裝入地址必定是本欄位指定數值的整數倍。
- FileAlignment:磁碟上 PE 文件內的區塊對齊大小,值為 0x00000200H,對於 x86 的可執行文件,這個值通常是 200H 或 1000H,這是為了保證塊總是從磁碟的扇區開始的,這個值必須是 2 的冪,最小為 200H。
- MajorOpreatingSystemVersion:要求操作系統的最低版本號的主版本號,該值為 0x0006H,這個值似乎沒什麼用。
- 同上,沒什麼用。
- 同上,沒什麼用。
- 同上,沒什麼用。
- 同上,沒什麼用。
- 同上,沒什麼用。
- 同上,沒什麼用。
- SizeOfImage:映象裝入記憶體後的總尺寸,該值為 0x003D5000H,它指裝入文件從 ImageBase 到最後一個塊的大小,最後一個塊根據其大小往上取整。
- SizeOfHeaders:是 MS-DOS 頭部、PE 頭部、區塊表的組合尺寸。該值為 0x00000400H。
- CheckSum:校驗和,IMAGEHLP.DLL 中的 CheckSumMappedFile 函數可以計算這個值,一般的EXE文件可以是 0,但一些內核模式的驅動程式和系統 DLL 必須有一個校驗和。
- Subsystem:一個標明可執行文件所期望的子系統的枚舉值,這個值只對 EXE 是重要的。該值為 0x0003H。
- DllCharacteristics:DllMain() 函數何時被調用,預設為 0。
- SizeOfStackReserve:在 EXE 文件里,為線程保留的堆棧大小,它一開始只提交其中一部分,只有在必要時,才提交剩下的部分。
- SizeOfStackCommit:在 EXE 文件里,一開始即被委派堆棧的記憶體數量,預設值為 4KB。
- SizeHeapReserve:在 EXE 文件里,為進程的預設堆保留的記憶體,預設值為 1MB,但是在當前 Windows 里,堆值在用戶不幹涉的情況下就能增長超過這個值。
- SizeOfHeapCommit:在 EXE 文件里,委派給堆的記憶體大小,預設值是 4KB。
- LoaderFlag:與調試有關,預設為 0。
- NumberOfRvaAndSizes:數據目錄表的項數,這個欄位一直以來都為 16。
- DataDirectory[16]:數據目錄表,由數個 `IMAGE_DATA_DIRECTORY` 結構組成,指向輸入表、輸出表、資源等數據。
同樣,將上述代碼放置最後,對擴展頭進行解析,因欄位太多,沒一一列舉,運行後如下:
對於該結構的最後一個欄位,它是一個數組,這個數組有 16 個成員,代表的是目錄表中的項,遍歷它也不是很難,代碼如下:
運行後如下:
將上述與 LoadPE 對照,看下是否正確,如下:
從上面可以看出,已經成功遍歷出目錄表中每項的 RVA 和大小。
(本小節完)