导入表,位于PE文件格式中的可选PE头中DataDirectory字段的第二项(索引值为1)
为了减少不同应用程序之间的重复性功能文件,windows将一部分功能函数抽出,最终形成对应的DLL文件,DLL文件提供对外的导出表,供外部应用程序调用。
本文主要谈论关于导入表加载流程相关问题。
//资源目录表,索引值为一的元素存储导入表结构地址
_IMAGE_DATA_DIRECTORY DataDirectory[1];
具体PE文件的作用介绍在此处就不再介绍,PE基本结构图如下:
可选PE头中定义了大量关于PE结构的重要信息,包括、等重要信息。虽然本文谈论的导出表结构不在其中定义,但是进入导出表的大门(地址)被记录在可选PE头的最后一个字段属性中 -- 。
C语言中对可选PE头的定义结构体如下:
typedef struct _IMAGE_OPTIONAL_HEADER
{
WORD Magic; // 32位PE文件是010Bh,64位PE文件是20B*
BYTE MajorLinkerVersion; // 链接程序的主版本号
BYTE MinorLinkerVersion; // 链接程序的次版本号
DWORD SizeOfCode; // 所有含代码的节的总大小,按照FileAlignment对齐后的大小
DWORD SizeOfInitializedData; // 所有含已初始化数据的节的总大小
DWORD SizeOfUninitializedData; // 所有含未初始化数据的节的大小
DWORD AddressOfEntryPoint; // 程序执行入口OEP,加载到内存后的偏移值*
DWORD BaseOfCode; // 代码的区块的起始RVA
DWORD BaseOfData; // 数据的区块的起始RVA
//
// NT additional fields. 以下是属于NT结构增加的领域。
//
DWORD ImageBase; // 程序的首选装载地址,加载到内存的首地址(程序基址),但是随机基址可能不会使用此值*
DWORD SectionAlignment; // 内存中的区块的对齐大小*
DWORD FileAlignment; // 文件中的区块的对齐大小*
WORD MajorOperatingSystemVersion; // 要求操作系统最低版本号的主版本号
WORD MinorOperatingSystemVersion; // 要求操作系统最低版本号的副版本号
WORD MajorImageVersion; // 可运行于操作系统的主版本号
WORD MinorImageVersion; // 可运行于操作系统的次版本号
WORD MajorSubsystemVersion; // 要求最低子系统版本的主版本号
WORD MinorSubsystemVersion; // 要求最低子系统版本的次版本号
DWORD Win32VersionValue; // 莫须有字段,不被病毒利用的话一般为0
DWORD SizeOfImage; // 映像装入内存后的总尺寸(按照内存对齐后的大小)*
DWORD SizeOfHeaders; // DOS头 + DOS Stub + PE头 + 可选PE头 + 区段头(最终需要用文件区块大小对齐)*
DWORD CheckSum; // 映像的校检和
WORD Subsystem; // 可执行文件期望的子系统
WORD DllCharacteristics; // DllMain()函数何时被调用,默认为 0*
DWORD SizeOfStackReserve; // 初始化时的栈大小
DWORD SizeOfStackCommit; // 初始化时实际提交的栈大小
DWORD SizeOfHeapReserve; // 初始化时保留的堆大小
DWORD SizeOfHeapCommit; // 初始化时实际提交的堆大小
DWORD LoaderFlags; // 与调试有关,默认为 0
DWORD NumberOfRvaAndSizes; // 下边数据目录的项数,这个字段自Windows NT 发布以来一直是16*
IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES]; //数据目录表(PE核心)*
} IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32;
将其绘成内存空间占比图,可以看出可选PE头中一半以上空间都被数据目录表所占:
资源目录表记录了各项资源的入口点,该字段也是整个PE研究的。
数组编号 | 英文描述 | 中文描述 |
---|---|---|
00 | Export table address and size | 导出表地址和大小 |
01 | Import table address and size | |
02 | Resource table address and size | 资源表地址和大小 |
03 | Exception table address and size | 异常表地址和大小 |
Certificate table address and size | 属性证书数据地址和大小 | |
05 | Base relocation table address and size | 基地址重定位表地址和大小 |
Debugging information starting address and size | 调试信息地址和大小 | |
Architecture-specific data | 预留为 0 | |
Global pointer register relative virtual address | 指向全局指针寄存器的值 | |
09 | Thread local storage (TLS) table address and size | 线程局部存储地址和大小 |
10 | Load configuration table address and size | 加载配置表地址和大小 |
11 | Bound import table address and size | |
12 | Import address table address and size | |
13 | Delay import descriptor address and size | |
CLR Runtime Header address and size | CLR运行时头部数据地址和大小 | |
Reserved | 系统保留 |
C语言对IMAGE_DATA_DIRECTORY定义如下:
//数据目录表详细构成
typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress; //虚拟地址,相对内存的偏移
DWORD Size; //表大小,该值可以被修改,但是该值不影响表大小
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
我们自己编写的应用程序时,程序会调用系统开放的接口函数(API)。因此,在使用这些功能函数时,就需要将对应的系统DLL,并获取DLL中所调用函数对应地址。
C语言对导入表的定义如下:
typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics; // 0 for terminating null import descriptor
DWORD OriginalFirstThunk; // 包含指向IMAGE_THUNK_DATA(输入名称表)结构体的数组
} DUMMYUNIONNAME;
//此字段为0,FirstThunk也指向输入名称表,与OriginalFirstThunk作用相同
//如果该字段为-1,表示FirstThunk存储了真实的地址值
DWORD TimeDateStamp; // 当可执行文件不与输入的dll绑定时,此字段为0
DWORD ForwarderChain; // 第一个被转向的API的索引
DWORD Name; // 指向被输入的DLL的acii字符串的RVA
DWORD FirstThunk; // 指向输入地址表(IAT)的RVA,IAT是一个IMAGE_THUNK_DATA结构的数组
} IMAGE_IMPORT_DESCRIPTOR;
typedef IMAGE_IMPORT_DESCRIPTOR UNALIGNED *PIMAGE_IMPORT_DESCRIPTOR;
根据次结构可以看出,结构中存储了外部DLL的名称,但是没有我们想要的对应函数地址。但是OriginalFirstThunk字段指向了一个新的结构体IMAGE_THUNK_DATA。
IMAGE_THUNK_DATA结构定义
typedef struct _IMAGE_THUNK_DATA32 {
union {
DWORD ForwarderString; //指向一个转向者字符串的RVA;
DWORD Function; // 被输入的函数的内存地址
DWORD Ordinal; //被输入的API的序数值
DWORD AddressOfData; // 指向IMAGE_IMPORT_BY_NAME
} u1;
} IMAGE_THUNK_DATA32;
typedef IMAGE_THUNK_DATA32 * PIMAGE_THUNK_DATA32;
C语言对_IMAGE_THUNK_DATA32的定义,看着一大串内容,实际上仅仅占用了四个字节,主要使用AddressOfData字段含义。在此处也没有发现函数名称和函数地址。但是AddressOfData又指向了另一个结构体 ----- IMAGE_IMPORT_BY_NAME。
IMAGE_IMPORT_BY_NAME结构体定义
typedef struct _IMAGE_IMPORT_BY_NAME {
WORD Hint; //函数编号
CHAR Name[1];//表示函数名的字符串偏移
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;
直到遇见IMAGE_IMPORT_BY_NAME,我们才勉强又看见好像是函数名称的字节命名(Name),但是细看会发现,该字节定义仅仅有大小。
然我们整理下这些结构体的寻址过程:
文件对齐:0x200
导入表RVA:0x19188
导入节RVA:0x19000
导入节大小:0x8A3
导入节在文件中偏移:0x6400
计算导入表在文件中的具体位置
0x19188 - 0x19000 + 0x6400 = 0x6588
根据刚刚讨论,现在在文件中0x6588位置上的应该是结构体_IMAGE_IMPORT_DESCRIPTOR,其第一个字段指向了_IMAGE_THUNK_DATA32结构体,而该结构体又指向了_IMAGE_IMPORT_BY_NAME结构体
但是新的问题就出来了,明明_IMAGE_IMPORT_BY_NAME结构体的第二个NAME字段只有一个字节,为什么能够指向这么多字符的字符串。
经过一次分析可以看出导出表结构和开始分析的一样,原因是只使用了一个外部函数,此文件源码如下:
#include <stdio.h>
#include <windows.h>int main(){
MessageBox(NULL, TEXT("Hello"), TEXT("World"), MB_OK);
return 0;
}
如果我们使用了多个外部函数,并且存在多个函数在不同的DLL文件中的情况。那么PE文件的导入表如何构成
接下来我们分析下Notepad这个经典的exe文件
#寻找_IMAGE_OPTIONAL_HEADER结构
0x204BC - 0x20000 + 0x1CC00 = 0x1D0BC
#寻找_IMAGE_THUNK_DATA32结构
0x206EC - 0x20000 + 0x1CC00 = 0x1D2EC
#寻找_IMAGE_IMPORT_BY_NAME结构
0x20BA4 - 0x20000 + 0x1CC00 = 1D7A4
我们同样可以根据规律找到Notepad中的导入函数名称。但是问题是,自己的导入表中仅有一个函数,但是在OpenProcessToken函数后边还有其他的导入函数,那么后边的导入函数是谁去索引。
_IMAGE_THUNK_DATA32结构特点:四字节,存储了函数名称地址(_IMAGE_IMPORT_BY_NAME结构地址),我们返回去重新计算_IMAGE_THUNK_DATA32结构的下一组值
0x20BB8 - 0x20000 +0x1CC00 = 0x1D7B8
可以发现每一个函数名称地址(_IMAGE_IMPORT_BY_NAME)都被_IMAGE_THUNK_DATA32结构(四字节)进行存储。在本文最上边的暂定寻址模型需要进行更新。
暂定的寻址模型-2仅仅解决了同一DLL中的函数名称记录,如果存在不同DLL呢,此时就需要利用更上一层的结构体_IMAGE_OPTIONAL_HEADER来保存。让我们看一下最终的导入表寻址模型。
从开始到现在,聊了这么多,也才仅仅找到了对应的函数名称位置,在真正获取函数地址时,还有一小段录需要走。那就是需要了解下双桥结构。
前述文章只讲述了OriginalFirstThunk字段的指向过程,但是FirstThunk字段与之相同。在PE文件未加载时,两字段都指向相同的位置,但是如果将PE文件装载到内存中,FirstThunk所指向的_IMAGE_THUNK_DATA32结构体数组,其中存储的就不是函数名称地址,而是对应可执行函数的首地址。
因此,OriginalFirstThunk和FirstThunk也存有一个别名:
最终,我们可以在中,获取想要执行的函数在内存中的地址。
根据当前系统的DLL查找顺序,寻找相同名称的DLL,然后再DLL中获取查找对应的地址
DLL加载后,会在TEB、PEB环境中遍历到同名DLL,在对应DLL的导出表中,比对函数名称,比对成功后找到对应函数地址,修改到自己的导入表中。追中完成导入表的装载。
注意:不同版本操作系统,DLL加载顺序可能不同,甚至系统对DLL加载顺序会引起DLL劫持问题。具体细节参考百度。
如果,导入表内容庞大,那么每次加载可执行PE文件时,修正导入地址表的就需要考虑。既然已经了解了导入地址表的修正过程,那么我们是否可以考虑将导入地址表进行固化。程序每次执行直接进行指定地址的调用,跳过。
能够考虑IAT表绑定固化的前提:
如果真的进行手工替换导入地址表,造成新的两个问题:
绑定导入表失效怎么修复
如何判断PE是否使用了绑定导入表