PE文件解析(一)

基本概念

什么是PE文件

  1. PE文件的全程:Portable Executable,即可移植的可执行文件
  2. 常见的PE文件:EXE文件、DLL文件、OCX文件、SYS文件、COM文件
  3. PE文件通常是指32位的,而64位的PE文件通常称为PE32+、PE+、PE64

文件偏移地址、虚拟地址与相对虚拟地址

  1. 文件偏移地址:PE文件存储在磁盘中时,某个数据的位置相对于文件头部的偏移量,通常将其称为文件偏移地址(File Offset Address)或物理地址(RAW Offset)
  2. 虚拟地址:在Windows系统中,PE文件会被系统加载器映射到内存中,而每个PE文件都有其自己的独立的虚拟空间,这个虚拟空间的内存地址就被称为虚拟地址(Virtual Address)
  3. 相对虚拟地址:当PE文件映射到内存之后,某个数据相对于文件载入点地址(即基地址,ImageBase)的偏移量,通常称其为相对虚拟地址(Relative Virtual Address),虚拟地址与相对虚拟地址存在如下关系:虚拟地址(VA) = 基地址(ImageBase) + 相对虚拟地址(RVA)

PE结构图

  1. PE文件框架结构

  2. PE文件的详细结构

  3. PE文件磁盘结构与内存结构(对齐原因)

PE Headers解析

首先需要明确的是,严格意义上的PE文件头是指IMAGE_NT_HEADERS,但为了方便解析,此处将

  • IMAGE_DOS_HEADER(DOS头)
  • IMAGE_NT_HEADERS(NT头)
  • IMAGE_FILE_HEADER(映像文件头)
  • IMAGE_OPTIONAL_HEADER(可选映像头)
  • IMAGE_SECTION_HEADER(区块表)

这五个部分都视作PE的头部部分一并进行解析

IMAGE_DOS_HEADER

MS-DOS头部,大小为64字节,每个PE文件都是以一个DOS程序开始的,且DOS可以识别出一个文件是不是一个有效的执行体,若其首部的e_magic被置为0x5A4D(即ASCII的 “MZ”,该值对应于winnt.h文件中的一个宏定义,IMAGE_DOS_SIGNATURE),那么该文件就是一个DOS可执行文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
typedef struct _IMAGE_DOS_HEADER {      
WORD e_magic; // DOS可执行文件标记 "MZ"
WORD 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; // DOS代码入口IP
WORD e_cs; // DOS代码入口CS
WORD e_lfarlc;
WORD e_ovno;
WORD e_res[4];
WORD e_oemid;
WORD e_oeminfo;
WORD e_res2[10];
LONG e_lfanew; // 偏移地址,指向IMAGE_NT_HEADERS,"PE00"(0x00004550)
} IMAGE_DOS_HEADER

其中比较重要的两个字段分别是e_magic与e_lfanew,前者的作用已经解释过,而e_lfanew是真正的PE文件头IMAGE_NT_HEADERS的相对偏移(lfanew = long file address of new exe)

用十六进制编辑器打开exe文件可以发现,起始位置e_magic字段的值为”MZ”,而e_lfanew的值为”0x000000F0”,在相对文件起始位置0x000000F0的位置我们可以找到真正的PE文件头标记”PE00”

我们可以观察到在e_lfanew和真正的PE头之间还有一些数据,这部分数据被称为DOS stub(即DOS块),DOS stub实际上是一个有效的exe,在不支持PE文件格式的操作系统中,它将显示一个错误提示,即”This program cannot be run in DOS mode”,DOS stub的数据大多由编译器自动生成,可根据自己的需要修改其中的内容,我们将IMAGE_DOS_HEADER与DOS stub合称为DOS文件头

IMAGE_NT_HEADERS

紧跟着DOS stub的就是真正的PE文件头了,这部分也被称为NT映像头,在一个有效PE文件中,其Signature字段被置为0x00004550(即ASCII的”PE00”,该值对应于winnt.h文件中的一个宏定义,IMAGE_NT_SIGNATURE),而紧跟在Signature字段之后的就是IMAGE_FILE_HEADER映像文件头,在此之后紧跟的是IMAGE_OPTIONAL_HEADER可选映像头

1
2
3
4
5
typedef struct _IMAGE_NT_HEADERS {
DWORD Signature; // PE文件标识
IMAGE_FILE_HEADER FileHeader;
IMAGE_OPTIONAL_HEADER32 OptionalHeader;
} IMAGE_NT_HEADERS32

在十六进制编辑器中,NT影响头的结构如下图所示,首个字段即为”PE00”标记,紧跟其后红色框所示部分就是映像文件头,紧跟映像文件头之后的蓝色框所示的部分就是可选映像头

