PE 全称 Portable Executable (可移植可执行文件格式),是 Windows 操作系统下可执行文件(.exe​)、动态链接库(.dll​)、驱动程序(.sys​)等的标准格式。其核心作用是:告诉操作系统如何加载程序、分配内存、解析函数地址等。

何为PE

PE 文件(Portable Executable)格式是 Windows 操作系统中用于存储可执行程序、动态链接库(DLL)、驱动程序等文件的标准格式。PE 文件格式基于 COFF(Common Object File Format)Unix 的 Executable and Linkable Format(ELF) ,并在此基础上做了改进,以适应 Windows 操作系统的需求。

PE 文件格式的核心基于 COFF 格式,它最早用于 Unix 操作系统中的目标文件格式。COFF 是一种用于存储编译后代码和数据的文件格式,它定义了如何存储代码、数据段、符号表、重定位信息等,目标文件中的每个部分都有明确的定义。Windows NT 系统采用了 COFF 格式作为可执行文件格式的基础,并对其进行了扩展和改进,最终形成了 PE 格式。

虽然 PE 文件并没有直接基于 ELF,但 ELF 格式的设计思路对 PE 文件的构建有一定影响。ELF 格式广泛应用于 Unix 和类 Unix 系统(如 Linux 和 BSD)。与 COFF 相似,ELF 定义了如何描述目标文件和可执行文件的结构,尤其在动态链接和加载方面提供了灵活性。

Windows NT 是微软为支持多平台和多处理器架构(如 x86、MIPS、Alpha 等)而开发的操作系统。在设计 Windows NT 的可执行文件格式时,微软结合了 COFF 和 ELF 的优点,并根据 Windows 特有的需求进行了修改,形成了 PE 文件格式。

解剖PE

PE文件的结构可以使用以下两张图片非常直观的表现出来。

可以将它转换为一个目录结构:

.exe​文件

  • 文件头(Header)

    • Dos头(DOS Header)
    • PE头(PE Header)
    • 可选头(Optional Header)
    • 数据目录(Data directories)
    • 节表(Sections table)
  • 节区(Sections)

    • 代码(Code)
    • 导入表(Imports)
    • 数据(Data)

我们使用C​编译一个简单的HelloWorld.exe​以便分析,这里我编译为32位

#include <stdio.h>
int main()
{
  printf("Hello, World!\n");
  return 0;
}

Header

在 PE 文件(Portable Executable 文件)格式中,Header 是文件的开头部分,它包含了文件的基本信息,用于描述可执行文件如何被加载和执行。PE 文件的头部分主要包括两个重要的头部结构:DOS Header(DOS 头)​和PE Header(PE 头)(也叫 "NT Header")​。此外,PE 文件头还包含一些可选的部分,如 Optional HeaderSection Table(节区表)。我们来详细解释这些部分。

Dos Header

Dos Header又由Dos Header​主题与Dos Stub​组成。

Dos Header

使用CFF Explorer​和PEView​打开编译好的HelloWorld.exe

可以得出DOS头文件的结构体定义为

