4.2 详解PE文件结构

在PSDK的头文件Winnt.h中,包含了PE文件结构的定义格式。PE头文件分为32位和 64位。64位的PE结构是对32位的PE结构做了扩展,这里主要讨论32位的PE文件结构。对于64位的PE文件结构大家可以自行查阅资料进行学习。

4.2.1 DOS头部详解 IMAGE_D〇S_HEADER

对于一个PE文件来说,最开始的位置就是一个DOS程序。DOS程序包含一个DOS头和一个DOS程序体。DOS头是用来装载DOS程序的,DOS程序也就是图4-1中的那个DOS存根,也就是说DOS头是用来装载DOS存根的。保留这部分内容是为了与DOS系统做兼容。一般在PE结构中的DOS存根,只会输出一个“This program cannot be run in DOS mode.”字符串。

虽然DOS头是为了装载DOS程序的,但是DOS头部中的一个字段保存着指向PE头部的位置。DOS头在Winnt.h头文件中被定义为IMAGE_DOS_HEADER,该结构体的定义如下:

4.2 详解PE文件结构 - 图1

在该结构体中,有两个字段需要注意,分别是第一个字段e_magic,和最后一个字段e_lfanew字段。

e_magic字段是一个DOS可执行文件的标识符,该字段占用两个字节,该位置保存着的字符是“MZ”。该标识符在Winnt.h头文件中有一个宏定义,定义如下所示:

4.2 详解PE文件结构 - 图2

e_lfanew字段中保存着PE文件的起始位置。

在VC下创建一个简单的“Win32 Application”程序(注意,这次创建的不是控制台程序,创建程序的方法类似,这里就不再介绍创建工程的步骤了),用来学习PE结构。

程序代码如下:

4.2 详解PE文件结构 - 图3

由于创建的工程是“Win32 Application”的程序,而不是控制台的程序,因此主函数是 WinMain(),而不是main()了。WinMain()函数有4个参数,具体参数的含义请参考MSDN,这里就不进行介绍了。

该程序的功能是弹出一个Messagebox对话框,使用“Win32 Release”方式进行编译连接,并把编译好的程序用C32Asm打开。C32Asm是一个反汇编与十六进制编辑于一体的程序,其界面如图4-2所示。

4.2 详解PE文件结构 - 图4

图4-2 C32Asm程序界面

在这里选择“十六进制模式”单选项,单击“确定”按钮,打开十六进制编辑模式,如图4-3所示。

4.2 详解PE文件结构 - 图5

图4-3 十六进制编辑状态下的C32Asm

在图中可以看到,在文件偏移为0×00000000的位置处保存着两个字节的内容0×5A4D,用ASCII表示则是“MZ”。图中明明写着是4D 5A,为什么说的是0×5A4D,大家到上面看 Winnt.h头文件中定义的那个宏,也写着是0×5A4D,这是为什么呢?这就是在前面章节中讲解的字节顺序,高位保存在高地址,地位保存在低地址。这个概念是很重要的,希望大家能够掌握。

在图中0×0000003c的位置处,就是e_lfanew字段,该字段保存着PE结构的起始位置,该字段的值为多少呢?保存的是0×C800000000吗?如果是这样就错了,CPU架构使用的是小尾方式,系统对数据的存放始终是高位存放高字节,低位存放低字节,因此该处保存的是 0×000000C8。大家查看一下0×000000C8这个位置保存的内容是50 45 00 00,与之对应的 ASCII字符为“PE\0\0”。这里就是PE结构开始的位置。

“PE\0\0”和IMAGE_DOS_HEADER之间的内容就是DOS存根,就是一个没什么太大用处的DOS程序。由于这个程序本身没什么利用的价值,因此这里就不做介绍了。选中DOS存根程序,也就是从0×40000000处一直到0×000000C7处的内容。然后单击右键选择“填充”命令,在弹出的“填充数据”的对话框中,选中“使用十六进制填充”单选钮,在其后的编辑框中输入“00”,单击“OK”按钮,该过程如图4-4、图4-5所示。

4.2 详解PE文件结构 - 图6

图4-4 填充数据

4.2 详解PE文件结构 - 图7

