【第33节】windows原理:初探PE文件

目录

一、PE文件概述

二、DOS头部

三、DOS头部与NT头部之间

四、NT头部

五、文件头区段

六、了解个别概念

七、扩展头

八、区段头表


一、PE文件概述

        PE文件是有特定格式的文件,像后缀名是EXE的可执行文件、后缀名是DLL的动态链接库文件、sys格式的驱动文件,这些都属于PE格式文件。

        PE文件主要分成头和主体两部分,这两部分里面还会再细分。文件头是由几个结构体组成的,包含了文件的一些描述信息;文件主体由多个段构成,里面有文件的可执行代码、执行时要用的数据,还有资源(像Windows程序里的图标、一些界面等)。一般来说,不用于执行的就是数据,用于执行的就是代码,所以主体大概能分成代码和数据两部分,实际上按照不同作用,还能再细分成多个部分。

PE文件是按下面的结构顺序组成的:
1. DOS头部:是为了和DOS程序兼容才设置的。
2. NT头部:存着PE文件的所有属性、初始化信息等内容。
3. 区段头表:对PE文件主体属性进行分段描述,数量不固定。
4. 各个区段:是PE文件的主体,分段存着可执行代码、各种数据和资源等。
5. 一些调试信息:先不详细说。

        接下来会逐个介绍这些部分,总结每个部分的关键内容,把这些要点记住,基本上就能了解PE文件的结构了。同时,会用LoadPE做例子来讲怎么使用,为后面写PE解析工具做准备。

二、DOS头部