typedef struct _IMAGE_DOS_HEADER {
    WORD e_magic;           // 0x00: 魔术数字,2字节,用于标识DOS可执行文件。通常为 'MZ'。
    WORD e_cblp;            // 0x02: 文件最后一页的字节数,2字节。
    WORD e_cp;              // 0x04: 文件中的页面数,2字节。
    WORD e_crlc;            // 0x06: 重定位项的数目,2字节。
    WORD e_cparhdr;         // 0x08: 头部的段大小(以段为单位),2字节。
    WORD e_minalloc;        // 0x0A: 程序所需的最小额外段数,2字节。
    WORD e_maxalloc;        // 0x0C: 程序所需的最大额外段数,2字节。
    WORD e_ss;              // 0x0E: 初始的(相对的)SS值,2字节,表示栈段。
    WORD e_sp;              // 0x10: 初始的SP值,2字节,栈指针。
    WORD e_csum;            // 0x12: 校验和,2字节,用于验证文件的完整性。
    WORD e_ip;              // 0x14: 初始的IP值,2字节,指令指针。
    WORD e_cs;              // 0x16: 初始的(相对的)CS值,2字节,表示代码段。
    WORD e_lfarlc;          // 0x18: 重定位表的文件地址,2字节。
    WORD e_ovno;            // 0x1A: 覆盖号,2字节,指定不同的覆盖文件。
    WORD e_res[4];          // 0x1C: 保留字段,4个字(8字节),通常未使用。
    WORD e_oemid;           // 0x24: OEM标识符,2字节,特定OEM的标识。
    WORD e_oeminfo;         // 0x26: OEM信息,2字节,具体OEM的附加信息。
    WORD e_res2[10];        // 0x28: 保留字段,10个字(20字节),通常未使用。
    LONG e_lfanew;          // 0x3C: 新的EXE头的文件地址,4字节,指向PE头部的位置。
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;

其中各字段的解释及字节占用如下:

  1. e_magic​ (2 bytes)

    • 用途:这个字段存储一个固定的魔数,用于标识文件为 DOS 可执行文件。
    • 0x5A4D​,即 "MZ"​,表示这是一个 DOS 可执行文件。
  2. e_cblp​ (2 bytes)

    • 用途:表示文件最后一页的字节数。文件按照页面(通常为 512 字节一页)存储。
    • 说明:文件的最后一页可能没有完全填满,因此这个字段指示最后一页剩余的字节数。
  3. e_cp​ (2 bytes)

    • 用途:表示整个文件占用了多少页面(每页 512 字节)。
    • 说明:这个字段指定文件有多少个 512 字节的页面。
  4. e_crlc​ (2 bytes)

    • 用途:表示重定位项的数量。这个字段在现代 PE 文件中不再使用,但在 DOS 可执行文件中用于存储重定位信息。
  5. e_cparhdr​ (2 bytes)

    • 用途:表示头部的大小(以段为单位)。DOS 可执行文件的头部大小通常是 4 字节(一个段),但是根据不同的系统,可能会有所不同。
  6. e_minalloc​ (2 bytes)

    • 用途:表示程序运行所需的最小内存段数。这是程序启动时所需的内存段数量,用于指示 DOS 系统分配给程序的最小内存量。
  7. e_maxalloc​ (2 bytes)

    • 用途:表示程序运行时所需的最大内存段数。
    • 说明:DOS 系统会使用此字段来决定可以分配给程序的最大内存量。
  8. e_ss​ (2 bytes)

    • 用途:表示初始的堆栈段寄存器(SS)的值。
    • 说明:DOS 程序启动时使用此值来设置堆栈。
  9. e_sp​ (2 bytes)

    • 用途:表示初始堆栈指针(SP)的值。
    • 说明:DOS 程序启动时,堆栈指针会从此值开始。
  10. e_csum​ (2 bytes)

    • 用途:表示文件的校验和。DOS 程序使用此值来检查文件的完整性。
  11. e_ip​ (2 bytes)

    • 用途:表示程序启动时的初始指令指针(IP)的值。
  12. e_cs​ (2 bytes)

    • 用途:表示程序启动时的初始代码段寄存器(CS)值。
  13. e_lfarlc​ (2 bytes)

    • 用途:表示重定位表的地址。DOS 程序使用这个地址来存储重定位信息。
  14. e_ovno​ (2 bytes)

    • 用途:表示 DOS 可执行文件的覆盖号。现代的 PE 文件不再使用这个字段。
  15. e_res[4] ​ (8 bytes)

    • 用途:这是一个保留字段,供以后扩展使用,现代 PE 文件不再使用这些值。
  16. e_oemid​ (2 bytes)

    • 用途:表示 OEM 的标识符,用于特定厂商的扩展信息。
    • 说明:通常为 0。
  17. e_oeminfo​ (2 bytes)

    • 用途:与 e_oemid​ 配对,表示 OEM 特定的信息。
  18. e_res2[10] ​ (20 bytes)

    • 用途:这是一个保留字段,供以后扩展使用。现代 PE 文件也不再使用这个字段。
  19. e_lfanew​ (4 bytes)

    • 用途:这个字段非常重要,它指向 PE 头(NT Header)在文件中的偏移位置。
    • 说明e_lfanew​ 通常是 0x3C​,并指向 PE 文件的真正可执行头部的位置。它是从 DOS 头开始到 PE 头的偏移量。

其中最为重要的就是e_magic​和e_lfanew​,我们使用hex编辑器​打开可以找到这些函数存在的样子

为什么 e_magic​ 为 0x5A4D​ 在 CFF Explorer 中显示为 5A4D​,而在 hex 编辑器中却是 4D5A​?

这实际上与 字节顺序Endianess)有关。在不同的工具中,字节顺序可能不同,导致同一数据在不同工具中显示的顺序不同。

  • CFF Explorer 的显示方式
    CFF Explorer 按照 大端序(Big Endian) 显示数据,即高位字节在前,低位字节在后。e_magic​ 由两个字节组成,分别是 0x5A​ 和 0x4D​,因此在 CFF Explorer 中,你会看到它显示为 5A4D​。
  • Hex 编辑器的显示方式
    小端序(Little Endian) 中,低位字节存储在前,高位字节存储在后。e_magic​ 的值是 0x5A4D​,但是在 hex 编辑器中,由于文件的存储是按小端序的方式排列,所以它将字节顺序反转为 4D5A​,即低字节(0x4D​)在前,高字节(0x5A​)在后。

