PE 文件格式详解(中)
一次性进群,长期免费索取教程,没有付费教程。
教程列表见微信公众号底部菜单
进微信群回复公众号:微信群;QQ群:16004488
微信公众号:计算机与网络安全
ID:Computer-network
接上篇《PE 文件格式详解(上)》
五、导入表
导入表机制是PE文件从其他第三方程序中导入API,以供本程序调用的机制。
在反病毒领域里,技术人员在某些情况下仅根据导入表就能猜测出此程序的大致行为,由此可见导入表对于一个应用程序的重要性。而技术人员之所以要拥有如此看似神奇的技能,是因为在Windows平台下所有由系统提供的API函数都是使用导入表、导出表完成的,因此如果在应用程序中调用了系统的某些函数,那么这些信息就势必要体现在导入表中。
不过随着病毒与反病毒之间的博弈,现在已经诞生了很多不用导入表调用系统API的技术。
1、IMAGE_IMPORT_DESCRIPTOR结构
IMAGE_IMPORT_DESCRIPTOR(导入表)是一个比较特殊的结构,其本身仅相当于一个引导者的角色,并不能完成PE文件中的整个导入工作,它只负责引导系统找到真正保存有导入信息的其他两个结构,这两个结构分别为IMAGE_THUNK_DATA与IMAGE_IMPORT_BY_NAME,后面将逐一进行讲解。
IMAGE_IMPORT_DESCRIPTOR结构的个数是由导入映像文件的个数决定的,需要从多少个映像文件中导入函数,就要有多少个IMAGE_IMPORT_DESCRIPTOR结构,最后会以一个空的IMAGE_IMPORT_DESCRIPTOR结构结束。相关代码如下:
上述结构说明如下。
1)OriginalFirstThunk:包含指向INT的RAV,INT是一个IMAGE_THUNK_DATA结构的数组,在大多数情况下,数组中的每个IMAGE_THUNK_DATA结构会再指向IMAGE_IMPORT_BY_NAME结构,结尾处则以一个全0x00的IMAGE_THUNK_DATA结构结束。
2)TimeDateStamp:一个32位的时间标识,与下一个转发链字段合并工作,否则可以为空。
3)ForwarderChain:这是控制导入表转发器forwarders的索引值,一个映像文件可以输出一个没有在本文件内定义的符号,并且这个符号可以是从另一个映像文件引入的,这样的符号称为转发符号。当此值为-1时,说明到此文件转发已经结束。当值为0时,证明此映像文件未启用此机制。
4)Name:指向导入映像文件的名字。
5)FirstThunk:指向导入地址表(IAT)的RAV。
在一般情况下,对于导入表我们只需要关注它的两个字段,分别是OriginalFirstThunk与FirstThunk,这两个字段分别指向了保存导出名称与导出地址的IMAGE_THUNK_DATA结构数组,且这个数组以一个空的IMAGE_THUNK_DATA结构结尾,其结构的详细内容如下:
上述结构说明如下。
1)ForwarderString:负责与导入表转发器forwarders协同工作的一个字段。当导入表的ForwarderChain不为0时,此值有效,并指向包含有转发函数与导出这个函数的映像文件名的字符串RVA。
2)Function:导入表导入函数的实际内存地址,此字段仅在此映像被加载,且此结构为IAT的前提下有效。
3)Ordinal:导入表导入函数的导出序号,当IMAGE_THUNK_DATA的最高位为1时,此值有效。
4)AddressOfData:指向IMAGE_IMPORT_BY_NAME结构,当以上3个值都未生效时,此值有效。
由上面的介绍可知,IMAGE_THUNK_DATA实际上就是一个4字节的联合体,在同一时间同一地点,联合体内的4个4字节的字段只能有一个有效,其中有两个字段的生效条件值得我们再深入研究一下。
首先是Ordinal字段,由于PE文件结构定义允许使用序号的方式导出函数,所以与之对应的导入表也要有相应的导入机制才行。
微软规定当IMAGE_THUNK_DATA的最高位为1时,就要采用序号导入方式,而且此时这个值的低31位将被看作是一个函数序号。
那么有没有可能出现这种情况,即当需要使用函数名导入的时候,其最高位必须为1?
我们都知道,当一个32位的数最高位为1时,这个数的最小值也得是0x80000000。而在Windows操作系统的内存中,0x80000000以上的空间被称为系统空间,仅系统可用,因此程序中的有效访问地址总是小于0x80000000的,这也就有效地解决了序号导入方式与函数名(字符串)导入方式可能会发生碰撞的问题。
宏IMAGE_SNAP_BY_ORDINAL32可以用来判断此项是否为序号。
其次是字段Function,它是一个由操作系统使用的字段,在PE文件被系统地加载之前,输入表的INT与IAT都是使用AddressOfData字段指向一个IMAGE_IMPORT_BY_NAME结构的,但当我们的PE文件被加载时,操作系统首先会逐个遍历INT中的内容,并逐一取出已导入函数的内存地址,然后将这些动态获取的地址逐一填入对应的IAT中,此时操作系统使用的就是Function这个字段。
下面来了解一下IMAGE_IMPORT_BY_NAME结构。
上面的结构说明如下。
1)Hint:保存着本映像导入表需导入的函数序号。
2)Name:保存着本映像导入表需导入的函数名称。
IMAGE_IMPORT_BY_NAME结构以一个结构数组的形式存在,并以一个空的IMAGE_IMPORT_BY_NAME结构结束。
2、识别导入表
代码清单3是由示例PEDemo.dll导出来的十六进制格式的导入表文本,其中用方框括起来的是IMAGE_IMPORT_DESCRIPTOR结构。这个例子导入了两个映像文件中的导出函数,因此会有两个IMAGE_IMPORT_DESCRIPTOR结构,并且每个结构中只保留了两个条目,以确保例子更加简洁明了。
代码清单3 导入表示例
通过以上信息可以发现,每个结构都是以一个空的结构结尾的,从而也可得出这两个IMAGE_IMPORT_DESCRIPTOR结构的信息,如图4所示。
图4 两个导入表的表头信息
由图4可以知道3条重要信息,分别是INT、IAT与映像名的RAV,通过这3条信息又可以索引到它们的具体结构,如表9所示。
由表9可以看出,OriginalFirstThunk与FirstThunk各有两个导入项,而且它们都是两两相同的,这一点再次证明了我们讲解IMAGE_THUNK_DATA结构时所述的观点。
根据这些导入项信息,就可以找到真正的导入函数与导入序号的信息了,如图5所示。
图5 导入表最终要导入的信息
图6很清晰地表明了导入表的结构。
图6 导入表结构
导入表的序号并不是不可靠的,因为编译器在生成程序时,使用的序号是SDK中的库文件(*.lib)中所保存的。例如我们在编写程序时用到了API函数CloseHandle(),我们都知道它是由系统中的Kernel32.dll导出的,但是编译器使用的Kernel32.lib显然与系统中的动态链接库版本很难同步,因此从Kernel32.lib获取的序号与系统中Kernel32.dll的序号不匹配也就可以理解了。
为了避免因此发生加载错误,最可靠的处理方法是首先使用本程序导入的序号,在导出此函数的DLL中查找与此序号所对应的函数名,如果目标DLL中与此序号对应的API函数名与本程序中此序号对应的函数名一致,则直接调用,否则使用函数名来搜索比对获取API地址。
六、资源
Windows下程序中的各种界面(数据)组成部分称为资源,比如菜单、图标、快捷键、版本信息以及其他未格式化的二进制资源。
数据目录中的IMAGE_DIRECTORY_ENTRY_RESOURCE项指向此结构。
1、资源结构
资源在PE文件中是以目录结构的形式存在的,一般情况下这个目录分为3层,从根目录开始分别为资源类型、目录资源ID与资源代码页。
这3层结构其实是非常简单的,只要我们能清晰地判断出IMAGE_RESOURCE_DIRECTORY_ENTRY结构所处的目录深度,以及当前情况下此结构中联合体内的哪个值是有效的,即可理清资源结构。
这3层目录结构都是由一个IMAGE_RESOURCE_DIRECTORY结构为头部的,并且在其后面跟着一个IMAGE_RESOURCE_DIRECTORY_ENTRY结构数组。IMAGE_RESOURCE_DIRECTORY结构主要负责指出后面结构数组的成员个数,而后面结构数组的每个成员则分别指向下一层目录结构(或资源数据)。
下面我们就先了解一下IMAGE_RESOURCE_DIRECTORY结构。
上面的结构说明如下。
1)Characteristics:资源属性,一般情况下为0x00000000。
2)TimeDateStamp:资源建立的时间。
3)MajorVersion:资源的主版本,一般情况下为0x0004。
4)MinorVersion:资源的子版本,一般情况下为0x0000。
5)NumberOfNamedEntries:用字符串作为资源标识的条目个数。
6)NumberOfIdEntries:用数字ID作为资源标识的条目个数。
前面我们讲过,每层目录结构中的起始位置都是一个IMAGE_RESOURCE_DIRECTORY结构,这个结构负责指出紧随其后的IMAGE_RESOURCE_DIRECTORY_ENTRY结构数组的成员个数。下面就来看看IMAGE_RESOURCE_DIRECTORY_ENTRY结构。
上面的结构说明如下。
1)NameOffset:当字段NameIsString为1时,此字段的值为资源名字符串的偏移。
2)NameIsString:资源名为字符串,当此值为1时,NameOffset会指向一个IMAGE_RESOURCE_DIR_STRING_U结构体,此结构体保存着资源名称,其数据结构如下。
3)Name:此结构体位于第一层目录中时,此字段保存有资源类型的值。当此结构体位于第三层目录中时,此字段保存有资源语言区域的类型值,资源类型的值如表10所示。
4)Id:资源的数字ID。
5)OffsetToData:数据偏移的RAV。
6)OffsetToDirectory:当字段DataIsDirectory为1时,此字段的值指向下一层子目录的偏移(相对资源目录起始地址的偏移)。
7)DataIsDirectory:数据指向目标为子目录。
这个IMAGE_RESOURCE_DIRECTORY_ENTRY是由两个大小为4字节的联合体组成的,在不同情况下,两个联合体中的有效字段也不同。
第一个联合体内的字段是根据当前结构体所处的目录层次来决定的,位于第一层目录时字段Name有效,保存的信息是资源类型;位于第二层目录时字段Id或结构体有效,这取决于此资源的索引方式,如果采用的是编号索引就是字段Id有效,否则结构体有效;位于第三层目录时字段Name有效,保存的信息是资源语言区域类型。
第二个联合体内的字段理论上是根据具体情况而定的,如果下一级是一个子目录的话,那么就是结构体生效,如果下一级是资源数据则是字段OffsetToData生效。
在经过三层目录的索引后,最后会到达一个IMAGE_RESOURCE_DATA_ENTRY结构中,这个结构将指引我们找到资源数据。下面这个结构体描述的就是此结构。
上面的结构说明如下。
1)OffsetToData:此字段保存着指向资源数据RAV的指针。
2)Size:资源数据的体积。
3)CodePage:此资源的代码页信息。
4)Reserved:保留字段,恒为0x00000000。
2、识别资源
下面是从PEDemo.dll中Dump出来的资源结构,其中用方框括起来的内容都是IMAGE_RESOURCE_DIRECTORY结构,单下划线标注的内容是属于第一层目录结构中的IMAGE_RESOURCE_DIRECTORY_ENTRY结构,与此同理,双下划线与加重下划线则分别属于第二层、第三层目录结构中的IMAGE_RESOURCE_DIRECTORY_ENTRY结构。
通过以上信息,我们可以总结出位于目录结构第一层的数据情况,如图7所示。
图7 资源目录中位于第一层目录的数据
由图7可以知道这个目录内共有两项内容,一个为描述字符串列表资源的二级目录,另一个是描述XML信息资源的二级目录。
而通过两个OffsetToDirectory的相对偏移可以分别得到两个二级目录的起始地址:
0x00000020+0x00001600=0x00001620
0x00000038+0x00001600=0x00001638
由以上两个偏移查看Dump出的数据可得到二级目录的关键信息,如表11所示。
出于篇幅考虑,以上表格并未采用十六进制偏移的模式描述,且为了避免重复,表中也未包含两个二级目录的IMAGE_RESOURCE_DIRECTORY结构信息,后面遇到相似情况将不再重复说明。
从表11仍然可以得到足够的信息,首先可以知道两个资源的ID号分别为7和2;其次通过备注及二级目录下的条目个数可以发现每个二级目录中仅有一条信息,且由两个OffsetToDirectory的高位可知,这两个目录下分别有一个子目录(三级目录),同时还可分别得到两个子目录的偏移地址:
0x00000050+0x00001600=0x00001650
0x00000068+0x00001600=0x00001668
由以上两个偏移查看Dump出的数据,可得到三级目录的关键信息,如表12所示。
字段Name里保存的内容是语言区域代码,无需关注。
通过OffsetToDirectory的高位可知这已经是目录的最底层了,下面就是指向资源数据的IMAGE_RESOURCE_DATA_ENTRY结构了。通过OffsetToDirectory我们可以分别得出两个IMAGE_RESOURCE_DATA_ENTRY结构的偏移:
0x00000080+0x00001600=0x00001680
0x00000090+0x00001600=0x00001690
由以上两个偏移查看Dump出的数据,可得到IMAGE_RESOURCE_DATA_ENTRY结构的信息,如图8所示。
图8 IMAGE_RESOURCE_DATA_ENTRY结构信息
到此,我们已经找出PEDemo.dll中两个资源的所有信息,图9粗略地描述了资源结构。
图9 资源结构
七、异常
PE文件中的异常目录用于描述异常处理相关的异常处理函数、SHE相关的地址等信息,这些信息通常位于名为.pdata的区段中,数据目录中IMAGE_DIRECTORY_ENTRY_EXCEPTION项指向此结构。
异常结构根据平台的不同而有细微的差别,就目前来讲,PE文件中的异常目录只有在x64、ARM、ARMv7、PowerPC与MIPS平台下才会发挥作用。
由于这其中属x64平台应用得最为普及,所以下面将以x64平台为基础讲解异常目录的结构。x64平台中异常目录的数据结构如下:
上述结构说明如下。
1)BeginAddress:此值表示与SEH相关(受SEH影响)代码的起始偏移地址。
2)EndAddress:此值表示与SEH相关(受SEH影响)代码的末尾偏移地址。
3)UnwindInfoAddress:指向描述位于字段BeginAddress与EndAddress之间的代码异常属性信息的UNWIND_INFO结构。
UNWIND_INFO结构称为展开处理程序(unwind handlers)结构,此结构用来描述堆栈指针的记录属性与寄存器中保存的地址属性,其结构如下:
上述结构说明如下。
1)Version:此展开数据的版本号,一般情况下为1。
2)Flags:指定展开数据的标识位,表13为我收集的一些标识值。
3)SizeOfProlog:函数起始部分字节的长度。
4)CountOfCodes:展开代码数组的成员数,有些展开代码需要占用数个数组成员的位置。
5)FrameRegister:如果不为0,则此字段为寄存器帧指针;如果为0,则指定的函数不使用框架指针(此帧指针由UNWIND_CODE结构中的OpInfo成员来描述)。
6)FrameOffset:如果FrameRegister字段不为0,则由偏移处数据取出RSP设置FP寄存器。
7)UnwindCode:指定永久性寄存器与RSP的数组项目数。
8)ExceptionHandler:异常/终止处理程序函数的映像相对地址指针。
9)FunctionEntry:展开信息链(函数)的映像相对地址指针(如果设置了UNW_FLAG_CHAININFO标识)。
10)ExceptionData:异常处理程序的数据。
八、安全
数据目录的IMAGE_DIRECTORY_ENTRY_SECURITY项指向此结构,此结构被称为安全目录或属性证书目录,这个目录里一般保存着此映像文件的数字签名,以证实此映像文件的可信程度。
由这个结构保存的数字签名证书可以是任何类型的,但是这些证书都会保存该PE文件的映像散列值。这个散列值类似于文件的校验和,只不过是使用散列算法生成的一个记录PE文件完整性的相关信息摘要。如果签名文件被修改,这个摘要就会被破坏,因此这个机制极大地保证了文件的完整性与正确性,由此PE文件中保存这个数据结构的目录也被称为安全目录。
1、安全目录结构
这个数据结构在Windows SDK里的wintrust.h中被定义为WIN_CERTIFICATE类型结构体,具体内容如下:
上述结构说明如下。
1)dwLength:就微软官方提供的信息来看,此字段保存的是不定长数组bCertificate的大小,但就我实际试验的结果来看,此字段保存的是整个WIN_CERTIFICATE结构体的大小。
2)wRevision:在bCertificate字段里保存证书的版本号,这个版本号只有两个类型,在Windows SDK中的定义如表14所示。
3)wCertificateType:指定bCertificate中的内容类型,根据内容的不同,Windows SDK对其做了如表15所示的定义。
4)bCertificate:包含一盒证书(里面有一个或多个证书)。
正如第四个字段bCertificate所描述的那样,安全数据目录中的证书可能并非只有一个,这主要取决于第一个证书的大小与安全数据目录中Size的值,如果安全目录的大小大于第一个证书,那么这个映像势必存在第二个证书;如果这两个证书的体积之和仍小于安全目录的大小,那么就存在第三个证书。依此循环遍历,直到这些证书体积之和大于等于安全数据目录的大小为止。
当然,除此之外也可以通过循环调用Windows的API函数ImageEnumerateCertificates来达到相同目的。
2、识别安全结构
安全结构(属性证书结构)一般广泛存在于各种常用的应用程序中,比如我们常用的聊天工具与防病毒产品的主程序都需要此结构,以便对其安全性进行支持。
如图10所示就是一个典型的使用了PE文件中安全数据目录的应用程序。
图10 使用了数字签名的应用程序
根据此程序数据目录表中安全项的描述,可提取出以下十六进制文本信息:
上面标有3种不同下划线的数据就是WIN_CERTIFICATE结构的头3个字段,紧随其后直到0x21570位置处都是最后一个bCertificate字段的内容,图11形象地描述以上数据。
图11 WIN_CERTIFICATE数据结构
根据Windows提供的宏信息与图11的描述可以得知,这是一个大小为0x1578、版本为2且类型为PKCS#7的属性证书,而PKCS#7正是我们所熟知的数字签名,如图12所示。
图12 某著名即时通讯软件的数字证书
九、基址重定位
数据目录的IMAGE_DIRECTORY_ENTRY_BASERELOC项指向此结构,由于在Windows系统中DLL(动态链接库)文件并不是每次都能加载到预设的基址(ImageBase)上,因此基址重定位主要应用于DLL文件中。
假如程序的默认基址为0x00400000,那么如果想将此程序偏移为0x100的数据地址压入堆栈的话,可能会用到如下汇编代码:
由于EXE文件能保证每次加载的基址均为0x00400000,因此这条汇编指令执行起来是没有任何问题的,但是如果这条指令位于DLL文件中的话就没有这么简单了,微软已经告诉了我们,DLL文件并不能保证每次都加载到预设基址上,因此当这个DLL程序加载到0x00500000的位置上时,就必须使用0x00500100才能访问到正确的数据,如果仍试图用0x00400100取数据的话显然是错误的,而基址重定位表就是为了避免发生这个错误而设计的。
1、基址重定位表结构
一般情况下重定位表位于一个名为.reloc的区块内,PE对于重定位的定义非常简单,它无需参考外部信息或模块中的其他节,只需简单地将文件中所有需要重定位的地址放在一个数组里即可,如果此映像未能在预设的基址上载入,那么加载器就会简单地将数组中的重定位信息逐一修改。
PE文件中的重定位结构是由多个IMAGE_BASE_RELOCATION子结构组成的,每个子结构只负责描述一个4KB大小的分页内重定位信息,换句话说也就是PE文件中需要重定位的部分每隔0x1000字节大小的区域就要有一个IMAGE_BASE_RELOCATION结构与之对应,因此在这个结构中用于描述需重定位数据起始RAV的VirtualAddress字段,其值总是为0x1000的倍数。
重定位的本质非常简单,就是比较实际加载地址与ImageBase的值,如果相等则不需要做任何操作,如果不相等就用其差值加上需要重定位的地址数据。因此PE文件中的重定位结构只负责索引出需要重定位的地址信息,并不包含具体重定位过程中可能需要的其他任何数据。以下是基址重定位表的结构:
上述结构说明如下。
1)VirtualAddress:指向PE文件中需要重定位数据的RAV,由于每个重定位结构体只负责描述0x1000字节大小区域的重定位信息,因此这个字段的值总是0x1000的倍数。
2)SizeOfBlock:描述IMAGE_BASE_RELOCATION结构体与重定位数组TypeOffset的体积总大小(IMAGE_SIZEOF_BASE_RELOCATION+2*n)。
3)TypeOffset:一个不定长的WORD型数组,按照Windows SDK中的描述来推断,其本身并不属于IMAGE_BASE_RELOCATION结构体。此数组负责与IMAGE_BASE_RELOCATION结构体配合描述需要进行重定位的数据的具体偏移,其结构如下。
上述结构说明如下。
Type:重定位信息类型值,具体类型如表16所示。
Offset:记录位于VirtualAddress字段所指分页中的需要重定位的地址。这个TypeOffset的2字节结构可以非常高效地引导我们在制定分页内找到需要重定位的数据。由于在x86平台上最常用的索引类型为IMAGE_REL_BASED_HIGHLOW,因此后面的分析例子也将以此为基础。
2、识别基址重定位表
以PEDemo.dll为例,通过数据目录表的IMAGE_DIRECTORY_ENTRY_BASERELOC项可得知其起始RAV为0x00005000,大小为0x0000013C。以下为我提取出来的一些关键信息:
在上面的十六进制信息中可发现两个IMAGE_BASE_RELOCATION结构(见有下划线的相关内容)。前面讲过,重定位结构的总大小为0x0000013C,而通过位于0x00001A04处的信息可知第一个重定位块的大小为0x00000118,其第一个重定位块的体积远小于整个重定位表的体积,因此可粗略判定是存在第二个重定位块的。图13是对以上十六进制信息的解读。
图13 PEDemo.dll的部分重定位表信息
由SizeOfBlock的值与第一个重定位块的结束地址0x00001B18可知,SizeOfBlock字段保存的是包括IMAGE_BASE_RELOCATION结构与TypeOffset在内的整个重定位块的大小,由此可以总结出如下公式:
重定位项数目=(SizeOfBlock-IMAGE_SIZEOF_BASE_RELOCATION)/2
通过这个公式我们可以分别计算出这两个重定位区块的重定位项的数量:
0x88=(0x00000118-0x8)/0x2
0x0E=(0x00000024-0x8)/0x2
在计算出每个重定位块项的具体数量后,随后要逐个计算需要重定位的地址,我们以偏移为0x00001A08处的第一个重定位项为例,按照前4位表示类型,后12位表示相对偏移的方式将其分解,会得到如下结果:
TypeOffset=0x3006
Type=0x3
Offset=0x006
由本重定位块的基址VirtualAddress可知,这是一个负责RVA在0x1000~0x2000之间的重定位块,同时Offset字段又告诉了我们此项描述的需要重定位的信息位于VirtualAddress+0x006的位置,由此很容易得到需要重定位数据的RAV如下:
∵VirtualAddress=0x1000
重定位的RAV=VirtualAddress+0x006
∴重定位的RAV=0x1000+0x006=0x1006
由此我们可推出需要进行重定位数据的RAV为0x00001006,此时通过计算选项头里ImageBase字段中保存的首选基地址与实际装入地址的差值来确定是否需要重定位,如果两者差值为0(既实际装入地址就是ImageBase中的首选装入地址),那么无需进行重定位,否则就要用指定数据加上这个计算出的差值来进行重定位。
下面我们再次利用这个重定位信息与相关反汇编代码进行一次示例分析。需重定位的反汇编代码如下:
由于此文件的装载基址为0x10000000,而且上面已经得到了需要重定位数据的RAV为0x00001006,因此可算出需要重定位的内存地址是0x10001006。由以上代码可知0x10001005处的0x68是汇编指令"push xxx"的OpCode,那么后面跟着的4个字节就是需要重定位的地址信息了。
现在假如这个程序并没有加载到首选加载地址中,而是加载到了0x72AB0000处,很显然这时就要对其进行重定位了。
我们首先计算出它们的差值为0x62AB0000,然后再将其加上原地址即可得到重定位后的地址,由此可推出以下公式:
重定位后的地址=(加载基址-ImageBase)+重定位前的地址
由以上公式可计算出重定位后的地址为:
0x72AB2210=(0x72AB0000-0x10000000)+0x10002210
重定位后的代码如下所示:
图14更加清晰地描述了基址重定位的逻辑。
图14 基址重定位表结构
(未完待续)
微信公众号:计算机与网络安全
ID:Computer-network