typedef struct IMAGE_DOS_HEADER {
    WORD   e_magic;
    WORD   e_cblp;
    WORD   e_cp;
    WORD   e_crlc;
    WORD   e_cparhdr;
    WORD   e_minalloc;
    WORD   e_maxalloc;
    WORD   e_ss;
    WORD   e_sp;
    WORD   e_csum;
    WORD   e_ip;
    WORD   e_cs;
    WORD   e_lfarlc;
    WORD   e_ovno;
    WORD   e_res[4];
    WORD   e_oemid;
    WORD   e_oeminfo;
    WORD   e_res2[10];
    LONG   e_lfanew;
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;

说明
1. 这个结构体从文件的第一个字节开始。
2. 真正有用的数据成员是第一个和最后一个,也就是 `e_magic` 和 `e_lfanew`。
3. `e_magic` 是魔数,它是DOS头的标志位,其值始终为4D5A,在系统里用宏定义成 `IMAGE_DOS_SIGNATURE`,用来代表DOS标志。
4. `e_lfanew` 指的是新EXE文件的偏移量,也就是NT头部在文件中的偏移位置。
5. 其他的数据成员大多没什么用,里面的内容很多都是0。

使用
1. 可以利用 `e_magic` 来判断一个文件是不是PE文件。要是从文件开头用 `PIMAGE_DOS_HEADER` 进行解析,`e_magic` 成员的值不是 `IMAGE_DOS_SIGNATURE`,那就说明这个文件不是PE文件。示例代码如下:

if(((PIMAGE_DOS_HEADER)pFile)->e_magic != IMAGE_DOS_SIGNATURE){
    //不是DOS头,返回
    return;
}

2. `e_lfanew` 用来计算偏移量,从而找到NT头在文件中的位置。假设文件已经被读入内存,内存首地址是 `pFile`(`void` 类型的指针),通过 `(long)pFile+((PIMAGE_DOS_HEADER)pFile)->e_lfanew` 就能得到NT头的位置。
3. 在加载器里,这部分内容能让加载器找到NT头,在DOS环境下还能提示程序的运行环境是Windows。

三、DOS头部与NT头部之间

        在DOS头部和NT头部之间有一块区域,这里存储着一些会被DOS头用到的数据,比如提示字符串等。这部分区域的大小不固定,NT头的具体位置由DOS头的最后一个成员 `e_lfanew` 来确定。

使用
        DOS头和这部分空间的作用比较小。可以把PE头放到这个区域,甚至让DOS头和PE头重合,以此来实现一些特殊的用途,比如缩小PE文件的体积。

四、NT头部

typedef struct _IMAGE_NT_HEADERS {
    DWORD Signature;
    IMAGE_FILE_HEADER FileHeader;
    IMAGE_OPTIONAL_HEADER32 OptionalHeader;
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;

说明
1. NT头由一个简单标记、一个不算复杂的文件头和一个相对复杂的扩展头构成。
2. 如果是PE文件,这个标记的值一直是0x00004550,对应的ASCII码是 `PE00`,在系统里用宏定义成 `IMAGE_NT_SIGNATURE`。
3. 另外两个成员是结构体,里面存的信息很有用,对这两个结构体进行解析才是真正开始解析PE文件。

使用
1. `Signature` 的作用和DOS头里的 `e_magic` 差不多,都是用来判断文件是不是PE文件的。示例代码如下:

DWORD dwNewPos =(DWORD)pFile+((PIMAGE_DOS_HEADER)pFile)->e_lfanew;
PIMAGE_NT_HEADERS32 pNTHeader =(PIMAGE_NT_HEADERS32)(dwNewPos);
if (pNTHeader->Signature != IMAGE_NT_SIGNATURE){
    //不是NT头,说明不是PE文件,返回
    return;
}

2. 因为另外两个成员是结构体,而且比较复杂,所以可以用指针指向这些数据成员,比如 `pFileHeader = &(pNTHeader->FileHeader);` 和 `pOptionalHeader = &(pNTHeader->OptionalHeader);`,然后通过指针来读取信息。

五、文件头区段

NT头的第二个成员是文件头结构体,存储着关于PE文件的一些信息。

typedef struct IMAGE_FILE_HEADER {
    WORD   Machine;
    WORD   NumberOfSections;
    DWORD  TimeDateStamp;
    DWORD  PointerToSymbolTable;
    DWORD  NumberOfSymbols;
    WORD   SizeOfOptionalHeader;
    WORD   Characteristics;
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;

说明:
1. Machine:表示文件可运行的CPU平台,如0x014c代表i386(Intel 32位平台),0x0200代表Intel 64位平台,更多信息可查阅相关书籍。
2. NumberOfSections:区段的个数,即PE文件主体被分成的部分,一般有代码、只读数据、数据、重定位等区段。
3. TimeDateStamp:文件创建时间,是32位数值,可使用 `struct tm*gmtime(const time_t*timer);` 函数解析,将 `TimeDateStamp` 地址强制转换后作为参数,用 `tm` 结构体接收得到具体时间。
4. PointerToSymbolTable:一般没用,多为0,后面的符号个数同样用处不大。
5. SizeOfOptionalHeader:扩展头的大小,32位程序中一般是00E0,64位程序中一般是00F0。
6. Characteristics:PE文件属性值很重要,如DLL一般是0x0210,EXE一般是0x010F,不同属性值对应不同含义,可转换为二进制对照相关表查看。

 使用:
1. 文件头7个数据成员中,5个有用。


2. 有用的数据成员在PE解析工具中主要用于显示,如LoadPE软件显示了其中4个信息。

示例代码如下:

PIMAGE_NT_HEADERS32    pNTHeader =(PIMAGE_NT_HEADERS32)((long)pFile + ((PIMAGE_DOS_HEADER)pFile)->e_lfanew);
PIMAGE_FILE_HEADER        pFileHeader =&(pNTHeader->FileHeader);

`pFileHeader` 所指数据都可显示。


3. 时间转换代码:

tm*FileTime = gmtime((time_t*)&pFileHeader->TimeDateStamp);

`FileTime` 包含时间的所有信息,`tm` 结构体定义如下:

struct tm{
    int tm_sec;  /*seconds after the minute -[0,59]秒*/
    int tm_min;/*minutes after the hour -[0,59]分*/
    int tm_hour;/*hours since midnight -[0,23]  小时*/
    int tm_mday;/*day of the month -[1,31]      日*/
    int tm_mon;/*months since January -[0,11]   月*/
    int tm_year;/*years since 1900              年*/
    int tm_wday;/*days since Sunday -[0,6]      星期几*/
    int tm_yday;/*days since January 1-[0,365]这一年的第几天*/
    int tm_isdst;/*daylight savings time flag*/
};

需注意月从0开始,日从1开始,LoadPE中的时间标志功能可用此实现。

六、了解个别概念

(1)虚拟地址与相对虚拟地址:每个程序都配有4GB的虚拟内存地址。当加载PE文件时,不是直接把文件原样复制到内存里,而是要经过扩充或调整。PE文件无法提供各部分在内存中的准确加载位置,也就是虚拟地址(VA),它给出的是相对于自身起始加载位置的偏移量,即相对虚拟地址(RVA)。虚拟地址的计算公式为VA = 加载基址(ImageBase) + RVA。PE文件有默认的加载基址,这个基址由扩展头的ImageBase成员确定。要是默认加载基址被占用,文件就会被加载到其他地址。
(2)文件偏移:文件偏移指的是文件在磁盘这类存储设备里,各部分相对于文件开头的偏移量,它代表加载前PE文件的位置。在扩展头中,描述PE文件结构位置用的是RVA。当把目标文件读入内存后,需要将RVA转换为文件偏移,具体转换方法会在介绍区段表时详细讲解。
(3)对齐的概念:区块无论是在内存还是磁盘中存放,都需要进行对齐操作,不过内存和磁盘的对齐值不一样。
    - 磁盘区块对齐:PE文件头中的`FileAligment`确定了磁盘区块的对齐值。每个区块都从对齐值倍数的偏移位置开始存放,要是区块实际大小不足对齐值的倍数,多余部分会用00h填充,这部分填充区域就形成了区块间隙。举例来说,如果对齐值是200h,第一个区块起始于400h,长度为90h,那么490h到600h就会被00h填充,下一个区块则从600h开始。
    - 内存区块对齐:PE文件头的`SectionAligment`规定了内存中区块的对齐值。当PE文件映射到内存时,区块至少要从一个页边界开始。在X86系列CPU中,一页的大小是4KB(即1000h);在IA - 64架构中,一页大小为8KB(2000h)。在X86系统里,PE文件区块的内存对齐值通常为1000h,每个区块都按1000h的倍数在内存中存放。

七、扩展头

typedef struct _IMAGE_OPTIONAL_HEADER {
    //Standard fields.
    WORD   Magic;
    BYTE   MajorLinkerVersion;
    BYTE   MinorLinkerVersion;
    DWORD  SizeOfCode;
    DWORD  SizeOfInitializedData;
    DWORD  SizeOfUninitializedData;
    DWORD  AddressOfEntryPoint;
    DWORD  BaseOfCode;
    DWORD  BaseOfData;
    //NT additional fields.
    DWORD  ImageBase;
    DWORD  SectionAlignment;
    DWORD  FileAlignment;
    WORD   MajorOperatingSystemVersion;
    WORD   MinorOperatingSystemVersion;
    WORD   MajorImageVersion;
    WORD   MinorImageVersion;
    WORD   MajorSubsystemVersion;
    WORD   MinorSubsystemVersion;
    DWORD  Win32VersionValue;
    DWORD  SizeOfImage;
    DWORD  SizeOfHeaders;
    DWORD  CheckSum;
    WORD   Subsystem;
    WORD   DllCharacteristics;
    DWORD  SizeOfStackReserve;
    DWORD  SizeOfStackCommit;
    DWORD  SizeOfHeapReserve;
    DWORD  SizeOfHeapCommit;
    DWORD  LoaderFlags;
    DWORD  NumberOfRvaAndSizes;
    IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];
} IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32;

说明:
1. 共31个成员,重要的有6个,没用的有11个,还有一个极度重要的数据目录。成员多为记忆性内容,在PE解析器中有选择地显示,重点是数据目录。
2. 扩展头是NT头部的第三部分,紧随文件头结构之后,存储加载文件时的初始化信息,扩展头大小一般为E⁰ 。扩展头也叫可选头,但不是真正可选,而是必须有。
3. `IMAGE_NUMBEROF_DIRECTORY_ENTRIES` 是宏定义,值为0x10,表示一般有16个数据目录。数据目录定义如下:

typedef struct _IMAGE_DATA_DIRECTORY {
    DWORD VirtualAddress;
    DWORD Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;

        数据目录表指示数据存储的相对虚拟地址(RVA)和大小,帮助找到数据,数据真身都在PE文件的区段中。具体每个数据目录的解析涉及相对虚拟地址到文件偏移的转换,后续介绍。先列出数据目录名字与介绍:
    - `IMAGE_DIRECTORY_ENTRY_EXPORT`:索引导出表(`IMAGE_EXPORT_DIRECTORY` 结构)。
    - `IMAGE_DIRECTORY_ENTRY_IMPORT`:索引导入表(`IMAGE_IMPORT_DESCRIPTOR` 结构数组)。
    - `IMAGE_DIRECTORY_ENTRY_RESOURCE`:索引资源(`IMAGE_RESOURCE_DIRECTORY` 结构)。
    - `IMAGE_DIRECTORY_ENTRY_EXCEPTION`:索引异常处理程序表(`IMAGE_RUNTIME_FUNCTION_ENTRY` 结构数组)。
    - `IMAGE_DIRECTORY_ENTRY_SECURITY`:索引安全结构,不加载入内存,地址成员是文件偏移,不是相对虚拟地址。
    - `IMAGE_DIRECTORY_ENTRY_BASERELOC`:索引基址重定位信息。
    - `IMAGE_DIRECTORY_ENTRY_DEBUG`:索引调试信息。
    - `IMAGE_DIRECTORY_ENTRY_ARCHITECTURE`:版权。
    - `IMAGE_DIRECTORY_ENTRY_GLOBALPTR`:全局指针目录,用在64位平台。
    - `IMAGE_DIRECTORY_ENTRY_TLS`:指向线程局部存储(`Thread Local Storage`)初始化节。
    - `IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG`:载入配置。
    - `IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT`:绑定输入目录。
    - `IMAGE_DIRECTORY_ENTRY_IAT`:导入地址表。
    - `IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT`:延迟载入描述。
    - `IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR`:COM信息。
    - 还有一个全零的保留目录,一般数组长度为16,文件可拥有更多数据目录,此时扩展头大小不是E0。

使用:
1. 显示扩展头信息可自行决定,LoadPE软件显示了其中12个。示例代码如下:

DWORD dwNewPos =(DWORD)pFile+((PIMAGE_DOS_HEADER)pFile)->e_lfanew;
PIMAGE_NT_HEADERS32    pNTHeader =(PIMAGE_NT_HEADERS32)(dwNewPos);
PIMAGE_OPTIONAL_HEADER32 pOptionalHeader =&(pNTHeader->OptionalHeader);

用 `pOptionalHeader` 指针指向要显示的信息即可。
2. 读取数据目录表信息的伪代码如下:

PIMAGE_FILE_HEADER      pFileHeader =&(pNTHeader->FileHeader);
PIMAGE_OPTIONAL_HEADER32 pOptionalHeader =&(pNTHeader->OptionalHeader);
PIMAGE_DATA_DIRECTORY    pDataDirectory = pOptionalHeader->DataDirectory;
DWORD    i=0;
while(i !=0x10) {
    //显示pDataDirectory[i].VirtualAddress;
    //显示pDataDirectory[i].Size;
}

        LoadPE的数据目录信息界面可显示数据目录信息,点击基本界面中的目录即可,显示顺序与上述表格一致。数据目录具体解析后续详细介绍,至此NT头内容结束,接下来是区段头表。

八、区段头表

一般称这部分为区段表,叫区段头表可能更合适。回顾PE文件结构:
1. DOS头
2. DOS头用的数据
3. NT头(包括文件头与扩展头)
4. 区段(节)头表
5. 各个区段(节)

        区段无需直接解析,区段头表是直接探索的最后位置。区段头表存储PE文件主体的一些属性,由若干个结构体依次排列组成(即结构体数组),每个结构体代表PE文件主体中一段数据的属性,每个区段头对应PE文件主体的一段数据(区段或节),区段头规定了区段(节)的属性。

typedef struct _IMAGE_SECTION_HEADER {
    BYTE Name[IMAGE_SIZEOF_SHORT_NAME];
    union {
        DWORD PhysicalAddress;
        DWORD VirtualSize;
    } Misc;
    DWORD VirtualAddress;
    DWORD SizeOfRawData;
    DWORD PointerToRawData;
    DWORD PointerToRelocations;
    DWORD PointerToLinenumbers;
    WORD NumberOfRelocations;
    WORD NumberOfLinenumbers;
    DWORD Characteristics;
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;

说明
1. 区段头表是由多个这样的结构体组成的,最后以一个全为 0 的结构体结束。
2. 区段名字的规则:
    - `.text 段`:通常是代码段,很重要。
    - `.data 段`:一般是数据段。
    - `.bss 段`:代表未初始化的数据,像 static 变量,可能在进入函数时才会被初始化。
    - `.rdata 段`:表示只读的数据,例如字符串。
    - `.textbss 段`:和代码有关,但具体作用不太清楚。
    - `.idata 和.edata`:存放导入表和导出表的信息。
    - `.rsrc 段`:存储资源的区段。
    - `.reloc 段`:存储重定位信息的区段。
3.VirtualSize:这个区段在虚拟内存里会用到的总大小,没有经过对齐处理。要注意这是个联合体。
4. VirtualAddress:该区段起始的相对虚拟地址,也就是说这个区段加载时,是以 PE 文件加载基地址加上这个数据成员的值为起始点的。
5. SizeOfRawData:这个区段在磁盘文件里的大小,这个值经过了文件对齐处理。
6. PointerToRawData:区段的文件偏移,也就是这个区段在磁盘文件中的起始位置,之前已经讲过文件偏移的概念。
7. Characteristics:这一区段的属性,属性的具体值可以参考《黑客免杀攻防》121 页。
8. 这个结构体一共有 10 个成员,其中 4 个没什么用,有用的是 6 个。这个结构体的大小是 40 字节,也就是 0x28。

使用方式
1. 虽然区段头表就在 NT 头的后面,但是找它还是有点麻烦。系统提供了一个宏可以方便地找到它的位置,即 `IMAGE_FIRST_SECTION(pNTHeader)`,参数是 NT 头的指针。


2. 用于相对虚拟地址(RVA)和文件偏移(Offset)的转换,这是解析数据目录表的基础。
在之前提到的那些结构里,有很多相对虚拟地址(RVA),实际上这些地址都在 PE 主体的某个区段中。但当我们想在文件里找到对应的位置时,不能直接用相对虚拟地址(RVA),之前说过需要进行转换。这里给出转换的方法:
要转换的相对虚拟地址肯定会落在某个区段中,这时我们要看看它落在了哪个区段,就要分别和各个区段起始的相对虚拟地址(RVA)作比较(也就是和 `VirtualAddress` 比较)。如果落在了某个区段中,就用要转换的相对虚拟地址减去这个区段起始的相对虚拟地址,得到这个地址相对于该区段的偏移。然后用这个偏移加上区段在文件中的起始位置,也就是 `PointerToRawData` 成员的值,就得到了要转换的相对虚拟地址在磁盘文件中的位置,也就是文件偏移。
用公式表示就是:`Offset(转换) = RVA(转换) - RVA(区段) + Offset(区段)`
这里给出一个转换函数,这个函数就是上面这段话的代码形式。同样假设已经把目标文件读入内存,首地址是 `pFile`,是 `void` 类型的指针,可以把 `pFile` 看作一个全局变量,实际上在编写工具时,它是 PE 处理类里的一个数据成员。

DWORD CalcOffset(DWORD Rva) {
    //1获取NT头
    PIMAGE_NT_HEADERS32 pNTHeader =(PIMAGE_NT_HEADERS32)((long)pFile + ((PIMAGE_DOS_HEADER)pFile)->e_lfanew);
    //2获取区段头表
    PIMAGE_SECTION_HEADER pSectionHeader = IMAGE_FIRST_SECTION(pNTHeader);
    //3循环比较它在哪一个区段中,不在这个区段就继续循环,注意假如VirtualAddress比SizeOfRawData大的话,说明大出来的部分是未初始化的数据,所以这里用要用SizeOfRawData
    while(!(Rva >= pSectionHeader->VirtualAddress && Rva < pSectionHeader->VirtualAddress + pSectionHeader->SizeOfRawData)) {
        ++pSectionHeader;
        //防止错误的PE文件引发崩溃。
        if(pSectionHeader->PointerToRawData == 0)
            return 0;
    }
    return Rva - pSectionHeader->VirtualAddress + pSectionHeader->PointerToRawData;
}

        LoadPE中有一个功能(位置计算器),用这个函数就可以实现。`VA`是用镜像基址+`RVA`。偏移量,可以用上面的方法算出,附加信息也可以在之前那个函数中改动一下,可以一起查找到。


3. 区段信息的循环获取,其实和上面的代码非常像,只是在循环体中输出数据即可。

void ViewSectionInfo() {
    //1获取NT头
    PIMAGE_NT_HEADERS32 pNTHeader =(PIMAGE_NT_HEADERS32)((long)pFile + ((PIMAGE_DOS_HEADER)pFile)->e_lfanew);
    //2 获取区段头表
    PIMAGE_SECTION_HEADER pSectionHeader = IMAGE_FIRST_SECTION(pNTHeader);
    //3循环输出,区段表的信息,可以用两种方式判断结束,一个是文件头给出了区段的数量,可以用那个,也可以判断最后一个全零的区段头
    while (pSectionHeader->PointerToRawData) {
        //输出或者获取每一个pSectionHeader中的成员
        ++pSectionHeader;
    }
}

        区段表上显示的名称对应叫区段名称,voffset 对应叫起始相对虚拟地址,VSize对应叫加载后的区段大小,RDffset对应叫文件偏移,RSize对应叫文件中的区段大小,特征对应是区段的标志属性。

        上面介绍的就是PE文件所有头部信息,接下来讲讲PE文件的主体部分。当PE文件主体加载到内存时,会依据区段表,一块一块地进行加载。多数情况下,我们无需直接处理区段,因为PE文件的关键信息都包含在文件头里。借助文件头的引导,我们就能对主体中的数据展开解析。像之前未深入分析的数据目录表,后续将重点解析导出表、导入表、资源表以及重定位表等数据。