在正式讲解PE文件格式之前,我们有必要先熟悉和PE相关的一些基本概念,以便于更好的理解和掌握PE文件格式。
本节必须掌握的知识点:
地址
指针
数据目录项
节
对齐方式
字符串编码格式
3.1.1 地址
■在PE文件中涉及到四类地址
●VA虚拟内存地址
VA(Virtual Address)是指虚拟内存地址,它是在操作系统中使用的一种抽象地址空间。虚拟内存是一种内存管理技术,它允许程序使用连续的、逻辑上连续的内存地址来访问内存,而不考虑实际的物理内存布局。
在虚拟内存系统中,每个进程拥有自己的独立的虚拟地址空间,进程中的程序代码、数据和堆栈都使用虚拟内存地址来进行访问。这些虚拟内存地址被映射到物理内存中的实际存储位置。
虚拟内存地址空间通常是由一定的位数来表示,例如32位或64位。在32位系统中,虚拟内存地址空间的范围是从0x00000000到0xFFFFFFFF(0到4GB)。在64位系统中,虚拟内存地址空间的范围更大,可以达到2的64次方(即18,446,744,073,709,551,616)个地址。
虚拟内存地址和物理内存地址之间的映射是通过操作系统的内存管理单元(Memory Management Unit,MMU)来实现的。MMU通过地址映射表将虚拟内存地址转换为物理内存地址,以便正确地访问和管理内存。
虚拟内存的使用带来了许多优势,包括更大的地址空间、内存隔离、内存保护和灵活的内存分配等。它使得操作系统能够更有效地管理内存资源,并为每个进程提供独立的地址空间,提高了系统的稳定性和安全性。
我们在程序源代码中写的地址都是虚拟地址,具有R3权限的普通应用程序源代码只需要写32位或64位偏移地址。
1.32位Windows系统给定默认的段选择子,通过段选择子在段描述符表中找到对应的段描述符。将32位(4GB虚拟空间内的)偏移地址加上段描述符中的32位段基址(通常为0段)得到32位线性地址,我们在OD调试器中看到的虚拟地址就是线性地址。线性地址再由MMU内存管理器通过地址映射表将虚拟内存地址转换为物理内存地址。
2.在64位Windows程序中,段寄存器的使用已经大大减少,甚至可以说几乎没有。这是因为在64位寻址模式下,采用了平坦模型(Flat Model),寻址空间足够大,整个虚拟地址空间被视为一片连续的地址范围,不再需要段选择。32位保护模式下分段的寻址方式在64位寻址模式下已经被废弃。在64位Windows程序中,虚拟内存地址直接映射到物理内存,程序可以直接使用虚拟地址来访问内存,无需进行段选择。64位寻址模式使用了分页机制,通过分页机制将虚拟地址映射到物理地址,实现了地址的转换和内存的管理。因此64位程序直接写64为偏移地址就可以了。
将32位或64位程序源代码编译为二进制可执行文件(PE文件)后,PE文件内与此对应的地址同样也是虚拟地址。只不过PE文件中的虚拟地址将VA地址分为两部分,由基址和相对虚拟内存地址构成。即:
VA(虚拟内存地址)=进程的基址+RVA(相对虚拟内存地址)
●RVA相对虚拟内存地址
RVA(Relative Virtual Address)是指相对虚拟内存地址,它用于描述在可执行文件(如EXE、DLL)的内部,某个特定的数据或代码的地址相对于该文件的基地址(Image Base)的偏移量。
在Windows操作系统中,可执行文件包含了各种节(Section),每个节都有自己的起始地址和大小。RVA是相对于所属节起始地址的偏移量,它是一个相对值,而不是绝对的内存地址。
当可执行文件加载到内存中时,操作系统会将文件的基地址(Image Base)分配给该文件。此时,RVA可以通过加上文件的基地址得到绝对的虚拟内存地址(线性地址),从而定位到文件中的特定数据或代码。
RVA的使用有助于可执行文件的可移植性和重定位。通过使用RVA,程序在不同的内存位置加载时,可以根据基地址的变化自动调整地址的偏移量,而不需要对文件进行修改。
【注意】RVA与虚拟内存地址之间的转换需要了解可执行文件的内部结构以及操作系统的加载过程。在Windows操作系统中,可以使用相关的API函数(如ImageRvaToVa)来进行RVA和虚拟内存地址的转换。
总结起来,RVA是指相对虚拟内存地址,在可执行文件内部描述某个数据或代码相对于文件基地址的偏移量。通过RVA,程序可以在加载时自动调整地址,实现可移植性和重定位。
●FOA文件偏移地址
FOA(File Offset Address)是指文件偏移地址,它用于描述可执行文件(如EXE、DLL)中某个特定数据或代码在文件中的位置偏移量。
在可执行文件中,各个节(Section)包含了不同的数据和代码。FOA是相对于文件起始位置的偏移量,用于表示某个数据或代码在文件中的位置。
当可执行文件被加载到内存时,操作系统将文件的内容映射到内存中。此时,FOA可以与文件的起始位置相加,得到数据或代码在内存中的绝对虚拟内存地址。
FOA的使用有助于在文件中定位特定的数据或代码。通过FOA,程序可以根据文件起始位置和偏移量来准确定位所需的内容。
需要注意的是,FOA与虚拟内存地址之间的转换需要了解可执行文件的内部结构以及操作系统的加载过程。在Windows操作系统中,可以使用相关的API函数(如SetFilePointer)来进行FOA和虚拟内存地址的转换。
总结起来,FOA是指文件偏移地址,在可执行文件中描述某个数据或代码相对于文件起始位置的偏移量。通过FOA,程序可以准确地定位文件中的内容。
实验八:使用调试器观察32位和64位记事本程序的VA虚拟地址
●32位记事本程序
第一步:将notepad32.exe拖入DTDebug调试器,按Ctrl+F9进入程序的入口地址。如图3-1所示,notepad32.exe的入口地址为0x0100739D。这个入口地址是一个VA虚拟内存地址(线性地址),即代码段执行的起始地址。入口地址由两部分组成,其中基址为0x01000000,偏移地址为0x739D(RVA地址)。
第二步:查看进程的内存映射窗口,加载notepad32.exe映像文件的起始地址为为0x01000000,这个地址即PE头的开始地址。
图3-1 观察32位记事本程序的入口地址
第三步:我们再来观察一下notepad32.exe加载前磁盘PE文件中给出的VA地址。将程序拖入WinHex工具,找到PE头特征,如下所示:
Offset 0 1 2 3 4 5 6 7 8 9 A B C D E F
000000E0 50 45 00 00 4C 01 03 00 87 52 02 48 00 00 00 00 PE..L...嘡.H....
000000F0 00 00 00 00 E0 00 0F 01 0B 01 07 0A 00 78 00 00 ....?.......x..
00000100 00 88 00 00 00 00 00 00 9D 73 00 00 00 10 00 00 .?......s......
00000110 00 90 00 00 00 00 00 01 00 10 00 00 00 02 00 00 ................
在FOA文件偏移地址0x00000108处给出的0x0000739D(小端存储)即程序的入口地址,基址为FOA文件偏移地址0x00000114处给出的0x01000000,二者相加得到程序入口VA地址0x0100739D。
●64位记事本程序
我们再来观察一下64位记事本notepad64.exe程序的入口地址。
第一步:将notepad64.exe程序拖入x64dbg调试器,如图3-2所示。程序的入口地址为00007FF7550693E0H。
第二步:打开内存布局窗口,“.text”节区(代码段)的起始地址为00007FF755051000H,这是“.text”节区的基址。“.text”节区节区的上方为PE头,PE头占据一页(1000H)大小的内存空间,减去PE头占据的一页内存空间,则notepad64.exe程序加载的起始地址(基址)为00007FF755050000H,VA入口地址00007FF7550693E0H减去基址00007FF755050000H得到RVA偏移地址0x000193E0。
图3-2 观察64位记事本程序的入口地址
第三步:将磁盘文件notepad64.exe拖入WinHex工具,找到PE特征码,如下所示:
Offset 0 1 2 3 4 5 6 7 8 9 A B C D E F
000000F0 50 45 00 00 64 86 06 00 PE..d?.
00000100 AB CE C4 A0 00 00 00 00 00 00 00 00 F0 00 22 00 臓........?".
00000110 0B 02 0E 0A 00 90 01 00 00 52 02 00 00 00 00 00 .........R......
00000120 E0 93 01 00 00 10 00 00 00 00 00 40 01 00 00 00 鄵.........@....
00000130 00 10 00 00 00 02 00 00 0A 00 00 00 0A 00 00 00 ................
在FOA文件偏移地址0x00000120地址处给出程序的入口RVA偏移地址0x000193E0,与x64dbg调试器中得到的地址一致。在FOA文件偏移地址0x00000128地址处给出的程序基址为0000000140000000H,这与我们在调试器中看到的基址不一致,这是什么原因呢?注意观察,节区中有一个“.reloc“节区,这个节区为重定位节区。也就是说,编译器编译时给定的基址为0000000140000000H,但是当程序加载到内存时,基址重定位到了00007FF755050000H地址处。
总结
- 由上述实验可知,PE文件加载到虚拟内存时,加载的起始地址称为可执行程序的基地址,也就是加载后PE头的起始地址。PE文件中的每个节区都有其节区起始地址,称为节区基地址。
- 节区内的偏移地址称为RVA地址。
- VA地址=基址+RVA地址。
在后面的章节中我们将详细讲解PE头结构和节区。
●其他地址
在PE结构中还有一种特殊地址,其计算方法并不是从文件头算起,也不是从内存的某个模块的基地址算起,而是从某个特定的位置算起。这种地址在PE结构中通常见于资源表中。
3.1.2 指针
指针就是地址的意思。PE结构中包含了各种头部信息和数据结构,其中涉及到指针的主要有以下几个:
■ImageBase
ImageBase是PE文件在内存中的首选加载地址,也称为基地址。它是一个指针,指向文件被加载到内存中的起始位置。在32位PE文件中,ImageBase是一个32位的指针;在64位PE文件中,ImageBase是一个64位的指针。
■AddressOfEntryPoint
AddressOfEntryPoint是指向程序的入口点的指针。它指示了程序开始执行的位置。在32位PE文件中,AddressOfEntryPoint是一个32位的指针;在64位PE文件中,AddressOfEntryPoint是一个64位的指针。
■ImageThunkData
ImageThunkData是用于处理导入表和导出表的结构体。它包含了指向导入函数或导出函数的指针。在32位和64位PE文件中,ImageThunkData都是指针类型。
■Relative Virtual Address (RVA)
RVA是一个相对虚拟地址,它是相对于ImageBase的偏移量。在PE文件中,各个节(Section)的数据和代码都以RVA的形式存储。通过将RVA与ImageBase相加,可以得到在内存中的绝对虚拟地址。
这些指针在PE文件的解析和加载过程中起着重要的作用。它们用于定位和访问各个数据和代码的位置,使得程序能够正确执行和调用其他函数。
【注意】上述指针的具体位置和结构可以在PE文件的头部信息中找到,如IMAGE_OPTIONAL_HEADER和IMAGE_SECTION_HEADER等。这些头部信息描述了PE文件的结构和布局,提供了访问指针和数据的关键信息。
3.1.3 数据目录项
在PE文件的扩展头中,数据目录项(Data Directory)是一组数据结构,用于存储各种重要的数据和指针。这些目录项记录了PE文件中的各种数据和资源的位置和大小。下面是一些常见的PE数据目录项:
■导入表(Import Table): 导入表记录了PE文件中所引用的外部函数和模块。它包含了指向导入函数名称和地址的指针数组。
■导出表(Export Table): 导出表记录了PE文件中可供其他模块使用的函数和符号。它包含了导出函数的名称和地址等信息。
■资源表(Resource Table): 资源表包含了PE文件中的各种资源,如图标、位图、字符串等。它提供了访问这些资源的指针和大小。
■重定位表(Base Relocation Table): 重定位表用于处理程序在加载时需要重新定位的地址。它包含了需要修正的地址和相应的修正信息。
■异常处理表(Exception Handling Table): 异常处理表记录了PE文件中的异常处理信息,用于处理程序运行时的异常情况。
■安全相关表(Security-related Table): 安全相关表包含了用于程序安全性的信息,如代码签名、数字证书等。
■TLS表(Thread Local Storage Table): TLS表用于存储线程本地存储的相关信息,包括线程局部变量和线程初始化回调函数。
■延迟加载表(Delay Load Import Table): 延迟加载表用于延迟加载DLL文件和函数,以提高程序的启动速度。
这些数据目录项可以在PE文件的IMAGE_OPTIONAL_HEADER结构中找到。每个目录项包含了指向相关数据结构的指针和大小等信息,用于在运行时定位和访问相应的数据和资源。
【注意】不同的操作系统PE文件可能具有不同的数据目录项,具体的目录项数量和顺序可能会有所变化,取决于编译器和程序的特定需求。
3.1.4 节
在PE文件中,节(Section)是一种组织和存储可执行文件的主要方式。从操作系统加载角度来看,节是相同属性数据的组合。与数据目录不同的是,尽管有些数据类型不同,分別属于不同的数据目录,但由于其访问属性相同,便被归类到同一个节中。这个节最终可能会占用一个或多个页面;但无论有多少个,所有相关页面均会被赋予相同的页属性。这些属性包括只读、只写、可读、可写等。PE文件由多个节组成,每个节都包含了不同类型的数据和代码。以下是关于节的一些重要信息:
■名称(Name): 每个节都有一个名称,用于唯一标识该节。名称通常是一个8字节的ASCII字符串,可以通过查看PE文件的节表(Section Table)来获取节的名称。
■虚拟地址(Virtual Address): 虚拟地址是指节在内存中的虚拟地址,表示该节在可执行文件被加载到内存时所占用的内存地址。程序在运行时,通过虚拟地址来访问和执行节中的代码和数据。
■物理大小(Physical Size): 物理大小是指节在可执行文件中所占用的实际空间大小,以字节为单位。它表示了节在文件中的大小。
■虚拟大小(Virtual Size): 虚拟大小是指节在内存中所占用的虚拟空间大小,以字节为单位。它表示了节在内存中的大小。
■属性(Characteristics): 属性描述了节的特性和用途。一些常见的属性包括可执行代码、初始化数据、未初始化数据、只读数据等。属性信息可以通过查看节表中的Characteristics字段来获取。Windows操作系统通常对不同用途的数据设置不同的访问权限。比如,代码段中的字节码在程序运行的时候,一般不允许用户进行修改,数据段则允许在程序运行过程中读和写,常量只能读等。Windows操作系统在加载可执行程序时,会为这些具有不同属性的数据分別分配标记有不同属性的页面(当然,相同属性的数据可能会被放到同一个页面中),以确保程序运行时的安全。
通过节的组织和存储,PE文件可以将不同类型的数据和代码进行逻辑分组,并提供了对每个节的访问和执行。编译器和链接器会根据程序的需求,将相关的代码和数据放置在适当的节中,以便在运行时进行加载和执行。
【注意】PE文件的节的数量和具体的节名可以根据程序的编译设置和链接器的配置而有所不同。每个节的具体含义和用途取决于程序的结构和需求。
3.1.5 对齐方式
在PE文件中,存在多个对齐方式,用于对各个数据结构和节进行对齐,以提高访问和执行效率。以下是PE文件中常见的对齐方式:
■文件对齐(File Alignment): 文件对齐指定了PE文件中各个节在文件中的对齐方式。它决定了每个节在文件中的起始位置相对于文件的偏移量。文件对齐的值通常是一个较大的2的幂次方,例如512字节或4096字节。这样可以确保节在文件中的位置对齐到文件系统的簇边界,以提高文件的读取性能。
■节对齐(Section Alignment): 节对齐指定了PE文件中各个节在内存中的对齐方式。它决定了每个节在内存中的虚拟地址相对于内存页的偏移量。节对齐的值通常是一个较大的2的幂次方,例如4096字节。这样可以确保节在内存中的位置对齐到操作系统的内存页边界,以提高内存的访问效率。
■数据对齐(Data Alignment): 数据对齐指定了PE文件中各个数据结构的对齐方式。例如,PE文件头部和节表的对齐方式可以由数据对齐值确定。数据对齐的值通常是一个较小的2的幂次方,例如4字节或8字节。这样可以确保数据结构在文件中的位置对齐,以提高访问效率。
对齐方式的选择可以根据程序的特点和性能需求进行调整。较大的对齐值可以提高访问效率,但会增加文件和内存的空间占用。较小的对齐值可以节省空间,但可能会牺牲一些访问效率。在编译和链接过程中,开发人员可以根据需要调整对齐方式,以达到最佳的性能和空间利用。
注意
对齐方式的具体值和设置在PE文件的头部信息中进行了定义和描述,如IMAGE_OPTIONAL_HEADER结构中的对齐字段。Windows PE文件默认的文件对齐方式为512字节(200H),内存以4KB(1000H)为单位对齐。
3.1.6 字符串编码格式
在PE文件中,字符串可以采用不同的编码格式进行表示。以下是PE文件中常见的字符串编码格式:
■ASCII编码(ASCII Encoding): ASCII编码使用一个字节(8位)来表示一个字符,范围为0-127。它是最基本、最常见的字符编码方式,适用于英文字符和一些常见的符号。在PE文件中,许多文本信息(如文件名、函数名、动态链接库名称等)均使用ASCII编码进行表示。
■Unicode编码
在PE文件的资源表中,添加的资源字符串常使用Unicode编码格式。
●UTF-8编码(UTF-8 Encoding): UTF-8编码是一种可变长度的Unicode编码方式,它可以表示几乎所有的字符。UTF-8编码使用1到4个字节来表示一个字符,具有良好的兼容性和扩展性。在PE文件中,一些需要支持多语言或特殊字符的文本信息可以使用UTF-8编码进行表示。
●UTF-16编码(UTF-16 Encoding): UTF-16编码是一种固定长度的Unicode编码方式,它使用2个字节(16位)来表示一个字符。UTF-16编码可以表示几乎所有的Unicode字符,包括辅助平面字符。在PE文件中,一些需要支持多语言或包含特殊字符的文本信息可以使用UTF-16编码进行表示,例如资源字符串。
在PE文件中,字符串通常是以零结尾(null-terminated)的方式进行存储,即字符串的末尾有一个空字符(\0)来表示字符串的结束。这种表示方法在C/C++语言中被广泛使用。
需要根据具体的字符串内容和使用场景来选择适当的编码方式。在处理PE文件时,根据字符串的编码格式,开发人员可以选择合适的编码方式进行解析和处理。
以下是微软MSDN给出的PE文件一搬概念:
名称 | 描述 |
属性 证书 | 用于将可验证声明与映像关联的证书。 许多不同的可验证声明可以与文件相关联;其中最有用的一个声明是软件制造商的声明,此声明指示映像的消息摘要应该是什么。 消息摘要类似于检验和,只是很难伪造。 因此,很难修改文件以使其具有与原始文件相同的消息摘要。 可以使用公钥或私钥加密方案来验证声明是否是由制造商发出的。 本文档描述了有关属性证书的详细信息,但不允许将其插入到图像文件中。 |
日期/时间戳 | 在 PE 或 COFF 文件中的多个位置用于不同目的的戳记。 在大多数情况下,每个标记的格式与 C 运行时库中的时间函数使用的格式相同。 有关异常,请参见MSDN调试类型中 IMAGE_DEBUG_TYPE_REPRO 的说明。 如果戳记值为 0 或 0xFFFFFFFF,则它不表示实际或有意义的日期/时间戳。 |
文件 指针 | 链接器(对于目标文件)或加载器(对于映像文件)处理之前文件本身中项的位置。 换句话说,这是存储在磁盘上的文件内的位置。 |
链接器 | 随 Microsoft Visual Studio 一起提供的链接器引用。 |
对象 文件 | 作为链接器输入提供的文件。 链接器生成一个映像文件,而此映像文件又用作加载器的输入。术语“目标文件”并不一定意味着与面向对象的编程有任何联系。 |
已保留,必须为 0 | 字段的描述,指示该字段的值对于生成器来说必须为零,而使用者必须忽略该字段。 |
相对虚拟地址 (RVA) | 在映像文件中,这是项目加载到内存并从中减去映像文件基地址后的地址。 项目的 RVA 几乎总是与其在磁盘上文件中的位置(文件指针FOA)不同。 |
节区 | PE 或 COFF 文件中代码或数据的基本单位。 例如,目标文件中的所有代码都可以组合在单个节区中,或者(取决于编译器行为)每个函数都可以占用自己的部分。 节区越多,文件开销就越大,但链接器能够更有选择性地链接代码。 一个部分类似于 Intel 8086 体系结构中的段。 一个节区中的所有原始数据都必须连续加载。 此外,映像文件可以包含多个具有特殊用途的节区,例如 .tls 或 .reloc 。 |
虚拟地址 (VA) | 与 RVA 相同,只不过不减去映像文件的基址。 该地址称为 VA,因为 Windows 会为每个进程创建一个独立于物理内存的不同 VA 空间。 对于几乎所有目的,VA 应只被视为一个地址。 VA 不如 RVA 那么可预测,因为加载器可能不会在其首选位置加载映像(基址重定位)。 |