不賴猴的筆記,轉載請注明出處。
深入剖析PE文件
PE文件是Win32的原生文件格式.每一個Win32可執行文件都遵循PE文件格式.對PE文件格式的了解可以加深你對Win32系統的深入理解.
一、 基本結構。
上圖便是PE文件的基本結構。(注意:DOS MZ Header和部分PE header的大小是不變的;DOS stub部分的大小是可變的。)
一個PE文件至少需要兩個Section,一個是存放代碼,一個存放數據。NT上的PE文件基本上有9個預定義的Section。分別是:.text, .bss, .rdata, .data, .rsrc, .edata, .idata, .pdata, 和 .debug。一些PE文件中只需要其中的一部分Section.以下是通常的分類:
l 執行代碼Section , 通常命名為: .text (MS) or CODE (Borland)
l 數據Section, 通常命名為:.data, .rdata, 或 .bss(MS) 或 DATA(Borland).
l 資源Section, 通常命名為:.edata
l 輸入數據Section, 通常命名為:.idata
l 調試信息Section,通常命名為:.debug
這些只是命名方式,便於識別。通常與系統並無直接關系。通常,一個PE文件在磁盤上的映像跟內存中的基本一致。但並不是完全的拷貝。Windows加載器會決定加載哪些部分,哪些部分不需要加載。而且由於磁盤對齊與內存對齊的不一致,加載到內存的PE文件與磁盤上的PE文件各個部分的分布都會有差異。
當一個PE文件被加載到內存后,便是我們常說的模塊(Module),其起始地址就是所謂的HModule.
二、 DOS頭結構。
所有的PE文件都是以一個64字節的DOS頭開始。這個DOS頭只是為了兼容早期的DOS操作系統。這里不做詳細講解。只需要了解一下其中幾個有用的數據。
1. e_magic:DOS頭的標識,為4Dh和5Ah。分別為字母MZ。
2. e_lfanew:一個雙字數據,為PE頭的離文件頭部的偏移量。Windows加載器通過它可以跳過DOS Stub部分直接找到PE頭。
3. DOS頭后跟一個DOS Stub數據,是鏈接器鏈接執行文件的時候加入的部分數據,一般是“This program must be run under Microsoft Windows”。這個可以通過修改鏈接器的設置來修改成自己定義的數據。
三、 PE頭結構。
PE頭的數據結構被定義為IMAGE_NT_HEADERS。包含三部分:
1. Signature:PE頭的標識。雙字結構。為50h, 45h, 00h, 00h. 即“PE”。
2. FileHeader:20字節的數據。包含了文件的物理層信息及文件屬性。
這里主要注意三項。
l NumberOfSections:定義PE文件Section的個數。如果對PE文件新增或刪除Section的話,一定要記的修改此域。
l SizeOfOptionalHeader:定義OptionHeader結構的大小。
l Characteristics:主要用來標識當前的PE文件是執行文件還是DLL。其各位都有具體的含義。
數據位 |
Windows.inc的預定義 |
為1時的含義 |
0 |
IMAGE_FILE_RELOCS_STRIPPED |
文件中不存在重定位信息 |
1 |
IMAGE_FILE_EXECUTABLE_IMAGE |
文件是可執行的 |
2 |
IMAGE_FILE_LINE_NUMS_STRIPPED |
不存在行信息 |
3 |
IMAGE_FILE_LOCAL_SYMS_STRIPPED |
不存在符號信息 |
7 |
IMAGE_FILE_BYTES_REVERSED_LO |
小尾方式 |
8 |
IMAGE_FILE_32BIT_MACHINE |
只在32位平台運行 |
9 |
IMAGE_FILE_DEBUG_STRIPPED |
不包含調試信息 |
10 |
IMAGE_FILE_REMOVABLE_RUN_FROM_SWAP |
不能從可移動盤運行 |
11 |
IMAGE_FILE_NET_RUN_FROM_SWAP |
不能從網絡運行 |
12 |
IMAGE_FILE_SYSTEM |
系統文件。不能直接運行 |
13 |
IMAGE_FILE_DLL |
DLL文件 |
14 |
IMAGE_FILE_UP_SYSTEM_ONLY |
文件不能在多處理器上運行 |
15 |
IMAGE_FILE_BYTES_REVERSED_HI |
大尾方式 |
3. OptionalHeader:總共224個字節。最后128個字節為數據目錄(Data Directory)。
以下是字段的說明:
l AddressOfEntryPoint:程序入口點地址。但加載器要運行加載的PE文件時要執行的第一個指令的地址。它是一個RVA(相對虛擬地址)地址。一些對PE文件插入代碼的程序就是修改此處的地址為要運行的代碼,然后再跳轉回此處原來的地址。
l ImageBase:PE文件被加載到內存的期望的基地址。對於EXE文件,通常加載后的地址就期望的地址。但是DLL卻可能是其他的。因為如果這個地址被占,系統就會重新分配一塊新的內存,同時會修改此處加載后的地址。EXE文件通常是400000h.
l SectionAlignment:每一個Section的內存對齊粒度。比如:此值為4096(1000h),那么每一個Section的起始地址都應該是4096(1000h)的整數倍。如果第一個Section的地址是401000h,大小為100個字節。那么下一個Section的起始地址為402000h.。兩個Section之間的空間大部分是空的,未用的。
l FileAlignment:每一個Section的磁盤對齊粒度。比如,此值為512(200h),那么每一個Section在文件內的偏移位置都是512(200h)的整數倍。與SectionAlignment同理。
l SizeOfImage:PE文件在內存空間整個映像的大小。包含所有的頭及按SectinAlignment對齊的所有的Section。
l SizeOfHeaders:所有的頭加上Section表的大小。也就是文件大小減去文件中所有Section的大小。可以用這個值獲取PE文件中第一Section的位置。
l DataDiretory:16個IMAGE_DATA_DIRECTORY結構的數組。每一個成員都對應一個重要的數據結構,比如輸入表,輸出表等。
有兩個地方需要注意:
l 如果PE header里的最后兩個字段被賦予一個偽造的值的話,比如:
n LoaderFlags = ABDBFFFFh (其默認值為0)
n NumberOfRvaAndSizes = DFFDEEEEh (其默認值為10h)
一些調試工具或反編譯工具會認為這個PE文件是損壞的。有的會直接執行,如果是病毒的話,就會被直接感染;有的則會重啟工具。所以最好在查看調試一個PE文件前,先看一下這里的取值是否被人賦予一個偽造的很大的值。如果是的話,先修改成默認的值。
l 有人可能注意到在一些PE文件(MS的鏈接器鏈接的PE文件)的DOS Stub部分跟PE header部分之間存在一部分垃圾數據。標識為其倒數第二非0的雙字節是一個“Rich ”。這部分數據包含了一些加密數據,來標識編譯這個PE文件的組件。可用來檢舉某些病毒程序所編譯的程序來自哪台機器。
四、 數據目錄結構(Data Directory)。
DataDirectory是OptionalHeader的最后128個字節,也是IMAGE_NT_HEADERS的最后一部分數據。它由16個IMAGE_DATA_DIRECTORY結構組成的數組構成。IMAGE_DATA_DIRECTORY的結構如下:
每一個IMAGE_DATA_DIRECTORY都是對應一個PE文件重要的數據結構。他們分別如下:
VirtualAddress指的是對應數據結構的RVA地址;iSize指的是對應數據結構的大小(字節單位)。一個PE文件一般只包含其中的一部分,也就是其中一部分數據結構是有數據的;另一部分則都是0。比如,EXE文件一般都存在IMAGE_DIRECTORY_ENTRY_IMPORT(輸入表),而不存在IMAGE_DIRECTORY_ENTRY_EXPORT(輸出表)。而DLL則兩者都包含。下圖就是某一個PE文件的數據目錄:
五、 Section表。
Section表緊跟在PE header后面。由IMAGE_SECTION_HEADER數據結構組成的數組。每一個包含了對應Section在PE文件中的屬性和偏移位置。
這里不是所有的成員都是有用的。
l Name1: 塊名,這是一個8位ASCII碼名,用來定義塊名。多數塊名以一個"."開始(如.text),盡管許多PE文檔都認為這個"."實際上並不是必須的。值得注意的是,如果塊名超過8位,則最后的NULL不存在。帶有一個"$"的區塊名字會從鏈接器那里得到特殊的對待,前面帶"$"的相同名字的區塊被合並,在合並后的區塊中它們是按"$"后面的字符字母順序進行合並的。
l Misc.VirtualSize : 指出實際的、被使用的區塊大小。如果VirtualSize大於SizeOfRawData,那么SizeOfRawData來自於可執行文件初始化數據的大小,與VirtualSize相差的字節用0填充。這個字段在OBJ文件中設為0。
l VirtualAddress : 該塊裝載到內存中的RVA。這個地址是按照內存頁對齊的,它的數值總是SectionAlignment的整數倍。在MS工具中,第一塊的默認RVA為1000H.在OBJ中,該字段沒意義。如果該值為1000H, PE文件被加載到400000H,那么該Section的起始地址為401000H。
l SizeOfRawData : 該塊在磁盤文件中所占的大小。在可執行文件中,這個值必須是PE頭部指定的文件對齊大小的倍數。如果是0,則說明區塊中的數據是未初始化的。該塊在磁盤文件中所占的大小,這個數值等於VirtualSize字段的值按照FileAlignment的值對齊以后的大小。例如,FileAlignment的大小為1000H,如果VirtualSize中的塊長度為2911,則SizeOfRawData為3000H}
l PointerToRawData : 該塊在磁盤文件中的偏移。對於可執行文件,這個值必須是PE頭部指定的文件對齊大小的倍數。
l PointerToRelocations : 這部分在EXE文件中無意義。在OBJ文件中,表示本塊重定位信息的偏移量。在OBJ文件中如果不是零,則會指向一個IMAGE_RELOCATION的數據結構。
l NumberOfRelocations : 由PointerToRelocations指向的重定位的數目。
l NumberOfLinenumbers : 由NumberOfRelocations指向的行號的數目,只在COFF樣式的行號被指定時使用。
l Characteristics : 塊屬性,該字段是一組指出塊屬性(如代碼/數據/可讀/可寫等)的標志。多個標志值通過OR操作形成Characteristics的值。這些標志很多都可以通過鏈接器/SECTION選項設置。
位 |
數據位在Windows.inc中的預定義 |
為1時的含義 |
5 |
IMAGE_SCN_CNT_CODE (00000020H) |
節中包含代碼 |
6 |
IMAGE_SCN_CNT_INITIALIZED_DATA (00000040H) |
節中包含已初始化數據 |
7 |
IMAGE_SCN_CNT_UNINITIALIZED_DATA (00000080H) |
節中包含未初始化數據 |
25 |
IMAGE_SCN_MEM_DISCARDABLE (02000000H) |
節中的數據在進程開始后將被丟棄 |
26 |
IMAGE_SCN_MEM_NOT_CACHED (04000000H) |
節中的數據不會經過緩存 |
27 |
IMAGE_SCN_MEM_NOT_PAGED (08000000H) |
節中的數據不會被交換到磁盤 |
28 |
IMAGE_SCN_MEM_SHARED (10000000H) |
節中的數據將被不同的進程所共享 |
29 |
IMAGE_SCN_MEM_EXECUTE (20000000H) |
映射到內存后的頁面包含可執行屬性 |
30 |
IMAGE_SCN_MEM_READ (40000000H) |
映射到內存后的頁面包含可讀屬性 |
31 |
IMAGE_SCN_MEM_WRITE (80000000H) |
映射到內存后的頁面包含可寫屬性 |
六、 PE文件各個Section。
PE文件的Sections部分包含了文件的內容。包括代碼,數據,資源和其他可執行信息。每一個Section由一個頭部和一個數據部分組成。所有的頭部都存放在緊跟PE header后的Section表內。
1. 執行代碼。
在NT Windows系統內,所有的PE文件的代碼段都存放在一個Section內,通常命名為.text(MS)或CODE(Borland)。這一段包含了早先提起的AddressOfEntryPoint多指地址的指令及輸入表中的jump thunk table。
2. 數據。
l .bss段存放未初始化的數據,包括函數內或源模塊內聲明的靜態變量。
l .rdata段存放只讀數據,比如常字符串,常量,調試指示信息。
l .data 段存放其他所有的數據(除了自動化變量,其存放在棧中)。比如程序的全局變量。
3. 資源。
.rsrc段包含了一個模塊的資源信息。以資源樹的結構存放數據。需要用工具來查看。
4. 輸出數據。
.edata段包含了PE文件的輸出目錄(Export Directory)。
5. 輸入數據。
.idata包含了PE文件的輸入目錄和輸入地址表。
6. 調試信息。
調試信息存放在.debug段。PE文件也支持單獨的調試文件。Debug段包含調試信息,但是調試目錄卻存放在.rdata內。
7. 線程局部存儲。(TLS)
Windows支持每一個進程包含多個線程。每一個線程有其私有的存儲空間(TLS)去存放線程自身的數據。鏈接器都會為進程創建一個.tls段來存放TLS模板。當進程創建一個線程時,系統就會按照這個模板創建一個線程私有的局部存儲空間。
8. 基重定位。
當加載器加載PE文件到內存的時候,有時候不一定是其預期的基地址。那么就需要調整內部指令的相對地址。所有需要調整的地址都存放在.reloc段內。
七、輸出Section.
這個Section跟DLL關系比較密切。DLL一般定義兩種函數,內部使用的,和輸出到外部給其他調用程序使用的。輸出到外部的函數就存儲在這個Section內。
DLL輸出函數分兩種方式,通過名稱和通過序號輸出。當其他程序需要調用DLL的時候,調用GetProcAddress,通過設置需要調用的函數名稱或函數序號可以調用DLL內部輸出的函數。
那么GetProcAddress是怎么獲取DLL中真正的輸出函數地址呢?以下是詳細的解說。
PE頭的數據目錄(DATA DIRECTORY)數組的第一個成員對應的(通過其中的RVA地址可獲得)數據結構是IMAGE_EXPORT_DIRECTORY(這里稱為輸出目錄)。
成員 |
大小 |
描述 |
Characteristics |
DWORD |
未定義,總是0 |
TimeDateStamp |
DWORD |
輸出表的創建時間。與IMAGE_NT_HEADER.FileHeader.TimeDateStamp有相同的定義 |
MajorVersion |
WORD |
輸出表的主版本號。未使用,為0 |
MinorVersion |
DWORD |
輸出表的次版本號。未使用,為0 |
nName |
DWORD |
指向一個ASCII字符串的RVA,這個字符串是與這些輸出函數關聯的DLL的名稱(比如,Kernel32.dll)。這個值必須定義,因為如果DLL文件的名稱如果被修改,加載器將使用這里的名稱。 |
nBase |
DWORD |
這個字段包含用於這個可執行文件輸出表的起始序數值(基數)。正常情況下為1,但不是一定是。當通過序數來查詢一個輸出函數時,這個值會被從序數里減去。(比如,如果nBase = 1,被查詢的函數的序數是3,那么這個函數在序號表的索引是3 -1 = 2)。 |
NumberOfFunctions |
DWORD |
輸出地址表(EAT)的條目數。其中一些條目可能是0,意味着這個序數值沒有代碼和數據輸出。 |
NumberOfNames |
DWORD |
輸出名稱表(ENT)的條目數。這個值總是大於或等於NumberOfFunctions。小於的情況發生在符號只通過序數來輸出時。另外,當被賦值的序數里有數字間隔時也會有小於的情況。這個值也是輸出序數表的長度。 |
AddressOfFunctions |
DWORD |
輸出地址表(EAT)的RVA。輸出地址表本身是一個RVA數組,數組中的每一個非零的RVA都對應一個被輸出的符號。 |
AddressOfNames |
DWORD |
輸出名稱表(ENT)的RVA。輸出名稱表本身是一個RVA數組。數組中的每一個非零的RVA都向一個ASCII字符串。每一個字符串都對應一個通過名稱輸出的符號。這個表是排序。這允許加栽器在查詢一個被輸出的符號時可用二進制查找方式。名稱的排序是二進制的,而不是按字母。 |
AddressOfNameOrdinals |
DWORD |
輸出序數表(EOT)的RVA。這個表將ENT中的數組索引映射到相應的輸出地址條目。 |
實際上,IMAGE_EXPORT_DIRECTORY結構指向三個數組和一個ASCII字符串表。其中重要的是輸出地址表(EAT,即AddressOfFunctions指向的表), 輸出函數地址指針(RVA)構成了這個表。而ENT和EOT則是可以一起合作來獲取EAT里對應的地址數據。下圖演示了這個過程。
這個被加載的DLL的名稱是F00.DLL。總共輸出了四個函數,其RVA地址分別為0x400042、0x400156、0x401256和0x400520。一個外部調用程序需要調用其中一個名為”Bar”的函數,那么它先在輸出名稱表(ENT)里查找名稱為Bar的函數,找到后,根據其在輸出序號表(EOT)中對應的索引號,獲取其中的數值為EAT中的索引值,這里是4,然后從EAT中根據索引4獲取其真正的RVA地址0x400520。以下是幾個注意點:
l 輸出序號表(EOT)的存在就是為了是EAT跟ENT之間產生關聯。每一個ENT內的成員(函數名)有且只有一個EAT內的成員(函數地址)對應。但是一個EAT內的成員並不是只有一個ENT內的成員對應。比如,有的函數存在別名的話,就會出現多個ENT內的成員都對應一個EAT內的成員。
l 如果已經獲得一個函數的序號值,那么就可以直接到EAT內獲得其RVA地址,而不需要經過ENT和EOT進行查找。但是這樣的按序號輸出的DLL不易於維護。
l 通常情況下,EAT的個數(NumberOfFunctions)必須小於或等於ENT的個數(NumberOfNames)。只有在一個函數按序號輸出時(其在ENT和EOT表里沒有對應的數據),ENT的數量才有可能少於EAT的數量。比如,總共有70個函數輸出,但是在ENT表里只有40個,這就意味着剩余的30個函數是靠序號輸出的。那么我們如何知道哪些是直接靠序號輸出的呢?只有通過排除法來獲得。把存在在EOT表里的序號從EAT里排除出去,剩下的就是靠序號輸出的函數。
l 當通過一個序號值來獲取EAT內的函數RVA時,需要把這個序號值減去nBase的值來獲取在EAT表里真正的索引位置。而通過名稱查找則不需要這么做。
l 輸出轉向。某些時候,你從一個DLL中調用的一個函數可能位於另一個DLL中。這就叫輸出轉向。比如,Kernel32.dll中的HeapAlloc就是轉到調用NTDLL.dll中的RtlAllocHeap。這種轉向是在鏈接的時候,在.DEF文件中定義一個特殊的指令來實現的。那么當一個函數被轉向后,在其所在EAT表里對應的數據便不是其地址,而是一個指向表明被轉向的DLL和函數的ASCII字符串的地址指針。
上圖就是Kernel32.dll的輸出函數表,其中HeapAlloc的RVA值0x00009048就是一個指向“NTDLL.RtlAllocHeap”的指針。
八 、 輸入Section.
輸入Section通常位於.idata段內。它包含了所有程序需要用到的來自其他DLL的函數的信息。Windows加載器負責加載所有程序用到的DLL到進程空間。然后為進程找到所有其需要用到的函數的地址。下面描述這個過程:
成員 | 大小 | 描述 |
OriginalFirstThunk | DWORD | 指向輸入名稱表(INT)的RVA。INT是由IMAGE_THUNK_DATA數據結構構成的數組。數組中的每一個成員定義了一個輸入函數的信息,數組最后以一個內容為0的IMAGE_THUNK_DATA結束。 |
TimeDateStamp | DWORD | 當執行文件不與被輸入的DLL進行綁定時,這個字段為0。當以舊的方式綁定時,這個字段包括時間/日期。當以新的樣式綁定時,這個字段為-1。 |
ForwarderChain | DWORD | 這是第一個被轉向的API的索引。老樣式綁定的定義。 |
Name | DWORD | 指向被輸入DLL的ASCII字符串的RVA。 |
FirstThunk | DWORD | 指向輸入地址表(IAT)的RVA。IAT也是一個IMAGE_THUNK_DATA數據結構的數組。 |
成員 | 大小 | 描述 |
TimeDateStamp | DWORD | 必須與被輸入的DLL的PE頭內的TimeDateStamp一樣,如果不一致,那么加載器就會認為綁定的對象有誤,需要重新修補輸入表。 |
OffsetModuleName | WORD | 第一個IMAGE_BOUND_IMPORT_DESCRIPTOR結構到被輸入DLL名稱的偏移(非RVA)。 |
NumberOfModuleForwarderRefs | WORD | 包含緊跟在這個結構后面IMAGE_BOUND_FORWARDER_REF的數目。 |
ImgDelayDescr = packed record
grAttrs: DWORD;
szName: DWORD;
phmod: PDWORD;
pIAT: TImageThunkData32;
pINT: TImageThunkData32;
pBoundIAT: TImageThunkData32;
pUnloadIAT: TImageThunkData32;
dwTimeStamp: DWORD;
end;
成員 | 描述 |
grAttrs | 設為1的時候,下面的各個成員都是RVA,否則是VA(虛擬地址)。 |
szName | 指向一個DLL名稱的RVA。 |
phmod | 指向一個HMODULE的RVA。 |
pIAT | 指向DLL的IAT的RVA。 |
pINT | 指向DLL的INT的RVA。 |
pBoundIAT | 可選的綁定IAT的RVA。 |
pUnloadIAT | 指向DLL的IAT的未綁定拷貝 |
dwTimeStamp | 延遲裝載的輸入DLL的時間/日期。通常是0。 |
九、 Windows加載器
-
把整個IID數組移到一個有足夠空間來增加一個新的IID的地方。這個地方可以是.idata段的末尾或是新增一個Section來存放。
-
修改數據目錄數組對應輸入表的數據結構IMAGE_DATA_DIRECTORY的RVA和iSize。
-
如果必要,將存放新IID數組的Section大小按照Section Alignment向上取整(比如,原來大小是1500h, 而section Alignment為1000h,則調整為2000h)以便於整個段可以被映射到內存。
-
運行移動過IID數組的執行文件,如果正常的話,則進行第二步驟。如果不工作的話,需要檢查新增的IID是否已經被映射到內存及IID數組新的偏移位置是否正確。
-
在.idata節內增加兩個以null結尾的字符串,一個用來存放新增的DLL的名字。 一個用來存放需要導入的API的名稱。這個字符串前需要增加一個為null的WORD字段來構成一個 Image_Import_By_Name數據結構。
-
計算這個新增的DLL名稱字符串的RVA.
-
把這個RVA賦予新增的IID的Name1字段。
-
再找到一個DWORD的空間,來存放Image_Import_by_name的RVA。這個RVA就是新增DLL的IAT表。
-
計算上面DWORD空間的RVA,將其賦予新增IID的FirstThunk字段。
-
運行修改完的程序。
http://blog.csdn.net/suiyunonghen/article/details/3860171