1. 程式人生 > >PE檔案格式和ELF檔案格式(上)----PE檔案

PE檔案格式和ELF檔案格式(上)----PE檔案

Windows NT 3.1引入了一種名為PE檔案格式的新可執行檔案格式。PE檔案格式的規範包含在了MSDN的CD中(Specs and Strategy, Specifications, Windows NT File Format Specifications),但是它非常之晦澀。 
   然而這一的文件並未提供足夠的資訊,所以開發者們無法很好地弄懂PE格式。本文旨在解決這一問題,它會對整個的PE檔案格式作一個十分徹底的解釋,另外,本文中還帶有對所有必需結構的描述以及示範如何使用這些資訊的原始碼示例。 
   為了獲得PE檔案中所包含的重要資訊,我編寫了一個名為PEFILE.DLL的動態連結庫,本文中所有出現的原始碼示例亦均摘自於此。這個DLL和它的原始碼都作為PEFile示例程式的一部分包含在了CD中(譯註:示例程式請在MSDN中尋找,本站恕不提供),你可以在你自己的應用程式中使用這個DLL;同樣,你亦可以依你所願地使用並構建它的原始碼。在本文末尾,你會找到PEFILE.DLL的函式匯出列表和一個如何使用它們的說明。我覺得你會發現這些函式會讓你從容應付PE檔案格式的。 

介紹

 

   Windows作業系統家族最近增加的Windows NT為開發環境和應用程式本身帶來了很大的改變,這之中一個最為重大的當屬PE檔案格式了。新的PE檔案格式主要來自於UNIX作業系統所通用的COFF規範,同時為了保證與舊版本MS-DOS及Windows作業系統的相容,PE檔案格式也保留了MS-DOS中那熟悉的MZ頭部。 
   在本文之中,PE檔案格式是以自頂而下的順序解釋的。在你從頭開始研究檔案內容的過程之中,本文會詳細討論PE檔案的每一個組成部分。 
   許多單獨的檔案成分定義都來自於Microsoft Win32 SDK開發包中的WINNT.H檔案,在這個檔案中你會發現用來描述檔案頭部和資料目錄等各種成分的結構型別定義。但是,在WINNT.H中缺少對PE檔案結構足夠的定義,在這種情況下,我定義了自己的結構來存取檔案資料。你會在PEFILE.DLL工程的PEFILE.H中找到這些結構的定義,整套的PEFILE.H開發檔案包含在PEFile示例程式之中。 
   本文配套的示例程式除了PEFILE.DLL示例程式碼之外,還有一個單獨的Win32示例應用程式,名為EXEVIEW.EXE。建立這一示例目的有二:首先,我需要測試PEFILE.DLL的函式,並且某些情況要求我同時檢視多個檔案;其次,很多解決PE檔案格式的工作和直接觀看資料有關。例如,要弄懂匯入地址名稱表是如何構成的,我就得同時檢視.idata段頭部、匯入映像資料目錄、可選頭部以及當前的.idata段實體,而EXEVIEW.EXE就是檢視這些資訊的最佳示例。 
   閒話少敘,讓我們開始吧。 

PE檔案結構 


   PE檔案格式被組織為一個線性的資料流,它由一個MS-DOS頭部開始,接著是一個是模式的程式殘餘以及一個PE檔案標誌,這之後緊接著PE檔案頭和可選頭部。這些之後是所有的段頭部,段頭部之後跟隨著所有的段實體。檔案的結束處是一些其它的區域,其中是一些混雜的資訊,包括重分配資訊、符號表資訊、行號資訊以及字串表資料。我將所有這些成分列於圖1。

圖1.PE檔案映像結構 
   從MS-DOS檔案頭結構開始,我將按照PE檔案格式各成分的出現順序依次對其進行討論,並且討論的大部分是以示例程式碼為基礎來示範如何獲得檔案的資訊的。所有的原始碼均摘自PEFILE.DLL模組的PEFILE.C檔案。這些示例都利用了Windows NT最酷的特色之一——記憶體對映檔案,這一特色允許使用者使用一個簡單的指標來存取檔案中所包含的資料,因此所有的示例都使用了記憶體對映檔案來存取PE檔案中的資料。 
   注意:請查閱本文末尾關於如何使用PEFILE.DLL的那一段。 

MS-DOS頭部/真實模式頭部
 

   如上所述,PE檔案格式的第一個組成部分是MS-DOS頭部。在PE檔案格式中,它並非一個新概念,因為它與MS-DOS 2.0以來就已有的MS-DOS頭部是完全一樣的。保留這個相同結構的最主要原因是,當你嘗試在Windows 3.1以下或MS-DOS 2.0以上的系統下裝載一個檔案的時候,作業系統能夠讀取這個檔案並明白它是和當前系統不相相容的。換句話說,當你在MS-DOS 6.0下執行一個Windows NT可執行檔案時,你會得到這樣一條訊息:“This program cannot be run in DOS mode.”如果MS-DOS頭部不是作為PE檔案格式的第一部分的話,作業系統裝載檔案的時候就會失敗,並提供一些完全沒用的資訊,例如:“The name specified is not recognized as an internal or external command, operable program or batch file.” 
   MS-DOS頭部佔據了PE檔案的頭64個位元組,描述它內容的結構如下:

//WINNT.H

