C/C++

手工打造 PE 解析器之 DOS 头和 NT 头

zeronohacker · 12月24日 · 2018年 · 205次已读

1 前述

可执行文件格式是操作系统本身执行机制的反映,理解它有助于对操作系统的深刻理解,掌握可执行文件的数据结构及其一些机理,是研究软件安全的必修课。PE (Portable Executable File Format) 是目前 windows 平台上的主流可执行文件格式。PE 文件衍生于早期的 COFF 文件格式,描述 PE 格式及 COFF 文件的主要地方在 winnt.h 这个头文件,其中有一节叫 Image Format,如下:

winnt.h 中的 Image Format 节

该节给出了 DOS MZ 格式和 windows 3.1 的 NE 格式文件头,之后就是 PE 文件的内容,在这个头文件中,几乎能找到关于 PE 文件的每一个数据结构的定义、枚举类型、常量定义。winnt.h 这个头文件是 PE 文件定义的最终决定者。DLL 和 EXE 文件之间的区别完全是语义上的,它们使用完全相同的 PE 格式。唯一的区别就是用一个字段标识出这个文件是 EXE 还是 DLL。同时也包括其它的 DLL 扩展,比如 OCX 控件和控制面板程序 (CPL 文件)。另外,64 位 windows 只是对 PE 格式做了一些简单的修饰,新格式叫 PE32+,没有新的结构加进去,其余的改变只是简单地将以前的 32 位字段扩展成 64 位,比如 IMAGE_NT_HEADERS,如下:

IMAGE_NT_HEADERS 在 winnt.h 头文件中的定义

2 PE 文件大体结构

结构的选择依赖于用户正在编译的模式 (尤其是 _WIN64 是否被定义),在具体学习 PE 之前,先大概清楚下 PE 格式布局是怎样子的,如下:

PE 格式大致布局

PE 文件使用的是一个平面地址空间,所有代码和数据都被合并在一起,组成一个很大的结构,文件的内容被分割为不同的区块,区块包含代码和数据,各个区块按页边界来对齐,区块没有大小限制,是一个连续结构,每个块都有它自己在内存中的一套属性。PE 文件是由 PE 加载器加载到内存中的,这个 PE 加载器也就是 windows 加载器,它并不是将 PE 文件作为单一内存映射文件装入到内存中,而是去遍历 PE 文件,决定将哪一部分进行映射,这种映射方式是将文件较高的偏移位置映射到较高的内存地址,当磁盘文件装入到内存中,其数据结构布局是一致的,但是数据之间的相对位置可能会改变,如下:

PE 文件的平面地址空间结构

3 模块和基地址

下面需要理清两个概念,那就是 模块 和 基地址,当 PE 文件通过 windows 加载器加载到内存后,内存中的版本被称为模块 (Module),映射文件的起始地址被称为模块句柄 (hModule),可以通过模块句柄来访问在内存中其它的数据结构,这个初始地址也被称为基地址 (ImageBase)。在 32 位 windows 系统中可以直接调用 GetModuleHandle 以取得指向 DLL 的指针,通过指针访问该 DLL Module 的内容,函数原型为:HMODULE WINAPI GetModuleHandle(LPCTSTR lpModuleName)

功能:获取一个应用程序或动态链接库的模块句柄。

参数:传递一个可执行文件或 DLL 文件名字符串

返回值:若执行成功,则返回模块的句柄,也就是加载的基地址,若返回零,则表示失败。如果传递参数为 NULL,则返回调用的可执行文件的基地址。

注意事项:只有在当前进程中,这个句柄才会有效,也就是说已映射到调用该函数的进程内,才会正确得到模块句柄。

#include <windows.h>
#include <iostream>
int main()
{
    HMODULE hModule = GetModuleHandle(NULL);
    std::cout << hModule << std::endl;
    return 0;
}

