PE文件结构

什么是PE文件?

PE(Portable Execute)文件是Windows下可移植可执行文件的总称,常见的有DLL,EXE,OCX,SYS等,事实上,一个文件是否是PE文件与其扩展名无关。本文要说的PE文件结构,就是指的Window可移植可执行文件结构。

PE文件结构

PE文件具有较强的移植性;
PE结构是一种数据组织方式;
PE结构主要应用于windows系统;
具有PE结构的文件称为PE文件;
EXE、DLL都是PE文件;

一个完整的PE文件主要有4个部分组成:DOS头,PE头,节表以及节数据
1.Dos部分主要用来对非FE格式文件的处理,DOS时代遗留的产物,是PE文件的一个遗传基因;
2.PE头部分用于宏观上记录文件的一些信息,,运行平台,大小,创建日期,属性等。
3.节表部分用于对各中类型的数据进行定义分段;
4.节数据不言而喻就是文件的数据部分,实际上我们编写程序的过程中就是对该部分的数据进行编写。而其他的部分则是由编译器依照我们编写的部分进行相应的填写而得到的。

上图从下往上看,该结构清晰的描述了各个数据块在pe文件中的排列顺序和部分数据块之间的关联方式。

Dos部分

DOS部分由如下两部分构成:Dos头和Dos块。
Dos头:长度40h;
Dos块:长度不定,DOS插桩代码,是DOS下的16位程序代码,只是为了显示上面的提示数据。这段代码是编译器在程序编译过程中自动添加的。
Dos头对于非pe结构的文件将指引dos可执行程序部分,也就指引到dos块。而对于PE结构的文件将指引到PE结构部分。具体如何指引是通过dos头结构进行指定的。

Dos结构定义


这里是对dos头结构的完整定义,该字段的长度固定为40h。
第一项是字段的名称,第二项是字段的大小,第三项是字段的是数据内容。对于研究PE结构而言,该结构的定义中对我们有意义的字段只有最后一个(也就是红色的那一行),该字段的内容是一个长度为4个字节的地址,用于指向PE结构部分。
dosHeader->e_lfanew是DWORD类型,指出真正PE头相对于文件基地址的偏移值,即偏移多少个字节。
该字段在文件中如何查找,需要计算该字段E_lfanew的偏移(就是3Ch)。偏移的计算方法很简单,就是计算从结构头部到该字节的长度。找到Dos头的地址,然后加上偏移量3Ch就是e_lfanew的地址。或者使用OlluDebug查看:

我们已经知道dos头部分的长度是40h,其后面紧跟的就是dos块部分,而dos块的长度则是不固定的。它起始于dos头的结束位置,结束于pe头开始之前。也就是说,位于dos头和pe头之间的数据就是dos块。
下图中被选中的部分就是dos块。

Dos块:长度不定,DOS插桩代码,是DOS下的16位程序代码,只是为了显示上面的提示数据。这段代码是编译器在程序编译过程中自动添加的。

PE头部分

PE头的三个构成部分:
PE标志位,长度4H(4个字节)
文件头,长度14H(20个字节)
可选头,长度E0H(224个字节)
PE header 是真正的Win32程序的格式头部,其中包括如下:
PE标志位:共有4个字节的长度,用来判断文件是否为pe文件的标识。PE文件该字段的值固定为00004550,在字符串中看到的数据为“PE..”
文件头:共占有14H个字节的长度,主要记录该文件的一些调试信息。
可选头:长度为E0H个字节。该部分虽然叫可选头,PE文件中不可或缺的一部分。文件在装入内存时,主要的参照的就是该结构。文件在从源代码进行编译的时候也主要是对该部分进行填写。并在生成的时候依照该结构的一些字段的设置。
( PE表头内含的重要信息包括程序代码和资料区域的大小位置、适用的操作系统、堆栈(stack)的最初大小等等。 )


上面的图显示的是各个数据块的情况。
红色方框圈起来的是pe标志位(4字节),PE文件头标志 :“PE\0\0”。在开始DOS header的偏移3CH处所指向的地址开始 。
粉色方框内的数据是PE文件头部分(20字节),IMAGE_FILE_HEADER FileHeader;PE文件物理分布的信息 。
下面最大的那一部分是可选头部分,IMAGE_OPTIONAL_HEADER32 OptionalHeader;PE文件逻辑分布的信息 。

文件头结构定义


