一文讀懂PE格式


0x01 前言

PE文件是指windows系統下使用的可執行文件格式,它是微軟在unix平台的COFF基礎上制作而成的。PE文件一般指32位的可執行文件,也稱為PE32。64位的可執行文件稱為PE+或者PE32+(不是PE64),是PE32的一種擴展形式。

0x01 簡介

要了解PE文件,首先要知道PE格式,那么什么是PE格式呢,既然是一個格式,那肯定是需要遵循的一定的定理。其實PE格式就是各種結構體的結合,這些結構體都定義在在WinNT.h這個頭文件中。

PE文件整體結構
一個PE文件大致可分為以下幾個部分:

  • DOS部分
  • PE文件頭
  • 節區頭(節表)
  • 節數據(塊數據)
  • 調試信息

從DOS頭到節區頭是PE頭,其下的是PE體。文件中使用偏移(Offset), 內存中使用VA(Virtual Address)來表示位置。

VA指的是進程虛擬內存的絕對地址,RVA(Relative Virtual Address)指從某個基准位置(ImageBase)開始的相對地.址。兩者存在以下換算關系:

RVA +ImageBase = VA

PE頭內部的信息大多以RVA的形式存在

0x02 PE頭

0x02.1 DOS頭

DOS頭結構體大小為40個字節,其中只需要熟悉兩個成員變量:e_magice_lfanew,前者是DOS簽名,固定為MZ,取自微軟開發人員Mark Zbikowski首字母。后者則是指示NT頭的偏移位置(IMAGE_NT_HEADERS),除了這兩個成員,其他成員全部用0填充都不會影響程序正常運行。IMAGE_DOS_HEADER定義如下

typedef struct _IMAGE_DOS_HEADER {      // DOS .EXE header
    WORD   e_magic;                     // Magic number
    WORD   e_cblp;                      // Bytes on last page of file
    WORD   e_cp;                        // Pages in file
    WORD   e_crlc;                      // Relocations
    WORD   e_cparhdr;                   // Size of header in paragraphs
    WORD   e_minalloc;                  // Minimum extra paragraphs needed
    WORD   e_maxalloc;                  // Maximum extra paragraphs needed
    WORD   e_ss;                        // Initial (relative) SS value
    WORD   e_sp;                        // Initial SP value
    WORD   e_csum;                      // Checksum
    WORD   e_ip;                        // Initial IP value
    WORD   e_cs;                        // Initial (relative) CS value
    WORD   e_lfarlc;                    // File address of relocation table
    WORD   e_ovno;                      // Overlay number
    WORD   e_res[4];                    // Reserved words
    WORD   e_oemid;                     // OEM identifier (for e_oeminfo)
    WORD   e_oeminfo;                   // OEM information; e_oemid specific
    WORD   e_res2[10];                  // Reserved words
    LONG   e_lfanew;                    // File address of new exe header
  } IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;

變量e_lfanew固定偏移位置3C處,從此后到e_lfanew所指向的偏移位置為DOS Stud(中文一般翻譯為DOS存根),在win32中未使用。在16位系統中運行便輸出一個this programe cannot be urn in DOS mode 就退出了。
如下圖框中的內容便是DOS頭:
20210511142524

由圖可知e_lfanew的值是000000F8,則00000040000000F4的內容便為DOS Stud

0x02.2 PE文件頭

PE文件頭由PE文件頭標志,標准PE頭,擴展PE頭三部分組成

0x02.2.1 IMAGE_NT_HEADERS

上一節我們有講到DOS頭中的e_lfanew指向IMAGE_NT_HEADERS,
我們先看一下這個結構體在winnt.h中的定義:

typedef struct _IMAGE_NT_HEADERS {
    DWORD Signature;  //固定為'004550', 字符:"PE"
    IMAGE_FILE_HEADER FileHeader;//標准PE頭 => 20字節
    IMAGE_OPTIONAL_HEADER32 OptionalHeader;//擴展PE頭 => 32位下224字節(0xE0) 64位下240字節(0xF0)
} IMAGE_NT_HEADERS, *PIMAGE_NT_HEADERS;

