PE 文件格式

记录逆向工程核心原理加密解密Windows PE 权威指南等书中对 PE 文件格式的部分描述,眼过千遍不如手过一遍,加强学习,同时作为备忘,初始对很多内容理解不够深刻且容易忘记,日后需多加复习和练习;

使用工具

  • OllyDbg 1.10原版,简称OD
  • OD 汉化插件均来自互联网;
  • WinHex来自互联网;
  • 文中特殊数字均是HEX,为了书写方便采用DEC

PE 文件格式

PE 文件是 Windows 操作系统下的可执行文件格式;

是微软在 UNIX 平台的 COFF(Common Object File Format,通用对象文件格式)基础上制作而成的;

在 Win16 平台上,可执行文件格式是 NE;

在 Win32 平台上,可执行文件格式是 PE(Portable Executable File Format,可移植的执行体);

PE 文件是指 32 位的可执行文件,也称为 PE32;

64 位的可执行文件称为 PE+ 或 PE32+,是 PE 文件的一种扩展形式,而不是 PE64;

Tips:⚠️ 为当前参数列表需重点关注

PE 文件种类

种类主扩展名
可执行系列EXE、SCR
库系列DLL、OCX、CPL、DRV
驱动程序系列SYS、VXD
对象文件系列OBJ

除 OBJ 文件之外的所有文件都是可执行的;

EXE 文件和 DLL 文件的区别完全是语义上的,它们使用完全相同的 PE 格式,唯一的区别就是用一个字段标识出这个文件是 EXE 还是 DLL;

DLL、SYS 文件等虽然不能直接在 Shell 中运行,但可以使用其它方法执行;

只有扩展名为 .DLL 的动态链接库才能被 Windows 操作系统自动加载;

如果文件有另外的扩展名,则必须明确地用 LoadLibrary 或 LoadLibraryEx 函数加载;

基本结构

PE 文件使用的是一个平面地址空间,所有代码和数据都合并在一起,组成一个很大的结构;

文件的内容被分割为不同的区块(Section,又称区段、节等);

区块中包含代码或数据,各个区块按页边界对齐;

区块没有大小限制,是一个连续结构;

每个区块都有它自己在内存中的一套属性,如:是否包含代码、是否只读或可读/写等;

Windows 加载器(又称 PE 装载器)遍历 PE 文件并决定文件的哪一部分被映射,这种映射方式是将文件较高的偏移地址映射到较高的内存地址中;

磁盘文件一旦被载入内存,磁盘上的数据结构布局和内存中的数据结构布局就是一致的,但数据之间的相对位置可能会改变,某项的偏移地址可能区别于原始的偏移地址;

当 PE 文件通过 Windows 加载器载入内存后,内存中的版本称为模块(Module);

映射文件的起始地址称为模块句柄(hModule),可以通过模块句柄访问内存中的其它数据结构,这个初始内存地址也称为基地址(ImageBase);

基地址的值是由 PE 文件本身设定的,按照默认设置,用 Visual C++ 建立的 EXE 文件的基地址是 00400000,DLL 文件的基地址是 10000000;

从 DOS 头到节区头是 PE 头部分,称为 PE 头,下面的节区合称 PE 体;

文件中使用偏移(offset),内存中使用 VA(Virtual Address,虚拟地址)来表示位置;

文件加载到内存时,节区的大小、位置等会发生变化;

文件的内容一般可以分为代码(.text)、数据(.data)、资源(.src)节等,分别保存;

根据所用的不同开发工具与编译选项,节区的名称、大小、个数、存储的内容等都是不同的,它们按照不同的用途,分类保存到不同的节中;

各节区头定义了各节区在文件或内存中的大小、位置、属性等;

PE 头与各节区的尾部存在一个区域,称为 NULL 填充,也就是 PE 头与各节区以 NULL 分割;

计算机中,为了提高处理文件、内存、网络包的效率,使用“最小基本单位”这一概念,PE 文件也类似;

文件 / 内存中各节区的起始位置应该在各文件 / 内存的最小单位的倍数位置上,空白区域将用 NULL 填充;

VA & RVA

在 Windows 系统中,PE 文件将被系统加载器映射到内存中;

每个程序都有自己的虚拟空间,这个虚拟空间的内存地址称为虚拟地址(Virtual Address,VA);

VA(Virtual Address,虚拟地址) 指的是进程虚拟内存的绝对地址;

RVA(Relative Virtual Address,相对虚拟地址)指的是从基准位置(ImageBase)开始的相对地址;

VA 与 RVA 之间的关系:虚拟地址(VA) = 相对虚拟地址(RVA) + 基地址(ImageBase);

PE 头内部信息大多以 RVA 形式存在;

原因在于,PE 文件(主要是 DLL)加载到进程虚拟内存的特定位置时,该位置可能已经加载了其它 PE 文件(DLL);

此时,必须通过重定位将其加载到其它空白的位置,若 PE 头信息使用的是 VA,则无法正常访问;

因此使用 RVA 来定位信息,即使发生了重定位,只要相对于基准位置的相对地址没有变化,就能正常访问到指定信息,不会出现任何问题;

32 位 Windows 系统中,各进程分配有 4GB 的虚拟内存,因此进程中 VA 值的范围是 00000000 ~ FFFFFFFF;

PE 头

PE 头由许多结构体组成;

DOS 头

每个 PE 文件都是以一个 DOS 程序开始的,有了它,一旦程序在 DOS 下执行,DOS 就能识别出这是一个有效的执行体,然后运行紧随 MZ Header 的 DOS stub(DOS 块);

DOS stub(DOS 存根) 实际上是一个有效的 EXE,在不支持 PE 文件格式的操作系统中,它将简单的显示一个错误提示;

通常把 DOS MZ 头与 DOS stub 合并称为 DOS 头;

微软创建 PE 文件格式时,广泛使用的是 DOS 文件,考虑到 PE 文件对 DOS 文件的兼容性,在 PE 头的最前面添加了一个 IMAGE_DOS_HEADER 结构体,用来扩展已有的 DOS EXE 头;

