目录
PE中的导出表
导出表作用、链接库功能、获得导出函数地址:
构造含导出表的 PE 文件:
安转MASM32软件包:
DLL 源代码,编写def,Inc头文件、编译链接:
使用导出函数:
导出表数据结构
导出表定位:
导出目录结构 IMAGE_EXPORT_DIRECTORY:
导出表数据结构实例分析:
导出表编程:
根据编号查找函数地址:
根据名字查找函数地址:
遍历导出表:
导出表的应用:
导出函数覆盖:
导出私有函数:
PE中的导出表
这里重点介绍与导入表刚好相反的导出表,它描述了导出表所在 PE 文件向其他程序提供的可供调用的函数的情况。
导出表作用、链接库功能、获得导出函数地址:
导出表的作用:
代码重用机制提供了重用代码的动态链接库,它会向调用者说明库里的哪些函数是可以被别人使用的,这些用来说明的信息便组成了导出表。
通常情况下,导出表存在于动态链接库文件里。但我们不能简单地认为 EXE 中没有导出表,例如 WinWord.exe 文件里就有 ;也不能简单地认为所有的 DLL 中都有导出表,例如一些专门存放资源文件的 DLL 里就没有导出表。
不过可以这样理解 :
EXE 文件中很少有导出表,大部分的 DLL 文件中都有导出表。
提示:
如果你想开发一个类库供自己或他人使用,不仅要通过导出机制声明每个库文件的公用函数,还要详细编写每个函数的说明文档。
装载过程:
Windows 装载器在进行 PE 装载时,会将导入表中登记的所有 DLL 一并装入,然后根据 DLL 的导出表中对导入函数的描述修正导入表的 IAT 值。通过导出表,DLL 文件向调用它的程序或系统提供导出函数的名称、序号(编号),以及入口地址等信息。
综上所述,可以得出结论,导出表的作用有两个:
一是可以通过导出表分析不认识的动态链接库文件所能提供的功能。
二是向调用者提供输出函数指令在模块中的起始地址。
分析动态链接库功能:
很多时候,我们都得不到某些动态链接库中输出函数的完整说明,这时候只能函数的名字猜测。
下面是从一个安装程序中获取到的某动态链接库文件的导出表:
根据输出函数的英文提示不难看出,该动态链接库提供了程序的 License 验证功能 (通过系列函数PSA_GetLicenseXXXXXX 可以看出),而且程序具有限制并发连接数功能 (通过函数 PSA_GetNumberOfConnections 可以看出)。对于猜测出功能的函数,可以尝试通过程序调用来测试函数的参数以及调用方法,从而为自己所用。
获得导出函数地址:
对一个动态链接库里导出的函数的调用,既可以通过函数名称来进行,也可以通过函数在导出表的索引来进行。Windows 加载器将与进程相关的 DLL 加载到虚拟地址空间以后,会根据导入表中登记的与该动态链接库相关的由 INT 指向的名称或编号来遍历 DLL 所在虚拟地址空间,通过函数名或编号查找导出表结构,从而确定该导出函数在虚拟地址空间中的起始地址 VA,并将该 VA 覆盖导入表的 IAT 相关项。
在覆盖 IAT 的过程中,导出表起到了参照和指引的作用。如果一个动态链接库没有定义导出表,其内部包含的所有函数都无法被其他程序透明地调用。这里所说的透明,是指公开调用。当然,只要你掌握了动态链接库的内部编码,即使没有导出表,你也可以随意地引用里面的函数,哪怕这些函数是私有的
构造含导出表的 PE 文件:
将编写并创建一个动态链接库文件,该文件输出的函数可以被其他的基于 Windows GUI 的程序调用,用来增加程序窗口显示或退出时的动态效果。
本节的例子是本书第一个以 DLL 为扩展名的 PE 文件,它提供了两种窗口显示或退出时可以使用的特效:
口 逐级缩放
口 渐入渐出
使用一个 DLL 文件需要经过以下四步:
步骤1 编写 DLL 文件的源代码。
步骤2 编写函数导出声明文件(扩展名为 def)。
步骤3 使用特殊参数编译链接生成最终的 DLL 文件。
步骤4 编写包含文件(扩展名为 inc )。
在其他源代码中,可以通过静态引用和动态加载两种技术使用新编写的 DLL 文件中声明的导出函数。
安转MASM32软件包:
可以从网站 http://www.masm32.com/ 上获得 MASM32 SDK 的最新版本:
步骤1 :运行安装程序 installexe,安装汇编环境:
首先选择安装路径,此处我们选择的路径为D:(backup),然后单击“Start”按钮。此后,中间过程所有的按钮均选择默认的设置,即可完成软件安装。安装结束后,会显示IDE 汇编集成环境 QEditor 的界面,下图是该环境加载了 intro.txt 文档后的界面。
步骤2:建立自己的工作区:
笔者选择将软件安装到非系统盘 (D 盘),建议大家也这样做。为了能存放自己编写的汇编代码,笔者在 D:masm32 中新建立了一个文件夹 source。所有要做的准备工作都已经完成,不过现在还不能开始编写汇编程序,因为还设有告诉电脑汇编程序所依赖的环境,以及要调用的 API 函数库都在哪里。
步骤3:设置系统环境变量:
打开环境变量选项,在选项卡上单击“环境变量”按钮,在用户的环境变量中增加以下三个环境变量;
口include=d:masm32\include
口lib=d:masm32\lib
口path=d:\masm32\bin
步骤4:测试环境变量设置是否成功:
打开cmd,输入 ml 和 link ,如果窗口显示提示就说明安装成功:
DLL 源代码,编写def,Inc头文件、编译链接:
编写 DLL 源代码和编写其他程序唯一不同之处是,在源代码内部必须定义 DLL 的入口函数。该入口函数必须符合一定的格式。
显示窗口的特殊效果代码见 (chapter5\winResult.asm)
以上代码中一共定义了6个函数,其中有 4 个是可以被导出的公有函数,有一个私有函数,还有一个动态链接库必须具备的入口函数。
4 个公有函数分别代表了 4 种显示窗口的动态效果:
口AnimateOpen (窗口放大进入)
口AnimateClose(窗口缩小退出)
口FadeInOpen (窗口淡入)
口FadeOutClose (窗口淡出)
入口函数名为 DIEntry,由于我们没有初始化代码,所以只是简单地返回了TRUE。入口函数负责处理动态链接库的生命周期中发生的各种消息,如库被装载、御载、新线程创建和新线程结束等操作。入口函数的名字可以随意命名,但格式必须遵循一定规范(这些规范包括人口参数的个数和返回值的类型 ) 。
编写 def 文件:
为了能让系统识别哪些是私有函数,哪些是导出函数,还需要在源代码外另外附加一个文件,在文件中列出要导出的函数的名字。这个文件就是 def 文件。链接器会根据这个 def 文件的内容在导出表中加入由 EXPORTS 指定的函数名。
以下是编写 def 文件的过程 :
在记事本中输入如下内容,并保存为文件"winResult.def"。
编译和链接:
链接时需要额外增加两个链接参数:
新增加的两个链接参数分别是“-DLL”和“-Def ”。前者表示生成的最终文件是一个动态链接库,扩展名为 dll ,后者表示在生成的链接库的导出表中,加入该参数指定的 def 文件中的函数。
链接后生成以下两个相关文件:
口 winResult.dll 是动态链接库文件。该文件可以共享给使用其他高级语言如VC++、Delphi、VB 等的开发者使用。
口 winResult.lib 是汇编语言环境下的库文件。使用 winResult.dll 的汇编程序必须使用该库文件和下面要介绍的 inc 包含文件。
编写头文件:
包含文件扩展名为“.inc”,该文件类似于C语言的“.h”头文件,所以又称为头文件。该文件中包含了动态链接库中导出函数的声明。将如下内容输入记事本程序,并保存为“winResult.inc”。
有了动态链接库、相关的 lib 文件和头文件,就可以在任何一个程序的代码中使用这个DLL 里导出的函数了。
使用导出函数:
接下来使用刚生成的三个文件 , winResult.dll、winResult.lib 和 winResult.inc 来编写一个渐入式窗口显示程序:(代码在chapter5\FirstWindow.asm)
与引入其他动态链接库的方法一样,在代码行 19 引入 winResult.lib,在行 20 引入头文件 winResult.inc。创建窗口以后,显示时不再使用如下所示的正常的显示代码;
invoke ShowWindow,hwinMain,SW_SHONNORMRL
而是使用了具有动画特效的代码 (如下所示),该代码由动态链接库 winResult.dll 输出。
可以看出,调用公用函数的方法和调用其他动态链接库的方法(使用常规方法编译链接程序,生成 FirstWindow.exe,然后运行它以便查看窗口显示的动画效果) 是完全一样的。
最后使用编译命令编译生成可执行.exe 文件:
注意要和dll、lib、inc文件一起放在同一目录中:
导出表数据结构
接下来简单分析到目前为止接触的两种不同类型的 PE 文件:
口 FirstWindow.exe (EXE 可执行文件)
口 winResult.dll (DLL 动态链接库)
使用PEInfo 小工具查看两者结构,通过比较可以看出,两种不同类型的 PE 文件在内容上存在很大差异,如头文件中的装载基地址、人入口地址、文件属性均不相同。EXE 文件的 PE格式中不存在重定位表项,也没有导出表; 而 DLL 文件的 PE 格式中这两项都存在。
导出表定位:
首先回顾一下PE头中的扩展PE头IMAGE_OPTIONAL HEADER32中的最后一个字段IMAGE_DATA_DIRECTORY数据目录结构 :
现在锁定winResult.dll 的数据目录中导出表的RVA和导出表数据大小:
加框部分为导出表数据目录项信息。通过以上字节码可以获得与导出表有关的两条信息:
口 导出表所在地址 RVA=0x000002140
口 导出表数据大小 =0000008fh
下图是PEinfo显示的数据分析:
根据 RVA 与FOA 的换算关系,可以得到:导出表数据所在文件的偏移地址为: 0x00000940 。
导出目录结构 IMAGE_EXPORT_DIRECTORY:
导出数据的第一个结构是 IMAGE_EXPORT_ DIRECTORY。该结构详细定义如下:
导入表的 IMAGE_IMPORT_DESCRIPTOR 个数与调用的动态链接库个数相等,而导出表的IMAGE_EXPORT_DIRECTORY 只有一个。
IMAGE_EXPORT_DIRECTORY.nName:(文件名字符串所在地址)
+000ch,双字。该字段指示的地址指向了一个以“\0”结尾的字符串,字符串记录了导出表所在的文件的最初文件名。
IMAGE_EXPORT_DIRECTORY.NumberOfFunctions:(函数个数)
+0014h,双字。该字段定义了文件中导出函数的总个数。
IMAGE_EXPORT_DIRECTORY.NumberOfNames:(函数名字个数)
+0018h,双字。在导出表中,有些函数是定义名字的,有些是没有定义名字的。该字段记录了所有定义名字函数的个数。如果这个值是0,则表示所有的函数都没有定义名字。NumberOfNames 和NumberOfFunctions 的关系是前者小于等于后者。
IMAGE_EXPORT_DIRECTORY.AddressOfFunctions:(函数RVA值所在地址)
+001ch,双字。该指针指向了全部导出函数的入口地址的起始。从入口地址开始为双字数组,数组的个数由字段 IMAGE_EXPORT_ DIRECTORY.NumberOfFunctions 决定。导出函数的每一个地址按函数的编号顺序依次往后排开。在内存中,我们可以通过函数编号来定位某个函数的地址。大致代码如下:
IMAGE_EXPORT_DIRECTORY.nBase:(编号起始值)
+0010h,双字。导出函数编号的起始值。DLL 中的第一个导出函数并不是从 0 开始的,某导出函数的编号等于从 AddressOfFunctions 开始的顺序号(索引)加上这个值。
大致示意图如图下图所示:
Funl 的函数编号为 nBase+0=200h, Fun2 的函数编号为 nBase+1=201h,以此类推。
IMAGE_EXPORT_DIRECTORY.AddressOfNames:(指向存储函数名字符串的地址)
+0020h,双字。该值为一个指针。该指针指向的位置是一连串的双字值,这些双字值均指向了对应的定义了函数名的函数的字符串地址。这一连串的双字个数为NumberOfNames字段的值。
IMAGE_EXPORT_DIRECTORY.AddressOfNameOrdinals:(左边索引值所在地址)
+0024h,双字。该值也是一个指针,与 AddressOfNames 是一一对应关系 注意,是一一对应),所不同的是,AddressOfNames 指向的是字符串的指针数组,而 AddressOfNameOrdinals 则指向了该函数在 AddressOfFunctions 中的索引值。
注意:
索引值是一个字,而非双字。该值与函数编号是两个不同的概念,两者之间的关系为:索引值 =编号 - nBase
关系图描述字段之间关系:
上面所述的字段之间的关系可以用下图表示:
如图所示,AddressOfNames 中的函数是从 Function 2 开始的,也就是说这里假设Function 1 只提供编号访问 ; 其 nBase 为 200h,所以对应的 AddressOfNameOrdinals 是 0000h,但最终函数 Function 1 的编号为: 索引值 + nBase 的值,即 0200h。
提示:在以前的导入表图中中最后指向的结构“Hint/Name”中的Hint 值是AddressOfFunctions 的索引值,并非函数编号:
导出表数据结构实例分析:
下面以动态链接库 winResult.dll 为例,分析该 PE 文件的导出表结构及其数据组织。使用小工具PEDump 获取 winResult.dll 的导出表字节码内容如下(从文件地址偏移0x00000940 开始):
其中,主要字段所对应的字节码解释如下:
>>90 21 00 00
对应 IMAGE_EXPORT _DIRECTORY.nName 字段(文件名字符串所在地址),指向文件偏移 0x00000990。该处的值为字符串“winResult.dll”,是动态链接库的最初的名字。
>>01 00 00 00
对应 IMAGE_EXPORT_DIRECTORY.nBase 字段(编号起始值),表示起始编号为 1。
>>04 00 00 00
对应 IMAGE_EXPORT_DIRECTORY.NumberOfFunctions 字段(函数个数),表示共有 4 个导出函数。
>>04 00 00 00
对应 IMAGE_EXPORT_DIRECTORY.NumberOfNames 字段(函数名字个数),表示 4 个导出函数均为按照名称导出。
>>68 21 00 00
对应 IMAGE_EXPORT_DIRECTORY.AddressOfFunctions 字段(函数RVA值所在地址)。从该位置取出连续4个地址(个数由IMAGE_EXPORT_DIRECTORYNumberOfFunctions 字段决定),这些地址分别对应 4 个函数的RVA。
AddressOfFunctions 的值如下图:
>>78 21 00 00
对应 IMAGE_EXPORT_DIRECTORY.AddressOfNames 字段(指向存储函数名字符串的地址)。
从该位置取出的连续4个地址依次为:
>>88 21 00 00
对应 IMAGE_EXPORT_DIRECTORY. AddressOfNameOrdinals 字段(左边索引值所在地址、单字)。
从该位置取出的连续 4 个单字索引依次为:
这些索引的值存在于字段 IMAGE_EXPORT _ DIRECTORY.AddressOfFunctions 所指向的函数RVA地址列表中,注意函数是在实际的运行文件FirstWindow.exe中。最终 4 个函数的编号将分别是此处的索引值加上 nBase 的值,即 0001、0002、0003 和 0004。
函数名对应的索引值可以在调用了该动态链接库的程序 FirstWindow.exe 的导入表数据中查找到:
导出表编程:
如前所述,通过导出表可以获取相关函数的地址。函数可以通过索引值定位,也可以通过函数名定位。
通过编程查找函数地址有两个不同方法,分别是:
口 根据编号查找函数地址
口 根据名字查找函数地址
根据编号查找函数地址:
要通过编号查找函数地址,其步骤如下;
步骤1 定位到PE 头。
步骤2 从PE文件头中找到数据目录表,表项的第一个双字值是导出表的起始 RVA。
步骤 3 从导出表的 nBase 字段得到起始序号。
步骤4 函数编号减去起始序号得到的是函数在 AddressOfFunctions 中的索引号。
步骤5 通过查询 AddressOfFunctions 指定索引位置的值,找到虚拟地址。
步骤6 将虚拟地址加上该动态链接库在被导入到地址空间后的基地址,即为函数的真实入口地址。
不建议使用编号查找函数地址。因为有很多的动态链接库中标识的编号与对应的函数并不一致,通过这种方法找到的函数地址往往是错误的。
根据名字查找函数地址:
要根据函数名字从导出表结构中查找函数的地址,步骤如下:
步骤1 定位到PE 头。
步骤2 从PE文件头中找到数据目录表,表项的第一个双字值是导出表的起始 RVA。
步骤3 从导出表中获取 NumberOfNames 字段的值,以便构造一个循环,根据此值确定循环的次数。
步骤4 从 AddressOfNames 字段指向的函数名称数组的第一项开始,与给定的函数名字进行匹配; 如果匹配成功,则记录从 AddressOfNames 开始的索引号。
步骤5 通过索引号再去检索 AddressOfNameOrdinals 数组,从同样索引的位置找到函数的地址索引。
步骤6 通过查询 AddressOfFunctions 指定函数地址索引位置的值,找到虚拟地址。
步骤7 将虚拟地址加上该动态链接库在被导和人到地址空间的基地址,即为函数的真实入口地址。
其中通过函数名获取函数调用地址的编码见下面代码清单:
获取指定字符串的 API 函数的调用地址的函数 _getApi(chapter5\peinfo.asm)
遍历导出表:
遍历导出表的编程是以PEInfo.asm 程序为模板开始的。在函数 .openFile 中加入以下代码 (加黑部分):
遍历导出表的函数 _getExportlnfo (chapter5\peinfo.asm):
代码略~
以下内容是使用 PEInfo 小工具分析 chapter5\winResult.dll 文件输出的与导出表有关的信息:
显示信息分三部分 :
导出表所处的节
导出表结构 IMAGE_EXPORT_DIRECTORY 的主要字段的值
导出函数的相关信息(含导出序号、虚拟地址和导出函数名称)。
导出表的应用:
导出表和常见的应用主要包括对导出表函数的覆盖,以及对动态链接库内部私有函数的导出等。通过对导出表函数进行覆盖,可以更改代码流程或代码功能,为应用程序实施补丁 。
导出函数覆盖:
导出表编程中常见的技术是,不需要修改用户程序,便能将用户程序中调用的动态链接库函数转向或者实施代码覆盖,实现用户程序的调用转移。这种技术通常用在病毒程序的开发中,因为用户程序没有发生改变,所以杀毒软件在对用户程序的防护过程中,针对这种渗透是无效的。
下面介绍两种常见的导出函数覆盖技术:
口 修改导出结构中的函数地址
口 覆盖函数地址部分的指令代码
修改dll导出结构中的函数地址:
以 winResult.dll 为例, 将 AddressOfFunctions 索引1 和2 的地址(分别对应函数 AnimateOpen 和 FadeInOpen) 交换位置:
如上所示,无需修改应用程序FirstWindow.exe,仅通过将函数调用RVA 地址0x00001282 和 0x00001022 交换位置,即可实现导出函数的覆盖。直接测试,发现显示窗口的动画效果发生了变化。
需要注意的是,在使用导出函数地址覆盖技术的时候,首先要保证所涉及的两个函数参数入口(即参数的个数、类型、大小)要一致,否则调用完成后栈不平衡,这会导致应用程序调用失败;其次,要求用户对两个函数的内部实现要有充分的了解,使得地址转向后,能够保证应用程序在功能上可以全面兼容并运行良好。
覆盖dll中函数地址部分的指令代码:
第二种常见的覆盖技术,是将 AddressOfFunctions (函数RVA所在地址)指向的地址空间指令字节码实施覆盖。
这种技术又衍生出两种:
口 暴力覆盖,即将所有的代码全部替换为新代码。新代码可能含有原来代码的全部功能,也可能不包含原有代码功能。
口完美覆盖,通过构造指令,实施新代码与原代码的共存和无遗漏运行。为完美覆盖涉及代码的重定位,相对复杂一些。
这里以暴力覆盖为例,相关文件在随书文件的 chapters\b 目录中,winResult.dll 是被覆盖了函数 FadeInOpen 后的动态链接库。
打开 winResult.dll 文件的字节码,将FadeInOpen 定义部分(文件中起始偏移 0x0682) 修改成成如下指令序列:
第一个红框保存原始栈基地址,第二个红框是维持栈平衡的返回指令。所有字节码对应的反汇编指令为:
因为函数 FadeInOpen 的代码被全部覆盖,所以运行 FirstWindow 只会弹出提示对话框,内容显示“user32.dll”。显示的字符串是借用了 winResult.asm 的数据段中定义的函数SetLayeredWindowAttributes 所在动态链接库的名称。
从反汇编指令可以看出,调用函数 user32.MessageBoxA 时使用了硬编码,即将该函数在虚拟地址空间分配的 VA 直接写人了代码段,不通过导入表直接跳转到函数代码处执行,上面反汇编代码加黑部分即为地址字节码(在这里 OD 错误地将它识别成了指令序列)。使用硬编码最大的好处是引入动态链接库的函数时不需要修改导入表。因为 FirstWindow 引入的动态链接库就这一个,不存在基地址被占用的问题,所以,与重定位有关的信息在此例中不需要进
行修改。
导出私有函数:
在某些场合下,DLL 中的私有函数还是很有用的。也许是出于保密考虑,或者其他原因,DLL 的开发者将一些比较重要的函数设置为内部私有函数,并不在导出表中声明。当程序被二次开发时,开发者却需要这些函数,这时候就需要开发者自己将这些被定义为私有的函数添加到导出表中。
在本章的实例中,程序 winResult.dll 一共导出了4 个公有函数; 源代码中的 TopXY 函数被声明为私有函数,并未导出,所以在使用PEInfo 分析时看不到该函数。
导出私有函数所要的步骤:
首先,将最原始的导出表整体搬迁到一个空闲空间中(原来部分可删可不删)。
先附上前面导出表数据结构实例分析和导出表定位中确定的导出表RVA和数据大小:
这里选择从文件偏移 0x0940 处搬到 0x0a50 处。以下是添加私有函数到导出表后的两处地址对应的字节码:
(注意将覆盖dll中函数地址部分的指令代码目录中暴力覆盖的FadeInOpen 定义部分改回原样,不然程序运行不了)
现在从几个方面来分析添加了私有函数的导出表与原有导出表的区别:
1) 长度从 8Fh 变成了 9Fh。增加的部分包含:
口 函数名:'TopXY\0'共 6 个字节。
口 函数的 RVA: 0x0000100c,共 4 个字节。
口 函数名称所在地址: 0x000032e9,共 4 个字节。
2) 函数个数由原来的4个变成 5 个(见字节码的第一个蓝色大框部分)。
3) 修正其他因搬迁和增加而变动的地址。比如内存30偏移对A0。
4) 数据目录项修改。因为导出表位置和数据大小发生了变化,所以 PE 文件头部的数据目录项中需要进行如下修正:
口位置由原来的 0x2140 变成 0x3050。
口大小由原来的 8Fh 变成 9Fh。
如上所示,导出函数已经由最初的4个变成了5个 ; 在导出函数的描述部分也显示了新函数的导出序号、函数所在的RVA,以及新导出函数的名称TopXY,这意味着将私有函数转换为导出函数是成功了。