由上可知IMAGE_NT_HEADERS定義了3個成員變量,

  • Signature固定為字符PE
  • FileHeader指向一個為IMAGE_FILE_HEADER的結構體;
  • 在32位下OptionalHeader指向一個IMAGE_OPTIONAL_HEADER32的結構體。在64位下,OptionalHeader指向一個IMAGE_OPTIONAL_HEADER64的結構體。

0x02.2.2 IMAGE_FILE_HEADER

我們一個一個分析,先看下IMAGE_FILE_HEADER的定義:

typedef struct _IMAGE_FILE_HEADER {
    WORD    Machine; 				//可以運行在什么平台上 任意:0 ,Intel 386以及后續:14C x64:8664
    WORD    NumberOfSections; 		//節的數量
    DWORD   TimeDateStamp; 			//編譯器填寫的時間戳
    DWORD   PointerToSymbolTable;   //調試相關
    DWORD   NumberOfSymbols; 		//調試相關
    WORD    SizeOfOptionalHeader;   //標識擴展PE頭大小
    WORD    Characteristics;        //文件屬性 => 16進制轉換為2進制根據哪些位有1,可以查看相關屬性
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;

IMAGE_FILE_HEADER里面大概有4個重要的成員變量需要掌握,如果這些變量的值設置不正確,程序便不能正常運行。

  • Machine:每個CPU都有一個唯一的Machine碼,兼容32位Intel x86芯片的machine碼是14c。其它Machine碼可在winnt.h中查看。
  • NumberOfSections: 指示文件中存在的節區數量, 此值是一定要大於0,且當定義的節區數與實際的節區數不一樣時,將發生運行錯誤。
  • SizeOfOptionalHeader:標識IMAGE_NT_HEADERS第三個成員變量OptionalHeader的大小。
  • Characteristics:標識文件屬性,是否是dll,是否可執行等信息。

如下圖便是FileHeader,
20210511145711

根據圖片所示,我們可以得出各值:

Machine=014c; 
NumberOfSections =0004; 	
TimeDateStamp=4ade5203; 		
PointerToSymbolTable=00000000;  
NumberOfSymbols=00000000; 		
SizeOfOptionalHeader=00e0;   
Characteristics=010f;        

0x02.2.2 IMAGE_OPTIONAL_HEADER

擴展PE頭在32位和64位系統上大小是不同的,在32位系統上有224個字節,16進制就是0xE0,與上面的SizeOfOptionalHeader也能對上。

IMAGE_OPTIONAL_HEADER是PE頭結構體最大的一個,先看定義:

typedef struct _IMAGE_OPTIONAL_HEADER {
    //
    // Standard fields.
    //

    WORD    Magic;						//PE32: 10B PE64: 20B
    BYTE    MajorLinkerVersion;
    BYTE    MinorLinkerVersion;
    DWORD   SizeOfCode;					//所有含有代碼的區塊的大小 編譯器填入 沒用(可改)
    DWORD   SizeOfInitializedData;		//所有初始化數據區塊的大小 編譯器填入 沒用(可改)
    DWORD   SizeOfUninitializedData;	//所有含未初始化數據區塊的大小 編譯器填入 沒用(可改)
    DWORD   AddressOfEntryPoint;		//程序入口RVA,指出程序最先執行的代碼起始位置,相當重要
    DWORD   BaseOfCode;					//代碼區塊起始RVA
    DWORD   BaseOfData;					//數據區塊起始RVA

    //
    // NT additional fields.
    //

    DWORD   ImageBase;						//內存鏡像基址(程序默認載入基地址)
    DWORD   SectionAlignment; 				//內存中對齊大小
    DWORD   FileAlignment; 					//文件中對齊大小(提高程序運行效率)
    WORD    MajorOperatingSystemVersion;
    WORD    MinorOperatingSystemVersion;
    WORD    MajorImageVersion;
    WORD    MinorImageVersion;
    WORD    MajorSubsystemVersion;
    WORD    MinorSubsystemVersion;
    DWORD   Win32VersionValue;
    DWORD   SizeOfImage;					//內存中整個PE文件的映射的尺寸,可比實際值大,必須是SectionAlignment的整數倍
    DWORD   SizeOfHeaders; 					//所有的頭加上節表文件對齊之后的值
    DWORD   CheckSum;						//映像校驗和,一些系統.dll文件有要求,判斷是否被修改
    WORD    Subsystem;						
    WORD    DllCharacteristics;				//文件特性,不是針對DLL文件的,16進制轉換2進制可以根據屬性對應的表格得到相應的屬性
    DWORD   SizeOfStackReserve;
    DWORD   SizeOfStackCommit;
    DWORD   SizeOfHeapReserve;
    DWORD   SizeOfHeapCommit;
    DWORD   LoaderFlags;
    DWORD   NumberOfRvaAndSizes;
    IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES]; //數據目錄表,結構體數組
} IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32;