typedef struct _IMAGE_DOS_HEADER { // DOS的.EXE頭部
  USHORT e_magic; // 魔術數字
  USHORT e_cblp; // 檔案最後頁的位元組數
  USHORT e_cp; // 檔案頁數
  USHORT e_crlc; // 重定義元素個數
  USHORT e_cparhdr; // 頭部尺寸,以段落為單位
  USHORT e_minalloc; // 所需的最小附加段
  USHORT e_maxalloc; // 所需的最大附加段
  USHORT e_ss; // 初始的SS值(相對偏移量)
  USHORT e_sp; // 初始的SP值
  USHORT e_csum; // 校驗和
  USHORT e_ip; // 初始的IP值
  USHORT e_cs; // 初始的CS值(相對偏移量)
  USHORT e_lfarlc; // 重分配表文件地址
  USHORT e_ovno; // 覆蓋號
  USHORT e_res[4]; // 保留字
  USHORT e_oemid; // OEM識別符號(相對e_oeminfo)
  USHORT e_oeminfo; // OEM資訊
  USHORT e_res2[10]; // 保留字
  LONG e_lfanew; // 新exe頭部的檔案地址
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;
第一個域e_magic,被稱為魔術數字,它被用於表示一個MS-DOS相容的檔案型別。所有MS-DOS相容的可執行檔案都將這個值設為0x5A4D,表示ASCII字元MZ。MS-DOS頭部之所以有的時候被稱為MZ頭部,就是這個緣故。還有許多其它的域對於MS-DOS作業系統來說都有用,但是對於Windows NT來說,這個結構中只有一個有用的域——最後一個域e_lfnew,一個4位元組的檔案偏移量,PE檔案頭部就是由它定位的。對於Windows NT的PE檔案來說,PE檔案頭部是緊跟在MS-DOS頭部和真實模式程式殘餘之後的。 

真實模式殘餘程式

   真實模式殘餘程式是一個在裝載時能夠被MS-DOS執行的實際程式。對於一個MS-DOS的可執行映像檔案,應用程式就是從這裡執行的。對於Windows、OS/2、Windows NT這些作業系統來說,MS-DOS殘餘程式就代替了主程式的位置被放在這裡。這種殘餘程式通常什麼也不做,而只是輸出一行文字,例如:“This program requires Microsoft Windows v3.1 or greater.”當然,使用者可以在此放入任何的殘餘程式,這就意味著你可能經常看到像這樣的東西:“You can''t run a Windows NT application on OS/2, it''s simply not possible.” 
   當為Windows 3.1構建一個應用程式的時候,連結器將向你的可執行檔案中連結一個名為WINSTUB.EXE的預設殘餘程式。你可以用一個基於MS-DOS的有效程式取代WINSTUB,並且用STUB模組定義語句指示連結器,這樣就能夠取代連結器的預設行為。為Windows NT開發的應用程式可以通過使用-STUB:連結器選項來實現。 

PE檔案頭部與標誌

   PE檔案頭部是由MS-DOS頭部的e_lfanew域定位的,這個域只是給出了檔案的偏移量,所以要確定PE頭部的實際記憶體對映地址,就需要新增檔案的記憶體對映基地址。例如,以下的巨集是包含在PEFILE.H原始檔之中的: 
//PEFILE.H

#define NTSIGNATURE(a) ((LPVOID)((BYTE *)a + \
                       ((PIMAGE_DOS_HEADER)a)->e_lfanew))
在處理PE檔案資訊的時候,我發現檔案之中有些位置需要經常查閱。既然這些位置僅僅是對檔案的偏移量,那麼用巨集來實現這些定位就比較容易,因為它們較之函式有更好的表現。 
   請注意這個巨集所獲得的是PE檔案標誌,而並非PE檔案頭部的偏移量。那是由於自Windows與OS/2的可執行檔案開始,.EXE檔案都被賦予了目標作業系統的標誌。對於Windows NT的PE檔案格式而言,這一標誌在PE檔案頭部結構之前。在Windows和OS/2的某些版本中,這一標誌是檔案頭的第一個字。同樣,對於PE檔案格式,Windows NT使用了一個DWORD值。 
   以上的巨集返回了檔案標誌的偏移量,而不管它是哪種型別的可執行檔案。所以,檔案頭部是在DWORD標誌之後,還是在WORD標誌處,是由這個標誌是否Windows NT檔案標誌所決定的。要解決這個問題,我編寫了ImageFileType函式(如下),它返回了映像檔案的型別: 
//PEFILE.C

DWORD WINAPI ImageFileType (LPVOID lpFile)
{
  /* 首先出現的是DOS檔案標誌 */
  if (*(USHORT *)lpFile == IMAGE_DOS_SIGNATURE)
  {
    /* 由DOS頭部決定PE檔案頭部的位置 */
    if (LOWORD (*(DWORD *)NTSIGNATURE (lpFile)) ==
        IMAGE_OS2_SIGNATURE ||
        LOWORD (*(DWORD *)NTSIGNATURE (lpFile)) ==
        IMAGE_OS2_SIGNATURE_LE)
      return (DWORD)LOWORD(*(DWORD *)NTSIGNATURE (lpFile));
    else if (*(DWORD *)NTSIGNATURE (lpFile) ==
      IMAGE_NT_SIGNATURE)
    return IMAGE_NT_SIGNATURE;
    else
      return IMAGE_DOS_SIGNATURE;
  }
  else
    /* 不明檔案種類 */
    return 0;
}
以上列出的程式碼立即告訴了你NTSIGNATURE巨集有多麼有用。對於比較不同檔案型別並且返回一個適當的檔案種類來說,這個巨集就會使這兩件事變得非常簡單。WINNT.H之中定義的四種不同檔案型別有:
//WINNT.H

#define IMAGE_DOS_SIGNATURE 0x5A4D // MZ
#define IMAGE_OS2_SIGNATURE 0x454E // NE
#define IMAGE_OS2_SIGNATURE_LE 0x454C // LE
#define IMAGE_NT_SIGNATURE 0x00004550 // PE00
  