IMAGE_DOS_HEADER 结构体的大小为 64 字节,在该结构体中必须知道 2 个重要的成员:

  • e_magic:DOS 签名(Signature,4D5A,对应的 ASCII 值是“MZ”),也称为 DOS MZ 头;
  • e_lfanew:指示 NT 头的偏移(根据不同文件拥有可变值),是真正的 PE 文件头的相对偏移(RVA)位置,占用 4 个字节,位于从文件开始偏移 3C 字节处;

所有 PE 文件在开始部分(e_magic)都有 DOS 签名(4D5A,“MZ”);

一个名叫 Mark Zbikowski 的开发人员在微软设计了 DOS 可执行文件,MZ 取自其名字的首字母;

e_lfanew 值指向 NT 头所在位置(NT 头的名称为 IMAGE_NT_HEADERS);

NOTEPAD.EXE 的 IMAGE_DOS_HEADER 结构体:

IMAGE_DOS_HEADER

文件开始的 2 个字节为 4D5A,e_lfanew 值为 000000E0(Intel x86 CPU,小端序标识法);

DOS 存根

DOS 存根(stub)位于 DOS 头下方,是可选项,且大小不固定,也可以说:DOS MZ 头与 NT 头之间的数据就是 DOS 存根;

DOS 存根由代码与数据混合而成;

NOTEPAD.EXE 的 DOS 存根:

NOTEPAD.EXE 的 DOS 存根

NT 头(PE Header,PE 头)

NT 头的名称为 IMAGE_NT_HEADERS;

IMAGE_NT_HEADERS 结构体由 3 个成员组成,第一个成员为签名(Signature)结构体,其值为 50450000(PE00),另外两个成员分别为文件头(File Header)与可选头(Optional Header)结构体;

IMAGE_NT_HEADERS 结构体:

IMAGE_NT_HEADERS 结构体

IMAGE_NT_HEADERS 结构体的大小为 F8,相当大,通常,标准 PE 文件的 MAGE_NT_HEADERS 结构体由 4 个字节的签名标识符和 20 个字节的基本信息头(文件头)以及 216 个字节的扩展信息头(可选头)组成;

NT 头:文件头 IMAGE_FILE_HEADER

文件头是表现文件大致属性的 IMAGE_FILE_HEADER 结构体,该结构在微软官方文档中被称为标准通用对象文件格式(Common Object File Format,COFF)头;

IMAGE_FILE_HEADER 结构体的成员:

IMAGE_FILE_HEADER 结构体的成员

以下成员的前 4 个非常重要,若设置不正确,将导致文件无法正常运行:(偏移量基于 PE 文件头 (IMAGE_NT_HEADERS))

偏移量字段Size注释
4Machine ⚠️WORD运行平台
6NumberOfSections ⚠️WORDPE 中区块的数量
14SizeOfOptionalHeader ⚠️WORDIMAGE_OPTIONAL_HEADER 结构体的长度
16Characteristics ⚠️WORD文件属性
8TimeDateStampDWORD文件创建时间
CPointerToSymbolTableDWORD指向符号表
10NumberOfSymbolsDWORD符号表中符号的数量
  1. Machine:单字,每个 CPU 都拥有唯一的 Machine 码,兼容 32 位 Intel x86 芯片的 Machine 码为 14C,具体定义位于 winnt.h;

    几种典型的机器类型标志:

    含义标志常量符号
    适用于任何处理器0IMAGE_FILE_MACHINE_UNKNOWN
    Intel i386 处理器或后续兼容处理器14CIMAGE_FILE_MACHINE_I386
    x64 处理器8664IMAGE_FILE_MACHINE_AMD64
    MIPS 小尾处理器166IMAGE_FILE_MACHINE_R4000
    ARM 小尾处理器1C0IMAGE_FILE_MACHINE_ARM
    Power PC 小尾处理器1F0IMAGE_FILE_MACHINE_POWERPC
  2. NumberOfSections:单字,用来指出文件中存在的节区(Section)数量,该值一定要大于 0,且当定义的节区数量与实际数量不同时,将发生运行错误;

    如果想在 PE 中增加或删除节,必须变更此处的值;

  3. SizeOfOptionalHeader:单字,NT 头的最后一个成员为 IMAGE_OPTIONAL_HEADER 结构体(也就是扩展信息头),SizeOfOptionalHeader 成员用来指出扩展信息头的长度,表示数据的大小;

    Windows 的 PE 装载器需要查看 IMAGE_FILE_HEADER 的 SizeOfOptionalHeader 值,从而识别出 IMAGE_OPTIONAL_HEADER 结构体的大小;

    PE32+ 格式的文件中使用的是 IMAGE_OPTIONAL_HEADER64 结构体,而不是 IMAGE_OPTIONAL_HEADER32 结构体;

    IMAGE_OPTIONAL_HEADER64 与 IMAGE_OPTIONAL_HEADER32 两个结构体的尺寸不同,所以需要在 SizeOfOptionalHeader 成员中明确指出结构体的大小;

    IMAGE_OPTIONAL_HEADER 的大小依赖于当前 PE 文件是 32 位还是 64 位;

    对于 32 位 PE 文件,这个域通常是 00E0;
    对于 64 位 PE 文件,这个域通常是 00F0;

    用户可以自定义 SizeOfOptionalHeader 的值,不过需要注意两点:

    • 更改完成后,需要自行将文件中 IMAGE_OPTIONAL_HEADER32 的大小扩充为指定的值(一般以 0 填充);
    • 扩充完成后,要维持文件中的对齐特性(保证每个节区的起始位置不变);
  4. Characteristics:单字,用于标识文件的属性,文件是否为可运行的形态、是否为 DLL 文件等信息,具体定义位于 winnt.h;

    属性位字段的含义:

    特征值常量符号含义
    0001IMAGE_FILE_RELOCS_STRIPPED文件中不存在重定位信息
    0002IMAGE_FILE_EXECUTABLE_IMAGE文件可执行,如果为 0,一般是链接时出问题了 ⚠️
    0004IMAGE_FILE_LINE_NUMS_STRIPPED行号信息被移除
    0008IMAGE_FILE_LOCAL_SYMS_StRIPPED符号信息被移除
    0020IMAGE_FILE_LARGE_ADDRESS_AWARE应用程序可以处理超过 2GB 的地址
    0080IMAGE_FILE_BYTES_REVERSED_LO处理机的低位字节是相反的
    0100IMAGE_FILE_32BIT_MACHINE目标平台为 32 位机器
    0200IMAGE_FILE_DEBUG_STRIPPED.DBG 文件的调试信息被移除
    0400IMAGE_FILE_REMOVABLE_RUN_FROM_SWAP如果映像文件位于可移动介质中,则先复制到交换文件中再运行
    0800IMAGE_FILE_NET_RUN_FROM_SWAP如果映像文件位于网络中,则复制到交换文件后才运行
    1000IMAGE_FILE_SYSTEM系统文件
    2000IMAGE_FILE_DLL文件是 DLL ⚠️
    4000IMAGE_FILE_UP_SYSTEM_ONLY文件只能运行在单处理器上
    8000IMAGE_FILE_BYTES_REVERSED_HI处理机的高位字节是相反的
  5. TimeDateStamp:双字,用来记录编译器创建此文件的时间;

  6. PointerToSymbolTable:双字,COFF 符号表的文件偏移位置,若没有符号表存在,将此值设置为 0;

  7. NumberOfSymbols:双字,如果有符号表,它表示其中的符号数目;

