从PE结构到LoadLibrary
PE是Windows平台主流可执行文件格式,.exe , .dll, .sys, .com文件都是PE格式
32位的PE文件称为PE32,64位的称为PE32+,PE文件格式在winnt.h头中有着详细的定义,PE文件头包含了一个程序在运行时需要的所有信息,包括了如何将文件加载到内存、开辟多大的堆栈空间、调用哪些DLL以及相关函数、从何处开始运行,这些信息都以结构体的形式存储在PE头中
PE文件包括四个组成部分:
-
MS-DOS(Disk Operation System) 头
-
NT头(New Technology)
-
Section table表中包含了所有的 section头
-
所有的section实体(段实体)
实现LoadLibrary,即把PE文件加载到内存中需要经过四步
-
判定输入文件是否是PE格式
-
将PE文件按照内存映像结构分块放在内存中
-
在IAT(导入地址表)中,填入其依赖的导入函数地址
-
利用重定位表修复需要重定位的值
LoadLibrary
是 Windows API 中的一个函数,用于动态加载 DLL文件到当前进程的地址空间,并返回一个句柄以供后续操作。
1 检查PE格式
PE文件加载,首先要检查的是该文件是否为PE格式,还需要检查该PE文件是否为DLL
- 对于PE格式的检测,需要检查的部分是MS-DOS头中“MZ” 关键字和NT头中“PE/0/0”关键字
- 对于DLL的检测,则需要检查NT头中的IMAGE_FILE_HEADER的Characteristics字段下IMAGE_FILE_DLL信息位
-
MS-DOS头
MS-DOS头是微软为了考虑PE文件对DOS文件的兼容性而添加的。 大多数情况下由编译器自动生成,通常把DOS MZ头与DOS stub(模拟对象接口)合称为DOS文件头,IMAGE_DOS_HEADER结构体如下图 ,MAGE_DOS_HEADER结构体共64字节,其中两个字段比较重要,分别是e_magic和e_lfanew
e_magic需要被设置为0x5A4D,其ASCII值为“MZ” ,为DOS签名,标志着DOS头的开始
e_lfanew字段是NT头的相对偏移,其指出NT头的文件偏移位置,共占用四个字节,位于文件开始偏移 0x3C字节中
-
NT头
在DOS stub后的是NT头 (IMAGE_NT_HEADERS)
Signature 在一个有效的PE文件中,Signature字段必须被设置为 0x00004550,对应于ASCII字符“PE\0\0” 。
IMAGE_FILE_HEADER IMAGE_FILE_HEADER结构包含了PE文件的基本信息,最重要的是其中一个字段指出了IMAGE_OPTIONAL_HEADER 的大小
其中Machine,NumberOfSections, SizeOfOptionalHeader,Characteristics如果出现错误, 将导致该PE文件无法正常执行
- Machine 该字段说明了可执行文件的目标CPU类型,每类CPU都有唯一的Machine码
- NumberOfSections 该字段说明了在这个PE文件中节区(Section)的数目
- TimeDateStamp 该字段说明了该PE文件是何时被创建的
- PointerToSymbolTable 该字段说明了COFF符号表(基本用不到)的文件偏移位置, COFF符号表在PE文件中较为少见,通常其值为0
- NumberOfSymbols 如果存在COFF符号表,该字段说明了其中的符号数目
- SizeOfOptionalHeader 该字段说明了紧跟在IMAGE_FILE_HEADER后的数据大小 对于32位文件,该字段通常为0x00E0,对于64位文件, 该字段通常为0x00F0
- Characteristics 该字段用于标识文件的属性,文件是否可执行,是否为 DLL等文件信息,这些信息以比特位的方式组合起来
IMAGE_OPTIONAL_HEADER:
IMAGE_OPTIONAL_HEADER虽然叫做可选头,但是仅有IMAGE_FILE_HEADER并不足以定义PE文件的属性 IMAGE_OPTIONAL_HEADER定义了更多的PE文件的属性,两者结合起来描述了一个完整的PE文件
- Magic 当IMAGE_OPTIONAL_HEADER为IMAGE_OPTIONAL_HEADER32 (32位)时,Magic为0x10B。当其为 IMAGE_OPTIONAL_HEADER64时,Magic为0x20B。
- AddressOfEntryPoint 该字段的值为相对虚拟地址(加载到内存的地址),该值表明了程序最先执行的代码的启始地址,即程序入口点。
- ImageBase 该字段表明了PE文件被加载进内存时,文件将被优先装入的虚拟内存的地址。对于EXE来说,ImageBase通常为0x00400000; 对于DLL来说,ImageBase通常为0x10000000。装载后,EIP = ImageBase + AddressOfEntryPoint。
- SectionAlignment,FileAlignment PE文件的Body部分划分为若干节区,FileAlignment制定了节区在文件系统中的最小单位,SectionAlignment则指定了节区在内存中的最小单位,磁盘文件或内存的节区大小必定为FileAlignment或SectionAlignment的整数倍
- SizeOfImage 加载PE文件时,SizeOfImage指定了PEImage在虚拟内存中所占的空间大小
- SizeOfHeaders 该字段表明了整个PE文件头部的大小,该值必须是 FileAlignment的整数倍
- Subsystem 该字段用于区分系统驱动文件与普通可执行文件。
- NumberOfRvaAndSizes 该字段表明了下面出现的DataDirectory数组的个数。一般来说该值为16。
- DataDirectory 由IMAGE_DATA_DIRECTORY结构体构成的数组,数组的每项都有不同的意义
-
PE格式检查:
PE格式检查主要针对于MS-DOS头和NT头。要求MS- DOS头和NT头的签名与规定相同。其中MS-DOS头的签名为0x4D5A即ASCII码的”MZ” 。
通过MS-DOS头中的e_lfanew成员变量找到NT头。检查NT头签名为0x50450000即ASCII的”PE\0\0”
根据NT头中FileHeader中的Characteristics中的 IMAGE_FILE_DLL位可以判断该PE文件是否为DLL。
2 内存映像结构
将PE文件按照内存映像结构分块放在内存中 ,将PE文件从硬盘中映射到内存映像结构
-
最小基本单元
计算机中,为了提高处理文件过程中,内存的效率, 使用“最小基本单元”这一概念,PE文件映射到内存后节区的起始位置应该在最小基本单元的倍数上,在最小基本单元中空余的空间填NULL
-
程序处理
首先将PE文件的MS-DOS头,NT头以及节区头拷贝到开辟的内存空间的首地址处。
下面的代码中pFileBuf存储了从硬盘中读取的PE数据, pFileBuf_New为依据SizeofImage开辟的新内存空间,头部大小可由可选头中的SizeOfHeaders成员变量获得
Windows提供了一个宏IMAGE_FIRST_SECTION,可以根据NT头直接返回第一个节区头的指针
由每个节区头中的PointerToRawData(指针指向的原始数据),VirtualAddress以及SizeOfRawData成员变 量,可以获知每个节区的数据在pFileBuf中的首地址, 该数据应该被放在pFileBuf_New的地址加上VirtualAddress
-
RVA&VA
RVA是在PE文件中为了避免使用确定的内存地址, 出现了相对虚拟地址(RVA),RVA是内存中相对于PE文件装入地址的偏移位置, 是一个“相对地址” ,或称为“偏移量” ,VA指的是进程装入内存后实际的内存地址,被称为虚拟地址,VA=Image Base + RVA
其中基地址是PE文件通过Windows加载器装入内存后,该模块的初始内存地址就被称为基地址
-
从文件偏移到相对虚拟地址
在以上小节的地址计算中,都是在文件映射到内存之后进行的,但是PE文件在存储时为了减少体积,FileAlignment 通常小于SectionAlignment
当文件被映射到内存中后,同一数据在文件中的偏移量与在内存中的偏移量是不一样的,这样就存在文件偏移地址(RAW)到相对虚拟地址(RVA)之间的转换
如果需要对存储在硬盘中的PE文件进行操作,需要将RVA转换为RAW
由于应用程序的映射是以节区为单位做的映射,一个节区内数据的地址相对于节区的地址是不变的, 因此只需要计算各节区在磁盘与内存中起始地址的差值即可,notepad:
在计算某虚拟地址对应的文件偏移时,应首先查看其属于哪一节区,找到相应的差值后再进行转换, 以上述notepad为例,如给定一虚拟地址0x402854, ImageBase为0x400000,要求计算其文件偏移地址
3 基址重定位
重定位表是PE文件中用于支持代码在内存中动态加载到不同地址的一种机制。它解决了代码中的绝对地址在加载到不同内存地址时需要调整的问题。
由第二节中对可选头的描述可知,可选头的 ImageBase成员变量描述了程序在装入内存时优先装入的地址,在生成PE文件时,EXE文件优先装入的地址是 0x400000,DLL文件优先装入的地址是0x10000000
在xp中没有地址随机化,EXE会被默认装入基址处,通常不需要进行基址重定位,一个可执行程序要加载的DLL有很多,不能都加载在0x10000000处 ,当地址已经被占用时,就需要加载到未被占用的空间中,此时,程序中的一些绝对地址访问过程中,就会访问或跳转到别的地址空间中,而不是访问或转到预期的位置,重定位表就是为此而产生的
在vista及以上的操作系统中,开启了地址随机化保护,EXE也会被加载到别的地址,因此EXE也有了重定位表
基址重定位结构定义
重定位表由许多重定位块串接组成,每个重定位块中存放着4KB大小PE文件内容的重定位信息
重定位块开头以IMAGE_BASE_RELOCATION开始
- VirtualAddress 声明了该组重定位数据开始的相对虚拟地址,各项重定位地址与该值相加,才是需要进行重定位的相对虚拟地址
- SizeOfBlock 声明了该组重定位数据的大小,其中包含了 IMAGE_BASE_RELOCATION ,在IMAGE_BASE_RELOCATION之后紧随的是TypeOffset数组, 数组每项大小为两个字节,高四位代表重定位类型,低十二位值为重定位地址,最终所有的重定位块以一个VirtualAddress为0的 IMAGE_BASE_RELOCATION结构结束
程序处理:
重定位表在PE文件中往往单独分为1个节区,名称为“.reloc” ,可以由可选头中DataDirectory中的BASERELOC Directory成员找到
整个代码共有两个循环,第一个循环遍历每个重定位块,第二个循环遍历每个重定位块中的重定位信息。根据每个重定位信息的高四位确定其是否需要重定位,需要重定位时,根据程序预期存储位置 ImageBase以及当前程序存储位置m_pFileBuf对其地址进行修正
4 导入表
导入表用于描述一个模块(EXE 或 DLL)在运行时需要从其他模块(通常是 DLL)中调用的函数或变量。
在编写程序时,会使用到大量的库函数,由于动态链接的存在,这些函数并不会都编写进二进制文件中,而是在函数调用处填入对应的导入表地址
当程序加载到内存中后,Windows加载器才将相关的DLL装入,并将调用输入函数的指令和函数实际所在地址关联起来
调用VirtualAlloc函数时,依据二进制查看得到 VirtualAlloc地址为0x47d1d8
查看0x47d1d8处,如下图所示,其值在IDA中为未知值,这是因为该PE文件尚未装入内存中,没有在导入地址表中填写相应的地址
当其执行后,该处的值会由Windows加载器填写, 如图所示,其值已变为0x74cf6970
在程序装入内存时,PE加载器完成了这些工作,同样,编写一个自己的LoadLibrary也需要在导入表中填入相应的函数地址
IMAGE_IMPORT_DESCRIPTOR
PE文件头的可选映像头中,数据目录表的第二成员指向导入表,导入表由IMAGE_IMPORT_DESCRIPTOR(IID) 数组构成。
每个被PE文件导入的DLL都有一个与之对应的IID
IID中并无字段表明IID数组的长度大小,该数组最后的一个单元为NULL,由此可以计算出IID数组的项数
- OriginalFirstThunk(Characteristics) 包含指向导入名称表的RVA
- TimeDataStamp 一个32位的时间标志
- ForwarderChain 当程序引用一个DLL中的API,而这个API又引用别的DLL 的API时使用
- Name DLL名字的指针。名称字符串以\0结尾
- FirstThunk 包含指向导入地址表的RVA
导入名称表(INT)的结构:
- int 该字段表明本函数在DLL中的导出表序号。
- Name 该字段为函数名,是一个ASCII字符串,以NULL结尾
导入地址表(IAT)中填写对应函数的虚拟地址
程序处理
与基址重定位表类似的,从可选头中的DataDirectory中的IMPORT Directory成员找到
使用LoadLibrary载入所有关联的DLL,使用 GetProcAddress获取所有函数地址,填入IAT中
程序共有两个循环,第一个循环为遍历所有需要导入的DLL,第二个循环为遍历每个DLL中需要导入的函数。将获取到的函数地址填入IAT表中即可
5 导出表
导出表用于描述一个模块(通常是 DLL)向其他模块提供的函数或变量。通过导出表,其他模块可以找到并使用 DLL 中的功能。
上面LoadLibrary已经完成了,接下来需要完成的是与LoadLibrary配套的GetProcAddress
仅仅把DLL加载到内存中是不够的,无法得到DLL导出函数的地址,这个DLL就是无效的
在DLL中,DataDirectory比EXE中多了一项 EXPORTDirectory,通过EXPORT Directory可以找到 DLL中的导出地址表
IMAGE_EXPORT_DIRECTORY
-
Characteristics 未定义,为0
-
TimeDateStamp 该字段表明输出表创建时间
-
MajorVersion 该字段表明输出表的主版本号,未使用,值为0
-
MinorVersion 该字段表明输出表的次版本号,未使用,值为0
-
Name 该字段指向DLL名称字符串地址
-
Base 该字段包含用于这个可执行文件输出表的起始序数值
-
NumberOfFunctions 该字段表明导出地址表(EAT)中的条目数量
-
NumberOfNames 输出函数名称表的条数数量,该值小于或等于NumberOfFunctions。当函数只通过序数输出时会出现NumberOfNames小于NumberOfFunctions
-
AddressOfFunctions 该字段指向EAT地址,EAT中存储了所有导出函数的相对虚拟地址
-
AddressOfNames 该字段指向导出名称表(ENT)地址,ENT中存储了所有函数名称字符串的相对虚拟地址
-
AddressOfNameOrdinals 该字段指向导出序数表地址,导出序数表中存储了所有导出函数的序数
程序处理
从可选头中的DataDirectory中的EXPORT Directory成员找到IMAGE_EXPORT_DIRECTORY地址
从ENT中取出函数名,与要取的函数名进行对比, 一致时从EAT中得到函数地址与内存中的DLL基址相加,即得到了函数真实的地址
至此,自制的GetProcAddress也已经完成,与之前编写的LoadLibrary配合,就可以实现在内存中加载DLL并获取函数地址
总结对比
功能 | 导入表(Import Table) | 导出表(Export Table) |
---|---|---|
作用 | 描述模块需要调用的外部函数和变量 | 描述模块提供给其他模块的函数和变量 |
包含内容 | DLL 名称、函数名称或序号、IAT | 函数名称、地址、序号 |
用途 | 解析外部依赖关系,加载和绑定外部模块 | 提供函数或变量接口供外部模块调用 |
位置 | 依赖的模块中不存在,位于当前模块 | 位于模块本身内部 |
导入表:一个 EXE 调用系统函数如 MessageBox
,导入表会记录 user32.dll
的名称和 MessageBox
函数。
导出表:一个 graphics.dll
提供 DrawLine
和 DrawCircle
两个函数,导出表记录了这些函数的名称和地址,供其他模块调用。