在PE结构中最复杂的就是PE结构中的16个目录,如导入目录,导出目录,重定位目录,资源目录等等。对于病毒分析来说,最为重要的是导入目录和导出目录。在我的手写可执行程序的文章中已经介绍了导入目录。此篇文章将介绍导出目录。
    我们知道,一个Windows程序,它所实现的所有功能最终几乎都是调用系统DLL提供的API函数。通过我先前文章手写的Hello World程序可以看出,要使用任何一个DLL所提供的函数,我们需要将它导入,也就是用到了导入表。然而对于那些提供了被导出的函数的DLL程序来说,他们必须使用导出表将函数导出,之后别的程序才可以使用。无论是系统提供的标准DLL还是个人编写的DLL,只要想提供自己的函数给别人使用就必须建立导出表。一般使用任何开发环境编写具有导出功能的程序,导出表都是由链接器自动建立的。程序员只需指定被导出的函数名称或序号即可。在这里我不打算自己构造导出表(对于导入表的学习,我选择了自己构造------在先前手写可执行程序文章中。在此不再使用此方法,而是直接分析现成的导出表进行学习。)这里我们将分析一个自己编写的DLL文件的导出表,从而学习他。
     一个函数被导出通常有两种方法:一、仅以序号的方式导出    二、同时以序号和名称的方式导出。
    首先使用VC++6.0编写一个DLL程序,该程序导出了两个函数,fnDll1和fnDll2。其中第一个函数使用序号导出,导出序号是1,第二个函数同时使用序号和名称导出,导出序号为2,导出函数名称为fnDll2。该DLL程序导出模块名为DLL.dll。编写的vc代码如下:
#include "stdafx.h"
BOOL APIENTRY DllMain( HANDLE hModule, 
DWORD ul_reason_for_call, 
LPVOID lpReserved)
{
    return TRUE;
}
void fnDll1()
{  
}
void fnDll2()
{  
}
其中的def文件的内容如下:
LIBRARY DLL
EXPORTS
  fnDll1 @ 1 NONAME  
  fnDll2 @ 2 
    下面我们分析一下这个DLL文件的导出表是如何构造的。首先使用十六进制编辑工具将这个文件打开,这里我们使用VC++6.0自带的十六进制编辑工具将其打开。如图1所示:


                        图1  用十六进制编辑器打开DLL文件
由图1所示可以看到DLL.dll程序内容。根据先前手写可执行程序学习的PE结构的知识,我们定位到PE结构中的导出目录,如图1中被选中的部分即为导出目录。从中可以得知导出表的位于偏移0x2e070处,大小为0x152字节。然后在编辑器中按快捷键Ctrl+G,即出现Go To对话框,如图2所示:

                          
 图2  搜索地址

输入0x0002e070,然后单击Go To按钮即可到达0x0002e070地址处,如图3所示:


  图3  导出表