NT 头:可选头 IMAGE_OPTIONAL_HEADER

IMAGE_OPTIONAL_HEADER 是 PE 头结构体中最大的;

可选头又分为两部分,前 10 个字段属于 COFF,用来加载和运行一个可执行文件,后 21 个字段则是通过链接器追加的,作为 PE 扩展部分,用于描述可执行文件的一些信息,供 PE 加载器加载使用;

IMAGE_OPTIONAL_HEADER32

首先在 DOS 头中找到 PE 头(NT 头)的偏移量,然后黄色表示的是签名(IMAGE_NT_SIGNATURE)结构体,红色表示 IMAGE_FILE_HEADER 结构体,黑色和蓝色表示 IMAGE_OPTIONAL_HEADER32 结构体,其中,黑色为 IMAGE_OPTIONAL_HEADER32 结构体中需要重点关注的部分(DataDirectory 成员未标注);

在 IMAGE_OPTIONAL_HEADER32 结构体中需要关注下列成员,这些成员是文件运行必需的,设置错误将导致文件无法正常运行:(偏移量基于 PE 文件头 (IMAGE_NT_HEADERS))

偏移量字段Size注释
18MagicWORD标志字
28AddressOfEntryPointDWORD程序执行入口 RVA
34ImageBaseDWORD程序默认载入基地址
38SetionAlignmentDWORD内存中区块的对齐值
3CFileAlignmentDWORD文件中区块的对齐值
50SizeOfImageDWORD内存中的整个 PE 映像尺寸
54SizeOfHeadersDWORD整个 PE 头的大小
5CSubsystemWORD文件子系统
74NumberOfRvaAndSizesDWORD数据目录表的项目数量
78DataDirectory数据目录表
  1. Magic:单字,为 IMAGE_OPTIONAL_HEADER32 结构体时,Magic 码为 10B;为 IMAGE_OPTIONAL_HEADER64 结构体时,Magic 码为 20B,文件为 ROM 映像时,Magic 码为 107;

  2. AddressOfEntryPoint:双字,持有 EP 的 RVA 值,该值指出程序最先执行的代码的起始地址,非常重要;

  3. ImageBase:双字,进程的虚拟内存范围是 00000000 ~ FFFFFFFF(32 位系统),PE 文件被加载到如此大的内存中时,ImageBase 指出文件的优先装载地址,而 ImageBase 就是文件在内存中的首选载入地址, 如果 PE 文件是在这个地址载入的,那么加载器将跳过应用基址重定位的步骤;

    • EXE、DLL 文件被装载到用户内存的 00000000 ~ 7FFFFFFF 中;

    • SYS 文件被装载到内核内存的 80000000 ~ FFFFFFFF 中;

    • 一般而言,使用开发工具创建的 EXE 文件,其默认 ImageBase 值为 00400000,DLL 文件的 ImageBase 值为 10000000;(可以指定为其它值)
      用户可以自定义 ImageBase 的值,但取值有限:

      • 取值不能超出边界,即取的值必须在进程地址空间中;
      • 该值必须是 64KB 的整数倍;
    • 执行 PE 文件时,PE 装载器先创建进程,再将文件载入内存,然后把 EIP 寄存器的值设置为 ImageBase + AddressOfEntryPoint

      ImageBase + AddressOfEntryPoint

      如图所示,AddressOfEntryPoint 为 0000739D,ImageBase 为 01000000,则 EIP 的值(也就是 EP)为:0100739D;

      勘误:#### NT 头:可选头 IMAGE_OPTIONAL_HEADER 开始位置,图中的签名(IMAGE_NT_SIGNATURE)结构体标注错了,有发现的小伙伴吗?

  4. SetionAlignment / FileAlignment

    PE 体,也就是 PE 的 Body 部分划分为若干个节区,这些节区存储着不同类别的数据;

    SetionAlignment:双字,指定了节区在内存中的最小单位,每个节区被载入的地址必定是此字段指定数值的整数倍;

    SetionAlignment 默认的对齐尺寸是目标 CPU 的页尺寸,对于 32 位操作系统来说,这个值是 4KB(16 进制表示为 1000),对于 64 位操作系统来说,这个值是 8KB(16 进制表示为 2000);

    FileAlignment:双字,指定了节区在磁盘文件中的最小单位,组成块的原始数据必须保证从本字段的倍数地址开始;

    通常情况下,Windows 会选择使用 512 字节(最大为 4KB)的簇大小(一个物理扇区的大小)来格式化分区,用 16 进制表示为 200,这就是常见的代码段、数据段等起始地址是 200 的倍数的原因;

    SetionAlignment 必须大于或等于 FileAlignment,如果 SetionAlignment 被定义为小于操作系统页的大小,则 SetionAlignment 和 FileAlignment 的值必须相等;

  5. SizeOfImage:双字,加载 PE 文件到内存时,SizeOfImage 指定了 PE 映像在虚拟内存中所占空间的大小;

    映像载入内存后的总尺寸,是指载入文件从 ImageBase 到最后一个块的大小,最后一个块根据其大小向上取整;

    一般而言,文件的大小与加载到内存中的大小是不同的(节区头中定义了各节装载的位置与占有内存的大小),SizeOfImage 可以比实际的值大,但不能比它小,且 SizeOfImage 必须是 SetionAlignment 的整数倍;

  6. SizeOfHeaders:双字,用来指出整个 PE 头的大小,该值必需是 FileAlignment 的整数倍;

    MS-DOS 头部、PE 文件头、节表的总尺寸,是按照文件对齐粒度对齐后的大小(含补足的 0);

    第 1 节区所在位置与 SizeOfHeaders 距文件开始偏移的量相同;

  7. Subsystem:单字,用来区分系统驱动文件(*.sys)与普通的可执行文件(*.exe,*.dll);

    一个标明可执行文件所期望的子系统(用户界面类型)的枚举值,这个值只对 EXE 重要;

    成员值如下:

    常量符号说明
    0IMAGE_SUBSYSTEM_UNKNOWN未知的子系统
    1IMAGE_SUBSYSTEM_NATIVE设备驱动和 Native Windows 进程
    2IMAGE_SUBSYSTEM_WINDOWS_GUI图形接口子系统,Windows 图形用户界面
    3IMAGE_SUBSYSTEM_WINDOWS_CUI字符子系统,Windows 字符模式(控制台)
    7IMAGE_SUBSYSTEM_POSIX_CUIPOSIX 字符子系统 (控制台)
    9IMAGE_SUBSYSTEM_WINDOWS_CE_GUIWindows CE 图形界面
    10IMAGE_SUBSYSTEM_EFI_APPLICATION可扩展固件接口(EFI)应用程序
    11IMAGE_SUBSYSTEM_EFI_BOOT_SERVICE_DRIVER带引导服务的 EFI 驱动程序
    12IMAGE_SUBSYSTEM_EFI_RUNTIME_DRIVER带运行时服务的 EFI 驱动程序
    13IMAGE_SUBSYSTEM_EFI_ROMEFI ROM 映像
    14IMAGE_SUBSYSTEM_XBOXXBOX
  8. NumberOfRvaAndSizes:双字,用来指定 DataDirectory 数组的个数;

    数据目录表(DataDirectory)的项数,这个字段的值从 Windows NT 发布以来一直是 16(10),实际应用中可以取 2 ~ 16 的值;

    PE 装载器通过查看 NumberOfRvaAndSizes 的值来识别数组大小;

  9. DataDirectory:是由 IMAGE_DATA_DIRECTORY 结构体组成的数组,数组的每项都有被定义的值,指向输出表、输入表、资源块等数据;

    DataDirectory

    数据目录表的每个成员占 8 个字节,分别指向相关的结构体,前 4 个字节表示地址,后 4 个字节表示大小,最后一个成员必须为 0;

    序号成员结构偏移量(PE / PE32+)描述
    0Export Table ⚠️IMAGE_DIRECTORY_ENTRY_EXPORT78 / 88导出表
    1Import Table ⚠️IMAGE_DIRECTORY_ENTRY_IMPORT80 / 90导入表
    2Resources Table ⚠️IMAGE_DIRECTORY_ENTRY_RESOURCE88 / 98资源表
    3Exception TableIMAGE_DIRECTORY_ENTRY_EXCEPTION90 / A0异常表
    4Security TableIMAGE_DIRECTORY_ENTRY_SECURITY98 / A8属性证书表
    5Base relocation TableIMAGE_DIRECTORY_ENTRY_BASERELOCA0 / B0基地址重定位表
    6DebugIMAGE_DIRECTORY_ENTRY_DEBUGA8 / B8调试信息表
    7CopyrightIMAGE_DIRECTORY_ENTRY_COPYRIGHTB0 / C0版权表
    8Global PtrIMAGE_DIRECTORY_ENTRY_GLOBALPTRB8 / C8全局指针表
    9Thread local storage (TLS) ⚠️IMAGE_DIRECTORY_ENTRY_TLSC0 / D0线程本地存储
    10Load configurationIMAGE_DIRECTORY_ENTRY_LOAD_CONFIGC8 / D8加载配置表
    11Bound ImportIMAGE_DIRECTORY_ENTRY_BOUND_IMPORTD0 / E0绑定导入表
    12Import Address Table (IAT) ⚠️IMAGE_DIRECTORY_ENTRY_IATD8 / E8导入函数地址表
    13Delay ImportIMAGE_DIRECTORY_ENTRY_DELAY_IMPORTE0 / F0延迟导入表
    14COM descriptorIMAGE_DIRECTORY_ENTRY_COM_DESCRIPTORE8 / F8
    15保留,必须为 0F0 / 100系统保留

    PE 文件在定位输出表、输入表和资源表等重要数据时,就是从 IMAGE_DATA_DIRECTORY 结构体开始的;

