本节书摘来自异步社区出版社《C++ 黑客编程揭秘与防范(第2版)》一书中的第6章,第6.2节,作者:冀云,更多章节内容可以访问云栖社区“异步社区”公众号查看。
6.2 详解PE文件结构
C++ 黑客编程揭秘与防范(第2版)
PSDK的头文件Winnt.h包含了PE文件结构的定义格式。PE头文件分为32位和64位版本。64位的PE结构是对32位的PE结构做了扩展,这里主要讨论32位的PE文件结构。对于64位的PE文件结构,读者可以自行查阅资料进行学习。
6.2.1 DOS头部详解IMAGE_DOS_HEADER
对于一个PE文件来说,最开始的位置就是一个DOS程序。DOS程序包含了一个DOS头部和一个DOS程序体。DOS头部是用来装载DOS程序的,DOS程序也就是如图6-1中的那个DOS存根。也就是说,DOS头是用来装载DOS存根用的。保留这部分内容是为了与DOS系统相兼容。当Win32程序在DOS下被执行时,DOS存根程序会有礼貌地输出“This program cannot be run in DOS mode.”字样对用户进行提示。
虽然DOS头部是为了装载DOS程序的,但是DOS头部中的一个字段保存着指向PE头部的位置。DOS头在Winnt.h头文件中被定义为IMAGE_DOS_HEADER,其定义如下:
typedef struct _IMAGE_DOS_HEADER { // DOS .EXE header
WORD e_magic; // Magic number
WORD e_cblp; // Bytes on last page of file
WORD e_cp; // Pages in file
WORD e_crlc; // Relocations
WORD e_cparhdr; // Size of header in paragraphs
WORD e_minalloc; // Minimum extra paragraphs needed
WORD e_maxalloc; // Maximum extra paragraphs needed
WORD e_ss; // Initial (relative) SS value
WORD e_sp; // Initial SP value
WORD e_csum; // Checksum
WORD e_ip; // Initial IP value
WORD e_cs; // Initial (relative) CS value
WORD e_lfarlc; // File address of relocation table
WORD e_ovno; // Overlay number
WORD e_res[4]; // Reserved words
WORD e_oemid; // OEM identifier (for e_oeminfo)
WORD e_oeminfo; // OEM information; e_oemid specific
WORD e_res2[10]; // Reserved words
LONG e_lfanew; // File address of new exe header
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;```
该结构体中需要掌握的字段只有2个,分别是第一个字段e_magic和最后一个字段e_lfanew字段。
e_magic字段是一个DOS可执行文件的标识符,占用2字节。该位置保存着的字符是“MZ”。该标识符在Winnt.h头文件中有一个宏定义,如下:
`#define IMAGE_DOS_SIGNATURE 0x5A4D // MZ`
e_lfanew字段中保存着PE头的起始位置。
在VC下创建一个简单的“Win32 Application”程序,然后生成一个可执行文件,用于学习和分析PE文件结构的组织。
程序代码如下:
include
int WINAPI WinMain(IN HINSTANCE hInstance,
IN HINSTANCE hPrevInstance,
IN LPSTR lpCmdLine,
IN int nShowCmd)
{
MessageBox(NULL, "hello world!", "hello", MB_OK);
return 0;
}`
该程序的功能只是弹出一个MessageBox对话框。为了减小程序的体积,使用“Win32 Release”方式进行编译连接,并把编译好的程序用C32Asm打开。C32Asm是一个反汇编与十六进制编辑于一体的程序,其界面如图6-2所示。
图6-2 C32Asm程序界面
在图6-2上选择“十六进制模式”单选按钮,单击“确定”按钮,程序就被C32Asm程序以十六进制的模式打开了,如图6-3所示。
图6-3 十六进制编辑状态下的C32Asm
在图6-3中可以看到,在文件偏移为0x00000000的位置处保存着2字节的内容0x5A 4D,用ASCII码表示则是“MZ”。图6-3中的前两个字节明明写着“4D 5A”,为什么说的是0x5A4D呢?到上面看Winnt.h头文件中定义的那个宏,也写着0x5A4D,这是为什么呢?如果读者还记得前面章节中介绍的字节顺序的内容,那么就应该明白为什么这么写了。这里使用的系统是小尾方式存储,即高位保存高字节,低位保存低字节。这个概念是很重要的,希望读者不要忘记。
注意:
在这里,如果以ASCII码的形式去考察e_magic字段的话,那么值的确是“4D 5A”两个字节,但是为什么宏定义是“0x5A4D”呢?因为IMAGE_DOS_HEADER对于e_magic的定义是一个WORD类型。定义成WORD类型,在代码中进行比较时可以直接使用数值比较;而如果定义成CHAR型,那么比较时就相对不是太方便了。
在图6-3中0x0000003C的位置处,就是IMAGE_DOS_HEADER的e_lfanew字段,该字段保存着PE头部的起始位置。PE头部的地址是多少呢?是0xC8000000吗?如果是,就错了,原因还是字节序的问题。因此,e_lfanew的值为0x000000C8。在文件偏移为0x000000C8处保存着“50 45 00 00”,与之对应的ASCII字符为“PE00”。这里就是PE头部开始的位置。
“PE00”和IMAGE_DOS_HEADER之间的内容是DOS存根,就是一个没什么太大用处的DOS程序。由于这个程序本身没有什么利用的价值,因此这里就不对这个DOS程序做介绍了。在免杀技术、PE文件大小优化等技术中会对该部分进行处理,可以将该部分直接删除,然后将PE头部整体向前移动,也可以将一些配置数据保存在此处等。选中DOS存根程序,也就是从0x00000040处一直到0x000000C7处的内容,然后单击右键选择“填充”命令,在弹出的“填充数据”对话框中,选中“使用16进制填充”单选按钮,在其后的编辑框中输入“00”,单击“确定”按钮,该过程如图6-4和图6-5所示。
图6-4 填充数据
图6-5 填充后的数据
把DOS存根部分填充完毕以后,单击工具栏上的“保存”按钮对修改后的内容进行保存。保存时会提示“是否进行备份”,选择“是”,这样修改后的文件就被保存了。找到文件然后运行,程序中的MessageBox对话框依旧弹出,说明这里的内容的确无关紧要了。DOS存根部分经常由于各种需要而保存其他数据,因此这种填充操作较为常见。具体填充什么数据,请读者在今后的学习中自行发挥想象。
6.2.2 PE头部详解IMAGE_NT_HEADERS
DOS头是为了兼容DOS系统而遗留的,DOS头中的最后一个字节给出了PE头的位置。PE头部是真正用来装载Win32程序的头部,PE头的定义为IMAGE_NT_HEADERS,该结构体包含PE标识符、文件头IMAGE_FILE_HEADER和可选头IMAGE_OPTIONAL_HEADER 3部分。IMAGE_NT_HEADERS是一个宏,其定义如下:
#ifdef _WIN64
typedef IMAGE_NT_HEADERS64 IMAGE_NT_HEADERS;
typedef PIMAGE_NT_HEADERS64 PIMAGE_NT_HEADERS;
#define IMAGE_FIRST_SECTION(ntheader) IMAGE_FIRST_SECTION64(ntheader)
#else
typedef IMAGE_NT_HEADERS32 IMAGE_NT_HEADERS;
typedef PIMAGE_NT_HEADERS32 PIMAGE_NT_HEADERS;
#define IMAGE_FIRST_SECTION(ntheader) IMAGE_FIRST_SECTION32(ntheader)
#endif```
该头分为32位和64位两个版本,其定义依赖于是否定义了_WIN64。这里只讨论32位的PE文件格式,来看一下IMAGE_NT_HEADERS32的定义,如下:
typedef struct _IMAGE_NT_HEADERS {
DWORD Signature;
IMAGE_FILE_HEADER FileHeader;
IMAGE_OPTIONAL_HEADER32 OptionalHeader;
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;`
该结构体中的Signature就是PE标识符,标识该文件是否是PE文件。该部分占4字节,即“50 45 00 00”。该部分可以参考图6-3。Signature在Winnt.h中有一个宏定义如下:
`
define IMAGE_NT_SIGNATURE 0x00004550 // PE00`
该值非常重要。如果要简单地判断一个文件是否是PE文件,首先要判断DOS头部的开始字节是否是“MZ”。如果是“MZ”头部,则通过DOS头部找到PE头部,接着判断PE头部的前四个字节是否为“PE00”。如果是的话,则说明该文件是一个有效的PE文件。
在PE头中,除了IMAGE_NT_SIGNATURE以外,还有两个重要的结构体,分别是IMAGE _FILE_HEADER(文件头)和IMAGE_OPTIONAL_HEADER(可选头)。这两个头在PE头部中占据重要的位置,因此需要详细介绍这两个结构体。
6.2.3 文件头部详解IMAGE_FILE_HEADER
文件头结构体IMAGE_FILE_HEADER是IMAGE_NT_HEADERS结构体中的一个结构体,紧接在PE标识符的后面。IMAGE_FILE_HEADER结构体的大小为20字节,起始位置为0x000000CC,结束位置在0x000000DF,如图6-6所示。
图6-6 IMAGE_FILE_HEADER在PE文件中的位置
IMAGE_FILE_HEADER的起始位置取决于PE头部的起始位置,PE头部的位置取决于IMAGE_DOS_HEADER中e_lfanew的位置。除了IMAGE_DOS_HEADER的起始位置外,其他头部的位置都依赖于PE头部的起始位置。
IMAEG_FILE_HEADER结构体包含了PE文件的一些基础信息,其结构体的定义如下:
//
// File header format.
//
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;
#define IMAGE_SIZEOF_FILE_HEADER 20```
下面介绍该结构的各字段。
Machine:该字段是WORD类型,占用2字节。该字段表示可执行文件的目标CPU类型。该字段的取值如图6-7所示。
![image](https://yqfile.alicdn.com/092899b45b5a35ace85ce4535c372572fcd98d6a.png)
图6-7 CPU类型取值
在图6-6中,Machine字段的值为“4C 01”,即0x014C,也就是支持Intel类型的CPU。
NumberOfSections:该字段是WORD类型,占用两个字节。该字段表示PE文件的节区的个数。在图6-6中,该字段的值为“03 00”,即为0x0003,也就是说明该PE文件的节区有3个。
TimeDataStamp:该字段表明文件是何时被创建的,这个值是自1970年1月1日以来用格林威治时间计算的秒数。
PointerToSymbolTable:该字段很少被使用,这里不做介绍。
NumberOfSymbols:该字段很少被使用,这里不做介绍。
SizeOfOptionalHeader:该字段为WORD类型,占用两个字节。该字段指定IMAGEOPTION AL HEADER结构的大小。在图6-6中,该字段的值为“E0 00”,即0x00E0,也就是说IMAGE_ OPTIONAL_HEADER的大小为0x00E0。注意,在计算IMAGE_OPTIONAL_HEADER的大小时,应该从IMAGE_FILE_HEADER结构中的SizeOfOptionalHeader字段指定的值来获取,而不应该直接使用sizeof(IMAGE_OPTIONAL_HEADER)来计算。由该字段可以看出,IMAGE_OPTIONAL _HEADER结构体的大小可能是会改变的。
Characteristics:该字段为WORD类型,占用2字节。该字段指定文件的类型,其取值如图6-8所示。
![image](https://yqfile.alicdn.com/def09448ea767193aa2538d988955395aa1fe73c.png)
图6-8 文件类型的取值
从图6-6中可知,该字段的的值为“0F 01”,即“0x010F”。该值表示该文件运行的目标平台为32位平台,是一个可执行文件,且不存在重定位信息,行号信息和符号信息已从文件中移除。
###6.2.4 可选头详解IMAGE_OPTIONAL_HEADER
IMAGE_OPTINAL_HEADER在几乎所有的参考书中都被称作“可选头”。虽然被称作可选头,但是该头部不是一个可选的,而是一个必须存在的头,不可以没有。该头被称作“可选头”的原因是在该头的数据目录数组中,有的数据目录项是可有可无的,数据目录项部分是可选的,因此称为“可选头”。而笔者觉得如果称之为“选项头”会更好一点。不管程序如何,只要读者能够知道该头是必须存在的,且数据目录项部分是可选的,就可以了。
可选头紧挨着文件头,文件头的结束位置在0x000000DF,那么可选头的起始位置为0x000000E0。可选头的大小在文件头中已经给出,其大小为0x00E0字节(十进制为224字节),其结束位置为0x000000E0 + 0x00E0 – 1 = 0x000001BF,如图6-9所示。
![image](https://yqfile.alicdn.com/98272859a9d554b761843ecc8fee75ad91ad8ddd.png)
图6-9 可选头的内容
可选头的定位有一定的技巧性,起始位置的定位相对比较容易找到,按照PE标识开始寻找是非常简单的。可选头结束位置其实也非常容易找到。通常情况下(注意这里是指通常情况下,不是手工构造的PE文件),可选头的结尾后面跟的是第一项节表的名称。观察图6-9,文件偏移0x000001C0处的节名称为“.text”,也就是说,可选头的结束位置在0x000001C0偏移的前一字节,即0x000001BF处。
可选头是对文件头的一个补充。文件头主要描述文件的相关信息,而可选头主要用来管理PE文件被操作系统装载时所需要的信息。该头同样有32位版本与64位版本之分。IMAGE_OPTIONAL_HEADER是一个宏,其定义如下:
ifdef _WIN64
typedef IMAGE_OPTIONAL_HEADER64 IMAGE_OPTIONAL_HEADER;
typedef PIMAGE_OPTIONAL_HEADER64 PIMAGE_OPTIONAL_HEADER;
define IMAGE_SIZEOF_NT_OPTIONAL_HEADER IMAGE_SIZEOF_NT_OPTIONAL64_HEADER
define IMAGE_NT_OPTIONAL_HDR_MAGIC IMAGE_NT_OPTIONAL_HDR64_MAGIC
else
typedef IMAGE_OPTIONAL_HEADER32 IMAGE_OPTIONAL_HEADER;
typedef PIMAGE_OPTIONAL_HEADER32 PIMAGE_OPTIONAL_HEADER;
define IMAGE_SIZEOF_NT_OPTIONAL_HEADER IMAGE_SIZEOF_NT_OPTIONAL32_HEADER
define IMAGE_NT_OPTIONAL_HDR_MAGIC IMAGE_NT_OPTIONAL_HDR32_MAGIC
endif`
32位版本和64位版本的选择是根据是否定义了_WIN64而决定的,这里只讨论其32位的版本。IMAGE_OPTIONAL_HEADER32的定义如下:
//
// Optional header format.
//
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;```
该结构体的成员变量非常多,为了能够更好地掌握该结构体,这里对结构体的成员变量一一进行介绍。
Magic:该成员变量指定了文件的状态类型,状态类型部分取值如图6-10所示。
![image](https://yqfile.alicdn.com/8dc8fde227e9b492ceb76c948f0f22b985713f5b.png)
图6-10 Magic变量取值
MajorLinkerVersion:主连接版本号。
MinorLinkerVersion:次连接版本号。
SizeOfCode:代码节的大小。如果有多个代码节的话,该值是所有代码节大小的总和(通常只有一个代码节),该处是指所有包含可执行属性的节的大小。
SizeOfInitializedData:已初始化数据块的大小。
SizeOfUninitializedData:未初始化数据块的大小。
AddressOfEntryPoint:程序执行的入口地址。该地址是一个相对虚拟地址,简称EP(EntryPoint),这个值指向了程序第一条要执行的代码。程序如果被加壳后会修改该字段的值。在脱壳的过程中找到了加壳前该字段的值,就说明找到了原始入口点,原始入口点被称为OEP。该字段的地址指向的不是main()函数的地址,也不是WinMain()函数的地址,而是运行库的启动代码的地址。对于DLL来说,这个值的意义不大,因为DLL甚至可以没有DllMain()函数,没有DllMain()只是无法捕获装载和卸载DLL时的4个消息。如果在DLL装载或卸载时没有需要进行处理的事件,可以将DllMain()函数省略掉。
BaseOfCode:代码段的起始相对虚拟地址。
BaseOfData:数据段的起始相对虚拟地址。
ImageBase:文件被装入内存后的首选建议装载地址。对于EXE文件来说,通常情况下,该地址就是装载地址:对于DLL文件来说,可能就不是其装入内存后的地址了。
SectionAlignment:节表被装入内存后的对齐值。节表被映射到内存中需要对其的单位。在Win32下,通常情况下,该值为0x1000,也就是4KB大小。Windows操作系统的内存分页一般为4KB。
FileAlignment:节表在文件中的对齐值。通常情况下,该值为0x1000或0x200。在文件对齐值为0x1000时,由于与内存对齐值相同,可以加快装载速度。而文件对齐值为0x200时,可以占用相对较少的磁盘空间。0x200是512字节,通常磁盘的一个扇区即为512字节。
注:程序无论是在内存中还是磁盘上,都无法恰好满足SectionAlignment和FileAlignment值的倍数,在不足的情况下需要补0值,这样就导致节与节之间存在了无用的空隙。这些空隙对于病毒之类程序而言就有了可利用的价值。
MajorOperatingSystemVersion:要求最低操作系统的主版本号。
MinorOperatingSystemVersion:要求最低操作系统的次版本号。
MajorImageVersion:可执行文件的主版本号。
MinorImageVersion:可执行文件的次版本号。
Win32VersionValue:该成员变量是被保留的。
SizeOfImage:可执行文件装入内存后的总大小。该大小按内存对齐方式对齐。
SizeOfHeaders:整个PE头部的大小。这个PE头部泛指DOS头、PE头、节表的总和大小。
CheckSum:校验和值。对于EXE文件通常为0;对于SYS文件,则必须有一个校验和。
SubSystem:可执行文件的子系统类型。该值如图6-11所示。
![image](https://yqfile.alicdn.com/f678733b2bd2af1738c8c394f07bdc94ab34ae84.png)
图6-11 SubSystem的取值范围
DllCharacteristics:指定DLL文件的属性,该值大部分时候为0。
SizeOfStackReserve:为线程保留的栈大小。
SizeOfStackCommit:为线程已提交的栈大小。
SizeOfHeapReserve:为线程保留的堆大小。
SizeOfHeapCommit:为线程已提交的堆大小。
LoaderFlags:被废弃的成员值。MDSN上的原话为“This member is obsolete”。但是该值在某些情况下还是会被用到的,比如针对原始的低版本的OD来说,修改该值会起到反调试的作用。
NumberOfRvaAndSizes:数据目录项的个数。该个数在PSDK中有一个宏定义,如下:
`#define IMAGE_NUMBEROF_DIRECTORY_ENTRIES 16`
DataDirectory:数据目录表,由NumberOfRvaAndSize个IMAGE_DATA_DIRECTORY结构体组成。该数组包含输入表、输出表、资源、重定位等数据目录项的RVA(相对虚拟地址)和大小。IMAGE_DATA_DIRECTORY结构体的定义如下:
//
// Directory format.
//
typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress;
DWORD Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;`
该结构体的第一个变量为该目录项的相对虚拟地址的起始值,第二个是该目录项的长度。数据目录中的部分成员在数组中的索引如图6-12所示,详细的索引定义请参考Winnt.h头文件。
图6-12 数据目录部分成员在数组中的索引
在数据目录中,并不是所有的目录项都会有值,很多目录项的值都为0。因为很多目录项的值为0,所以说数据目录项是可选的。
可选头的结构体就介绍完了,希望读者按照该结构体中各成员变量的含义自行学习可选头中的十六进制值的含义。只有参考结构体的说明去对照分析PE文件格式中的十六进制值,才能更好、更快地掌握PE结构。
6.2.5 节表详解IMAGE_SECTION_HEADER
节表的位置在IMAGE_OPTIONAL_HEADER的后面,节表中的每个IMAGE_SECTION_HEADER中都存放着可执行文件被映射到内存中所在位置的信息,节的个数由IMAGE_ FILE_HEADER中的NumberOfSections给出。节表数据如图6-13所示。
图6-13 IMAGE_SECTION_HEADER位置的数据内容
由IMAGE_SECTION_HEADER结构体构成的节表起始位置在0x000001C0处,最后一个节表项的结束位置在0x00000237处。IMAGE_SECTION_HEADER的大小为40字节,该文件有3个节表,因此共占用了120字节。
IMAGE_SECTION_HEADER结构体的定义如下:
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;
#define IMAGE_SIZEOF_SECTION_HEADER 40```
这个结构体相对于IMAGE_OPTIONAL_HEADER结构体来说,成员变量少很多。下面介绍IMAGE_SECTION_HEADER结构体的各个成员变量。
Name:该成员变量保存着节表项的名称,节的名称用ASCII编码来保存。节名称的长度为IMAGE_SIZEOF_SHORT_NAME,这是一个宏,其定义如下:
`#define IMAGE_SIZEOF_SHORT_NAME 8`
节名的长度为8字节,多余的字节会被自动截断。通常情况下,节名“.”为开始。当然,这是编译器的习惯,并非强制性的约定。下面来看图6-13中文件偏移0x000001C0处的前8字节的内容“2E 74 65 78 74 00 00 00”,其对应的ASCII字符为“.text”。
VirtualSize:该值为数据实际的节表项大小,不一定是对齐后的值。
VirtualAddress:该值为该节表项载入内存后的相对虚拟地址。这个地址是按内存进行对齐的。
SizeOfRawData:该节表项在磁盘上的大小,该值通常是对齐后的值,但是也有例外。
PointerToRawData:该节表项在磁盘文件上的偏移地址。
Characteristics:节表项的属性,该属性的部分取值如图6-14所示。
![image](https://yqfile.alicdn.com/22875cef36bbad747c44f85c7fbac6d5b0223810.png)
图6-14 节表项属性的部分取值
IMAGE_SECTION_HEADER结构体主要用到的成员变量只有这6个,其余不是必须要了解的,这里不做介绍。关于IMAGE_SECTION_HEADER结构体的介绍就到这里。