如图3所示被选中的部分即为该程序的导出表。下面我们开始解析它。导出表对应一个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;     // RVA from base of image
    DWORD   AddressOfNames;         // RVA from base of image
    DWORD   AddressOfNameOrdinals;  // RVA from base of image
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;
      第一个成员(Characteristics)是个DWORD类型,占四个字节。通常该值为零。
    第二个成员(TimeDateStamp)DWORD类型,表示输出表创建的时间,该时间是自1970年1月1日至今的秒数。这个值通常不会影响程序执行,可以是零。
    第三个成员(MajorVersion)是个WORD类型,表示主版本号,这个值通常不会影响程序执行,可以是零。
    第四个成员(MinorVersion)是个WORD类型,表示次版本号这个值通常不会影响程序执行,可以是零。
    第五个成员(Name)是个DWORD类型,指向模块的真实名称字符串的RVA值。该值是必须的,不能为零,通常PE装载器加载导入库的时候就是用这个内部名字而无论文件名是什么,一般情况下它和文件名相同。可以看到本程序中该值为0x2e0a6。将其转换为文件偏移,因为改程序的内存对齐粒度和文件对齐粒度都是0x1000,是相同的。所以RVA值转换成文件偏移也是相同的,是0x2e0a6。在文件找到偏移为0x2e0a6的地址处,可以看到该地址存放的字符串为:DLL.dll。由此得知此文件的导出模块名为DLL.dll。
    第六个成员(Base)是个DWORD类型,是基数,实际上它等于所有函数导出序号中最小的值,该值也是导出地址表中第一个地址对应函数的导出序号。默认情况下导出序号从一开始递增,所以最小序号为一。但是也有特殊的情况,如下所述。
    第七个成员(NumberOfFunctions)是个DWORD类型,表示模块中导出函数/符号个数,这里是0x00000002,由此得知此模块导出了两个函数(注意:这里不一定是真实导出函数的个数。实际上,该值等于所有函数中导出序号最小的值依次递增一到最大的值所经历的数的个数。)因为默认情况下导出序号从一开始递增,每次递增一,所以所经历的个数与导出函数个数相等。例如某模块导出了五个函数,而导出序号从一开始依次递增,每次递增一,递增到最大的是五,那么从一到五经历的的个数也是五。但是可以通过修改DEF文件为某个函数指定特定的导出序号,如果指定的结果并不是按照从一开始逐个递增一,那么将导致导出函数个数与递增个数不相等(也就是这里的值)。例如我们修改原来的DLL.dll程序,使其导出三个函数,并且在DEF文件中定义如下:
EXPORTS
  fnDll1  @ 3 NONAME  
  fnDll2  @ 2 
  fnDll3  @ 5
    该DEF定义的含义为函数fnDll1仅以序号方式导出,导出序号为3。函fnDll2分别以函数名和序号的方式导出,导出序号为2,函数fnDll3分别以函数名和序号方式导出,导出序号为5。由此可以看出,我们强制指定了各个函数的导出序号,其中最小的是2,最大的是5,那么从最小的2到最大的5要经历2、3、4、5总共4个数。因此在导出表中,基数Base的值为2,第七个成员的值为4。如图4所示:

                           
图4  修改后DLL文件的导出表

    (提示:我们接下来的讲解都将以修改后的DLL文件为例。)
    第八个成员(NumberOfNames)是个DWORD类型,表示通过名字导出的函数/符号的数目。注意该值不是模块导出的函数/符号总数,总数由上面的NumberOfFunctions即第七个成员给出。本成员值可以为0,表示模块没有通过名字导出的函数,完全通过序数导出。我们这个模块(修改前的模块)导出了两个函数,一个按照序号导出,一个按照名字导出,所以看到此处的值为0x00000001。而修改后的模块fnDll2和fnDll3都按照名字导出,只有fnDll1仅以序号的方式导出,所以对于修改后的模块此处值为2。
    第九个成员(AddressOfFunctions)是个DWORD类型,指向一个地址表(即DWORD数组结构),被指向的地址表存放的是所有导出函数所在地址的RVA值。该地址表的地址个数(一个函数地址占4个字节)和第七个成员的值相等,而该地址表对应函数的导出序号从第六个成员(Base)开始,依次递增,每次增加一。
    第十个成员(AddressOfNames)是个DWORD类型,指向一个地址表(即DWORD数组结构),被指向的地址表存放的是所有导出函数名称字符串所在地址的RVA值。该地址表的地址个数和第八个成员(NumberOfNames)相等。
    第十一个成员(AddressOfNameOrdinals)是个DWORD类型,指向一个导出序号表(即WORD数组结构),被指向的表中每个成员是一个WORD类型,存放的是所有导出函数中以名称方式导出的函数的导出序号(实际导出序号是此表的成员值加上Base的值)。因此他与第十个成员(AddressOfNames)指向的地址表一一对应。所以该地址表的大小也和第八个成员(NumberOfNames)相等。
    到此为止,导出表的所有成员就分析介绍完了。接下来我们详细介绍解析导出表的过程,以修改后的DLL文件为例,如图4所示。解析导出表分为如下几个步骤:
    1、找到导出目录,通过导出目录定位到导出表的文件偏移。然后跳转到该偏移。如图4所示。
    2、查看导出表结构的第七个成员(NumberOfFunctions),由此得知导出地址表中导出函数地址的个数。如图5所选部分,我们这里是4。

                           
