首先介绍一下DLL相关内容。根据Win32 Programmer's Reference所述(自己翻译的):
“在Windows中,动态链接库(DLL)是含有函数与数据的模块。一个DLL在运行时期由其调用模块加载,当DLL被加载时,它会被映射到主叫进程的地址空间中去。
DLL内有两类函数:输出(exported)与内部(internal)。输出函数是要被其他模块调用的函数,内部函数仅供DLL内部使用。尽管DLL可以输出数据,但通常数据也仅作内部使用。
DLL可以使应用程序模块化,并且多个应用程序可以共享同一份内存中的DLL,因此也节省系统资源。Win32 API是被实现为一组DLL的,所以任何使用Win32 API的应用程序一定使用DLL。”
DLL中函数(也常被叫做符号Symbols)可以通过两种方式输出:通过名字与通过序号。一个序号通常是一个字大小的一个数,它在一个DLL中唯一标识一个函数,注意,这个序号仅在同一个DLL中唯一,不同DLL间序号不不唯一!
如果说函数通过名字输出,那么当其他模块要调用它时,可以在GetProcAddress中或使用它的名字或使用它的序号来指定,GetProcAddress函数则会返回被调用函数的地址。注意:使用名字的话,它的拼写与大小写必须与源DLL的模块定义文件(.DEF)中的一模一样,并且序号可以不从1开始,如果GetProcAddress找不到对应函数,就会返回NULL。具体GetProcAddress信息请参考Win32 Programmer’s Reference,或者MSDN。
之所以GetProcAddress可以得到DLL的输出信息,是因为DLL中定义了输出目录表(Export Directory),还记得我们在(五)中讲的IMAGE_DATA_DIRECTORY数组吗?它的第0个元素就描述了Export Directory的虚拟地址与大小,这样我们就可以通过这些信息找到PE中对应的输出目录表的全部信息。
输出目录表是用IMAGE_EXPORT_DIRECTORY这个结构定义的。
typedef struct _IMAGE_EXPORT_DIRECTORY {
DWORD Characteristics;
DWORD TimeDateStamp;
WORD MajorVersion;
WORD MinorVersion;
DWORD Name;
DWORD Base;
DWORD NumberOfFunctions;
DWORD NumberOfNames;
DWORD AddressOfFunctions;
DWORD AddressOfNames;
DWORD AddressOfNameOrdinals;
} IMAGE_EXPORT_DIRECTORY,*PIMAGE_EXPORT_DIRECTORY;
我们只关心其中几个成员:
Name。模块的内部名称,如果DLL文件的名字被用户改了,那么PE加载器会使用这个内部名称。
Base。前面说的序号的起始编号。比如一个函数的序号是4,起始序号Base是2,就表示该函数位于输出地址列表第三个元素,输出地址列表中默认首元素对应序号1,而不是序号0。序号的起始数字可以在.DEF文件中定义。
NumberOfFunctions。顾名思义,就是该DLL输出函数的个数。
NumberOfNames。就是通过名字输出的函数个数。
AddressOfFunctions。输出地址列表(Export Address Table,EAT,其实就是一个地址数组)的首地址。
AddressOfNames。输出名字列表(Export Name Table, ENT)的首地址。
AddressOfNameOrdinals。输出序号列表(Export Ordinal Table, EOT)的首地址。该数组的元素都是16-bit(一个字)长度的整数。
我们看到IMAGE_EXPORT_DIRECTORY其实主要就是指向了三个数组。现在我们再看下怎么通过函数的名字来找到对应的函数地址。
假设Base = 3, 且有以下的表格:
AddressOfNames - ENT | AddressOfNameOrdinals - EOT |
Name1 | 3 |
Name2 | 4 |
Name3 | 5 |
… | … |
AddressOfFunctions - EAT
索引 | 地址 |
1 | 0x400042 |
2 | 0x400156 |
3 | 0x401256 |
4 | 0x400520 |
5 | 0x401452 |
… | … |
比如我们传给GetProcAddress的名字是”Name3”,之后系统会先查找ENT这张表格,发现找到了,然后平行地看过去,发现在EOT中对应的序数是5,那么说明该函数的地址在EAT的第5个位置(从1开始数),取出地址0x401452,任务就完成了。
如果只通过序号来查找函数地址那就很方便,只要读取EOT,然后直接在EAT里索引就可以了。但这样的做法一不利于记忆,二不利于维护与扩展(因为序号一变就得改许多用户源代码)。
下面看一种新的情况,如果一个输出函数尽管出现在了EAT中,但没有出现在ENT与EOT中,那该怎么办呢?那就只能通过排除法了,也就是要满足在EAT中而不在ENT与EOT中。
好了,最后一个事儿了,就是输出转送(Export Forwarding)。就比如我调用了kernal32.dll里的HeapAlloc函数,它本身并没有实现这个函数,而是把我的调用请求转送到了ntdll.dll的RtlAllocHeap这个函数,这就是DLL输出转送,同样可以通过修改.DEF文件在链接时期进行。输出转送的引入,一来可以隔离通用的Win32 API与内核支持函数,达到屏蔽底层差异的目的,二来就是支持了操作系统的模式转换,划清用户模式与内核模式的界限。
具体反应在输出目录表中是这样的,那个AddressOfNames指向的表格中,本来存的都是函数的名字,现在就换成“模块名.函数名”,比如上面的例子就是”NTDLL. RtlAllocHeap”,因此如果你看到了类似这样的名字,就说明这个函数调用被转送了。
这次的文章没有图片实例,是因为咱们的testPE.exe没有输出表,而输出表本身也比较简单,我就懒得再去编一个DLL再提取16进制数据了,所以就抱歉啦!其实研究方法和前面几篇中的一模一样,就是先通过data directory找到输出表,然后读入IMAGE_EXPORT_DIRECTORY结构,得到三张表的地址,然后按上面的查表方法就可以了。
OK!本文就到这。(八)将讲的是输入区段(The Import Section)。