20个字节,对应的结构体名字:IMAGE_FILE_HEADER STRUCT
从结构的定义可以看到第二个字段是当前PE文件的节的数目。
Machine:指出该文件运行所需的CPU。
NumberOfSections:文件的节数目。
Characteristics文件属性:区分文件是exe还是dll等。

可选头定义


对应的结构体名字:IMAGE_OPTIONAL_HEADER32 STRUCT
由于可选头字段数目多达32个,这里没有对全部字段的定义进行列举,只列举了几个相对重要的一些字段(12个)。
DWORD AddressOfEntryPoint;//PE装载器准备运行的PE文件的第一个指令的RVA,若要改变整个执行的流程,可以将该值指定到新的RVA,这样新RVA处的指令首先被执行。
DWORD SectionAlignment; //块对齐
DWORD FileAlignment; //文件块对齐
(其中注释部分前面的16进制数据是该字段相对于PE头的偏移量。)
“RVA”:全名是“Relative Virtual Address”,翻译过来是“相对虚拟地址”。但该地址在用16进制工具打开磁盘文件后得到的数据中并不能找到。这是因为RVA是一个内存地址,我们打开的磁盘中的文件是找不到该地址的。 使用动态调试工具可以看到,如OD:

PE文件中的许多字段内容都是以RVA表示。
一个RVA是某一资料项的offset(偏移)值 – 从文件被映像进来的起点算起。
举个例子,我们说Windows加载器把一个PE档映像到虚拟地址空间的 0x400000 处,如果此image有一个表格开始于0x401464,那么这个表格 RVA就是0x1464:
虚拟地址 0x401464 - 基地址 0x400000 = RVA 0x1464
只要把RVA加上基地址,RVA就可以被转换为一个有用的指针。
由于内存中和文件中节对齐粒度不同,同一数据相对于文件头的偏移量在内存中和在磁盘文件中可能是不同的,为了提高程序执行的效率,PE文件头中使用的都是内存映像中的偏移量,也就是RVA。

注意,可选头的最后一个字段是一个结构体:
DataDirectory IMAGE_DATA_DIRECTORY 16 dup(<>) ;0078h
该结构体定义如下:
IMAGE_DATA_DIRECTORY STRUCT
VirtualAddress DWORD ? ;数据的起始RVA
Size DWORD ? ;数据块的长度大小
IMAGE_DATA_DIRECTORY ENDS
分别定义一个数据的起始地址,另一个是定义一个数据块的长度。

可选头中定义了如下重要信息:

  • 所有含代码的节的总大小
  • 所有含已初始化数据的节的总大小
  • 所有含未初始化数据的节的大小
  • 程序执行入口RVA
  • 代码的节的起始RVA
  • 数据的节的起始RVA
  • 程序的建议装载地址
  • 内存中的节的对齐粒度
  • 文件中的节的对齐粒度
  • 内存中整个PE映像尺寸
  • 所有头+节表的大小
  • 导出表
  • 导入表
  • 资源
  • 重定位表
  • 调试信息
  • 版权信息
  • 导入函数地址表

节表


节表:PE代码和数据的结构数据,指示装载系统代码段在哪里,数据段在哪里等。
PE文件中所有节的属性都被定义在节表(段表)中,节表是一个由IMAGE_SECTION_HEADER结构组成的数组,每个结构用来描述一个节。我们来分析一下这个结构。
结构的排列顺序和它们描述的节在文件中的排列顺序是一致的。全部有效结构的最后以一个空的IMAGE_SECTION_HEADER结构作为结束,所以节表中总的IMAGE_SECTION_HEADER结构数量等于节的数量加一。节表总是被存放在紧接在PE文件头的地方,也就是从PE文件头开始的偏移为00f8h的地方。
(注意:不是文件本身的头部,由于程序的dos块的大小并不是固定的,所以导致PE头相对于mz头的偏移变化的。 所以我们在计算偏移的时候,对于PE头后面的数据都习惯用相对于PE头的偏移,PE头大小是固定的的。)
在文件头中已经定义了文件的节的数目:0ah个,那么该文件的节表共有11个IMAGE_SECTION_HEADER结构。

节数据

