目录 Table of Contents
输出表 (导出表) 详解
当 PE 文件被执行的时候, Windows 加载器将文件装入内存并将导入表 (Import Table) 登记的动态链接库 (一般是 dll 格式) 文件一并装入地址空间, 再根据 dll 文件中的函数导出信息对被执行文件的 IAT 进行修正.
基础补充 : 我们都明白 windows 在加载一个程序后, 就在内存中给这个程序开辟一个单独的虚拟地址空间, 这样每个程序自己看来, 自己就拥有几乎任意地址的支配权, 所以它自身的函数想放在哪个地址都是自己说了算.
有一些函数很多程序都会用到, 为每一个程序写一个相同的函数看起来似乎有点浪费空间, 因此 windows 就整出了动态链接库的概念, 将一些常用函数封装成动态链接库, 等到需要的时候直接加载进来, 把需要的函数拿到自己这里来, 这样就大大节约了内存中自愿的存放.
有一个重要的概念需要记住 : 动态链接库是被映射到其他应用程序的地址空间中执行的, 它和应用程序之间可以看出是 "一体的", 动态链接库可以使用应用程序的资源, 动态链接库的资源也可以被应用程序所使用, 它的任何操作都是代表应用程序进行的, 当动态链接库进行打开文件、分配内存和创建窗口等操作后, 这些文件、内存和窗口都是应用程序所拥有的. 所以, 动态链接库用小甲鱼的话来说就是 "寄生虫" !
那么导出表是干啥用的呢 ? 导出表就是记载着动态链接库的一些导出信息. 通过导出表, dll 文件可以向系统提供导出函数的名称、序号和入口地址等信息, 方便 windows 加载器通过这些信息来完成动态链接的整个过程.
友情提示 : 扩展名为 .exe 的 pe 文件中一般不存在导出表, 而大部分的 .dll 文件中都包含导出表.
但注意, 这并不是绝对的. 例如纯粹用作资源的 .dll 文件就不需要导出函数了, 另外有些特殊功能的 .exe 文件也会存在导出函数.
导出表结构
导出表 (Export Table) 中的主要成分是一个表格, 包含函数名称、输出序数等. 序数是指定 dll 中某个函数的 16 位数字, 在所指向的 dll 文件中是独一无二的.
在此我们不提倡仅仅使用序数来索引函数的方法, 因为这样会给 dll 文件的维护带来问题. 例如当 dll 文件一旦升级或者修改就可能导致调用该 dll 的程序无法加载到所需要的函数.
数据目录表的第一个成员指向导出表, 是一个 IMAGE_EXPORT_DIRECTORY (简称 IED) 结构, IED 结构的定义如下 :
IMAGE_EXPORT_DIRECTORY struct
Characteristics DWORD ? ; 未使用, 总是定义为 0
TimeDateStamp DWORD ? ; 文件生成时间
MajorVersion WORD ? ; 未使用, 总是定义为 0
MinorVersion WORD ? ; 未使用, 总是定义为 0
Name DWORD ? ; 模块的真是名称
Base DWORD ? ; 基数, 加上序数就是函数地址数组的索引值
NumberOfFunctions DWORD ? ; 导出函数的总数
NumberOfNames DWORD ? ; 以名称方式导出的函数的总数
AddressOfFunctions DWORD ? ; 指向输出函数地址的 RVA
AddressOfNames DWORD ? ; 指向输出函数名字的 RVA
AddressOfNameOrdinals DWORD ? ; 指向输出函数序号的 RVA
IMAGE_EXPORT_DIRECTORY ends
这个结构中的一些字段并没有被使用, 有意义的字段说明如下.
- Name : 一个 RVA 值, 指向一个定义了模块名称的字符串. 如即使 Kernel32.dll 文件被改名为 Ker.dll, 仍然可以从这个字符串中的值得知其在编译时的文件名是 "Kernel32.dll".
- NumberOfFunctions : 文件中包含的导出函数的总数
- NumberOfNames : 被定义函数名称的导出函数的总数, 显然只有这个数量的函数既可以用函数名方式导出, 也可以用序号方式导出. 剩下的 NumberOfFunctions 减去 NumberOfNames 数量的函数只能用序号方式导出. 该字段的值只会小于或者等于 NumberOfFunctions 字段的值, 如果这个值是 0, 表示所有的函数都是以序号的方式导出的.
- AddressOfFunctions : 一个 RVA 值, 指向包含全部导出函数入口地址的双字数组. 数组中的每一项是一个 RVA 值, 数组的项数等于 NumberOfFunctions 字段的值.
-
Base : 导出函数序号的起始值, 将 AddressOfFunctions 字段指向的入口地址表数组的索引号加上这个起始值就是对应函数的导出序号.
假如 Base 字段的值为 x, 那么入口地址表指定的第一个导出函数的序号就是 x; 第二个导出函数的序号就是 x+1. 总之, 一个导出函数的序号等于 Base 字段的值加上其在入口地址表中的位置索引值.
-
AddressOfNames 和 AddressOfNameOrdinals : 均为 RVA 值. 前者指向函数名字字符串地址表, 这个地址表是一个双字数组, 数组中的每一项指向一个函数名称字符串的 RVA. 数组的项数等于 NumberOfNames 字段的值, 所有有名称的导出函数的名称字符串都定义在这个表中;
后者指向另一个 word 类型的数组 (注意不是双字数组). 数组项数与文件名地址表中的项目一一对应, 项目值代表函数入口地址表的索引, 这样函数名称与函数入口地址关联起来.
举个例子来说, 加入函数名称字符串地址表的第 n 项指向一个字符串 "MyFunction", 那么可以去查找 AddressOfNameOrdinals 指向的数组的第 n 项, 假如第 n 项中存放的值是 x, 则表示 AddressOfFunctions 字段描述的地址表中的第 x 项函数入口地址对应的名称就是 "MyFunction". 是不是很复杂, 没事, 接着看就懂了.
整个流程和其他 PE 结构一样复杂, 所以咱们看图吧 :
1. 从序号查找函数入口地址
下边我们来模拟一下 Windows 装载器查找导出函数入口地址的整个过程.
如果已知函数的导出序号, 如何得到函数的入口地址呢 ?
Windows 装载器的工作步骤如下
-
定位到 PE 文件头
-
从 PE 文件头中的 IMAGE_OPTIONAL_HEADER32 结构中取出数据目录表, 并从第一个数据目录中得到导出表的 RVA
-
从导出表的 Base 字段得到起始序号
-
将需要查找的导出序号减去起始序号, 得到函数在入口地址表中的索引
-
检测索引值是否大于导出表的 NumberOfFunctions 字段的值, 如果大于后者的话, 说明输入的序号是无效的
-
用这个的索引值在 AddressOfFunctions 字段指向的导出函数入口地址表中取出相应的项目, 这就是函数入口地址的 RVA 值, 当函数被装入内存的时候, 这个 RVA 值加上模块实际装入的基地址, 就得到了函数真正的入口地址
2. 从函数名称查找入口地址
如果已知函数的名称, 如何得到函数的入口地址呢 ? 与使用序号来获取入口地址相比, 这个过程要相对复杂一点.
Windows 装载器的工作步骤如下_
-
最初步骤是一样的, 那就是首先得到导出表的地址
-
从导出表的 NumberOfNames 字段的到已命名函数的总数, 并以这个数字作为循环的次数, 我们来构造一个循环
-
从 AddressOfNames 字段指向得到的函数名称地址表的第一项开始, 在循环中将每一项定义的函数名与要查找的函数名相比较, 如果没有任何一个函数名是符合的, 表示文件中没有指定名称的函数
-
如果某一项定义的函数名与要查找的函数名符合, 那么记下这个函数名在字符串地址表中的索引值, 我们这里假设这个值是 x
-
最后, 以 x 值作为索引值, 在 AddressOfFunctions 字段指向的函数入口地址表中获取的 RVA 就是函数的入口地址
一般情况下, 病毒程序就是通过函数名称查找入口地址的, 因为病毒程序作为一段额外代码被附加到可执行文件中, 如果病毒代码中用到某些 API 的话, 这些 API 的地址不可能在宿主文件的导出表中为病毒代码写好. 因此只能通过在内存中动态查找的方法来实现获取 API 的地址.