PE 學習之路 —— DOS 頭、NT 頭
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 和大小。
(本小節完)