[原创] PE 文件结构剖析之 NT 头
紧跟 Rich Header 后面就是 NT 头了,在说 NT 头之前,先说下 RVA 这个概念,RVA 意为相对虚拟地址,这个相对是相对于文件载入到内存后的基地址,后续提到某个字段的 RVA,表示这个字段在内存中相对于载入的基地址偏移大小,可计算出这个字段的虚拟地址为载入的内存基地址加上 RVA。NT 头结构包含 3 个字段:// 32 位
typedef struct _IMAGE_NT_HEADERS {
DWORD Signature; // PE 标识
IMAGE_FILE_HEADER FileHeader; // 文件头
IMAGE_OPTIONAL_HEADER32 OptionalHeader; // 可选头
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;
// 64 位
typedef struct _IMAGE_NT_HEADERS64 {
DWORD Signature; // PE 标识
IMAGE_FILE_HEADER FileHeader; // 文件头
IMAGE_OPTIONAL_HEADER64 OptionalHeader; // 可选头
} IMAGE_NT_HEADERS64, *PIMAGE_NT_HEADERS64;
文件布局 (32 位),后面的解析以 32 位讲解
一、Signature
Signature 字段是一个 DWORD 型字段,占用 4 个字节,为固定值 0x50450000 (PE00),用 IMAGE_NT_SIGNATURE 宏表示,可用于进一步判断是否为 PE 文件,在这里先初步用 C++ 简单实现读取并判断:
#include <iostream>
#include <Windows.h>
int main(void)
{
// 1.以只读方式打开一个 PE 文件
HANDLE hFile = CreateFile(
TEXT("D:\\test"),
GENERIC_READ,
0,
NULL,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
NULL
);
// 2.判断文件句柄是否有效,若无效提示打开文件失败并退出
if (INVALID_HANDLE_VALUE == hFile)
{
std::cout << "Open file failed!" << std::endl;
exit(EXIT_SUCCESS);
}
// 3.获取 PE 文件大小
DWORD dwFileSize = GetFileSize(hFile, NULL);
// 4.申请一块 PE 文件大小的缓冲区
BYTE* pFileBuffer = new BYTE;
// 此变量仅做接收用
DWORD dwReadFile = 0;
// 5.将文件内容读到申请的缓冲区中
if (!ReadFile(hFile, pFileBuffer, dwFileSize, &dwReadFile, NULL))
{
std::cout << "Read file failed!" << std::endl;
CloseHandle(hFile);
delete[] pFileBuffer;
pFileBuffer = NULL;
exit(EXIT_SUCCESS);
}
// 6.判断这个文件是不是一个有效的 PE 文件
// 6.1 先检查 DOS 头中的 MZ 标志,判断 e_magic 字段是否为 0x5A4D (IMAGE_DOS_HEADER)
DWORD dwFileAddr = (DWORD)pFileBuffer;
PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)dwFileAddr;
if (pDosHeader->e_magic != IMAGE_DOS_SIGNATURE)
{
std::cout << "This is a invalid PE file." << std::endl;
CloseHandle(hFile);
delete[] pFileBuffer;
pFileBuffer = NULL;
exit(EXIT_SUCCESS);
}
// 6.2 若通过的话再获取 NT 头所在的位置,并判断 e_lfanew 字段是否为 0x50450000 (IMAGE_NT_SIGNATURE)
PIMAGE_NT_HEADERS pNtHeader = (PIMAGE_NT_HEADERS)(dwFileAddr + pDosHeader->e_lfanew);
if (pNtHeader->Signature != IMAGE_NT_SIGNATURE)
{
std::cout << "This is a invalid PE file." << std::endl;
CloseHandle(hFile);
delete[] pFileBuffer;
pFileBuffer = NULL;
exit(EXIT_SUCCESS);
}
// 7.若以上都通过则为一个有效的 PE 文件
std::cout << "This is a valid PE file." << std::endl;
delete[] pFileBuffer;
pFileBuffer = NULL;
return 0;
}
运行后如下:
二、文件头
第 2 个字段为文件头,是一个结构体,包含 7 个字段,后面只说相对重要字段:
typedef struct _IMAGE_FILE_HEADER {
WORD Machine; // 1)运行平台
WORD NumberOfSections; // 2)区段的数量
DWORD TimeDateStamp; // 3)文件的创建时间
DWORD PointerToSymbolTable; // 4)符号表指针 (0,已弃用)
DWORD NumberOfSymbols; // 5)符号的数量 (0,已弃用)
WORD SizeOfOptionalHeader; // 6)扩展头大小
WORD Characteristics; // 7)文件属性
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;
IMAGE_FILE_HEADER 结构体包含了 PE 文件的概览信息。Machine 字段表示此 PE 文件可以运行在哪种结构类型的 CPU 上,在给出的示例中为 0x14C,表示的是 Intel386,用 IMAGE_FILE_MACHINE_I386 宏表示,关于其它值可参考下面这张图 (具体类型可参看文档 https://learn.microsoft.com/zh-cn/windows/win32/debug/pe-format#machine-types):
NumberOfSections 字段表示的区段的数量,此示例为 5,表示有 5 个区段 (区段也称为节):
SizeOfOptionalHeader 字段表示扩展头的大小,一般为固定值,32 位为 0x00E0,64 位为 0x00F0。
TimeDateStamp 字段表示文件创建的时间,此示例为 0x65E7FA12,需要将其转换为友好可读形式:
// 8.获取文件头
IMAGE_FILE_HEADER FileHeader = pNtHeader->FileHeader;
std::cout << "run target:0x" << std::hex << FileHeader.Machine << std::endl;
std::cout << "section number:0x" << std::hex << FileHeader.NumberOfSections << std::endl;
// 进行时间转换
tm FileCreateTime;
errno_t ret = gmtime_s(&FileCreateTime, (time_t*)&FileHeader.TimeDateStamp);
if (ret == 0)
{
std::cout << "file createtime:" << std::dec << FileCreateTime.tm_year + 1900 << "-"
<< FileCreateTime.tm_mon + 1 << "-"
<< FileCreateTime.tm_mday << " "
<< FileCreateTime.tm_hour + 8 << ":"
<< FileCreateTime.tm_min << ":"
<< FileCreateTime.tm_sec << std::endl;
}
std::cout << "file Characteristics: 0x" << std::hex << FileHeader.Characteristics << std::endl;
运行结果 (参照 DIE):
Characteristics 字段表示 PE 文件的属性,一般情况下,普通 exe 为 0x010F,DLL 文件为 0x0210,可参考如下图:
在此示例中为 0x102,可看作是 0x0100 | 0x0002 组合成的值,意为此示例为 32 位且可执行的。具体更加详细说明参考:https://learn.microsoft.com/zh-cn/windows/win32/debug/pe-format#characteristics
三、可选头
第 3 个字段为可选头 IMAGE_OPTIONAL_HEADER,但很多人称它为扩展头,意为对 PE 属性的一个扩展说明,但个人觉得叫可选头也没错,因为某些文件类型没有它,比如 obj 文件,但此头 是 NT 头里面最重要的头,PE 加载器查找该头提供的特定信息,以便能够加载和运行可执行文件。
有很多人有一个疑问,为什么文件头里有一个可选头大小字段说明 (IMAGE_FILE_HEADER.SizeOfOptionalHeader),原因也很简单,就是可选头的大小不固定。可选头有两个版本,一个 32 位的,一个 64 位的,这两种版本有两个不同之处:
[*]结构体本身的大小,IMAGE_OPTIONAL_HEADER32 有 31 个成员,而 IMAGE_OPTIONAL_HEADER64 只有 30 个成员,少掉的一个字段为 32 位版本中的 BaseOfData;
[*]部分成员的数据类型:以下 5 个字段在 32 位中定义为 DWORD ,在 64 位中定义为 ULONGLONG (ImageBase、SizeOfStackReserve、SizeOfStackCommit、SizeOfHeapReserve、SizeOfHeapCommit);
结构如下:
// 32 位
typedef struct _IMAGE_OPTIONAL_HEADER {
// 标准域
WORD Magic; // 1) 标志位
BYTE MajorLinkerVersion; // 2) 链接器主版本号
BYTE MinorLinkerVersion; // 3) 链接器子版本号
DWORD SizeOfCode; // 4) 所有代码段 (注:本段代码里的 "段" 应为 "区段",简写为 "段" 是为了保证格式的整齐) 的总大小
DWORD SizeOfInitializedData; // 5) 所有已初始化段总大小
DWORD SizeOfUninitializedData; // 6) 所有未初始化段总大小
DWORD AddressOfEntryPoint; // 7) 程序执行入口 RVA
DWORD BaseOfCode; // 8) 代码段起始 RVA
DWORD BaseOfData; // 9) 数据段起始 RVA
// NT 附加域
DWORD ImageBase; // 10) 程序默认载入基地址
DWORD SectionAlignment; // 11) 内存中的段对齐值
DWORD FileAlignment; // 12) 文件中的段对齐值
WORD MajorOperatingSystemVersion; // 13) 系统主版本号
WORD MinorOperatingSystemVersion; // 14) 系统子版本号
WORD MajorImageVersion; // 15) 自定义的主版本号
WORD MinorImageVersion; // 16) 自定义的子版本号
WORD MajorSubsystemVersion; // 17) 所需子系统主版本号
WORD MinorSubsystemVersion; // 18) 所需子系统子版本号
DWORD Win32VersionValue; // 19) 保留,通常为 0x00
DWORD SizeOfImage; // 20) 内存中映像总尺寸
DWORD SizeOfHeaders; // 21) 各个文件头的总尺寸
DWORD CheckSum; // 22) 映像文件校验和
WORD Subsystem; // 23) 文件子系统
WORD DllCharacteristics; // 24) DLL 标志位
DWORD SizeOfStackReserve; // 25) 初始化栈大小
DWORD SizeOfStackCommit; // 26) 初始化实际提交栈大小
DWORD SizeOfHeapReserve; // 27) 初始化保留栈大小
DWORD SizeOfHeapCommit; // 28) 初始化实际保留栈大小
DWORD LoaderFlags; // 29) 调试相关,默认 0x00
DWORD NumberOfRvaAndSizes; // 30) 数据目录表的数量
IMAGE_DATA_DIRECTORY DataDirectory; // 31) 数据目录表
} IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32;
// 64 位
typedef struct _IMAGE_OPTIONAL_HEADER64 {
// 标准域
WORD Magic; // 1) 标志位
BYTE MajorLinkerVersion; // 2) 链接器主版本号
BYTE MinorLinkerVersion; // 3) 链接器子版本号
DWORD SizeOfCode; // 4) 所有代码段 (注:本段代码里的 "段" 应为 "区段",简写为 "段" 是为了保证格式的整齐) 的总大小
DWORD SizeOfInitializedData; // 5) 所有已初始化段总大小
DWORD SizeOfUninitializedData; // 6) 所有未初始化段总大小
DWORD AddressOfEntryPoint; // 7) 程序执行入口 RVA
DWORD BaseOfCode; // 8) 代码段起始 RVA
// DWORD BaseOfData; // 9) 数据段起始 RVA
// NT 附加域
ULONGLONG ImageBase; // 10) 程序默认载入基地址
DWORD SectionAlignment; // 11) 内存中的段对齐值
DWORD FileAlignment; // 12) 文件中的段对齐值
WORD MajorOperatingSystemVersion; // 13) 系统主版本号
WORD MinorOperatingSystemVersion; // 14) 系统子版本号
WORD MajorImageVersion; // 15) 自定义的主版本号
WORD MinorImageVersion; // 16) 自定义的子版本号
WORD MajorSubsystemVersion; // 17) 所需子系统主版本号
WORD MinorSubsystemVersion; // 18) 所需子系统子版本号
DWORD Win32VersionValue; // 19) 保留,通常为 0x00
DWORD SizeOfImage; // 20) 内存中映像总尺寸
DWORD SizeOfHeaders; // 21) 各个文件头的总尺寸
DWORD CheckSum; // 22) 映像文件校验和
WORD Subsystem; // 23) 文件子系统
WORD DllCharacteristics; // 24) DLL 标志位
ULONGLONG SizeOfStackReserve; // 25) 初始化栈大小
ULONGLONG SizeOfStackCommit; // 26) 初始化实际提交栈大小
ULONGLONG SizeOfHeapReserve; // 27) 初始化保留栈大小
ULONGLONG SizeOfHeapCommit; // 28) 初始化实际保留栈大小
DWORD LoaderFlags; // 29) 调试相关,默认 0x00
DWORD NumberOfRvaAndSizes; // 30) 数据目录表的数量
IMAGE_DATA_DIRECTORY DataDirectory; // 31) 数据目录表
} IMAGE_OPTIONAL_HEADER64, *PIMAGE_OPTIONAL_HEADER64;
同样只提下相对比较重要的字段,Magic 字段表示文件的类型,普通可执行镜像为 0x010B,ROM 镜像为0x0170,PE32+ 可执行文件为 0x20B,这个字段决定了可执行文件是 32 位的还是 64 位的,Windows 加载器将忽略 IMAGE_FILE_HEADER.Machine。
SizeOfCode 字段表示所有 IMAGE_SCN_CNT_CODE 属性的区段总大小,此大小在计算时按照磁盘扇区字节数的整数倍计算。
SizeOfInitializedData 字段保存初始化数据 (.data) 大小,如果有多个部分,则保存所有初始化数据部分的总和。
SizeOfUninitializedData 字段保存未初始数据 (.bss) 大小,如果有多个部分,则保存所有未初始化数据部分的总和。
AddressOfEntryPoint 字段表示程序执行入口的 RVA,在大多数可执行文件中,入口点并不指向 main、winmain 和 dllmain 等函数的入口,而是指向运行库代码,再由其调用这些函数。对于程序映像,此相对地址指向起始地址,对于设备驱动程序,它指向初始化函数。对于 DLL,入口点是可选的,如果没有入口点,则 AddressOfEntryPoint 字段设置为 0。
BaseOfCode 字段表示代码段起始 RVA
BaseOfData 字段 (仅 32 位) 表示数据段的起始 RVA,这个值在 64 位文件中是无效的。
ImageBase 字段表示文件在内存中的首选装入的基地址 (默认为 0x400000),加载器将试图在此地址载入,如果载入成功,则跳过基址重定位步骤,如果此地址被占用,则会重新在正确对齐的合法地址中选择一个作为实际载入地址,由于 ASLR 等内存保护 (VS 中默认开启,ASLR 需要操作系统支持及程序本身支持,两者满足才能生效,xp 系统不支持 ASLR) 以及许多其他原因该字段指定的地址几乎从未被使用过,在这种情况下,PE 加载器会选择一个未使用的内存范围来加载映像,将映像加载到该地址后加载器进入一个称为重定位的过程,有一个特殊的部分保存有关如果需要重定位则需要修复的位置的信息,该部分称为重定位表 (.reloc)。
ASLR (Address space layout randomization,地址空间布局随机化) 是一种针对缓冲区溢出的安全保护技术,通过对堆、栈、共享库映射等线性区布局的随机化,通过增加攻击者预测目的地址的难度,防止攻击者直接定位攻击代码位置,达到阻止溢出攻击的目的。据研究表明 ASLR 可以有效的降低缓冲区溢出攻击的成功率,如今 Linux、FreeBSD、Windows 等主流操作系统都已采用了该技术。
把文件载入到调试器中,发现载入的首地址为 0x6B5C68,开头第一个不是 4,这个就是 ASLR 在起作用
如果没有 ASLR,载入的首地址可以计算出 ImageBase + AddressOfEntryPoint = 0x400000 + 0x5C68 = 0x405C68,而不是 0x6B5C68
在 ASLR 存在的情况下,每次重启操作系统后 (必须重启,载入首地址才会发生变化) 将文件载入调试器查看,载入首地址会变化,细心的你有没有发现其中规律,载入的基地址一般为几十万整 H,比如此示例为 0x6B0000,低 2 个字节全为 0,而 AddressOfEntryPoint 经常低 2 个字节有值,高 2 个字为 0,这样首地址的低 2 个字节始终不会变 (这里能玩出骚操作来,具体怎么玩,看你的能力了)。那如何去掉 ASLR 呢,有两种方法 (这里只从文件角度出发,通过修改注册表来关闭系统 ASLR 就不说了),一种是在有源码情况下只需要将随机基址选项设置为否即可:
另一种是没有源码情况下,即修改他人编写的程序,通过修改文件头中 Characteristics 字段中 IMAGE_FILE_RELOCS_STRIPPED 值为 1 即可 (stripped 意为剥离)
或者修改可选头中 DllCharacteristics 字段中 IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE 的值为 0
在关掉 ASLR 后,重新载入到调试器中查看,发现载入的首地址变为 0x405C68 了
ImageBase 可谓是比较重要的字段,花了这么大的篇幅,接下来说下其它字段吧。SectionAlignment 字段表示内存对齐粒度,默认为 0x1000;FileAlignment 字段表示文件对齐粒度,默认为 0x200。
SizeOfImage 字段表示文件装入内存后的总大小 (从 ImageBase 到最后一个区段的总大小),为 SectionAligment 的倍数,因为将映像加载到内存时会使用该值。
0x1000 + 0x1C000 + 0xA000 + 0x2000 + 0x1000 = 0x2A000
这还可以通过区段结构中 VirtualSize 计算,不过这个字段并未做对齐处理,按 0x1000 对齐后处理后也是能计算的出。
SizeOfHeader 字段表示 MS-DOS 头、PE 头、区块表的大小之和,为 FileAlignment 的倍数。
CheckSum 字段表示文件的校验和 (在一些 exe 文件中此值可为 0,但在一些内核模式的驱动与系统 DLL 中此值必须为一个有效值)。
Subsystem 字段表示可执行文件所期望的子系统值,这个值只对 exe 文件是重要的。
DllCharacteristics 字段定义可执行映像文件的一些特征,例如是否兼容 NX 以及是否可以在运行时重定位。我不知道为什么它被命名为 DllCharacteristics ,它存在于正常的可执行映像文件中,并且定义了可应用于正常可执行文件的特征,文档说明 https://learn.microsoft.com/zh-cn/windows/win32/debug/pe-format#dll-characteristics。
SizeOfStackReserve、SizeOfStackCommit、SizeOfHeapReserve 和 SizeOfHeapCommit 这些字段指定要保留的堆栈的大小、要提交的堆栈的大小,分别是要保留的本地堆空间的大小和要提交的本地堆空间的大小。
NumberOfRvaAndSizes 字段表示目录成员的数量,一般为 0x00000010 (最后一个目录成员值为 0)。
DataDirectory 字段表示 IMAGE_DATA_DIRECTORY 结构的数组,结构如下:
typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress; // 数据块的起始RVA地址
DWORD Size; // 数据块的长度
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
这是一个非常简单的结构,只有两个字段,第一个为数据目录起始 RVA,第二个为数据目录的大小。
什么是数据目录,数据目录是位于 PE 文件的一个部分中的一段数据。数据目录包含加载程序所需的有用信息,一个非常重要的目录例子是导入表目录,它包含从其它库导入的外部函数的列表,后面会专门来讨论。
请注意,并非所有数据目录都具有相同的结构,IMAGE_DATA_DIRECTORY.VirualAddress 指向数据目录,但是该目录的类型决定了如何解析该数据块。
以下为 winnt.h 中定义的数据目录表:
#define IMAGE_DIRECTORY_ENTRY_EXPORT 0x0 // 1)
#define IMAGE_DIRECTORY_ENTRY_IMPORT 0x1 // 2)
#define IMAGE_DIRECTORY_ENTRY_RESOURCE 0x2 // 3)
#define IMAGE_DIRECTORY_ENTRY_EXCEPTION 0x3 // 4)
#define IMAGE_DIRECTORY_ENTRY_SECURITY 0x4 // 5)
#define IMAGE_DIRECTORY_ENTRY_BASERELOC 0x5 // 6)
#define IMAGE_DIRECTORY_ENTRY_DEBUG 0x6 // 7)
#define IMAGE_DIRECTORY_ENTRY_ARCHITECTURE 0x7 // 8)
#define IMAGE_DIRECTORY_ENTRY_GLOBALPTR 0x8 // 9)
#define IMAGE_DIRECTORY_ENTRY_TLS 0x9 // 10)
#define IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG 0xA // 11)
#define IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT 0xB // 12)
#define IMAGE_DIRECTORY_ENTRY_IAT 0xC // 13)
#define IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT 0xD // 14)
#define IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR0xE // 15)
后面会专门来讨论数据目录表这个问题。
// 9.可选头信息
IMAGE_OPTIONAL_HEADER OptionalHeader = pNtHeader->OptionalHeader;
std::cout << "\nOption Header" << std::endl;
std::cout << "filetype: 0x" << std::hex << OptionalHeader.Magic << std::endl;
// ...
// 这里不就一一列出来了
// 主要列下最后一个字段目录信息
// 需加头文件 #include <iomanip>
// 才能调用 setfill 和 setw
std::cout << "DataDirectory\nRVA\t\tSize" << std::endl;
for (int i = 0; i < IMAGE_NUMBEROF_DIRECTORY_ENTRIES; ++i)
{
std::cout << "0x" << std::hex << std::setfill('0') << std::setw(8) << OptionalHeader.DataDirectory.VirtualAddress << "\t";
std::cout << "0x" << std::hex << std::setfill('0') << std::setw(8) << OptionalHeader.DataDirectory.Size << std::endl;
}
运行后
对照一下,值没有显示的都为 0
最后来回顾下 NT 头的总体结构 (32 位),我画了一张图,方便大家脑海里有个大概样子:
最后一张高清图:
感谢楼主无私奉献 {:1_447:}
页:
[1]