PE文件中的结构众多,但是其实,这些结构一多半是告诉loader怎么去加载自身PE的。正常的PE文件总是很严格去填充自身的内部结构,但是也有一小部分变形PE文件没有那么的中规中矩,所以PE结构中有的信息到了loader可能最终也只是起到了一个校验作用。在纵观PE整个大结构的时候,不应该去拘泥于每一个数据结构的特征,应该从大体上去把握它。本文试图通过loader加载方式的类比和举例的角度,介绍了loader是如何讲PE结构中相关重要数据进行加载的。因为介绍PE的优秀文章有很多加上作者水平很有限,如果有错误,请各位指正。
下面,就开始逐一的介绍相关重要的结构:
1.各种表头
1.1 Dos表头
1.2 pe文件头
1.3 区块表头
2.导入表
3.导出表
4.重定位表
5.资源
6.附加数据
1.表头
一共包含了有三个类型的表头,一个是Dos表头,目的是为了兼容以前的平台。一个是pe文件头,包含了pe自身加载的各个配置信息,还有一个就是区块表头,包含pe文件各个区块的信息。整个表头,就给loader提供了一个这样的信息:“这个PE是什么属性,整个PE如何在内存分布的,又会被加载到哪里去“。
1.1 Dos表头
为了兼容以前的DOS平台,MS在设计的PE格式的时候考虑到了MS-DOS头部。其中有一个结构是MZ值,也就是相对偏移00处。在壳程序判断是否是PE文件的时候,这个是条件之一。另一个条件就是距离偏移0X3Ch处。这里保存了一个地址,这个地址指向了(PE header)PE文件头。这个才是装载器开始装载真正意义上的PE开始的地方。在相对于PE header偏移0h的地方,保存着一个“PE”标识。
根据上面两个信息,我们就可以判断被加壳的文件是否为PE文件了。
1.2 pe文件头
PE标识的下面一个偏移,就是FILE_HEADER了,它被称之为PE头,其中相对FILE_HEADER偏移0x06h的NumberOfSections表示的是文件的块数目。0x14h偏移处的SizeOfOptionalHeader表示的是可选头的大小,这个值可以用来定位节表可选头OptionalHeader的大小。
PE头0x18h偏移处就是OptionalHeader了。OptionalHeader是PE中相对重要的结构。虽然译做可选头,但是里面的很多选项都是必选的。
其中以下的结构是比较重要的:
AddressOfEntryPoint
指出文件载入内存后,代码开始执行的地方,也就是我们常说的OEP。例如我们对文件进行加壳之后,原来的入口点保存后,修改其中的值,指向外壳代码,使加壳的后PE能够先进行解压,再跳到原始的入口点继续运行。
ImageBase
指出文件的优先装入地址。Loader优先将文件装入到由ImageBase字段指定的地址中,若指定地址装载失效,才被装入到其他地址中。
EXE文件通常即指定,即装载,所以EXE总是能够按照这个地址装入。因为ms为每一个exe文件设定了单独的4GB进程空间,对于装载来说,exe的地位是优先的,所以总能够保证其装载ImageBase成功。
而DLL文件来说,不能保证优先装入地址没有被其他的DLL使用,因为一个exe可以装载不同的dll到其地址空间去,所以DLL通常含有重定位信息来进行二次定位,即该地址若被占用,调用重定位表的偏移,重新定位。
SectionAlignment 和FileAlignment
这里即是内存对齐粒度和文件对齐粒度,参见第一篇的阐述。
DataDirectory
数据表它由16个相同的IMAGE_DATA_DIRECTORY结构组成,因为PE文件中有很多的表块,例如导入表,导出表,重定位表等等,这样就需要有一个结构,保存着这些表的相关信息,来告诉Loader去加载哪里并且如何加载这些表。IMAGE_DATA_DIRECTORY结构指出了某种数据表的位置和长度。
1.3 区块表头
紧跟在IMAGE_NT_HEADERS后面的是区块表。在未载入这个块表信息之前Loader只知道根据前面提供的NumberOfSections,知道有多少个块,可是块的大小,块的各个偏移和名称却不知道,所以有一个结构来告诉Loader。这样loader就可以按照载入的规则将所以的块信息放入内存了。
注意:在进行区块装载的时候,有可能被装载的PE是个变形文件,所以,当其结构提供的数据代码和loader装载规律不一致的时候,就要按照loader对齐粒度来进行装载了。
那么如何读取了,首先要定位到各个头的起始位置(参见前面介绍的判断是否是PE文件中的方法定位),因为偏移是固定的,只需移动的固定的偏移读取即可。
至此,头表的重要结构都介绍完毕了,我们发现,这些结构都是按照固定偏移寻址读取的,告诉loader怎么去装载PE文件的,从宏观上,大致的表述了一个PE文件粗枝大叶的部分。可以说,这就如同之前所形容的那样,loader只是将这些作为数据来对待,进行读取,然后和自身的装载规则作比较。
下面就来介绍PE中比较重要的几个区块:
2.导入表
Exe要用到外部DLL提供函数,在装载的时候,loader需要将各个函数的地址分配过去。在生成exe的时候,连接器为了统一一种形式将所有的调用函数统一成call [xxxxxxxx]的形式,而这个地址一开始会被PE文件预留,只有当要被加载的时候,loader进行填充真正的函数地址到xxxxxxxx地址处。
例如某一处的代码:
代码:
00401000 CALL [00404000]
00404000这个地方在文件自身未被加载时候,会被预留(通常会填充和OriginalFirstThunk一样值),直到被loader装载的时候,才会写出正在的要调用的函数地址。
每一个DLL都对应一个IMAGE_IMPORT_DESCRIPTOR,其重要结构如下:
代码:
Dword OriginalFirstThunk //输入名称表结构IMAGE_THUNK_DATA ->指向IMAGE_IMPORT_BY_NAME
Dword TimeDateStamp
Dword ForwarderChain
Dword Name1 //DLL名字指针
Dword FirstThunk //输入地址表结构IMAGE_THUNK_DATA ->指向IMAGE_IMPORT_BY_NAME
一共是三个结构,IMAGE_THUNK_DATA其实就是一个Dword大小的联合结构,当最高值为1,低31位就是表示的一个函数序号,当最高值为0,就表示一个指向IMAGE_IMPORT_BY_NAME的RVA。而IMAGE_IMPORT_BY_NAME表示的是一个带Hint值(可以理解为序号值)和带一个可变大小的byte结构,指向输入函数的函数名。这就好像我们上学报到的时候,除开自己的名字以外,我们还会被赋予一个属于自己的学号。
对照着图,就可以很清晰的看到了,在Loader未载入PE之前,都是IMAGE_THUNK_DATA结构的OriginalFirstThunk其实和FirstThunk指向的是同一个结构IMAGE_IMPORT_BY_NAME,loader通过它们的RVA指引定位到IMAGE_IMPORT_BY_NAME,从而知道了输入函数名称,一切就是这么简单:)
当Loader载入PE之后,OriginalFirstThunk依旧是指向了IMAGE_IMPORT_BY_NAME,但是FirstThunk此时被指向了之前指向输入函数在系统的真实地址。
一个EXE有很多的DLL,Loader要装载这么多的数据,那么它又是怎么判断一个结构的结束和另一个结构的开始呢?其实,在c语言表示的字符串中,我们知道,一个字符串的结束,使用‘0’来表示的,同样,一个结构的结束,也可以用同结构大小一样的0来表示结束:)。
我们可以这样来理解导入表的工作。假设学生组织考试(对应装载PE文件),在给学生要排位置,我们通过学生的学号或者姓名,来给出座位号(通过OriginalFirstThunk 以及FirstThunk 寻找到MAGE_IMPORT_BY_NAME)。当学生坐到位置上考试的时候,我们才开始派发试卷,让学生考试(载入PE文件的时候,修改FirstThunk指向的数据。)。这样,排座位给座位号只是考试的一个手段,最终的目的是为了让学生考试(对应装载PE文件)。
3.导出表
Dll的一个重要功能就是函数封装,那么告知内部函数给loader的这个任务,就交给了导出表。作为导出表,要告诉loader自己提供了什么样的函数,一个是告知自己的导出序号,一个就是告知函数名。同样就好像老师在点名,可以点学号,也可以点名字。
导出表的重要结构如下:
代码:
Name DLL名称
Base 基数,加上序数就是函数地址的索引值了。
NumberOfFunctions DLL导出的函数总数。
NumberOfNames 通过名字引出的函数数目
AddressOfFunctions 所有函数的RVA地址
AddressOfNames 导出函数名地址
AddressOfNameOrdinals 通过名字导出序号数的地址
我们可以这么去理解一个导出表是如何进行工作的。假设是在课堂上,老师要点名,首先要知道的就是这个班(对应总DLL_Name)学生的总人数(对应NumberOfFunctions)。再一个,老师要知道第一个学生的学号是多少(对应Base),这样,只要依此往下递增,即可点出所有的学生来(对应AddressOfFunctions)。有的学生中途退学,那么名字就会被注销,可是为了保证学号的唯一和一致性,学号会保留,他后面的同学学号通常是不会变的。有的学生勤奋好学(对应NumberOfNames),上课积极回答问题,给老师留下很深的印象,老师和他很熟,可以直接喊出他的名字。有的不熟悉,就只能去点学号(对应NumberOfFunctions - NumberOfNames)。老师点名的时候只要保证学号不越界,(即保证所点的学号都在这个班级内)那么可以不按照顺序来点(AddressOfNameOrdinals的顺序不是按照AddressOfFunctions排列顺序来的,内部排序号不同)。我们要明确的一点:如果有确定的导出函数,那么导出函数一定至少是有序号的,名字是可选的。
导出表函数扫面的算法表现:序号(AddressOfFunctions的顺序+ Base)+ 名字(若序号存在对应的AddressOfNames的序号中)+地址(AddressOfFunctions)
关于导出函数,有两点要值得注意:
1.AddressOfNameOrdinals在AddressOfFunctions和AddressOfNames之间起到了纽带作用。因为AddressOfNames的顺序对应AddressOfFunctions的顺序,这个顺序在AddressOfNameOrdinals取值,其值+Base得出导出函数序号。可见,AddressOfNameOrdinals是AddressOfFunctions和AddressOfNames的寻址根据,也是导入表内部排序的索引号,也是通过这个值来求出序号的。
2.若某一个导出函数是按序号导出,序号的计算方法是AddressOfFunctions的顺序+ Base,其结果带入到AddressOfNameOrdinals后,是不会有对应的序号。另外若导出函数的AddressOfFunctions指向的值为0,则表示该序号的导出函数是不存在的。
4.重定位表
当某个dll要加载的时候,预计加载地址已被占用,此时重定位表会告诉loade一系列的数据,loade会根据所给的数据对加载地址进行修正。这就是重定位表的任务。
例如在DLL中有如下指令:
0010720B A1 00104000 mov eax,dword ptr ds:[0x0040100]
试图将0x401000指向的数据拷贝到eax中,可是当DLL真正加载的时候,loader发现这时候指针被移到了0x0050100h的地方,有0x100000h的偏差,怎么办呢?没错,loader这个时候就会根据重定位表提供的信息修改代码为:
0010720B A1 00105000 mov eax,dword ptr ds:[0x0050100]
很明显,试图加载地址在PE可选头已经设定好,实际加载地址在loader载入动态获取,唯一需要告知loader的就是需要重定位代码的位置了。
重定位表应该在PE格式中是比较好理解的了,它的结构就是告诉loader怎么找到自己,然后给出一系列的数据(其实就是要重定位代码的地址),经过某种一致的方式来修正成新的加载地址。就如同你去参加图书馆还书时(要加载DLL),发现二楼还书要排长队(加载地址被占用需要重定位),这时候你查了一下书的登记序列号搜索其他可以还书地点(搜寻重定位代码位置,选择其他加载地址),发现四楼也可以还这本书且不要排队,于是你就跑到四楼把书还了(加载更新后的代码)。
重定位表的结构比较的简单,如下所示:
代码:
Dword VirtualAddress //重定位的起始地址
Dword SizeOfBlock //当前重定位结构大小
Word TypeOffset //数组结构,高4位表示重定位类似(在X86系统中,该值一直是IMAGE_REL_BASED_HIGHLOW),低12位表示重定位地址,其值+VirtualAddress可以定位到要修改地址。
整体的结构如下图所示:
当一个重定位表项结束的时候,会以一个类型IMAGE_REL_BASED_ABSOLUTE的重定位结束,这个重定位什么都不做,只用于填充,以便下一个重定位标项结构是以四字节分界线来对其。所有的重定位结构以VirtualAddress为0的重定位表结束。
5.资源段
资源可以说是PE中,结构相对来说比较复杂,而且种类也比较复杂。windows中的各种界面,包括声音,图像,甚至是一段代码stub,都可以作为资源来保存。
资源分为了16个类型,每一个类型下面又有不同的项目名称,每一个项目都会包含不一样的数据,那么资源表就是依据资源这样归类的属性,来安排其结构的。如,常见的资源树:
一个PE的资源表通常至多是包含三层的,第一层一般是表示资源的类型,第二层表示同一资源类型下的各个资源项,第三层表示每一个资源项属性,这其中有三个重要的数据结构来分别的实现这些功能:
1. IMAGE_RESOURCE_DIRECTORY
只需要关心其中的最后两项结构:NumberOfNamedEntries和NumberOfIdEntries,前者是表示使用名字的资源数目个数,后者表示使用ID数字资源条目的个数,加起来就是资源目录中,目录项的总和。
2. IMAGE_RESOURCE_DIRECTORY_ENTRY
紧随IMAGE_RESOURCE_DIRECTORY其后就是该结构,包含两个成员:name和OffsetToData,前者指向目录项名称或者ID号,后者表示资源数据或者子目录的偏移的地址。根据所在目录位置的不同有不同的含义
name
1.位于第一层目录的时候,表示资源的类型
2.位于第二层目录的时候,表示资源ID或者是名字
3.位于第三次目录的时候,当最高位为0,表示其值作ID,当最高位为1,低位作指针,指向一个unicode编码结构,该结构表示一个以unicode编码的字符串。
offset
这个就是一个之中,它本身的最高位来决定自身的意义
1.如果最高位为1,低位数据指向下一层IMAGE_RESOURCE_DIRECTORY地址
2.如果最高位为0,则指向IMAGE_RESOURCE_DATA_ENTRY结构
值得注意的是,name和offset做指针的时候,都是从资源区段开始的地方算,而不是之前文件头开始算起。
3. IMAGE_RESOURCE_DATA_ENTRY
这个结构其实就是定位到资源数据最关键的结构,不管前面的目录有多少层,laoder遍历前面的结构,做的所有的前期准备都是为了找到资源的地址。该结构有四个成员:
代码:
OffsetToData //资源数据的RVA
Size //资源数据的长度
CodePage //代码页,通常置0
Reserved //预留值段
其中,RESOURCE DATA的OffsetToData和Size就告诉loader从哪里去加载和加载的大小。
一旦loader定位到了资源段开始的时候,剩下的结构,只需要按照事先定义的格式,按部就班的来了。举个例子,0XE17H这个字段,数据是0x80h,其实就是告诉loader,第二层还有一个IMAGE_RESOURCE_DIRECTORY的目录,loader继续遍历,接着0XE2Fh也告诉loader,第三层还有一个IMAGE_RESOURCE_DIRECTORY的目录,loader继续遍历,直到0xE48h,读取到了RESOURCE_DATA结构,这才找到真正资源的位置。loader就可以将资源进行加载了。
6.附加数据
附加,顾名思义,就是附属上去的数据,为什么有一个这样的称呼?因为,loader判断一个PE文件的大小(即需要载入的文件大小),为该文件最有一个节磁盘的磁盘偏移+最后一个节的文件大小。那么,超出这个大小的数据,是不被Loader载入的,即不会被映射到内存空间去。就好像你考试时候的附加分一样,虽然有,但是不会计入总分:)。附加数据可以包含很多东西,例如程序的特殊的数据调用和校验,运行时候的配置信息。有的病毒就是将正常感染的程序放在附加数据等等。
处理附加数据比较的简单,只需定位到最后一个节末尾,附加的大小等于取文件大小减去节末尾偏移。
举一个例子:
我们看到,最后一个节偏移+大小是0x1000h,也就是说,最后一个节末尾偏移应该是0x9ffh,之后的数据就成为了附加数据,不会被loader载入。
熟练的掌握pe结构是操作pe文件的基础,上文介绍了PE文件中最基本常用的结构,希望通过这篇文章的介绍,能能对初学者有一定的帮助,一起共同的进步:)但水平有限,还请大家多多指正。