深入学习PE文件结构系列四Import-Relocation
0x1 前言
本篇文章是系列的最后一部分内容,讲述PE文件如何存储它需要用到的外部函数,也就是PE Import,以及PE文件如何处理需要重定位的地址,也就是PE Relocation
PE Import主要是三部分:导入目录表(Import Directory Table)、导入查询表(Import Lookup Table)、导入地址表(Import Address Table),这三部分都位于.idata(导入数据节)中
还记得前面文章中提到的Data Directory么,里面的每一个索引对应一段特殊功能的数据,这三部分就是特殊功能的数据
关于PE Relocation会讲述它PE重定位是什么,它在哪里,如何进行重定位
0x2 导入目录表(Import Directory Table)
导入目录表是从.idata节起始地址开始的一段特殊数据,它是一个IMAGE_IMPORT_DESCRIPTOR结构体数组,数组中的每个成员对应一个需要导入的DLL,它没有固定大小,数组中最后一个成员的所有值被置为0,来表示导入目录表的结尾,IMAGE_IMPORT_DESCRIPTOR的定义如下
1 | typedef struct _IMAGE_IMPORT_DESCRIPTOR { |
OriginalFirstThunk:是导入查询表的RVA
TimeDateStamp:如果没使用绑定导入,这个字段值为0,如果使用绑定导入,这个字段值为-1,如果开始没使用绑定导入,后面发生绑定导入,那时间戳会被更新为DLL的时间戳,当使用绑定导入时,这个字段的值始终为-1,此时DLL的真实时间戳位于绑定导入目录表IMAGE_BOUND_IMPORT_DESCRIPTOR中(这段看不懂也没关系,后面会介绍绑定导入,可以再回过头来看)
ForwarderChain:第一个转发链引用的索引,转发链是负责DLL转发的,当一个DLL想要转发它的一些导出函数到另外一个DLL
Name:包含要导入DLL的名字的ASCII字符串的RVA
FirstThunk:导入地址表的RVA
绑定导入(Bound Imports)
绑定导入的含义是提前计算要导入函数的地址,在编译阶段由链接器计算和写入到绑定导入数据目录表
绑定导入本质是一种优化机制,它减少了PE文件载入内存后,PE Loader解析函数地址并填充到IAT的时间,但是如果提前计算的地址不等于实际地址(DLL版本更新了),那PE Loader还是需要重新计算函数地址,并写入IAT
在讨论IMAGE_IMPORT_DESCRIPTOR.TimeDateStamp的时候,提到了当使用绑定导入的时候,日期时间戳设置为-1,实际上DLL的日期时间戳可以在绑定导入数据目录的IMAGE_BOUND_IMPORT_DESCRIPTOR中发现
绑定导入数据目录(Bound Import Data Directory)
绑定导入数据目录和导入目录表类似,只不过它里面的信息是关于绑定导入,它是一个IMAGE_BOUND_IMPORT_DESCRIPTOR结构体数组,同样最后一个成员的全部值为0,其中IMAGE_BOUND_IMPORT_DESCRIPTOR的定义如下
1 | typedef struct _IMAGE_BOUND_IMPORT_DESCRIPTOR { |
TimeDateStamp:被导入DLL的时间戳
OffsetModuleName:包含要导入DLL的名字的ASCII字符串的RVA,它是相对于IMAGE_BOUND_IMPORT_DESCRIPTOR起始地址的RVA
NumberOfModuleForwarderRefs:紧随这个结构体之后下一个结构体IMAGE_BOUND_FORWARDER_REF的数量,IMAGE_BOUND_FORWARDER_REF是一个几乎等同于IMAGE_BOUND_IMPORT_DESCRIPTOR的结构体,唯一的区别是最后一个成员是被保留的
0x3 导入查询表(Import Lookup Table)
导入查询表,也叫导入名称表
每一个要导入的DLL都有一个导入查询表,IMAGE_IMPORT_DESCRIPTOR.OriginalFirstThunk中的值就是对应DLL的导入查询表的RVA
导入查询表的本质是对DLL中用到的函数的引用,它告诉PE Loader,DLL中的哪些函数是被用到的
导入查询表是一个4字节的数组(对于32位PE),或者8字节的数组(对于64位PE),最后一个成员值为0表示导入查询表的结束
64位数字(8字节)中,每一位的含义如下:
- 最后一位(32位PE中是Bit 31,64位PE中是Bit 63):称为序号名称标志位,它指定了是通过名字还是通过序号导入函数
- Bits 0-15(序号名称标志位为1):如果序号名称标志位为1的话,则Bits 0-15装的是16位的序号,用这个序号来导入函数,Bits 16-30(Bits 16-62)值为0
- Bits 0-30(序号名称标志位为0):如果序号名称标志位为0的话,则Bits 0-30(Bits 0-62)装的是函数名称表的RVA
其中函数名称表是一个结构体,定义在winnt.h中,定义如下
1 | typedef struct _IMAGE_IMPORT_BY_NAME { |
- Hint:一个2字节的数字用来查找函数,这个数字首先作为DLL导出函数名称表的索引,如果查找失败,那么会在DLL导出函数名称表执行二分搜索
- Name:包含要导入函数的名称,以Null结尾
0x4 导入地址表(Import Address Table)
在PE文件中IAT完全等于导入查询表,不同的地方在于,PE文件载入内存时,IAT中的地址被绑定导入数据目录中的地址覆盖
梳理一下,PE Loader在解析的时候,首先通过NT Headers -> PE Optional Header -> Data Directory[IMAGE_DIRECTORY_ENTRY_IMPORT]来找到导入目录表的地址
然后通过IMAGE_IMPORT_DESCRIPTOR -> OriginalFirstThunk找到ILT的地址,通过IMAGE_IMPORT_DESCRIPTOR -> FirstThunk找到IAT的地址,PE文件载入到内存前,IAT等于ILT,PE文件载入内存后,PE Loader会使用实际地址覆盖IAT中的地址
如果在编译期间使用了绑定导入,就会有绑定导入数据目录,PE文件载入时无需重新计算实际地址,直接使用绑定导入数据目录中的地址替换,没使用绑定导入的话,就需要PE文件载入时计算
我们看一个实际PE文件的导入部分,这个PE文件没有.idata(导入节)
可以看到导入了哪些DLL,以及每个DLL导入了哪些函数,上述图片中IAT不等于ILT是因为链接器启用了IAT分离优化,将IAT和ILT放在不同节区,IAT因为后期会写入放在.data节,ILT是只读的放在.rdata节,不管它们是否相等,作用是不变的
- ILT负责“记录要导入的函数”
- IAT负责“存储函数的真实地址(加载后)”
0x5 基址重定位
重定位是什么
PE文件在编译时,编译器会假设PE文件载入内存时的基地址是IMAGE_OPTIONAL_HEADER.ImageBase,然后根据这个基址计算后得出一些硬编码地址,但由于ASLR等安全机制,PE文件在载入内存时,几乎不会使用ImageBase这个基址,这个时候,硬编码那些地址就无效了,所以需要重定位
全部硬编码的地址被集中在一个区域,这个区域称为重定位表,上一篇文章讲述了,PE Option Header->Data Directory[BASERELOC]就负责指向重定位表,并且重定位表位于.reloc节中
举个小例子演示一下
1 | int test = 2; |
在编译时,假设PE在内存的基地址是0x1000,然后test的偏移地址是0x100,那么test在内存中的地址就是0x1100,testPtr的值也就是0x1100,然后当PE实际载入到内存后,基地址变成了0x4000,这个时候重定位需要做的是,0x1100 + (0x2000 - 0x1000) = 0x2100,重定位修复后,testPtr的值变为0x2100
重定位表
.reloc节本质不是一个结构体,而是由多个紧密相邻的重定位块组成的(需要注意,这里的紧密相邻需要遵循4字节对齐,就是起始地址需要是4或者4的整数倍),最后一个重定位块是空块,表示重定位表的结束,每个重定位块对应一个4KB页面,其中每个重定位块的结构是一个重定位块头 + 多个重定位块项,重定位块头是一个结构体,定义如下
1 | typedef struct _IMAGE_BASE_RELOCATION { |
VirtualAddress:表示指向的4KB页面的起始地址
SizeOfBlock:表示当前重定位块的大小
每个重定位块项对应一个需要修改的硬编码地址,所以有几个重定位块项,要看这个4KB页面有几个硬编码地址,重定位块项是WORD类型,也就是2字节,16位,其中高4位用来表示这个硬编码地址的重定位类型(比如最常见的IMAGE_REL_BASED_HIGHLOW,表示“修正32位地址”),低12位用来寻址,4KB=4096字节=2的12次幂,所以用12位来寻址刚好可以覆盖4KB大小
需要重定位修复的地址 = PE基址 + VirtualAddress + 低12位的地址
下图所示是一个实际PE文件的重定位表,可以看到它有2个重定位块,拿第一个重定位块来说,它指向的页面偏移地址是0x5000,那个页面中需要重定位的块项数是0x3A,重定位块本身的大小是0x7C
重定位块头固定是8字节,7C(124) - 8 = 116,每个重定位块项占2个字节,所以重定位块项的数量是 116 / 2 = 58(3A)
参考链接
https://0xrick.github.io/win-internals/pe6/
https://0xrick.github.io/win-internals/pe7/
