PE文件

PE简介

可移植可执行(Portable Executable,PE)格式。由于PE是Windows操作系统上使用的主要二进制格式,因此熟悉PE格式对在Windows操作系统上分析常见的二进制恶意软件非常有用。

PE是通用对象文件格式(Common Object File Format,COFF)的修改版本,在被ELF取代之前,COFF还在UNIX操作系统上使用。PE有时也被称为PE/COFF。让人困惑的是,PE的64位版本被称为PE32+。PE32+和原始PE格式相比只有很小的差异。

图3-1中显示的数据结构在WinNT.h中定义,该文件包含在Windows的软件开发工具包(Software Development Kit,SDK)中。

image-20220924100815226

1.MS-DOS头和MS-DOS存根

MS-DOS是Microsoft在1981年发行的一款操作系统,Microsoft为了实现向后兼容,将其包含在二进制格式中。

引入PE格式时,有一段过渡期,即用户同时使用老旧的MS-DOS二进制文件和较新的PE二进制文件。为了避免在过渡期人们对此产生混淆,每个PE二进制文件都会以MS-DOS头开始,因此从狭义的角度来讲,PE二进制文件也可以被解释为MS-DOS二进制文件。MS-DOS头的主要功能是描述如何加载并执行MS-DOS存根。该存根通常只是一段很小的MS-DOS程序,当用户在MS-DOS操作系统上执行PE二进制文件的时候,存根就会被执行,而不是主程序。

MS-DOS头以一个幻数作为开头,该值由“MZ”的ASCII字符码组成。

MS-DOS头中唯一重要的字段是最后一个字段,被称为e_lfanew。该字段包含了实际PE二进制文件开始的文件偏移量(也就是NT头相对文件起始地址的偏移)。因此,当PE加载器打开二进制文件的时候,它会自动读取MS-DOS头并跳过它和MS-DOS存根,然后直接进入PE头的开始位置。

2.PE签名、PF文件头及PE可选头

“可执行文件头”由3部分组成:32位的PE签名(signature)、PE文件头以及PE可选头。可能会认为IMAGE_NT_HEADERS64作为PE格式的可执行文件头是一个整件,但实际上,PE签名、PE文件头和PE可选头是3个独立的实体。

image-20220924102235778

PE签名

PE签名就是一个包含“PE”的ASCII字符码的字符串,后面跟着两个NULL字符。它类似于ELF头部中e_ident字段中的幻数字节。

PE文件头

PE文件头描述了PE二进制文件的一般属性。其中最重要的字段有:Machine、NumberOfSections、SizeOfOptionalHeader及Characteristics。用来描述两个符号表的字段已经被废弃了,并且PE二进制文件不应该再使用嵌入的符号和调试信息,而可以选择将这些符号作为单独的调试文件共享出来。

  • Machine字段描述了PE二进制文件所对应的机器体系结构。在这个示例中是x86-64,定义的常数为0x8664❶。
  • NumberOfSections字段表示节表的条目数
  • SizeOfOptionalHeader表示PE可选头的大小(对于32位操作系统,通常为0x00E0,对于64位操作系统,通常为0x00F0)。
  • Characteristics字段包含了描述二进制文件的各种属性标志位,如字节序是否为动态链接库(DynamicLinkLibrary,DLL)文件、是否被剥离(stripped)等。

PE可选头

PE可选头对PE二进制文件来说并不是真的可选(不过它可能会在对象文件中丢失)。事实上,任何PE文件都有PE可选头。PE可选头包含许多字段,接下来会介绍几个非常重要的字段。

首先,这里有一个16位的幻数值,对64位的PE二进制文件来说,这个值是0x020b❸。这里还有几个字段用于描述创建二进制文件链接器的主/次要版本号,以及运行二进制文件时所需的最低操作系统版本。ImageBase字段❻描述了加载二进制文件的地址(PE二进制文件被设计为需要在指定的虚拟地址进行加载)。其他指针字段如相对虚拟地址(RelativeVirtualAddress,RVA),作用是将其与ImageBase基址进行相加,得出虚拟地址。如BaseOfCode字段❺为代码段起始地址的RVA。因此,你可以通过计算ImageBase+BaseOfCode找到代码段的虚拟地址。可能你已经猜到了,AddressOfEntryPoint字段❹包含了二进制文件的入口点地址,同时也是一个RVA。