首先,Windows的可執行檔案型別沒有出現在這一列表中,這一點看起來很奇怪。但是,在稍微研究一下之後,就能得到原因了:除了作業系統版本規範的不同之外,Windows的可執行檔案和OS/2的可執行檔案實在沒有什麼區別。這兩個作業系統擁有相同的可執行檔案結構。 
   現在把我們的注意力轉向Windows NT PE檔案格式,我們會發現只要我們得到了檔案標誌的位置,PE檔案之後就會有4個位元組相跟隨。下一個巨集標識了PE檔案的頭部:
//PEFILE.C

#define PEFHDROFFSET(a) ((LPVOID)((BYTE *)a + \
                        ((PIMAGE_DOS_HEADER)a)->e_lfanew + \
                        SIZE_OF_NT_SIGNATURE))
  
這個巨集與上一個巨集的唯一不同是這個巨集加入了一個常量SIZE_OF_NT_SIGNATURE。不幸的是,這個常量並未定義在WINNT.H之中,於是我將它定義在了PEFILE.H中,它是一個DWORD的大小。 
   既然我們知道了PE檔案頭的位置,那麼就可以檢查頭部的資料了。我們只需要把這個位置賦值給一個結構,如下:
PIMAGE_FILE_HEADER pfh;
pfh = (PIMAGE_FILE_HEADER)PEFHDROFFSET(lpFile);
在這個例子中,lpFile表示一個指向可執行檔案記憶體映像基地址的指標,這就顯出了記憶體對映檔案的好處:不需要執行檔案的I/O,只需使用指標pfh就能存取檔案中的資訊。PE檔案頭結構被定義為:
//WINNT.H

typedef struct _IMAGE_FILE_HEADER {
  USHORT Machine;
  USHORT NumberOfSections;
  ULONG TimeDateStamp;
  ULONG PointerToSymbolTable;
  ULONG NumberOfSymbols;
  USHORT SizeOfOptionalHeader;
  USHORT Characteristics;
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;

#define IMAGE_SIZEOF_FILE_HEADER 20
請注意這個檔案頭部的大小已經定義在這個包含檔案之中了,這樣一來,想要得到這個結構的大小就很方便了。但是我覺得對結構本身使用sizeof運算子(譯註:原文為“function”)更簡單一些,因為這樣的話我就不必記住這個常量的名字IMAGE_SIZEOF_FILE_HEADER,而只需要記住結構IMAGE_FILE_HEADER的名字就可以了。另一方面,記住所有結構的名字已經夠有挑戰性的了,尤其在是這些結構只有WINNT.H中才有的情況下。 
   PE檔案中的資訊基本上是一些高階資訊,這些資訊是被作業系統或者應用程式用來決定如何處理這個檔案的。第一個域是用來表示這個可執行檔案被構建的目標機器種類,例如DEC(R) Alpha、MIPS R4000、Intel(R) x86或一些其它處理器。系統使用這一資訊來在讀取這個檔案的其它資料之前決定如何處理它。 
   Characteristics域表示了檔案的一些特徵。比如對於一個可執行檔案而言,分離除錯檔案是如何操作的。偵錯程式通常使用的方法是將除錯資訊從PE檔案中分離,並儲存到一個除錯檔案(.DBG)中。要這麼做的話,偵錯程式需要了解是否要在一個單獨的檔案中尋找除錯資訊,以及這個檔案是否已經將除錯資訊分離了。我們可以通過深入可執行檔案並尋找除錯資訊的方法來完成這一工作。要使偵錯程式不在檔案中查詢的話,就需要用到IMAGE_FILE_DEBUG_STRIPPED這個特徵,它表示檔案的除錯資訊是否已經被分離了。這樣一來,偵錯程式可以通過快速檢視PE檔案的頭部的方法來決定檔案中是否存在著除錯資訊。 
   WINNT.H定義了若干其它表示檔案頭資訊的標記,就和以上的例子差不多。我把研究這些標記的事情留給讀者作為練習,由你們來看看它們是不是很有趣,這些標記位於WINNT.H中的IMAGE_FILE_HEADER結構之後。 
   PE檔案頭結構中另一個有用的入口是NumberOfSections域,它表示如果你要方便地提取檔案資訊的話,就需要了解多少個段——更明確一點來說,有多少個段頭部和多少個段實體。每一個段頭部和段實體都在檔案中連續地排列著,所以要決定段頭部和段實體在哪裡結束的話,段的數目是必需的。以下的函式從PE檔案頭中提取了段的數目:
PEFILE.C
int WINAPI NumOfSections(LPVOID lpFile)
{
  /* 檔案頭部中所表示出的段數目 */
  return (int)((PIMAGE_FILE_HEADER)
    PEFHDROFFSET (lpFile))->NumberOfSections);
}
如你所見,PEFHDROFFSET以及其它巨集用起來非常方便。

PE可選頭部

   PE可執行檔案中接下來的224個位元組組成了PE可選頭部。雖然它的名字是“可選頭部”,但是請確信:這個頭部並非“可選”,而是“必需”的。OPTHDROFFSET巨集可以獲得指向可選頭部的指標:
//PEFILE.H

#define OPTHDROFFSET(a) ((LPVOID)((BYTE *)a + \
                        ((PIMAGE_DOS_HEADER)a)->e_lfanew + \
                        SIZE_OF_NT_SIGNATURE + \
                        sizeof(IMAGE_FILE_HEADER)))
  
可選頭部包含了很多關於可執行映像的重要資訊,例如初始的堆疊大小、程式入口點的位置、首選基地址、作業系統版本、段對齊的資訊等等。IMAGE_OPTIONAL_HEADER結構如下:
//WINNT.H

