PE即Portable Executable,是Windows OS下使用的可执行文件格式。PE文件是指32位的可执行文件,亦称为PE32。64位的可执行文件称为PE+或PE32+,是PE文件的一种扩展形式。
PE文件种类如下表:
PE文件基本结构:
从DOS头至节区头是PE头部分,下面的节区合称PE体。文件的内容一般分为代码(.text)、数据(.data)、资源(.rsrc)节,分别保存。
文件中使用偏移(offset),内存中使用VA(Virtual Address虚拟地址)来表示位置。当文件加载到内存时,节区的大小和位置等会发生改变。
VA是指进程虚拟内存的绝对地址,RVA(Relative Virtual Address相对虚拟地址)是指从某个基准位置(ImageBase)开始的相对地址:RVA + ImageBase = VA
PE头内部信息大多以RVA形式存在,因为PE文件(主要是DLL)加载到进程虚拟内存的特定位置时,该位置可能已经加载了其他PE文件,此时必须通过重定位将其加载到其他空白位置。
PE头最前面添加的IMAGE_DOS_HEADER结构体,用来扩展已有的DOS EXE头。该结构体大小为40(h)字节,其中有两个重要的成员e_magic和e_lfanew。
e_magic:DOS签名(4D5Z,即“MZ”)。
e_lfanew:指示NT头的偏移。
所有PE文件在开始部分(e_magic)都有DOS签名(“MZ”),e_lfanew值指向NT头所在位置(NT即IMAGE_NT_HEADERS)。
使用WinHex打开notepad查看:
PE文件开始即为“MZ”DOS签名,e_lfanew值为000000E0。
DOS存根(stub)在DOS下方,是个可选项,由代码和数据混合而成,大小不固定(即使没有DOS存根,文件也可以正常运行)。
32位Windows OS不会运行其中的代码,但在DOS环境中可以运行。下面notepad示例是输出如图的字符串后便终止。
IMAGE_NT_HEADERS结构体大小为F8,由3个成员组成:Signature签名结构体,其值为50450000h(“PE”00);IMAGE_FILE_HEADER文件头结构体;IMAGE_OPTIONAL_HEADER32可选头结构体。
IMAGE_FILE_HEADER结构体用于表示文件大致属性。其中有4个重要成员。
1.Machine:每个CPU都有唯一的MAchine码,如Intel 386为14C。
2.NumberOfSections:说明文件中存在的节区数量。
3.SizeOfOptionalHeader:说明IMAGE_OPTIONAL_HEADER32结构体的长度。IMAGE_OPTIONAL_HEADER32结构体由C语言编写,因而其大小确定,但Windows的PE装载器需要查看IMAGE_FILE_HEADER结构体SizeOfOptionalHeader值来识别出IMAGE_OPTIONAL_HEADER32结构体的大小。PE32+文件使用的是IMAGE_OPTIONAL_HEADER64结构体,两者大小不同,所以需要SizeOfOptionalHeader来识别结构体的大小。
4.Characteristics:用于识别文件的属性,文件是否是可运行的形态、是否为DLL文件等信息,以bit OR形式组合起来。其中0002h为文件可执行,2000h为DLL文件。
IMAGE_OPTIONAL_HEADER32结构体为PE头结构体中最大的。
1.Magic:当为IMAGE_OPTIONAL_HEADER32结构体时,为10B;当为IMAGE_OPTIONAL_HEADER64结构体时,为20B。
2.AddressOfEntryPoint:持有EP的RVA值,指出程序最先执行的代码起始地址。
3.ImageBase:指出文件的优先装入地址。EXE、DLL文件被装载到用户内存的0~7FFFFFFF中,SYS文件被装载到内核内存的80000000~FFFFFFFF中。一般地,使用开发工具创建好EXE文件后,其ImageBase值为00400000,DLL文件的为10000000。执行PE文件时,PE装载器先创建进程,在将文件载入内存,然后把EIP寄存器的值设置为ImageBase+AddressOfEntryPoint。
4.SectionAlignment,FileAlignment:SectionAlignment指定了节区在内存中的最小单位,FileAlignment指定了节区在磁盘文件中的最小单位。磁盘文件或呢寸的节区大小比定位FileAlignment或SectionAlignment值的整数倍。
5.SizeOfImage:指定PE Image在虚拟内存中所占空间的大小。一般地,文件的大小与加载到内存中的大小是不同的。
6.SizeOfHeader:指明整个PE头的大小,必须是FileAlignment的整数倍。第一节区所在位置与SizeOfHeader距文件开始偏移的量相同。
7.Subsystem:用来区分系统驱动文件(SYS)和普通的可执行文件(EXE、DLL)。
8.NumberOfRvaAndSizes:用来指定DataDirectory数组的个数。虽然结构体定义中明确指出数组个数为IMAGE_NUMBEROF_DIRECTORY_ENTRIES(16),但PE装载器通过查看NumberOfRvaAndSizes来识别数组大小。
9.DataDirectory:由IMAGE_DATA_DIRECTORY结构体组成。
节区头是由IMAGE_SECTION_HEADER结构体组成的数组,每个结构体对应一个节区。
IMAGE_SECTION_HEADER结构体中几个重要的成员如下。
VirtualSize:内存中节区所占大小。
VirtualAddress:内存中节区起始地址(RVA)。
SizeOfRawData:磁盘文件中节区所占大小。
PointerToRawData:磁盘文件中节区起始位置。
Characteristics:节区属性(bit OR)。
VirtualAddress与PointToRawData不带有任何值,分别由SectionAlignment和FileAlignment确定。SizeOfRawData与VirtualSize一般具有不同的值,即磁盘文件中节区的大小和加载到内存中的节区大小是不同的。
RVA to RAW指的是PE文件加载到内存时每个节区都要能够准确完成内存地址与文件偏移之间的映射。方法如下:
(1)查找RVA所在节区。
(2)使用简单的公式计算文件偏移(RAW)。
换算公式如下:
RAW - PointerToRawData = RVA - VirtualAddress
RAW = RVA - VirtualAddress + PointerToRawData
即文件偏移RAW为内存地址RVA减去内存中节区的起始地址再加上磁盘文件中节区的起始地址,其中VirtualAddress并不是VA、而是以RVA形式保存,两者只是术语相同。
其中 虚拟地址VA = RVA + ImageBase
IAT,Import Address Table导入地址表,用来记录程序正在使用哪些库中的哪些函数。
DLL,Dynamic Linked Library动态链接库。加载DLL的方式有两种,即显式链接(程序使用DLL时加载,使用完毕后释放内存)和隐式链接(程序开始时即一同加载DLL,程序终止时再释放占用的内存),IAT提供的机制与隐式链接有关。
IMAGE_IMPORT_DESCRIPTOR结构体中记录着PE文件要导入哪些库文件。导入多少个库就有多少个IMAGE_IMPORT_DESCRIPTOR结构体并形成数组,结构体数组最后以NULL结构体结束。IMAGE_IMPORT_DESCRIPTOR结构体的几个重要成员如下。
OriginalFirstThunk:INT的地址(RVA)。
Name:库名称字符串的地址(RVA)。
FirstThunk:IAT的地址(RVA)。
INT与IAT是长整型数组,以NULL结束,两者大小应相同。
INT中各元素的值为IMAGE_IMPORT_BY_NAME结构体指针。
PE装载器将导入函数输入至IAT的顺序:
1、读取IID的Name成员,获取库名称字符串;
2、装载相应的库;
3、读取IID的OriginalFirstThunk成员,获取INT地址;
4、逐一读取INT中数组的值,获取相应IMAGE_IMPORT_BY_NAME地址(RVA);
5、使用IMAGE_IMPORT_BY_NAME的Hint(ordinal)或Name项,获取相应函数的起始地址;
6、读取IID的FirstThunk(IAT)成员,获得IAT地址;
7、将上述获得的函数地址输入相应的IAT数组值中;
8、重复步骤4~7,直至INT结束(遇到NULL时)。
下面以notepad为示例。
IMAGE_IMPORT_DESCRIPTOR结构体是位于PE体而非PE头中,但查找其位置信息需要到PE头中查找。在IMAGE_OPTIONAL_HEADER32.DataDirectory[1].VirtualAddress的值即使IMAGE_IMPORT_DESCRIPTOR结构体数组的起始地址(RVA)IMAGE_IMPORT_DESCRIPTOR结构体数组亦称为IMPORT Directory Table。
第一个4字节为虚拟地址,第二个4字节为Size。
可以看到RVA为7604,位于内存中的第一节区(1000~9000),而磁盘文件中第一节区为400~7C00。
RAW = RVA - VirtualAddress + PointerToRawData = 7604 - 1000 + 400 = 6A04
所以直接在WinHex中查看RAW地址即可看到IMAGE_IMPORT_DESCRIPTOR结构体数组:
框中的部分为数组第一个元素。分析该元素并计算相应的RAW值:
OriginalFirstThunk(INT):RVA=00007990,RAW=00006D90
TimeDataStamp:RVA=FFFFFFFF
ForwardChain:RVA=FFFFFFFF
Name:RVA=00007AAC,RAW=00006EAC
FirstThunk(IAT):RVA=000012C4,RAW=000006C4
1、查看Name——库名称
2、查看OriginalFirstThunk(INT)
INT是包含导入函数信息(Ordinal,Name)的结构体指针数组。如下图,INT由地址数组形式组成,数组尾部以NULL结束,每个地址值分别指向一个IMAGE_IMPORT_BY_NAME结构体:
查看第一个IMAGE_IMPORT_BY_NAME结构体,其RVA为00007A7A,计算出RAW为00006E7A。
框中的内容为Ordinal,是库中函数的固有编号,其后的字符为函数名称字符串,字符串末尾同样以NULL结尾。
3、查看FirstThunk(IAT)
IAT由结构体数组组成,以NULL结尾。其中数组元素值是硬编码的,并无实际意义,文件加载到内存后准确的地址值会取代该值。
IAT的RVA为000012C4,ImageBase为01000000,则使用Ollydbg打开notepad到地址010012C4中查看:
可以看到,数组元素的值加载到内存后被替代为准确的起始地址值。
EAT,Export Address Table导出地址表,使不同的应用程序可以调用库文件中提供的函数。只有通过EAT才能准确求得从相应库中导出函数的起始地址。IMAGE_EXPORT_DIRECTORY结构体中保存着导出信息,并且PE文件中仅有一个用来说明库EAT的IMAGE_EXPORT_DIRECTORY结构体(用来说明IAT的IMAGE_IMPORT_DIRECTORY结构体以数组形式存在,拥有多个成员,因为PE文件可以同时导入多个库)。
与IAT类似,IMAGE_OPTIONAL_HEADER32.DataDirectory[0].VirtualAddress的值即是IMAGE_EXPORT_DIRECTORY结构体数组的起始地址(RVA)。
这里示例为kernel32.dll:
第一个4字节为VirtualAddress,第二个4字节为Size。
RVA为0000262C,则RAW为00001A2C。
IMAGE_EXPORT_DIRECTORY结构体的几个重要成员:
NumberOfFunctions:实际Export函数的个数。
NumberOfNames:Export函数中具名的函数个数。
AddressOfFunctions:Export函数地址数组。
AddressOfNames:函数名称地址数组。
AddressOfNameOrdinals:Ordinal地址数组。
从库中获取函数地址的API为GetProcAddress()函数,其引用EAT来获取指定API的地址。
GetProcAddress()函数操作原理:
(1)利用AddressOfNames成员转到函数名称数组;
(2)函数名称数组中保存着字符串地址,通过比较字符串查找到特定的函数名称(此时数组的索引称为name_index);
(3)利用AddressOfNameOrdinals成员转到ordinal数组;
(4)在ordinal数组中通过name_index查找相应的ordinal值;
(5)利用AddressOfFunctions成员转到EAT;
(6)在EAT中将ordinal值用作索引,获取指定函数的起始地址。
下面在kernel32.dll文件中查找Export函数地址。
由上述可知,kernel32.dll的IMAGE_EXPORT_DIRECTORY结构体的RAW为1A2C,现在查找其中的AddAtomW()函数。
关注最后三个4字节,AddressOfFunctions为00002654,其RAW为00001A54;AddressOfNames位00003538,其RAW为00002938;AddressOfNameOrdinals位0000441C,其RAW为0000381C。
1、在函数名称数组中查找指定函数名称:
进入AddressOfNames的RAW地址查看,逐一进入4字节的地址查看,直至查看到数组的第三个元素、其索引为2即为我们需要查找的函数:
记录此时的地址00004BB3,其RAW则为00003FB3。
转到该地址查找“AddAtomW”函数名称字符串:
2、根据索引值查找Ordinal值:
由上述可知,AddressOfNameOrdinals的RAW为0000381C,进入其中查看:
根据第一步中的索引值2,在由多个2字节ordinal组成的数组中可以查找到对应的ordinal值为0002。
3、查找函数地址:
由上述可知,AddressOfFunctions的RAW为00001A54,进入其中查看:
根据ordinal值2,在4字节函数地址RVA数组中查找到函数地址为000326D9。
下面直接使用Lord_PE打开kernel32.dll文件查看该函数地址来进行验证:
可以看到,显示AddAtomW()函数的RVA和查找到的是一致的。
上述过程即是在DLL文件中查找Export函数的方法,与使用GetProcAddress() API获取指定函数地址的方法是一样的。