PE 文件加载的基地址 (ImageBase):EXE 默认基地址为 0x00400000,DLL 默认基地址为 0x10000000,这个值可以在链接应用时使用链接程序的 /BASE 选项设定,或者通过 REBASE 应用程序进行设置。说完基地址,再来说下相对虚拟地址,由于 PE 文件中里的东西可以载入到空间的任何位置,所以不能依赖于 PE 的载入点,必须有一个方法来指定地址而不依赖于 PE 载入点的地址,所以出现相对虚拟地址 (RVA) 概念,RVA 只是内存中的一个简单的相对于 PE 文件装入地址的偏移位置,例如,假设一个 EXE 文件从地址 0x400000 处装入,并且它的代码区块开始于 0x401000,代码区块的 RVA 就是:0x401000 – 0x400000 = 0x1000,在这里,0x401000 是实际的内存地址,这个地址被称为虚拟内存地址 (VA),另外也可以把虚拟地址想象为加上首选装入地址的 RVA。

4 文件偏移地址

当 PE 文件储存在磁盘上,某个数据的位置相对于文件头的偏移量,称为文件偏移地址或物理地址。文件偏移地址从 PE 文件的第一个字节开始计数,起始值为 0,用十六进制文本编辑器打开文件,里头显示的就是文件偏移地址。

5 IMAGE_DOS_HEADER 结构

IMAGE_DOS_HEADER 结构

在这个结构体中,有两个字段非常重要,分别是第一个和最后一个,其它的不重要,其中第一个 e_magic 字段需要被设置为 0x5A4D。它也被称为魔术数字。

e_magic 字段 0x5A4D

这个值有个宏定义,名为 IMAGE_DOS_SIGNATURE,它的 ASCII 值为 MZ,是 MS-DOS 的最初创建者之一 Mark Zbikowski 字母的缩写。

IMAGE_DOS_SIGNATURE 宏的值为 0x5A4D

e_lfanew 字段是真正 PE 文件头的相对偏移(RVA),那么,这个字段在哪呢?

IMAGE_DOS_HEADER 结构体成员

上图已经说明了,为了验证是否正确,如下:

e_lfanew 值指向 NT 头

在 3CH 偏移处,显示 0x00000110 (由于 Intel CPU 属于 Little-Endian 类,字符存储时低位在前,高位在后,反序排列,将顺序恢复后便是 0x00000110),这个是 e_lfanew 字段所存储的值,它占 4 个字节。后面就是 PE 头了。

6 IMAGE_NT_HEADER 结构

IMAGE_NT_HEADERS 在 winnt.h 头文件中的定义

在一个有效的 PE 文件里,Signature 字段被设置为 0x00004550,ASCII 码字符是 PE00

特征值为 0x00004550

宏定义为 IMAGE_NT_SIGNATURE

IMAGE_NT_SIGNATURE

那么这两个重要的字段 (e_lfanew 和 Signature) 有什么用呢?这个在以后解析 PE 文件,判断一个文件是否是一个 PE 文件时提供重要依据,即判断这两个字段的值是否为 0x5A4D 和 0x00004550,你也可以用它们的宏定义,分别为 IMAGE_DOS_SIGNATURE 和  IMAGE_NT_SIGNATURE,如果相等,则为一个 PE 文件,如果不相等,则不是一个 PE 文件。

#include <windows.h>
#include <iostream>