节区头 IMAGE_SECTION_HEADER 结构体数组

PE 文件中的 code(代码)、data(数据)、resource(资源)等按照属性分类存储在不同节区;

节区头中定义了各节区属性;

节区属性中有文件/内存的起始位置、大小、访问权限等;

不同内存属性的访问权限:

类别访问权限
code执行,读取权限
data非执行,读写权限
resource非执行,读取权限

节区头是由 IMAGE_SECTION_HEADER 结构体组成的数组,每个结构体对应一个节区,每个结构体包含它所关联区块的信息,如位置、长度、属性等,该数组的数目由 IMAGE_NT_HEADERS.IMAGE_FILE_HEADER.NumberOfSections 指出,说人话就是:节区头中结构体的的数量由 PE 头中的可选头(扩展信息头)中的 NumberOfSections 指定;

IMAGE_SECTION_HEADER 结构体中的重要成员如下:偏移量基于节区头(当前 IMAGE_SECTION_HEADER 结构体)

偏移量项目含义
8VirtualSize内存中节区所占大小
0CVirtualAddress内存中节区起始位置(RVA)
10SizeOfRawData磁盘文件中节区所占大小
14PointerToRawData磁盘文件中节区起始位置
24Characteristics节区属性(bit OR)
  1. VirtualSize:双字,指出实际被使用的区块的大小,是在进行对齐处理前区块的实际大小;

  2. VirtualAddress:双字,该块装载到内存中的 RVA,这个地址是按照内存页对齐的,它的数值总是 SectionAlignment 的整数倍;

  3. SizeOfRawData:双字,该块在磁盘中所占的空间,在可执行文件中,该字段包含经 FileAlignment 调整的块的大小;

  4. PointerToRawData:双字,该块在磁盘文件中的偏移,程序经编译或汇编后生成原始数据,这个字段用于给出原始数据在文件中的偏移;

    如果程序装载自 PE 或 COFF 文件(而不是由操作系统载入的),这一字段将比 VirtualAddress 还重要,在这种情况下,必须完全使用线性映像的方法载入文件,所以需要在该偏移处找到块的数据,而不是 VirtualAddress 字段中的 RVA 地址;

  5. Characteristics:双字,块属性,该字段是一组指出块属性的标志,多个标志值求或即为 Characteristics 的值;

    Characteristics 的值由下列值组合而成:

    定义说明
    IMAGE_SCN_CNT_CODE00000020包含代码,常与 10000000 一起设置
    IMAGE_SCN_CNT_INITIALIZED_DATA00000040该块包含已初始化的数据
    IMAGE_SCN_CNT_UNINITIALIZED_DATA00000080该块包含未初始化的数据
    IMAGE_SCN_MEM_DISCARDABLE02000000该块可被丢弃,因为它一旦被载入,进程就不再需要它了
    常见的可丢弃块是 .reloc(重定位块)
    IMAGE_SCN_MEM_SHARED10000000该块为共享块
    IMAGE_SCN_MEM_EXECUTE20000000该块可执行,通常当 00000020 标志被设置时,该标志也被设置
    IMAGE_SCN_MEM_READ40000000该块可读,可执行文件中的块总是设置该标志
    IMAGE_SCN_MEM_WRITE80000000该块可写,如果 PE 文件中没有设置该标志,装载程序就会将内存映像页标记为可读或可执行

