PE可选头部
PE可执行文件中接下来的224个字节组成了PE可选头部。虽然它的名字是“可选头部”,但是请确信:这个头部并非“可选”,而是“必需”的。OPTHDROFFSET宏可以获得指向可选头部的指针:
标准域
首先,请注意这个结构被划分为“标准域”和“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可执行映像的入口点。
·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之中所定义的数据目录为:
所以要获得一个数据目录的话,那么首先你需要了解段的概念。我在下面会对其进行描述,这个讨论之后还有一个有关如何定位数据目录的示例。
PE文件段
PE文件规范由目前为止定义的那些头部以及一个名为“段”的一般对象组成。段包含了文件的内容,包括代码、数据、资源以及其它可执行信息,每个段都有一个 头部和一个实体(原始数据)。我将在下面描述段头部的有关信息,但是段实体则缺少一个严格的文件结构。因此,它们几乎可以被链接器按任何的方法组织,只要 它的头部填充了足够能够解释数据的信息。
段头部
PE文件格式中,所有的段头部位于可选头部之后。每个段头部为40个字节长,并且没有任何的填充信息。段头部被定义为以下的结构:
段头部的域
·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之中定义的数据目录。
预定义段
一个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结构形成了这棵树的根和各个结点。
一个目录入口由两个域组成,正如下面IMAGE_RESOURCE_DIRECTORY_ENTRY结构所描述的那样:
叶子结点是资源树之中最底层的结点,它们定义了当前资源数据的尺寸和位置。IMAGE_RESOURCE_DATA_ENTRY结构被用于描述每个叶子结点:
要更清楚地了解这些内容,请参考图2。
图2.一个简单的资源树结构
图2描述了一个非常简单的资源树,它包含了仅仅两个资源对象:一个菜单和一个字串表。更深一层地来说,它们各自都有一个子项。然而,你仍然可以看到资源树有多么复杂——即使它像这个一样只有一点点资源。
在树的根部,第一个目录有一个文件中包含的所有资源种类的入口,而不管资源种类有多少。在图2中,有两个由树根标识的入口,一个是菜单的,另一个是字串表的。如果文件中拥有一个或多个对话框资源,那么根结点会再拥有一个入口,因此,就有了对话框资源的另一个分支。
WINUSER.H中标识了基本的资源种类,我将它们列到了下面:
每个根目录的入口都指向了树中第二层级的一个兄弟结点,这些结点也是目录,并且每个都拥有它们自己的入口。在这一层级,目录被用来以给定的种类标识每一个资源种类。如果你的应用程序中有多个菜单,那么树中的第二层级会为每个菜单都准备一个入口。
你可能意识到了,资源可以由名称或整数标识。在这一层级,它们是通过目录结构的Name域来分辨的。如果如果Name域最重要的位被设置了,那么其它的31个位就会被用作一个到IMAGE_RESOURCE_DIR_STRING_U结构的偏移量。
另一方面,如果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、特定的资源是否存在以及单独种类资源的尺寸。为了示范如何利用这一信息,以下的函数说明了如何决定一个文件中 包含的不同种类的资源:
导出数据段,.edata
.edata段包含了应用程序或DLL的导出数据。在这个段出现的时候,它会包含一个到达导出信息的导出目录。
AddressOfFunctions域是一个到导出函数入口列表的偏移量。AddressOfNames域是到一个导出函数名称列表起始处偏移量的地 址,这个列表是由null分隔的。AddressOfNameOrdinals是一个到相同导出函数顺序值(每个值2字节长)列表的偏移量。
三个AddressOf...域是当模块装载时进程地址空间中的相对虚拟地址。一旦模块被装载,那么要获得进程地质空间中的确切地址的话,就应该在相对虚 拟地址上加上模块的基地址。可是,在文件被装载前,仍然可以决定这一地址:只要从给定的域地址中减去段头部的虚拟地址(VirtualAddress), 再加上段实体的偏移量(PointerToRawData),这个结果就是映像文件中的偏移量了。以下的例子解说了这一技术:
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段中的资源对象总数。这样一来,从这个段中提取其它的信息,借助这些函数或另外编 写函数就方便多了。
导出数据段,.edata
.edata段包含了应用程序或DLL的导出数据。在这个段出现的时候,它会包含一个到达导出信息的导出目录。
// WINNT.H typedef struct _IMAGE_EXPORT_DIRECTORY { ULONG Characteristics; ULONG TimeDateStamp; USHORT MajorVersion; USHORT MinorVersion; ULONG Name; ULONG Base; ULONG NumberOfFunctions; ULONG NumberOfNames; PULONG *AddressOfFunctions; PULONG *AddressOfNames; PUSHORT *AddressOfNameOrdinals; } IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;导出目录中的Name域标识了可执行模块的名称。NumberOfFunctions域和NumberOfNames域表示模块中有多少导出的函数以及这些函数的名称。
AddressOfFunctions域是一个到导出函数入口列表的偏移量。AddressOfNames域是到一个导出函数名称列表起始处偏移量的地 址,这个列表是由null分隔的。AddressOfNameOrdinals是一个到相同导出函数顺序值(每个值2字节长)列表的偏移量。
三个AddressOf...域是当模块装载时进程地址空间中的相对虚拟地址。一旦模块被装载,那么要获得进程地质空间中的确切地址的话,就应该在相对虚 拟地址上加上模块的基地址。可是,在文件被装载前,仍然可以决定这一地址:只要从给定的域地址中减去段头部的虚拟地址(VirtualAddress), 再加上段实体的偏移量(PointerToRawData),这个结果就是映像文件中的偏移量了。以下的例子解说了这一技术:
// PEFILE.C int WINAPI GetExportFunctionNames(LPVOID lpFile, HANDLE hHeap, char **pszFunctions) { IMAGE_SECTION_HEADER sh; PIMAGE_EXPORT_DIRECTORY ped; char *pNames, *pCnt; int i, nCnt; /* 获得.edata域中的段头部和指向数据目录的指针 */ if ((ped = (PIMAGE_EXPORT_DIRECTORY)ImageDirectoryOffset (lpFile, IMAGE_DIRECTORY_ENTRY_EXPORT)) == NULL) return 0; GetSectionHdrByName (lpFile, &sh, ".edata"); /* 决定导出函数名称的偏移量 */ pNames = (char *)(*(int *)((int)ped->AddressOfNames - (int)sh.VirtualAddress + (int)sh.PointerToRawData + (int)lpFile) - (int)sh.VirtualAddress + (int)sh.PointerToRawData + (int)lpFile); /* 计算出要为所有的字符串分配多少内存 */ pCnt = pNames; for (i = 0; i < (int)ped->NumberOfNames; i++) while (*pCnt++); nCnt = (int)(pCnt.pNames); /* 在堆上为函数名称分配内存 */ *pszFunctions = HeapAlloc (hHeap, HEAP_ZERO_MEMORY, nCnt); /* 将所有字符串复制到缓冲区 */ CopyMemory((LPVOID)*pszFunctions, (LPVOID)pNames, nCnt); return nCnt; }请 注意,在这个函数之中,变量pNames是由决定偏移量地址和当前偏移量位置的方法来赋值的。偏移量的地址和偏移量本身都是相对虚拟地址,因此在使用之前 必须进行转换——函数之中体现了这一点。虽然你可以编写一个类似的函数来决定顺序值或函数入口点,但是我为什么不为你做好呢?—— GetNumberOfExportedFunctions、GetExportFunctionEntryPoints和 GetExportFunctionOrdinals已经存在于PEFILE.DLL之中了。