所谓PE文件就是可执行文件在硬盘中存储是的文件表示,Windows中可执行文件的文件表示的发展途径为com->LE(Linerar executable)->PE(Portable Executable File Formate)。在程序运行的时候PE文件中的部分内容被载入内存(各种节),这些内容提供了程序运行所需的所有信息,下图展示了PE文件到内存的映射:
文章目录
- PE文件结构
- IMAGE_DOS_HEADER
- DOS Stub
- IMAGE_NT_HEADERS
- IMAGE_FILE_HEADER
- IMAGE_OPTIONAL_HEADER32
- IMAGE_SECTION_HEADER
- 功能数据介绍
- 导入表
- 导出表
- 资源
- 重定位表
- 总结
下图展示了PE文件的总体结构,在下面小节中将对各个结构进行详细的说明。
DOS部分是为了兼容DOS系统程序,此部分包括Header和stub两部分,前者是对DOS程序的描述,后者包含了DOS可执行程序,Header头部如下所示:
IMAGE_DOS_HEADER STRUCT
e_magic WORD ;DOS可执行文件标记
e_cblp WORD
e_cp WORD
e_crlc WORD
e_cparhdr WORD
e_minalloc WORD
e_maxalloc WORD
e_ss WORD ;代码的初始化堆栈段基址
e_sp WORD ;代码的堆栈段偏移指针
e_csum WORD
e_ip WORD ;DOS代码段偏移指针
e_cs WORD ;DOS代码段基址
e_lfarlc WORD
e_ovno WORD ;DOS文件头部到此为此,下述字段为了对其他可执行文件进行扩充,DOS系统对下述字段不解释
e_res WORD
e_oemid WORD
e_oeminfo WORD
e_res2 WORD
e_lfanew DWORD ;指向PE文件头部(或者LE,LX其他可执行文件头部)
IMAGE_DOS_HEADER ENDS
通过Lord PE工具可以查看程序的IMAGE_DOS_HEADER信息:
DOM可执行文件的识别标志为MZ
DOS系统下可执行的代码,默认情况下这段代码只会简单的显示一个“This program cannot be run in DOS mode”然后就退出了,具体代码如下图黄框所示(Study PE工具截图)
0E PUSH cs
1F POP ds
BA0E00 MOV dx, 0x000E ;串地址(即This program cannot.....的地址)
B409 MOV ah, 0x09 ;显示字符串
CD21 INT 0x21 ;中断调用
B8014C MOV ax,0x4c01 ;带返回码结束,返回码为1
CD21 INT 0x21
可以使用链接器(link.exe)的/stud参数在链接时指定其他的DOM程序来代替上述提示程序
IMAGE_NT_HEADERS是正式的PE文件头,本部分以8字节为单位对其,详细结构如下所示:
IMAGE_NT_HEADERS STRUCT
Signature DWORD ;PE文件标识
FileHeader IMAGE_FILE_HEADER
OptionalHeader IMAGE_OPTIONAL_HEADER32
IMAGE_NT_HEADERS ENDS
其中Signature的值为0x00004550也就是PE\0\0
IMAGE_FILE_HEADER STRUCT
Machine WORD ;运行平台
NumberOfSections WORD ;文件的节数
TimeDateStamp DWORD ;文件创建日期和时间
PointerToSymbolTable DWORD ;符号表指针(调试用)
NumberOfSymbols DWORD ;符号表中的符号数量(调试用)
SizeOfOptionalHeader WORD ;IMAGE_OPTIONAN_HEADER32结构的长度
Characteristics WORD ;文件属性
IMAGE_FILE_HEADER ENDS
此结构体本意是让开发者能够在PE文件头中使用自定义数据,这个结构体弥补了IMAGE_FILE_HEADER不能够充分描述PE文件属性的不足
IMAGE_OPTIONAL_HEADER32 STRUCT:
Magic WORD ;
MajorLinkerVersion BYTE ;连接器版本号
MinorLinkerVersion BYTE ;
SizeOfCode DWORD ;所有包含代码的节的总大小
SizeOfInitializedData DWORD ;所有包含已初始化数据的节的总大小
SizeOfUninitializedData DWORD ;所有包含未初始化数据的节的总大小
AddressOfEntryPoint DWORD ;程序执行的入口偏移虚拟地址(RVA)
BaseOfCode DWORD ;代码节的起始偏移虚拟地址(RVA)
BaseOfData DWORD ;数据节的起始偏移虚拟地址(RVA)
ImageBase DWORD ;程序建议装载地址
SectionAlignment DWORD ;内存中的节的对齐粒度
FileAlignment DWORD ;文件中的节的对其粒度
MajorOperatingSystemVersion WORD ;操作系统的主版本号
MinorOperatingSystemVersion WORD ;操作系统中副版本号
MajorImageVersion WORD ;可运行于操作系统的最小版本号
MinorImageVersion WORD ;
MajorSubsystemVersion WORD ;可运行于操作系统的最小子版本号
MinorSubsystemVersion WORD ;
Win32VersionValue DWORD ;未用
SizeOfImage DWORD ;内存中整个PE映像的尺寸
SizeOfHeaders DWORD ;所有头+节表的大小
CheckSum WORD ;
Subsystem WORD ;文件的子系统
DllCharacteristics DWORD ;
SizeOfStackReserve DWORD ;初始化时堆栈大小
SizeOfStackCommit DWORD ;初始化时实际提交的堆栈大小
SizeOfHeapReserve DWORD ;初始化时保留的堆的大小
SizeOfHeapCommit DWORD ;初始化时实际提交的堆的大小
LoaderFlags DWORD ;未用
NumberOfRvaAndSizes DWORD ;下面的数据目录结构的数量
DataDirectory IMAGE_DATA_DIRECTORY 16 dup;
IMAGE_OPTIONAL_HEADER32 ENDS
下图展示了某程序的IMAGE_OPTIONAL_HEADER32 :
IMAGE_DATA_DIRECTORY STRUCT
VirtualAddress DWORD ;数据的起始RVA
isize DWORD ;数据块的长度
IMAGE_DATA_DIRECTORY ENDS
下图展示了这16个IMAGE_DATA_DIRECTORY结构的用途:
下图是通过Lord PE软件查看的某个EXE文件的DataDirectory列表:
在PE文件装入时Windows装载器在装载DOS部分、PE文件头部分和节表部分时不进行任何处理,而在装载节的时候会根据节的属性做不同的处理,一般需要处理以下几个方面内容:
PE文件所有节的属性均被定义在节表中,节表由一些列(IMAGE_NT_HEADERS.FileHeader.NumberOfSecitons字段指定节表长度)的IMAGE_SECTION_HEADER结构组成,这些结构的排列顺序与他们所描述节的排列顺序一致,节表起始地址位于距离PE文件头(不是文件头)00f8h偏移的位置,IMAGE_SECTION_HEADER结构如下所示:
IMAGE_SCETION_HEADER STRUCT
Name1 db IMAGE_SIZEOF_SHORT_NAME dup(?) ;8个字节的节区名称
union Misc
PhysicalAddress dd ;
VirtualSize dd ;节的尺寸
ends
VirtualAddress dd ;节区的RVA
SizeofRawData dd ;在文件中对齐后的尺寸
PointerToRawData dd ;在文件中的偏移
PointerToRelocations dd ;在OBJ文件中使用
PoninterToLinenumbers dd ;行号表的位置(供调试用)
NumberOfRelocations dw ;在OBJ文件中使用
NumberOfLinenumbers dw ;行号表中行号的数量
Characteristics dd ;节的属性
IMAGE_SCETION_HEADER ENDS
上面有些字段是供COFF格式的OBJ文件使用的,对于可执行文件来说不代表任何意义
前面已经提及过节是按照数据属性进行的分类,DataDirectory是按照数据功能进行的分类。本节中介绍导入表,导出表,资源,重定位表四个重要的DataDirectory。
导入表中保存了导入函数的相关信息(导入函数名,其驻留的DLL),所谓导入函数就是代码中调用了这些函数而这些函数并没有在本代码中定义而是从一个或多个DLL中导入进来的函数。当PE文件位与磁盘上时无法得知导入函数会位与内存的哪个位置,只有PE文件被装入内存时,Windows转载器才会将DLL装入并将导入函数的指令与函数实际所处的地址联系起来,这就是动态链接的概念。
导入表由一系列的IMAGE_IMPORT_DESCRIPTOR结构组成,这个结构与导入的DLL文件一 一对应,最后需要一个内容全为0的IMAGE_IMPORT_DESCRIPTOR结构作为结束
IMAGE_IMPORT_DESCRIPTOR STRUCT
union
Characteristics dd
OriginalFirstThunk dd
ends
TimeDateStamp dd
ForwarderChain dd
Name1 dd ;指向DLL文件名称的RVA
FirstThunk dd
IMAGE_IMPORT_DESCRIPTOR ENDS
OriginalFirstThunk和FirstThunk字段含义均指向一个包含一系列IMAGE_THUNK_DATA结构的数据,每个IMAGE_THUNK_DATA结构对应于一个导入函数信息,数组最后由一个全0的IMAGE_THUNK_DATA结构收尾,此结构(双字大小)具体如下:
IMAGE_THUNK_DATA STRUCT
union u1
ForwarderString dd
Function dd
Ordinal dd
AddressOfData dd
ends
IMAGE_THUNK_DATA ENDS
由于上述结构采用union结构所以其实其就是一个双字类型,当这个双字表示了函数的导入方式:
IMAGE_IMPORT_BY_NAME STRUCT
Hint dw ;表示函数序号,此字段可选
Name1 db ;定义了导入函数的名称字符串,以0结尾的字符串
IMAGE_IMPORT_BY_NAME ENDS
下图展示了装入内存前PE文件中的导入表,此表表示从Kernel32.dll导入四个函数(前三个以函数名方式导入,最后那个以序号方式导入):
下图展示了装载进内存后上述导入表的状况:
导入内存后FirstThunk指向的那个数组中的每个双字均被替换为真正的函数地址,OriginalFirstThunk与FirstThunk对照使用可以由地址得知函数名称,也可以由函数名或者序号查询函数在内存中的地址。
之前说个IMAGE_IMPORT_DESCRIPTOR结构与DLL文件一一对应,这些结构中FirstThunk指向的地址数组在内存中是被排列在一起的,这一内存块称之为导入地址表(Import Address Table,IAT),导入表中第一个IMAGE_IMPORT_DESCRIPTOR结构的FirstThunk字段指向了IAT的起始地址,除此之外DataDirectory结构中第13项IMAGE_DATA_DIRECTORY 结构也指向了IAT的起始地址。
除了导入表还有导出表,导入表指的是程序运行时要从DLL中导入的函数信息,而导出表则表示着DLL文件中函数的导出信息,主要用于修正IAT,导出表包含了函数的名称、序号以及入口地址等信息。EXE文件一般不存在导出表,大部分的DLL文件都包含导出表,通过导出表可以看DLL中包含那些函数。下图展示了一个DLL文件的导入与导出函数信息:
导出表中为每个导出函数定义了导出序号,但是函数名的定义是可选的,导出表只有一个IMAGE_EXPORT_DIRECTORY结构,此结构定义如下:
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
下图展示了导出表及相关数据的结构及关系(nBase:x):
不明白的是函数的入口RVA在载入内存之前的磁盘文件中就存在了,为什么?RVA不是内存中才有的概念吗?难道是.text是第一个段所以其前面的头部载入时不会发生相对偏移?
接下来对资源涉及到的几种数据结构进行介绍,首先介绍IMAGE_RESOURCE_DIRECTORY,此结构包含了本目录的各种属性信息,具体结构如下:
IMAGE_RESOURCE_DIRECOTRY STRUCT
Characteristics dd ; 理论上为资源的属性,不过通常总是为0
TimeDateStamp dd ; 资源的产生时刻
MajorVersion dw ; 理论上为资源的版本,不过事实上总是为0
MinerVersion dw ;
NumberOfNameEntries dw ; 以名称命名的入口数量
NumberOfIdEntries dw ; 以ID命名的入口数量
IMAGE_RESOURCE_DIRECTORY ENDS
上述结构中后两项说明了本目录中目录项的数量,这两者相加是本目录下目录项的总和,接下来了解下IMAGE_RESOURCE_DIRECTORY_ENTRY结构:
IMAGE_RESOURCE_DIRECTORY_ENTRY STRUCT
Name1 dd ;
OffsetToData dd ;
IMAGE_RESOURCE_DIRECTORY_ENTRY ENDS
Name1字段字段在不同情况下有不同的含义,具体如下所示:
IMAGE_RESOURCE_DIR_STRING_U STRUCT
Length1 dw ;字符串长度
NameString dw dup(?) ;dup(?)代表重复?次,因为Unicode字符串长度不固定
IMAGE_RESOURCE_DIR_STRING_U ENDSUnicode字符串
OffsetToData字段在不同情况下有不同的含义,具体如下所示:
IMAGE_RESOURCE_DATA_ENTRY STRUCT
OffsetToData dd ;资源数据的RVA
Size1 dd ;资源数据的长度
CodePage dd ;代码页,实际之中好像未被使用,总是为0
Reserved dd ;保留字段
IMAGE_RESOURCE_DATA_ENTRY ENDS
需要注意的是上述结构中的指针含义均是相对于资源根目录起始地址的偏移量
下面结合Winhex与stud_PE分析下某程序的资源信息:
for i in 节表:
if RVA>i.VirtualAddress and RVA < i.VirtualAddress+i.sizeOfRawData:
文件偏移 = i.PointerToRawData+(RVA-i.VirtualAddress)
节信息如下,按上述算法资源处于.rsrc节中且资源的起始地址刚好是这个节的起始地址,为此可以判断出资源在文件中的偏移为0x000FE000
上述代码中第5行使用了直接寻址的方式,在此应用程序装入预设的0x0040000地址时这中寻址方式直接出现在代码中没有问题,如果程序没有载入预设地址那怎么办呢?重定位就是来解决这个问题的,32位的代码中涉及直接寻址的指令都是需要重定位的,16的DOS中只有涉及段操作的指令才需要重定位,重定位信息是在编译时有编译器生成并保存在可执行文件中的。重定位具体操作如下:
重定位后地址 = 代码中出现的直接寻址地址-预装地址(0x00400000)+代码实际装入地址
上述三个操作数中预装地址已经在IMAGE_OPTIONAL_HEADER32.ImageBase字段定义了,而代码的实际装入地址也在代码装入是由Windows装载器确定,为此只需要将出现直接寻址地址的代码地址放入重定向表中就能够准确的进行代码重定向操作了。通过DataDirectory中的第六项可以定位重定位表的位置,重定位表通常保存在**.reloc**节中但是在此强调一次,通常但不是必须为此还是通过DataDirectory来寻找重定位表的位置比较靠谱。还需要说明的是重定向表一般不会再程序运行时装入内存,其仅在程序装载时辅助装载器对程序进行重定位操作。为了节约存储空间,重定位表在保存时采用了这样的存储方式即按页保存重定位代码地址,首先保存一个4字节的内存页起始地址,再保存一个此页内重定位代码的项数,最后保存所有的需要重定位操作的代码地址相对于页起始地址的偏移(页内寻址为12位,这里扩展到16位)。这样存储一个页内的所有重定位代码信息就需要4+4+2n字节,这种存储方式在内存页内重定位项数多于4时优于直接存储每个需要重定位代码的地址(4n)。按照这个逻辑关系每个内存页对应着一个重定位块这个块以IMAGE_BASE_RELOCATION开头:
IMAGE_BASE_RELOCATION STRUCT
VirtualAddress dd ;页起始RVA
SizeOfBlock dd ;重定位块的长度 4+4+2*n
IMAGE_BASE_RELOCATION ENDS
上面提到要把12位的页内地址扩展到16位,低12位是原来的地址,高四位也被赋予了具体的函数,如下表所示:
上述表中0和3是最为常见的情况,所有的重定位块终于一个VirtualAddress字段全0的IMAGE_BASE_RELOCATION结构,这时候问题来了,那么RVA为0的内存页中需要重定位的代码如何解决呢?为了解决这个问题所有的可执行文件的代码总是从装入地址的0x1000处开始定义,如下图所示:
下面结合Winhex与stud_PE分析下某DLL文件重定位表:
for i in 节表:
if RVA>i.VirtualAddress and RVA < i.VirtualAddress+i.sizeOfRawData:
文件偏移 = i.PointerToRawData+(RVA-i.VirtualAddress)