VirtualAddress 与 PointerToRawData 不带有任何值,分别由定义在 IMAGE_OPTIONAL_HEADER32 中的 SectionAlignment 与 FileAlignment 确定;

VirtualSize 与 SizeOfRawData 一般具有不同的大小,即磁盘文件中节区的大小与加载到内存中的节区的大小是不同的;

如果 VirtualSize 的值大于 SizeOfRawData 的值,那么 SizeOfRawData 表示来自可执行文件初始化数据的大小,与 VirtualSize 相差的字节用 0 填充;

最后,PE 规范未明确规定节区的 Name,所以可以向其中放入任何值,甚至可以填充 NULL 值,因此,节区的 Name 字段仅供参考,不能保证其百分百的用作某种信息,说人话就是:节区的名字不可信;

RVA to RAW

PE 文件加载到内存时,每个节区都要能准确完成内存地址与文件偏移间的映射,这种映射一般称为 RVA to RAW;

方法如下:

  1. 查找 RVA 所在节区;

  2. 使用简单的公式计算文件偏移(RAW)

    根据 IMAGE_SECTION_HEADER 结构体,换算公式如下:

    1
    2
    RAW - PointerToRawData = RVA - VirtualAddress
    RAW = RVA - VirtualAddress + PointerToRawData

IAT(Import Address Table,导入地址表)

在 PE 文件内有一组数据结构,它们分别对应于被输入的 DLL,每一个这样的结构体都给出了被输入的 DLL 的名称并指向一组函数指针,这组函数指针称为输入地址表(IAT);

每一个被引入的 API 在 IAT 里都有保留的位置,在那里它将被 Windows 加载器写入输入函数的地址,也就是调用位置;

一旦模块被载入,IAT 中将包含所要输入函数的地址;

把所有输入函数放在 IAT 中,这样,无论代码中用一个输入函数多少次,都会通过 IAT 中的同一个函数指针来完成;

16 位的 DOS 时代不存在 DLL 这一概念,只有“库”一说,比如在 C 语言中使用 printf() 函数时,编译器会从 C 语言库中读取相应函数的二进制代码,然后插入应用程序的源代码中,也就是说,可执行文件中包含着 printf() 函数的二进制代码;

Windows 系统支持多任务,如果同时运行多个程序,而每个程序包含相同的库,将造成严重的内存浪费和磁盘浪费,因此,Windows 引入了 DLL 这一概念,描述如下:

  1. 不要把库包含到程序中,单独组成 DLL 文件,需要时调用即可;
  2. 内存映射技术使加载后的 DLL 代码、资源在多个进程中实现共享;
  3. 更新库时,只要替换相关的 DLL 文件即可,简单易行;

加载 DLL 的方式有两种:

  1. “显式链接”(Explicit Linking),程序使用 DLL 时加载,使用完毕后释放内存,必须确定目标 DLL 已经被加载,然后才寻找 API 的地址,这几乎总是通过调用 LoadLibrary 和 GetProcAddress 完成的;
  2. “隐式链接”(Implicit Linking),程序开始时即同时加载 DLL,程序终止时再释放占用的内存,Windows 加载器保证 PE 文件所需的任何附加的 DLL 都被载入,调用 LoadLibrary 和 GetProcAddress 的过程由 Windows 加载器完成;

IAT 提供的机制与隐式链接有关;

大多程序在调用 API 时,都并非直接调用,而是通过获取指定地址处的值来实现调用,为什么不直接调用呢?

这是由于,程序的制作者编译程序时,无法确定程序的运行环境(XP、Vista、7、10)、语言(ENG、CHS、JPN、KOR)、服务包(Service Pack),不同环境中,使用的 DLL 版本各不相同,指定的 API 的实际位置也会不同;