小端序与大端序

  • 大端序(Big Endian) :高字节存储在低地址(最先存储)。
  • 小端序(Little Endian) :低字节存储在低地址(最先存储)。

对于 PE 文件中的 e_magic​(0x5A4D​):

  • 大端序 中,它会被存储为 5A 4D​。
  • 小端序 中,它会被存储为 4D 5A​。

同时根据e_lfanew​也可以找到PE头的位置

DWARF 调试格式 或类似的调试数据结构中,偏移(如 CFF Explorer 中显示的 Pos)通常使用 LEB128 编码。这是因为 LEB128 编码在存储偏移量或地址时非常高效,尤其是对于小的偏移量,能够节省存储空间。

PE 文件的 DOS 头是为了兼容老式的 DOS 系统而保留的部分,它并不直接影响 PE 文件的加载和执行,但它提供了对早期操作系统的兼容性。DOS 头的存在是因为 Windows NT 操作系统需要能够运行老的 DOS 可执行文件。

即使在现代的 PE 文件中,DOS 头部分依然保留着,目的是确保老的 DOS 可执行文件在 Windows 系统上能够运行,或者至少显示一个兼容的 DOS 提示符或错误消息。

  • ==为什么需要====​e_lfanew​====?==

    1、什么是e_lfanew​?

    • e_lfanew​ 是 PE 文件头中的一个字段,位于 DOS Header 的偏移量 0x3C​ 处。
    • 它是一个 4 字节的值,表示 PE 文件头(PE Header)的起始位置相对于文件开头的偏移量。
    • 换句话说,e_lfanew​ 告诉解析器:“从这里跳转到 PE 文件头”。

    2、PE文件的整体结构

    • 
      |------------------------|
      | DOS Header             | <-- 包含 e_lfanew
      |------------------------|
      | DOS Stub (Optional)    | <-- 可选的 DOS 程序代码
      |------------------------|
      | PE Header              |  两者之间转换需要借助节表信息。例如:
      >
      > ​RVA = FileOffset + SectionAlignment (根据节表计算)​
      >

    4、示例:如何找到入口点的实际地址

    • 假设我们有一个 PE 文件,以下是相关字段的值:

    • Image Base : 0x400000

    • AddressOfEntryPoint : 0x1000

    • Section Table :

      • .text​:

      • VirtualAddress​: 0x1000

      • PointerToRawData​: 0x400

    1️⃣计算入口点的内存地址

    • 入口点的RVA是0x1000​。
    • 模块基址是 0x400000​。
    • 因此,入口点的实际内存地址为:
    • Entry Point Address = Image Base + AddressOfEntryPoint
                        = 0x400000 + 0x1000
                        = 0x401000

    2️⃣计算入口点的文件偏移

    • .text​ 节的 VirtualAddress​ 是 0x1000​,对应的 PointerToRawData​ 是 0x400​。

      入口点的 RVA 是 0x1000​,落在 .text​ 节中。

    • 因此,入口点的文件偏移为:

      File Offset = RVA - VirtualAddress + PointerToRawData
                = 0x1000 - 0x1000 + 0x400
                = 0x400

Dos stub存根

在 PE 文件中,DOS Stub 紧跟在 DOS Header 后面。DOS Header 定义了文件的基本结构和大小信息,而 DOS Stub 就是紧接着 DOS Header 的数据,直到 PE Header(NT Header)的起始位置。DOS Header 中有一个 e_lfanew​ 字段,它指向 PE Header 的位置,而 PE Header 包含了真正的 Windows 可执行文件格式信息。

在 CFF Explorer 或类似工具中,你可以看到 DOS Header 和 DOS Stub 部分,它们通常位于 PE 文件的最前面。如果该文件在 DOS 系统中运行,它会执行存根代码;如果在 Windows 中运行,Windows 会忽略这个部分并直接跳到 PE 头部。

这就是个经典的DOS Stub,其中,MZ​ 是 DOS 文件格式的魔数(标识该文件为 DOS 可执行文件)。当 DOS 系统尝试运行该文件时,用户会看到以下消息:

This program cannot be run in DOS mode.

提取出hex值

0E 1F BA 0E 00 B4 09 CD 21 B8 01 4C CD 21 54 68
69 73 20 70 72 6F 67 72 61 6D 20 63 61 6E 6E 6F
74 20 62 65 20 72 75 6E 20 69 6E 20 44 4F 53 20
6D 6F 64 65 2E 0D 0D 0A 24 00 00 00 00 00 00 00

因为Dos stub是一个16位程序,所以我们使用x86​架构16bit​反汇编分析

得到完整的汇编代码