int main()
{
    // 1.首先须打开一个文件
    HANDLE hFile = CreateFile(
        TEXT("test.png"),
        GENERIC_ALL,
        NULL,
        NULL,
        OPEN_EXISTING,
        NULL,
        NULL
    );

    // 2.判断文件句柄是否有效,若无效则提示打开文件失败并退出
    if (hFile == INVALID_HANDLE_VALUE)
    {
        std::cout << "打开文件失败!" << std::endl;
        CloseHandle(hFile);
        exit(EXIT_SUCCESS);
    }

    // 3.若打开文件成功,则获取文件的大小
    DWORD dwFileSize = GetFileSize(hFile, NULL);

    // 4.申请内存空间,用于存放文件数据
    BYTE * FileBuffer = new BYTE[dwFileSize];

    // 5.读取文件内容
    DWORD dwReadFile = 0;
    ReadFile(hFile, FileBuffer, dwFileSize, &dwReadFile, NULL);

    // 6.判断这个文件是不是一个有效的PE文件
    //    6.1 先检查 DOS 头中的 MZ 标记,判断 e_magic 字段是否为 0x5A4D,或者是 IMAGE_DOS_SIGNATURE
    DWORD dwFileAddr = (DWORD)FileBuffer;
    auto DosHeader = (PIMAGE_DOS_HEADER)dwFileAddr;
    if (DosHeader->e_magic != IMAGE_DOS_SIGNATURE)
    {
        // 如果不是则提示用户,并立即结束
        MessageBox(NULL, TEXT("这不是一个有效 PE 文件"), TEXT("提示"), MB_OK);
        delete[] FileBuffer;
        CloseHandle(hFile);
        exit(EXIT_SUCCESS);
    }

    //    6.2 若都通过的话再获取 NT 头所在的位置,并判断 e_lfanew 字段是否为 0x00004550,或者是 IMAGE_NT_SIGNATURE
    auto NtHeader = (PIMAGE_NT_HEADERS)(dwFileAddr + DosHeader->e_lfanew);
    if (NtHeader->Signature != IMAGE_NT_SIGNATURE)
    {
        // 如果不是则提示用户,并立即结束
        MessageBox(NULL, TEXT("这不是一个有效 PE 文件"), TEXT("提示"), MB_OK);
        delete[] FileBuffer;
        CloseHandle(hFile);
        exit(EXIT_SUCCESS);
    }

    // 7.若上述都通过,则为一个有效的 PE 文件
    MessageBox(NULL, TEXT("这是一个有效 PE 文件"), TEXT("提示"), MB_OK);
    delete[] FileBuffer;
    CloseHandle(hFile);

    // 8.结束程序
    return 0;
}

以上代码就是简单实现判断一个文件是不是有效的 PE 文件。在上述代码中,运用了 CreateFile()、GetFileSize()、ReadFile() 来获取文件内容,得到文件的基址 dwFileAddr,只需将该变量转换成 PIMAGE_DOS_HEADER 类型,那么就能获取到 NT 头的开始位置,NT 头的位置可通过 (PIMAGE_NT_HEADERS)((PIMAGE_DOS_HEADER)dwFileAddr->e_lfanew + dwFileAddr) 获取,有了这个,后面的工作就变得简单多了。

7 IMAGE_FILE_HEADER 结构

IMAGE_FILE_HEADER 结构体成员

该结构体描述的是文件的一般性质,有 7 个字段,共占 20 个字节,20 相当于十六进制的 14H,下图已标出实际位置,如下:

各成员在文件中对应的位置
  • 第 1 处标记的是 Machine 字段,占两个字节,它的值为 0x014C,代表的是 Intel i386 平台。
  • 第 2 处标记的是 NumberOfSections 字段,占两个字节,它的值为 0x0006,代表的是有 6 个区块,也可以说有 6 个节。
  • 第 3 处标记的是 TimeDateStamp 字段,占四个字节,它的值为 0x5C0748D5,代表的是文件创建日期和时间。由以下图可以看出,该文件创建时间为 2018-12-05 / 11:41:09。
时间转换
  • 第 4 处值以 0 填充,用不到。
  • 第 5 处值以0填充,用不到。
  • 第 6 处字段就比较重要,划重点,SizeOfOptionalHeader,占两个字节,它的值为 0x00E0,代表的是 IMAGE_OPTIONAL_HEADER32 结构的大小,在 32 位系统,它的值为 0x00E0,在 64 位系统,它的值为 0x00F0,
  • 第 7 处最后一个字段 Characteristics,占两个字节,它的值为 0x0102,代表的是文件的属性。这个值是由 0x0100 和 0x0002 两者之和,0x0100 这个值代表的是目标平台为 32 位机器,0x0002 这个值代表文件可执行,如果为 0,一般是链接出现了问题。
