导出表
导出表的作用
一个可执行程序是由多个PE文件构成的
利用EverEdit.exe为例,查看运行他所需的所有模块
使用od载入程序,利用e查看载入的模块(dll与exe程序,PE文件载入内存后可称作模块)
我们可以看出,该程序除了载入自身exe程序外,还在外部动态链接了大量dll文件,这些dll为程序提供了运行所需的一些函数
1 | <!--more--> |
就比如MessageBoxA的弹窗函数就是由User32.dll模块提供
以上的每个模块都发挥着其作用,使得程序得以正常运行
一个程序引用哪些模块是由其导入表决定的
与导入表相对应的便是导出表,导出表则是决定当前PE文件能给其他PE文件提供的函数
拿User32.dll举例,它的导出表结构中,一定包含着MessageBoxA这个函数,否则它不可能提供这个函数给程序。
导入表与导出表概念总结:
- 导出表:设置本PE文件可以将什么函数提供给其他PE文件
- 导入表:该PE文件可以使用哪些PE文件。
个人理解,PE文件中的导入表像是C/C++中的#include
何为导出表?
导出表便是记录该PE文件提供给其他PE文件的函数的一种结构
定位导出表
定位导出表的原理
导出表的定位可以利用PE文件_IMAGE_OPTIONAL_HEADER32中的DataDirectory结构进行定位。
DataDirecoty是一个长为16的数组,每个数组成员对应一个表
1 | 成员 数据宽度 |
数据目录中共有16个表
根据表的结构可知,导出表的下标为0
即DataDirectory[0]表示导出表
根据c语言中,该成员在扩展PE头中的定义
1 | IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES]; |
可以看到数组成员的结构为IMAGE_DATA_DIRECTORY
IMAGE_DATA_DIRECTORY
1 | typedef struct _IMAGE_DATA_DIRECTORY { |
这样的结构于扩展PE头内有15个
IMAGE_DATA_DIRECTORY成员 | 数据宽度 | 说明 |
---|---|---|
VirtualAddress | DWORD(4字节) | 表的起始位置(RVA) |
Size | DWORD(4字节) | 表的大小 |
VirtualAddress 意为一个相对虚拟地址(RVA)
Size: 表的大小
根据VirtualAddress 与 Size 可求出该表的范围。
同时,IMAGE_DATA_DIRECTORY仅仅记录表的位置和大小,没有描述表的具体结构
流程
1.找到IMAGE_OPTIONAL_HEADER32的最后一个成员DataDirectory
2.获取DataDirectory[0]
3.根据DataDirectory[0].virtualAddress得到导出表的RVA
4.将导出表的RVA转换为FOA,利用16进制编辑器定位导出表
RVA = 区块.VirtualAddress + 差值
1 | ### RVA 转 FOA 计算方式 |
分析实例
以lyl610abc师傅于文中公布的dll文件为例,进行调试
导出函数的声明定义
1 | EXPORTS |
具体导出函数定义
1 | int _stdcall Add(int x, int y) |
找到DataDirectory
由于DataDirectory数据目录本身在PE头的扩展头部分,因此我们需要先获取到PE头的起始位,再根据IMAGE_NT_HEADERS结构的PE文件头标志大小+标准PE头大小达到扩展PE头:
演示DLL文件为PE32文件,因此PE格式的标准头文件为 IMAGE_NT_HEADERS:
扩展PE头首地址:0xF8+0x4+0x14(20) = 0x110
同时扩展PE头大小为224 字节,数据目录(DATADirectory大小) = _IMAGE_DATA_DIRECTORY大小*16 = 8 *16 = 128
因此224 - 128 = 96个字节
因此扩展PE头起始地址+ 96字节后为DataDirectory结构的起始地址
DataDirectory起始地址 = 扩展头地址+ 0x60(96) = 0x110 + 0x60 = 0x110
由此拿到DataDirectory,顺序排放DataDirectory[0] ~ DataDirectory[15] 8字节一张表
DataDirectory[0] 对应着导出表
IMAGE_DATA_DIRECTORY成员 | 值 | 说明 |
---|---|---|
VirtualAddress | 0x00018FB0 | 表的起始位置(RVA) |
Size | 0x00000190 | 表的大小 |
得到导出表的RVA
根据结构拿到导出表RVA值为: 0x018FB0
导出表的size为: 0x0190
RVA转换FOA
但是IMAGE_DATA_DIRECTORY中的VirtualAddress 是RVA它在内存中才有的用, 在磁盘文件下需要将其转换成FOA
直接利用代码获取对应的FOA:
1 | // PE.cpp : Defines the entry point for the console application. |
关键代码
1 | VaToFoa32(nt->OptionalHeader.ImageBase +0x18FB0,dos,nt,sectionArr); |
由于之前的函数是VA转FOA,这边我们拿到的是RVA,因此想要正确利用,需要用到RVA + ImageBase得到VA
自此拿到FOA为:0x79B0
导出表的结构
介绍
可以定位到导出表后下一步便是要了解导出表的结构,才能解读导出表的信息
C语言中的结构体(Winnt.h可以找到),本结构在C语言中长40字节,导出表范围:导出表首地址(FOA)+导出表长度-1 (减1是由于导出表首地址也占一个字节)
1 | typedef struct _IMAGE_EXPORT_DIRECTORY { |
结构体分析
成员 | 数据宽度 | 说明 |
---|---|---|
Characteristics | DWORD(4字节) | 标志,未用 |
TimeDateStamp | DWORD(4字节) | 时间戳 |
MajorVersion | WORD(2字节) | 未用 |
MinorVersion | WORD(2字节) | 未用 |
Name | DWORD(4字节) | 指向该导出表的文件名字符串 |
Base | DWORD(4字节) | 导出函数起始序号 |
NumberOfFunctions | DWORD(4字节) | 所有导出函数的个数 |
NumberOfNames | DWORD(4字节) | 以函数名字导出的函数个数 |
AddressOfFunctions | DWORD(4字节) | 导出函数地址表RVA |
AddressOfNames | DWORD(4字节) | 导出函数名称表RVA |
AddressOfNameOrdinals | DWORD(4字节) | 导出函数序号表RVA |
解释几个比较重要的字段
Name
该字段指示的地址指向了一个以”\0”结尾的字符串,字符串记录了导出表所在文件的最初文件名,因此就算后期重命名此PE文件,也可在name处找到最初文件名
Base:
该字段指出了导出函数序号的起始值。dll中第一个导出函数的序号并非从0开始,导出函数的编号等于从AddressOfFunction开始的序号加上这个值。大概意思是这样
如图所示,Func1的函数编号为nBase + 0 = 200h, Func2的函数编号为 nbase + 1 = 201h
NumberOfFunctions
该字段定义了文件中导出函数的总个数
NumberOfNames
在导出表中,有些函数是定义名字的,有些是没有定义名字的。该字段记录了所有定义名字函数的个数。如果这个值是0,则表示所有的函数都没有定义名字。NumbersOfNames一定小于等于NumbersOfFuctions
AddressOfNames
该值为一个指针。该指针指向的位置是一连串的DWORD值,这些值均指向了对应的定义了函数名的函数的字符串地址。这一连串的DWORD值的个数为NumberOfNames
AddressOfFunctions
该指针指向了全部导出函数的入口地址的起始。从入口地址开始为DWORD数组,数组的个数由NumbersOfFuctions决定
导出函数的每一个地址按函数的编号顺序依次往后排开。在内存中,可以通过函数编号来定位某个函数的地址
AddressOfNameOrdinals
该值也是一个指针,与AddressOfNames是一一对应关系
不同的是,AddressOfNames指向的是字符串的指针数组,而AddressOfNameOrdinals则指向了该函数在AddressOfFunctions中的索引值
注意:索引值数据类型为WORD,而非DWORD。该值与函数编号是两个不同的概念,两者的关系为:
索引值 = 编号 - Base
根据结构分析导出表
根据之前拿到的导出表FOA + 40 - 1 ,拿到导出表范围,自0x79B0 至0x79D7
成员 | 值 | 说明 |
---|---|---|
Characteristics | 0x00000000 | 标志,未用 |
TimeDateStamp | 0xFFFFFFFF | 时间戳 |
MajorVersion | 0x0000 | 未用 |
MinorVersion | 0x0000 | 未用 |
Name | 0x0001900A | 指向该导出表文件名字符串 |
Base | 0x0000000A | 导出函数的起始序号 |
NumberOfFunctions | 0x00000008 | 所有导出函数的个数 |
NumberOfNames | 0x00000003 | 以函数名字导出的函数个数 |
AddressOfFunctions | 0x00018FD8 | 导出函数地址表RVA |
AddressOfNames | 0x00018FF8 | 导出函数名称表RVA |
AddressOfNameOrdinals | 0x00019004 | 导出函数序号表RVA |
Name
存储的值为指针,该指针为RVA(0x1900A),同样需要转换成FOA
1 | VaToFoa32(nt->OptionalHeader.ImageBase+0x1900A,dos,nt,sectionArr); //0x1900A 为name的rva |
运行可得
利用16进制编辑器得到0x7A0A,拿到Name,PE文件的文件名字符串,以00结尾,MyDll.dll
Base
导出函数起始序号的RVA 为 0xA , 对应十进制10
回顾之前导出函数的定义
1 | EXPORTS |
可以看出,此处的base=最小的序号=min{12,15,17,10}=10
NumberOfFunctions
所有导出函数的个数为8
为什么前面声明的导出函数只有4个,但这里显示的却会有八个?
此处的NumberOfFunctions = 最大的序号减去最小的序号 +1 =17-10+1=8
NumberOfNames
以函数名字导出的函数个数为3,和定义声明中有名称的导出函数数量一致
AddressOfFunctions
存储的值为指针,该指针为RVA(0x18FD8),同样需要转换为FOA
1 | VaToFoa32(nt->OptionalHeader.ImageBase+0x18FD8,dos,nt,sectionArr); |
利用之前的VA转FOA程序修改VaToFoa32代码处数值
运行程序可得AddressOfFunctions处文件偏移为0x79D8,跳转到该地址
根据AddressOfFunctions (DWORD)为数组且数组个数由NumbersOfFuctions决定,本dll文件NumberOfFunctions为8
因此8x4=32(10进制字节数)
拿到每个函数的RVA.
记录下每个导出函数的地址并转换为RVA为FOA得到
Oridinals(索引) | 序号(Base+ORIDINALS) | 导出函数地址(RVA) | 导出函数地址(FOA) |
---|---|---|---|
0 | 10+0 = 10 | 0x00011320 | 0x720 |
1 | 10+1=11 | 0x00000000 | |
2 | 12 | 0x00011302 | 0x702 |
3 | 13 | 0x00000000 | |
4 | 14 | 0x00000000 | |
5 | 15 | 0x000111EF | 0x5EF |
6 | 16 | 0x00000000 | |
7 | 17 | 0x000111A4 | 0x5A4 |
可以发现,只有4个函数存在有效地址,跟前方Mydll.dll定义的序号以及数量一致
AddressOfNames
内部存储着导出函数名称表的RVA
存储的值为指针,该指针为RVA,同样需要转为FOA
1 | VaToFoa32(nt->OptionalHeader.ImageBase+0x18FF8,dos,nt,sectionArr); |
16进制编辑器直接跳转过去
导出函数名称表 = 导出函数名称表单个大小(DWORD) * NumberOfNames值 = 4 * 3 = 12(十进制)
拿到所有导出函数名称的地址
1 | 0x00019014 |
利用程序将RVA转为OA
1 | VaToFoa32(nt->OptionalHeader.ImageBase+0x19014,dos,nt,sectionArr); |
按照顺序依次拿到
1 | foa 7a14 |
即得到有名称函数的名称地址为:
顺序索引 | RVA | FOA |
---|---|---|
1 | 0x19014 | 0x7A14 |
2 | 0x19018 | 0x7A18 |
3 | 0x1901F | 0x7A1F |
利用FOA与16进制编辑器拿到各自的函数名字符串
顺序索引 | RVA | FOA | 导出函数名称 |
---|---|---|---|
1 | 0x19014 | 0x7A14 | Add |
2 | 0x19018 | 0x7A18 | Divide |
3 | 0x1901F | 0x7A1F | Multiply |
AddressOfNameOrdinals
存储的值为指针,指针为RVA,同样需要转为FOA在PE文件中读取
1 | VaToFoa32(nt->OptionalHeader.ImageBase+0x19004,dos,nt,sectionArr); |
获取foa为 7A04
跳转至0x7A04位置
由NumberOfFunctions可知共有3个有名函数
因此Ordinals列表为 4x3=12
拿到有名称的Ordinals
Ordinals的为Word型,共2个字节
顺序索引 | Oridinals | 序号(Oridinals+Base) |
---|---|---|
1 | 0x0002 | 12 |
2 | 0x0000 | 10 |
3 | 0x0007 | 17 |
根据有名称函数的Oridinals结合前面得到的AddressOfFunctions和AdressOfNames,就可以得到函数的名称、函数的地址的关系
顺序索引 | Oridinals | 导出函数地址(RVA) | 导出函数地址(FOA) | 函数名称 |
---|---|---|---|---|
1 | 0x0002 | 0x00011302 | 0x702 | Add |
2 | 0x0000 | 0x00011320 | 0x720 | Divide |
3 | 0x0007 | 0x000111A4 | 0x5A4 | Multiply |
至此导出表分析完毕
由导出表获得导出函数
从前面的分析中可以得知查询导出表有两个办法
- 根据导出表函数名称获得导出函数地址
- 根据导出表函数序号获得导出函数地址
函数名称获取导出函数地址
- 根据导出表的函数名称去AddressOfNames指向的每个名称字符串查询是否有匹配的字符串
- 找到匹配的字符串后,根据找到的顺序索引去AddressOfNameOrdinals中找到对应的Ordinals
- 根据前面找到的Ordinals到AddressOfFunctions中获得函数地址
图示
函数序号取得导出函数
- 根据函数序号-导出表.Base获得导出函数的Ordinal
- 根据前面拿到的Ordinals到AddressOfFunctions中获得函数地址
图示
也可利用工具得出导出表的RVA,后利用RVA转FOA可获得其文件偏移量
总结
- 导出表中还包含了三张小表:导出函数地址表、导出函数名称表
- 导出表中存储了指向这三张表的指针,而不是直接存储表内的内容
- 无论是根据函数名称还是函数序号来获取导出函数,都需要用到Ordinals,用Ordinals到导出函数地址表中获取地址
- 导出表的Base取决于编写DLL时导出定义的最小序号
- 导出表的NumberOfFuctions取决于编写DLL时导出定义的序号最大差值+1
- 导出名称表和导出函数序号表只对有名称的导出函数有效
学习复现自52pojie论坛内的lyl640abc 师傅的pe笔记系列