L_00000000:   push cs
L_00000001:   pop ds
L_00000002:   mov dx, 0xe
L_00000005:   mov ah, 0x9
L_00000007:   int 0x21
L_00000009:   mov ax, 0x4c01
L_0000000C:   int 0x21
L_0000000E:   push sp
L_0000000F:   push 0x7369
L_00000012:   and [bx+si+0x72], dh
L_00000015:   outsw 
L_00000016:   db 0x67 
L_00000017:   jb 0x7a
L_00000019:   insw 
L_0000001A:   and [bp+di+0x61], ah
L_0000001D:   outsb 
L_0000001E:   outsb 
L_0000001F:   outsw 
L_00000020:   jz 0x42
L_00000022:   bound sp, [di+0x20]
L_00000025:   jb 0x9c
L_00000027:   outsb 
L_00000028:   and [bx+di+0x6e], ch
L_0000002B:   and [si+0x4f], al
L_0000002E:   push bx
L_0000002F:   and [di+0x6f], ch
L_00000032:   db 0x64 
L_00000033:   db 0x65 
L_00000034:   db 0x2e 
L_00000035:   or ax, 0xa0d
L_00000038:   and al, 0x0
L_0000003A:   add [bx+si], al
L_0000003C:   add [bx+si], al
L_0000003E:   add [bx+si], al

拆开分析

L_00000000:   push cs
L_00000001:   pop ds

push cs​ 和 pop ds​:这两条指令的作用是将代码段寄存器 CS​ 的值压入堆栈,并将其弹出到数据段寄存器 DS​。这段代码用于初始化 DS​ 寄存器,确保在 16 位环境下,数据访问正确。DS​ 必须指向有效的内存段。

L_00000002:   mov dx, 0xe
L_00000005:   mov ah, 0x9
L_00000007:   int 0x21

mov dx, 0xe​:将 0xE 存入 DX​ 寄存器,0xE 是一个偏移量,指向存储在 .COM​ 文件中的字符串数据。

mov ah, 0x9​:将 AH​ 设置为 0x9,这表示调用 DOS 中断 int 21h​ 功能号 0x09,用于显示字符串。

int 0x21​:通过 DOS 中断 0x21​ 调用,执行功能号 0x09,显示在 DX​ 寄存器指定地址的字符串。该字符串实际上是 "This program cannot be run in DOS mode.\r\n\"。

0D 0A​:这是回车换行符(\r\n​),会在字符串输出时创建新的行。

$终止符:DOS 中的字符串显示功能依赖于 $​ 来标识字符串结束。

L_00000009:   mov ax, 0x4c01
L_0000000C:   int 0x21

mov ax, 0x4c01​:将 AX​ 寄存器设置为 0x4C01,0x4C 是 DOS 中断 0x21 的功能号,用于退出程序,0x01 表示返回的退出代码。

int 0x21​:调用 DOS 中断 int 0x21​,功能号 0x4C,程序终止并返回控制权给操作系统。

后面的一些 and​ 和 add​ 操作,都是对特定内存地址的修改,可能用于在 DOS 环境下与程序的一些数据结构交互,或者是保护某些标志。

这段代码是一个典型的 DOS Stub 部分,主要目的是为了在 DOS 环境下显示一条程序不能在 DOS 模式下运行的消息,并退出。它展示了DOS中断、堆栈操作和内存操作等基本技能,虽然这段代码在现代操作系统中已经不再使用,但它依然保留在许多 PE 文件中,以确保兼容性,尤其是对老旧操作系统的兼容。

PE Header (NT Header)

PEHeader​由IMAGE\_FILE\_HEADER​和IMAGE\_OPTIONAL\_HEADER

同样使用CFF Explorer​和PEView​。

可以构建出PE(NT)头的结构体为:

typedef struct _IMAGE_NT_HEADERS {
    DWORD Signature;           // 0x00: PE 文件的标识符,通常为 "PE\0\0",0x00004550
    IMAGE_FILE_HEADER FileHeader;  // 0x04: 文件头,包含关于文件的元数据
    IMAGE_OPTIONAL_HEADER OptionalHeader; // 0x18: 可选头,包含加载程序所需的详细信息
} IMAGE_NT_HEADERS, *PIMAGE_NT_HEADERS;

根据CFF Explorer​可以看到Signature​的值为0x00004550​,其为一个DWORD,占四个字节,大端序就是50450000​,通常为 ASCII 字符串 "PE\0\0"​,它紧跟在 DOS 头(IMAGE_DOS_HEADER​)的末尾处,通过 DOS 头中的 e_lfanew​ 字段指向这个位置。当操作系统或加载器读取文件时,首先读取 DOS 头,然后根据 e_lfanew​ 跳转到 PE 头位置,验证这个 4 字节 Signature,确保这是一个合法的 Windows PE 可执行文件。如果这个 Signature 不正确,则说明文件不是有效的 PE 文件,操作系统不会当作 Windows 程序加载。