为了确保能在所有环境中都能正常调用 API,编译器准备并记录要保存 API 的实际地址,执行文件时,PE 装载器会将 API 的地址写入到这个实际地址;

编译器不使用直接指令调用 API 的另一个原因在于 DLL 重定向。

DLL 文件的 ImageBase 值一般为 10000000,比如程序使用了 a.dll 和 b.dll,PE 装载器先将 a.dll 装载到内存的 10000000 处,然后尝试将 b.dll 也装载到该处,但由于该地址已经装载了 a.dll,所以,PE 装载器会查找其它空白的内存空间,然后将 b.dll 装载进去,这就是 DLL 重定向,这就使得无法对实际地址进行硬编码;

还有一个原因在于:PE 头中表示地址时使用 RVA,而不是 VA;

实际操作中,无法保证 DLL 一定会被加载到 PE 头指定的 ImageBase 处,但是 EXE 文件(生成进程的主体)却能准确的加载到自身的 ImageBase 中,这是因为它拥有自己的虚拟空间;

输入函数的调用

输入函数就是被程序调用但其执行代码不在程序中的函数,这些函数的代码位于相关的 DLL 中,在调用程序中只保留相关的函数信息,如函数名、DLL 文件名等;

对磁盘上的 PE 文件来说,它无法得知这些输入函数在内存中的地址;

只有当 PE 文件载入内存后,Windows 加载器才会将相关 DLL 载入,并将调用输入函数的指令和函数实际所处的地址联系起来;

当应用程序调用一个 DLL 的代码和数据时,它正在被隐式链接到 DLL,这个过程完全由 Windows 加载器完成;

IMAGE_IMPORT_DESCRIPTOR 导入表描述符

IMAGE_IMPORT_DESCRIPTOR 结构体中记录着 PE 文件要倒入哪些库文件;

可执行文件使用来自其它 DLL 的代码或数据的动作称为输入(import);

当 PE 文件被载入时,Windows 加载器的工作之一就是定位所有被输入的函数和数据,并让正在载入的文件可以使用这些地址,这个过程是通过 PE 文件的输入表(Import Table,简称“IT”,也称导入表)完成的;

输入表中保存的是函数名和其驻留的 DLL 名等动态链接库所需的信息;

在 PE 文件头的可选映像头中,数据目录表的第 2 个成员指向输入表,即 IMAGE_NT_HEADERS.IMAGE_OPTIONAL_HEADER.DataDirectory[1],也就是 IMAGE_DIRECTORY_ENTRY_IMPORT;

输入表(数组)以 IMAGE_IMPORT_DESCRIPTOR(IID) 结构体开始,每个被 PE 文件隐式链接的 DLL 都有一个 IID,这个数组中,没有字段指出该结构体数组的项数,但它的最后一个单元是 NULL,由此可以计算出该数组的项数;

执行一个普通程序时,往往需要导入多个库,导入多少个库就存在多少个 IID 结构体,这些结构体形成了数组,且结构体数组最后以 NULL 结构体结尾;

IMAGE_IMPORT_DESCRIPTOR 结构体的成员:

项目含义
OriginalFirstThunk(Characteristics)⚠️INT 的地址(RVA)
TimeDateStamp一个 32 位的时间标志,可忽略
ForwarderChain第 1 个被转向的 API 的索引,一般为 0
Name ⚠️库名称字符串的地址(RVA)
FirstThunk ⚠️IAT 的地址(RVA)

INT:输入名称表(Import Name Table);

IAT:输入地址表(Import Address Table);

  1. OriginalFirstThunk(INT):双字,包含指向输入名称表(INT)的 RVA;
    INT 是一个包含导入函数信息(Ordinal,Name)的结构体指针数组,只有获得了这些信息,才能在加载到进程内存的库中准确求得相应函数的起始地址;
    INT 是一个 IMAGE_THUNK_DATA 结构的数组,数组中的每个 IMAGE_THUNK_DATA 结构都指向 IMAGE_IMPORT_BY_NAME 结构,数组尾部以 NULL 结束,即数组以一个内容为 0 的 IMAGE_THUNK_DATA 结构体结束;
  2. TimeDateStamp:双字,一个 32 位的时间标志,可忽略;
  3. ForwarderChain:双字,这是第 1 个被转向的 API 的索引,一般为 0,在程序中引用一个 DLL 中的 API,而这个 API 又在引用其它 DLL 的 API 时使用;
  4. Name:双字,DLL 名字的指针,是一个以 “00” 结尾的 ASCII 字符的 RVA 地址,该字符包含输入 DLL 的名称;
    Name 是一个字符串指针,它指向导入函数所属的库文件的名称;
  5. FirstThunk(IAT):双字,包含指向输入地址表(IAT)的 RVA;

OriginalFirstThunk 与 FirstThunk 相似,它们分别指向两个本质上相同的数组 IMAGE_THUNK_DATA 结构;

两个数组中都有 IMAGE_THUNK_DATA 结构类型的元素,它是一个指针大小的联合(union);

每个 IMAGE_THUNK_DATA 元素对应于一个从可执行文件输入的函数;

两个数组的结束都是由一个值为 0 的 IMAGE_THUNK_DATA 元素表示;

IMAGE_THUNK_DATA 结构实际上是一个双字,该结构在不同时刻有不同的含义,定义如下:

1
2
3
4
5
6
7
8
IMAGE_THUNK_DATA STRUCT
union ul
ForwarderString DWORD ? ; 指向一个转向者字符串的 RVA
Function DWORD ? ; 被输入的函数的内存地址
Ordinal DWORD ? ; 被输入的 API 的序数值
AddressOfData DWORD ? ; 指向 IMAGE_IMPORT_BY_NAME
ends
IMAGE_THUNK_DATA ENDS

当 IMAGE_THUNK_DATA 值的最高位(双字的最高位)为 1 时,表示函数以序号方式输入,这时低 31 位(或者 64 位可执行文件的低 63 位)被看成一个函数序号;

