本篇文章学习PE导入导出表、重定位表以及函数转发的概念。
导出表
导出表的作用就不赘述了,直接贴chatgpt的回答:
由于一般exe都没有导出表,我们随便找个dll
记得之前的文章讲过IMAGE_OPTIONAL_HEADER里的DataDirectory字段保存的是PE使用的各种表的信息,DataDirectory的结构如下。
typedef struct _IMAGE_DATA_DIRECTORY { DWORD VirtualAddress; //表在内存的相对地址 DWORD Size; //表的大小,以字节为单位} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY
我们可以在010中找到对应的导出表项
说明导出表的RAV是16500H,大小是834H,我们还可以看到导出表的结构名称是IMAGE_DIRECTORY_ENTRY_EXPORT,其结构如下。
typedef struct _IMAGE_EXPORT_DIRECTORY {
DWORD Characteristics; // 未使用
DWORD TimeDateStamp; // 时间戳
WORD MajorVersion; // 未使用
WORD MinorVersion; // 未使用
DWORD Name; // 指向该导出表的文件名字符串RVA
DWORD Base; // 导出函数的起始序号
DWORD NumberOfFunctions; // 所有导出函数的个数
DWORD NumberOfNames; // 以函数名字导出的函数个数
DWORD AddressOfFunctions; // 导出函数地址表RVA
DWORD AddressOfNames; // 导出函数名称表RVA
DWORD AddressOfNameOrdinals; // 导出函数序号表RVA
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;
由于保存的是RAV,我们肯定不能直接到文件16500H处找导出表,需要将FOA和RAV转化,很麻烦,有两种方便找到导出表的方法。第一种是直接在内存中找导出表,第二种是利用CFF Explorer。先说第一种。
我们可以在x64dbg中查看dll的进程布局(x64dbg可以手动装载dll),但是dll不是exe,不能单独调试,我们需要先选一个exe进行调试。
然后在符号界面右键点击注入模块,选择你要查看的dll
就可以在内存中找到了
第二种方法是使用CFF Explorer,CFF Explorer会把各种表解析出来
其中的Offset是FOA,这样就可以直接静态找到导出表了,比第一种方法方便
根据上图,IMAGE_EXPORT_DIRECTORY的文件偏移量是15900H
我们找到name字段,其偏移量是0x0c
不过这个值是一个RVA,看来还是得在内存中查找
我们找到dll的基址是00007FF9C1EF0000H,加上000167EEH,应该是00007FF9C1F067EEH
说明该导出表的名称是VCRUNTIME140.dll
接着看Base、NumberOfFunctions、NumberOfNames,调用函数可以通过函数名称也可以通过函数序号,所以导出函数也会有名称和序号。
说明导出函数有0x47个,并且全部都以名称导出
最后看AddressOfFunctions、AddressOfNames、AddressOfNameOrdinals
AddressOfFunctions很好理解,函数不可能保存在导出表中,所以这里保存的是一个RVA,指向的是每个元素大小为4字节的数组,元素个数由NumberOfFunctions提供,每项元素为导出函数地址的RVA,是的没看错,指向的仍然是RVA,相当于是间接在间接,我们在内存空间中看看。
首先AddressOfFunctions的值为0x16528
找到0x00007FF9C1F06528,之后每四个字节为一个RVA,共0x47个
再来看AddressOfNames,也是指向元素大小为4字节的数组,元素个数由 NumberOfNames提供,也是每项元素都是RVA,指向真正的函数名字符串。
AddressOfNames的偏移量是0x16644
找到对应地址,同理,每四个一组,每组都是一个RVA,我们随便找一组吧,就找第一个,0x000167ff
我们找到的函数名与工具解析的是一致的
其实后面一大串都是函数名,AddressOfNameOrdinals,该成员相当于指向元素大小为2字节的数组,元素个数由 NumberOfNames提供,元素值加上Base为函数导出序号,不再详细分析。
接下来需要知道这三个字段之间的联系
可以很直观的看到,AddressOfNameOrdinals和AddressOfName其实是按数组下标一一对应的,AddressOfNameOrdinals的值则是对应AddressOfFunctions的下标,那么就有如下两句话:
1.通过函数名称调用函数时,先遍历导出函数名称表中的数组,如果函数名称表中查找到对应函数名,将函数名称表中索引值作为下标去函数序号表中查找对应值,然后将函数序号表中的值作为函数地址表中的下标即可得到导出函数地址
2.通过函数序号调用函数时,先用函数序号减去Base(函数序号起始值),再将得到的值作为函数地址表中的下标即可得到导出函数地址
根据上图我们知道,函数_CreateFrameInfo在AddressOfName中的下标是0,那么对应AddressOfNameOrdinals中下标为0的值。
对应的值是0,那我们就找AddressOfFunctions下标为0的元素
说明_CreateFrameInfo函数的RVA是0x0001030
与工具解析是一致的,看来三个字段的对应关系没有问题,顺带提一嘴,导出表一般位于 .edata 节(export data 的缩写),也可以在.rdata中。
导入表
导入表会比导出表复杂一些,解析导入表可以知道程序依赖的函数,能从一定程度上了解程序是否恶意,在对抗杀软时混淆导入表也是常用的方法,所以相对来说我们会更加关注导入表的情况。
导入几个模块就有几张导入表,每个表记录该模块的信息,每个导入表的大小是20个字节,从起始位置划分每20字节一组,直到出现一组20字节全部为0即代表结束,其实只要Name字段是0就满足结束条件了。
导入表大部分PE文件都有,可以选择Project3.exe来调试
RVA是3AF4H,大小是0xB4,确实有多张导入表,导入表的名称是IMAGE_IMPORT_DESCRIPTOR,结构如下:
typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics;
DWORD OriginalFirstThunk; //导入名称表(INT)的地址RVA
} DUMMYUNIONNAME;
DWORD TimeDateStamp; //时间戳多数情况可忽略.如果是0则表明IAT没有绑定,如果是-1(0xFFFFFFFF),则表示IAT为绑定导入
DWORD ForwarderChain; //链表的前一个结构
DWORD Name; //导入DLL文件名的地址RVA
DWORD FirstThunk; //导入地址表(IAT)的地址RVA
} IMAGE_IMPORT_DESCRIPTOR;
DUMMYUNIONNAME一般都是表示OriginalFirstThunk,ForwarderChain在早
期的绑定导入中使用,用于表示当前 DLL 的导出是否是被转发到其他 DLL 的函数,后面会提到,ForwarderChain基本不用。
我们先找到导入表
每20个字节为一张导入表,直到全为0的部分
共8张表
与工具解析的是一样的,找到其中一张表看看name字段
仍然是一致的,其它字段涉及IAT、INT,结构如下
typedef struct _IMAGE_THUNK_DATA64 {
union {
ULONGLONG ForwarderString; // 如果是转发,指向转发字符串
ULONGLONG Function; // IAT 表中被填充后的函数地址
ULONGLONG Ordinal; // 按序号导入时,高位为1,低位为序号
ULONGLONG AddressOfData; // 指向 IMAGE_IMPORT_BY_NAME(INT使用)
} u1;
} IMAGE_THUNK_DATA64;
涉及IMAGE_IMPORT_BY_NAME结构,如下:
typedef struct _IMAGE_IMPORT_BY_NAME {
WORD Hint; // 函数在导出表中的提示索引(可选)
CHAR Name[1]; // 函数名称(以 null 结尾的 ASCII 字符串)
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;
IAT、INT其实就是同一种结构的不同表示,我们可以看到_IMAGE_THUNK_DATA64其实只有8个字节,只是在不同的场景有不同的含义,ForwarderString暂时不讲,后面讲,Function用在IAT中用,Ordinal和AddressOfData用在INT中,区别就是如果是函数序号导入,则解释为Ordinal,如果是名称导入,则解释为AddressOfData
最高位如果是1,那么除去最高位,剩下的就是函数的函数序号,否则就是一个RVA,指向一个IMAGE_IMPORT_BY_NAME数组。
关于IMAGE_IMPORT_BY_NAME的Hint字段,如果其不为零的话,它会先用 Hint 作为索引访问 AddressOfNames[Hint],取得导出函数名称,然后和导入表中 IMAGE_IMPORT_BY_NAME.Name 做一个字符串比较,如果相等,表示 Hint 命中;如果不等,就根据Name字段遍历整个导出表查找函数名称,Name字段看着只有一个元素,实际是变长数组。
INT由很多的IMAGE_THUNK_DATA结构体构成,要用到对应dll的几个函数,就有几个IMAGE_THUNK_DATA结构体,每个结构体大小是8字节,当出现连续的8字节为0,则说明INT截止。
IAT表在文件未运行前跟INT表是一样的,在运行后会变成存放函数的直接地址,在文件运行过程中,程序会遍历解析每个IMAGE_THUNK_DATA,通过函数名、函数序号到导出表中获得函数的直接地址并填入IAT
神图如下
多说无益,还得实践,我们仍然是找第一个导入表
INT的RVA是00003c28,IAT的RVA是00003080,先看INT
五个8字节,以最后一个全零8字节结尾,说明有五个函数,不会错,因为是INT,并且最高位不是1,所以要么表示ForwarderString,要么表示AddressOfData,继续跟进。
指向的并不是一个字符串,所以是AddressOfData,前两个字节是Hint,后面的字符串是Name
与工具解析的是一样的,接着来看IAT
仍然是5个8字节,甚至都不用自己判断地址是否是函数地址,x64dbg已经给出
值得注意的是这些数据都是存储在.rdata节中。
函数转发
前面一些字段涉及到函数转发这个概念,那么什么是函数转发呢
一个 DLL 中声明导出某个函数,但这个函数并不在自己里面实现,而是转发到另一个 DLL 的某个函数,起到“中转”的作用。
我们都知道,Windows的dll是一层层调用的,比如我们使用的是kernel32!GetCurrentProcess,但其实kernel32还会调用ntdll!NtGetCurrentProcess,那这是函数转发吗,其实不是。
这其实是封装,GetCurrentProcess函数在kernel32中是有定义的,类似
HANDLE GetCurrentProcess()
{
调用ntdll!NtGetCurrentProcess
return (HANDLE)-1; // 其实就是一个固定值伪句柄
}
所以Windows实际函数转发的情况比较少,真正的函数转发一般没有定义,而是直接转发,函数转发下PE加载流程如下。
当加载器(Windows Loader)解析 IAT 表(IMAGE_IMPORT_DESCRIPTOR -> FirstThunk)时,会按如下逻辑处理:
1.遍历每个 IMAGE_THUNK_DATA 项(即 IAT)
2.如果它是名称导入,找到 IMAGE_IMPORT_BY_NAME 所指的名称
3.定位导入的 DLL(Name 字段指向导入 DLL 名)
4.加载对应的导出表(IMAGE_EXPORT_DIRECTORY)
5.查找目标函数
6.解析转发字符串:
7.加载器递归处理,去那个 DLL 中继续找真正的函数地址
8.最终在原进程的 IAT 表中填入目标函数的地址(即 Function 字段)
我们可以写一个函数转发的例子,实现函数转发有两种方法
使用.def文件或者在C代码中添加#pragma comment(linker, "/EXPORT:MyFunc=KERNEL32.Sleep")
方法一:贴chatgpt(狗头)
方法二:我更喜欢这种方法,更方便
DLL导出MyGetTickCount,但是并没有定义,只是转发到KERNEL32.GetTickCount
然后我们在exe中动态加载相应的DLL
现在自定义模块已装载
函数地址的偏移量是0x2878
导出函数偏移量是0x289e
可以看到其指向的并不是一个函数地址,而是一串字符串
重定位表
重定位的结构相较简单一些,我们在数据目录表中找到其RAV
其实就是.reloc节
有效内容很少,那么重定位表的作用是什么呢
在链接生成 PE 文件时,如果某个变量、函数、或跳转目标是基于 ImageBase 来计算的地址,那么这些地址都会以“绝对地址”或“基于 ImageBase 的偏移地址”写入代码或数据段中,当一个 PE 文件不能加载到它预期的基址时,ImageBase的值会变,那么原先的那些数据就需要修正,重定位表中记录的就是这些数据的地址。
重定位表的基本结构是IMAGE_BASE_RELOCATION,可以有多个,结构如下
typedef struct _IMAGE_BASE_RELOCATION {
DWORD VirtualAddress; // 起始RVA
DWORD SizeOfBlock; // 整个块的大小(含自己+后续所有重定位项)
} IMAGE_BASE_RELOCATION;
IMAGE_BASE_RELOCATION后面会跟着一系列 WORD 类型的重定位项,每个 WORD 的格式如下
高4位(type) 低12位(offset)
[15:12] [11:0]
在计算时,我们需要修正的数据的地址的RVA就是IMAGE_BASE_RELOCATION. VirtualAddress+ offset
前8个字节是IMAGE_BASE_RELOCATION结构,说明起始RVA是00003000,然后又0x30个重定位项,我们看第一个,其值是0xA1D0,按照上述格式分开type就是10,offset就是0x1D0,加上起始RVA,实际RVA就是0x31D0
RVA=0x31D0,说明要修改的地方在.rdata中
看上去像是函数地址,其实我并不能很准确的知道这些都是什么地址,感觉是内置函数的地址。
还有就是重定位表的重要性,如果能保证主模块加载地址就是原来的基址,那么重定位表就用不上,还有就是如果开启了ASLR,那就一定要有重定位表了。
看雪ID:mb_ttrwvnrc
https://bbs.kanxue.com/user-home-1011025.htm
#
原文始发于微信公众号(看雪学苑):PE结构学习
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论