IMAGE_FILE_HEADER

映像文件头中包含PE文件的一些基本信息,大小为20字节,其中较为重要的两个字段为NumberOfSections字段与SizeOfOptionalHeader字段,前者指出了区块Section的数量(同时也指明了IMAGE_SECTION_HEADER区块表的数量,因为每一个区块表记录了对应区块的相关信息),后者指出了IMAGE_OPTIONAL_HEADER可选映像头的大小

1
2
3
4
5
6
7
8
9
typedef struct _IMAGE_FILE_HEADER {
WORD Machine; // 运行平台
WORD NumberOfSections; // 区块数
DWORD TimeDateStamp; // 文件创建的日期和时间
DWORD PointerToSymbolTable; // 指向符号表(用于调试)
DWORD NumberOfSymbols; // 符号表中的符号的个数(用于调试)
WORD SizeOfOptionalHeader; // 可选映像头的大小
WORD Characteristics; // 文件属性
} IMAGE_FILE_HEADER

这里对字段进行详细的解释:
1. Machine:可执行文件的目标CPU类型,因为不同平台上指令集不同,因此需要该字段标识运行的平台,如Inter i386及其之后的处理器,该字段的值都为0x14C
2. NumberOfSections:区块数
3. TimeDateStamp:文件创建的时间,将该值翻译为易读字符串需要使用_ctime函数
4. PointerToSymbolTable:COFF符号表的文件偏移位置(FOA),现较为少见
5. NumberOfSymbols:如果有文件符号表,其指出了文件符号表中符号的数目
6. SizeOfOptionalHeader:可选映像头的大小,其大小通常依赖于文件是32位还是64位的,若是32位文件,这个值默认为0x00E0,若是64位文件,这个值默认为0x00F0,这表示了选映像头大小的最小值,因此该值是可以修改的
7. Characteristics:文件属性,其结果为若干个有效值的和,有效值在winnt.h定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#define IMAGE_FILE_RELOCS_STRIPPED           0x0001  // 不存在重定位信息
#define IMAGE_FILE_EXECUTABLE_IMAGE 0x0002 // 文件可执行 若为0,通常是链接时出问题
#define IMAGE_FILE_LINE_NUMS_STRIPPED 0x0004 // 行号信息被移除
#define IMAGE_FILE_LOCAL_SYMS_STRIPPED 0x0008 // 符号信息被移除
#define IMAGE_FILE_AGGRESIVE_WS_TRIM 0x0010 // Aggressively trim working set
#define IMAGE_FILE_LARGE_ADDRESS_AWARE 0x0020 // 应用程序可以处理超过2GB的地址,因为大部分数据库服务器需要很大的内存,而NT仅提供2GB给应用程序,因此从NT SP3开始,可以通过设置此参数,使应用程序分配2 ~ 3GB区域的地址(此部分原本为系统内存区)
#define IMAGE_FILE_BYTES_REVERSED_LO 0x0080 // 处理器的低位字节是相反的
#define IMAGE_FILE_32BIT_MACHINE 0x0100 // 目标平台为32为机器
#define IMAGE_FILE_DEBUG_STRIPPED 0x0200 // .DBG文件的调试信息被移除
#define IMAGE_FILE_REMOVABLE_RUN_FROM_SWAP 0x0400 // 如果映像文件在可移动介质中,则先复制到交换文件中再运行
#define IMAGE_FILE_NET_RUN_FROM_SWAP 0x0800 // 如果映像文件在网络中,则先复制到交换文件后再运行
#define IMAGE_FILE_SYSTEM 0x1000 // 系统文件
#define IMAGE_FILE_DLL 0x2000 // DLL文件
#define IMAGE_FILE_UP_SYSTEM_ONLY 0x4000 // 文件只能运行在单处理上
#define IMAGE_FILE_BYTES_REVERSED_HI 0x8000 // 处理器的高位字节是相反的

IMAGE_OPTIONAL_HEADER

