PE

来自osdev
跳到导航 跳到搜索
可执行文件格式
Microsoft

16 bit:
COM
MZ
NE
32/64 bit:
PE
Mixed (16/32 bit):
LE

*nix

对于Windows 95/NT,需要新的可执行文件类型。 于是,“PE”可移植可执行文件(Portable Executable)诞生了,目前仍在使用中。 与以前的版本不同,WIN-PE是一种真正的32位文件格式,支持可重定位代码。 它确实区分了TEXT,DATA和BSS(译者注:参看可执行格式)。 事实上,它是COFF格式的一个低级版本。

如果您确实在Windows计算机上设置了Cygwin环境,“PE”是Cygwin GCC工具链的目标格式,在尝试将Cygwin下构建的部件与Linux或BSD(默认情况下使用ELF目标)下构建的部件链接时,这会引起一些让人头痛的问题。(提示:您必须构建GCC交叉编译器

PE格式用于Windows 95及更高版本、Windows NT 3.1及更高版本、Mobius、ReactOS和UEFI。 PE格式还用作Microsoft .Net CLI和Mono的.NET程序集的容器。 在这种情况下,它实际上并不存储可执行数据,而是.Net元数据,附带IL。

深入PE文件

下面将尝试解释构成PE文件的各种概念和部分,而不是其中的确切数据结构,因为这肯定会占用太多篇幅。 PE文件相关的大多数资料往往只是在你面前罗列一堆数据结构,而没有充分解释它们的用途。 因此,通过阅读以下内容并了解PE文件的组成,您将更好地了解预期内容以及如何利用PE文件资料。

概述

PE文件由几个部分组成。下面会对它们进行简要总结,然后在每个部分上深入更多细节。 以下是字段类型的定义。PE文件以小端顺序存储,与x86相同的字节顺序。

An overview of the format

DOS存根

PE格式以MS-DOS存根(一个头加上可执行代码)开始,使其成为有效的MS-DOS可执行文件。 MS-DOS标头以MagicCode 0x5A4D开始,长度为64字节,后跟实模式可执行代码。 几乎普遍使用的标准存根长度为128字节(包括头和可执行代码),只输出“This program cannot be run in DOS mode.”。“尽管许多支持PE文件的工具是硬编码的,希望PE头在中正好以128字节开始,但这是不正确的,因为在某些链接器中,包括Microsoft自己的Link,都可以用自己选择的一个内容来替换MS-DOS存根,许多旧程序这样做是为了允许开发人员将MS-DOS和Windows版本捆绑到单个文件中。 正确的方法是读取位于0x3C(通常称为e_lfanew的字段)的MS-DOS头中以前保留的4字节地址,该地址包含找到PE文件签名的地址,后面紧跟的是PE头。 通常这是一个非常标准的值(大多数情况下,默认link.exe存根会将此字段设置为0xE8)。微软似乎建议将PE头对齐在8字节的边界上(http://msdn.microsoft.com/en-us/gg463119.aspx,第10页,图1)。

PE头

PE头包含与整个文件有关的信息,而不是稍后将出现的独立片段信息。 最小裸头包含一个4字节的签名(0x00004550)、内部可执行代码的机器类型/体系结构、一个时间戳、一个指向符号的指针,以及各种标志(文件是否为可执行文件、DLL、应用程序能否处理2GB以上的地址、是否需要将文件复制到交换文件(如果从可移除设备运行),等等)。 除非你使用一个真正精简的静态链接的PE文件,为了节省内存,只有一个硬编码的入口点,而不使用任何资源,否则仅仅分析PE头是不够的。

// 1 byte aligned
struct PeHeader {
	uint32_t mMagic; // PE\0\0 or 0x00004550
	uint16_t mMachine;
	uint16_t mNumberOfSections;
	uint32_t mTimeDateStamp;
	uint32_t mPointerToSymbolTable;
	uint32_t mNumberOfSymbols;
	uint16_t mSizeOfOptionalHeader;
	uint16_t mCharacteristics;
};

可选头

可选PE头紧跟在标准PE头之后。 它的大小在PE标头中指定,您也可以使用它来判断可选头是否存在。 可选PE头以表示体系结构的2字节magic code开始(0x010B表示PE32,0x020B表示PE64,0x0107 ROM)。 这可以与机器类型一起使用,以便在PE头中查看,以检测PE文件是否是在兼容系统上运行。 还有一些其他有用的与内存相关的变量,包括代码和数据的大小和虚拟基地址,以及应用程序的版本号(完全由用户指定,一些更新实用程序使用它来检测是否有新版本可用)、入口点和目录数(见下文)。

可选头的部分是NT-specific。 这包括子系统(控制台、驱动程序或GUI应用程序)、要保留多少栈和堆空间,以及所需的最低操作系统、子系统和Windows版本。 您可以根据操作系统的需要使用自己的值来实现所有这些功能。

// 1 byte aligned
struct Pe32OptionalHeader {
	uint16_t mMagic; // 0x010b - PE32, 0x020b - PE32+ (64 bit)
	uint8_t  mMajorLinkerVersion;
	uint8_t  mMinorLinkerVersion;
	uint32_t mSizeOfCode;
	uint32_t mSizeOfInitializedData;
	uint32_t mSizeOfUninitializedData;
	uint32_t mAddressOfEntryPoint;
	uint32_t mBaseOfCode;
	uint32_t mBaseOfData;
	uint32_t mImageBase;
	uint32_t mSectionAlignment;
	uint32_t mFileAlignment;
	uint16_t mMajorOperatingSystemVersion;
	uint16_t mMinorOperatingSystemVersion;
	uint16_t mMajorImageVersion;
	uint16_t mMinorImageVersion;
	uint16_t mMajorSubsystemVersion;
	uint16_t mMinorSubsystemVersion;
	uint32_t mWin32VersionValue;
	uint32_t mSizeOfImage;
	uint32_t mSizeOfHeaders;
	uint32_t mCheckSum;
	uint16_t mSubsystem;
	uint16_t mDllCharacteristics;
	uint32_t mSizeOfStackReserve;
	uint32_t mSizeOfStackCommit;
	uint32_t mSizeOfHeapReserve;
	uint32_t mSizeOfHeapCommit;
	uint32_t mLoaderFlags;
	uint32_t mNumberOfRvaAndSizes;
};

数据目录(Data Directories)

从技术上讲,数据目录是可选头的一部分,指向数据目录的条目列表(仅在可执行映像和DLL中)。 由于可选头的大小可能会有所不同,因此您只需关注现有的和预期的目录,因为将来可能会在PE规范中添加新的数据目录 (例如.Net就是后来添加的)。 每个数据目录在可选头中被引用为一个8字节的条目。 前4个字节是目录的相对虚拟地址(Relative Virtual Address),简称RVA(请参见下面的部分),最后4个字节是目录的大小。

条目指向的每个数据目录都有自己的格式。 数据目录用于描述动态链接的导入表、嵌入在PE文件中的资源表、调试信息(行号和断点)、CLI .Net头。

Position (PE/PE32+) Section
96/112 导出表的地址和大小。格式与.edata相同
104/120 导入表的地址和大小。格式与.idata相同
112/128 资源表的地址和大小。格式与.rsc相同
120/136 异常表的地址和大小。格式与.pdata相同
128/144 属性证书表偏移量(不是RVA)和大小。另见 签名PE如下
136/152 基重定位表的地址和大小。格式与.reloc相同
144/160 调试数据的起始地址和大小。格式.debug与相同
152/168 架构(Architecture),保留MBZ
160/176 全局指针寄存器中要存储的值的RVA。此结构的成员size必须设置为零。
168/184 线程本地存储(thread local storage - TLS)表地址和大小。格式与.tls相同

节(Sections)

PE文件由节组成,这些节的内容包括名称、文件内的偏移量、要复制到的虚拟地址,以及文件和虚拟内存中节的大小(可能不同,在这种情况下,差异应0秒清除),以及相关标志。 节通常遵循通用命名(“.text”、“.rsrc”等),但这在链接器之间也可能有所不同,并且在某些情况下可以是用户定义的,因此最好依靠标志来判断节是可执行的还是可写的。 但是,如果您希望将自定义数据嵌入到可执行文件中,那么将其放入节中并通过节的名称进行标识是一个好主意,这样您就不必更改PE格式,并且您的可执行文件将与PE工具保持兼容。

相对虚拟基地址(Relative Virtual Base)是PE文档中经常出现的一个短语。 RVA是加载到内存中后存在的地址,而不是文件中的偏移量。 如果要从RVA计算出文件地址,又同时不实际将节加载到内存中,可以使用节条目表。 通过使用表中每个节的虚拟地址和大小,就可以找到RVA所属的节,然后减去节的虚拟地址和文件偏移量之间的差值,就可以得到RVA在文件中的位置。

节头

每个节在节头表中都有一个条目。

struct IMAGE_SECTION_HEADER { // size 40 bytes
	char[8]  mName;
	uint32_t mVirtualSize;
	uint32_t mVirtualAddress;
	uint32_t mSizeOfRawData;
	uint32_t mPointerToRawData;
	uint32_t mPointerToRelocations;
	uint32_t mPointerToLinenumbers;
	uint16_t mNumberOfRelocations;
	uint16_t mNumberOfLinenumbers;
	uint32_t mCharacteristics;
};

在asm linkage中

如果在nasm中声明如下代码块:

segment .code
aAsmFunction:
;Do whatever
mov BYTE[aData], 0
ret
segment .data
aData: db 0xFF

segments将以节-sections的方式出现。 使用此选项可以将C和Asm分开,因为链接器不会自动合并.code.text两个节,这是C编译器的正常输出。

位置独立代码(Position Independent Code)

如果每个节都指定了将其加载到哪个虚拟地址,那么您可能想知道,在一个虚拟地址空间中,多个DLL如何能够存在而不发生冲突。 的确,在PE文件(DLL或其他)中找到的大多数代码都是位置相关的,并且链接到特定的地址。 但是,为了解决这个问题,存在一个称为重定位表(Relocation Table)的结构,该结构附加到每个节条目。 该表基本上是一个巨大的长列表,记录了在该节中的每个存储地址,因此您可以将其偏移到节被加载到的位置。

因为地址可以跨节边界指向,所以应该在将每个节加载到内存中后进行重新定位。 然后在每个节上重复,遍历重定位表中的每个地址,找出RVA存在于哪个节中,并添加/减去该节的链接虚拟地址和加载到其中的节的虚拟地址之间的偏移量。(译者注:这样重新安排节中的地址指向,使它们在内存中安排妥当)

带属性证书表(Attribute Certificate Table)的已签名PE

许多PE可执行文件(最明显的是所有Microsoft更新)都使用证书签名。 这些信息存储在属性证书表中,由数据目录的第5个条目指向。 重要的是,对于属性证书表,不存储RVA,而是存储一个简单的文件偏移量。 格式为串联签名,每个签名具有以下结构:

Offset Size Field Description
0 4 dwLength 指定属性证书项的长度。
4 2 wRevision 包含证书版本号magic 0x0200 (WIN_CERT_REVISION_2_0)
6 2 wCertificateType 指定bCertificate中的内容类型, magic 0x0002 (WIN_CERT_TYPE_PKCS_SIGNED_DATA)
8 x bCertificate 包含PKCS#7 SignedData结构

对于Secure Boot,在EFI下的[Secure Boot]这样的签名是必须的。 PE格式允许在一个PE文件中嵌入多个证书(这一点毫无价值),但UEFI固件实现通常只允许一个,必须由Microsoft KEK签名。 如果固件允许安装更多KEK(不典型),那么您也可以使用其他证书。

bCertificate数据是PKCS#7签名和证书,用ASN.1 格式。 微软使用signtool.exe创建这些签名项,但存在一个名为sbsigntool的工具(也可在github和debian包中)。

CLI / .Net

CLI与PE格式一起工作。 它不是该格式的扩展,而是作为其自身的格式存在于一种格式中,具有完全不同的存储表和值的方式。 所有的.Net数据和头存在于加载到内存中的节中(它们被加载到内存中,因为CLI涉及大量的语言反射,需要元数据而不影响磁盘)。 第二个原因是.Net元数据存在于节中,而不是PE头中,这是因为PE加载程序实际上完全不能理解.Net。 (例外:有一个数据目录条目指向CLI头的RVA,因此工具可以轻松访问.Net数据,而无需将其加载到虚拟内存中。) 事实上,我非常怀疑Windows内核是否有任何.Net的概念。 .Net works是通过动态链接来实现的.Net运行时(mscoree.dll),并将入口点设置为解析为mscoree.dll内部位置的符号(_CorExeMain)而不是本地可执行文件。 这意味着Windows CE、WINE和ReactOS都可以加载.Net程序集,.Net framework可以在没有任何特定代码的情况下安装。

加载PE文件

加载PE文件非常简单;

1. 从头中提取入口点、堆和栈大小。

2. 遍历每个节并将其从文件复制到虚拟内存中(虽然不是必需的,但最好将内存中的节大小与文件中的节大小之间的差异清除为0)。

3. 通过在符号表中查找正确的条目来查找入口点的地址。

4. 在该地址创建一个新线程并开始执行!

要加载需要动态DLL的PE文件,可以执行相同的操作, 但需要检查导入表(由数据目录引用)以查找所需的符号和PE文件, 从其它PE文件中的导出表(也被数据目录引用),可以查看这些符号的位置,并在将该PE的节加载到内存中(并重新定位它们!),然后将它们匹配起来最后,请注意,还必须递归解析每个DLL的导入表, 一些DLL可以使用技巧来引用DLL中的一个符号来加载它,所以请确保加载程序不会陷入循环! 注册加载的符号并使其全局化可能是一个很好的解决方案。(译者注:感觉这里有点想树形依赖关系管理)

检查MachineMagic字段的有效性也是一个好主意,而不仅仅是PE签名。 这样,您的加载程序就不会尝试将64位二进制文件加载到32位模式(这肯定会导致异常)。

64 bit PE

64位PE与普通PE极为相似,但机器类型(如果为AMD64)为0x8664,而不是0x14c。 此字段直接位于PE签名之后。 magic number 也从0x10b更改为0x20b。 magic field位于可选头的起始位置。 另外,可选头的BaseOfData成员不存在。 这是因为ImageBase成员已扩展到64位。 所以需要删除BaseOfData以腾出空间

另见

de:Microsoft Portable Executable and Common Object File Format