图4-5 填充后的数据

把DOS存根部分填充完以后,单击工具栏上的“保存”按钮对修改内容进行保存。保存时会提示是否备份,选择“是”,这样文件就保存了。找到我们的文件,然后运行,对话框依旧弹出了,说明这里的内容是无关紧要的。

注:DOS存根部分经常由于某种需要而保存其他数据,因此该操作较为常见。

4.2.2 PE头部详解IMAGE_NT_HEADERS

DOS头是为了兼容DOS系统而遗留的,在DOS头中的最后一个字段给出了 PE头的位置。PE头部是真正用来装载Win32程序的头部,PE头的定义为IMAGE_NT_HEADERS,该结构体包含PE标识符、文件头与可选头这三部分。IMAGE_NT_HEADERS是一个宏,其定义如下:

4.2 详解PE文件结构 - 图8

该头分为32位与64位,看是否定义了_WIN64。我们只讨论32位的PE文件格式,因此来看一下IMAGE_NT_HEADER32的定义,该定义如下:

4.2 详解PE文件结构 - 图9

该结构体中的Signature就是PE标识符,该标识符标识该文件是否是PE文件。该部分占4个字节,即“50 45 00 00”,该部分可以参看图4-3。这里在Winnt.h中有一个宏定义,定义如下:

4.2 详解PE文件结构 - 图10

该值非常重要,简单地判断一个文件是否是PE文件,首先要判断DOS头部的开始字节是否是“MZ”,然后通过DOS头部找到PE头,接着判断PE头部的前四个字节是否为“PE\0\0”,如果是的话,则可以说明该文件是一个有效的PE文件。

在PE头中,除了IMAGE_NT_SIGNATURE以外,还有两个重要的结构体,分别是 IMAEG_FILE_HEADER(文件头)和IMAGE_OPTIONAL_HEADER32(可选头)。这两个头在PE头中占据重要的位置,因此需要详细介绍这两个结构体。

4.2.3 IAMGE_FILE_HEADER

该结构体是IMAGE_NT_HEADERS中的第一个结构体,该结构紧接在PE标识符的后面。该结构体大小为20个字节,起始位置为0×000000CC,结束位置为0×000000DF,如图4-6所示。

4.2 详解PE文件结构 - 图11

图4-6 IMAGE_FILE_HEADER在PE文件中的位置

该结构包含了PE文件的一些基础信息,其结构体的定义如下:

4.2 详解PE文件结构 - 图12

下面介绍一下该结构各字段。

(1) Machine:该字段是WORD类型,占用两个字节。该字段表示可执行文件的目标CPU类型。该字段的取值如图4-7所示。

4.2 详解PE文件结构 - 图13

图4-7 CPU类型取值

在图4-6中,Machine字段的值为“4C 01”,即0×014C,也就是支持Intel类型的CPU。

(2) NumberOfSection:该字段是WORD类型,占用两个字节。该字段表示PE文件的节区的个数。在图4-6中,该字段值为“03 00”,即为0×0003,也就是说明该PE文件的节区有3个。

(3) TimeDataStamp:该字段表明文件是何时被创建的,这个值是自1970年1月1日以来用格林威治时间计算的秒数。

(4) PointerToSymbolTable:该字段很少被使用,这里不做介绍。

(5) NumberOfSymbols:该字段很少被使用,这里不做介绍。

(6) SizeOfOptionalHeader:该字段为WORD类型,占用两个字节。该字段指定 IMAGE_OPTIONAL_HEADER结构的大小。在图4-6中,该字段的值为“E0 00”,即“0×00E0”,也就是说IMAGE_OPTIONAL_HEADER的大小为 0×E0。注意,在计算 IMAGE_OPTIONAL_HEADER的大小时应该从IMAGE_FILE_HEADER结构中的 SizeOfOptionalHeader获取,而不应该直接使用sizeof(IMAGE_OPTIONAL_HEADER)获取。由该字段可以看出IMAGE_OPTIONAL_HEADER结构体的大小可能是会改变的。

(7) Characteristics:该字段为WORD类型,占用两个字节。该字段指定文件的类型。该字段取值如图4-8所示。