虽然称为可选映像头,但该结构是必不可少的,其中定义了更多的数据,32位下最小大小为E0

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
typedef struct _IMAGE_OPTIONAL_HEADER {
WORD Magic; // 标志字
BYTE MajorLinkerVersion; // 链接器主版本号
BYTE MinorLinkerVersion; // 连接器次版本号
DWORD SizeOfCode; // 所有含有代码的区块的大小
DWORD SizeOfInitializedData; // 所有初始化数据区块大小
DWORD SizeOfUninitializedData; // 所有未初始化数据区块大小
DWORD AddressOfEntryPoint; // 程序执行入口RVA
DWORD BaseOfCode; // 代码区块起始RVA
DWORD BaseOfData; // 数据区块起始RVA
DWORD ImageBase; // 程序默认载入基地址
DWORD SectionAlignment; // 内存中块的对齐值
DWORD FileAlignment; // 磁盘文件中块的对齐值
WORD MajorOperatingSystemVersion; // 操作系统主版本号
WORD MinorOperatingSystemVersion; // 操作系统次版本号
WORD MajorImageVersion; // 用户自定义主版本号
WORD MinorImageVersion; // 用户自定义次版本号
WORD MajorSubsystemVersion; // 所需子系统主版本号
WORD MinorSubsystemVersion; // 所需子系统此版本号
DWORD Win32VersionValue; // 保留,通常设置为0
DWORD SizeOfImage; // 映像载入内存后的总大小
DWORD SizeOfHeaders; // DOS头、PE文件头、区块表的总大小
DWORD CheckSum; // 映像校验和
WORD Subsystem; // 文件子系统
WORD DllCharacteristics; // 显示DLL特性的旗标
DWORD SizeOfStackReserve; // 初始化时栈的大小
DWORD SizeOfStackCommit; // 初始化时实际提交的栈大小
DWORD SizeOfHeapReserve; // 初始化时保留的堆大小
DWORD SizeOfHeapCommit; // 初始化时实际保留的堆大小
DWORD LoaderFlags; // 调试相关,默认值为0
DWORD NumberOfRvaAndSizes; // 数据目录项的数量
IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES]; // 数据目录表数组
} IMAGE_OPTIONAL_HEADER32

这里对一些较为关键的字段进行详细的解释:
1. Magic:标志字,ROM映像为0x107,32位可执行映像为0x010B,64位可执行映像位0x020B
2. SizeOfCode:所有含有IMAGE_SCN_CNT_CODE属性的区块的总大小,必须是FileAlignment的整数倍,由编译器填写,通常情况下,大多数文件只有一个Code块,所以该字段与.text块的大小匹配
3. SizeOfUninitializedData:所有未初始化数据区块大小,装载程序需要在虚拟地址空间中位这些数据分配空间,这些块在磁盘文件中不占空间,在程序开始运行时没有指定值,未初始化数据通常在.bss块中
4. AddressOfEntryPoint:程序执行入口RVA。对于DLL,这个入口点在进程初始化和关闭时与线程创建和销毁时被调用,在大多数可执行文件中,这个地址不直接指向Main、WinMain或DllMain,而是指向运行时的库代码,并由它来调用上述函数
5. ImageBase:程序默认载入基地址,如果PE文件在这个地址载入,加载器将会跳过应用基址重定位的步骤
6. SectionAlignment:载入内存时,内存中块的对齐值,也就是说每个区块被载入的地址必定是本字段指定数值的整数倍,默认的对齐尺寸是目标CPU的页尺寸(通常是0x10000,也就是4KB)
7. FileAlignment:磁盘文件中块的对齐值,区块在磁盘文件中存储的首地址必定是本字段指定数值的整数倍,对于x86可执行文件,这个值常为0x200或0x1000,这是为了保证块总是从磁盘的扇区开始,该值必须是2的幂
8. SizeOfImage:映像载入内存后的总大小,即从ImageBase到最后一个块结束,且按照SectionAlignment对齐的大小
9. SizeOfHeaders:DOS头、PE文件头、区块表的总大小,按FileAlignment对齐
10. CheckSum:映像校验和,CheckSumMappedFile函数可以计算该值,通常情况下,普通的EXE文件该值为0,但内核模式的驱动程序和系统DLL必须有一个校验和
11. NumberOfRvaAndSizes:数据目录项的数量,该值至今一直为16
12. DataDirectory[16]:数据目录数组,由数个相同的IMAGE_DATA_DIRECTORY结构组成,其具体的结构如下

1
2
3
4
typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress; // 数据块的RVA
DWORD Size; // 数据块的大小
} IMAGE_DATA_DIRECTORY

数据目录表成员的结构如下所示

