
PE格式是 Windows下最常用的可执行文件格式,理解PE文件格式不仅可以了解操作系统的加载流程,还可以更好的理解操作系统对进程和内存相关的管理知识,而有些技术必须建立在了解PE文件格式的基础上,如文件加密与解密,病毒分析,外挂技术等,在PE文件中我们最需要关注,PE结构,导入表,导出表,重定位表,下面将具体介绍PE的关键结构,并使用C语言编程获取到这些结构数据.
参考文献: [琢石成器 Win32汇编语言程序设计 - 罗云彬] 整理学习笔记,精简内容,翻译汇编代码C语言化
在任何一款操作系统中,可执行程序在被装入内存之前都是以文件的形式存放在磁盘中的,在早期DOS操作系统中,是以COM文件的格式存储的,该文件格式限制了只能使用代码段,堆栈寻址也被限制在了64KB的段中,由于PC芯片的快速发展这种文件格式极大的制约了软件的发展。
为了应对这种局面,微软的工程师们就发明了新的文件格式(EXE文件),该文件格式在代码段前面增加了文件头结构,文件头中包括各种说明数据,如程序的入口地址,堆栈的位置,重定位表等,显然可执行文件的格式是操作系统工作方式的真实写照,不同的系统之间文件格式千差万别,从而导致不同系统中的可执行文件无法跨平台运行。
Windows NT 系统中可执行文件使用微软设计的新的文件格式,也就是至今还在使用的PE格式,PE文件的基本结构如下图所示:

在PE文件中,代码,已初始化的数据,资源和重定位信息等数据被按照属性分类放到不同的Section(节区/或简称为节)中,而每个节区的属性和位置等信息用一个IMAGE_SECTION_HEADER结构来描述,所有的IMAGE_SECTION_HEADER结构组成了一个节表(Section Table),节表数据在PE文件中被放在所有节数据的前面.
在PE文件中将同样属性的数据分类放在一起是为了统一描述这些数据装入内存后的页面属性,由于数据是按照属性在节中放置的,不同用途但是属性相同的数据可能被放在同一个节中,PE文件头被放置在节和节表的前面,上面介绍的是真正的PE文件,为了兼容以前的DOS系统,所以保留了DOS的文件格式,接下来将依次介绍这几种数据结构.
我们需要编程实现读取PE结构,在读取PE文件中的数据的前提下,我们先来打开文件,然后才能读取。
#include <stdio.h>
#include <Windows.h>
#include <Imagehlp.H>
#pragma comment(lib,"Imagehlp.lib")
HANDLE OpenPeByFileName(LPTSTR FileName)
{
LPTSTR peFile = FileName;
HANDLE hFile, hMapFile, lpMapAddress = NULL;
DWORD dwFileSize = 0;
hFile = CreateFile(peFile, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
dwFileSize = GetFileSize(hFile, NULL);
hMapFile = CreateFileMapping(hFile, NULL, PAGE_READONLY, 0, dwFileSize, NULL);
lpMapAddress = MapViewOfFile(hMapFile, FILE_MAP_READ, 0, 0, dwFileSize);
if (lpMapAddress != NULL)
return lpMapAddress;
}
int main()
{
HANDLE lpMapAddress = NULL;
lpMapAddress = OpenPeByFileName(L"d://x32dbg.exe");
return 0;
}在上面PE结构图中可知PE文件的开头部分包括了一个标准的DOS可执行文件结构,这看上去有些奇怪,但是这对于可执行程序的向下兼容性来说却是不可缺少的,当然现在已经基本不会出现纯DOS程序了,现在来说这个IMAGE_DOS_HEADER结构纯粹是历史遗留问题。
DOS头结构: PE文件中的DOS部分由MZ格式的文件头和可执行代码部分组成,可执行代码被称为DOS块(DOS stub),MZ格式的文件头由IMAGE_DOS_HEADER结构定义,在C语言头文件winnt.h中有对这个DOS结构详细定义,如下所示:
typedef struct _IMAGE_DOS_HEADER {
WORD e_magic; // DOS的头部
WORD e_cblp; // Bytes on last page of file
WORD e_cp; // Pages in file
WORD e_crlc; // Relocations
WORD e_cparhdr; // Size of header in paragraphs
WORD e_minalloc; // Minimum extra paragraphs needed
WORD e_maxalloc; // Maximum extra paragraphs needed
WORD e_ss; // Initial (relative) SS value
WORD e_sp; // Initial SP value
WORD e_csum; // Checksum
WORD e_ip; // Initial IP value
WORD e_cs; // Initial (relative) CS value
WORD e_lfarlc; // File address of relocation table
WORD e_ovno; // Overlay number
WORD e_res[4]; // Reserved words
WORD e_oemid; // OEM identifier (for e_oeminfo)
WORD e_oeminfo; // OEM information; e_oemid specific
WORD e_res2[10]; // Reserved words
LONG e_lfanew; // 指向了PE文件的开头(重要)
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;在DOS文件头中,第一个字段e_magic被定义为MZ,标志着DOS文件的开头部分,最后一个字段e_lfanew则指明了PE文件的开头位置,现在来说除了第一个字段和最后一个字段有些用处,其他字段几乎已经废弃了,这里附上读取DOS头的代码。
void DisplayDOSHeadInfo(HANDLE ImageBase)
{
PIMAGE_DOS_HEADER pDosHead = NULL;
pDosHead = (PIMAGE_DOS_HEADER)ImageBase;
printf("DOS头: %x\n", pDosHead->e_magic);
printf("文件地址: %x\n", pDosHead->e_lfarlc);
printf("PE结构偏移: %x\n", pDosHead->e_lfanew);
}PE头结构: 从DOS文件头的e_lfanew字段向下偏移003CH的位置,就是真正的PE文件头的位置,该文件头是由IMAGE_NT_HEADERS结构定义的,定义结构如下:
typedef struct _IMAGE_NT_HEADERS {
DWORD Signature; // PE文件标识字符
IMAGE_FILE_HEADER FileHeader;
IMAGE_OPTIONAL_HEADER32 OptionalHeader;
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;如上PE文件头的第一个DWORD是一个标志,默认情况下它被定义为00004550h也就是P,E两个字符另外加上两个零,而大部分的文件属性由标志后面的IMAGE_FILE_HEADER和IMAGE_OPTIONAL_HEADER32结构来定义,我们继续跟进IMAGE_FILE_HEADER这个结构:
typedef struct _IMAGE_FILE_HEADER {
WORD Machine; // 运行平台
WORD NumberOfSections; // 文件的节数目
DWORD TimeDateStamp; // 文件创建日期和时间
DWORD PointerToSymbolTable; // 指向符号表(用于调试)
DWORD NumberOfSymbols; // 符号表中的符号数量
WORD SizeOfOptionalHeader; // IMAGE_OPTIONAL_HANDLER32结构的长度
WORD Characteristics; // 文件的属性 exe=010fh dll=210eh
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;继续跟进 IMAGE_OPTIONAL_HEADER32 结构,该结构体中的数据就丰富了,重要的结构说明经备注好了:
typedef struct _IMAGE_OPTIONAL_HEADER {
WORD Magic;
BYTE MajorLinkerVersion; // 连接器版本
BYTE MinorLinkerVersion;
DWORD SizeOfCode; // 所有包含代码节的总大小
DWORD SizeOfInitializedData; // 所有已初始化数据的节总大小
DWORD SizeOfUninitializedData; // 所有未初始化数据的节总大小
DWORD AddressOfEntryPoint; // 程序执行入口RVA
DWORD BaseOfCode; // 代码节的起始RVA
DWORD BaseOfData; // 数据节的起始RVA
DWORD ImageBase; // 程序镜像基地址
DWORD SectionAlignment; // 内存中节的对其粒度
DWORD FileAlignment; // 文件中节的对其粒度
WORD MajorOperatingSystemVersion; // 操作系统主版本号
WORD MinorOperatingSystemVersion; // 操作系统副版本号
WORD MajorImageVersion; // 可运行于操作系统的最小版本号
WORD MinorImageVersion;
WORD MajorSubsystemVersion; // 可运行于操作系统的最小子版本号
WORD MinorSubsystemVersion;
DWORD Win32VersionValue;
DWORD SizeOfImage; // 内存中整个PE映像尺寸
DWORD SizeOfHeaders; // 所有头加节表的大小
DWORD CheckSum;
WORD Subsystem;
WORD DllCharacteristics;
DWORD SizeOfStackReserve; // 初始化时堆栈大小
DWORD SizeOfStackCommit;
DWORD SizeOfHeapReserve;
DWORD SizeOfHeapCommit;
DWORD LoaderFlags;
DWORD NumberOfRvaAndSizes; // 数据目录的结构数量
IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];
} IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32;IMAGE_DATA_DIRECTORY数据目录列表,它由16个相同的IMAGE_DATA_DIRECTORY结构组成,这16个数据目录结构定义很简单仅仅指出了某种数据的位置和长度,定义如下:
typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress; // 数据起始RVA
DWORD Size; // 数据块的长度
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;上方的结构就是PE文件的重要结构,接下来将通过编程读取出PE文件的开头相关数据。
读取NT文件头: 第1个函数用于判断是否为可执行文件,第2个函数用于读取PE文件头信息。
BOOL IsPEFile(HANDLE ImageBase)
{
PIMAGE_DOS_HEADER pDosHead = NULL;
PIMAGE_NT_HEADERS pNtHead = NULL;
if (ImageBase == NULL){ return FALSE; }
pDosHead = (PIMAGE_DOS_HEADER)ImageBase;
if (IMAGE_DOS_SIGNATURE != pDosHead->e_magic){ return FALSE; }
pNtHead = (PIMAGE_NT_HEADERS)((DWORD)pDosHead + pDosHead->e_lfanew);
if (IMAGE_NT_SIGNATURE != pNtHead->Signature){ return FALSE; }
return TRUE;
}
PIMAGE_NT_HEADERS GetNtHead(HANDLE ImageBase)
{
PIMAGE_DOS_HEADER pDosHead = NULL;
PIMAGE_NT_HEADERS pNtHead = NULL;
pDosHead = (PIMAGE_DOS_HEADER)ImageBase;
pNtHead = (PIMAGE_NT_HEADERS)((DWORD)pDosHead + pDosHead->e_lfanew);
return pNtHead;
}读取PE文件结构:
void DisplayFileHeaderInfo(HANDLE ImageBase)
{
PIMAGE_NT_HEADERS pNtHead = NULL;
PIMAGE_FILE_HEADER pFileHead = NULL;
pNtHead = GetNtHead(ImageBase);
pFileHead = &pNtHead->FileHeader;
printf("运行平台: %x\n", pFileHead->Machine);
printf("节区数目: %x\n", pFileHead->NumberOfSections);
printf("时间标记: %x\n", pFileHead->TimeDateStamp);
printf("可选头大小 %x\n", pFileHead->SizeOfOptionalHeader);
printf("文件特性: %x\n", pFileHead->Characteristics);
}读取OptionalHeader结构:
void DisplayOptionalHeaderInfo(HANDLE ImageBase)
{
PIMAGE_NT_HEADERS pNtHead = NULL;
pNtHead = GetNtHead(ImageBase);
printf("入口点: %x\n", pNtHead->OptionalHeader.AddressOfEntryPoint);
printf("镜像基址: %x\n", pNtHead->OptionalHeader.ImageBase);
printf("镜像大小: %x\n", pNtHead->OptionalHeader.SizeOfImage);
printf("代码基址: %x\n", pNtHead->OptionalHeader.BaseOfCode);
printf("区块对齐: %x\n", pNtHead->OptionalHeader.SectionAlignment);
printf("文件块对齐: %x\n", pNtHead->OptionalHeader.FileAlignment);
printf("子系统: %x\n", pNtHead->OptionalHeader.Subsystem);
printf("区段数目: %x\n", pNtHead->FileHeader.NumberOfSections);
printf("时间日期标志: %x\n", pNtHead->FileHeader.TimeDateStamp);
printf("首部大小: %x\n", pNtHead->OptionalHeader.SizeOfHeaders);
printf("特征值: %x\n", pNtHead->FileHeader.Characteristics);
printf("校验和: %x\n", pNtHead->OptionalHeader.CheckSum);
printf("可选头部大小: %x\n", pNtHead->FileHeader.SizeOfOptionalHeader);
printf("RVA 数及大小: %x\n", pNtHead->OptionalHeader.NumberOfRvaAndSizes);
}在执行PE文件的时候,Windows 并不在一开始就将整个文件读入内存,PE装载器在装载的时候仅仅建立好虚拟地址和PE文件之间的映射关系,只有真正执行到某个内存页中的指令或者访问页中的数据时,这个页面才会被从磁盘提交到内存中,这种机制极大的节约了内存资源,使文件的装入速度和文件的大小没有太多的关系。

Windows 装载器在装载DOS部分PE文件头部分和节表部分时不进行任何处理,而在装载节区的时候会根据节的不同属性做不同的处理,一般需要处理以下几个方面的内容:
节区的属性: 节是相同属性的数据的组合,当节被装入内存的时候,同一个节对应的内存页面将被赋予相同的页属性,Windows系统对内存属性的设置是以页为单位进行的,所以节在内存中的对其单位必须至少是一个页的大小,对于X86来说这个值是4KB(1000h),而对于X64来说这个值是8KB(2000h),磁盘中存储的程序并不会对齐4KB,而只有被PE加载器载入内存的时候,PE装载器才会自动的补齐4KB对其的零头数据。
节区的偏移: 节的起始地址在磁盘文件中是按照IMAGE_OPTIONAL_HEADER结构的FileAhgnment字段的值对齐的,而被加载到内存中时是按照同一结构中的SectionAlignment字段的值对齐的,两者的值可能不同,所以一个节被装入内存后相对于文件头的偏移和在磁盘文件中的偏移可能是不同的。
节区的尺寸: 由于磁盘映像和内存映像的对齐单位不同,磁盘中的映像在装入内存后会自动的进行长度扩展,而对于未初始化的数据段(.data?)来说,则没有必要为它在磁盘文件中预留空间,只要可执行文件装入内存后动态的为其分配空间即可,所以包含未初始化数据的节在磁盘中长度被定义为0,只有在运行后PE加载器才会动态的为他们开辟空间。
不进行映射的节: 有些节中包含的数据仅仅是在装入的时候用到,当文件装载完毕时,他们不会被递交到物理内存中,例如重定位节,该节的数据对于文件的执行代码来说是透明的,他只供Windows装载器使用,可执行代码根本不会访问他们,所以这些节存在于磁盘文件中,不会被映射到内存中。
节表结构定义: PE文件中的所有节的属性定义都被定义在节表中,节表由一系列的IMAGE_SECTION_HEADER结构排列而成,每个结构邮过来描述一个节,节表总被存放在紧接在PE文件头的地方,也即是从PE文件头开始偏移为00f8h的位置。
typedef struct _IMAGE_SECTION_HEADER {
BYTE Name[IMAGE_SIZEOF_SHORT_NAME];
union {
DWORD PhysicalAddress;
DWORD VirtualSize; // 节区尺寸
} Misc;
DWORD VirtualAddress; // 节区RVA
DWORD SizeOfRawData; // 在文件中对齐后的尺寸
DWORD PointerToRawData; // 在文件中的偏移
DWORD PointerToRelocations; // 在OBJ文件中使用
DWORD PointerToLinenumbers;
WORD NumberOfRelocations;
WORD NumberOfLinenumbers;
DWORD Characteristics; // 节区属性字段
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;读取所有节表: 通过编程实现读取节区中的所有节,并打印出来。
void DisplaySectionHeaderInfo(HANDLE ImageBase)
{
PIMAGE_NT_HEADERS pNtHead = NULL;
PIMAGE_FILE_HEADER pFileHead = NULL;
PIMAGE_SECTION_HEADER pSection = NULL;
DWORD NumberOfSectinsCount = 0;
pNtHead = GetNtHead(ImageBase);
pSection = IMAGE_FIRST_SECTION(pNtHead);
pFileHead = &pNtHead->FileHeader;
NumberOfSectinsCount = pFileHead->NumberOfSections; // 获得区段数量
DWORD *difA = NULL; // 虚拟地址开头
DWORD *difS = NULL; // 相对偏移(用于遍历)
difA = (DWORD *)malloc(NumberOfSectinsCount*sizeof(DWORD));
difS = (DWORD *)malloc(NumberOfSectinsCount*sizeof(DWORD));
printf("节区名称 相对偏移\t虚拟大小\tRaw数据指针\tRaw数据大小\t节区属性\n");
for (int temp = 0; temp<NumberOfSectinsCount; temp++, pSection++)
{
printf("%s\t 0x%.8X \t 0x%.8X \t 0x%.8X \t 0x%.8X \t 0x%.8X\n",
pSection->Name, pSection->VirtualAddress, pSection->Misc.VirtualSize,
pSection->PointerToRawData, pSection->SizeOfRawData, pSection->Characteristics);
difA[temp] = pSection->VirtualAddress;
difS[temp] = pSection->VirtualAddress - pSection->PointerToRawData;
}
}使用C语言实现可能有点复杂,我们可以使用Python几行代码搞定,最终的调用效果是相同的,格式也是相同的。
import pefile
if __name__ == "__main__":
pe = pefile.PE("D://run.exe")
section_count = int(pe.FILE_HEADER.NumberOfSections + 1)
print("序号\t节区名称\t\t相对偏移\t\t虚拟大小\t\tRaw数据指针\tRaw数据大小\t节区属性")
print("-" * 80)
for count,item in zip(range(1,section_count),pe.sections):
print("%d\t\t%-10s\t0x%.8X\t0x%.8X\t0x%.8X\t0x%.8X\t0x%.8X" %(count,(item.Name).decode("utf-8"),item.VirtualAddress,
item.Misc_VirtualSize,item.PointerToRawData,item.SizeOfRawData,item.Characteristics))
导入表在可执行文件中扮演了重要的角色,在Win32编程中我们会经常用到导入函数,导入函数就是程序调用其执行代码又不在程序中的函数,这些函数通常是系统提供给我们的API,在调用者程序中只保留一些函数信息,包括函数名机器所在DLL路径。
对于磁盘上的PE文件来说,它无法得知这些导入函数会在的那个内存的那个地方出现,只有当PE文件被装入内存时,Windows装载器才将DLL载入,并调用导入函数指令,和函数实际所处的地址联系起来。
首先我通过汇编编写了一段简单的弹窗代码,我们观察下方代码,有没有发现一些奇特的地方?
00801000 | 6A 00 | push 0 |
00801002 | 6A 00 | push 0 |
00801004 | 68 00308000 | push main.803000 |
00801009 | 6A 00 | push 0 |
0080100B | E8 0E000000 | call 0x0080101E | call MessageBoxA
00801010 | 6A 00 | push 0 |
00801012 | E8 01000000 | call 0x00801018 | call ExitProcess
00801017 | CC | int3 |
00801018 | FF25 00208000 | jmp dword ptr ds:[0x00802000] | 导入函数地址
0080101E | FF25 08208000 | jmp dword ptr ds:[0x00802008] | 导入函数地址反汇编后,可看到对MessageBox和ExitProcess函数的调用变成了对0x0080101E和0x00801018地址的调用,但是这两个地址显然是位于程序自身模块,而不是系统的DLL模块中的,实际上这是由于编译器在程序的代码的后面自动添加了一条jmp dword ptr[xxxxx]类型的跳转指令,其中的[xxxxx]地址中才是真正存放导入函数的地址。
那么在程序没有被PE装载器加载之前,0x00802000地址处的内容是什么呢?
节区名称 相对偏移 虚拟大小 Raw数据指针 Raw数据大小 节区属性
.text 0x00001000 0x00000024 0x00000400 0x00000200 0x60000020
.rdata 0x00002000 0x00000092 0x00000600 0x00000200 0x40000040
.data 0x00003000 0x0000000E 0x00000800 0x00000200 0xC0000040
.reloc 0x00004000 0x00000024 0x00000A00 0x00000200 0x42000040此处由于建议装入地址是0x00800000所以0x00802000地址实际上是处于RVA偏移为2000h的地方,在观察各个节的相对偏移,可发现2000h开始的地方位于.rdata节内,而这个节的Raw数据指针项为600h,也就是说0x00802000地址的内容实际上对应了PE文件中偏移600h处的数据。
你可以打开任意一款十六进制查看器,将光标拖到600h处,会发现其对应的地址是0000205Ch,这个地址显然也不会是ExitProcess函数地址,但我们将它作为RVA来看的话。
查看节表可以发现RVA地址0000205C也处于.rdata节内,减去节的起始地址0x00002000得到这个RVA相对于节首的偏移是5Ch,也就是说它对应文件为0x00000600+5c = 065ch开始的地方,接下来观察可发现,这个字符串正好就是ExitProcess所对应的文件偏移地址。
当PE文件被装载的时候,Windows装载器会根据xxxx处的RVA得到函数名,再根据函数名在内存中找到函数地址,并且用函数地址将xxxx处的内容替换成真正的函数地址。
导入表位置和大小可以从PE文件头中IMAGE_OPTIONAL_HEADER32结构的IMAGE_DATA_DIRECTORY数据目录字段中获取,从IMAGE_DATA_DIRECTORY字段得到的是导入表的RVA值,如果在内存中查找导入表,那么将RVA值加上PE文件装入的基址就是实际的地址。
找到了数据目录结构,既能够找到导入表,导入表由一系列的IMAGE_IMPORT_DESCRIPTOR结构组成,结构的数量取决于程序需要使用的DLL文件数量,每个结构对应一个DLL文件,在所有结构的最后,由一个内容全为0的IMAGE_IMPORT_DESCRIPTOR结构作为结束标志,表结构定义如下:
typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics;
DWORD OriginalFirstThunk; // 包含指向IMAGE_THUNK_DATA(输入名称表)结构的数组
} DUMMYUNIONNAME;
DWORD TimeDateStamp; // 当可执行文件不与被输入的DLL进行绑定时,此字段为0
DWORD ForwarderChain; // 第一个被转向的API的索引
DWORD Name; // 指向被输入的DLL的ASCII字符串的RVA
DWORD FirstThunk; // 指向输入地址表(IAT)的RVA
} IMAGE_IMPORT_DESCRIPTOR;OriginalFirstThunk和FirstThunk字段是相同的,他们都指向一个包含IMAGE_THUNK_DATA结构的数组,数组中每个IMAGE_THUNK_DATA结构定义了一个导入函数的具体信息,数组的最后以一个内容全为0的IMAGE_THUNK_DATA结构作为结束,该结构的定义如下:
typedef struct _IMAGE_THUNK_DATA32 {
union {
DWORD ForwarderString; // PBYTE
DWORD Function; // PDWORD
DWORD Ordinal;
DWORD AddressOfData; // PIMAGE_IMPORT_BY_NAME
} u1;
} IMAGE_THUNK_DATA32;从上方的结构定义不难看出,这是一个双字共用体结构,当结构的最高位为1时,表示函数是以序号的方式导入的,这时双字的低位就是函数的序号,当双字最高位为0时,表示函数以函数名方式导入,这时双字的值是一个RVA,指向一个用来定义导入函数名称的IMAGE_IMPORT_BY_NAME结构,此结构定义如下:
typedef struct _IMAGE_IMPORT_BY_NAME {
WORD Hint; // 函数序号
CHAR Name[1]; // 导入函数的名称
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;上面的所有结构就是导入表的全部,总结起来就是下图这张表,看了之后是不是很懵逼。

现在我们来分析下上图,导入表中IMAGE_IMPORT_DESCRIPTOR结构的NAME字段指向字符串Kernel32.dll表明当前程序要从Kernel32.dll文件中导入函数,OriginalFirstThunk和FirstThunk字段指向两个同样的IMAGE_THUNK_DATA数组,由于要导入4个函数,所有数组中包含4个有效项目并以最后一个内容为0的项目作为结束。
第4个函数是以序号导入的,与其对应的IMAGE_THUNK_DATA结构最高位等于1,和函数的序号0010h组合起来的数值就是80000010h,其余的3个函数采用的是以函数名方式导入,所以IMAGE_THUNK_DATA结构的数值是一个RVA,分别指向3个IMAGE_IMPORT_BY_NAME结构,每个结构的第一个字段是函数的序号,后面就是函数的字符串名称了,一切就这么简单!
上图为什么会出现两个一模一样的IMAGE_THUNK_DATA数组结构呢? 这是因为PE装载器会将其中一个结构修改为函数的地址jmp dword ptr[xxxx]其中的xxxx就是由FirstThunk字段指向的那个数组中的一员。
实际上当PE文件被装载入内存后,内存中的映像会被Windows修正为如下图所示的样子:

其中由FristThunk字段指向的那个数组中的每个双字都被替换成了真正的函数入口地址,之所以在PE文件中使用两份IMAGE_THUNK_DATA数组的拷贝并修改其中的一份,是为了最后还可以留下一份备份数据用来反过来查询地址所对应的导入函数名。
最后通过编程实现读取导入表数据,如果需要64位需要修改GetNtHead里面的Dword = Dword_PTR
void DisplayImportTable(HANDLE ImageBase)
{
PIMAGE_DOS_HEADER pDosHead = NULL;
PIMAGE_NT_HEADERS pNtHead = NULL;
PIMAGE_IMPORT_DESCRIPTOR pInput = NULL;
PIMAGE_THUNK_DATA _pThunk = NULL;
DWORD dwThunk = NULL;
USHORT Hint;
pDosHead = (PIMAGE_DOS_HEADER)ImageBase;
pNtHead = GetNtHead(ImageBase);
if (pNtHead->OptionalHeader.DataDirectory[1].VirtualAddress == 0){ return; } // 读取导入表RVA
pInput = (PIMAGE_IMPORT_DESCRIPTOR)ImageRvaToVa((PIMAGE_NT_HEADERS)pNtHead, pDosHead, pNtHead->OptionalHeader.DataDirectory[1].VirtualAddress, NULL);
for (; pInput->Name != NULL;)
{
char *szFunctionModule = (PSTR)ImageRvaToVa((PIMAGE_NT_HEADERS)pNtHead, pDosHead, (ULONG)pInput->Name, NULL); // 遍历出模块名称
if (pInput->OriginalFirstThunk != 0)
{
dwThunk = pInput->OriginalFirstThunk;
_pThunk = (PIMAGE_THUNK_DATA)ImageRvaToVa((PIMAGE_NT_HEADERS)pNtHead, pDosHead, (ULONG)pInput->OriginalFirstThunk, NULL);
}
else
{
dwThunk = pInput->FirstThunk;
_pThunk = (PIMAGE_THUNK_DATA)ImageRvaToVa((PIMAGE_NT_HEADERS)pNtHead, pDosHead, (ULONG)pInput->FirstThunk, NULL);
}
for (; _pThunk->u1.AddressOfData != NULL;)
{
char *szFunction = (PSTR)ImageRvaToVa((PIMAGE_NT_HEADERS)pNtHead, pDosHead, (ULONG)(((PIMAGE_IMPORT_BY_NAME)_pThunk->u1.AddressOfData)->Name), 0);
if (szFunction != NULL)
memcpy(&Hint, szFunction - 2, 2);
else
Hint = -1;
printf("%0.4x\t%0.8x\t%s\t %s\n", Hint, dwThunk, szFunctionModule, szFunction);
dwThunk += 8; // 32位=4 64位=8
_pThunk++;
}
pInput++;
}
}当PE文件执行时 Windows装载器将文件装入内存并将导入表中登记的DLL文件一并装入,再根据DLL文件中函数的导出信息对可执行文件的导入表(IAT)进行修正。
导出函数的DLL文件中,导出信息被保存在导出表,导出表就是记载着动态链接库的一些导出信息。通过导出表,DLL文件可以向系统提供导出函数的名称、序号和入口地址等信息,以便Windows装载器能够通过这些信息来完成动态链接的整个过程。
导出函数存储在PE文件的导出表里,导出表的位置存放在PE文件头中的数据目录表中,与导出表对应的项目是数据目录中的首个IMAGE_DATA_DIRECTORY结构,从这个结构的VirtualAddress字段得到的就是导出表的RVA值,导出表同样可以使用函数名或序号这两种方法导出函数。
导出表的起始位置有一个IMAGE_EXPORT_DIRECTORY结构与导入表中有多个IMAGE_IMPORT_DESCRIPTOR结构不同,导出表只有一个IMAGE_EXPORT_DIRECTORY结构,该结构定义如下:
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;上面的_IMAGE_EXPORT_DIRECTORY 结构如果总结成一张图,如下所示:

在上图中最左侧AddressOfNames结构成员指向了一个数组,数组里保存着一组RVA,每个RVA指向一个字符串即导出的函数名,与这个函数名对应的是AddressOfNameOrdinals中的结构成员,该对应项存储的正是函数的唯一编号并与AddressOfFunctions结构成员相关联,形成了一个导出链式结构体。
获取导出函数地址时,先在AddressOfNames中找到对应的名字MyFunc1,该函数在AddressOfNames中是第1项,然后从AddressOfNameOrdinals中取出第1项的值这里是1,然后就可以通过导出函数的序号AddressOfFunctions[1]取出函数的入口RVA,然后通过RVA加上模块基址便是第一个导出函数的地址,向后每次相加导出函数偏移即可依次遍历出所有的导出函数地址,代码如下所示:
CHAR *LoadFile(char *filename)
{
DWORD dwReadWrite, LenOfFile = GetFileSize(filename, NULL);
HANDLE hFile = CreateFileA(filename, GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, 0, OPEN_EXISTING, 0, 0);
if (hFile != INVALID_HANDLE_VALUE)
{
PCHAR buffer = (PCHAR)malloc(LenOfFile);
SetFilePointer(hFile, 0, 0, FILE_BEGIN);
ReadFile(hFile, buffer, LenOfFile, &dwReadWrite, 0);
CloseHandle(hFile);
return buffer;
}
return NULL;
}
VOID DisplayExportTable(char *filename)
{
PIMAGE_NT_HEADERS pNtHead;
PIMAGE_DOS_HEADER pDosHead;
PIMAGE_EXPORT_DIRECTORY pExport;
char *filedata;
filedata = LoadFile(filename);
pDosHead = (PIMAGE_DOS_HEADER)filedata;
pNtHead = (PIMAGE_NT_HEADERS)(filedata + pDosHead->e_lfanew);
if (pNtHead->Signature != 0x00004550){return;} // 无效PE文件
//if (pNtHead->OptionalHeader.Magic != 0x20b){return;} // 不是64位PE
pExport = (PIMAGE_EXPORT_DIRECTORY)ImageRvaToVa((PIMAGE_NT_HEADERS)pNtHead, pDosHead, pNtHead->OptionalHeader.DataDirectory[0].VirtualAddress, NULL);
DWORD i = 0;
DWORD NumberOfNames = pExport->NumberOfNames;
ULONGLONG **ppdwNames = (ULONGLONG **)pExport->AddressOfNames;
ppdwNames = (PULONGLONG*)ImageRvaToVa((PIMAGE_NT_HEADERS)pNtHead, pDosHead, (ULONG)ppdwNames, NULL);
ULONGLONG **ppdwAddr = (ULONGLONG **)pExport->AddressOfFunctions;
ppdwAddr = (PULONGLONG*)ImageRvaToVa((PIMAGE_NT_HEADERS)pNtHead, pDosHead, (DWORD)ppdwAddr, NULL);
ULONGLONG *ppdwOrdin = (ULONGLONG*)ImageRvaToVa((PIMAGE_NT_HEADERS)pNtHead, pDosHead, (DWORD)pExport->AddressOfNameOrdinals, NULL);
char* szFunction = (PSTR)ImageRvaToVa((PIMAGE_NT_HEADERS)pNtHead, pDosHead, (ULONG)*ppdwNames, NULL);
for (i = 0; i<NumberOfNames; i++)
{
printf("%0.8x\t%s\n",*ppdwAddr, szFunction);
szFunction = szFunction + strlen(szFunction) + 1;
ppdwAddr++;
}
}总结:导入表多出现在EXE可执行文件中,而导出表则多出现在DLL文件中,除此之外重定位表也多出现在DLL文件中,而我们最需要关注的其实就是区段信息和导入表相关的内容其他的不太重要。