File Header(IMAGE_FILE_HEADER)

IMAGE_FILE_HEADER​ 是 PE 文件头中的一部分,用来描述文件的基本属性,比如目标机器类型、节的数量、文件特性等信息。它紧跟在 PE Signature 之后,是整个 PE 头的重要组成。

IMAGE_FILE_HEADER​ 结构体定义:

typedef struct _IMAGE_FILE_HEADER {
    WORD  Machine;              // 目标机器类型(CPU架构)
    WORD  NumberOfSections;     // 节(section)的数量
    DWORD TimeDateStamp;        // 文件创建时间戳(UNIX时间戳)
    DWORD PointerToSymbolTable; // 符号表的文件偏移(通常为0,调试信息用)
    DWORD NumberOfSymbols;      // 符号数量(通常为0)
    WORD  SizeOfOptionalHeader; // 可选头大小,通常是0xE0(224字节)或0xF0(240字节)
    WORD  Characteristics;      // 文件特性标志(是否可执行,是否DLL等)
} IMAGE_FILE_HEADER;

各个字段的说明:

字段名 类型 占用字节 作用说明
Machine WORD 2 指示该文件目标机器架构,比如: -0x14c​\= Intel 386 (x86) -0x8664​\= x64(AMD64)
NumberOfSections WORD 2 文件中节的个数,节是代码或数据的区块,紧跟在头部之后
TimeDateStamp DWORD 4 文件创建时间戳,Unix 时间戳格式,用于标识文件生成时间
PointerToSymbolTable DWORD 4 符号表在文件中的偏移(不常用,调试信息用)
NumberOfSymbols DWORD 4 符号数量(调试信息相关,通常为0)
SizeOfOptionalHeader WORD 2 可选头大小(单位字节),PE32格式通常是224字节,PE32+(64位)是240字节
Characteristics WORD 2 文件特性标志,表示文件类型和属性,如: -0x0002​可执行文件 -0x2000​DLL 文件 -0x0100​可32位机器

总结构占用20字节​。

常见 Machine 枚举值:

含义
0x014c Intel 386
0x8664 x64 (AMD64)
0x01c0 ARM
0x01c4 ARMv7
0x0200 Intel Itanium

常见 Characteristics 标志:

标志值 含义
0x0001 目标文件包含可重定位信息
0x0002 可执行文件
0x2000 DLL 文件
0x0100 32位机器

上图给出HelloWorld.exe​的Characteristics​为0106​,这代表了一组文件属性的组合。这个字段是按位标志(bit flags),每个位对应一个特定的文件特性。

0x0106​ \= 0000 0001 0000 0110​ (16位二进制)

位序号从低到高(右到左):

位号 含义 是否置位(1是0否)
0 IMAGE_FILE_RELOCS_STRIPPED(去除重定位信息) 0
1 IMAGE_FILE_EXECUTABLE_IMAGE(可执行文件) 1
2 IMAGE_FILE_LINE_NUMS_STRIPPED(去除行号信息) 1
3 IMAGE_FILE_LOCAL_SYMS_STRIPPED(去除本地符号) 0
4 IMAGE_FILE_AGGRESIVE_WS_TRIM(优化工作集) 0
5 IMAGE_FILE_LARGE_ADDRESS_AWARE(支持大地址) 0
6 未定义或保留 0
7 未定义或保留 0
8 IMAGE_FILE_32BIT_MACHINE(32位机器) 1
9\~15 其他标志 0

结合官方标志定义,0x0106 对应的标志:

0x0002​ (bit 1​) — IMAGE_FILE_EXECUTABLE_IMAGE
表示这是一个可执行文件(不是纯数据文件)。

0x0004​ (bit 2​) — IMAGE_FILE_LINE_NUMS_STRIPPED
表示调试时行号信息被移除。

0x0100​ (bit 8​) — IMAGE_FILE_32BIT_MACHINE
表示这是一个32位的目标机器文件。

Characteristics = 0x0106​ 表示:

  • 该文件是 可执行文件(EXE 或 DLL)。
  • 文件中 不包含调试用的行号信息
  • 这是一个 32位机器的文件
  • 其他调试符号、重定位信息等未设置或未剥离。

Optional Header(IMAGE\_OPTIONAL\_HEADER)

根据不同的架构有两个版本:

  • IMAGE_OPTIONAL_HEADER32​ — 32 位 PE 文件
  • IMAGE_OPTIONAL_HEADER64​ — 64 位 PE 文件(64 位指针和地址)

结构体示例 (32位版本)

