PE数据目录表解析

admin 2021年12月4日05:16:17评论128 views字数 12105阅读40分21秒阅读模式
PE数据目录表解析

点击上方“蓝字”,关注更多精彩


PE数据目录表解析

前言

看雪链接:https://bbs.pediy.com/thread-270585.htm

接着学习PE结构解析。

写的过程中总结一下常用到的基础知识:

  • 基地址(ImageBase):当PE文件通过Windows加载器载入内存后,内存中的版本称为模块,映射文件的起始地址称为模块句柄,可通过模块句柄访问内存中其他数据结构,这个内存起始地址就称为基地址。

  • 虚拟地址(VA):在Windows系统中,PE文件被系统加载到内存后,每个程序都有自己的虚拟空间,这个虚拟空间的内存地址称为虚拟地址。

  • 相对虚拟地址(RVA):可执行文件中,有许多地方需要指定内存中的地址。例如,应用全局变量时需要指定它的地址。为了避免在PE文件中出现绝对内存地址引入了相对虚拟地址,它就是在内存中相对于PE文件载入地址的偏移量。

它们之间的关系:虚拟地址(VA)  = 基地址(Image Base)+相对虚拟地址(RVA)

  • 文件偏移地址(Offset):当PE文件存储在磁盘中时,某个数据的位置相对于文件头的偏移量称为文件偏移地址(File Offset)。文件偏移地址从PE文件的第一个字节开始计数,起始值为0。

PE数据目录表解析


PE数据目录表解析
PE数据目录表解析

数据目录表结构

数据目录表是PE中比较重要的一个部分,其也是一个结构。微软在Microsoft Virtual Studio在对其结构又定义。

PE数据目录表解析

