PE文件结构

简介

PE(Portable Execute)文件是Windows下可执行文件的总称,常见的有DLL,EXE,OCX,SYS等,事实上,一个文件是否是PE文件与其扩展名无关,PE文件可以是任何扩展名。

那Windows是怎么区分可执行文件和非可执行文件的呢?我们调用LoadLibrary传递了一个文件名,系统是如何判断这个文件是一个合法的动态库呢?这就涉及到PE文件结构了。

PE文件分为5个部分

  • DOS文件头
  • DOS加载模块
  • NT文件头
  • 区段表
  • 区段

当一个PE文件被加载到内存中以后,我们称之为“映象”(image),一般来说,PE文件在硬盘上和在内存里是不完全一样的,被加载到内存以后其占用的虚拟地址空间要比在硬盘上占用的空间大一些,这是因为各个节在硬盘上是连续的,而在内存中是按页对齐的,所以加载到内存以后节之间会出现一些“空洞”。所以表示某个位置的地址采用了2种方式:一个是物理地址,另一个是RVA

在内存中PE文件中显示的地址全部都是RVA
虚拟地址(VA):每个32位PE文件被加载到内存中会分配4GB的虚拟内存,在PE用语里,实际的内存地址被称作虚拟地址(Virtual Address )简称VA
相对虚拟地址(RVA):是当PE文件被展开时相对于文件头的地址

公式: 虚拟地址 VA=装入地址(Imagebase)+相对虚拟地址(RVA)
程序入口点 (EOP):程序开始执行的地方

我们都知道PE文件可以导出函数让其他的PE文件使用,也可以从其他PE文件导入函数,这些是如何做到的?PE文件通过导出表指明自己导出那些函数,通过导入表指明需要从哪些模块导入哪些函数。


DOS头

DOS头的作用:兼容MS-DOS操作系统中的可执行文件,对于32位PE文件来说,DOS所起的作用就是显示一行文字,提示用户:我需要在32位windows上才可以运行。

DOS头的特征:我们需要关心组成DOS头的两个域

  1. e_magic:一个WORD类型,值是一个常数0x4D5A,用文本编辑器查看该值位‘MZ’,可执行文件必须都是’MZ’开头。如下图所示
  2. e_lfanew:为32位可执行文件扩展的域,用来表示DOS头之后的NT头相对文件起始地址的偏移。这里面比较有用的是最后4个字节(DOS头一共为64字节),表示PE头的RVA,详情如下图。
    在这里插入图片描述

如果是合法的PE文件,则NT文件头的开始数据一定为5045。如上图所示DOS头的最后四个字节代表NT文件头的RVA(000000F0)——倒着读是因为小端存储的原因,跟CPU框架有关。

这边简单说一下小端存储,具体用法就是,如果你想写在程序中的数据为 1 2 3 4,那么你必须先传4 再传3 直到1,这样子计算机读取出来的数据才是1234,如上图,我们必须传入 f0 00 00 00 ,计算机读出来的数据才能是000000f0。

DOS头中还有其他数据,但是我们一般用不到


NT头

windows环境中会直接跳过前两个部分直接从NT头开始。NT文件头大小为224字节左右,因为有两个可选头,所以长度不确定。里面包含PE文件的整体信息,这些信息主要是描述PE文件的大概情况,更细节的信息在区段表中。
在这里插入图片描述下图是一张真实的NT文件头结构和数据取值(作为对比学习):
在这里插入图片描述
如上图所示,NT头分为3个部分

Signature

类似于DOS头中的e_magic,其高16位是0,低16是0x4550,用字符表示是’PE‘(ASCLL码:PE00)。

IMAGE_FILE_HEADER

structIMAGE_FILE_HEADER
{
WORD Machine;//运行平台
WORD NumberOfSections;//区块表的个数
DWORD TimeDataStamp;//文件创建时间,是从1970年至今的秒数
DWORD PointerToSymbolicTable;//指向符号表的指针
DWORD NumberOfSymbols;//符号表的数目
WORD SizeOfOptionalHeader;//IMAGE_NT_HEADERS结构中OptionHeader成员的大小,对于win32平台这个值通常是0x00e0
WORD Characteristics;//文件的属性值
}

IMAGE_OPTIONAL_HEADER