typedef struct _IMAGE_OPTIONAL_HEADER {
  //
  // 標準域
  //
  USHORT Magic;
  UCHAR MajorLinkerVersion;
  UCHAR MinorLinkerVersion;
  ULONG SizeOfCode;
  ULONG SizeOfInitializedData;
  ULONG SizeOfUninitializedData;
  ULONG AddressOfEntryPoint;
  ULONG BaseOfCode;
  ULONG BaseOfData;
  //
  // NT附加域
  //
  ULONG ImageBase;
  ULONG SectionAlignment;
  ULONG FileAlignment;
  USHORT MajorOperatingSystemVersion;
  USHORT MinorOperatingSystemVersion;
  USHORT MajorImageVersion;
  USHORT MinorImageVersion;
  USHORT MajorSubsystemVersion;
  USHORT MinorSubsystemVersion;
  ULONG Reserved1;
  ULONG SizeOfImage;
  ULONG SizeOfHeaders;
  ULONG CheckSum;
  USHORT Subsystem;
  USHORT DllCharacteristics;
  ULONG SizeOfStackReserve;
  ULONG SizeOfStackCommit;
  ULONG SizeOfHeapReserve;
  ULONG SizeOfHeapCommit;
  ULONG LoaderFlags;
  ULONG NumberOfRvaAndSizes;
  IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];
} IMAGE_OPTIONAL_HEADER, *PIMAGE_OPTIONAL_HEADER;
如你所見,這個結構中所列出的域實在是冗長得過分。為了不讓你對所有這些域感到厭煩,我會僅僅討論有用的——就是說,對於探究PE檔案格式而言有用的。 

標準域

   首先,請注意這個結構被劃分為“標準域”和“NT附加域”。所謂標準域,就是和UNIX可執行檔案的COFF格式所公共的部分。雖然標準域保留了COFF中定義的名字,但是Windows NT仍然將它們用作了不同的目的——儘管換個名字更好一些。 
   ·Magic。我不知道這個域是幹什麼的,對於示例程式EXEVIEW.EXE示例程式而言,這個值是0x010B或267(譯註:0x010B為.EXE,0x0107為ROM映像,這個資訊我是從eXeScope上得來的)。 
   ·MajorLinkerVersion、MinorLinkerVersion。表示連結此映像的連結器版本。隨Window NT build 438配套的Windows NT SDK包含的連結器版本是2.39(十六進位制為2.27)。 
   ·SizeOfCode。可執行程式碼尺寸。 
   ·SizeOfInitializedData。已初始化的資料尺寸。 
   ·SizeOfUninitializedData。未初始化的資料尺寸。 
   ·AddressOfEntryPoint。在標準域中,AddressOfEntryPoint域是對PE檔案格式來說最為有趣的了。這個域表示應用程式入口點的位置。並且,對於系統黑客來說,這個位置就是匯入地址表(IAT)的末尾。以下的函式示範瞭如何從可選頭部獲得Windows NT可執行映像的入口點。
//PEFILE.C

LPVOID WINAPI GetModuleEntryPoint(LPVOID lpFile)
{
  PIMAGE_OPTIONAL_HEADER poh;
  poh = (PIMAGE_OPTIONAL_HEADER)OPTHDROFFSET(lpFile);
  if (poh != NULL)
    return (LPVOID)poh->AddressOfEntryPoint;
  else
    return NULL;
}
·BaseOfCode。已載入映像的程式碼(“.text”段)的相對偏移量。 
   ·BaseOfData。已載入映像的未初始化資料(“.bss”段)的相對偏移量。 

Windows NT附加域

   新增到Windows NT PE檔案格式中的附加域為Windows NT特定的程序行為提供了裝載器的支援,以下為這些域的概述。 
   ·ImageBase。程序映像地址空間中的首選基地址。Windows NT的Microsoft Win32 SDK連結器將這個值預設設為0x00400000,但是你可以使用-BASE:linker開關改變這個值。 
   ·SectionAlignment。從ImageBase開始,每個段都被相繼的裝入程序的地址空間中。SectionAlignment則規定了裝載時段能夠佔據的最小空間數量——就是說,段是關於SectionAlignment對齊的。 
   Windows NT虛擬記憶體管理器規定,段對齊不能少於頁尺寸(當前的x86平臺是4096位元組),並且必須是成倍的頁尺寸。4096位元組是x86連結器的預設值,但是它可以通過-ALIGN: linker開關來設定。 
   ·FileAlignment。映像檔案首先裝載的最小的資訊塊間隔。例如,連結器將一個段實體(段的原始資料)加零擴充套件為檔案中最接近的FileAlignment邊界。早先提及的2.39版連結器將映像檔案以0x200位元組的邊界對齊,這個值可以被強制改為512到65535這麼多。 
   ·MajorOperatingSystemVersion。表示Windows NT作業系統的主版本號;通常對Windows NT 1.0而言,這個值被設為1。 
   ·MinorOperatingSystemVersion。表示Windows NT作業系統的次版本號;通常對Windows NT 1.0而言,這個值被設為0。 
   ·MajorImageVersion。用來表示應用程式的主版本號;對於Microsoft Excel 4.0而言,這個值是4。 
   ·MinorImageVersion。用來表示應用程式的次版本號;對於Microsoft Excel 4.0而言,這個值是0。 
   ·MajorSubsystemVersion。表示Windows NT Win32子系統的主版本號;通常對於Windows NT 3.10而言,這個值被設為3。 
   ·MinorSubsystemVersion。表示Windows NT Win32子系統的次版本號;通常對於Windows NT 3.10而言,這個值被設為10。 
   ·Reserved1。未知目的,通常不被系統使用,並被連結器設為0。 
   ·SizeOfImage。表示載入的可執行映像的地址空間中要保留的地址空間大小,這個數字很大程度上受SectionAlignment的影響。例如,考慮一個擁有固定頁尺寸4096位元組的系統,如果你有一個11個段的可執行檔案,它的每個段都少於4096位元組,並且關於65536位元組邊界對齊,那麼SizeOfImage域將會被設為11 * 65536 = 720896(176頁)。而如果一個相同的檔案關於4096位元組對齊的話,那麼SizeOfImage域的結果將是11 * 4096 = 45056(11頁)。這只是個簡單的例子,它說明每個段需要少於一個頁面的記憶體。在現實中,連結器通過個別地計算每個段的方法來決定SizeOfImage確切的值。它首先決定每個段需要多少位元組,並且最後將頁面總數向上取整至最接近的SectionAlignment邊界,然後總數就是每個段個別需求之和了。 
   ·SizeOfHeaders。這個域表示檔案中有多少空間用來儲存所有的檔案頭部,包括MS-DOS頭部、PE檔案頭部、PE可選頭部以及PE段頭部。檔案中所有的段實體就開始於這個位置。 
   ·CheckSum。校驗和是用來在裝載時驗證可執行檔案的,它是由連結器設定並檢驗的。由於建立這些校驗和的演算法是私有資訊,所以在此不進行討論。 
   ·Subsystem。用於標識該可執行檔案目標子系統的域。每個可能的子系統取值列於WINNT.H的IMAGE_OPTIONAL_HEADER結構之後。 
   ·DllCharacteristics。用來表示一個DLL映像是否為程序和執行緒的初始化及終止包含入口點的標記。 
   ·SizeOfStackReserve、SizeOfStackCommit、SizeOfHeapReserve、SizeOfHeapCommit。這些域控制要保留的地址空間數量,並且負責棧和預設堆的申請。在預設情況下,棧和堆都擁有1個頁面的申請值以及16個頁面的保留值。這些值可以使用連結器開關-STACKSIZE:與-HEAPSIZE:來設定。 
   ·LoaderFlags。告知裝載器是否在裝載時中止和除錯,或者預設地正常執行。 
   ·NumberOfRvaAndSizes。這個域標識了接下來的DataDirectory陣列。請注意它被用來標識這個陣列,而不是陣列中的各個入口數字,這一點非常重要。 
   ·DataDirectory。資料目錄表示檔案中其它可執行資訊重要組成部分的位置。它事實上就是一個IMAGE_DATA_DIRECTORY結構的陣列,位於可選頭部結構的末尾。當前的PE檔案格式定義了16種可能的資料目錄,這之中的11種現在在使用中。 