部分成員變量說明:

  • ImageBase:指出文件被加載到內存應該被優先加載的內存地址,exe,dll文件被裝載到用戶內存的0~7fffffff中,sys文件被載入內存的80000000~ffffffff中,一般情況下,exe會被裝載到00400000,dll文件的ImageBase值為10000000

  • NumberOfRvaAndSizes: 用來指定DataDirectory數組的個數,在winnt.h中IMAGE_NUMBEROF_DIRECTORY_ENTRIES被明確定義為16,但PE loader一般會通過此值來識別DataDirectory的大小,也就是說DataDirectory的長度不一定都是16。

  • DataDirectory是由IMAGE_DATA_DIRECTORY結構體數據組成的,數組中的每一項都有定義,詳細如下:

20210511153038

但我們一般只需要關心幾個常見的即可,導出表、導入表、資源表、TLS表。詳細的我們放到后面再講。

整個擴展頭內容如圖所示。
20210511151700

根據圖片例子,我們把常見的成員變量值列舉出來如下:

Magic=010b
AddressOfEntryPoint=0x0005ec27
BaseOfCode=1000
BaseOfData=8b000
ImageBase=0x00400000
SectionAlignment=1000
FileAlignment=1000
SizeOfImage=dd000
SizeOfHeader=1000
NumberOfRvaAndSizes=10

還需要知道的是,程序的真正入口點 = ImageBase + AddressOfEntryPoint。

0x03.節區頭

節區頭由IMAGE_SECTION_HEADER定義,節區頭的結構如下。