图5  导出函数地址个数为4

      3、查看导出表结构的第九个成员(AddressOfFunctions),由此得到导出地址表的RVA,并且将其转换成文件偏移。我们这个程序的导出地址表的RVA是0x0002e098,因为内存对齐粒度和文件对齐粒度相同,所以转换成文件偏移也是0x0002e098。定位到该文件偏移即可找到导出地址表。如图6所选部分:

                            
图6  导出地址表

    4、查看导出表结构的第八个成员(NumberOfNames),由此得到该导出表中由函数名导出函数的个数,即导出序号表中成员的个数。如图7所示,我们这里有2个成员。

                            
图7  由名称导出函数的个数为2

      5、查看导出表结构的第十一个成员(AddressOfNameOrdinals)由此得到导出序号表,我们这里得到导出序号表的RVA是0x0002e0b0。转换成文件偏移也是0x0002e0b0,在文件中跳转到该偏移即得到导出序号表。如图8所选部分:

                           
 图8  导出序号表

    6、查看导出表结构的第十个成员(AddressOfNames),由此得到该导出表中导出函数名称字符串所在地址的函数名称地址表。我们这里是0x0002e0a8,在文件中跳转到该偏移即得到导出函数名称地址表。如图9所选部分:

                            
图9 导出函数名称地址表

    7、现在开始依次查看导出函数地址表的成员。该地址表总共有4个成员,每个成员导出序数从Base值开始以一为单位依次递增。由图6可以得知第一个成员的地址是0x00001005,就是说该程序导出的第一个函数的导出地址的RVA是0x00001005,导出序号是Base值,这里为2。那么导出函数名如何得到呢?这要遍历导出序号表,如果该函数的导出序号在导出序号表中存在,那么说明他也同时以函数名的方式导出。但是注意导出序号表成员值需要加上Base值才得到真正的导出序号。由图8可以看出导出序号表的第一个成员是0x0000,将其加上Base值2,为2,刚好与第一个函数的导出序号相等。那么由此得知第一个函数同时以函数名导出,导出函数名自然位于导出函数名称地址表,那么是哪个成员呢?这个与导出序号表是一一对应的。因为在导出序号表中是第一个成员,那么对应导出函数名称地址表也是第一个成员,由如图9可以看出该地址表的第一个成员的值为0x0002e0bc,文件偏移也是0x0002e0bc,在文件中定位到该地址,可以看到是字符串fnDll2。由此得知第一个函数的导出函数名为fnDll2。接下来继续看导出函数地址表的第二个成员,由图6得知其地址RVA为0x00001014,导出序号为2+1=3。若获得导出函数名,同样需要在导出序号表中查找此导出序号。由图7得知,导出序号表中的序号为0+2=2和3+2=5。并不含有导出序号3。那么由此说明第二个函数没有以函数名的方式导出。继续看导出函数地址表的第三个成员,由图6得知其地址RVA为0x00000000,地址为零说明不存在此导出函数。也就是不存在导出序号为2+1+1=4的导出函数。再看导出函数地址表的第四个成员,由图6得知其地址RVA为0x0000100f,其导出序号为2+1+1+1=5,在导出序号表中查找该导出序号,由图7得知导出序号表中的第二个成员的导出序号为3+2=5刚好与其等。由此说明这个函数同时以函数名的方式导出。导出函数名同样需要查询导出函数名称地址表,因为在导出序号表中第二个成员,那么导出函数名称地址表中也对应是第二个成员,由图9得知该值为0x0002e0c3,转换为文件偏移也是0x0002e0c3,在文件中定位到该地址,由图中可以看出该地址指向字符串fnDll3。由此得知这个函数的导出函数名称为fnDll3。最终得到导出表结果如表下所示:

序号  名称  地址
2  fnDll2  1005
3  无      1014
5  fnDll3  100f

按照上述方法,你可以去尝试解析其他动态库的导出表以此练习。当然最好是按照上述方法编写程序去解析,否则碰到庞大的导出表,你手工解析会让你累坏的。