序号 表名 结构
0 Export Table IMAGE_DIRECTORY_ENTRY_EXPORT
1 Import Table IMAGE_DIRECTORY_ENTRY_IMPORT
2 Resources Table IMAGE_DIRECTORY_ENTRY_RESOURCE
3 Exception Table IMAGE_DIRECTORY_ENTRY_EXCEPTION
4 Security Table IMAGE_DIRECTORY_ENTRY_SECURITY
5 Base Relocation Table IMAGE_DIRECTORY_ENTRY_BASERELOC
6 Debug IMAGE_DIRECTORY_ENTRY_DEBUG
7 Copyright IMAGE_DIRECTORY_ENTRY_COPYRIGHT
8 Global Ptr IMAGE_DIRECTORY_ENTRY_GLOBALPTR
9 Thread Local Storage (TLS) IMAGE_DIRECTORY_ENTRY_TLS
10 Load Configuration IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG
11 Bound Import IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT
12 Import Address Table (IAT) IMAGE_DIRECTORY_ENTRY_IAT
13 Delay Import IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT
14 COM Descriptor IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR
15 保留,必须为0 -

IMAGE_SECTION_HEADER

区块表中记录了区块的具体信息,每个区块表分别指向了不同的区块实体,紧跟在 IMAGE_OPTIONAL_HEADER 之后,每个区块表大小都是40字节

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typedef struct _IMAGE_SECTION_HEADER {
BYTE Name[IMAGE_SIZEOF_SHORT_NAME]; // 8字节大小的块名
union {
DWORD PhysicalAddress;
DWORD VirtualSize;
} Misc; // 实际被使用的区块的大小,未对齐
DWORD VirtualAddress; // 该块装载到内存中的RVA
DWORD SizeOfRawData; // 在磁盘中区块的大小,已对齐
DWORD PointerToRawData; // 该块在磁盘中的偏移FOA
DWORD PointerToRelocations; // 在EXE中无意义,在OBJ文件中表示本块重定位信息表的偏移
DWORD PointerToLinenumbers; // 调试信息,行号表在文件中的偏移
WORD NumberOfRelocations; // 在EXE中无意义,在OBJ文件中表示本块在重定位表中重定位数量
WORD NumberOfLinenumbers; // 该块在行号表中的行号数量
DWORD Characteristics; // 块属性
} IMAGE_SECTION_HEADER

块属性中的一些重要字段值如下所示

1
2
3
4
5
6
7
8
#define IMAGE_SCN_CNT_CODE                   0x00000020  // 包含代码,通常与0x10000000一起设置
#define IMAGE_SCN_CNT_INITIALIZED_DATA 0x00000040 // 包含已初始化数据
#define IMAGE_SCN_CNT_UNINITIALIZED_DATA 0x00000080 // 包含未初始化数据
#define IMAGE_SCN_MEM_DISCARDABLE 0x02000000 // 该块可被丢弃,因为它一旦被载入,进程就不再需要它了,常见的可丢弃块是.reloc(重定位块)
#define IMAGE_SCN_MEM_SHARED 0x10000000 // 该块为共享块
#define IMAGE_SCN_MEM_EXECUTE 0x20000000 // 该块可执行,通常当0x00000020标志被设置时,该标志也被设置
#define IMAGE_SCN_MEM_READ 0x40000000 // 该块可读,可执行文件中总是设置该标志
#define IMAGE_SCN_MEM_WRITE 0x80000000 // 该块可写,若PE文件中没有设置该标志,装载程序就会将内存映像页标记为可读或可执行

在十六进制编辑器中的区块表信息如下图所示,可以观察到该exe文件包含4个区块表,其中四个区块的信息名称分别为

  • .text
  • .rdata
  • .data
  • .rsrc

区块解析

首先需要注意的是,区块名称只是为了方便辨识,但对于操作系统来说是无关紧要的,如当寻找输出表、输入表信息时,不应该默认到.text和.rdata区块中寻找,而是要严格依据数据目录数组DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES]中的信息进行查找,常见区块如下

名称 描述
.text 默认的代码区块,其中的内容全是指令代码
.data 默认的读、写区块,全局变量、静态变量通常放在此处
.rdata 默认的只读数据区块,程序较少用到该块中的数据,但至少有两种情况会用到,一是在Microsoft链接器产生的exe文件中,用于存放调试目录;二是用于存放说明字符串,如果程序的DEF文件中指定了DESCRIPTION,字符串就会在出现在该块中
.idata 输入表,包含其他外来DLL的函数及数据信息,通常将其合并到其他区块中,如.rdata
.edata 输出表,当创建一个输出API或数据的可执行文件时(如DLL),链接器会创建一个.exp文件,.exp文件将会包含一个.edata区块,并加入到最后的可执行文件中,通常将.edata合并到其他块中,如.text区块中
.rsrc 资源,包含模块的全部资源,例如图标、菜单、位图等,该区块是只读的,无论如何都不应该命名为为.rsrc以外的名字,也不能被合并到其他区块中
.reloc 可执行文件的基址重定位,通常只是DLL需要,而exe不需要,通常在Release模式下,链接器不会给exe文件加上基址重定位