typedefstruct_IMAGE_OPTIONAL_HEADER{
+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;

这里大多数选项都很重要,例如数据段代码段入口RVA,或者说内存中区块对齐大小与文件中区块对齐大小imagebase等等。下面给出几个建议熟悉的选项:

入口点EOP #一般跟base of code值一样
基地址Imagebase #一般为00400000
SectionAlignment #一般为 0x1000
fileAligment #一般为0x0200

重点讲一下数据目录表,数据目录表保存了各种表的RVA及大小。
来看一下的定义:

IMAGE_DATA_DIRECTORY STRUCT{
VirtualAddress DWORD ? ;// 数据的起始RVA
Size DWORD ? ;// 数据块的长度
IMAGE_DATA_DIRECTORY ENDS
}IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;

数据目录表十分重要,例如下面的前两项,就可以到具体的位置去看程序从dll文件中导入了什么函数。
在这里插入图片描述

PE导出表

可执行文件头 的结尾出现了一个大数组,这个数组中的每一项都是一个特定的结构,通过函数获取数组中的项可以用RtlImageDirectoryEntryToData函数,DataDirectory中的每一项都可以用这个函数获取,函数原型如下:

PVOID NTAPI RtlImageDirectoryEntryToData
(PVOID Base, BOOLEAN MappedAsImage, USHORT Directory, PULONG Size);

Base:模块基地址。
MappedAsImage:是否映射为映象。
Directory:数据目录项的索引。
Size:对应数据目录项的大小,比如Directory为0,则表示导出表的大小。

返回值表示数据目录项的起始地址。

导出表是用来描述模块中的导出函数的结构,如果一个模块导出了函数,那么这个函数会被记录在导出表中,这样通过GetProcAddress函数就能动态获取到函数的地址。函数导出的方式有两种,一种是按名字导出,一种是按序号导出。这两种导出方式在导出表中的描述方式也不相同。模块的导出函数可以通过Dependency walker工具来查看:
在这里插入图片描述上图中红框位置显示的就是模块的导出函数,有时候显示的导出函数名字中有一些符号,像 ??0CP2PDownloadUIInterface@@QAE@ABV0@@Z,这种是导出了C++的函数名,编译器将名字进行了修饰。

导出表结构:

typedef struct _IMAGE_EXPORT_DIRECTORY {
DWORD Characteristics;//现在没有用到,一般为0。
DWORD TimeDateStamp;//导出表生成的时间戳,由连接器生成。
WORD MajorVersion;//看名字是版本,实际貌似没有用,都是0。
WORD MinorVersion;
DWORD Name;//模块的名字。
DWORD Base;//序号的基数,按序号导出函数的序号值从Base开始递增。
DWORD NumberOfFunctions;//所有导出函数的数量。
DWORD NumberOfNames;//按名字导出函数的数量。
DWORD AddressOfFunctions; // 一个RVA,指向一个DWORD数组,数组中的每一项是一个导出函数的RVA,顺序与导出序号相同。
DWORD AddressOfNames; // 一个RVA,依然指向一个DWORD数组,数组中的每一项仍然是一个RVA,指向一个表示函数名字。
DWORD AddressOfNameOrdinals; // 一个RVA,还是指向一个WORD数组,数组中的每一项与AddressOfNames中的每一项对应,表示该名字的函数在AddressOfFunctions中的序号。
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;

在这里插入图片描述在上图中,AddressOfNames指向一个数组,数组里保存着一组RVA,每个RVA指向一个字符串,这个字符串即导出的函数名,与这个函数名对应的是AddressOfNameOrdinals中的对应项。获取导出函数地址时,先在AddressOfNames中找到对应的名字,比如Func2,他在AddressOfNames中是第二项,然后从AddressOfNameOrdinals中取出第二项的值,这里是2,表示函数入口保存在AddressOfFunctions这个数组中下标为2的项里,即第三项,取出其中的值,加上模块基地址便是导出函数的地址。如果函数是以序号导出的,那么查找的时候直接用序号减去Base,得到的值就是函数在AddressOfFunctions中的下标。


PE导入表

IMAGE_DIRECTORY_ENTRY_IMPORT,即导入表。在IMAGE_DATA_DIRECTORY中,有几项的名字都和导入表有关系,其中包括

  1. IMAGE_DIRECTORY_ENTRY_IMPORT就是我们通常所知道的导入表,在PE文件加载时,会根据这个表里的内容加载依赖的DLL,并填充所需函数的地址。
  2. IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT叫做绑定导入表,在第一种导入表导入地址的修正是在PE加载时完成,如果一个PE文件导入的DLL或者函数多那么加载起来就会略显的慢一些,所以出现了绑定导入,在加载以前就修正了导入表,这样就会快一些。
  3. IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT叫做延迟导入表,一个PE文件也许提供了很多功能,也导入了很多其他DLL,但是并非每次加载都会用到它提供的所有功能,也不一定会用到它需要导入的所有DLL,因此延迟导入就出现了,只有在一个PE文件真正用到需要的DLL,这个DLL才会被加载,甚至于只有真正使用某个导入函数,这个函数地址才会被修正。
  4. IMAGE_DIRECTORY_ENTRY_IAT是导入地址表,前面的三个表其实是导入函数的描述,真正的函数地址是被填充在导入地址表中的。

举个实际的例子,看一下下面这张图:
在这里插入图片描述这个代码调用了一个RegOpenKeyW的导入函数,我们看到其opcode是FF 15 00 00 19 30,其中FF 15表示这是一个间接调用,即call dword ptr [30190000] ;这表示要调用的地址存放在30190000这个地址中,而30190000这个地址在导入地址表的范围内,当模块加载时,PE 加载器会根据导入表中描述的信息修正30190000这个内存中的内容。

最后总结一下:

导入表其实是一个IMAGE_IMPORT_DESCRIPTOR的数组,每个导入的DLL对应一个IMAGE_IMPORT_DESCRIPTOR。
IMAGE_IMPORT_DESCRIPTOR包含两个IMAGE_THUNK_DATA数组,数组中的每一项对应一个导入函数。
加载前OriginalFirstThunk与FirstThunk的数组都指向名字信息,加载后FirstThunk数组指向实际的函数地址。


延迟导入表

因为有些导入函数可能使用的频率比较低,或者在某些特定的场合才会用到,而有些函数可能要在程序运行一段时间后才会用到,这些函数可以等到他实际使用的时候再去加载对应的DLL,而没必要再程序一装载就初始化好。这样就可以减少内存消耗

这个机制听起来很诱人,因为他可以加快启动速度,我们应该如何利用这项机制呢?VC有一个选项,可以让我们很方便的使用到这项特性,如下图所示:
在这里插入图片描述在这一项后面填写需要延迟导入的DLL名称,连接器就会自动帮我们将这些DLL的导入变为延迟导入。

在IMAGE_DATA_DIRECTORY中,有一项为IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT,这一项便延迟导入表,IMAGE_DATA_DIRECTORY.VirtualAddress就指向延迟导入表的起始地址。既然是表,肯定又是一个数组,每一项都是一个ImgDelayDescr结构体,和导入表一样,每一项都代表一个导入的DLL,来看看定义:

typedef struct ImgDelayDescr {
DWORD grAttrs; // 用来区分版本,1是新版本,0是旧版本
RVA rvaDLLName; // 一个RVA,指向导入DLL的名字。
RVA rvaHmod; //一个RVA,指向导入DLL的模块基地址,这个基地址在DLL真正被导入前是NULL,导入后才是实际的基地址。
RVA rvaIAT; // 一个RVA,表示导入函数表,实际上指向IAT,在DLL加载前,IAT里存放的是一小段代码的地址,加载后才是真正的导入函数地址。
RVA rvaINT; // 一个RVA,指向导入函数的名字表。
RVA rvaBoundIAT; // RVA of the optional bound IAT
RVA rvaUnloadIAT; // 延迟导入函数卸载表。
DWORD dwTimeStamp; // 延迟导入DLL的时间戳。
} ImgDelayDescr, * PImgDelayDescr;
typedef const ImgDelayDescr * PCImgDelayDescr;

定义知道了,那他是怎么被处理的呢?前面提到了,在延迟导入函数指向的IAT里,默认保存的是一段代码的地址,当程序第一次调用到这个延迟导入函数时,流程会走到那段代码

.text:75C7A363 __imp_load__InternetConnectA@32:        ; InternetConnectA(x,x,x,x,x,x,x,x)  
.text:75C7A363                 mov     eax, offset __imp__InternetConnectA@32  
.text:75C7A368                 jmp     __tailMerge_WININET  

这段代码其实只有两行汇编,第一行把导入函数IAT项的地址放到eax中,然后用一个jmp跳转走,那么他跳转到哪里了呢?我们继续跟踪:

__tailMerge_WININET proc near             
.text:75C6BEF0                 push    ecx  
.text:75C6BEF1                 push    edx  
.text:75C6BEF2                 push    eax  
.text:75C6BEF3                 push    offset __DELAY_IMPORT_DESCRIPTOR_WININET  
.text:75C6BEF8                 call    __delayLoadHelper  
.text:75C6BEFD                 pop     edx  
.text:75C6BEFE                 pop     ecx  
.text:75C6BEFF                 jmp     eax  
.text:75C6BEFF __tailMerge_WININET endp  

其中最重要的是push offset __DELAY_IMPORT_DESCRIPTOR_WININET,这代表了push进一个上文种提到的ImgDelayDescr 结构,它的数据定义语言名字是wininet.ddl
CALL了一个__delayLoadHelper,在这个函数里,执行了加载DLL,查找导出函数,填充导入表等一系列操作,函数结束时IAT中已经是真正的导入函数的地址,这个函数同时返回了导入函数的地址,因此之后的eax里保存的就是函数地址,最后的jmp eax就跳转到了真实的导入函数中。

这个过程很完美,也很灵巧,但是如果仔细观察就会发现什么地方有点不对劲,你发现了吗?__delayLoadHelper的参数中只有IAT项的偏移和整个模块的延迟导入描述__DELAY_IMPORT_DESCRIPTOR_WININET,但是参数中并没有要导入函数的名字。也许你说,名字在__DELAY_IMPORT_DESCRIPTOR_WININET的名字表中,是的,那里确实有名字,但是别忘了,那是个表,里面存的是所有要从该模块导入的函数名字,而不是“当前”这个被调用函数的函数名。或许你觉得参数中应该有个索引号,用来表示名字列表中的第几项是即将被导入的那个函数的名字,不幸的是我们也没有看到参数中有这样的信息存在,那Windows执行到这里是如何得到名字的呢?MicroSoft在这里使用了一个巧妙的办法:__DELAY_IMPORT_DESCRIPTOR_WININET中有一项是rvaIAT,前面提到了,这里实际上就是指向了IAT,而且是该模块第一个导入函数的IAT的偏移,现在我们有两个偏移,即将导入的函数IAT项的偏移(记作RVA1)和要导入模块第一个函数IAT项的偏移(记作RVA0),(RVA1-RVA0)/4 = 导入函数IAT项在rvaIAT中的下标,rvaINT中的名字顺序与rvaIAT中的顺序是相同的,所以下标也相同,这样就能获取到导入函数的名字了。有了模块名和函数名,用GetProcAddress就可以获取到导入函数的地址了。
在这里插入图片描述最后还有两点要提醒:

延迟导入的加载只发生在函数第一次被调用的时候,之后IAT就填充为正确函数地址,不会再走__delayLoadHelper了。

延迟导入一次只会导入一个函数,而不是一次导入整个模块的所有函数。


区段表

区段表包含.text表、.data表、.rsrc表,区段表相当于区段的目录,里面包含了每个区段的信息,如区段名称,大小,基地址,偏移地址

.text:代码段,是在编译或汇编结束时产生的一种块,它的内容全部是指令代码。也有的编译器将该段命名为.code
.data:初始化的数据块,是初始化的数据块,包含那些编译时被初始化的变量、字符串
.idata:输入表,包含其他外来dll的函数和数据信息,也就是输入表,也有人称之为导入表。
.rsrc:资源数据块,包含模块的全部资源数据,如图标、菜单、位图等。
.reloc:重定位表,用于保存基址的重定位表。即当装在程序不能按照连接器所指定的地址装载文件是,需要对指令或已经初始化的变量进行调整,该块中也包含了调整过程中所需要的一些数据,如果装载能够正常装在则忽略此段中的数据。
.edata:导出表,是pe文件的输出表,以供其他模块使用,并不是每个pe文件都有此数据段,因为有的文件并不需要输出一些函数,该数据段常见于动态连接库文件中。
.radata:存放调试目录、说明字符串,该数据块并不常见主要是用于存放一些调试信息。

在这里插入图片描述
区段表的具体内容如下图所示:在这里插入图片描述

区段

.text:存在可执行文件的二进制机器码,免杀主要战场;
.data:初始化的数据块,比如全局变量等
.idata:可执行文件所使用的动态链接库等外接函数与文件信息,如输入输出表信息
输入表:程序调用系统dll函数或第三方dll函数的信息
输出表:程序提供自身函数给第三方程序的信息
.rsrc:存放程序的资源,如图标、菜单等

在这里插入图片描述这里面存储的是各个区段的具体二进制数据。

理解一下

PE文件感觉可以理解成是一堆汽车零件与一些说明书,Dos头与各种头信息与区段表就像是说明书,后面的各种区段就类似于一些零件。在PE文件没有被加载的时候,就类似于所有的零件被装在一个袋子里面,袋子里有着组装那些零件的说明书。

当PE文件被加载的时候,就类似于建造汽车中工人开始读这个说明书,先看是不是真的袋子里装的是汽车零件,这对应着计算机看Dos头与PE的signature段判断是否是合法的PE文件。

判断完成后,继续看说明书,对袋子里面的零件进行组装,直到组装好整个汽车。这个过程就类似于计算机根据PE的文件头与可选头与区段表,将区段里面的数据加载到指定的内存中,使得程序运行。

综上所述,各种头信息与区段表信息就是说明书,各个区段的数据就是零件。而PE文件就是装有零件与说明书的袋子。PE文件执行就类似于取出零件根据说明书建造具体的物件。

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值