4.2 详解PE文件结构 - 图14

图4-8 文件类型取值

从图4-6可知,该字段的值为“0F 01”,即“0×010F”。该值表示该文件运行的目标平台为32位平台,是一个可执行文件,且不存在重定位信息,行号信息和符号信息已从文件中移除。

4.2.4 IMAGE_OPTIONAL_HEADER

IMAGE_OPTIONAL_HEADER在几乎所有的参考书中都被称作“可选头”。虽然被称作可选头,但是该头不是一个可选头,而是一个必须存在的头,不可以没有。该头被称作“可选头”认为是在该头的数据目录数组中,有的数据目录项是可有可无的,这部分内容是可选的,因此成为可选头。而我觉得如果称之为“选项头”是否会更好一点?不管如何称呼,只要大家能够理解该头是必须存在的,就可以了。

可选头紧挨着文件头,文件头的结束位置为0×000000DF,那么可选头的起始位置为 0×000000E0。可选头的大小在文件头中给出,其大小为0×00E0字节(十进制为224个字节),其结束位置为0×000000E0+ 0×00E0-1=0×000001BF,如图4-9所示。

4.2 详解PE文件结构 - 图15

图4-9 可选头的内容

可选头是文件头的一个补充,其中的字段除了对文件的一些定义外,还为操作系统提供了装载PE文件的相关定义。该头同样有32位与64位版本之分,IMAGE_OPTIONAL_HEADER是一个宏,该宏的定义如下:

4.2 详解PE文件结构 - 图16

32位版本与64位版本的选择是根据是否定义了_WIN64而决定的,这里只讨论其32位的版本,IMAGE_OPTIONAL_HEADER32的定义如下:

4.2 详解PE文件结构 - 图17

4.2 详解PE文件结构 - 图18

该结构体的成员变量非常之多,为了能够很好地掌握该结构体,这里对该结构体的成员变量进行一一介绍。

(1) Magic:该成员变量指定了文件的状态类型,状态类型如图4-10所示。

4.2 详解PE文件结构 - 图19

图4-10 Magic变量取值

(2) MajorLinkerVersion:主链接版本号。

(3) MinorLinkerVersion:次链接版本号。

(4) SizeOfCode:代码节的大小,如果有多个代码节的话,就是它们的总和。该处是指所有包含可执行属性的节的大小。

(5) SizeOfInitializedData:已初始化数据块的大小。

(6) SizeOfUninitializedData:未初始化数据块的大小。

(7) AddressOfEntryPoint:程序执行的入口地址,该地址是一个相对虚拟地址,该地址简称EP。如果加壳后,找到了该地址,就被称作了 OEP。该地址指向的不是main(),也不是 WinMain()的地址,该地址指向了运行库代码的地址。对于DLL这个值的意义不大,因为DLL甚至可以没有DllMain()函数,没有DllMain()是无法捕获DLL的4个消息的。

(8) BaseOfCode:代码段的起始相对虚拟地址。

(9) BaseOfData:数据段的起始相对虚拟地址。

(10) ImageBase:文件被装入内存后的首选建议装载地址。对于EXE文件来说,通常情况下该地址就是装载地址;对于DLL来说,可能就不是其装入内存后的地址了。

(11) SectionAlignment:节被装入内存后的对齐值。节被映射到内存中需要对齐的单位。通常情况下0×1000,也就是4KB大小。Windows操作系统的内存分页一般为4KB。

(12) FileAlignment:节在文件中的对齐值。节在磁盘上是对齐单位。

(13) MajorOperatingSystemVersion:要求最低操作系统的主版本号。

(14) MinorOperatingSystemVersion:要求最低操作系统的次版本号。

(15) MajorImageVersion:可执行文件的主版本号。

(16) MinorImageVersion:可执行文件的次版本号。

(17) MajorSussystemVersion:要求最低子系统的主版本号。

(18) MinorSubsystemVersion:要求最低子系统的次版本号。

(19) Win32VersionValue:该成员变量是被保留的。

(20) SizeOfImage:可执行文件装入内存后的总大小。该大小按内存对齐方式对齐。