当 IMAGE_THUNK_DATA 值的最高位(双字的最高位)为 0 时,表示函数以字符串类型的函数名方式输入,这时双字的值是一个 RVA,指向一个 IMAGE_IMPORT_BY_NAME 结构;

IMAGE_IMPORT_BY_NAME 结构仅有 1 个字大小,存储了一个输入函数的相关信息,定义如下:

1
2
3
4
IMAGE_IMPORT_BY_NAME STRUCT
Hint WORD ? ; 函数编号
Name BYTE ? ; 表示函数名称的字符串
IMAGE_IMPORT_BY_NAME ENDS
  1. Hint:本函数在其所驻留 DLL 的输入表中的序号,该域被 PE 装载器用来在 DLL 的输出表里快速查询函数;
    该值不是必需的,一些链接器将它设为 0;
  2. Name:含有输入函数的函数名,函数名是一个 ASCII 字符串,以 NULL 结尾;
    这里虽然将 Name 的大小以字节为单位进行定义,但其实它是一个可变尺寸域,由于没有更好的表示方式,只好在定义中写成 BYTE;

PE 装载器把导入函数输入至 IAT 的顺序:

1
2
3
4
5
6
7
8
1. 读取 IID 的 Name 成员,获取库名称字符串;
2. 装载相应库:LoadLibrary("库名称.dll")
3. 读取 IID 的 OriginalFirstThunk 成员,获取 INT 地址;
4. 逐一读取 INT 数组中的值,获取相应的 IMAGE_IMPORT_BY_NAME(RVA);
5. 使用 IMAGE_IMPORT_BY_NAME 的 Hint 项或 Name 项,获取相应函数的起始位置;
6. 读取 IID 的 FirstThunk 成员,获得 IAT 地址;
7. 将上面获得的函数地址输入相应 IAT 数组中;
8. 重复以上步骤 4~7,直到 INT 表结束(遇到 NULL);

以 NOTEPAD.EXE 为例,梳理思路,强化学习

  1. 在 PE 文件头的可选映像头中,数据目录表的第 2 个成员指向输入表;

  2. 数据目录表是 IMAGE_DIRECTORY_ENTRY_IMPORT,位于 IMAGE_OPTIONAL_HEADER32.DataDirectory[1];

  3. 而 IMAGE_OPTIONAL_HEADER32 位于 IMAGE_NT_HEADERS,也就是 PE 头;

  4. 使用 WinHex 打开 NOTEPAD.EXE,找到 PE 头的偏移位置(位于从文件开始偏移 3C 字节处);

    PE头的偏移位置

  5. 找到 PE 头后,IMAGE_OPTIONAL_HEADER32 是其的第 3 个成员,所以,直接定位到第 3 个成员,然后找到 DataDirectory:

    IMAGE_DIRECTORY_ENTRY_IMPORT

    绿色是 IMAGE_NT_SIGNATURE;

    黄色是 IMAGE_FILE_HEADER;

    蓝色是 IMAGE_OPTIONAL_HEADER32 的其它成员,从 158 位置开始,是 DataDirectory 数组成员,也就是数据目录表成员,最后的红色则表示 DataDirectory[1];

    数据目录表的每个成员占 8 个字节,分别指向相关的结构体,前 4 个字节表示地址,后 4 个字节表示大小;

    所以在这里,IMAGE_DIRECTORY_ENTRY_IMPORT 的相对虚拟地址是7604;

  6. 由于得到的地址是 RVA,所以还需要转换为 RAW,先来看看 RAW 的计算公式:RAW = RVA - VirtualAddress + PointerToRawData,这其中的 RVA 是已知的,也就是 7604,而 VirtualAddress 和 PointerToRawData 如何得知呢?

    VirtualAddress 和 PointerToRawData 位于 IMAGE_SECTION_HEADER 结构体;

    IMAGE_SECTION_HEADER 结构体在 PE 头的下方,所以 DataDirectory 数组之后就是 IMAGE_SECTION_HEADER 结构体,也就是 节区表(区块表);

    节区表(区块表)

    图中选中部分是 DataDirectory 数组;

    红色是第 1 个节区的部分参数,第 3 个参数说明,此节区(.text)在内存中的起始位置(VirtualAddress)是 00001000,第 5 个参数说明,此节区在磁盘文件中的偏移(PointerToRawData)是 00000400;

    绿色是第 2 个节区的部分参数,第 3 个参数说明,此节区(.data)在内存中的起始位置(VirtualAddress)是 00009000,这里为什么要看第 2 节区的 VirtualAddress 呢?因为确定了第 2 节区的起始位置,也就确定了第 1 节区的结束位置,也就是范围,很显然,1000 < 7604 < 9000,位于第 1 节区(.text);

    根据公式计算,第 1 节区的 RAW = 7604 - 1000 + 400,结果是6A04

  7. 既然已知第 1 节区的 RAW,按下ALT + G或者菜单栏选择位置 >> 转到偏移位置

    第 1 节区的 RAW

    图中所示就是第 1 节区 IID,共有 5 个成员,分别来看一下:

    • 第 1 个成员是 OriginalFirstThunk:INT 的地址(RVA);根据计算,其 RAW 是6D90

      OriginalFirstThunk

      跳转到6D90可以看到输入名称表数组,最后一个单元为 NULL,跟随第 1 个成员,查看其名称,RVA:7A7A -> RAW:6E7A

      跟随第 1 个成员

      初始的 2 个字节为 Ordinal,是库中函数的固有编号,Ordinal 后面是导入函数的名称字符串PageSetupDlgW(末尾以 Terminating NULL[‘\0’]结尾);

    • 第 2 个成员是 TimeDateStamp,由于这里的值为 0,故忽略;

    • 第 3 个成员是 ForwarderChain,其值也为 0,忽略;

    • 第 4 个成员是 Name:DLL 名字的指针;RVA:7AAC -> RAW:6EAC;

      DLL 名字的指针

      可以看到,包含PageSetupDlgW的库文件是comdlg32.dll

    • 第 5 个成员是 FirstThunk(IAT):包含指向输入地址表(IAT)的 RVA;RVA:12C4 -> RAW:6C4;

      FirstThunk(IAT)

      选中区域即为 IAT 数组区域,对应于 cmdlg32.dll 库,与 INT 类似,由结构体指针组成,且以 NULL 结尾;

