68.2 Win32 PE

PE是Windows下的可执行文件格式。

.exe,.dll,.sys文件它们之间的区别是,.exe和.sys文件通常没有导出表,只有导入表。

DLL文件和其它PE文件类似,有一个入口点(OEP)(DllMain()函数),但一般情况下很少DLL带有这个函数。

.sys通常是一个设备驱动程序。

作为驱动程序,Windows需要检验它的PE文件并保证它是正确的。

从Windows Vista开始,一个驱动程序文件必须拥有数字签名,否则它会被拒绝加载。

每个PE文件都由一段打印“This program cannot be run in DOS mode.”的DOS程序块开始。如果你的程序运行于DOS或者Windows 3.1(这些OS并不识别PE文件格式),这个DOS程序块将被执行打印。

68.2.1 术语

  • Module(模块) - 一个exe/dll文件。
  • Process(进程) - 加载到内存中并正在运行的程序,通常由一个exe文件和多个dll文件组成。
  • Process memory(进程内存) - 进程所在容所。每个进程都拥有自己的内存。通常是加载的模块,栈内存,堆内存等等。
  • VA(虚拟地址) - 可以被程序所使用的地址。
  • Base address(基地址) - 模块被加载到进程内存后的地址。
  • RVA(相对虚拟地址) - VA地址减去基地址后的地址。PE文件中有许多地址使用RVA地址。
  • IAT(导入地址表)- 一个导入符号地址的数组。通常由一个IMAGE_DIRECTORY_ENTRY_IAT数据目录指向IAT。值得注意的是,IDA可会给IAT分配一个名为.idata的pseudo-section,即使IAT是其它section的一部分。
  • INT(导入名称表) - 一个导入符号名的数组。

68.2.2 Base address

问题是,模块(DLL)的开发者不可能事先知道哪些地址分配给哪些模块使用的。

这就是为什么两个具有相同基地址的DLL需要一个加载到这个基地址而另外一个加载到进程的其它空闲内存处并调整第二个DLL的虚拟地址。

通常情况下,MSVC链接器生成.exe文件的基地址是0x400000,并把代码段安排在0x401000。这意味着该代码段的RVA地址是0x1000。DLL的基地址通常被MSVC链接器安排在0x10000000。

还有一种情况下加载模块时会导致基地址浮动。

这就是ASLR(Address Space Layout Randomization(地址空间布局随机化))。

一个shellcode想要执行必须调用到系统的函数。

在老的操作系统当中(如果是WindowsNT,则在Windows Vista之前),系统的DLL(如kernel32.dll,user32.dll)总是加载到已知的地址。如果我们还记得的话,它们的版本是很少有变动的。因为函数的地址是固定的,shellcode可以直接调用它们。

为了避免这种情况,ASLR每次在加载模块的时候都会随机安排它们的基地址。

支持ASLR的程序在PE头中会设置IMAGE_DLL_CHARACTERISTICS_DYNAMIC_BASE标识表明其支持ASLR。

68.2.3 Subsystem

还有一个subsystem字段, 通常是: - native (sys驱动程序) - console (控制台程序) - GUI (图形程序)

68.2.4 OS version

PE文件还规定了可以加载它的最小Windows版本号。有一个表保存了PE的版本号和相应的Windows开发代号。

举个例子,MSVC 2005编译的.exe文件运行在Windows NT4(version 4.00)。但MSVC 2008不是(生成文件的版本是5.00,至少运行于Windows 2000)。

MSVC 2012生成的.exe文件默认是6.00版本,最低平台要求至少是Windows Vista。但可以通过更改编译选项,强制编译器支持Windows XP。

68.2.5 Sections

一部分section似乎存在于所有可执行文件格式里面。

下面的标志位用于区分代码和常量数据:

  • 当IMAGE_SCN_CNT_CODE或IMAGE_SCN_MEM_EXECUTE被置位,表示该section是一个可执行代码。
  • 在数据section中,IMAGE_SCN_CNT_INITIALIZED_DATA,IMAGE_SCN_MEM_READ和IMAGE_SCN_MEM_WRITE被置位。
  • 在未初始化section和空section中,IMAGE_SCN_CNT_UNINITIALIZED_DATA, IMAGE_SCN_MEM_READ和IMAGE_SCN_MEM_WRITE被置位。
  • 在常量数据section(写保护)中,IMAGE_SCN_CNT_INITIALIZED_DATA和IMAGE_SCN_MEM_READ被置位,但不可以置位 IMAGE_SCN_MEM_WRITE。当一个进程尝试在这个section写数据时,进程会崩溃掉。

每个section在PE文件可能有一个名字,但是它并不是很重要。通常(但不总是)代码section的名字是.text,数据section是.data,常量数据section是.rdata(readable data)。其它流行的名字还有:

  • .idata—imports section(导入section)。IDA可能会创建一个类似(68.2.1)的pseudo-section。
  • .edata—exports section(导出section)。
  • .pdata—在Windows NT(MIPS,IA64,x64)包含了所有异常信息。
  • .reloc—relocs section(重定位section)
  • .bss—uninitialized data(未初始化数据(BSS))
  • .tls—thread local storage(线程局部存储(TLS))
  • .rsrc—resources(资源)
  • .CRT—可能存在古老的MSVC版本编译出来的二进制文件里面。