資料目錄

WINNT.H之中所定義的資料目錄為:
//WINNT.H
 
// 目錄入口
// 匯出目錄
#define IMAGE_DIRECTORY_ENTRY_EXPORT 0
// 匯入目錄
#define IMAGE_DIRECTORY_ENTRY_IMPORT 1
// 資源目錄
#define IMAGE_DIRECTORY_ENTRY_RESOURCE 2
// 異常目錄
#define IMAGE_DIRECTORY_ENTRY_EXCEPTION 3
// 安全目錄
#define IMAGE_DIRECTORY_ENTRY_SECURITY 4
// 重定位基本表
#define IMAGE_DIRECTORY_ENTRY_BASERELOC 5
// 除錯目錄
#define IMAGE_DIRECTORY_ENTRY_DEBUG 6
// 描述字串
#define IMAGE_DIRECTORY_ENTRY_COPYRIGHT 7
// 機器值(MIPS GP)
#define IMAGE_DIRECTORY_ENTRY_GLOBALPTR 8
// TLS目錄
#define IMAGE_DIRECTORY_ENTRY_TLS 9
// 載入配置目錄
#define IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG 10
  
基本上,每個資料目錄都是一個被定義為IMAGE_DATA_DIRECTORY的結構。雖然資料目錄入口本身是相同的,但是每個特定的目錄種類卻是完全唯一的。每個資料目錄的定義在本文的以後部分被描述為“預定義段”。
//WINNT.H