常见的节数据:
.text:代码段,是在编译或汇编结束时产生的一种块,它的内容全部是指令代码。也有的编译器将该段命名为.code
.data:初始化的数据块,是初始化的数据块,包含那些编译时被初始化的变量、字符串
.idata:输入表,包含其他外来dll的函数和数据信息,也就是输入表,也有人称之为导入表。
.rsrc:资源数据块,包含模块的全部资源数据,如图标、菜单、位图等。
.reloc:重定位表,用于保存基址的重定位表。即当装在程序不能按照连接器所指定的地址装载文件是,需要对指令或已经初始化的变量进行调整,该块中也包含了调整过程中所需要的一些数据,如果装载能够正常装在则忽略此段中的数据。
.edata:导出表,是pe文件的输出表,以供其他模块使用,并不是每个pe文件都有此数据段,因为有的文件并不需要输出一些函数,该数据段常见于动态连接库文件中。
.radata:存放调试目录、说明字符串,该数据块并不常见主要是用于存放一些调试信息。

导入表

导入表由一系列的IMAGE_IMPORT_DESCRIPTOR结构组成,结构的数量取决于程序要使用的DLL文件的数量,每个结构对应一个DLL文件,例如,如果一个PE文件从10个不同的DLL文件中引入了函数,那么就存在10个IMAGE_IMPORT_DESCRIPTOR结构来描述这些DLL文件,在所有这些结构的最后,由一个内容全为0的IMAGE_IMPORT_DESCRIPTOR结构作为结束。
IMAGE_IMPORT_DESCRIPTOR STRUCT
union
Characteristics DWORD ?
OriginalFirstThunk DWORD ? ;指向被调函数名的指针数组的指针
ends
TimeDateStamp DWORD ? ;时间日期记录,无实际意义,可忽略
ForwarderChain DWORD ? ;正向连接索引
Name1 DWORD ? ;指向被调用dll的名字指针数组的指针
FirstThunk DWORD ? ;指向被调函数地址的指针数组的指针
IMAGE_IMPORT_DESCRIPTOR ENDS
ForwarderChain:当程序调用一个dll中的函数A,而函数A又需要调用其他dll中的函数B时使用该字段。这种情况很少见,该字段也就很少被用到。一般为0。
FirstThunk:该字段在文件中看到的数据是和第一个字段OriginalFirstThunk是一样的,这里之所以叫做地址是由于程序加载到内存后是会对该字段重写,将地址写入到这里。

导出表

IMAGE_EXPORT_DIRECTORY STRUCT
Characteristics DWORD ? ;未使用,总是为0
TimeDateStamp DWORD ? ;文件的产生时刻
MajorVersion WORD ? ;未使用,总是为0
MinorVersion WORD ? ;未使用,总是为0
nName DWORD ? ;指向文件名的RVA
nBase DWORD ? ;导出函数的起始序号
NumberOfFunctions DWORD ? ;导出函数的总数
NumberOfNames DWORD ? ;以名称导出的函数总数
AddressOfFunctions DWORD ? ;指向导出函数地址表的RVA
AddressOfNames DWORD ? ;指向函数名地址表的RVA
AddressOfNameOrdinals DWORD ? ;指向函数名序号表的RVA
IMAGE_EXPORT_DIRECTORY ENDS

nName字段:这个字段是一个RVA值,指向一个定义了模块名称的字符串。这个字符串说明了模块的原始文件名,比如说即使Kernel32.dll文件被改名为Ker.dll,仍然可以从这个字符串中的值得知它被编译时的文件名是“Kernel32.dll”。
NumberOfFunctions字段:文件中包含的导出函数的总数。
NumberOfNames字段:被定义了函数名称的导出函数的总数。显然,只有这个数量的函数既可以用函数名方式导出,也可以用序号方式导出,剩下的NumberOfFunctions减去NumberOfNames数量的函数只能用序号方式导出。NumberOfNames字段的值只会小于或者等于NumberOfFunctions字段的值,如果这个值是0,表示所有的函数都是以序号方式导出的。
AddressOfFunctions字段:这是一个RVA值,指向包含全部导出函数入口地址的双字数组,数组中的每一项是一个RVA值,数组的项数等于NumberOfFunctions字段的值。
nBase字段:导出函数序号的起始值。将AddressOfFunctions字段指向的入口地址表的索引号加上这个起始值就是对应函数的导出序号,举例来说,假如nBase字段的值为x,那么入口地址表指定的第一个导出函数的序号就是x,第二个导出函数的序号就是x+1,总之,一个导出函数的导出序号等于nBase字段的值加上其在入口地址表中的位置索引值。
AddressOfNames和AddressOfNameOrdinals字段:AddressOfNames字段的数值是一个RVA值,指向函数名字符串地址表,这个地址表是一个双字数组,数组中的每一项指向一个函数名称字符串的RVA,数组的项数等于NumberOfNames字段的值,所有有名称的导出函数的名称字符串都定义在这个表中。