PE文件之导入表(一)::导入函数调用机制、导入表基本结构
导入表是PE数据组织中的一个很重要的组成部分,它是为实现代码重用而设置的。Windows加载器在运行PE时会将导入表中声明的动态链接库一并加载到进程的地址空间,并修正指令代码中调用的函数地址。
数据目录中一共有四种类型的数据与导入表有关:
- 导入表
- 导入函数地址表
- 绑定地址表
- 延迟加载导入表
当程序调用了动态链接库的相关函数,在进行编译和链接的时候,编译程序和链接程序就会将调用的相关信息写入最终生成的PE文件中,以告诉操作系统这些函数的执行指令字节码从哪里能够获取。这些信息就是导入表所要描述的内容。
我们先来看看导入表是如何使用的。
导入函数

可以看到,我们的程序中invoke了两个外部函数(它们不在HelloWor模块中,而在其它可执行模块中),那么,我们的程序是如何能够找到正确的函数地址呢?我们先来看看硬盘中的PE文件中存储了怎样的调用代码:

**对函数的调用,反汇编之后是先用call指令进行一个段内调用,再进行一个无条件跳转。**我们以jmp指令的操作数作为地址,在内存窗口中查看:

我们在可执行文件(未运行)中查看对应的地址:

可以看到对于同一个PE文件中的内容,在硬盘和在内存中是不一样的。具体来说,硬盘中PE文件对应地址中保存的是一个RVA,这个地址指向了函数名称(可选)+函数名称字符串:

加载器根据这些字符串在内存中找到函数地址,并且用函数的地址替换PE文件中的RVA:

call的操作数是jmp指令所在的地址;而jmp指令的操作数则是该导入函数在导入表的地址。在程序中所有的导入函数地址被排列在一起,组成IAT(导入地址表),通过这样的分解操作配合导入表实现对外部函数的调用。
接下来我们看看导入表。
导入表
导入表的RVA和size由数据目录项的第二项指定:

我们把RVA转换为FOA,得到地址0x6100,在十六进制编辑器中查看(其中第一行0x600是IAT,下面的是导入表的内容):

导入表由一系列的IMAGE_IMPORT_DESCRIPTOR结构组成,结构的大小为20字节,结构的数量取决于程序要使用的DLL文件的数量,每个结构对应一个DLL文件。在所有这些结构的最后,由一个内容全为0的IMAGE_IMPORT_DESCRIPTOR结构作为结束。

下面对各字段进行解释:
OriginalFirstThunk
双字。因为它是指向另外数据结构的通路,因此简称为桥1。
**该字段指向一个包含了一系列结构(IMAGE_THUNK_DATA)的数组。**指向的数组中的每个结构定义了一个导入函数的信息,最后以一个内容为全0的结构作为结束。该结构实际上只是一个双字,但在不同的时刻却拥有不同的解释。

OriginalFirstThunk字段有两种解释:
- 双字最高位为0,表示导入符号是一个数值,该数值是一个RVA。
- 双字最高位为1,表示导入符号是一个名称。
在示例程序中,第一个IMAGE_IMPROT_DESCRIPTOR中OriginalFirstThunk字段的内容为:

最高位是0,这是一个RVA,表明函数是以字符串类型的函数名导入的。我们把这个RVA转换为FOA(0x654),在编辑器中查看该地址的内容(每次取双字,因为IMAGE_THUNK_DATA大小为双字):

得到的是0x0000205C,最后以双字的0标志着这个数组的结束。
因为这个动态链接库只调用了一个函数,所以,数组里只有两个元素。如果调用的函数多了,数组中的元素也会增加。**这组数中每一个都是一个RVA,不过这个RVA却指向了另外一个结构IMAGE_IMPORT_BY_NAME。**这个结构大小不确定,是桥1的最终目的地。结构的第一个为双字,紧跟着的是函数的名字。

该结构的第一个字段是双字,代表着函数的编号。在DLL中,每个函数都有自己的编号,访问函数可以通过编号访问,也可以通过函数名访问。第二个字段是函数名字字符串的内容,以'\0'作为字符串结束的标志。
我们把0x0000205C转换为FOA:

TimeDateStamp
双字。时间戳,一般不用,多为0。如果该导入表项被绑定,那么绑定后的这个时间戳就被设置为对应DLL文件的时间戳。操作系统在加载时,可以通过这个时间戳来判断绑定的信息是否过时。
ForwarderChain
双字。链表的前一个结构。
Name1
双字。这个字段的含义和名称并不一致,这里的Name1是一个RVA,它指向该结构所对应的DLL文件的名称,而这个名称是以"\0"结尾的Ansi字符串。
在示例程序中为:6A 20 00 00,把这个地址转换为FOA,就是user32.dll这个字符串。
FirstThunk
双字。与OriginalFirstThunk相同,它指向的链表定义了针对Name1这个动态链接库引入的所有导入函数,简称桥2。
桥2指向的数据和桥1是完全相同的,只是存储位置不同。
我们把桥1指向的IMAGE_THUNK_DATA结构数组称为INT,桥2指向的IMAGE_THUNK_DATA数组称为IAT。
为什么要让桥1和桥2指向的数据完全相同
为什么需要两个一模一样的IMAGE_THUNK_DATA数组呢?答案是当PE文件被装入内存的时候,其中一个数组的值将被改作他用,还记得前面分析Hello World程序时提到的吗?Windows装载器会将指令Jmp dword ptr[xxxxxxxx]指定的xxxxxxxx处的RVA替换成真正的函数地址,其实xxxxxxxx地址正是由FirstThunk字段指向的那个数组中的一员。之所以在PE文件中使用两份IMAGE_THUNK_DATA数组的拷贝并修改其中的一份,是为了最后还可以留下一份拷贝用来反过来查询地址所对应的导入函数名。
总结
每一个结构IMAGE_IMPORT_DESCRIPTOR都对应一个唯一的动态链接库文件,以及引用了该动态链接库的多个函数,每个函数的最终"值-名称"描述均可以沿着桥1或者桥2找到,这种导入表结构被称为双桥结构。
双桥结构的导入表在文件中存在两份内容完全相同的地址列表。一般情况下,桥2指向的地址列表被定义为IAT,而桥1指向的地址列表则被定义为INT(Import Name Table)。