(21) SizeOfHeaders: PE头的大小,这个PE头泛指DOS头、PE头、节表的总和大小。

(22) CheckSum:校验和。对于EXE文件通常为0,对于SYS文件则必须有一个校验和。

(23) Subsystem:可执行文件的子系统类型。该值如图4-11所示。

4.2 详解PE文件结构 - 图20

图4-11 Subsystem值的意义

(24) DllCharacteristics:指定DLL文件的属性。该值大部分时候为0。

(25) SizeOfStackReserve:为线程保留的栈大小。

(26) SizeOfStackCommit:为线程已经提交的栈大小。

(27) SizeOfHeapReserve:为线程保留的堆大小。

(28) SizeOfHeapCommit:为线程已经提交的堆大小。

(29) LoaderFlags:被废弃的成员值。MSDN上的原话为“This member is obsolete”。但是该值在某些情况下还是会被用到的,比如针对旧版OD时,修改该值会起到反调试的作用。

(30) NumberOfRvaAndSizes:数据目录项的个数。该个数在PSDK中有一个宏定义,定义如下:

4.2 详解PE文件结构 - 图21

(31)DataDirectory:数据目录表,由NumberOfRvaAndSize个IMAGE_DATA_DIRECTORY结构体组成。该数组中包含了输入表、输出表、资源等数据的RVA。 IMAGE_DATA_DIRECTORY的定义如下:

4.2 详解PE文件结构 - 图22

该结构体的第一个变量为该目录的相对虚拟地址的起始值,第二个是该目录的长度。数据目录中的部分成员在数组中的索引如图4-12所示。

4.2 详解PE文件结构 - 图23

图4-12 数据目录部分成员在数组中的索引

在数据目录中,并不是所有的目录项都会有值,有很多目录项的值都为0。

这个可选头的结构体就介绍完了,希望大家按照对结构体中各成员变量的掌握自行学习可选头中的十六进制的字段,这样有助于我们对PE文件格式的分析,加快对PE文件格式的掌握。

4.2.5 节区详解IMAGE_SECTION_HEADER

节表的位置在IMAGE_OPTIONAL_HEADER的后面,节表中的每个 IMAGE_SECTION_HEADER中都存放着可执行文件被映射到内存中所在位置的信息,节的个数由IMAGE_FILE_HEADER中的NumberOfSections给出,希望大家没有忘记。该内容如图4-13所示。

4.2 详解PE文件结构 - 图24

图4-13 IMAGE_SECTION_HEADER位置的数据内容

IMAGE_SECTION_HEADER的结构体起始位置在0×000001CO处,结束位置在 0×00000237处,IMAGE_SECTION_HEADER的大小为40个字节,该文件有3个节表,因此占用了120个字节。

IMAGE_SECTION_HEADER结构体的定义如下:

4.2 详解PE文件结构 - 图25

这个结构体相对于IMAGE_OPTIONAL_HEADER来说成员变量少很多,下面逐一进行介绍。

(1)Name:该成员变量保存节的名称,节的名称用ASCII编码来保存。节名的长度为 IMAGE_SIZEOF_SHORT_NAME,这是一个宏,该宏的定义如下:

4.2 详解PE文件结构 - 图26

节名的长度为8个字节,多余的字节会被自动截断。通常情况下节名以“.”为开始,当然这是编译器的习惯。我们看一下图4-13的前8个字节的内容为“2E 74 65 78 74 00 00 00”,其对应ASCII字符为“.text”。

(2) VirtualSize:该值为数据实际的节区大小,该值不一定为对齐后的值。

(3) VitualAddress:该节区载入到内存后的相对虚拟地址。这个地址是按内存进行对齐的。

(4) SizeOfRawData:该节区在磁盘上的大小,该值通常是对齐后的值,但是也有例外。

(5) PointerOfRawData:该节区在磁盘文件上的偏移值。

(6) Characteristics:节区属性。该属性的值如图4-14所示。

4.2 详解PE文件结构 - 图27

图4-14 节区属性取值

关于IMAGE_SECTION_HEADER的介绍就到这里了。

4.2.6 与PE结构相关的3种地址