typedef struct _IMAGE_DATA_DIRECTORY {
  ULONG VirtualAddress;
  ULONG Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
每個資料目錄入口指定了該目錄的尺寸和相對虛擬地址。如果你要定義一個特定的目錄的話,就需要從可選頭部中的資料目錄陣列中決定相對的地址,然後使用虛擬地址來決定該目錄位於哪個段中。一旦你決定了哪個段包含了該目錄,該段的段頭部就會被用於查詢資料目錄的精確檔案偏移量位置。 
   所以要獲得一個數據目錄的話,那麼首先你需要了解段的概念。我在下面會對其進行描述,這個討論之後還有一個有關如何定位資料目錄的示例。 

PE檔案段

   PE檔案規範由目前為止定義的那些頭部以及一個名為“段”的一般物件組成。段包含了檔案的內容,包括程式碼、資料、資源以及其它可執行資訊,每個段都有一個頭部和一個實體(原始資料)。我將在下面描述段頭部的有關資訊,但是段實體則缺少一個嚴格的檔案結構。因此,它們幾乎可以被連結器按任何的方法組織,只要它的頭部填充了足夠能夠解釋資料的資訊。 

段頭部

   PE檔案格式中,所有的段頭部位於可選頭部之後。每個段頭部為40個位元組長,並且沒有任何的填充資訊。段頭部被定義為以下的結構:
//WINNT.H

#define IMAGE_SIZEOF_SHORT_NAME 8
typedef struct _IMAGE_SECTION_HEADER {
UCHAR Name[IMAGE_SIZEOF_SHORT_NAME];
  union {
    ULONG PhysicalAddress;
    ULONG VirtualSize;
  } Misc;
  ULONG VirtualAddress;
  ULONG SizeOfRawData;
  ULONG PointerToRawData;
  ULONG PointerToRelocations;
  ULONG PointerToLinenumbers;
  USHORT NumberOfRelocations;
  USHORT NumberOfLinenumbers;
  ULONG Characteristics;
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;
  
你如何才能獲得一個特定段的段頭部資訊?既然段頭部是被連續的組織起來的,而且沒有一個特定的順序,那麼段頭部必須由名稱來定位。以下的函式示範瞭如何從一個給定了段名稱的PE映像檔案中獲得一個段頭部:
//PEFILE.C

BOOL WINAPI GetSectionHdrByName(LPVOID lpFile, IMAGE_SECTION_HEADER *sh, char *szSection)
{
  PIMAGE_SECTION_HEADER psh;
  int nSections = NumOfSections (lpFile);
  int i;
  if ((psh = (PIMAGE_SECTION_HEADER)SECHDROFFSET(lpFile))
      != NULL)
  {
    /* 由名稱查詢段 */
    for (i = 0; i < nSections; i++)
    {
      if (!strcmp(psh->Name, szSection))
      {
        /* 向頭部複製資料 */
        CopyMemory((LPVOID)sh, (LPVOID)psh,
            sizeof(IMAGE_SECTION_HEADER));
        return TRUE;
      }
      else
        psh++;
    }
  }
  return FALSE;
}
這個函式通過SECHDROFFSET巨集將第一個段頭部定位,然後它開始在所有段中迴圈,並將要尋找的段名稱和每個段的名稱相比較,直到找到了正確的那一個為止。當找到了段的時候,函式將記憶體映像檔案的資料複製到傳入函式的結構中,然後IMAGE_SECTION_HEADER結構的各域就能夠被直接存取了。 

段頭部的域

   ·Name。每個段都有一個8字元長的名稱域,並且第一個字元必須是一個句點。 
   ·PhysicalAddress或VirtualSize。第二個域是一個union域,現在已不使用了。 
   ·VirtualAddress。這個域標識了程序地址空間中要裝載這個段的虛擬地址。實際的地址由將這個域的值加上可選頭部結構中的ImageBase虛擬地址得到。切記,如果這個映像檔案是一個DLL,那麼這個DLL就不一定會裝載到ImageBase要求的位置。所以一旦這個檔案被裝載進入了一個程序,實際的ImageBase值應該通過使用GetModuleHandle來檢驗。 
   ·SizeOfRawData。這個域表示了相對FileAlignment的段實體尺寸。檔案中實際的段實體尺寸將少於或等於FileAlignment的整倍數。一旦映像被裝載進入了一個程序的地址空間,段實體的尺寸將會變得少於或等於FileAlignment的整倍數。 
   ·PointerToRawData。這是一個檔案中段實體位置的偏移量。 
   ·PointerToRelocations、PointerToLinenumbers、NumberOfRelocations、NumberOfLinenumbers。這些域在PE格式中不使用。 
   ·Characteristics。定義了段的特徵。這些值可以在WINNT.H及本光碟(譯註:MSDN的光碟)的PE格式規範中找到。 

值         定義 
0x00000020 程式碼段 
0x00000040 已初始化資料段 
0x00000080 未初始化資料段 
0x04000000 該段資料不能被快取 
0x08000000 該段不能被分頁 
0x10000000 共享段 
0x20000000 可執行段 
0x40000000 可讀段 
0x80000000 可寫段 

定位資料目錄

   資料目錄存在於它們相應的資料段中。典型地來說,資料目錄是段實體中的第一個結構,但不是必需的。由於這個緣故,如果你需要定位一個指定的資料目錄的話,就需要從段頭部和可選頭部中獲得資訊。 
   為了讓這個過程簡單一點,我編寫了以下的函式來定位任何一個在WINNT.H之中定義的資料目錄。
// PEFILE.C

LPVOID WINAPI ImageDirectoryOffset(LPVOID lpFile,
    DWORD dwIMAGE_DIRECTORY)
{
  PIMAGE_OPTIONAL_HEADER poh;
  PIMAGE_SECTION_HEADER psh;
  int nSections = NumOfSections(lpFile);
  int i = 0;
  LPVOID VAImageDir;
  /* 必須為0到(NumberOfRvaAndSizes-1)之間 */
  if (dwIMAGE_DIRECTORY >= poh->NumberOfRvaAndSizes)
    return NULL;
  /* 獲得可選頭部和段頭部的偏移量 */
  poh = (PIMAGE_OPTIONAL_HEADER)OPTHDROFFSET(lpFile);
  psh = (PIMAGE_SECTION_HEADER)SECHDROFFSET(lpFile);
  /* 定位映像目錄的相對虛擬地址 */
  VAImageDir = (LPVOID)poh->DataDirectory
      [dwIMAGE_DIRECTORY].VirtualAddress;
  /* 定位包含映像目錄的段 */
  while (i++ < nSections)
  {
    if (psh->VirtualAddress <= (DWORD)VAImageDir &&
        psh->VirtualAddress + 
        psh->SizeOfRawData > (DWORD)VAImageDir)
      break;
    psh++;
  }
  if (i > nSections)
    return NULL;
  /* 返回映像匯入目錄的偏移量 */
  return (LPVOID)(((int)lpFile + 
    (int)VAImageDir. psh->VirtualAddress) +
    (int)psh->PointerToRawData);
}
  

該函式首先確認被請求的資料目錄入口數字,然後它分別獲取指向可選頭部和第一個段頭部的兩個指標。它從可選頭部決定資料目錄的虛擬地址,然後它使用這個值來決定資料目錄定位在哪個段實體之中。如果適當的段實體已經被標識了,那麼資料目錄特定的位置就可以通過將它的相對虛擬地址轉換為檔案中地址的方法來找到。 

預定義段 

   一個Windows NT的應用程式典型地擁有9個預定義段,它們是.text、.bss、.rdata、.data、.rsrc、.edata、.idata、.pdata和.debug。一些應用程式不需要所有的這些段,同樣還有一些應用程式為了自己特殊的需要而定義了更多的段。這種做法與MS-DOS和Windows 3.1中的程式碼段和資料段相似。事實上,應用程式定義一個獨特的段的方法是使用標準編譯器來指示對程式碼段和資料段的命名,或者使用名稱段編譯器選項-NT——就和Windows 3.1中應用程式定義獨特的程式碼段和資料段一樣。 
   以下是一個關於Windows NT PE檔案之中一些有趣的公共段的討論。 

可執行程式碼段,.text 

   Windows 3.1和Windows NT之間的一個區別就是Windows NT預設的做法是將所有的程式碼段(正如它們在Windows 3.1中所提到的那樣)組成了一個單獨的段,名為“.text”。既然Windows NT使用了基於頁面的虛擬記憶體管理系統,那麼將分開的程式碼放入不同的段之中的做法就不太明智了。因此,擁有一個大的程式碼段對於作業系統和應用程式開發者來說,都是十分方便的。 
   .text段也包含了早先提到過的入口點。IAT亦存在於.text段之中的模組入口點之前。(IAT在.text段之中的存在非常有意義,因為這個表事實上是一系列的跳轉指令,並且它們的跳轉目標位置是已固定的地址。)當Windows NT的可執行映像裝載入程序的地址空間時,IAT就和每一個匯入函式的實體地址一同確定了。要在.text段之中查詢IAT,裝載器只用將模組的入口點定位,而IAT恰恰出現於入口點之前。既然每個入口擁有相同的尺寸,那麼向後退查詢這個表的起始位置就很容易了。 

資料段,.bss、.rdata、.data 

   .bss段表示應用程式的未初始化資料,包括所有函式或源模組中宣告為static的變數。 
   .rdata段表示只讀的資料,比如字串文字量、常量和除錯目錄資訊。 
   所有其它變數(除了出現在棧上的自動變數)儲存在.data段之中。基本上,這些是應用程式或模組的全域性變數。 

資源段,.rsrc 

   .rsrc段包含了模組的資源資訊。它起始於一個資源目錄結構,這個結構就像其它大多數結構一樣,但是它的資料被更進一步地組織在了一棵資源樹之中。以下的IMAGE_RESOURCE_DIRECTORY結構形成了這棵樹的根和各個結點。

//WINNT.H

typedef struct _IMAGE_RESOURCE_DIRECTORY {
  ULONG Characteristics;
  ULONG TimeDateStamp;
  USHORT MajorVersion;
  USHORT MinorVersion;
  USHORT NumberOfNamedEntries;
  USHORT NumberOfIdEntries;
} IMAGE_RESOURCE_DIRECTORY, *PIMAGE_RESOURCE_DIRECTORY;
  

請看這個目錄結構,你將會發現其中竟然沒有指向下一個結點的指標。但是,在這個結構中有兩個域NumberOfNamedEntries和NumberOfIdEntries代替了指標,它們被用來表示這個目錄附有多少入口。附帶說一句,我的意思是目錄入口就在段資料之中的目錄後邊。有名稱的入口按字母升序出現,再往後是按數值升序排列的ID入口。 
   一個目錄入口由兩個域組成,正如下面IMAGE_RESOURCE_DIRECTORY_ENTRY結構所描述的那樣:

// WINNT.H

typedef struct _IMAGE_RESOURCE_DIRECTORY_ENTRY {
  ULONG Name;
  ULONG OffsetToData;
} IMAGE_RESOURCE_DIRECTORY_ENTRY, *PIMAGE_RESOURCE_DIRECTORY_ENTRY;

根據樹的層級不同,這兩個域也就有著不同的用途。Name域被用於標識一個資源種類,或者一種資源名稱,或者一個資源的語言ID。OffsetToData與常常被用來在樹之中指向兄弟結點——即一個目錄結點或一個葉子結點。 
   葉子結點是資源樹之中最底層的結點,它們定義了當前資源資料的尺寸和位置。IMAGE_RESOURCE_DATA_ENTRY結構被用於描述每個葉子結點:

// WINNT.H

typedef struct _IMAGE_RESOURCE_DATA_ENTRY {
  ULONG OffsetToData;
  ULONG Size;
  ULONG CodePage;
  ULONG Reserved;
} IMAGE_RESOURCE_DATA_ENTRY, *PIMAGE_RESOURCE_DATA_ENTRY;

OffsetToData和Size這兩個域表示了當前資源資料的位置和尺寸。既然這一資訊主要是在應用程式裝載以後由函式使用的,那麼將OffsetToData作為一個相對虛擬的地址會更有意義一些。——幸甚,恰好是這樣沒錯。非常有趣的是,所有其它的偏移量,比如從目錄入口到其它目錄的指標,都是相對於根結點位置的偏移量。 
   要更清楚地瞭解這些內容,請參考圖2。 

圖2.一個簡單的資源樹結構 
   圖2描述了一個非常簡單的資源樹,它包含了僅僅兩個資源物件:一個選單和一個字串表。更深一層地來說,它們各自都有一個子項。然而,你仍然可以看到資源樹有多麼複雜——即使它像這個一樣只有一點點資源。 
   在樹的根部,第一個目錄有一個檔案中包含的所有資源種類的入口,而不管資源種類有多少。在圖2中,有兩個由樹根標識的入口,一個是選單的,另一個是字串表的。如果檔案中擁有一個或多個對話方塊資源,那麼根結點會再擁有一個入口,因此,就有了對話方塊資源的另一個分支。 
   WINUSER.H中標識了基本的資源種類,我將它們列到了下面:

//WINUSER.H

/*
* 預定義的資源種類
*/
#define RT_CURSOR MAKEINTRESOURCE(1)
#define RT_BITMAP MAKEINTRESOURCE(2)
#define RT_ICON MAKEINTRESOURCE(3)
#define RT_MENU MAKEINTRESOURCE(4)
#define RT_DIALOG MAKEINTRESOURCE(5)
#define RT_STRING MAKEINTRESOURCE(6)
#define RT_FONTDIR MAKEINTRESOURCE(7)
#define RT_FONT MAKEINTRESOURCE(8)
#define RT_ACCELERATOR MAKEINTRESOURCE(9)
#define RT_RCDATA MAKEINTRESOURCE(10)
#define RT_MESSAGETABLE MAKEINTRESOURCE(11)
  

在樹的第一層級,以上列出的MAKEINTRESOURCE值被放置在每個種類入口的Name處,它標識了不同的資源種類。 
   每個根目錄的入口都指向了樹中第二層級的一個兄弟結點,這些結點也是目錄,並且每個都擁有它們自己的入口。在這一層級,目錄被用來以給定的種類標識每一個資源種類。如果你的應用程式中有多個選單,那麼樹中的第二層級會為每個選單都準備一個入口。 
   你可能意識到了,資源可以由名稱或整數標識。在這一層級,它們是通過目錄結構的Name域來分辨的。如果如果Name域最重要的位被設定了,那麼其它的31個位就會被用作一個到IMAGE_RESOURCE_DIR_STRING_U結構的偏移量。

// WINNT.H

typedef struct _IMAGE_RESOURCE_DIR_STRING_U {
  USHORT Length;
  WCHAR NameString[1];
} IMAGE_RESOURCE_DIR_STRING_U, *PIMAGE_RESOURCE_DIR_STRING_U;
  

這個結構僅僅是由一個2位元組長的Length域和一個UNICODE字元Length組成的。 
   另一方面,如果Name域最重要的位被清空,那麼它的低31位就被用於表示資源的整數ID。圖2示範的就是選單資源作為一個命名的資源,以及字串表作為一個ID資源。 
   如果有兩個選單資源,一個由名稱標識,另一個由資源標識,那麼它們二者就會在選單資源目錄之後擁有兩個入口。有名稱的資源入口在第一位,之後是由整數標識的資源。目錄域NumberOfNamedEntries和NumberOfIdEntries將各自包含值1,表示當前的1個入口。 
   在第二層級的下面,資源樹就不再更深一步地擴充套件分支了。第一層級分支至表示每個資源種類的目錄中,第二層級分支至由識別符號表示的每個資源的目錄中,第三層級是被個別標識的資源與它們各自的語言ID之間一對一的對映。要表示一個資源的語言ID,目錄入口結構的Name域就被用來表示資源的主語言ID和子語言ID了。Windows NT的Win32 SDK開發包中列出了預設的值資源,例如對於0x0409這個值來說,0x09表示主語言LANG_ENGLISH,0x04則被定義為子語言的SUBLANG_ENGLISH_CAN。所有的語言ID值都定義於Windows NT Win32 SDK開發包的檔案WINNT.H中。 
   既然語言ID結點是樹中最後的目錄結點,那麼入口結構的OffsetToData域就是到一個葉子結點(即前面提到過的IMAGE_RESOURCE_DATA_ENTRY結構)的偏移量。 
   再回過頭來參考圖2,你會發現每個語言目錄入口都對應著一個數據入口。這個結點僅僅表示了資源資料的尺寸以及資源資料的相對虛擬地址。 
   在資源資料段(.rsrc)之中擁有這麼多結構有一個好處,就是你可以不存取資源本身而直接可以從這個段收集很多資訊。例如,你可以獲得有多少種資源、哪些資源(如果有的話)使用了特別的語言ID、特定的資源是否存在以及單獨種類資源的尺寸。為了示範如何利用這一資訊,以下的函式說明了如何決定一個檔案中包含的不同種類的資源:

// PEFILE.C

int WINAPI GetListOfResourceTypes(LPVOID lpFile, HANDLE hHeap, char **pszResTypes)
{
  PIMAGE_RESOURCE_DIRECTORY prdRoot;
  PIMAGE_RESOURCE_DIRECTORY_ENTRY prde;
  char *pMem;
  int nCnt, i;
  /* 獲得資源樹的根目錄 */
  if ((prdRoot = (PIMAGE_RESOURCE_DIRECTORY)ImageDirectoryOffset
      (lpFile, IMAGE_DIRECTORY_ENTRY_RESOURCE)) == NULL)
    return 0;
  /* 在堆上分配足夠的空間來包括所有型別 */
  nCnt = prdRoot->NumberOfIdEntries * (MAXRESOURCENAME + 1);
  *pszResTypes = (char *)HeapAlloc(hHeap, HEAP_ZERO_MEMORY,
      nCnt);
  if ((pMem = *pszResTypes) == NULL)
    return 0;
  /* 將指標指向第一個資源種類的入口 */
  prde = (PIMAGE_RESOURCE_DIRECTORY_ENTRY)((DWORD)prdRoot +
      sizeof (IMAGE_RESOURCE_DIRECTORY));
  /* 在所有的資源目錄入口型別中迴圈 */
  for (i = 0; i < prdRoot->NumberOfIdEntries; i++)
  {
    if (LoadString(hDll, prde->Name, pMem, MAXRESOURCENAME))
      pMem += strlen(pMem) + 1;
    prde++;
  }
  return nCnt;
}
  

這個函式將一個資源種類名稱的列表寫入了由pszResTypes標識的變數中。請注意,在這個函式的核心部分,LoadString是使用各自資源種類目錄入口的Name域來作為字串ID的。如果你檢視PEFILE.RC,你會發現我定義了一系列的資源種類的字串,並且它們的ID與它們在目錄入口中的定義完全相同。PEFILE.DLL還有有一個函式,它返回了.rsrc段中的資源物件總數。這樣一來,從這個段中提取其它的資訊,藉助這些函式或另外編寫函式就方便多了。 <