PE文件的打包器/加密器经常打乱section名字或者把名字替换为自己的。

MSVC允许你任意命名section。

一些编译器和链接器可以添加一个用于调试符号和其他调试信息的section(例如MinGW)。但不包括MSVC现在的版本(提供单独的PDB文件用于这个目的)。

这是PE文件的section结构体定义:

  1. typedef struct _IMAGE_SECTION_HEADER {
  2. BYTE Name[IMAGE_SIZEOF_SHORT_NAME];
  3. union {
  4. DWORD PhysicalAddress;
  5. DWORD VirtualSize;
  6. } Misc;
  7. DWORD VirtualAddress;
  8. DWORD SizeOfRawData;
  9. DWORD PointerToRawData;
  10. DWORD PointerToRelocations;
  11. DWORD PointerToLinenumbers;
  12. WORD NumberOfRelocations;
  13. WORD NumberOfLinenumbers;
  14. DWORD Characteristics;
  15. } IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;

一些相关的字段的解释:PointerToRawData是在磁盘文件中的偏移,VirtualAddress在Hiew中是装载到内存中的RVA。

68.2.6 Relocations (relocs)

也称为FIXUP-s(在Hiew)。

他们也存在于几乎所有的可执行文件格式。

显然,模块可以被加载到各种基地地址,但如何处理全局变量?一个解决方案是使用位置无关代码(67.1章),但它并不是总是有用的。

这就是重定位表存在的理由:当模块加载到不同的基地址的时候,它们的入口地址都需要修正。

举个例子,有一个全局变量的地址是0x410000,它是这样访问的:

  1. A1 00 00 41 00 mov eax, [000410000]

模块的基地址是0x400000,全局变量的RVA地址是0x10000。

如果模块加载到0x500000这个基地址,那么全局变量实际的地址必须是0x510000。

我们可以看到,在0xA1字节之后,变量的地址编码到MOV指令中的。

这就是为什么0xA1字节之后的4个字节地址写在了重定位表。

如果模块加载到不同的基地址,操作系统加载器枚举重定位表中所有地址,查找每个32位的地址,减去原来的基地址(我们这里得到了RVA),并添加新的基地址。

如果模块加载到原来的基地址,那么不做任何事情。

所有的全局变量都可以这样处理。

重定位表可能有各种类型,但是在x86处理器的Windows中,通常是IMAGE_REL_BASED_HIGHLOW。

顺便说一下,重定位表在Hiew是隐藏的。相关例子请查看(Figure 7.12)。

OllyDbg会用下划线标识哪些使用了重定位表。相关例子请查看(Figure 13.11)。

68.2.7 Exports and imports

众所周知,任何可执行文件都必须使用操作系统提供的服务和其它一些动态链接库。

可以说,一个模块(通常是DLL)的函数通常都是导出提供给其它模块使用(.exe文件或其它DLL)。

这种情况下,每个DLL都有一个导出(exports)表,由模块的函数加它们的地址组成。

每个exe或dll文件也有一个导入(imports)表,里面包含了程序执行所需函数对应的DLL文件名。

在加载main.exe文件之后,操作系统加载器开始处理导入表:它加载所需的DLL文件,接着在DLL的导入表查找对应函数名字的地址,然后把它们的地址写到main.exe模块的IAT((Import Address Table)导入表)。

我们可以看到,加载器必须大量比较函数名,但字符串比较效率并不是很高。所以有一个支持“ordinals”或“hints”的东西,表示函数存储在表中的序号,用于代替它们的函数名。

这使得它们可以更快地加载DLL。Ordinals在导出表中永远都存在。

举个例子:一个使用MFC库的程序都是通过ordinals加载mfc*.dll,在这种程序中,INT(Import Name Table)是不存在MFC函数名字的。

使用IDA加载这类程序的时候,如果告诉它mfc*.dll文件路径,则可以看到函数名。如果不告诉IDA这些DLL路径,它会显示诸如mfc80_123而不是函数名。

Imports section

编译器通常会给导入表及其相关内容分配一个单独的section(名字类似.idata),但这不是一个强制规定。

因为术语混乱,导入表是一个比较令人困惑的地方。让我们尝试一下整理这些信息。

Figure 68.1: A scheme that unites all PE-file structures related to imports

Figure 68.1: A scheme that unites all PE-file structures related to imports

Figure 68.1: A scheme that unites all PE-file structures related to imports

里面主要的结构是IMAGE_IMPORT_DESCRIPTOR数组。每个被加载进来的DLL占用一个元素。

每个元素包含一个文本字符串(DLL名字)的RVA地址。

