PE基础知识(上)
PE文件的格式是一个很重要的基础知识,我希望有一天我的Blog可以适宜很多阶段的人学习,不管是小白还是初学者亦或者是回顾的人。
什么是PE文件
PE(Portable Executable)文件是一种可移植可执行文件格式,是Windows操作系统中应用程序(EXE)、动态链接库(DLL)和驱动程序(SYS)等二进制文件的标准文件格式。PE文件可以包含代码、数据、资源以及操作系统加载器在运行时执行程序所需的信息。
它是基于Unix的COFF(Common Object File Format)而来,用于存储编译后的中间文件。,COFF则是PE的前身,但PE文件并不仅仅是COFF文件,而是将COFF的某些特性扩展并融入了Windows特有的结构。在Windows中,PE文件实际上包含了一个COFF头(在PE Header中被称为“File Header”),因此可以说PE文件部分基于COFF格式。
在Unix和Linux中,COFF逐渐被ELF(Executable and Linkable Format)取代,因为ELF在功能和灵活性方面更强。
在Windows中,COFF演变成了PE(Portable Executable)格式,并得到了进一步扩展,增加了更多与Windows操作系统紧密结合的功能(例如导入/导出表、重定位表等)。
初识
我们使用十六进制编辑器 010 Editor打开一个PE文件,就是上图这个样子,当然使用WinHex等工具也是没有任何问题的。我们使用编辑器打开这个文件才可以最清晰根本的感受到这个文件在我们的硬盘中是什么样子的。
这里我们借用两张非常经典的图,可以清晰的看出我们的PE文件由哪些部分组成,首先可以看出我们可以分为两大块
- 文件头(Header)
- 节区(Sections)由很多个组成
在文件头中包含了所有的属性信息以及节区的索引信息,节区中则是我们的程序的数据、导入导出表、指令集等内容。
PE Header
我们将文件头分为五个小部分来说(对应导图):
- DOS头
- PE头
- 可选头
- 数据目录
- 节表
DOS头
这是我们PE文件最开始的部分,这部分是为了兼容DOS系统留下来的玩意儿。在Windows NT之前的Windows系统是基于dos操作系统内核, 为了兼容dos系统上可执行文件,Windows NT在设计可执行文件格式时保兼容了之前的格式。
我们把这部分再分成两个部分:
- DOS头(固定长度)
- DOS stub 存根(不固定长度)
DOS头的固定长度是64个字节,这是一个非常标准的结构体,定义如下:
1 |
|
IMAGE_DOS_HEADER
结构体
字段名称 | 类型 | 说明 |
---|---|---|
e_magic |
WORD |
魔术数:标识文件类型,固定为 0x5A4D (ASCII 表示为 “MZ”),表示这是一个 DOS 可执行文件。 |
e_cblp |
WORD |
文件最后一页的字节数:DOS 可执行文件中最后一页的有效字节数,不足 512 字节时用 0 填充。 |
e_cp |
WORD |
文件总页数:文件的总页数,每页为 512 字节。 |
e_crlc |
WORD |
重定位表项数:DOS 可执行文件中的重定位表条目数量。 |
e_cparhdr |
WORD |
段落数目:DOS 头部分所占的段落数量,每个段落为 16 字节(64 字节的 DOS 头通常为 4 个段落)。 |
e_minalloc |
WORD |
最小附加段数:程序加载时需要的最小附加段数。 |
e_maxalloc |
WORD |
最大附加段数:程序加载时允许的最大附加段数。 |
e_ss |
WORD |
初始 SS(栈段)值:程序启动时的栈段基址的偏移量(相对于加载地址)。 |
e_sp |
WORD |
初始 SP(栈指针)值:程序启动时的栈指针值。 |
e_csum |
WORD |
校验和:文件的简单校验和,用于检测文件的完整性(通常未使用)。 |
e_ip |
WORD |
初始 IP(指令指针)值:程序启动时的指令指针值。 |
e_cs |
WORD |
初始 CS(代码段)值:程序启动时的代码段基址的偏移量(相对于加载地址)。 |
e_lfarlc |
WORD |
重定位表偏移:文件中重定位表的起始地址(相对于文件起始位置的偏移量)。 |
e_ovno |
WORD |
覆盖号:指定覆盖文件编号(通常为 0)。 |
e_res[4] |
WORD[4] |
保留字段:用于未来扩展(通常未使用,填充为 0)。 |
e_oemid |
WORD |
OEM 标识符:表示 OEM 制造商(通常未使用)。 |
e_oeminfo |
WORD |
OEM 信息:与 e_oemid 对应的附加信息(通常未使用)。 |
e_res2[10] |
WORD[10] |
保留字段:进一步的扩展字段(通常未使用,填充为 0)。 |
e_lfanew |
LONG |
新 PE 头偏移量:从文件开头到新 PE 头(即 IMAGE_NT_HEADERS )的偏移量。 |
重要字段解析
**
e_magic
**:- 确认文件是一个 DOS 可执行文件,Windows 系统通过这个字段来识别文件格式。这是我们判断是不是PE文件的一个重要参考信息。
- “MZ” 是 MS-DOS 的创始人 Mark Zbikowski 的首字母缩写。
**
e_lfanew
**:- 关键字段:标识 PE 头在文件中的偏移量。
- 当 Windows 发现
e_magic
是合法的 DOS 文件时,会读取e_lfanew
,以定位真正的 PE 头(IMAGE_NT_HEADERS
)。(灰常重要!) - 对现代 PE 文件来说,这是最重要的连接点。
**
e_res
和e_res2
**:- 这些保留字段主要用于填充,在现代文件中一般未使用。
DOS stub 存根:
- 紧跟在 DOS 头之后,是一个小程序,主要用于在 MS-DOS 环境下显示消息或运行一些简单的代码。
- 通常的内容是类似“此程序需要 Windows 环境才能运行”的(英文)提示。
其实DOS Stub是一个16位程序
1 |
|
反编译分析一下:
1 |
|
- 作用:将代码段寄存器(
CS
)的值压入堆栈,然后弹出到数据段寄存器(DS
)。 - 原因:在16位环境下,必须手动设置
DS
指向与CS
相同的段地址,确保后续数据访问正确。
1 |
|
- 作用:
- 将
DX
设置为偏移地址0Eh
,该地址指向存储在.COM
文件中的字符串数据。 - 将
AH
设置为9
,这表示调用 DOS 的int 21h
中断服务功能号为09h
(显示字符串)。 - **
int 21h
,功能号09h
**:显示以$
结尾的字符串。这里的字符串是"This program cannot be run in DOS mode.\r\n"
。
- 将
- 结果:在 DOS 模式下运行时,输出该字符串到屏幕。
1 |
|
- 作用:
- 将
AX
设置为4C01h
,其中4C
是 DOS 的中断功能号,用于终止程序。 01h
是返回代码,表示程序的退出状态。- **
int 21h
,功能号4Ch
**:终止程序并返回控制权给操作系统。
- 将
16位 DOS Stub 的完整流程
- 设置段寄存器:确保
DS
指向代码段,便于访问字符串数据。 - **调用
int 21h
中断,功能号09h
**:显示字符串"This program cannot be run in DOS mode."
。 - **调用
int 21h
中断,功能号4Ch
**:终止程序并返回状态。
字符串部分
位于 0Eh
偏移处的字符串:
1 |
|
- **
$
**:字符串终止符,这是int 21h
功能号09h
所需的格式。 - **
0D 0A
**:回车换行符,对应\r\n
。
int 21h
中断的作用
int 21h
是 MS-DOS 中的核心中断,用于提供多种系统服务功能。不同的功能由 AH
的值决定,例如:
- **
AH = 09h
**:显示字符串。 - **
AH = 4Ch
**:退出程序并返回状态。
DOS Stub 在运行时调用 int 21h
,确保了程序在 MS-DOS 环境中的最低兼容性,允许输出提示并优雅退出。
PE文件头
这是PE文件真正的脑袋,它实际上是在一个更大的文件头内部,也就是NT头。在DOS头的最后一个字段指示了NT头的位置,NT头的定义如下:
1 |
|
Signature
第一部分标识符也就是IMAGE_NT_HEADERS结构体的第一个字段,其为一个DWORD,占四个字节,固定为50450000,就是ASCII字符的PE。
加载器从 DOS 头的 e_lfanew
偏移跳转到 IMAGE_NT_HEADERS
,通过读取 Signature
验证这是一个有效的 PE 文件。
File Header (IMAGE_FILE_HEADER)
第二部分长度为20个字节,包含了一些重要信息,结构定义如下:
1 |
|
字段名称 | 类型 | 说明 |
---|---|---|
Machine |
WORD |
目标平台:描述文件适用于哪种 CPU 架构。常见值包括: - 0x14c (Intel 386/32位)- 0x8664 (AMD x64/64位)。 |
NumberOfSections |
WORD |
节的数量:PE 文件中 Section Table (节表)的数量,定义了文件的结构和内容分布(如 .text 、.data )。 |
TimeDateStamp |
DWORD |
时间戳:文件创建或最后修改的时间,格式为自 1970 年 1 月 1 日以来的秒数(UNIX 时间戳)。用于版本管理和调试。 |
PointerToSymbolTable |
DWORD |
符号表指针:指向文件中的符号表位置(通常为 0,现代 PE 文件不使用符号表)。 |
NumberOfSymbols |
DWORD |
符号数量:符号表中的符号数量(通常为 0,现代编译器不会填充)。 |
SizeOfOptionalHeader |
WORD |
可选头大小:IMAGE_OPTIONAL_HEADER 的大小,通常为 224 字节(32 位)或 240 字节(64 位)。 |
Characteristics |
WORD |
文件特性:描述 PE 文件的属性,是一组标志的组合。例如: - 0x0002 :可执行文件exe- 0x2000 :DLL 文件- 0x0100 :32 位机代码 |
可选头 Optional Header (IMAGE_OPTIONAL_HEADER
)
IMAGE_OPTIONAL_HEADER
是 IMAGE_NT_HEADERS
的第三部分,虽然名字叫 “Optional”(可选头),实际上对现代 PE 文件是必不可少的。
可选头结构
1 |
|
关键字段解析
**
Magic
**:- 指定文件是 32 位还是 64 位格式:
0x10B
:PE32(32 位)。0x20B
:PE32+(64 位)。
- 指定文件是 32 位还是 64 位格式:
**
AddressOfEntryPoint
**:- 程序入口点的相对虚拟地址(RVA),表示执行开始的地址,通常在
.text
节中。
- 程序入口点的相对虚拟地址(RVA),表示执行开始的地址,通常在
**
ImageBase
**:- 程序在内存中的首选加载地址,也就是这个PE文件要被加载到进程中的哪个地址,exe文件一般是0x00400000,dll文件一般是0x10000000(只是建议值)。如果加载器无法在该地址加载,会通过重定位表调整。
**
SizeOfImage
**:- 程序在内存中加载后的总大小(包括所有节),对齐到
SectionAlignment
的倍数。
- 程序在内存中加载后的总大小(包括所有节),对齐到
**
DataDirectory
**:- 一个数据目录数组,包含重要的信息,比如导入表、导出表、资源表等。
数据目录(IMAGE_DATA_DIRECTORY)
数据目录是 PE 文件可选头(IMAGE_OPTIONAL_HEADER
)的最后一部分。它是一个包含 16 个元素的数组,每个元素表示 PE 文件的一部分动态链接信息或元数据的起始地址和大小。
IMAGE_DATA_DIRECTORY 的结构
1 |
|
字段名称 | 类型 | 描述 |
---|---|---|
VirtualAddress |
DWORD |
数据块的相对虚拟地址(RVA),指向该数据块在内存中的位置。 |
Size |
DWORD |
数据块的大小(以字节为单位),描述该数据块占用的大小。 |
数据目录的 16 个条目
以下是 IMAGE_NUMBEROF_DIRECTORY_ENTRIES
(16个目录)的详细说明及用途:
索引 | 名称 | 描述 |
---|---|---|
0 | EXPORT Table | 导出表:包含此模块向其他模块导出的函数和变量信息(如 DLL 的导出函数)。 |
1 | IMPORT Table | 导入表:列出此模块所需的外部模块及其导入的函数或变量信息。 |
2 | RESOURCE Table | 资源表:包含模块的资源信息(如图标、字符串、菜单等)。 |
3 | EXCEPTION Table | 异常表:记录程序的异常处理信息(如 SEH 结构,主要用于 C++ 和 Windows 内核异常处理)。 |
4 | CERTIFICATE Table | 证书表:包含与模块签名验证相关的数字证书信息(用于代码签名)。 |
5 | BASE RELOCATION Table | 基址重定位表:当模块加载地址与默认基址不同,需要通过此表调整内存地址。 |
6 | DEBUG Table | 调试信息表:包含与调试相关的信息(如符号表、调试器使用的数据)。 |
7 | ARCHITECTURE | 架构特定数据:保留字段,通常未使用。 |
8 | GLOBAL POINTER | 全局指针:保留字段,通常未使用。 |
9 | TLS Table | TLS 表:线程本地存储(Thread Local Storage)的初始化数据。 |
10 | LOAD CONFIGURATION Table | 加载配置表:包含与模块加载配置相关的信息(如安全功能、堆栈保护等)。 |
11 | BOUND IMPORT Table | 绑定导入表:优化加载时间的导入表,记录了依赖模块的绑定信息及其时间戳。 |
12 | IMPORT Address Table (IAT) | 导入地址表:包含实际导入函数的地址(运行时由加载器填充)。 |
13 | DELAY IMPORT Descriptor | 延迟导入表:延迟加载模块的导入表(在运行时首次调用函数时加载模块)。 |
14 | COM Descriptor | COM 描述符表:描述与 .NET 托管代码相关的信息(CLR 头)。 |
15 | Reserved | 保留字段:目前未使用,通常填充为 0。 |
关键数据目录的详细作用
1. EXPORT Table(导出表)
- 用于描述模块(通常是 DLL)向外部模块提供的函数和变量。
- 常见场景:一个 DLL 文件被多个程序调用,其导出表列出了 DLL 提供的 API。
2. IMPORT Table(导入表)
- 描述当前模块依赖的外部模块以及需要调用的函数。
- 典型用途:列出依赖的 DLL 及其函数,例如 Kernel32.dll 中的
CreateFile
。
3. RESOURCE Table(资源表)
- 包含程序的资源数据,如图标、位图、字符串表、菜单和对话框。
- 常见场景:程序的用户界面元素往往通过资源表加载。
4. BASE RELOCATION Table(基址重定位表)
- 当模块未能加载到默认基址时,重定位表帮助调整代码中涉及的内存地址。
- 典型用途:调整绝对地址指令的操作数。
5. DEBUG Table(调试表)
- 包含与调试相关的数据,用于帮助调试器识别代码结构。
- 通常包括调试符号、代码行号等信息。
6. TLS Table(线程本地存储表)
- 描述线程私有的全局变量。
- 用途:为每个线程分配独立的存储空间。
7. LOAD CONFIGURATION Table(加载配置表)
- 包含安全功能信息,例如:
- 堆栈保护(GS Cookie)。
- 数据执行保护(DEP)。
- 结构化异常处理(SEH)过滤器。
8. DELAY IMPORT Descriptor(延迟导入描述符)
- 支持模块的延迟加载。
- 应用场景:减少程序启动时的加载开销,仅在首次使用函数时加载依赖模块。
9. COM Descriptor(CLR 头)
- 主要用于托管代码(.NET 程序),描述与公共语言运行时(CLR)相关的信息。
- 如果 PE 文件是 .NET 程序,CLR 头会在此描述。
节表(Section Table)
PE 文件中的节(Section)是实际存放数据的地方,例如代码、全局变量、资源等。节表位于 PE 文件头之后,由多个结构体组成,每个结构体描述一个节的信息。
节表结构体定义
1 |
|
结构体字段解析
字段名称 | 类型 | 描述 |
---|---|---|
Name |
BYTE[8] |
节的名称,最多为 8 个字符,常见的节名如:.text (代码段)、.data (数据段)、.rsrc (资源段)等。 |
Misc.VirtualSize |
DWORD |
节在内存中的实际大小,未对齐。用于运行时的内存分配。 |
VirtualAddress |
DWORD |
节在内存中的起始地址(RVA,相对虚拟地址),相对于镜像基址。 |
SizeOfRawData |
DWORD |
节在文件中对齐后的大小,通常大于等于 Misc.VirtualSize 。 |
PointerToRawData |
DWORD |
节在文件中的偏移地址。 |
PointerToRelocations |
DWORD |
指向重定位信息表的偏移地址(对于可执行文件通常为 0)。 |
PointerToLinenumbers |
DWORD |
指向行号信息表的偏移地址(通常为 0,现代 PE 文件不使用)。 |
NumberOfRelocations |
WORD |
重定位表中的条目数量(通常为 0,现代 PE 文件不使用)。 |
NumberOfLinenumbers |
WORD |
行号信息数量(通常为 0,现代 PE 文件不使用)。 |
Characteristics |
DWORD |
节的属性标志,定义节的权限和特性(如是否可读、可写、可执行)。 |
关键字段详解
**
Name
**:- 用于标识节的作用或内容。
- 常见节名称:
.text
:代码段,存储可执行代码。.data
:已初始化的数据段。.bss
:未初始化的数据段。.rdata
:只读数据段(常量)。.rsrc
:资源段,存储应用程序资源(如图标、对话框)。.reloc
:重定位表。
**
VirtualAddress
**:- 节的相对虚拟地址(RVA),表示节加载到内存后的位置。
- 计算方式:镜像基址(ImageBase) + VirtualAddress。
SizeOfRawData
和 **PointerToRawData
**:- **
SizeOfRawData
**:节在文件中的对齐后的大小。 - **
PointerToRawData
**:节在文件中的起始偏移。 - 注意:节在文件中的大小可能会比其实际使用的内存大小(
VirtualSize
)更大。
- **
**
Characteristics
**:- 描述节的属性,常见标志:
0x20000000
:包含代码。0x40000000
:包含初始化数据。0x80000000
:包含未初始化数据。0x02000000
:可执行。0x04000000
:可读。0x08000000
:可写。
- 描述节的属性,常见标志:
节表的数量
节表的数量由 PE 文件头(IMAGE_FILE_HEADER
)的 NumberOfSections
字段指定。每个节都有一个对应的 IMAGE_SECTION_HEADER
结构体。
节表的作用
节表是 PE 文件的重要部分,它为加载器提供以下信息:
- 定位节内容:
- 文件中的偏移(
PointerToRawData
)指向节的内容。 - 内存中的地址(
VirtualAddress
)指向节在内存中的加载位置。
- 文件中的偏移(
- 节的大小和属性:
- 加载器根据
SizeOfRawData
和VirtualSize
确定内存分配。 - 根据
Characteristics
设置节的读写和执行权限。
- 加载器根据
- 指引运行时行为:
- 加载器使用
.reloc
节处理重定位。 - 代码从
.text
节执行,数据从.data
节加载,资源从.rsrc
节访问。
- 加载器使用
常见 PE 文件节名称 的表格
节名称 | 用途/描述 |
---|---|
.text |
代码段:存储可执行代码,是程序运行的核心部分。 |
.data |
已初始化数据段:存储全局变量、已初始化的静态变量等数据。 |
.bss |
未初始化数据段:存储未初始化的全局变量和静态变量,加载时初始化为零。 |
.rdata |
只读数据段:存储只读数据,如常量字符串和调试目录。 |
.rsrc |
资源段:存储资源信息,如图标、对话框、字符串表等。 |
.reloc |
重定位表段:存储重定位信息,当加载地址不同于首选基址时,用于修正内存地址。 |
.edata |
导出表段:存储模块导出的函数和变量信息(如 DLL 的导出函数)。 |
.idata |
导入表段:存储模块所需的外部函数和变量信息(如 DLL 的导入函数)。 |
.pdata |
异常处理段:存储异常处理和运行时函数调度信息,通常用于 SEH(结构化异常处理)。 |
.debug |
调试段:存储调试信息,如符号表、代码行号等,通常供调试器使用。 |
.tls |
线程本地存储段:存储 TLS(Thread Local Storage)初始化数据,为线程分配私有的全局变量。 |
.crt |
运行时支持段:存储 C/C++ 运行时库初始化代码,通常在程序启动和结束时执行。 |
.sxdata |
安全执行数据段:用于存储与 Windows 安全机制相关的数据。 |
.gfids |
全局函数标识段:存储函数指针,用于全局范围内的功能识别(在某些链接器中出现)。 |
.orpc |
对象 RPC(远程过程调用)段:存储与 COM 对象相关的远程调用信息。 |
.ndata |
非标准数据段:存储与某些平台或工具链相关的非标准数据(可能工具链特定)。 |
.eh_frame |
异常处理帧段:存储 C++ 异常处理的帧信息,通常在混合代码中使用。 |
.symtab |
符号表段:存储调试符号表,包含符号名、地址等信息(较少见于现代 PE 文件)。 |