学习了Windwos下PE文件结构,并且了解自己写一个Load Library所需过程,最后实现 Windows 下去除 EXE 或者 DLL 的ASLR。
一、PE 简介
PE(Portable Executable File Format)
- PE 是Windows平台主流可执行文件格式,exe,dll, sys, com文件都是PE格式
- 32位的称为 PE32, 64位的称为 PE32+
- PE文件格式定义在 winnt.h 头中有详细定义
- PE文件头包含了一个程序在运行时需要的所有信息
- 包括了如何加载到内存、开辟多大的堆栈空间、调用哪些DLL以及相关函数、从何处开始运行,这些信息都以结构体的形式存储在PE头中。
PE格式的大致布局
PE文件格式包括:
- MS-DOS头
- NT头
- section表中包含了所有的section头
- 所有的section实体
实现LoadLibrary,即把PE文件加载到内存中需要经过四步:
- 一、要判定输入文件是否是PE格式
- 二、要将PE文件分块按照内存映像结构放在内存中
- 三、在IAT(Import Address Table,导入地址表)中,填入其依赖的导入函数地址
- 四、利用重定位表修复需要重定位的值
为了调用函数,需要执行 GetProcAddress获取函数地址:
- 为了使其他函数可调用本PE文件的函数,需要实现 GetProcAddress
- GetProcAddress的实现,则依赖于 EAT(Export Address Table,导出地址表)的读取
- 根据 EAT中读取到的地址,以及加载的模块的基址,可计算所加载的函数的地址
二、PE格式检查
PE文件加载,首先要检查的是该文件是否为 PE格式还需要检查该PE文件是否为DLL
- 对于 PE格式的检测,需要检查的部分是 MS-DOS 头 和 NT 头
- 对于 DLL 的检测,则需要检查 NT 头中的 IMAGE_FILE_HEADER
2.1 MS-DOS头
MS-DOS 头是微软为了考虑 PE文件对 DOS文件的兼容性而添加的
大多数情况下由编译器自动生成,通常把 DOS MZ 头 与 DOS stub 合称为 DOS 文件头
IMAGE_DOS_HEADER 结构体如下图:
- IMAGE_DOS_HEAEDER 结构体共 64 字节,其中两个字段比较重要,分别是 e_magic 和 e_lfanew
e_magic 需要被设置为 0x5A4D,其 ASCLL 值为 “MZ”,为 DOS 签名,标志着 DOS头的开始
e_lfanew字段是 NT 头的相对偏移,其指出 NT头的文件偏移位置,共占用四个字节,位于文件开始偏移 0x3C 字节中
2.2 NT头
在 DOS stub后的是 NT 头(IMAGE_NT_HEADERS)
Signature
在一个有效的 PE 文件中,Signature字段必须被设置为 0x00004550,对应于 ASCLL 字符 ”PE\0\0“
IMAGE_FILE_HEADER
IMAGE_FILE_HEADER 结构包含了 PE文件的基本信息,最重要的是 其中一个字段指出了 IMAGE_OPTIONAL_HEADER 的大小
Machine,NumberOfSections,SizeOfOptinalHeader,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 等文件信息,这些信息以比特位的方式组合起来
2.3 IMAGE_OPTIONAL_HEADER
IMAGE_OPTIONAL_HEADER 虽然叫做可选头,但是仅有 IMAGE_FILE_HEADER 并不足以定义 PE文件的属性,因此 IMAGE_OPTIONAL_HEADR 定义了更多的 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 指定了 PE Image 在虚拟内存中所占的空间大小
SizeOfHeaders:
该字段表明了整个PE 文件头部的大小,该值必须是 FileAlignment 的整数倍
Subsystem:
该字段用于区分系统驱动文件 与 普通可执行文件
NumberOfRvaAndSize:
该字段表明了下面出现的 DataDirectory 数组的个数,一般来说该值为 16
DataDirectory:
DataDirectory 是有 IMAGE_DATA_DIRECTORY 结构体构成的数组,数组的每项都有不同的意义
2.4 PE格式检查
PE 格式检查主要针对于 MS-DOS 头和 NT 头,要求 MS-DOS 头 和 NT 头的签名与规定相同。其中 MS-DOS 头的签名为 0x4D5A 即 ASCLL 码的 ”MZ”
通过 MS-DOS 头中的 e_lfanew 成员变量找到 NT 头。检查 NT 头签名 为 0x50450000 即 ASCLL 的 “PE\0\0”
根据 NT 头中 FileHeader 中的 Characteristics 中的 IMAGE_FILE_DLL 位可以判断该 PE 文件是否为 DLL。
三、内存映像结构
在检查了 PE 文件 格式之后,第二步 是将 PE 文件从 硬盘中映射到 内存映像结构
3.1 最小基本单元
计算机中,为了提高处理文件、内存的效率,使用 “最小基本单元” 这一概念
PE 文件映射到 内存后 节区的起始位置应该在 最小基本单元的倍数上
在最小基本单元中空余的 空间填 NULL
3.2 程序处理
首先将 PE 文件的 MS-DOS 头,NT 头以及节区头拷贝到 开辟的内存空间的首地址处
下面的代码中 pFileBuf 存储了从硬盘中读取的 PE 数据,pFileBuf_New 为依据 SizeOfImage 开辟的新内存空间
头部大小可由可选头中的 SizeOfHeaders 成员变量获得
Windwos 提供了一个宏 IMAGE_FIRST_SECTION,可以根据 NT 头直接返回第一个节区头的指针
由每个节区头中的 PointerToRawData(指针指向的原始数据),VirtualAddress 以及 SizeOfRawData 成员变量,可以获知每个节区的数据在 pFileBuf 中的首地址,该数据应该被放在 pFileBuf_New 的地址加上 VirtualAddress,以及该节区数据大小。
3.3 RVA & VA
RVA 是在 PE文件中为了避免确定的内存地址,出现了相对虚拟地址(Relative Virtual Address,简称为 RVA)
RVA 是内存中一个简单的相对于 PE 文件装入地址的偏移位置,是一个“相对地址”,或称为“偏移量”
VA 指的是进程装入内存后实际的内存地址,被称为 虚拟地址(Virtual Address,简称 VA)
虚拟地址(VA) = 基地址(ImageBase) + 相对虚拟地址(RVA)
其中基地址 是 PE 文件通过 Windows 加载器装入 内存后,该模块的初始内存地址 就被称为 基地址
3.4 从文件偏移到相对虚拟地址
在以上小节的地址计算中,都是在文件映射到内存之后进行的
但是通过之前的介绍可知,PE文件在存储时为了减少体积,FileAlignment 通常小于 SectionAlignment
当文件被映射到内存中后,同一数据在文件中的偏移量与在内存中的偏移量是不一样的,这样就存在这从文件偏移地址(RAW) 到相对虚拟地址(RVA) 之间的转换
- 如果需要对存储在硬盘中的 PE 文件进行操作,需要将 RVA 转换为 RAW。
由于应用程序的映射是以节区为单位做的 映射,一个节区内数据的地址相对于节区的地址是 不变的,因此只需要计算各节区在磁盘与内存中起始地址的差值即可。
将该差值以 Θ 表示,RAW 与 RVA 之间的关系如下所示:
1 | RAW = RVA - Θ |
由于
1 | RVA = VA - ImageBase |
RAW 的公示也可改写为下式:
1 | RAW = VA - ImageBase - Θ |
以 notepad 为例,其差值如下表所示:
在计算某虚拟地址对应的文件偏移时,应首先查看其属于哪一节区,找到相应的差值后再进行转换
四、基址重定位
由第二节中 可选头的描述可知,可选头的 ImageBase 成员变量描述了程序在装入内存时优先装入的地址
在生成 PE 文件时,EXE 文件优先装入的地址是 0x400000,DLL文件优先装入的地址是 0x10000000.
在 xp 中没有地址随机化,EXE 会被默认装入 基址处,通常没有重定位表,但一个可执行程序要加载的 DLL 有很多,不能都加载在 0x10000000处。
当地址已经被占用时,就需要加载到未被占用的空间中,但程序中的一些绝对地址就会访问或跳转到别的地址空间中,而不是访问或跳转到预期的位置,重定位表就是为此而产生的。
在 visita 及以上的操作系统中,开启了地址随机化保护,EXE 也会被加载到别的地址,因此 EXE 也有了重定位表
4.1 基址重定位结构定义
重定位表由许多重定位块串接组成,每个重定位块中存放着 4 KB 大小的重定位信息
重定位块开头以 IMAGE_BASE_RELOCATION 开始
VirtualAddress:
声明了该组重定位数据开始的相对虚拟地址,各项重定位地址与该值相加,才是需要进行重定位的相对虚拟地址
SizeOfBlock:
声明了该组重定位数据的大小,其中包含了 IMAGE_BASE_RELOCATION
在 IMAGE_BASE_RELOCATION 之后紧随的是 TypeOffset 数组,数组每项大小为两个字节,高四位代表重定位类型,低十二位值为重定位地址
最终所有的重定位块以一个 VirtualAddress 为0 的 IMAGE_BASE_RELOCATION 结构结束
常见的重定位类型如下:
对于 x86 文件,所有需要处理的基址重定位类型都是 IMAGE_REL_BASED_HIGHLOW
IMAGE_REL_BASED_ABSOLUTE 只是用于填充,以便于四字节对齐
4.2 程序处理
重定位表在 PE 文件中往往 单独分为一个 节区,名称为 “reloc” 。也可以由可选头中 DataDirectory 中的 BASERELOC Directory 成员找到。但是一般来说 是通过 BASERELOC Directory 来寻找重定位表
整个代码共有两个循环,第一个循环遍历每个重定位块,第二个循环遍历每个重定位块中的重定位信息。根据每个重定位信息的高四位确定其是否需要重定位,需要重定位时,根据程序预期存储位置 ImageBase 以及当前程序存储位置 m_pFileBuf 对其地址进行修正
五、导入表
在编写程序时,会使用到大量的库函数,由于动态链接的存在,这些函数并不会都编写进二进制文件中,而是在函数调用处填入对应的导入表地址
当程序加载到内存中后,Windwos 加载器才将相关的 DLL 装入,并将调用 输入函数的指令和函数实际所在地址关联起来
调用 VirtualAlloc 函数时,依据二进制查看得到 VirtualAlloc 地址为 0x47d1d8
查看 0x47d1d8处,如下图所示,其值在 IDA 中为未知值,这是因为该 PE 文件尚未装入内存中,没有在导入地址表中填写相应的地址
当期执行后,该处的值会由 Windows 加载器填写,如图所示,其值已变为 0x74cf6970
在程序装入内存时,PE加载器完成了这些工作
同样,编写一个自己的 LoadLibrary 也需要在导入表中填入相应的函数地址
5.1 IMAGE_IMPORT_DESCRIPTOR
PE文件头 的可选映像头中,数据目录表的第二成员指向导入表
导入表 由 IMAGE_IMPORT_DESCRIPTOR(简称 IID) 数组构成
每个被 PE 文件导入的 DLL 都有一个 与之对应的 IID
IID 中并无字段表明 IID 数组的长度大小,该数组最后的一个单元为 NULL,由此可以计算出 IID 数组的项数
OriginalFirstThunk:
包含指向导入名称表的 RVA
TimeDataStamp:
一个 32 位的时间标志
ForwarderChain:
当程序引用一个 DLL中的 API,而这个 API 又引用别的 DLL 的 API 时使用
Name:
DLL名字的指针,名称字符串以 \0 结尾
FirstThunk:
包含指向导入地址表的 RVA
导入名称表 INT 的结构:
Hint:
该字段表明本函数在 DLL 中的导出表序号
Name:
该字段为函数名,是一个 ASCLL 字符串,以 NULL 结尾
导入地址表(IAT)中填写对应函数的虚拟地址
5.2 程序处理
于基址重定位表类似的,从可选头中的 DataDirectory 中的 IMPORT Directory 成员找到。
然后使用 LoadLibrary 载入所有关联的 DLL,使用 GetProcAddress 获取所有函数地址,填入 IAT中
程序共有两个循环,第一个循环为遍历所有需要导入的 DLL,第二个循环为遍历每个DLL 中需要导入的函数,将获取到的 函数地址填入 IAT 表中即可。
至此,自制的 LoadLibrary 就已经完成了,如果是在 vista 及以上操作系统,使用上面的代码加载 EXE 文件,去掉对 DLL 位的校验,通过可选头获取程序入口点,将控制权家交给加载好的 EXE,即可正常执行EXE 程序,可以称为 LoadPE.
六、导出表
仅仅把 DLL 加载到 内存中是不够的,无法得到 DLL 导出函数的地址,这个 DLL 就是无效的。但是在 DLL 中,DataDirectory 比 EXE 中多了一项 EXPORTDirectory,通过 EXPORT Directory 可以找到 DLL 中的导出地址表。
Characteristic:
未定义,为0
TimeDateStamp:
该字段表明输出表创建时间
MajorVersion:
该字段表明输出表的主版本号,未使用,值为0
MinorVersion:
该字段表明输出表的次版本号,未使用,值为0
Name:
该字段指向 DLL 名称字符串地址
Base:
该字段包含用于这个可执行文件输出表的起始序数值
NumberOfFunctions:
该字段表明导出地址表(EAT) 中的条目数量
NumberOfNames:
输出函数名称表的条数数量,该值小于或等于 NumberOfFunctions。当函数只通过序数输出时会出现 NumberOfNames 小于 NumberOfFunctions
AddressOfFunctions:
该字段指向导出名称表(ENT)地址,ENT中存储了所有函数名称字符串的相对虚拟地址
AddressOfNameOridinals:
该字段指向导出序数表地址,导出序数表存储了所有导出函数的序数
6.2 程序处理
从可选头中的 Data Directory 中的 IMPORT Directory 成员找到 IMAGE_EXPORT_DIRECTORY地址
从 ENT 中取出函数名,与要取的函数名进行对比,一致时从 EBT 中得到 函数地址与内存中的 DLL 基址相加,即得到函数真实地址
七、PE文件中的地址讲解
PE文件有两种状态,一是内存展开,二是在文件中的状态。
如果想改变一个全局变量的初始值,此时则需要通过各种地址之间的转换。
1 | ImageBase:模块基址,程序一开始的地址 |
假设PE文件中的地址为 0x400,其在内存中展开的地址是 0x1000。我们就需要通过内存位置,找到这个文件中的位置,或者反之对其进行查找。
原因是由于 内存对齐 和 文件对齐 两者数据位置不一样,所以需要转换。
如下图所示:
7.2 转换方法
1. 内存转文件偏移计算
第一步,PE在内存中展开,是在 ImageBase 位置展开的,头跟文件是一样的,只不过节数据展开位置不一样。
RVA = 内存地址 - ImageBase
得到 RVA 后就需要计算 FOA,也就是文件偏移。
首先需要判断 RVA属于哪个节/头
如果 RVA属于头 (DOS + NT) 那么不需要进行计算,因为头在文件中和内存中都是一样展开的,直接从开始位置寻找到 RVA 个字节即可。
如果不在头,就要判断在哪个节里面,判断开始位置 和 结束位置,我们的 RVA 在这个值里面
其中 节 虚拟地址结束位置,就是用节数据对齐后的 大小 + 虚拟地址大小:
公式: RVA >= 节.VirtualAddress && RVA <= 节.VirtualAddress + 节.SizeOfRawData
其次 计算差值偏移,虚拟地址距离节数据的开始位置的偏移
差值 = RVA - 节.VirtualAddress
差值偏移: 为什么要计算差值,因为计算的差值偏移就是我们的 RVA 距离我们节数据开始位置的偏移是多少,这个差值 是不会改变的。
例如:节数据开始位置是 0x1000 ,我们的 RVA = 0x1024,那么差值是 0x24,如果文件中节数据开始的位置是 0x400,那么差值偏移是不会改变的,那么文件偏移 + 差值偏移,就是文件中的位置 例如 0x424。
计算 FOA:
公式: FOA = 差值偏移 + 节.PointToRawData
1 | 内存转文件偏移总结: |
八、去除dll的ASLR
8.1 去除PE标志位
根据
博客:
去除一个程序或者 DLL的 ASLR的方法为:
去除 程序和 DLL 库的 PE 文件中 OptionalHeader 头中的 DllCharacteristic 数据中的 0x004 标志位 即可。
网上其余所说的 方法:修改注册表,修改Windows Exploit Protection 等方法,经过尝试都无法去除 ASLR。
8.2 修复重定位表
但是,在某些情况下,我们修改一个dll 的PE标志位后,发现EXE在运行时加载该DLL时,该DLL的地址仍然会发生变化。
经过实验,发现原因在于虚拟地址冲突。当我们修改一个 DLL的 PE 重定位标志位后,该DLL被其他EXE加载使用时,其加载的基址首先为 PE结构中 Image_base 地址。但是,如果在该DLL 被加载前,其 Image_base 的地址处 就已经被其他 DLL 或者 进程自己的 页面占据,操作系统 就会为有冲突的 DLL 进行重定位。
也就是 我们去除 重定位标志位后的 DLL 仍然有可能被重定位。
为了解决这个问题,我们需要对 DLL 的 Image_base 地址进行修改,将该 DLL 加载基址改为 一个不会冲突 的固定地址。修改 Image_base 后,最大的问题在于 DLL 的 重定位表 也需要重新计算。
修改方法和 原理,放两篇参考性很高的博客:
https://github.com/276793422/ReparePE
https://bbs.pediy.com/thread-219232-1.htm
- 本文作者: A1ex
- 本文链接: http://yoursite.com/2020/07/26/PE-to-LoadLibrary-md/
- 版权声明: 本博客所有文章除特别声明外,均采用 MIT 许可协议。转载请注明出处!