PE是Portable Executable的简写,意思是可移植的可执行文件。此文件格式是跨win32平台的,即使Windows运行在非Intel的CPU上,任何win32平台的PE装载器也都能识别和使用该文件格式。PE文件格式可以概括为:
DOS MZ header | DOS stub | PE header | section table | section 1 | ... | section n
DOS依据简单的DOS MZ header来判断执行体是否有效。DOS stub是个有效的实模式残余程序,在不支持PE文件格式的操作系统中,它将简单显示一个错误提示。PE header是PE相关结构IMAGE_NT_HEADERS的简称,包含了许多PE装载器用到的重要域。PE文件的真正内容划分成块,称之为sections(节),每节是一块拥有共同属性的数据。在PE header和sections之间的数组结构是section table(节表),每个表项包含对应节的属性、文件偏移量、虚拟偏移量等。
PE文件加载机制
当PE文件被执行,PE装载器检查DOS MZ header里的PE header偏移量,如果找到,则跳转到PE header。然后PE装载器检查PE header的有效性,若有效,就跳转到PE header的尾部。PE装载器从节表中读取节信息,并采用文件映射方法将这些节映射到内存,同时附上节表里指定的节属性。PE文件映射入内存后,PE装载器将处理PE文件中的逻辑部分。
加载器读取一个PE文件的具体过程如下:
- 读入PE文件的DOS头,PE头和Section头。
- 判断PE头里ImageBase所定义的优先加载地址是否可用,如果已被其他模块占用,则重新分配一块空间。
- 根据Section头部的信息,把文件的各个Section映射到分配的空间,并根据各个Section的属性来修改所映射的页的属性。
- 如果文件被加载的地址不是ImageBase所定义的地址,则修正ImageBase的值。
- 根据PE文件的导入表加载所需要的DLL到进程空间。
- 替换IAT表内的数据为实际调用函数的地址。
- 根据PE头内的数据生成初始化的堆和栈。
- 创建初始化线程,开始运行进程。
DOS头
DOS头部DOS MZ header又命名为IMAGE_DOS_HEADER,在该结构体中,有两个字段比较重要:第一个字段e_magic,占用两个字节,值为0x5A4D,即字符串“MZ”;最后一个字段e_lfanew,值为PE文件头在PE文件中的偏移量。
typedef struct _IMAGE_DOS_HEADER { +000h WORD e_magic; // Magic number +002h WORD e_cblp; // Bytes on last page of file +004h WORD e_cp; // Pages in file +006h WORD e_crlc; // Relocations +008h WORD e_cparhdr; // Size of header in paragraphs +00Ah WORD e_minalloc; // Minimum extra paragraphs needed +00Ch WORD e_maxalloc; // Maximum extra paragraphs needed +00Eh WORD e_ss; // Initial (relative) SS value +010h WORD e_sp; // Initial SP value +012h WORD e_csum; // Checksum +014h WORD e_ip; // Initial IP value +016h WORD e_cs; // Initial (relative) CS value +018h WORD e_lfarlc; // File address of relocation table +01Ah WORD e_ovno; // Overlay number +01Ch WORD e_res[0x4]; // Reserved words +024h WORD e_oemid; // OEM identifier (for e_oeminfo) +026h WORD e_oeminfo; // OEM information; e_oemid specific +028h WORD e_res2[0xA]; //Reserved words +03Ch LONG e_lfanew; // File address of new exe header } IMAGE_DOS-HEADER, *PIMAGE_DOS_HEADER;
PE头
PE Header又称为NT头,正式命名是IMAGE_NT_HEADERS。在该结构体中,Signature值为0x00004550,即字符串”PE\x0\x0″;FileHeader包含了关于PE文件物理分布的信息;OptionalHeader包含了关于PE文件逻辑分布的信息。
typedef struct _IMAGE_NT_HEADERS { +000h DWORD Signature; // PE文件标志 +004h IMAGE_FILE_HEADER FileHeader; // PE文件头 +018h IMAGE_OPTIONAL_HEADER32 OptionalHeader; // PE文件可选头 } IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;
PE文件头
typedef struct _IMAGE_FILE_HEADER { +000h WORD Machine; // 文件运行所要求的CPU +002h WORD NumberOfSections; // 文件中节的数目 +004h DWORD TimeDateStamp; // 文件创建时间戳 +008h DWORD PointerToSymbolTable; // 符号表 +00Ch DWORD NumberOfSymbols; // 符号数 +010h WORD SizeOfOptionalHeader; // 文件可选头的大小 +012h WORD Characteristics; // 文件属性 } IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;
PE文件可选头
typedef struct _IMAGE_OPTIONAL_HEADER { +000h WORD Magic; // 标识文件格式 +002h BYTE MajorLinkerVersion; // 链接器主版本号 +003h BYTE MinorLinkerVersion; // 链接器次版本号 +004h DWORD SizeOfCode; // 代码区块大小 +008h DWORD SizeOfInitializedData; // 初始化数据区块大小 +00Ch DWORD SizeOfUninitializedData; // 未初始化数据区块大小 +010h DWORD AddressOfEntryPoint; // 程序执行入口的RVA +014h DWORD BaseOfCode; // 内存中代码区块的RVA +018h DWORD BaseOfData; // 内存中数据区块的RVA +01Ch DWORD ImageBase; // 文件在内存的首选加载地址 +020h DWORD SectionAlignment; // 内存中节对齐的粒度 +024h DWORD FileAlignment; // 文件中节对齐的粒度 +028h WORD MajorOperatingSystemVersion; // 操作系统主版本号 +02Ah WORD MinorOperatingSystemVersion; // 操作系统次版本号 +02Ch WORD MajorImageVersion; // 程序主版本号 +02Eh WORD MinorImageVersion; // 程序次版本号 +030h WORD MajorSubsystemVersion; // 子系统主版本号 +032h WORD MinorSubsystemVersion; // 子系统次版本号 +034h DWORD Win32VersionValue; // 保留,通常置为0 +038h DWORD SizeOfImage; // 内存中整个PE映像体的尺寸 +03Ch DWORD SizeOfHeaders; // DOS头+PE头+节表的大小 +040h DWORD CheckSum; // 校验和 +044h WORD Subsystem; // 可执行文件期望的子系统 +046h WORD DllCharacteristics; // 标记DLL的特性 +048h DWORD SizeOfStackReserve; // 为线程保留的堆栈大小 +04Ch DWORD SizeOfStackCommit; // 实际堆栈大小 +050h DWORD SizeOfHeapReserve; // 为进程保留的堆大小 +054h DWORD SizeOfHeapCommit; // 实际堆大小 +058h DWORD LoaderFlags; // 装载标志 +05Ch DWORD NumberOfRvaAndSizes; // 数据目录的项数 +060h IMAGE_DATA_DIRECTORY DataDirectory[16]; // 数据目录 } IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32;
数据目录表
可选头最后一个字段DataDirectory,即数据目录表,是一个IMAGE_DATA_DIRECTORY结构数组,共16个成员,总大小为2*4*16=128字节,包含了PE文件中各个重要数据结构的位置和尺寸信息。
# | 预定义值 | 数据结构 |
0 | ***_EXPORT | 导出表(IMAGE_EXPORT_DIRECTORY结构 |
1 | ***_IMPORT | 导入表(IMAGE_IMPORT_DESCRIPTOR结构数组 |
2 | ***_RESOURCE | 资源(IMAGE_RESOURCE_DIRECTORY结构 |
3 | ***_EXCEPTION | 异常处理表(IMAGE_RUNTIME_FUNCTION_ENTRY数组 |
4 | ***_SECURITY | WIN_CERTIFICATE结构列表 |
5 | ***_BASERELOC | 基址重定位信息 |
6 | ***_DEBUG | 调试信息(IMAGE_DEBUG_DIRECTORY结构数组 |
7 | ***_ARCHITECTURE | 特定架构(IMAGE_ARCHITECTURE_HEADER结构数组 |
8 | ***_GLOBALPTR | 某些架构VirtualAddress是个RVA,用作全局指针 |
9 | ***_TLS | 线程局部存储初始化节(Thread Local Store |
10 | ***_LOAD_CONFIG | IMAGE_LOAD_CONFIG_DIRECTORY结构 |
11 | ***_BOUND_IMPORT | IMAGE_BOUND_IMPORT_DESCRIPTOR结构数组 |
12 | ***_IAT | 第一个导入地址表 |
13 | ***_DELAY_IMPORT | 延迟加载信息(CImgDelayDescr结构数组 |
14 | ***_COM_DESCRIPTOR | .NET最高级别信息(IMAGE_COR20_HEADER结构 |
15 | 未定义 | |
注:***表示IMAGE_DIRECTORY_ENTRY |
数据目录表的每个成员都是一个IMAGE_DATA_DIRECTORY结构,该结构定位了PE文件中的某个重要数据结构(详见上表),字段VirtualAddress是重要数据结构的RVA,字段Size是重要数据结构的字节数。
typedef struct _IMAGE_DATA_DIRECTORY { 000h DWORD VirtualAddress; 004h DWORD Size; } IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
- 导入表
数据目录表的第二项是导入表,它是一个IMAGE_IMPORT_DESCRIPTOR结构数组,每个结构都对应一个DLL,PE文件从几个不同的DLL中导入函数,导入表就有几个元素,并以一个全0的元素结尾。在PE文件加载时,会根据这个表里的内容加载依赖的DLL,并填充所需函数的地址。
typedef struct _IMAGE_IMPORT_DESCRIPTOR { union { +000h DWORD Characteristics; +000h DWORD OriginalFirstThunk; // 某IMAGE_THUNK_DATA数组的RVA }; +004h DWORD TimeDateStamp; // 导入模块(即dll)的时间戳 +008h DWORD ForwarderChain; // 转发链,若无转发器,该值为-1 +00Ch DWORD Name; // 指向导入模块(即dll)名字的RVA +010h DWORD FirstThunk; // 某IMAGE_THUNK_DATA数组的RVA } IMAGE_IMPORT_DESCRIPTOR, *PIMAGE_IMPORT_DESCRIPTOR;
字段OriginalFirstThunk和FirstThunk都指向IMAGE_THUNK_DATA结构数组,数组中的每一项对应一个导入函数,字段ForwarderString是转发用的,Function表示导入函数地址,若是按序号导入则Ordinal最高位为1且低16位是导入序号,若是按名字导入则AddressOfData指向名字信息。加载前OriginalFirstThunk与FirstThunk都指向名字信息,加载后FirstThunk指向实际的函数地址。
typedef struct _IMAGE_THUNK_DATA32 { union { +000h DWORD ForwarderString; // PBYTE +000h DWORD Function; // PDWORD +000h DWORD Ordinal; +000h DWORD AddressOfData; // PIMAGE_IMPORT_BY_NAME }; } IMAGE_THUNK_DATA32, *PIMAGE_THUNK_DATA32;
typedef struct _IMAGE_IMPORT_BY_NAME { +000h WORD Hint; // 导入函数在Dll中的编号(ordinal) +002h BYTE Name[1]; // 导入函数的符号名称,以\0结尾 } IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;
- 导出表
一个Windows程序的所有功能,最终几乎都是调用DLL文件提供的API函数来实现的。一个程序要使用其他DLL文件所提供的函数,需要在程序加载时将该DLL文件导入,此时用到的是导入表。而一个DLL文件想让其他程序使用它提供的API函数,必须在文件中包含这些函数的导出信息,此时用到的是导出表,即Export Table,数据目录的第一项定位了该导出表。通过导出表,DLL文件向系统提供了API函数的名称、序号和入口地址等信息。
当PE文件被执行时,PE装载器会将PE文件以及导入表中登记的DLL文件装入内存,再根据这些DLL文件的导出表对被执行文件的IAT表进行修正。
typedef struct _IMAGE_EXPORT_DIRECTORY { +000h DWORD Characteristics; // 未使用,值为0 +004h DWORD TimeDateStamp; // 输出表创建的时间 +008h WORD MajorVersion; // 主版本号 +00Ah WORD MinorVersion; // 次版本号 +00Ch DWORD Name; // 指向模块的真实名称的RVA +010h DWORD Base; // 基数 +014h DWORD NumberOfFunctions; // 模块中导出函数个数 +018h DWORD NumberOfNames; // 通过名字导出的函数个数 +01Ch DWORD AddressOfFunctions; // 指向所有函数地址的RVA数组 +020h DWORD AddressOfNames; // 指向所有函数名字的RVA数组 +024h DWORD AddressOfNameOrdinals; // 指向所有函数序号的RVA数组 } IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;
其中,AddressOfNameOrdinals指向的数组里面,保存的是该DLL文件的导入表中的函数的hint(也就是Ordinal),每个元素都是WORD大小(占2个字节)。导出表中的函数,在AddressOfNameOrdinals数组里的索引,和它在AddressOfName那个数组中的索引是完全一样的。
PE装载器根据函数名称查找导出函数入口地址的整个过程:定位到DLL文件的PE Header,从数据目录读取导出表的虚拟地址,定位导出表,获取名字数目NumberOfNames,并行遍历 AddressOfNames数组和AddressOfNameOrdinals数组,如果在 AddressOfNames数组的第m个元素找到匹配,就提取AddressOfNameOrdinals数组的第m个元素n,于是读取AddressOfFunctions数组的第n个元素,此值就是导出函数的RVA,最后计算入口地址:
API’s Address = AddressOfFunctions[n-1] + the BaseAddress of Kernel32 = the VA of AddressOfFunctions + (n-1)*4 + the BaseAddress of Kernel32
节表
节表Section Table是一个结构数组,每个元素都是一个IMAGE_SECTION_HEADER结构,包含了对应节的属性、文件偏移量、虚拟偏移量等。
typedef struct _IMAGE_SECTION_HEADER { +000h BYTE Name[8]; //节名称,如".text" union { +008h DWORD PhysicalAddress; // 物理地址 +008h DWORD VirtualSize; // 节长度 } Misc; +00Ch DWORD VirtualAddress; // RVA +010h DWORD SizeOfRawData; // 对齐后的节尺寸 +014h DWORD PointerToRawData; // 基于文件的偏移 +018h DWORD PointerToRelocations; // 重定位的偏移 +01Ch DWORD PointerToLinenumbers; // 行号表的偏移 +020h WORD NumberOfRelocations; // 重定位项数目 +022h WORD NumberOfLinenumbers; // 行号表中行号数目 +024h DWORD Characteristics; // 节属性 } IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;
PE装载器把节映射到内存的具体过程:
- 定位节表:读取IMAGE_FILE_HEADER->NumberOfSections,作为节的数目;读取IMAGE_OPTIONAL_HEADER->SizeOfHeaders,作为节表的文件偏移量。然后遍历节表中的每个结构:
- 读取IMAGE_SECTION_HEADER->PointerToRawData得到对应节的文件偏移量offset,读取IMAGE_SECTION_HEADER->SizeOfRawData得到对应节的字节数n。
- 读取IMAGE_OPTIONAL_HEADER->ImageBase得到文件装载到虚拟地址空间的基地址base,读取IMAGE_SECTION_HEADER->VirtualAddress得到对应节的相对虚拟地址VA。
- 把文件偏移offset开始的n个字节映射到内存的 base + VA 位置,并根据IMAGE_SECTION_HEADER->Characteristics设置属性。