可选头中最不容易解释的字段可能就是DataDirectory数组❼。DataDirectory包含了名为IMAGE_DATA_DIRECTORY结构体类型的条目,包括其RVA和大小。在DataDirectory数组中,每个条目都描述了二进制文件重要部分的起始RVA和大小,通过DataDirectory数组的索引,我们可以精确地解释对应的条目。最重要的条目是索引0,该条目描述了导出目录的RVA和大小(导出表);索引1的条目描述的是导入目录(导入表);索引5描述了重定位表。在讨论PE节的时候,我将详细讨论导出表和导入表的内容。DataDirectory本质上就是加载器的快捷方式,使用它可以快速地查找数据的特定部分,而不用遍历节表。

3.节表

在很多部分,PE的节表与ELF的节表很相似

PE的节表是IMAGE_SECTION_HEADER结构的数组,每个结构描述一个节,表示该节在磁盘和在内存中的大小(SizeOfRawData和VirtualSize)、文件的偏移量和虚拟地址(PointerToRawData和VirtualAddress)、重定位信息和各种属性标志(Characteristics)。

==属性标志用于说明该节是可执行的、可读的,抑或是某种组合。==PE节头没有像ELF节头那样引用字符串表,而是使用简单的字符数组字段指定节名称,该字段也被称为Name域。因为数组只有8字节长,所以PE节的名称被限制在8个字符内。与ELF不同,PE格式没有明确区分节和段。PE文件最接近ELF执行视图的是DataDirectory,它为加载器提供了设置执行二进制代码重要部分的快捷方式。除此以外,PE格式没有单独的程序头表。节表则用来链接和加载。

4.节

PE二进制文件中许多节可以直接与ELF的节进行比较,甚至经常连名称也几乎相同。

image-20220924103306532

有一个.text节的代码段,.rdata节包含只读数据(相当于ELF中的.rodata节),.data节包含可读/可写的数据段,还有一个.bss节用于零初始化数据,还有一个.reloc节,里面包含重定位信息。

需要注意的一件事情是,像Visual Studio这样的PE编译器有时会将只读数据放在.text节中(与代码混合),而不是单独放在.rdata节中,这在反汇编的时候可能会出现问题,因为这可能会意外地将常量数据解释为指令。

.edata和.idata节

在PE二进制文件中很重要的.edata和.idata节,在ELF格式中是没有的,它们分别包含了导出表和导入表。

  • .idata节指定了从共享库或者DLL文件导入的符号(函数与数据)。

  • .edata节列出了PE二进制文件的导出符号和地址信息。

为了解析对外部符号的引用,加载器需要将导入信息与提供符号的DLL的导出表进行匹配。

可能会发现没有单独的.edata和.idata节。实际上,清单3-2中的PE二进制文件中也没有它们。当这些节不存在的时候,意味着它们通常被合并成.rdata节,但它们的内容和工作方式依旧没变。

当加载器需要解析依赖项(文件/变量)时,加载器将解析后的地址导入地址表(Import Address Table,IAT)。与ELF中的全局偏移表(GOT)相似,IAT就是一槽一指针地解析指针表。动态加载器将这些指针替换为指向实际导入的函数或者变量地址。然后,对库函数调用的实现等同于该函数对thunk的调用,这只是对函数IAT的间接跳转。

PE代码节的填充

在反汇编PE二进制文件的时候,可能会注意到有很多int3指令。Visual Studio发出这些指令作为填充(而不是GCC使用的nop指令)来对齐内存中的函数和代码块,以便对其进行有效访问。[2]调试器通常使用int3指令设置断点,如果不存在调试器,该指令会导致程序尝试去捕获调试器或者崩溃。但对代码填充来说,这是可以接受的,因为我们不打算执行这些填充的指令。