typedef struct _IMAGE_DATA_DIRECTORY {    DWORD   VirtualAddress; //虚拟地址,就是数据目录表的起始位置    DWORD   Size;//尺寸, 起始地址+尺寸 = 结束的位置} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;

结构如下:

PE数据目录表解析

用loadPE打开一个PE文件,点击目录,即可看到数据目录表,再点击每个表对应可以展开的按钮,即可看到相应参数对应的值,

比如点开输入表,可看到调用了哪些动态链接库,又在动态链接库里调用了哪些API:

PE数据目录表解析

为了加深理解,下面对一些重要的表进行学习解析。

地址转换函数

解析这些表之前,先写一个地址转换函数,就是将相对虚拟地址(RVA)转换为文件偏移地址(Offset)。

那么为什么要写这样一个函数呢?因为一些PE文件为了减小体积,磁盘对齐值不是一个内存页1000h,而是200h。当这类文件被映射到内存中后,同一数据相对于文件头的偏移量在内存中和磁盘文件是不同的,这样就出现了文件偏移地址和虚拟地址的转换问题。当然,那些磁盘对齐值与内存对齐值相同的区块,同一数据在磁盘文件中的偏移与在内存中的偏移相同,因此不需要转换。

如图,当文件被映射到内存中时,MS-DOS头,PE头和块表的偏移位置都没有改变,但是当区块被映射到内存中后,其偏移地址就发生了改变。

文件偏移地址(Offset)为add1 ,相对虚拟地址(RVA)为add2。它们直接相差了一个以0填充的空白区域,假设这个值为🔺H,那么:

Offset =RVA -🔺H

Offset =VA -ImageBase -🔺H

PE数据目录表解析

下面开始写代码

首先声明一个函数:

DWORD RvaToOffset(DWORD dwRa, char* buffer);//dwRva 是某个数据目录表的起始位置//buffer PE文件载入内存中的缓冲//返回地址,所有用DWORD存储

获取文件的缓存区:

#define FilePath "C:\Users\Administrator\Desktop\CreateProcess.exe"FILE* pFile = NULL;  char* buffer;  int nFileLength = 0;  pFile = fopen(FilePath, "rb");  fseek(pFile, 0, SEEK_END);  nFileLength = ftell(pFile);  rewind(pFile);  int imageLength = nFileLength * sizeof(char) + 1;  buffer = (char*)malloc(imageLength);  memset(buffer, 0, nFileLength * sizeof(char) + 1);  fread(buffer, 1, imageLength, pFile);

原理比较简单:首先判断这个地址是否在PE头中,如果在,文件偏移和内存偏移相等,如果存在于文件的区段中,则利用以下公式:

内存偏移 - 该段起始的RVA(VirtualAddress) = 文件偏移 - 该段的PointerToRawData 

内存偏移 = 该段起始的RVA(VirtualAddress) + (文件偏移 - 该段的PointerToRawData) 

文件偏移 = 该段的PointerToRawData + (内存偏移 - 该段起始的RVA(VirtualAddress))

代码逻辑如下:

DWORD RvaToOffset(DWORD dwRva, char* buffer) { 
//DOS头 PIMAGE_DOS_HEADER pDOS = (PIMAGE_DOS_HEADER)buffer; //PE头 PIMAGE_NT_HEADERS pNT = (PIMAGE_NT_HEADERS)(pDOS->e_lfanew + buffer); //区段表 PIMAGE_SECTION_HEADER pSection = IMAGE_FIRST_SECTION(pNT); //判断是否落在头部当中 if (dwRva < pSection[0].VirtualAddress) { return dwRva; } //_IMAGE_NT_HEADERS for (int i = 0; i <pNT->FileHeader.NumberOfSections; i++) { //VirtualAddress 起始地址 //Size 长度 // VirtualAddress + Size 结束地址 //判断是否落在某个区段内 if (dwRva >= pSection[i].VirtualAddress && dwRva <= pSection[i].VirtualAddress + pSection[i].Misc.VirtualSize) { //dwRva - pSection[i].VirtualAddress 数据目录表到区段起始地址的偏移(OFFSET) //pSection[i].PointerToRawData 区段到文件头的偏移(OFFSET) //返回的是数据目录起始表地址到文件头的偏移(OFFSET) return dwRva - pSection[i].VirtualAddress + pSection[i].PointerToRawData; } } //VirtualAddress 起始地址 //Size 长度 //VirtualAddress +Size 结束地址 return 0;}

导入表

可执行文件使用来自其他DLL的代码或数据的动作称为输入。当PE文件在被载入时,Windows加载器的工作之一就是定位所有被数据的函数和数据,并让正在载入的文件可以使用那些地址。

导入函数就是被程序调用但其执行代码不在程序中的函数,这些函数在DLL文件中,当应用程序调用一个DLL的代码和数据时,它正被隐式地链接到DLL,这个过程由Windows加载器完成。另一种链接是显示链接,它是已经约定目标DLL已经被加载,然后寻找API的地址,一般是通过Loadlibrary 和GetprocAddress完成。

简而言之,导入表主要是PE文件从其他第三方库中导入API,以供本程序调用,结构如下:

PE数据目录表解析

OriginalFirstThunkFirstThunk分别指向两个不同的IMAGE_THUNK_DATA结构的数组。这两个数组都以一个空的IMAGE_THUNK_DATA结构结尾。

一般情况下,导入表只需要关注OriginalFirstThunkFirstThunk这两个字段。

IMAGE_THUNK_DATA结构:

PE数据目录表解析

那么要解析导入表,首先要定位到导入表:

通过PE扩展头里数据目录字段 + 导入表的宏定义,即可定位到导入表,

PIMAGE_DATA_DIRECTORY pImportDir = (PIMAGE_DATA_DIRECTORY)(pNt->OptionalHeader.DataDirectory + IMAGE_DIRECTORY_ENTRY_IMPORT);//pNt->OptionalHeader.DataDirectory PE扩展头里数据目录字段 + 导入表的宏定义
在Microsoft Virtual Studio中,在IMAGE_DIRECTORY_ENTRY_IMPORT处 ,ctrl +鼠标左键 即可跳转到该宏定义:

PE数据目录表解析

然后就是填充结构,前面写了一个找数据表到文件头偏移的函数RvaToOffset,现在就用这个函数来找导入表到文件头的偏移:

PIMAGE_IMPORT_DESCRIPTOR pImport = (PIMAGE_IMPORT_DESCRIPTOR)(RvaToOffset(pImportDir->VirtualAddress, buffer)+buffer);//填充结构//pImportDir->VirtualAddress 导入表头的位置 也就是RVA //RvaToOffset(pImportDir->VirtualAddress, buffer)  导入表到文件头的偏移   再加上buffer就找到了地址
然后就是遍历数据:
while (pImport->Name!=NULL) {  char* szDllName = (char*)(RvaToOffset(pImport->Name, buffer)+buffer);  std::cout << "DLL名称[DllName]:" << szDllName << std::endl;  std::cout << "日期时间标志[TimeDateStamp]:" <<std::hex<< pImport->TimeDateStamp << std::endl;  std::cout << "转发链[ForWarderChain]:" << std::hex<<pImport->ForwarderChain << std::endl;  std::cout << "名称OFFSET[Name]:" << std::hex << pImport->Name << std::endl;  std::cout << "FirstThunk:" << std::hex << pImport->FirstThunk << std::endl;  std::cout << "OriginalFirstThunk:" << std::hex << pImport->OriginalFirstThunk << std::endl;  std::cout << "[******************************************************]" << std::endl;  //指向地址表中的RVA  PIMAGE_THUNK_DATA pIat = (PIMAGE_THUNK_DATA)(RvaToOffset(pImport->OriginalFirstThunk, buffer) + buffer);  DWORD Index = 0;  DWORD ImportOffset = 0;  //被导入函数的序号  while (pIat->u1.Ordinal !=0)
{ std::cout << "ThunkRva:" << std::hex << pImport->OriginalFirstThunk+Index << std::endl;
ImportOffset = RvaToOffset(pImport->OriginalFirstThunk, buffer); std::cout << "ThunkOffset:" << std::hex << ImportOffset+Index << std::endl; Index += 4; if ((pIat->u1.Ordinal & 0x80000000) != 1) { PIMAGE_IMPORT_BY_NAME pName = (PIMAGE_IMPORT_BY_NAME)(RvaToOffset(pIat->u1.AddressOfData, buffer) + buffer); std::cout << "API名称:" << pName->Name << std::endl; std::cout << "API序号:" << pName->Hint << std::endl; //被导入函数地址 std::cout << "ThunkValue:" << pIat->u1.Function << std::endl; std::cout << "---------------------" << std::endl; } pIat++; } pImport++; }
结果如下,可以看到是没有任何问题的:

PE数据目录表解析

导出表

   导出表是PE文件为其他应用程序提供自身的一些变量、函数以及类,将其导出给第三方程序使用的一张清单,里面包含了可以导出的元素。

结构如下:

PE数据目录表解析

从逻辑上来说,导出表由名称表、函数表与序号表组成。函数表和序号表必不可少,名称表则是可选的。序号表与名称表的作用是索引,找到真正需要的函数表,函数表中保存着被导出的函数的地址信息。

导出地址表(EAT)
序号
导出名称表(ENT)
0x00010000(某函数地址) 0x0001 FunName_A
0x00025000(某函数地址) 0x0002 FunName_B
0x00050000(某函数地址) 0x0003 FunName_C

这里写一个有导出函数的测试dll:

PE数据目录表解析

在loadPE中打开,打开导出表,可以看到有导出函数:

PE数据目录表解析

现在就是和导入表一样,找导出表到文件头的偏移,然后手写代码获取相关信息:

逻辑和导入表类似,就不赘述了,直接给出代码:

void AnalysisExportTable(char* buffer){ //Dos PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)buffer; //PE PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)(pDos->e_lfanew + buffer); //定位数据目录中的导出表 PIMAGE_DATA_DIRECTORY pExportDir = pNt->OptionalHeader.DataDirectory + IMAGE_DIRECTORY_ENTRY_EXPORT; //填充导出表结构 PIMAGE_EXPORT_DIRECTORY pExport = (PIMAGE_EXPORT_DIRECTORY)(RvaToOffset(pExportDir->VirtualAddress, buffer)+buffer); char* szName = (char*)(RvaToOffset(pExport->Name, buffer) + buffer);
if (pExport->AddressOfFunctions ==0) { printf("当前没有导出表"); return ; } printf("导出表OFFSET:%08Xn", RvaToOffset(pExportDir->VirtualAddress, buffer)); printf("特征值:%08Xn", pExport->Characteristics); printf("基:%08Xn", pExport->Base); printf("名称OFFSET:%08Xn", pExport->Name); printf("名称: %sn", szName); printf("函数数量:%08Xn", pExport->NumberOfFunctions); printf("函数名数量:%08Xn", pExport->NumberOfNames); printf("函数地址:%08Xn", pExport->AddressOfFunctions); printf("函数名称地址:%08Xn", pExport->AddressOfNames); printf("函数名称序号地址:%08Xn", pExport->AddressOfNameOrdinals); //函数地址数量 DWORD dwNumOfFun = pExport->NumberOfFunctions; //函数名数量 DWORD dwNumOfNames = pExport->NumberOfNames; //基 DWORD dwBase = pExport->Base; //导出地址表 PDWORD pEAt32 = (PDWORD)(RvaToOffset(pExport->AddressOfFunctions, buffer) + buffer); //导出名称表 PDWORD pENt32 = (PDWORD)(RvaToOffset(pExport->AddressOfNames, buffer) + buffer); //导出序号表 PWORD pId = (PWORD)(RvaToOffset(pExport->AddressOfNameOrdinals, buffer) + buffer); for (DWORD i = 0; i < dwNumOfFun; i++) { if (pEAt32[i] == 0) { continue; } DWORD Id = 0; for (; Id < dwNumOfNames; Id++) { if (pId[Id] == i) { break; } } if (Id == dwNumOfNames) { printf("Id:%x Address:0x%08X Name[NULL]n", i + dwBase,pEAt32[i]); } else { char* szFunName = (char *)(RvaToOffset(pENt32[Id], buffer) + buffer); printf("Id:%x Address:0x%08X Name[%s]n", i + dwBase, pEAt32[i], szFunName); } }}

结果如下,可以看到除了不美观以外,结果是没有任何问题的:

PE数据目录表解析

重定位表

基址重定位

当向程序的虚拟内存加载PE文件时,文件会被加载到ImageBase所指向的地址。

ImageBase就是前面讲到的PE拓展头中的一个成员:

PE数据目录表解析

EXE文件来说,EXE文件会首先加载到内存,每个文件总是使用独立的虚拟地址空间,这就意味着EXE文件不用考虑基址重定位问题;

对于DLL文件来说,多个DLL文件使用调用其本身的EXE文件的地址空间,不能保证ImageBase所指向的地址没有被其他DLL文件占用,所以DLL文件当中必须包含重定位信息,也就是说,本来A.DLL被加载到test.exe进程的00100000地址处,但是此处加载了B.DLL文件,PE装载器将A.DLL文件加载到其他还未被占用的地址处(00850000)处。

对于系统的DLL来说实际上不会发生重定位,因为同一系统的kernel32.dll、user32.dll等会被加载到自身固有的ImageBase。

重定位原理

重定位结构:

PE数据目录表解析

typedef struct _IMAGE_BASE_RELOCATION {    DWORD   VirtualAddress; //指向需要重地位的地址的RVA,每个IMAGE_BASE_RELOCATION只负责4KB大小分页内的重定位信息。因此结构中的VirtualAddress值为0x1000的倍数。    DWORD   SizeOfBlock;  //imagebase 结构体 和TypeOffset 的总和  重定位块的大小//  WORD    TypeOffset[1];  # 自定义的一个字段 表示这个结构体下面会出现WORD类型的数组,该数组元素的值就是硬编码在程序当中的偏移} IMAGE_BASE_RELOCATION;typedef IMAGE_BASE_RELOCATION UNALIGNED * PIMAGE_BASE_RELOCATION;
自定义Typeoffset结构:
 typedef struct _TYPE {  WORD Offset :12;   //大小 2bit 重定位的偏移  WORD Tyoe : 4; }TYPE, * PTYPE;

Windows的PE装载器进行PE重定位处理的操作原理流程如下:

  • 在应用程序当中查找硬编码位置

  • 读取之后减去ImageBase,也就是用VA-基址得到RVA

  • 加上实际加载地址得到真正的VA

其中最关键的就是找到硬编码的位置,而要找到硬编码的位置,首先要找到基址重定位表,该表位于.reloc区段,找到基址重定位表的的正确打开方式是通过数据目录表的IMAGE_DIRECTORY_ENTRY_BASERELOC条目查找  

实现如下:

void AnalysisRelocTable(char* buffer){ typedef struct _TYPE {  WORD Offset :12;   //大小 2bit 重定位的偏移  WORD Tyoe : 4; }TYPE, * PTYPE; //Dos PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)buffer; //PE PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)(pDos->e_lfanew + buffer); //定位重定位表 PIMAGE_DATA_DIRECTORY pRelocDir = (pNt->OptionalHeader.DataDirectory + IMAGE_DIRECTORY_ENTRY_BASERELOC); //填充结构 PIMAGE_BASE_RELOCATION pReloc = (PIMAGE_BASE_RELOCATION)(RvaToOffset(pRelocDir->VirtualAddress, buffer) + buffer); //定位区段 PIMAGE_SECTION_HEADER pSection = IMAGE_FIRST_SECTION(pNt); while (pReloc->SizeOfBlock !=0) {  //找到本0x1000个字节的起始位置  //重定位个数 = (SizeOfBlock - 8(IMAGE_BASE_RELOCATION的大小)) / 2(每个TypeOffset是2个字节)  DWORD dwCount = (pReloc->SizeOfBlock - 8) / 2; //需要重定位的个数
DWORD dwRva = pReloc->VirtualAddress; PTYPE pRelocArr = (PTYPE)(pReloc + 1); printf("区段:%sn", pSection->Name); printf("RVA:%08xn", dwRva); printf("项目:%X h / %d Dn", pReloc->SizeOfBlock,pReloc->SizeOfBlock); std::cout << "[******************************************************]" << std::endl;
//找到下一个0x1000的结构体 pReloc = (PIMAGE_BASE_RELOCATION)((char*)pReloc + pReloc->SizeOfBlock); for (int i = 0; i < dwCount; i++) { PDWORD pData = (PDWORD)(RvaToOffset(pRelocArr[i].Offset + dwRva, buffer) + buffer); DWORD pDataOffset = RvaToOffset(pRelocArr[i].Offset + dwRva, buffer); printf("RVA:%08Xn", pRelocArr[i].Offset + dwRva); printf("区段:%08Xn", *pData); printf("偏移:%08Xn", pDataOffset); std::cout << "[-----------------------------------------------]" << std::endl; } }}

PE数据目录表解析

TLS表

TLS:线程本地存储器,可以将数据与执行的特定线程联系起来。怎么理解呢?

如果一个变量是全局的,那么所有线程访问的是同一份,某一个线程对其修改会影响其他所有线程。如果我们需要一个变量在每个线程中都能访问,并且值在每个线程中互不影响,这就是TLS。

线程局部存储在不同平台有不同的实现,可移植性不好。线程局部存储不难实现,最简单的办法是建立一个全局表,通过当前线程ID去查询相应的数据,因为各个线程ID去查询相应的数据,因为各个线程的ID不同,查到的数据自然也不同。

这里就不详细介绍了,我们简单解析一些PE中的TLS表即可:


先看一下它的结构,分为32和64位的:

PE数据目录表解析

PE数据目录表解析

解析起来也比较简单:
void AnalysisTLSTable(char* buffer){ //Dos PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)buffer; //PE PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)(pDos->e_lfanew+buffer); //定位数据目录表中的TLS表 PIMAGE_DATA_DIRECTORY pTLSDir = (pNt->OptionalHeader.DataDirectory + IMAGE_DIRECTORY_ENTRY_TLS); //填充结构 PIMAGE_TLS_DIRECTORY pTLS = (PIMAGE_TLS_DIRECTORY)(RvaToOffset(pTLSDir->VirtualAddress, buffer) + buffer); printf("数据块开始VA: % 08Xn", pTLS->StartAddressOfRawData); printf("数据块结束VA: % 08Xn",pTLS->EndAddressOfRawData); printf("索引变量VA: % 08Xn",pTLS->AddressOfIndex); printf("回调表VA: % 08Xn",pTLS->AddressOfCallBacks); printf("填充大小: % 08Xn",pTLS->SizeOfZeroFill); printf("特征值: % 08Xn",pTLS->Characteristics);
}

PE数据目录表解析

延迟导入表

延迟载入是一种混合方式,通过LoadLibrary和GetProcAddress获取延迟加载函数额地址,然后直接加载转向对延迟加载函数的调用。

结构如下:

PE数据目录表解析

解析延迟导入表:
void AnalysisDelayTable(char* buffer){ //Dos PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)buffer; //PE PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)(pDos->e_lfanew + buffer); //定位数据吧、目录表中的延迟导入表 PIMAGE_DATA_DIRECTORY pDelayLoadDir =(PIMAGE_DATA_DIRECTORY)(pNt->OptionalHeader.DataDirectory + IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT); //填充延迟导入表数据结构 PIMAGE_DELAYLOAD_DESCRIPTOR pDelayLoad = (PIMAGE_DELAYLOAD_DESCRIPTOR)(RvaToOffset(pDelayLoadDir->VirtualAddress, buffer) + buffer); while (pDelayLoad->DllNameRVA != NULL) {  char* szDllName = (char*)(RvaToOffset(pDelayLoad->DllNameRVA, buffer) + buffer);  printf("DllName:%s", szDllName);  printf("Attributes:%08Xn", pDelayLoad->Attributes);  printf("ModuleHandleRVA:%08Xn", pDelayLoad->ModuleHandleRVA);  printf("ImportAddressTableRVA:%08Xn", pDelayLoad->ImportAddressTableRVA);  printf("ImportNameTableRVA:%08Xn", pDelayLoad->ImportNameTableRVA);  printf("BoundImportAddressTableRVA:%08Xn", pDelayLoad->BoundImportAddressTableRVA);  printf("UnloadInformationTableRVA:%08Xn", pDelayLoad->UnloadInformationTableRVA);  printf("TimeDateStamp:%08Xn", pDelayLoad->TimeDateStamp);  std::cout << "[******************************************************]" << std::endl;  pDelayLoad++; }}

PE数据目录表解析

PE数据目录表解析


PE数据目录表解析
最后

PE相关基础知识,大佬们见笑了。

百分之95的文字和代码都是手敲的,如有错别字多多包涵~~


PE数据目录表解析

END

PE数据目录表解析


PE数据目录表解析


看完记得点赞,关注哟,爱您!


请严格遵守网络安全法相关条例!此分享主要用于学习,切勿走上违法犯罪的不归路,一切后果自付!



关注此公众号,回复"Gamma"关键字免费领取一套网络安全视频以及相关书籍,公众号内还有收集的常用工具!

在看你就赞赞我!
PE数据目录表解析


PE数据目录表解析
PE数据目录表解析
PE数据目录表解析
扫码关注我们
PE数据目录表解析


扫码领hacker资料,常用工具,以及各种福利


PE数据目录表解析

转载是一种动力 分享是一种美德


原文始发于微信公众号(Gamma实验室):PE数据目录表解析

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2021年12月4日05:16:17
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   PE数据目录表解析https://cn-sec.com/archives/659291.html

发表评论

匿名网友 填写信息