深入理解计算机系统——PE文件(1)

Windows NT 3.1引入了一种名为PE文件格式的新可执行文件格式。PE文件格式的规范包含在了MSDN的CD中(Specs and Strategy, Specifications, Windows NT File Format Specifications),但是它非常之晦涩。 然而这一的文档并未提供足够的信息,所以开发者们无法很好地弄懂PE格式。本文旨在解决这一问题,它会对整个的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头部 DOS MZ header
   模式的程序残余 DOS Stub
   PE文件标志+头部 PE header
   可选头部:可执行映像的重要信息,如初始的堆栈大小、程序入口点位置、首选基地址、操作系统版本、段对齐的信息等
   段头部结构数组 Section Table
   段实体 Section1
  Section2
  …
   文件的结束处:重分配信息、符号表信息、行号信息以及字串表数据等**

MS-DOS头部/实模式头部

   PE文件的第一个组成部分是MS-DOS头部。它与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文件格式的第一部分的话,操作系统装载文件的时候就会失败,并提供一些完全没用的信息.
   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头部,就是这个缘故。
最后一个域e_lfanew,一个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.”
   当为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,它是一个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;//debug等特征信息
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;

#define IMAGE_SIZEOF_FILE_HEADER 20

请注意这个文件头部的大小已经定义在这个包含文件之中了。

   PE文件中的信息基本上是一些高级信息,这些信息是被操作系统或者应用程序用来决定如何处理这个文件的。第一个域是用来表示这个可执行文件被构建的目标机器种类,例如DEC(R) Alpha、MIPS R4000、Intel(R) x86或一些其它处理器。系统使用这一信息来在读取这个文件的其它数据之前决定如何处理它。
   Characteristics域表示了文件的一些特征。比如对于一个可执行文件而言,分离调试文件是如何操作的。调试器通常使用的方法是将调试信息从PE文件中分离,并保存到一个调试文件(.DBG)中。调试器需要了解是否要在一个单独的文件中寻找调试信息,以及这个文件是否已经将调试信息分离了。要使调试器不在文件中查找的话,就需要用到IMAGE_FILE_DEBUG_STRIPPED这个特征,它表示文件的调试信息是否已经被分离了。这样一来,调试器可以通过快速查看PE文件的头部的方法来决定文件中是否存在着调试信息。
   PE文件头结构中另一个有用的入口是NumberOfSections域,它表示如果你要方便地提取文件信息的话,就需要了解多少个段。每一个段头部和段实体都在文件中连续地排列着,所以要决定段头部和段实体在哪里结束的话,段的数目是必需的。
   以下的函数从PE文件头中提取了段的数目:

PEFILE.C
int WINAPI NumOfSections(LPVOID lpFile)
{
  /* 文件头部中所表示出的段数目 */
  return (int)((PIMAGE_FILE_HEADER)
    PEFHDROFFSET (lpFile))->NumberOfSections);
}

PE可选头部

   PE可执行文件中接下来的224个字节组成了PE可选头部。虽然它的名字是“可选头部”,但是请确信:这个头部并非“可选”,而是“必需”的。OPTHDROFFSET宏可以获得指向可选头部的指针:

//PEFILE.H

#define OPTHDROFFSET(a) ((LPVOID)((BYTE *)a + \
                        ((PIMAGE_DOS_HEADER)a)->e_lfanew + \
                        SIZE_OF_NT_SIGNATURE + \
                        IMAGE_SIZESOF_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;//代码地址".text"段
  ULONG BaseOfData;//数据地址".bss"段
  //
  // 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;

标准域

  这个结构被划分为“标准域”和“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。。这个域表示应用程序入口点的位置。并且,对于系统黑客来说,这个位置就是导入地址表(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;
}

Windows NT附加域

   添加到Windows NT PE文件格式中的附加域为Windows NT特定的进程行为提供了装载器的支持,以下为这些域的概述。
   **·ImageBase。进程映像地址空间中的首选基地址。**Windows NT的Microsoft Win32 SDK链接器将这个值默认设为0x00400000,但是你可以使用-BASE:linker开关改变这个值。
   ·SectionAlignment。从ImageBase开始,每个段都被相继的装入进程的地址空间中。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页)。这只是个简单的例子,它说明每个段需要少于一个页面的内存。在现实中,链接器个别地计算每个段需要多少字节,并且最后将页面总数向上取整至最接近的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文件(1))