// 获取文件头
auto FileHeader = NtHeader->FileHeader;
// 接下来就是解析各字段
std::cout << "运行平台:0x" << std::hex << FileHeader.Machine << std::endl;
std::cout << "区块数目:0x" << std::hex << FileHeader.NumberOfSections << std::endl;
std::cout << "文件创建日期和时间:0x" << std::hex << FileHeader.TimeDateStamp << std::endl;
std::cout << "IMAGE_OPTIONAL_HEADER32结构大小:0x" << std::hex << FileHeader.SizeOfOptionalHeader << std::endl;
std::cout << "文件属性:0x" << std::hex << FileHeader.Characteristics << std::endl;

将上述代码插入到 return 0; 之前,运行如下:

解析文件中的文件头各字段

再将上面文件创建日期和时间进行转换,代码如下:

// 获取文件头
auto FileHeader = NtHeader->FileHeader;
// 接下来就是解析各字段
std::cout << "运行平台:0x" << std::hex << FileHeader.Machine << std::endl;
std::cout << "区块数目:0x" << std::hex << FileHeader.NumberOfSections << std::endl;
// 进行时间转换
tm FileCreateTime = { 0 };
int nError = gmtime_s(&FileCreateTime, (time_t*)&FileHeader.TimeDateStamp);
if (!nError)
{
    std::cout << "文件创建日期和时间:" << 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 << "IMAGE_OPTIONAL_HEADER32结构大小:0x" << std::hex << FileHeader.SizeOfOptionalHeader << std::endl;
std::cout << "文件属性:0x" << std::hex << FileHeader.Characteristics << std::endl;

上面是用到了 tm 的结构,以及 gmtime_s 这个函数进行转换,在用之前需要包含头文件 time.h,运行如下:

进行时间转换

不过还是要注意下,首先 tm_year 这个值为十六进制,需转成十进制,而且要加上 1900,因为时间是从 1900 开始算,它的值为偏移,其次月是从 0 开始算的,所以要加 1,最后是时区问题,因为我这里位于东八区,所以小时需加上 8。另外关于调试的那两个字段,没有必要去对它深究,因为微软的VS已用了新的 Debug 格式,这个只是用来设置 COFF 符号,跟 COFF 符号有关,一般这个值都为 0,所以不探讨它。关于运行平台代码和文件属性代码可以去网上查表就行,这里就省略。

8 IMAGE_OPTIONAL_HEADER 结构

IMAGE_OPTIONAL_HEADER 结构体成员

上图展示的是 IMAGE_OPTIONAL_HEADER32 结构体各字段,这个结构体相对来说就比较大,我已经分析好了,这个是 32 位的,64 位的大体结构没变,只是有几个字段改成的 ULONGLONG 类型,那么它在实际内部是怎么样的呢?下面这张图是验证上面图片所叙述的。

各成员在文件中的位置

上图所标记的,为 IMAGE_OPTIONAL_HEADER32 结构所有成员,你也注意到了,在结尾处,有 .text,说明已经到了该结构体的末尾了,算了一下,恰好占了 224 个字节,这个值其实在 IMAGE_FILE_HEADER 中倒数第二个字段已经指出了,值为 0xE0,这个值相当于十进制中的 224。为了更好的说明,我用序号标记了各个字段,其中有一些为透明,一是没地方标,二是能看清实际数值大小,这样便于分析。

检测结果

以下是各字段解析:

序号字段含义
0001Magic这个是一个标记,它的值为 0x010B,代表的是普通的可执行映象,一般是 0x010B,如果是 64 位,则为 0x020B,如果为 ROM 映象,该值为 0x0107
0002MajorLinkerVersion链接程序主版本号,值为 0x0E
0003MinorLinkerVersion链接程序次版本号,值为 0x00
0004SizeOfCode所有含有代码区块的总大小,该值为 0x0031D000,这个代码区块是带有 IMAGE_SCN_CNT_CODE 属性,这个值是向上对齐某一个值的整数倍。通常情况下,多数文件只有一个 Code 块,所以这个字段和 .text 块的大小匹配
0005SizeOfInitializedData所有初始化数据区块总大小,该值为 0x000B4000,这个是在编译时所构成的块的大小(不包括代码段),一般这个值是不准确的
0006SizeOfUninitializedData所有未初始化数据区块总大小,该值为 0,这些块在程序开始运行时没有指定值,未初始化的数据通常在 .bss 块中
0007AddressOfEntryPoint程序执行入口 RVA,该值为 0x002B56D0。在大多数可执行文件中,这个地址并不直接指向 Main、WinMain 或者是 DllMain,而是指向运行库代码并由它来调用上述函数。对于 DLL 来说,这个入口点是在程序初始化和关闭时以及线程创建和毁灭时被调用
0008BaseOfCode代码段的起始 RVA,该值为 0x00001000,如果是用微软的链接器生成的,则该值通常是 0x00001000
0009BaseOfData数据段的起始 RVA,该值为 0x0031E000,数据段通常在内存的末尾,对于不同版本的微软链接器,这个值是不一致的,在64位可执行文件中是不出现的
0010ImageBase程序默认装入地址,该值为 0x00400000,加载器试图在这个地址表装入 PE 文件,如果可执行文件是在这个地址装入的,那么加载器将跳过应用基址重定位的步骤
0011SectionAlignment内存中区块对齐大小,值为 0x00001000,默认对齐尺寸是目标 CPU 的页尺寸,最小的对齐尺寸是一页 1000H(4KB),在 IA-64 上,这个值是 8KB。每个区块装入地址必定是本字段指定数值的整数倍
0012FileAlignment磁盘上 PE 文件内的区块对齐大小,值为 0x00000200,对于 x86 的可执行文件,这个值通常是 200H 或 1000H,这是为了保证块总是从磁盘的扇区开始的,这个值必须是 2 的幂,最小为 200H
0013MajorOperatingSystemVersion要求操作系统的最低版本号的主版本号,该值为 0x0006,这个值似乎没什么用
0014MinorOperatingSystemVersion同上,没什么用
0015MajorImageVersion同上,没什么用
0016MinorImageVersion同上,没什么用
0017MajorSubsystemVersion同上,没什么用
0018MinorSubsystemVersion同上,没什么用
0019Win32VersionValue同上,没什么用
0020SizeOfImage映象装入内存后的总尺寸,该值为 0x003D5000,它指装入文件从 ImageBase 到最后一个块的大小,最后一个块根据其大小往上取整
0021SizeOfHeaders是 MS-DOS 头部、PE 头部、区块表的组合尺寸。该值为 0x00000400
0022CheckSum校验和,IMAGEHLP.DLL 中的 CheckSumMappedFile 函数可以计算这个值,一般的EXE文件可以是 0,但一些内核模式的驱动程序和系统 DLL 必须有一个校验和
0023Subsystem一个标明可执行文件所期望的子系统的枚举值,这个值只对 EXE 是重要的。该值为 0x0003
0024DllCharacteristicsDllMain() 函数何时被调用,默认为 0
0025SizeOfStackReserve在 EXE 文件里,为线程保留的堆栈大小,它一开始只提交其中一部分,只有在必要时,才提交剩下的部分
0026SizeOfStackCommit在 EXE 文件里,一开始即被委派堆栈的内存数量,默认值为 4KB
0027SizeHeapReserve在 EXE 文件里,为进程的默认堆保留的内存,默认值为 1MB,但是在当前 Windows 里,堆值在用户不干涉的情况下就能增长超过这个值
0028SizeOfHeapCommit在 EXE 文件里,委派给堆的内存大小,默认值是 4KB
0029LoaderFlag与调试有关,默认为 0
0030NumberOfRvaAndSizes数据目录表的项数,这个字段一直以来都为 16
0031DataDirectory[16]数据目录表,由数个 IMAGE_DATA_DIRECTORY 结构组成,指向输入表、输出表、资源等数据

提示:以上表格中有颜色标记的为比较重要的字段

// 读取扩展头各个字段
auto OptionalHeader = NtHeader->OptionalHeader;
std::cout << std::endl;
std::cout << "扩展头解析" << std::endl;
if (OptionalHeader.Magic == 0x10b)
{
    std::cout << "文件类型:32位" << std::endl;
}
std::cout << "链接器的主版本号:0x" << std::hex << std::setfill('0') 
    << std::setw(8) << (int)OptionalHeader.MajorLinkerVersion << std::endl;
std::cout << "链接器的次版本号:0x" << std::hex << std::setfill('0') 
    << std::setw(8) << (int)OptionalHeader.MinorLinkerVersion << std::endl;
std::cout << "所有含有代码区块的总大小:0x" << std::hex << std::setfill('0') 
    << std::setw(8) << OptionalHeader.SizeOfCode << std::endl;
std::cout << "所有初始化数据区块的总大小:0x" << std::hex << std::setfill('0') 
    << std::setw(8) << OptionalHeader.SizeOfInitializedData << std::endl;
std::cout << "所有未初始化数据区块的总大小:0x" << std::hex << std::setfill('0') 
    << std::setw(8) << OptionalHeader.SizeOfUninitializedData << std::endl;
std::cout << "程序执行入口:0x" << std::hex << std::setfill('0') 
    << std::setw(8) << OptionalHeader.AddressOfEntryPoint << std::endl;
std::cout << "程序默认装入基地址:0x" << std::hex << std::setfill('0') 
    << std::setw(8) << OptionalHeader.ImageBase << std::endl;
std::cout << "内存中区块对齐值:0x" << std::hex << std::setfill('0') 
    << std::setw(8) << OptionalHeader.SectionAlignment << std::endl;
std::cout << "文件中区块对齐值:0x" << std::hex << std::setfill('0') 
    << std::setw(8) << OptionalHeader.FileAlignment << std::endl;
std::cout << "操作系统主版本号:0x" << std::hex << std::setfill('0') 
    << std::setw(8) << OptionalHeader.MajorOperatingSystemVersion << std::endl;
std::cout << "操作系统次版本号:0x" << std::hex << std::setfill('0') 
    << std::setw(8) << OptionalHeader.MinorOperatingSystemVersion << std::endl;
std::cout << "用户自定义主版本号:0x" << std::hex << std::setfill('0') 
    << std::setw(8) << OptionalHeader.MajorImageVersion << std::endl;
std::cout << "用户自定义次版本号:0x" << std::hex << std::setfill('0') 
    << std::setw(8) << OptionalHeader.MinorImageVersion << std::endl;
std::cout << "所需系统主版本号:0x" << std::hex << std::setfill('0') 
    << std::setw(8) << OptionalHeader.MajorSubsystemVersion << std::endl;
std::cout << "所需系统次版本号:0x" << std::hex << std::setfill('0') 
    << std::setw(8) << OptionalHeader.MinorSubsystemVersion << std::endl;
// ......
delete[] FileBuffer;
CloseHandle(hFile);

同样,将上述代码放置最后,对扩展头进行解析,因字段太多,没一一列举,运行后如下:

扩展头各字段解析

对于该结构的最后一个字段,它是一个数组,这个数组有 16 个成员,代表的是目录表中的项,每项包含两个结构体成员:

typedef struct _IMAGE_DATA_DIRECTORY
{
    DWORD VirtualAddress;    // 数据块的起始 RVA
    DWORD Size;              // 数据块的长度
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;

其实遍历它也不是很难,代码如下:

std::cout << "目录表" << std::endl;
std::cout << "RVA\t\t大小" << 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[i].VirtualAddress << "\t";
    std::cout << "0x" << std::hex << std::setfill('0') <<
        std::setw(8) << OptionalHeader.DataDirectory[i].Size << std::endl;
}

运行后如下:

目录表遍历

将上述与 LoadPE 对照,看下是否正确,如下:

LoadPE 检测结果

从上面可以看出,已经成功遍历出目录表中每项的 RVA 和大小。关于目录表各项具有什么作用,我会在本系列文章中后面详细讲到,故在此不再作过多讲解。

相关代码下载:

手工打造 PE 解析器之 DOS 头和 NT 头
价格:免费
  • 终身VIP购买价格 : 免费
已售1
最近更新2020年09月06日
0 条回应

必须 注册 为本站用户, 登录 后才可以发表评论!