下面介绍一下与PE结构相关的3种地址,分别是VA(虚拟地址)、RVA(相对虚拟地址)和FileOffset(文件偏移地址)。

VA(虚拟地址):PE文件映射到内存后的地址。

RVA(相对虚拟地址):相对虚拟地址是内存地址相对于映射基地址的偏移地址。

FileOffset(文件偏移地址):相对PE文件在磁盘上的文件开头的偏移量。

这3个地址都是和PE文件结构密切相关的,前面简单地引用过这几个地址,但是前面只是个概念。从了解节区开始,这3个地址的概念就非常重要了,否则后面的很多内容都将没法理解。

这3个概念之所以重要,是因为我们要不断地使用它们,而且三者之间的关系也很重要。每个地址之间的转换也很重要,尤其是VA和FileOffset的转换,还有RVA和FileOffset之间的转换。这两个转换不能说是复杂,但是需要一定的公式。而VA和RVA的转换就非常得简单与容易了。

PE文件在磁盘上和在内存中的结构是一样的。所不同的是,在磁盘上文件是按照IMAGE_OPTIONAL_HEADER的FileAlignment值进行对齐的。而在内存中,映像文件是按照IMAGE_OPTINAL_HEADER的SectionAlignment进行对齐的。这两个值前面已经介绍过了,这里再进行一个简单的回顾。FileAlignment是按照磁盘上的扇区为单位的,也就是说FileAlignment最小为512字节,十六进制的0×200字节。而SectionAlignment是按照内存分页为单位来对齐的,其值为4KB,也就是十六进制的0×1000字节。一般情况下,FileAlignment的值会与SectionAlignment的值相同,这样磁盘文件和内存映像的结构是完全一样的。当FileAlignment的值和SectionAlignment的值不相同的时候就存在一些细微的差异了,其差异的主要区别在于,根据对齐的实际情况而多填充了很多0值。PE文件映射如图4-15所示。

4.2 详解PE文件结构 - 图28

图4-15 PE文件映像

4.2.7 3种地址的转换

当FileAlignment和SectionAlignment的值不相同时,磁盘文件与内存映像的同一数据在磁盘和内存中的偏移也不相同,这样两个偏移就发生了一个需要转换的问题。当你知道某数据的RVA的时候,想要在文件中读取同样的数据,就必须将RVA转换为FileOffset。反之,也是同样的情况。

下面用一个例子来学习如何进行转换。还记得前面为了分析PE文件结构而写的那个用 MessageBox()输出“Hello World”的例子程序吧?我们用PEDI打开它,查看它的节表情况,如图4-16所示。

4.2 详解PE文件结构 - 图29

图4-16 节表内容

从图4-16的标题栏可以看到,这里不叫“节表”而叫“区段”,还有别的资料上称之为 “区块”,这个只是叫法不同,内容都是一样的。

从图4-16中可以看到,节表的第一个节区的节名称为“.text”。通常情况下,第一个节区都是代码区,入口点也通常指向这个节区。在早期壳不流行时,通过判断入口点是否在第一个节区来判断该程序是否为病毒,如今这种做法就不可靠了。我们关键要看的是“R.偏移”,这个表明了该节区在文件中的起始位置。PE头部,包括DOS头、PE头和节表,通常不会超过512个字节,也就是说,不会超过0×200的大小。如果这个“R.偏移”为0×00001000,那么通常情况下可以确定该文件的磁盘对齐大小为0×1000。测试验证一下这个程序,看到“V.偏移”与“R.偏移”相同,则说明磁盘对齐与内存对齐是一样的,这样,就没办法完成演示转换的工作了。不过,可以人为地修改磁盘对齐大小,也可以通过工具来修改磁盘对齐的大小。这里,借助LordPE来修改其大小,修改方法很简单。先将要修改的测试文件复制一份,以与修改后的文件做对比。打开LordPE,单击“重建PE”按钮,然后再选择刚才复制的那个测试文件,如图4-17、图4-18所示。

PE重建功能中会压缩文件大小的功能,这里的压缩也就是修改磁盘文件的对齐值,避免过多的因对齐而进行补0,使其少占用磁盘空间。用PEID查看一下这个进行重建的PE文件的节表,如图4-19所示。