typedef struct _IMAGE_SECTION_HEADER {
    BYTE    Name[IMAGE_SIZEOF_SHORT_NAME]; //ASCII字符串 可自定義 只截取8個字節
    union {								   //該節在沒有對齊之前的真實尺寸,該值可以不准確
            DWORD   PhysicalAddress;
            DWORD   VirtualSize;
    } Misc;
    DWORD   VirtualAddress; 			   //內存中的偏移地址
    DWORD   SizeOfRawData;				   //節在文件中對齊的尺寸
    DWORD   PointerToRawData;			   //節區在文件中的偏移
    DWORD   PointerToRelocations;
    DWORD   PointerToLinenumbers;
    WORD    NumberOfRelocations;
    WORD    NumberOfLinenumbers;
    DWORD   Characteristics;			   //節的屬性
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;

節區頭的數量由IMAGE_NT_HEADERS結構中的FileHeader.NumberOfSections 字段來指定的。同時也以一個全部為空的IMAGE_SECTION_HEADER結構體作為結束,節區頭總是被存放在緊接在PE文件頭的地方。
在節區頭中,我們一般只需要了解以下幾個:

  • Name :非必須以NULL結束,也未限制只能使用ASCII,可放入任何值
  • VirtualSize: 內存中節區所占大小
  • VirtualAddress: 內存中節區起始地址(RVA)
  • SizeOfRawData: 磁盤文件中節區所占大小
  • PointerToRawData:磁盤文件中節區起始位置
  • Charateristics:節區屬性。

實例:
20210511162620

如圖所示,可知有四個節區,外加一個全為0的節區。

0x04.導入表

先看如何定位導入表起始地址,在IMAGE_OPTIONAL_HEADER頭中的最后一個成員變量,我們有提到其是一個包含16個元素的IMAGE_DATA_DIRECTORY數組,其中第2個元素就是導入表的起始位置。

先看IMAGE_DATA_DIRECTORY的定義

typedef struct _IMAGE_DATA_DIRECTORY {
   DWORD   VirtualAddress;
   DWORD   Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;

即我們可以通過IMAGE_OPTIONAL_HEADER32.DataDirectory[1].VirtualAddress訪問到導入表的起始地址。

再來看一下定義導入表的結構體:

typedef struct _IMAGE_IMPORT_DESCRIPTOR {
   union {
       DWORD   Characteristics;            // 0 for terminating null import descriptor
       DWORD   OriginalFirstThunk;         // RVA 指向 INT (PIMAGE_THUNK_DATA結構數組)
   } DUMMYUNIONNAME;
   DWORD   TimeDateStamp;                  // 0 if not bound,
                                           // -1 if bound, and real date\time stamp
                                           //     in IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT (new BIND)
                                           // O.W. date/time stamp of DLL bound to (Old BIND)

   DWORD   ForwarderChain;                 // -1 if no forwarders
   DWORD   Name;							//RVA指向dll名字,以0結尾
   DWORD   FirstThunk;                     // RVA 指向 IAT (PIMAGE_THUNK_DATA結構數組)
} IMAGE_IMPORT_DESCRIPTOR;
typedef IMAGE_IMPORT_DESCRIPTOR UNALIGNED *PIMAGE_IMPORT_DESCRIPTOR;

一個程序導入了多少個庫就有多少個IMAGE_IMPORT_DESCRIPTOR結構體,這些結構體組成一個數組,且結構體數組以一個全為NULL的結構體作為結束。所以被稱為IMPORT Directory Table.其中比較重要的成員變量如下(以下地址值全為RVA)

  • OriginalFirstThunk:指向INT(導入名稱表 、Improt Name Table)的地址,以全NULL結束。
  • Name:庫名稱字符串的地址
  • FirstThunk: 指向IAT(導入地址表、Import Address Table)的地址,以全NULL結束。

舉個例子,隨便找個程序,
IMAGE_OPTIONAL_HEADER32.DataDirectory[1].VirtualAddress的值為9C 5A 0F 00 即RVA=F5A9C, 換算成RAW則為F4A9C(換算方法見附)。
我們轉到F4A9C的地址查看如下:
20210511193313

OriginalFirstThunk - INT (Import Name Table)
第一個成員變量為OriginalFirstThunk,它是INT的起始地址,換句話說,就是INT是一個包含導入函數信息的結構體指針數組,每個數組的元素都指向一個IMAGE_IMPORT_BY_NAME的結構體,並以全為NULL的元素結束。根據上圖我們知道第一個元素的值為F6284(RVA)->換成RAW則為F5284。來到F5284這個地址.如下。由圖可知INT數組長度為5。(以就代表着從這個庫文件里面導入了5個函數)

20210511194111
第一個值為F6648->RAW為F5648。來這這個地址。
20210511194234
要看懂這個結構得先看下IMAGE_IMPORT_BY_NAME的定義

typedef struct _IMAGE_IMPORT_BY_NAME {
    WORD    Hint;
    CHAR   Name[1];
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;

前兩個字節是庫中函數的固有編號,后面的則是一個字符數組,以00結束。所以這個導入的函數則是ColorHLSToRGB.

Name
根據IMAGE_IMPORT_DESCRIPTOR的結構可知,第四個成員則為Name,其值為F666A,換成RAW為F566A,我們轉到這個地址看下,可知其導入的是SHLWAPI.dll
20210511193438

另外,我們也可以通過工具驗證確實從SHLWAPI.dll導入了5個函數。
20210511194918

FirstThunk- IAT (Import Address Table)

IMAGE_IMPORT_DESCRIPTOR結構體可知其最后一個成員為FirstThunk。根據上面的圖可知,第一數組元素的FirstThunk值為C55F4, RAW為:C45F4。我們來到這個地址處:
20210511195756
第一個數組元素值為F6648.如上便是IAT數組區域,對應於SHLWAPI.dll,數組長度也剛好是5。轉為RAW則為F5648。來這個地址,發現他們指向同一個函數。
20210511200218

既然指向同一個地址,為啥需要兩個去索引,這是因為需要區分PE加載前還是加載后。如果是加載前,那個IAT跟INT一樣,都可以找到依賴的函數名稱,如果是加載后。也就是在內存中的話, 那么IAT表保存的就是函數的地址。

PELoader把導入函數輸入至IAT的步驟

  • 讀取IID的Name成員,獲取庫名稱字符串(eg:kernel32.dll)
  • 裝載相應庫: LoadLibrary("kernel32.dll")
  • 讀取IID的OriginalFirstThunk成員,獲取INT地址
  • 逐一讀取INT中數組的值,獲取相應IMAGE_IMPORT_BY_NAME地址(RVA)
  • 使用IMAGE_IMPORT_BY_NAME的Hint(ordinal)或Name項,獲取相應函數的起始地址:GetProcAddress("GetCurrentThreadld")
  • 讀取IID的FirstThunk(IAT)成員,獲得IAT地址
  • 將上面獲得的函數地址輸入相應IAT數組值
  • 重復以上步驟4~7,知道INT結束(遇到NULL)

0x5導出表

導出表由結構體IMAGE_EXPORT_DIRECTORY定義。在PE文件中,IMAGE_EXPORT_DIRECTORY結構體數組的起始位置由IMAGE_OPTIONAL_HEADER32.DataDirectory[0].VirtualAddress給出。
我們先看下IMAGE_EXPORT_DIRECTORY的定義:

typedef struct _IMAGE_EXPORT_DIRECTORY {
    DWORD   Characteristics;
    DWORD   TimeDateStamp;
    WORD    MajorVersion;
    WORD    MinorVersion;
    DWORD   Name;					// 指針指向該導出表文件名字符串
    DWORD   Base;					// 導出函數起始序號
    DWORD   NumberOfFunctions;		// 實際導出函數的個數
    DWORD   NumberOfNames;			// 以函數名字導出的函數個數
    DWORD   AddressOfFunctions;     // 指針指向導出函數地址表RVA
    DWORD   AddressOfNames;         // 指針指向導出函數名稱表RVA
    DWORD   AddressOfNameOrdinals;  // 指針指向導出函數序號表RVA
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;

函數查找過程 - GetProAddress工作原理

  • 利用AddressOfNames成員轉到“函數名稱數組”
  • “函數名稱數組”中存儲字符串地址。通過比較字符串,查找指定的函數名稱(此時數組的索引稱為name_index)
  • 利用AddressOfNameOrdinals成員,轉到orinal數組
  • 在orinal數組中通過name_index查找相應的值
  • 利用AddressOfFunction成員轉到“函數地址數組”
  • 在“函數地址數組”中將剛剛求得的ordinal用作數組索引,獲得指定的函數起始地址。

有了導入表的基礎,則理解導出表就很簡單了,這里就不再舉例了。

0x6 附

1.RVA to RAW

轉換方法如下:
1).查找RVA所在節區
2).使用如下公司計算。
RAW - PointerToRawData = RVA-VirtualAddress

RAW = RVA-VirtualAddress + PointerToRawData

原理:
在內存中或者在文件中,一個地址相對與該地址所在的區段的偏移大小是固定的。換句話說,它們距離這一個節區開頭的距離都是相同的。所以我們可以先算出相對於其所在區段的偏移大小,再加上對應區段的基地址。
舉個例子,如圖,假如我們要算 RVA a7bd0的raw地址是多少?
20210511173455
1、首先我們確定a7bd0在哪個Virtual Address中(因為是rva,所以當成內存地址看)。可知其在.rdata段,
2、rdata段的基地址是8b000,a7bd0相對首地址相差a7bd0-8b000=1cbd0.
3、再加上rdata段在文件中的首地址8b000(Raw Address).1cbd0+8b000=a7bd0
4、所以rva的a7bd0在文件中的偏移是a7bd0。


免責聲明!

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



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