- PE文件是Windows操作系统下使用的可执行文件格式,它是微软在UNIX平台的COFF基础上制作而成的。
- PE文件是指32为的可执行文件,也成为PE32,64位的可执行文件称为PE+或PE32+。
PE文件格式
-
PE文件种类
-
下图是notepad.exe文件的起始部分,也是PE文件的头部分(PE header),notepad.exe文件运行需要的所有信息都存储在这个PE头中,如何加载到内存、从何处开始执行、运行中需要的DLL有哪些、需要多大的栈/堆内存,大量信息以结构体形式存储在PE头中。
-
基本结构
- 从DOS头到节区头是PE头部分,其下的节区合称PE体,文件中使用偏移,内存中使用VA来表示位置。
- 文件加载到内存时,情况就会发生变化(节区的大小、位置等),文件的内容一般可分为代码、数据、资源节,分别保存。
- PE头与各节区的尾部都存在一个区域,称为NULL填充,为了提高处理文件、内存、网络包的效率,文件/内存中节区的起始位置应该在各文件/内存最小单位的倍数上。
- VA&RVA
- VA指的是进程虚拟内存的绝对地址,RVA指从某个基准位置开始的相对地址,VA与RVA满足下面的换算关系。
RVA + ImageBase = VA
- PE头内部信息大多以RVA形式存在,原因在与,PE文件加载到进程虚拟内存的特定位置时,该位置可能已经加载了其他PE文件,此时必须经过重定位,将其加载到其他空白的位置,若PE头信息使用的时VA,则无法正常访问。因此使用RVA来定位信息,即使发生了重定位,只要相对于基准位置的相对地址没有变化,就能正常访问到指定信息,不会出现任何问题。
PE头
- 创建PE文件格式时,人们正广泛使用DOS文件,所以微软充分考虑PE文件对DOS文件的兼容性,其结果是在PE头的最前面添加了一个IMAGE_DOS_HEADER结构体,用来拓展已有的DOS EXE头。
//(注:最左边是文件头的偏移量。)
IMAGE_DOS_HEADER STRUCT
{
+0h WORD e_magic // Magic DOS signature MZ(4Dh 5Ah) DOS可执行文件标记
+2h WORD e_cblp // Bytes on last page of file
+4h WORD e_cp // Pages in file
+6h WORD e_crlc // Relocations
+8h WORD e_cparhdr // Size of header in paragraphs
+0ah WORD e_minalloc // Minimun extra paragraphs needs
+0ch WORD e_maxalloc // Maximun extra paragraphs needs
+0eh WORD e_ss // intial(relative)SS value DOS代码的初始化堆栈SS
+10h WORD e_sp // intial SP value DOS代码的初始化堆栈指针SP
+12h WORD e_csum // Checksum
+14h WORD e_ip // intial IP value DOS代码的初始化指令入口[指针IP]
+16h WORD e_cs // intial(relative)CS value DOS代码的初始堆栈入口
+18h WORD e_lfarlc // File Address of relocation table
+1ah WORD e_ovno // Overlay number
+1ch WORD e_res[4] // Reserved words
+24h WORD e_oemid // OEM identifier(for e_oeminfo)
+26h WORD e_oeminfo // OEM information;e_oemid specific
+29h WORD e_res2[10] // Reserved words
+3ch DWORD e_lfanew // Offset to start of PE header 指向PE文件头
} IMAGE_DOS_HEADER ENDS
-
IMAGE_DOS_HEADER结构体的大小为40各字节,在该结构体中必须知道2个重要成员:
e_magic:DOS签名。
e_lfanew:指示NT头的偏移。 -
所有PE文件在开始部分都有DOS签名("MZ”),e_lfanew值指向NT头所在位置。
-
根据PE规范,文件开始的2个字节为4D5A,e_lfanew的值为000000e0.
- DOS存根
-
DOS存根位于DOS头下方,是个可选项,且大小不固定,即使没有DOS存根,文件也能正常运行,DOS存根由代码与数据混合而成。
-
文件偏移40~4D区域为16位汇编指令,32位的Windows OS中不会运行该命令,因此被识别位PE文件,所以完全忽略该代码,在DOS环境中运行Notepad.exe,可使其执行该代码。
- NT头
typedef struct _IMAGE_NT_HEADERS {
+00h DWORD Signature; // 固定为 0x00004550 根据小端存储为:"PE.."
+04h IMAGE_FILE_HEADER FileHeader;
+18h IMAGE_OPTIONAL_HEADER32 OptionalHeader;
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;
- IMAGE_NT_HEADERS结构体大小为F8,由3个成员组成,第一个成员为签名结构体,其值为50450000h("PE"00),另外两个成员分别为文件头与可选头结构体。
- NT头:文件头
typedef struct _IMAGE_FILE_HEADER {
+04h WORD Machine; // 运行平台
+06h WORD NumberOfSections; // 文件的区块数目
+08h DWORD TimeDateStamp; // 文件创建日期和时间
+0Ch DWORD PointerToSymbolTable; // 指向符号表(主要用于调试)
+10h DWORD NumberOfSymbols; // 符号表中符号个数(同上)
+14h WORD SizeOfOptionalHeader; // IMAGE_OPTIONAL_HEADER32 结构大小
+16h WORD Characteristics; // 文件属性
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;
- Machine
每个CPU都拥有唯一的Machine码,兼容32位Intel x86芯片的Machie码位14C。 - NumberOfSections
PE文件把代码、数据、资源等依据属性分类到各节区中存储,NumberOfSection用来指出文件中存在的节区数量,该值一定要大于0,且当定义的节区数与实际节区不同时,将发生运行错误。 - TimeDataStamp
用来记录编译器创建此文件的时间。 - SizeOfOptionalHeader
IMAGE_NT_HEADER结构体的最后一个成员位IMAGE_OPTIONAL_HEADER32结构体,SizeOfOptionalHeader成员用来指出IMAGE_OPTIONAL_HEADER32结构体的长度。
PE32+格式的文件中使用的是IMAGE_OPTIONAL_HEADER64结构体,而不是IMAGE_OPTIONAL_HEADER32结构体,2个结构体的尺寸是不同的,所以需要在SizeOfOptionalHeader成员明确指出结构体的大小。 - Characteristics
用于标识文件的属性,文件是否是可运行状态、是否位DLL文件等信息,以bit OR形式组合起来。 - 查看IMAGE_FILE_HEADER结构体
- NT头:可选头
typedef struct _IMAGE_OPTIONAL_HEADER
{
//
// Standard fields.
//
+18h WORD Magic; // 标志字, ROM 映像(0107h),普通可执行文件(010Bh)
+1Ah BYTE MajorLinkerVersion; // 链接程序的主版本号
+1Bh BYTE MinorLinkerVersion; // 链接程序的次版本号
+1Ch DWORD SizeOfCode; // 所有含代码的节的总大小
+20h DWORD SizeOfInitializedData; // 所有含已初始化数据的节的总大小
+24h DWORD SizeOfUninitializedData; // 所有含未初始化数据的节的大小
+28h DWORD AddressOfEntryPoint; // 程序执行入口RVA
+2Ch DWORD BaseOfCode; // 代码的区块的起始RVA
+30h DWORD BaseOfData; // 数据的区块的起始RVA
//
// NT additional fields. 以下是属于NT结构增加的领域。
//
+34h DWORD ImageBase; // 程序的首选装载地址
+38h DWORD SectionAlignment; // 内存中的区块的对齐大小
+3Ch DWORD FileAlignment; // 文件中的区块的对齐大小
+40h WORD MajorOperatingSystemVersion; // 要求操作系统最低版本号的主版本号
+42h WORD MinorOperatingSystemVersion; // 要求操作系统最低版本号的副版本号
+44h WORD MajorImageVersion; // 可运行于操作系统的主版本号
+46h WORD MinorImageVersion; // 可运行于操作系统的次版本号
+48h WORD MajorSubsystemVersion; // 要求最低子系统版本的主版本号
+4Ah WORD MinorSubsystemVersion; // 要求最低子系统版本的次版本号
+4Ch DWORD Win32VersionValue; // 莫须有字段,不被病毒利用的话一般为0
+50h DWORD SizeOfImage; // 映像装入内存后的总尺寸
+54h DWORD SizeOfHeaders; // 所有头 + 区块表的尺寸大小
+58h DWORD CheckSum; // 映像的校检和
+5Ch WORD Subsystem; // 可执行文件期望的子系统
+5Eh WORD DllCharacteristics; // DllMain()函数何时被调用,默认为 0
+60h DWORD SizeOfStackReserve; // 初始化时的栈大小
+64h DWORD SizeOfStackCommit; // 初始化时实际提交的栈大小
+68h DWORD SizeOfHeapReserve; // 初始化时保留的堆大小
+6Ch DWORD SizeOfHeapCommit; // 初始化时实际提交的堆大小
+70h DWORD LoaderFlags; // 与调试有关,默认为 0
+74h DWORD NumberOfRvaAndSizes; // 下边数据目录的项数,这个字段自Windows NT 发布以来 // 一直是16
+78h IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];
// 数据目录表
} IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32;
- Magic
IMAGE_OPTIONAL_HEADER32结构体是,Magic码为10B;为IMAGE_OPTIONAL_HEADER64结构体时,Magic码为20B。 - AddressOfEntryPoint
持有EP的RVA值,指出程序最先执行的代码起始地址,相当重要。 - ImageBase
进程虚拟内存的范围时0~FFFFFFFF,PE文件被加载到如此大的内存中时,ImageBase指出文件的优先装入地址。
执行PE文件时,PE装载器先创建进程,再将文件装入内存,然后把EIP寄存器的值设置为ImageBase+AddressOfEntryPoint. - SectionAlignment,FileAlignment
PE文件的Body部分划分为若干节区,这些节区存储着不同类别的数据,FileAlignment指定了节区在磁盘文件中的最小单位,而SectionAlignment指定了节区在内存中的最小单位,磁盘文件或内存的节区大小必定为FileAlignment或SectionAlignment值的整数倍。 - SizeOfImage
加载PE文件到内存时,SizeOfImage指定了PE Image在虚拟内存中所占空间的大小,一般而言,文件的大小与加载到内存的大小是不同的。 - SizeofHeader
用来指出整个PE头的大小,该值也必须是FileAlignment的证书倍。 - Subsystem
用来区分系统驱动文件(*.sys)与普通的可执行文件(*.exe,*.dll). - NumberOfRvaAndSIzes
NumberOfRvaAndSIzes用来指定DataDirectory数组(IMAGE_OPTIONAL_HEADER32结构体的最后一个成员)的个数。 - DataDirectory
DataDirectory是由IMAGE_DATA_DIRECTORY结构体组成的数组,数组的每项都有被定义的值。
重点关注EXPORT/IMPORT/RESOURCE、TLS Direction.
节区头
- PE格式的设计者决定把具有相似属性的数据统一保存在一个被称为节区的地方,然后需要把各节区属性记录在节区头中。
- 节区头是由IMAGE_SECTION_HEADER结构体组成的数组,每个结构体对应一个节区。
typedef struct _IMAGE_SECTION_HEADER
{
+0h BYTE Name[IMAGE_SIZEOF_SHORT_NAME]; // 节表名称,如“.text”
//IMAGE_SIZEOF_SHORT_NAME=8
union
+8h {
DWORD PhysicalAddress; // 物理地址
DWORD VirtualSize; // 真实长度,这两个值是一个联合结构,可以使用其中的任何一个,一
// 般是取后一个
} Misc;
+ch DWORD VirtualAddress; // 节区的 RVA 地址
+10h DWORD SizeOfRawData; // 在文件中对齐后的尺寸
+14h DWORD PointerToRawData; // 在文件中的偏移量
+18h DWORD PointerToRelocations; // 在OBJ文件中使用,重定位的偏移
+1ch DWORD PointerToLinenumbers; // 行号表的偏移(供调试使用地)
+1eh WORD NumberOfRelocations; // 在OBJ文件中使用,重定位项数目
+20h WORD NumberOfLinenumbers; // 行号表中行号的数目
+24h DWORD Characteristics; // 节属性如可读,可写,可执行等} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;
}
- VirtualSize:内存中节区所占的大小。
- VirtualAddress:内存中节区起始地址。
- SizeOfRawData:磁盘文件中节区所占大小。
- PointerToRawData:磁盘文件中节区起始地址。
- Characteristics:节区属性(bit OR)。
RAV to RAW
- PE文件加载到内存时,每个节区都要能准确完成内存地址与文件偏移间的映射,这种映射一般称为RVA to RAW。
- 查找RVA所在节区。
- 使用简单的公式计算文件偏移。(RAW - PointerToRawData = RVA - VirtualAddress)。
- EX:RVA = 5000, FILE Offset = 5000 - 1000 + 400 = 4400
IAT
- IAT是一种表格,用来记录程序正在使用哪些库中的哪些函数。
- DLL
- 不要把库包含到程序中,单独组成DLL文件,需要时调用即可。
- 内存映射技术使加载后的DLL代码、资源在多个进程中实现共享
- 更新库时只要替换相关DLL文件即可,简便易行。
- 加载DLL的方式有两种
- 一种是显式链接,程序使用DLL时加载,使用完毕后释放内存。
- 另一种是隐式链接,程序开始时即一同加载DLL,程序终止时再释放占用的内存,IAT提供的机制与隐式链接有关。
- IMAGE_IMPORT_DESCRIPTOR
typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics; // 0 for terminating null import descriptor
DWORD OriginalFirstThunk; // 包含指向IMAGE_DATA(输入名称表)RVA 的结构数组
};
DWORD TimeDateStamp; //当可执行文件不与被导入的DLL进行绑定时,此字段为0
DWORD ForwarderChain; //第一个被转向的API索引
DWORD Name; //指向被导入的DLL 名称
DWORD FirstThunk; //指向输入地址表(IAT)RVA,IAT是一个IMAGE_THUNK_DATA结构的数组
} IMAGE_IMPORT_DESCRIPTOR;
typedef struct _IMAGE_IMPORT_BY_NAME {
WORD Hint;
BYTE Name[1];
}IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;
- IMAGE_IMPORT_DESCRIPTOR结构体中记录着PE文件要导入哪些库文件,导入多少库就存在多个IMAGE_IMPORT_DESCRIPTOR结构体,这些结构体形成了数组,且结构体数组最后以NULL结构体借宿。
- IMAGE_IMPORT_DESCRIPTOR中的重要成员:
- 在PE文件没有导入到内存之前,OriginalFirstThunk和FirstThunk 结构都是一个东西,但是导入内存后,系统会根据OriginalFirstThunk对FirstThunk指向的表重新写入真实函数的地址。就是IAT了。
- 使用notepad.exe
- IMAGE_OPTIONAL_HEADER32.DataDirectory[1].VirtualAddress的值即使IMAGE_IMPORT_DESCROPTOR结构体数组的起始地址,IMAGE_IMPORT_DESCRIPTOR结构体也被称为IMPORT Directory Table。
- IMAGE_OPTIONAL_HEADER32.DataDirectory[1]结构体的值,第一个4字节为虚拟地址,第二个4字节为Size成员。
- RVA是7604,故文件偏移为6A04(Raw = 7604 - 1000 + 400 = 6A04),定位到6A04.
- 阴影部分即为全部的IMAGE_IMPORT_DESCRIPTOR结构体数组,粗线框内的一部分是结构体数组的第一个元素。
- 库名称:Name是一个字符串指针,它指向导入函数所属的库文件名称,在文件偏移6EAC,可以看到comdlg32.dll。
- OriginalFirstThunk - INT : INT是一个包含导入函数信息的结构体指针数组,我们定位到6D90,每个地址分别指向IMAGE_IMPORT_BY_NAME结构体,跟踪数组的第一个值7A7A(RVA),进入该地址,可以看到导入的API函数的名称字符串。
- IMAGE_IMPORT_BY_NAME:
RVA:7A7A -> RAW:6E7A。
文件偏移6E7A最初的2个字节值为Ordinal,是库中函数的固有编号,Ordinal的后面为函数名称字符串PageSetupDlgW。
如图,INT是IMAGE_IMPORT_BY_NAME结构体指针数组,数组的第一个元素指向函数Ordinal值为000F,函数的名称为PageSetupDlgW。
- FirstThunk - IAT
IAT的RVA:12C4即为RAW:6C4.
文件偏移6C4~6EB区域即为IAT数组区域,对应与comdlg32.dll库,它与INT类似,由结构体指针数组组成,且以NULL结尾。
IAT的第一个元素值被硬编码为76324906,该值无实际意义,notepad.exe文件加载到内存时,准备的地址值会取代该值。
EAT
- EAT是一种核心机制,它使不同的应用程序可以调用库文件中提供的函数,也就是说,只有通过EA才能准确求得从相应库中导出函数的起始地址。
- PE文件中仅有一个用来说明库EAT的IMAGE_EXPORT_DIRECTORY结构体。
- 可以在PE文件的PE头中查找到IMAGE_EXPORT_DIRECTORY结构体的位置,IMAGE_OPTIONAL_HEADER32.DataDirectory[0].VirtualAddress值即是IMAGE_EXPORT_DIRECTORY结构体数组的起始位置。
- 下图显示的是kernel32.dll文件的IMAGE_OPTIONAL_HEADER32.DataDirectory[0],第一个4字节为VirtualAddress,第二个4字节为Size成员。
- IMAGE_OPTIONAL_HEADER32.DataDirectory结构体数组信息整理如下表:
- 由于RVA的值为262C,所以文件偏移为1A2C.
- IMAGE_EXPORT_DIRECTORY
typedef struct _IMAGE_EXPORT_DIRECTORY {
DWORD Characteristics; // 未使用,总为0 DWORD TimeDateStamp; // 文件创建时间戳
WORD MajorVersion; // 未使用,总为0 WORD MinorVersion; // 未使用,总为0
DWORD Name; // 指向一个代表此 DLL名字的 ASCII字符串的 RVA
DWORD Base; // 函数的起始序号
DWORD NumberOfFunctions; // 导出函数的总数 DWORD NumberOfNames; // 以名称方式导出的函数的总数 DWORD AddressOfFunctions; // 指向输出函数地址的RVA
DWORD AddressOfNames; // 指向输出函数名字的RVA
DWORD AddressOfNameOrdinals; // 指向输出函数序号的RVA} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;
- NumberOfFuctions : 实际Export函数的个数。
- NumberOfNames:Export函数中具名的函数个数。
- AddressOfFunctions:Export函数地址数组
- AddressOfFunctions:函数名称地址数组
- AddressOfNameOrdinals: Ordinal地址数组
- kernel32.dll文件的IMAGE_EXPORT_DIRECTORY结构体与整个EAT结构。
- GetProcAddress()函数用来从库中获得函数地址,该API引用引用EAT来获取指定API的地址,GetProcAddress()API拥有函数的名称:
- 利用AddressOfNames成员转到函数名称数组。
- 函数名称数组里面存储着字符串地址,通过比较字符串,查找指定的函数名称。(数组的索引称为name_index)
- 利用AddressOfNameOrdinals成员,转到orinal数组。
- 在ordinal数组通过name_index查找相应ordinal值。
- 利用AddressOfFunction成员转到函数地址数组。
- 在函数地址数组中将刚刚求得的ordinal用作数组索引,获得指定函数的起始地址。
- kernel32.dll中所有导出函数均有相应名称,AddressOfNameOrdinals数组的值以index=ordianl存在,但并不是所有的DLL文件都如此,导出函数中也有一些函数没有名称,AddressOfNameOrdinals的index!=ordinal.
- 使用kernel32.dll练习
- 从kernel32.dll文件的EAT查找AddAtomW函数,kernel32.dll的IMAGE_EXPORT_DIRECTORY结构体RAW为1A2C。
- 深色部分就是IMAGE_EXPORT_DIRECTORY结构体区域,IMAGE_EXPORT_DIRECTORY结构体的各个成员如图:
- AddressOfName成员的值为RVA=353C,即RAW=293C,查看该地址
- 此处为4字节的RVA组成的数组,素组元素的个数为NumberOfNames(3BA),逐一跟随所有的RVA值即可发现函数名称字符串。
- 要查找的函数名称字符串为“AddAtomW”,只要找到RVA数组第三个元素的值(RVA:4BBD -> RAW:3FBD)即可,进入地址就会看到“AddAtomW”字符串,此时“AddAtomW”函数名即使数组的第三个元素,数组索引为2。
- 查找AddAtomW函数的Ordinal值,AddressOfNameOrdinals成员的值为RVA:4424 -> RVA:3824,深色部分是多个2字节的ordinal组成的数组。
- 之前求得的name_index值为2,根据name_index的值计算Ordinal数组的值即可求得AddressOfNameOrdinals[2] = 2.
- 最后查找AddAtomW的实际函数地址,AddressOfFunctions成员的值为RVA:2654 -> RAW:1A54,深色部分即为4字节函数地址RVA数组,它就是Export函数的地址。
- 为了获取AddAtomW函数的地址,将求得的Ordianl用作数组的索引,得到RVA=00326F1 -> AddressOfFunctions[ordinal] = RVA(ordinal=2,RVA=326F1)
- kernel32.dll的ImageBase=7C7D000,因此AddAtomW函数的实际地址(VA)为7C8026F1(7C7D0000 + 326F1 = 7C8026F1).