PE(Portable Executable)Windows平台下可执行文件(EXE)、动态链接库(DLL)、驱动(SYS)等的标准格式,核心由DOS头、NT头、节表、节区数据四大部分构成,是Windows加载、执行程序的底层依据。

总体结构图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
+---------------------+
| DOS MZ Header | (64字节,固定)
+---------------------+
| DOS Stub | (可变长度,16位DOS兼容代码)
+---------------------+
| NT Headers | (PE核心头部,含签名、文件头、可选头)
| - PE Signature |
| - File Header |
| - Optional Header |
+---------------------+
| Section Headers | (节表,每个节头40字节,数量由文件头指定)
+---------------------+
| Sections | (实际代码、数据、资源等,按节表描述存放)
| - .text |
| - .data |
| - .rdata |
| - .rsrc |
| - .reloc |
| - ... |
+---------------------+

DOS 头(IMAGE_DOS_HEADER)

基本信息

大小:固定64字节
作用:兼容DOS系统,同时提供NT头的定位入口,是PE文件的 “入口钥匙”
定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
typedef struct _IMAGE_DOS_HEADER {
WORD e_magic; // MZ标志,0x5A4D ('MZ')
WORD e_cblp; // 最后页字节数
WORD e_cp; // 文件页数
WORD e_crlc; // 重定位项数
WORD e_cparhdr; // 头部大小,以段落为单位
WORD e_minalloc; // 最小额外段落数
WORD e_maxalloc; // 最大额外段落数
WORD e_ss; // 初始SS值(DOS)
WORD e_sp; // 初始SP值(DOS)
WORD e_csum; // 校验和
WORD e_ip; // 初始IP值(DOS)
WORD e_cs; // 初始CS值(DOS)
WORD e_lfarlc; // 重定位表文件地址
WORD e_ovno; // 覆盖号
WORD e_res[4]; // 保留字
WORD e_oemid; // OEM标识符
WORD e_oeminfo; // OEM信息
WORD e_res2[10]; // 保留字
LONG e_lfanew; // NT头文件偏移 ← 最关键字段!
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;

关键字段

e_magicDOS标识(魔术字),合法PE文件必须为此值。
e_lfanewNT头在文件中的偏移,指向PE\0\0签名,通过此字段定位PE核心(NT头)。

DOS Stub(DOS 存根)

紧跟DOS头,是一段16DOS程序,在DOS下运行时输出“This program cannot be run in DOS mode”
现代Windows不执行此段,仅保留兼容性,长度可变,通常为几十到几百字节。

NT 头(IMAGE_NT_HEADERS)

NT头是PE文件的核心,由PE签名、COFF文件头、可选头三部分组成,32位与64位结构略有差异(IMAGE_NT_HEADERS32/IMAGE_NT_HEADERS64)。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 32位版本
typedef struct _IMAGE_NT_HEADERS {
DWORD Signature; // "PE\0\0" (0x00004550)
IMAGE_FILE_HEADER FileHeader; // 标准COFF文件头
IMAGE_OPTIONAL_HEADER32 OptionalHeader; // 扩展头(必须存在!)
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;

// 64位版本
typedef struct _IMAGE_NT_HEADERS64 {
DWORD Signature;
IMAGE_FILE_HEADER FileHeader;
IMAGE_OPTIONAL_HEADER64 OptionalHeader;
} IMAGE_NT_HEADERS64, *PIMAGE_NT_HEADERS64;

PE 签名(PE Signature)

大小:4字节(DWORD
值:0x00004550ASCII“PE\0\0”
作用:标识这是一个32/64PE文件,区别于16NE、LE格式

NT 头(IMAGE_NT_HEADERS)

大小:20字节(固定)
作用:描述文件基本属性,如目标架构、节区数量、文件类型等
定义:

1
2
3
4
5
6
7
8
9
typedef struct _IMAGE_FILE_HEADER {
WORD Machine; // 目标CPU架构
WORD NumberOfSections; // 节区数量
DWORD TimeDateStamp; // 时间戳
DWORD PointerToSymbolTable; // COFF符号表偏移
DWORD NumberOfSymbols; // 符号数量
WORD SizeOfOptionalHeader; // OptionalHeader大小
WORD Characteristics; // 文件属性
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;

可选头(IMAGE_OPTIONAL_HEADER)

名称:虽叫 “可选”,但Windows可执行文件必须存在,是加载器的核心依据
大小:32224字节(0xE0),64240字节(0xF0
定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// 32位结构
typedef struct _IMAGE_OPTIONAL_HEADER {
// 标准字段
WORD Magic; // 0x10B = PE32, 0x20B = PE32+
BYTE MajorLinkerVersion; // 链接器主版本
BYTE MinorLinkerVersion; // 链接器次版本
DWORD SizeOfCode; // 代码段总大小
DWORD SizeOfInitializedData; // 已初始化数据大小
DWORD SizeOfUninitializedData; // 未初始化数据大小
DWORD AddressOfEntryPoint; // 入口点RVA ← 最重要!
DWORD BaseOfCode; // 代码段起始RVA
DWORD BaseOfData; // 数据段起始RVA(PE32+无此字段)

// Windows特有字段
DWORD ImageBase; // 首选加载基址
DWORD SectionAlignment; // 内存对齐(通常0x1000)
DWORD FileAlignment; // 文件对齐(通常0x200)
WORD MajorOperatingSystemVersion; // 操作系统主版本
WORD MinorOperatingSystemVersion; // 操作系统次版本
WORD MajorImageVersion; // 映像主版本
WORD MinorImageVersion; // 映像次版本
WORD MajorSubsystemVersion; // 子系统主版本
WORD MinorSubsystemVersion; // 子系统次版本
DWORD Win32VersionValue; // 保留
DWORD SizeOfImage; // 内存中映像总大小
DWORD SizeOfHeaders; // 头部总大小
DWORD CheckSum; // 校验和
WORD Subsystem; // 子系统类型
WORD DllCharacteristics; // DLL特性
DWORD SizeOfStackReserve; // 栈保留大小
DWORD SizeOfStackCommit; // 栈提交大小
DWORD SizeOfHeapReserve; // 堆保留大小
DWORD SizeOfHeapCommit; // 堆提交大小
DWORD LoaderFlags; // 加载器标志
DWORD NumberOfRvaAndSizes; // 数据目录数量
IMAGE_DATA_DIRECTORY DataDirectory[16]; // 数据目录表
} IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32;

// 64位区别
1. Magic = 0x20B
2. ImageBase 是 ULONGLONG (8字节)
3. 没有 BaseOfData 字段
4. 其他字段大小从32位扩展为64

数据目录(Data Directory)
16IMAGE_DATA_DIRECTORY条目(每个8字节:RVA + 大小),是PE的 “功能索引表”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#define IMAGE_DIRECTORY_ENTRY_EXPORT         0  // 导出表,DLL 向外暴露的函数 / 变量,供其他程序调用
#define IMAGE_DIRECTORY_ENTRY_IMPORT 1 // 导入表,记录程序依赖的外部 DLL 及函数,加载时动态绑定
#define IMAGE_DIRECTORY_ENTRY_RESOURCE 2 // 资源表,图标、对话框、字符串等资源的位置
#define IMAGE_DIRECTORY_ENTRY_EXCEPTION 3 // 异常表
#define IMAGE_DIRECTORY_ENTRY_SECURITY 4 // 安全证书
#define IMAGE_DIRECTORY_ENTRY_BASERELOC 5 // 重定位表,当 ImageBase 被占用时,修正代码中绝对地址
#define IMAGE_DIRECTORY_ENTRY_DEBUG 6 // 调试信息,存储 PDB 路径、调试数据
#define IMAGE_DIRECTORY_ENTRY_ARCHITECTURE 7 // 架构特定数据
#define IMAGE_DIRECTORY_ENTRY_GLOBALPTR 8 // 全局指针
#define IMAGE_DIRECTORY_ENTRY_TLS 9 // TLS表,线程局部存储初始化数据
#define IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG 10 // 加载配置
#define IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT 11 // 绑定导入
#define IMAGE_DIRECTORY_ENTRY_IAT 12 // 导入地址表
#define IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT 13 // 延迟导入
#define IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR14 // COM描述符
#define IMAGE_NUMBEROF_DIRECTORY_ENTRIES 16 // 总数量

节表(Section Headers)

基本信息

位置:紧跟可选头之后,无直接指针,通过SizeOfHeaders计算定位
大小:每个节头固定40字节,数量由IMAGE_FILE_HEADER.NumberOfSections指定
定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typedef struct _IMAGE_SECTION_HEADER {
BYTE Name[IMAGE_SIZEOF_SHORT_NAME]; // 8字节节区名
union {
DWORD PhysicalAddress; // 物理地址
DWORD VirtualSize; // 内存中实际大小
} Misc;
DWORD VirtualAddress; // 内存中RVA
DWORD SizeOfRawData; // 文件中大小
DWORD PointerToRawData; // 文件中偏移
DWORD PointerToRelocations; // 重定位偏移
DWORD PointerToLinenumbers; // 行号偏移
WORD NumberOfRelocations; // 重定位项数
WORD NumberOfLinenumbers; // 行号项数
DWORD Characteristics; // 节区属性
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;

#define IMAGE_SIZEOF_SHORT_NAME 8

常见标准节区

1
2
3
4
5
6
7
节名	主要内容	内存属性	作用
.text 机器码、执行代码 可读、可执行 程序核心代码段
.data 已初始化全局 / 静态变量 可读、可写 存储全局变量
.rdata 只读数据、常量、导入 / 导出表 只读 字符串、函数地址表
.rsrc 资源(图标、菜单、版本信息) 只读 资源管理器读取
.reloc 基址重定位数据 只读 加载时修正绝对地址
.bss 未初始化全局变量 可读、可写 不占磁盘空间,加载时清零

关键数据结构

导入表(Import Table)

作用:记录程序依赖的外部DLL(如kernel32.dll、user32.dll)及要调用的函数,Windows加载器通过它完成动态链接
结构:IMAGE_IMPORT_DESCRIPTOR数组(以全 0 结尾),每个描述符对应一个DLL

1
2
3
4
5
6
7
8
9
10
typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics; // 0 表示结束
DWORD OriginalFirstThunk; // INT (Import Name Table) RVA
};
DWORD TimeDateStamp; // 时间戳,0表示未绑定
DWORD ForwarderChain; // 转发链索引
DWORD Name; // DLL名称字符串RVA
DWORD FirstThunk; // IAT (Import Address Table) RVA
} IMAGE_IMPORT_DESCRIPTOR, *PIMAGE_IMPORT_DESCRIPTOR;

核心流程:
加载器遍历导入表,加载对应DLL
查找DLL导出表,获取函数地址
填充IAT(导入地址表),程序通过IAT间接调用外部函数

导出表(Export Table)

作用:DLL向外暴露函数 / 变量,供其他程序调用,仅DLL存在此表
结构:IMAGE_EXPORT_DIRECTORY,包含导出函数名、序号、地址表

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct _IMAGE_EXPORT_DIRECTORY {
DWORD Characteristics; // 未使用,通常为0
DWORD TimeDateStamp; // 创建时间戳
WORD MajorVersion; // 主版本号
WORD MinorVersion; // 次版本号
DWORD Name; // 模块名称RVA
DWORD Base; // 起始序号基数
DWORD NumberOfFunctions; // 导出函数总数
DWORD NumberOfNames; // 导出名称数量
DWORD AddressOfFunctions; // 函数地址表RVA (DWORD数组)
DWORD AddressOfNames; // 函数名称表RVA (DWORD数组)
DWORD AddressOfNameOrdinals; // 序号表RVA (WORD数组)
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;

三个数组的关系

1
2
3
4
5
6
7
8
AddressOfNames      AddressOfNameOrdinals      AddressOfFunctions
[0] "CreateFile" ────→ [0] 0 ──────────→ [0] 0x1000
[1] "DeleteFile" ────→ [1] 2 ──────────→ [1] 0x0000 (空)
[2] "MoveFile" ────→ [2] 1 ──────────→ [2] 0x2000
[3] 0x3000
[4] 0x0000
...
实际导出序号 = Base + ordinalIndex

基址重定位表(Base Relocation Table)

作用:当程序无法加载到指定ImageBase(如被占用)时,修正代码中所有绝对地址(如call 0x401000
结构:按内存页(4KB)分组,每组包含重定位类型和偏移

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct _IMAGE_BASE_RELOCATION {
DWORD VirtualAddress; // 重定位块起始RVA
DWORD SizeOfBlock; // 块总大小(包括本结构)
// WORD TypeOffset[1]; // 重定位项数组
} IMAGE_BASE_RELOCATION, *PIMAGE_BASE_RELOCATION;

// 重定位项(16位)
// 高4位: 重定位类型
// 低12位: 相对于 VirtualAddress 的偏移
typedef struct {
WORD Offset : 12; // 12位偏移
WORD Type : 4; // 4位类型
} IMAGE_RELOCATION_ENTRY, *PIMAGE_RELOCATION_ENTRY;

原理:计算实际基址与默认基址的差值(Delta),对所有绝对地址加上Delta

PE文件核心概念

VA(虚拟地址):程序加载到内存后的绝对地址,VA = ImageBase + RVA
RVA(相对虚拟地址):相对ImageBase的偏移,PE文件中统一使用RVA描述地址
文件偏移:数据在磁盘PE文件中的字节偏移,通过节表PointerToRawDataVirtualAddress转换。

VMP遍历PE文件查找函数地址

  1. 输入校验与 PE 头部定位

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    void *InternalGetProcAddress(HMODULE module, const char *proc_name)
    {
    // 1. 输入空值校验
    if (!module || !proc_name)
    return NULL;

    // 2. 定位并校验 DOS 头(IMAGE_DOS_HEADER)
    PIMAGE_DOS_HEADER dos_header = reinterpret_cast<PIMAGE_DOS_HEADER>(module);
    if (dos_header->e_magic != IMAGE_DOS_SIGNATURE) // IMAGE_DOS_SIGNATURE = 0x5A4D(MZ)
    return NULL;

    // 3. 定位并校验 NT 头(IMAGE_NT_HEADERS)
    PIMAGE_NT_HEADERS pe_header = reinterpret_cast<PIMAGE_NT_HEADERS>(
    reinterpret_cast<uint8_t *>(module) + dos_header->e_lfanew // e_lfanew 指向 NT 头偏移
    );
    if (pe_header->Signature != IMAGE_NT_SIGNATURE) // IMAGE_NT_SIGNATURE = 0x00004550(PE\0\0)
    return NULL;

    确认模块是合法的PE文件,
    核心字段:e_magicDOS头魔术字)、e_lfanewNT头偏移)、SignaturePE签名)是PE合法性的核心校验点。

  2. 定位导出表(Export Directory)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // 1. 从数据目录中获取导出表的 RVA 和大小
    uint32_t export_adress = pe_header->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress;
    if (!export_adress) // 无导出表(如纯EXE,非DLL)直接返回NULL
    return NULL;
    uint32_t export_size = pe_header->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].Size;

    // 2. 转换导出表 RVA 为内存绝对地址,内存中 PE 模块的基地址 + RVA = 数据的绝对内存地址
    PIMAGE_EXPORT_DIRECTORY export_directory = reinterpret_cast<PIMAGE_EXPORT_DIRECTORY>(
    reinterpret_cast<uint8_t *>(module) + export_adress
    );
  3. 处理【序号查找】场景

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    // 判断是否为序号:proc_name 指针值 <= 0xFFFF(序号范围)
    if (proc_name <= reinterpret_cast<const char *>(0xFFFF)) {
    // 序号 = 输入值 - 导出表的 Base(导出序号起始值,通常为1)
    ordinal_index = static_cast<uint32_t>(INT_PTR(proc_name)) - export_directory->Base;
    // 校验序号有效性:不能超出导出函数总数
    if (ordinal_index >= export_directory->NumberOfFunctions)
    return NULL;
    // 从函数地址表中获取函数的 RVA
    address = (reinterpret_cast<uint32_t *>(
    reinterpret_cast<uint8_t *>(module) + export_directory->AddressOfFunctions
    ))[ordinal_index];
    // 函数地址为空则返回NULL
    if (!address)
    return NULL;
    }
  4. 处理【名称查找】场景

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    else { 
    if (export_directory->NumberOfNames) { // 有导出名称表才继续
    // 二分查找(名称表是按字母序排序的,可高效匹配)
    int left_index = 0;
    int right_index = export_directory->NumberOfNames - 1;
    uint32_t *names = reinterpret_cast<uint32_t *>(
    reinterpret_cast<uint8_t *>(module) + export_directory->AddressOfNames
    );
    while (left_index <= right_index) {
    uint32_t cur_index = (left_index + right_index) >> 1; // 等价于除以2,二分核心
    // 比较当前名称与目标名称
    switch (strcmp((const char *)(reinterpret_cast<uint8_t *>(module) + names[cur_index]), proc_name)) {
    case 0: // 匹配成功
    // 从名称序号表中获取对应函数索引
    ordinal_index = (reinterpret_cast<WORD *>(
    reinterpret_cast<uint8_t *>(module) + export_directory->AddressOfNameOrdinals
    ))[cur_index];
    left_index = right_index + 1; // 退出循环
    break;
    case 1: // 当前名称 > 目标名称,查左半区
    right_index = cur_index - 1;
    break;
    case -1: // 当前名称 < 目标名称,查右半区
    left_index = cur_index + 1;
    break;
    }
    }
    }
    // 校验索引有效性
    if (ordinal_index >= export_directory->NumberOfFunctions)
    return NULL;
    // 从函数地址表获取函数 RVA
    address = (reinterpret_cast<uint32_t *>(
    reinterpret_cast<uint8_t *>(module) + export_directory->AddressOfFunctions
    ))[ordinal_index];
    if (!address)
    return NULL;
    }

    导出表的「名称三表」关系(PE导出表核心结构):
    AddressOfNames:名称字符串的RVA数组(按字母序排序);
    AddressOfNameOrdinals:序号数组(每个元素对应AddressOfNames的下标,指向AddressOfFunctions的索引);
    AddressOfFunctions:函数RVA数组(最终函数地址来源)。

注意点
VMP这里的二分查找,

  1. 使用了位运算cur_index = (left + right) >> 1去代替除法,在汇编层面上这样子二分查找特征更隐蔽。

  2. 找到后left_index = right_index + 1强制退出,而不是直接return返回,混淆代码执行流程(是未找到结果left_index > right_index边界条件退出的,而不是找到结果退出的?)。

  3. 处理【函数转发】场景

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    // 判断是否为转发函数:函数 RVA 落在导出表范围内
    if (address < export_adress || address >= export_adress + export_size)
    return reinterpret_cast<FARPROC>(reinterpret_cast<uint8_t *>(module) + address);

    // 解析转发字符串(格式:"目标模块.函数名" 或 "目标模块.#序号")
    const char *name = reinterpret_cast<const char *>(reinterpret_cast<uint8_t *>(module) + address);
    const char *tmp = name;
    const char *name_dot = NULL;
    while (*tmp) {
    if (*tmp == '.') {
    name_dot = tmp;
    break;
    }
    tmp++;
    }
    if (!name_dot)
    return NULL;

    // 提取目标模块名
    size_t name_len = name_dot - name;
    if (name_len >= MAX_PATH)
    return NULL;
    char file_name[MAX_PATH];
    size_t i;
    for (i = 0; i < name_len && name[i] != 0; i++) {
    file_name[i] = name[i];
    }
    file_name[i] = 0;

    // 加载目标模块
    module = GetModuleHandleA(file_name);
    if (!module)
    return NULL;

    // 转发到函数名
    if (name_dot[1] != '#')
    return InternalGetProcAddress(module, name_dot + 1);

    // 转发到序号
    int ordinal = atoi(name_dot + 2);
    return InternalGetProcAddress(module, LPCSTR(INT_PTR(ordinal)));