typedef struct _IMAGE_OPTIONAL_HEADER32 {
    WORD  Magic;                    // 标识是PE32(0x10b)还是PE32+(0x20b)
    BYTE  MajorLinkerVersion;       // 链接器主版本号
    BYTE  MinorLinkerVersion;       // 链接器次版本号
    DWORD SizeOfCode;               // 代码段大小(字节)
    DWORD SizeOfInitializedData;    // 已初始化数据段大小(字节)
    DWORD SizeOfUninitializedData;  // 未初始化数据段大小(字节)
    DWORD AddressOfEntryPoint;      // 程序入口点的相对虚拟地址(RVA)
    DWORD BaseOfCode;               // 代码段起始地址(RVA)
    DWORD BaseOfData;               // 数据段起始地址(仅32位PE有)
    DWORD ImageBase;                // 程序期望加载的基地址(虚拟地址)
    DWORD SectionAlignment;         // 内存节对齐粒度(一般4KB)
    DWORD FileAlignment;            // 文件节对齐粒度(一般512字节)
    DWORD SizeOfImage;              // 映像总大小(字节)
    DWORD SizeOfHeaders;            // 文件头和节表的大小(字节)
    DWORD CheckSum;                 // 校验和(通常为0)
    WORD  Subsystem;                // 子系统类型(GUI、控制台等)
    WORD  DllCharacteristics;      // DLL特性标志(ASLR、DEP等)
    DWORD SizeOfStackReserve;       // 预留的栈大小(字节)
    DWORD SizeOfStackCommit;        // 提交的栈大小(字节)
    DWORD SizeOfHeapReserve;        // 预留的堆大小(字节)
    DWORD SizeOfHeapCommit;         // 提交的堆大小(字节)
    DWORD LoaderFlags;              // 加载标志(一般为0)
    DWORD NumberOfRvaAndSizes;      // 数据目录项数(一般为16)
    IMAGE_DATA_DIRECTORY DataDirectory[16];  // 数据目录数组
} IMAGE_OPTIONAL_HEADER32;

IMAGE_OPTIONAL_HEADER​ 结构体为操作系统装载PE文件时提供了关键元数据,告诉系统:

  • 程序入口在哪里
  • 程序占用内存大小
  • 堆栈和堆大小
  • 哪些子系统需要支持
  • 重要数据目录的位置和大小

字段名 作用描述 字节占用
Magic 标识格式,0x10b 表示 PE32(32位),0x20b 表示 PE32+(64位) 2
MajorLinkerVersion 链接器主版本号 1
MinorLinkerVersion 链接器次版本号 1
SizeOfCode 代码段大小,单位字节 4
SizeOfInitializedData 初始化数据段大小 4
SizeOfUninitializedData 未初始化数据段大小(BSS) 4
AddressOfEntryPoint 程序入口点的RVA,执行从这里开始 4
BaseOfCode 代码段基址(RVA) 4
BaseOfData 数据段基址(32位特有) 4
ImageBase 程序加载的首选虚拟地址 4
SectionAlignment 内存中节对齐粒度(通常4KB) 4
FileAlignment 文件中节对齐粒度(通常512字节) 4
SizeOfImage 映像在内存中的大小,包括所有节的对齐后总和 4
SizeOfHeaders PE头和节表大小 4
CheckSum 文件校验和,Windows校验用(通常为0) 4
Subsystem 子系统类型,比如控制台、GUI等 2
DllCharacteristics DLL特性标志,如支持 ASLR、DEP 2
SizeOfStackReserve 栈预留大小 4
SizeOfStackCommit 栈提交大小 4
SizeOfHeapReserve 堆预留大小 4
SizeOfHeapCommit 堆提交大小 4
LoaderFlags 加载标志,一般为0 4
NumberOfRvaAndSizes 数据目录数量,通常是16 4
DataDirectory 指向各种重要表的数组,如导入表、导出表、资源表等(每项8字节,共16项) 128 (16 × 8)

IMAGE_OPTIONAL_HEADER32​ 的标准大小为224字节

Data directories(IMAGE_DATA_DIRECTORY)

IMAGE_DATA_DIRECTORY​ 结构体定义:

typedef struct _IMAGE_DATA_DIRECTORY {
    DWORD VirtualAddress;   // 数据目录表项对应数据在内存中的 RVA(相对虚拟地址)
    DWORD Size;             // 该数据的大小(字节数)
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;

这是 IMAGE_OPTIONAL_HEADER​ 中的一个数组,通常包含16个 IMAGE_DATA_DIRECTORY​ 结构,每个指向PE文件中特定重要数据表的地址和大小。

索引 数据目录名称 描述
0 Export Table 导出表,包含导出函数和符号信息
1 Import Table 导入表,包含被引用的外部函数和DLL信息
2 Resource Table 资源表,程序中包含的图标、菜单、对话框等资源
3 Exception Table 异常处理表,用于支持结构化异常处理
4 Certificate Table 证书表,数字签名信息
5 Base Relocation Table 基址重定位表,用于重定位加载地址
6 Debug 调试信息表,包含调试符号
7 Architecture 体系结构特定数据目录(通常不使用)
8 Global Ptr 指向全局指针(全局变量指针)的表
9 TLS Table 线程本地存储(TLS)表
10 Load Config Table 加载配置表,包含安全和运行时配置
11 Bound Import 绑定导入表,优化导入过程
12 IAT (Import Address Table) 导入地址表,动态链接函数地址表
13 Delay Import Descriptor 延迟导入描述符,用于延迟绑定动态库函数
14 CLR Runtime Header .NET CLR 运行时头,管理托管代码
15 Reserved 保留,未使用

每项占用8字节,16项共128字节。

节表(Section Table)

PE 文件中的 节表(Section Table) (又称节头、段表,结构为 IMAGE_SECTION_HEADER​)是 PE 文件非常关键的一部分,它紧跟在 PE Header + Optional Header 后面,描述了程序在文件中和内存中的各个节(如 .text​、.data​、.rsrc​ 等)的属性、位置和大小。

节表(IMAGE_SECTION_HEADER)结构体的定义:

typedef struct _IMAGE_SECTION_HEADER {
    BYTE  Name[8];              // 节名称(如 .text, .data)
    union {
        DWORD PhysicalAddress;
        DWORD VirtualSize;     // 节的实际大小(内存中)
    } Misc;
    DWORD VirtualAddress;      // 节在内存中的 RVA
    DWORD SizeOfRawData;       // 节在文件中的大小(File Alignment 对齐)
    DWORD PointerToRawData;    // 节在文件中的偏移(文件中的起始位置)
    DWORD PointerToRelocations;// 重定位信息(OBJ文件用,EXE为0)
    DWORD PointerToLinenumbers;// 行号信息(调试用,已弃用)
    WORD  NumberOfRelocations; // 重定位数量(EXE为0)
    WORD  NumberOfLinenumbers; // 行号数量(EXE为0)
    DWORD Characteristics;     // 节的属性(代码、数据、读写执行等)
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;
字段名 字节数 说明
Name 8 字节 节名,最多 8 个 ASCII 字符,如.text​、.data​。不够补零。
VirtualSize 4 字节 节在内存中的真实大小(通常大于文件中大小)。
VirtualAddress 4 字节 节的 RVA,相对于ImageBase​的偏移。
SizeOfRawData 4 字节 文件中占用大小(按 FileAlignment 对齐)。
PointerToRawData 4 字节 文件中偏移地址(节的实际存储起始点)。
Characteristics 4 字节 节属性,如是否包含代码、数据、是否可执行、可读写等。

节表是 PE 文件的重要部分,它为加载器提供以下信息:

定位节内容

  • 文件中的偏移(PointerToRawData​)指向节的内容。
  • 内存中的地址(VirtualAddress​)指向节在内存中的加载位置。

节的大小和属性

  • 加载器根据 SizeOfRawData​ 和 VirtualSize​ 确定内存分配。
  • 根据 Characteristics​ 设置节的读写和执行权限。

指引运行时行为

  • 加载器使用 .reloc​ 节处理重定位。
  • 代码从 .text​ 节执行,数据从 .data​ 节加载,资源从 .rsrc​ 节访问。

常见 PE 文件节名称 的表格

节名(Name) 功能描述 常见属性 特点
.text 代码段,存放程序执行的机器指令 可执行 + 只读 (\EXECUTE|READ\)
.data 已初始化数据段,如全局变量、静态变量 读写 (\READ|WRITE\)
.rdata 只读数据段,如常量、导入表、导出表 只读 (READ​) 包括字符串字面值、调试信息等
.bss 未初始化数据段,只存在于内存中 读写 不占文件空间,映射时初始化为 0
.rsrc 资源段,包含图标、位图、对话框等 Windows 资源 只读 可用工具如 Resource Hacker 修改
.reloc 重定位表,当程序未加载到默认地址时使用 只读 存放 RVA 替换列表
.edata 导出表段,包含导出函数的信息 只读 通常包含 DLL 的导出函数
.idata 导入表段,包含导入函数(DLL 调用)的信息 只读 描述所依赖的外部函数
.tls 线程局部存储段(Thread Local Storage) 只读/读写 线程私有变量初始化数据
.debug 调试信息段 只读 包含符号、行号等,编译器调试用
.pdata 异常处理表段,Windows x64 用于栈展开和异常处理 只读 x64 下用于处理异常和 unwinding

其中.text​节是最关键的,所有程序执行的代码都会被放在这里。

.data​ 和 .bss​ 是传统的数据存储区域,BSS 并不出现在文件中,但会被操作系统在加载时初始化。

.reloc​ 节是实现 地址重定向(重基址) 的基础,尤其当不启用 ASLR 时可能为空。

某些节名如 .UPX0​, .UPX1​, .vmp0​ 是加壳程序(如 UPX、VMProtect)的标志。

Windows 加载器和 PE 分析器是根据节表中的 VirtualAddressCharacteristics 来加载和保护这些节的。

节属性(Characteristics)常见值

| 宏                                  | 值            | 含义       |
| ---------------------------------- | ------------ | -------- |
| `IMAGE_SCN_CNT_CODE`               | `0x00000020` | 节包含代码    |
| `IMAGE_SCN_CNT_INITIALIZED_DATA`   | `0x00000040` | 包含已初始化数据 |
| `IMAGE_SCN_CNT_UNINITIALIZED_DATA` | `0x00000080` | 包含未初始化数据 |
| `IMAGE_SCN_MEM_EXECUTE`            | `0x20000000` | 可执行      |
| `IMAGE_SCN_MEM_READ`               | `0x40000000` | 可读       |
| `IMAGE_SCN_MEM_WRITE`              | `0x80000000` | 可写       |

典型节布局示意:

节名 内存地址(RVA) 文件偏移 文件大小 内存大小
.text 0x1000 0x400 0x1A00 0x1C00
.rdata 0x3000 0x2000 0x800 0x900
.data 0x4000 0x2800 0x400 0x800
.rsrc 0x5000 0x2C00 0x600 0x800

节表用途和分析价值

  • 加载内存映像:操作系统根据节表将各节按 VirtualAddress 加载并对齐。
  • 逆向工程:节表可以用于快速识别程序逻辑位置(如入口点属于哪个节)。
  • 加壳/脱壳:壳代码往往会新建节或改动节表,修改入口点地址。
  • 恶意代码检测:非标准节名、写+执行权限并存等常用于恶意注入检测。

节表的数量由 PE 文件头(IMAGE_FILE_HEADER​)的 NumberOfSections​ 字段指定。每个节都有一个对应的 IMAGE_SECTION_HEADER​ 结构体。

可选节及扩展节:

  • .CRT​:C/C++ 运行时库初始化段
  • .sxdata​:用于结构化异常处理(SEH)表
  • .netmetadata​、.corhdr​:出现在 .NET 的 PE 文件中
  • .code​、.data1​:某些编译器自定义节名
  • .zdata​:压缩数据段(可执行壳工具常用)

    Sections

节区(Sections) 是文件内容的主要组成部分。每个节区都包含特定类型的数据,负责执行程序所需的各个方面。在 PE 文件中,节区通常被划分为不同的类型,例如 代码(Code)导入表(Imports)数据(Data) 。每个节区都对应着程序的不同功能模块。

Code

代码节区(通常是 .text 节区)包含了程序的机器代码指令,也就是执行的程序逻辑部分。这些是处理器将执行的实际指令。

  • 该节区是不可写的(只读),因此它是保护性访问的,防止代码被修改。
  • 在程序启动时,操作系统会将代码节区加载到内存中,CPU 会根据该节区中的指令执行程序的功能。

这里的内容就是程序的可执行指令,例如你用 C 或者其他语言编写的程序编译后转换成机器码的部分。

Inports

导入表节区存储程序需要调用的外部库函数的信息。它列出了所有外部动态链接库(DLL)函数和它们的地址。

  • 该节区通常包含外部库的名称和函数的名称。操作系统加载该可执行文件时会根据导入表查找并加载所需的 DLL 文件,随后程序可以访问这些库中的函数。

  • 通过这种方式,程序能够使用外部库提供的功能,例如标准输入输出、图形处理、网络通信等,而不需要自己实现这些功能。

  • 导入表包含了目标程序所需的 DLL 文件名和其中函数的名称。

data

数据节区包含程序的静态数据。这些数据可以是全局变量、初始化的数据、字符串常量等。数据节区通常分为两种类型:

.data 节区:存储可读可写的数据,如全局变量。

.rdata 节区:存储只读数据,如常量字符串。

特点

  • 可读可写性.data 节区通常是可读写的,这意味着程序可以在运行时修改这些数据。.rdata 节区则是只读的。
  • 初始化数据:这里存储的是程序中声明并初始化的全局变量、常量等。
  • 存储字符串常量:例如,在 C 语言中定义的字符串常量 char *str = "Hello, World!"; 就会存储在数据节区中。

内容

  • 这里存储的是静态数据(如全局变量、初始化的数组等),以及程序中使用的常量字符串等。
  • 示例:在 C 程序中声明的全局变量或字符串常量(如 char *str = "Hello World";)都会出现在数据节区中。