现在可以看到“V.偏移”与“R.偏移”的值不相同了,它们的对齐值也不相同了,大家可以自己验证一下FileAlignment和SectionAlignment的值是否相同。

现在我们有两个功能完全一样,而且PE结构也一样的两个文件了,唯一的不同就是其磁盘对齐大小不同。现在在这两个程序中分别寻找相同的数据,来学习不同地址之间的转换。

4.2 详解PE文件结构 - 图30

图4-17 LordPE界面

4.2 详解PE文件结构 - 图31

图4-18 重建PE功能结果

4.2 详解PE文件结构 - 图32

图4-19 重建PE后的节表

先用OD打开未进行重建PE的测试程序,找到MessageBox()处要弹出的两个对话框的地址,如图4-20、图4-21所示。

4.2 详解PE文件结构 - 图33

图4-20 MessageBox0函数中所使用的字符串地址

4.2 详解PE文件结构 - 图34

图4-21 两个字符串的地址

从图4-20和图4-21中可以看到,字符串“hello world!”的虚拟地址(VA)为0×00405030。

相对虚拟地址(RVA)为VA减去ImageBase(映像文件的装载虚拟地址),则RVA= VA-ImageBase=0×00405030-0×00400000=0×00005030。由于 SectionAlignment与 FileAlignment的值相同,因此其FileOffset的值也为0×00005030,用C32ASM打开该文件查看0×00005030处,如图4-22所示。

4.2 详解PE文件结构 - 图35

图4-22 文件偏移0×00005030处的内容为“hello world!”字符串

从这个例子中可以看出,当SectionAlignment和FileAlignment相同时,同一数据的RVA (相对虚拟地址)和FileOffset(文件偏移地址)相同。RVA的值是使用VA-ImageBase得到的。

再次用OD打开“重建PE”后的测试程序,同样找到MessageBox()函数使用的那个字符串“hello world!”,看其虚拟地址是多少。可以告诉大家,它的虚拟地址仍然是0×00405030。虚拟地址是0×00405030,那么同样的用虚拟地址减去装载地址,相对地址的值仍然为 0×00005030。用C32ASM打开该文件,看一下0×00005030地址处的内容,如图4-23所示。

4.2 详解PE文件结构 - 图36

图4-23 文件的末尾地址

从图中可以看到,用C32ASM打开该文件后,文件的末尾偏移为0000379C,根本没有 0×00005030这个偏移地址。这就是文件对齐与内存对齐的差异而引起的。这个时候,就要通过一些简单的计算把RVA转换FileOffset。

把RVA转换为FileOffset的方法很简单,首先要看一下当前的RVA或者是FileOffset属于哪个节。0×00005030这个RVA属于.data节。0×00005030这个RVA相对于该节的起始RVA地址0×00005000来说偏移0×30个字节。再看.data节在文件中的起始位置为0×00003400,以.data节的文件起始偏移0×00003400加上0×30个字节的值为0×00003430。用C32ASM看一下0×00003430这个地址处的内容,如图4-24所示。

4.2 详解PE文件结构 - 图37

图4-24 0×00003430文件偏移处的数据内容

从图4-24可以看出,该文件偏移处保存着“hello world!”字符串,也就是说将RVA转换为FileOffset是正确的。通过LordPE工具来验证一下,如图4-25所示。

4.2 详解PE文件结构 - 图38

图4-25 用LordPE计算0×00005030的文件偏移

我们再来回顾一下这个过程。

某数据的文件偏移=该数据所在节的起始文件偏移+(某数据的RVA-该数据所在节的起始RVA)。

除了上面的计算方法以外,还有一种计算方法,把节的起始RVA值减去节的起始文件偏移值,得到一个差值。然后再用RVA减去这个得到的差值就可以得到其所对应的FileOffset了。大家可以对这种方法自行验证。

这3种地址相互的转换方法就介绍完了。如果没有理解,那么就反复地按照公式进行学习和计算。只要在头脑中建立了关于磁盘文件和内存映像的结构,那么理解起来就不会太吃力。在后面的例子当中,将会写一个类似LordPE中那样转换3种地址的程序,来加强理解。