pe文件结构学习
PE(Portable Executable)是Windows平台下可执行文件(EXE)、动态链接库(DLL)、驱动(SYS)等的标准格式,核心由DOS头、NT头、节表、节区数据四大部分构成,是Windows加载、执行程序的底层依据。
总体结构图
1 | +---------------------+ |
DOS 头(IMAGE_DOS_HEADER)
基本信息
大小:固定64字节
作用:兼容DOS系统,同时提供NT头的定位入口,是PE文件的 “入口钥匙”
定义:
1 | typedef struct _IMAGE_DOS_HEADER { |
关键字段
e_magic:DOS标识(魔术字),合法PE文件必须为此值。e_lfanew:NT头在文件中的偏移,指向PE\0\0签名,通过此字段定位PE核心(NT头)。
DOS Stub(DOS 存根)
紧跟DOS头,是一段16位DOS程序,在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 | // 32位版本 |
PE 签名(PE Signature)
大小:4字节(DWORD)
值:0x00004550(ASCII“PE\0\0”)
作用:标识这是一个32/64位PE文件,区别于16位NE、LE格式
NT 头(IMAGE_NT_HEADERS)
大小:20字节(固定)
作用:描述文件基本属性,如目标架构、节区数量、文件类型等
定义:
1 | typedef struct _IMAGE_FILE_HEADER { |
可选头(IMAGE_OPTIONAL_HEADER)
名称:虽叫 “可选”,但Windows可执行文件必须存在,是加载器的核心依据
大小:32位224字节(0xE0),64位240字节(0xF0)
定义:
1 | // 32位结构 |
数据目录(Data Directory)16个IMAGE_DATA_DIRECTORY条目(每个8字节:RVA + 大小),是PE的 “功能索引表”。
1 |
节表(Section Headers)
基本信息
位置:紧跟可选头之后,无直接指针,通过SizeOfHeaders计算定位
大小:每个节头固定40字节,数量由IMAGE_FILE_HEADER.NumberOfSections指定
定义:
1 | typedef struct _IMAGE_SECTION_HEADER { |
常见标准节区
1 | 节名 主要内容 内存属性 作用 |
关键数据结构
导入表(Import Table)
作用:记录程序依赖的外部DLL(如kernel32.dll、user32.dll)及要调用的函数,Windows加载器通过它完成动态链接
结构:IMAGE_IMPORT_DESCRIPTOR数组(以全 0 结尾),每个描述符对应一个DLL
1 | typedef struct _IMAGE_IMPORT_DESCRIPTOR { |
核心流程:
加载器遍历导入表,加载对应DLL
查找DLL导出表,获取函数地址
填充IAT(导入地址表),程序通过IAT间接调用外部函数
导出表(Export Table)
作用:DLL向外暴露函数 / 变量,供其他程序调用,仅DLL存在此表
结构:IMAGE_EXPORT_DIRECTORY,包含导出函数名、序号、地址表
1 | typedef struct _IMAGE_EXPORT_DIRECTORY { |
三个数组的关系
1 | AddressOfNames AddressOfNameOrdinals AddressOfFunctions |
基址重定位表(Base Relocation Table)
作用:当程序无法加载到指定ImageBase(如被占用)时,修正代码中所有绝对地址(如call 0x401000)
结构:按内存页(4KB)分组,每组包含重定位类型和偏移
1 | typedef struct _IMAGE_BASE_RELOCATION { |
原理:计算实际基址与默认基址的差值(Delta),对所有绝对地址加上Delta
PE文件核心概念
VA(虚拟地址):程序加载到内存后的绝对地址,VA = ImageBase + RVARVA(相对虚拟地址):相对ImageBase的偏移,PE文件中统一使用RVA描述地址
文件偏移:数据在磁盘PE文件中的字节偏移,通过节表PointerToRawData与VirtualAddress转换。
VMP遍历PE文件查找函数地址
输入校验与 PE 头部定位
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17void *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_magic(DOS头魔术字)、e_lfanew(NT头偏移)、Signature(PE签名)是PE合法性的核心校验点。定位导出表(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
);处理【序号查找】场景
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;
}处理【名称查找】场景
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
38else {
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这里的二分查找,
使用了位运算
cur_index = (left + right) >> 1去代替除法,在汇编层面上这样子二分查找特征更隐蔽。找到后
left_index = right_index + 1强制退出,而不是直接return返回,混淆代码执行流程(是未找到结果left_index > right_index边界条件退出的,而不是找到结果退出的?)。处理【函数转发】场景
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)));