OriginalFirstThink是INT表的RVA地址。这是一个RVA地址的数组,里面每个成员都指向一个函数名的文本字符串。每个函数名的字符串之前是一个16位的(“hint”)-“ordinal”整数。

加载的时候,如果可以通过ordinal找到函数,那么就不需要使用字符串比较来查找函数。数组的最后一个元素是0。还有一个FirstThunk字段指向IAT表,这个地方是加载器重写需要重新解析函数的地址的RVA地址。

需要加载器重写地址的函数在IDA中加了诸如这种标记:__imp_CreateFileA。

加载器至少有两种方法重写地址:

  • 代码会有诸如调用_impCreateFileA的指令,因为导入函数的地址在某种意义上是一个全局变量,当模块加载到不同的基地址时,call指令的地址被添加到重定位表中。 但是,显然这种方法可能会扩大重定位表。因为有可能从这个模块大量调用导入的函数。而且,重定位表太大的话会减慢模块的加载速度。

  • 每个导入函数给它分配一条jmp指令,使用jmp指令加上重定位表的地址跳转到导入函数。这些入口点被称之为“thunks”,所有调用导入函数仅需要调用相对应的“thunk”,这种情况下不需要额外的重定位操作,因为这些CALL都使用相对地址,不需要额外的调整操作。

这两种方法可以组合使用。可能的话,链接器给那些被调用太多次的函数创建一个“thunk”,然而默认情况下不是这样。

顺便说一下,FirstThunk指向的函数地址数组不必要位于IAT section。举个例子,我曾经写的PE_add_import工具可以给.exe文件添加一个导入函数。在早些时候,这个工具可以让你的函数调用其它DLL文件的函数。我的工具添加了类似下面的代码:

  1. MOV EAX, [yourdll.dll!function]
  2. JMP EAX

FirstThunk指向第一条指令,换句话说,当加载yourdll.dll的时候,加载器在代码中写入function函数的正确地址。

还值得注意的是代码段通常是写保护的,因此我的工具在code section添加了一个IMAGE_SCN_MEM_WRITE标志位。否则,程序在加载的时候会爆出错误码为5(访问失败)的异常错误。

有人可能会问:如果我提供一个程序与一组不变的DLL文件,是有可能加快加载过程?

是的,它可以提前把函数的地址写入到导入表的FirstThunk数组。IMAGE_IMPORT_DESCRIPTOR结构有一个Timestamp字段。如果这个变量存在,则加载器会比较这个变量和DLL文件日期时间。如果它们相等,那么加载器不做任何事情,所以加载过程可以很快完成。这就是所谓的“old-style binding”。为了加快程序的加载,Matt Pietrek. “An In-Depth Look into the Win32 Portable Executable File Format”,建议你的程序安装在最终用户的计算机后不久做捆绑。

PE文件的打包器/加密器也可以压缩/加密导入表。在这种情况下,Windows的加载器当然不会加载所有需要的DLL。因此打包器/加密器只能通过LoadLibrary()和GetProcAddress()来获取所需函数。

安装在Windows系统中的标准DLL文件,IAT往往是位于PE文件的开头。据说,这是一种优化。加载时.exe文件不是全部加载到内存,它是“映射”和加载部分需要被访问到的内存。可能微软的开发者认为这样加载比较快。

68.2.8 Resources

资源在PE文件只是一组图标,图片,文本字符串,对话框描述。因为把它们从主代码分离了出来,所以多国语言程序很容易实现,只需要根据操作系统设置的语言去选择文本或图片的语言。

作为一个副作用,通过使用诸如ResHack的编辑器,即使在没有专业知识的情况下,也可以轻松地编辑和保存可执行文件的资源。

68.2.9 .NET

.NET的程序并不编译成机器码,而是编译成字节码。严格地说,是在.exe文件里面使用字节码代替x86机器。然而,进入入口点(OEP)还是需要一小段x86机器码:

  1. jmp mscoree.dll!_CorExeMain

.NET的加载器位于mscoree.dll,由它来处理PE文件。它存在于之前的所有Windows XP操作系统。从XP启动的时候,OS的加载器能够探测.NET文件并通过JMP指令执行。

68.2.10 TLS

这个section包含了初始化TLS的数据(65章)(如果需要的话)。当一个新线程启动的时候,它的TLS数据使用这个section的数据进行初始化。

除此之外,PE文件规范还提供了TLS的初始化!当section,TLS callbacks存在,它们会在传递控制权到主入口点(OEP)之前被调用。这个功能广泛用于PE文件的打包和加密。

68.2.11 工具

  • objdump - cygwin版本可以反汇编PE文件
  • Hiew - (参考73章)
  • pefile - 一个处理PE文件的Python库
  • ResHack AKA Resource Hacker — 资源编辑器
  • PE_add_import — 添加符号到导入表的简易工具
  • PE_patcher — 修补PE文件的简易工具
  • PE_search_str_refs — 查找函数在PE文件里对应的字符串的简易工具

68.2.12 扩展阅读

Daniel Pistelli — The .NET File Format