EAT(Export Address Table,导出地址表)

在 Windows 系统中,“库”是为了方便其它程序调用而集中包含相关函数的文件(DLL / SYS);

创建 DLL 时,实际上创建了一组能让 EXE 或其它 DLL 调用的函数;

程序运行时, PE 装载器会根据 DLL 文件中输出的信息修正被执行文件的 IAT;

当一个 DLL 函数能够被 EXE 或其它 DLL 文件使用时,它就被“输出了(Exported)”;

输出信息被保存在输出表中,DLL 文件通过输出表向系统提供输出函数名、序号和入口地址等信息;

EAT 是一种核心机制,它使不同的应用程序可以调用库文件中提供的函数,也就是说,只有通过 EAT 才能准确获得从相应库中导出函数的起始地址;

IMAGE_EXPORT_DESCRIPTOR

与 IAT 一样,PE 文件内特定结构体(IMAGE_EXPORT_DESCRIPTOR)保存着导出信息,且 PE 文件中仅有一个用来说明库 EAT 的 IMAGE_EXPORT_DESCRIPTOR 结构体;

输出表的主要内容是一个表格,其中包括函数名称、输出序数等,序数是指 DLL 中某个函数的 16 位数字,在所指向的 DLL 中是独一无二的;
输出表是数据目录表的第 1 个成员,指向 IMAGE_EXPORT_DESCRIPTOR(简称 IED)结构;

IMAGE_EXPORT_DESCRIPTOR 结构体的重要成员:

字段Size注释
NameDWORD模块(DLL)的真实名称
NumberOfFunctions ⚠️DWORD实际 Export 函数的个数
NumberOfNames ⚠️DWORDExport 函数中具名函数的个数(ENT)
AddressOfFunctions ⚠️DWORDExport 函数地址数组(数组元素个数 = NumberOfFunction)
AddressOfNames ⚠️DWORD函数名称地址数组(数组元素个数 = NumberOfNames)
AddressOfNameOrdinals ⚠️DWORD序号地址数组(数组元素个数 = NumberOfNames)

ENT:输出函数名称表(Export Name Table);

EAT:输出地址表(Export Address Table);

  1. Name:指向一个 ASCII 字符串的 RVA,这个字符串是与输出函数相关联的 DLL 的名字;
  2. NumberOfFunctions: EAT 中的条目数量,0 表示没有代码或数据被输出;
  3. NumberOfNames:输出函数名称表(Export Name Table,ENT),NumberOfNames 的值总是小于或等于 NumberOfFunctions 的值;
  4. AddressOfFunctions:EAT 的 RVA,EAT 是一个 RVA 数组,数组中的每一个非零 RVA 都对应于一个被输出的序号;
  5. AddressOfNames:ENT 的 RVA,ENT 是一个指向 ASCII 字符串的 RVA 数组,每一个 ASCII 字符串对应于一个通过名字输出的序号;
  6. AddressOfNameOrdinals:输出序数表的 RVA,这个表是字(WORD)的数组,这个表将 ENT 中的数组索引映射到相应的输出地址表条目;

设计输出表是为了方便 PE 装载器工作;

模块必须保存所有输出函数的地址,供 PE 装载器查询;

模块将这些信息保存在 AddressOfFunctions 域指向的数组中,而数组元素的数量存放在 NumberOfFunctions 域中;

如果有些函数是通过名字引出的,这些名字的 RVA 值会存放在 AddressOfNames 域指向的数组中,数组元素的数量存放在 NumberOfNames 域中,以供 PE 装载器查询;

AddressOfNames 域指向的数组中仅包含函数名,并不包含函数的地址;

AddressOfNameOrdinals 域指向的数组中包含(AddressOfNames 数组中)函数名对应的(在 AddressOfFunctions 数组中的)索引,所以,序数表和名称表的元素数量相同;

PE 装载器会通过 AddressOfNameOrdinals 域指向的数组获取 AddressOfNames 域指向的数组中的函数名在 AddressOfFunctions 域指向的数组中对应的索引,从而获取函数地址;

以 KERNEL32.DLL 为例,梳理思路,强化学习

  1. 使用 WinHex 打开 KERNEL32.DLL,找到 IMAGE_EXPORT_DESCRIPTOR,位于 IMAGE_OPTIONAL_HEADER32.DataDirectory[0];

    IMAGE_EXPORT_DESCRIPTOR

    可以看到,IMAGE_EXPORT_DESCRIPTOR 的 RVA 为262C,则 RAW 为1A2C;

  2. 跳转到1A2C

    `1A2C`

    选中部分为 IMAGE_EXPORT_DESCRIPTOR 所有成员,红色部分依次是 Name、AddressOfFunctions、AddressOfNames 以及 AddressOfNameOrdinals;

  3. 首先,查看 Name,RVA:4B98 -> RAW:3F98:

    3F98

    可以看到,库名称为KERNEL32.dll(以’\0’结尾);

  4. 返回1A2C,然后查看 AddressOfFunctions,RVA:2654 -> RAW:1A54:

    1A54

    可以看到,第 1 个输出函数的 RAV 为:A6E4;

    ImageBase

    KERNEL32.DLL 的 ImageBase 为:7C800000,故第 1 个输出函数的实际地址(VA)为:7C800000 + A6E4 = 7C80A6E4;

    使用OD打开 KERNEL32.DLL:

    使用`OD`打开 KERNEL32.DLL

    发现 7C80A6E4 对应的函数名为ActivateActCtx

  5. 返回1A2C,接着查看 AddressOfNames,RVA:353C -> RAW:293C:

    293C

    指向函数名字符串的指针是:4BA5,则 RAW 为:3FA5:

    3FA5

    对应的函数名为ActivateActCtx,与查看 AddressOfFunctions 时获取的函数名一致;

  6. 返回1A2C,最后来看 AddressOfNameOrdinals,RVA:4424 -> RAW:3824:

    3824

    对应的索引值为 0,没问题;