查看原文
其他

打造自己的PE解释器

QiuJYu 看雪学院 2019-09-17

本文为看雪论坛精华文章

看雪论坛作者ID:QiuJYu



文章概述:写本篇文章的宗旨就是同学可以完全通过阅读本篇文章打造一个自己的PE文件解释器。让同学们能够更加深入的理解并掌握PE文件的基本结构,少走一些弯路。

阅读方法:本篇文章的结构是一章理论加一章代码实现的结构,这样可以完美的达到理论与实践相结合的学习效果。同时也希望同学们在看完理论部分后可以动手实践完成本章的课后练习,在文章的附件中包含每一个练习的参考代码。文章的图片也会附加在附件当中。

具备基础:掌握C语言

学习环境:VS2017 或 VC++6.0、Windows操作系统、LordPE(PE解释器)



贯穿全文的概念


地址空间:这个地址空间指的是PE文件被加载到内存的空间,是一个虚拟的地址空间。

之所以不是物理空间是因为数据在内存中的位置经常在变,这样既可以节约内存开支又可以避开错误的内存位置。

这个地址空间的大小为4G,但其中供程序装载的空间只有2G而且还是低2G空间,高2G空间则被用于装载DLL等文件。
 
文件映射:PE文件在磁盘上的状态和在内存中的状态是不一样的,我们把PE文件在磁盘上的状态称作FileBuffer,在内存中的状态称为ImageBuffer。

当PE文件通过装载器装入内存是会经过“拉伸”的过程,所以它在FileBuffer状态下和ImageBuffer状态下的大小是不一样的。

这个拉伸的具体过程会在讲完PE头结构后进行介绍。

大致的图解如下:


VA:英文全称是Virual Address,简称VA,中文意思是虚拟地址。指的是文件被载入虚拟空间后的地址。
 
ImageBase:中文意思是基址,指的是程序在虚拟空间中被装载的位置。
 
RVA:英文全称是Relative Virual Address,简称RVA,中文意思是相对虚拟地址。可以理解为文件被装载到虚拟空间(拉伸)后先对于基址的偏移地址。

计算方式:RVA = VA(虚拟地址) - ImageBase(基址)。

它的对齐方式一般是以1000h为单位在虚拟空间中对齐的(传说中的4K对齐),具体对齐需要参照IMAGE_OPTIONAL_HEADER32中的SectionAlignment成员。
 
FOA:英文全称是File Offset Address,简称FOA,中文意思是文件偏移地址。可以理解为文件在磁盘上存放时相对于文件开头的偏移地址。

它的对齐方式一般是以200h为单位在硬盘中对齐的(512对齐),具体对齐需要参照IMAGE_OPTIONAL_HEADER32中的FileAlignment成员。
 


第一章:打造自己的PE解释器——PE文件头结构


本章目的:通过简单的讲解PE文件头结构及其基本概念,让刚开始学习PE的同学基本了解PE文件头的结构和相关理论知识,并通过学到的知识获取PE文件头的结构信息。

重点掌握:
(1) IMAGE_DOS_HEADER
(2) IMAGE_NT_HEADERS32
(3) IMAGE_FILE_HEADER
(4) IMAGE_OPTIONAL_HEADER32
(5) IMAGE_SECTION_HEADER


第一节:PE文件结构



PE文件是由许许多多的结构体组成的,程序在运行时就会通过这些结构快速定位到PE文件的各种资源,其结构大致如图所示,从上到下依次是Dos头、Nt头、节表、节区和调试信息(可选)。

其中Dos头、Nt头和节表在本文中统称为PE文件头(因为SizeOfHeaders就是这三个头的总大小)、节区则称为节,所以也可以说PE文件是由PE文件头和节组成。

PE文件头保存着整个PE文件的索引信息,可以帮助PE装载器定位资源,而节则保存着整个PE文件的所有资源。

正因为如此,所以存在着这样的说法:头是节的描述,节是头的具体化。

第二节:IMAGE_DOS_HEADER


IMAGE_DOS_HEADER的结构体定义如下:

typedef struct _IMAE_DOS_HEADER {
WORD e_magic; **重要成员 相对该结构的偏移0x00**
WORD e_cblp;
WORD e_cp;
WORD e_crlc;
WORD e_cparhdr;
WORD e_minalloc;
WORD e_maxalloc;
WORD e_ss;
WORD e_sp;
WORD e_csum;
WORD e_ip;
WORD e_cs;
WORD e_lfarlc;
WORD e_ovno;
WORD e_res[4];
WORD e_oemid;
WORD e_oeminfo;
WORD e_res2[10];
LONG e_lfanew; **重要成员 相对该结构的偏移0x3C**
} IMAGE_DOS-HEADER, *PIMAGE_DOS_HEADER;

当我们用16进制编辑器打开一个PE文件时,就会发现所有PE文件的前两个字节都是MZ,用十六进制表示是4D 5A,这两个字母就是Mark Zbikowski的姓名缩写,他是最初的MS-DOS设计者之一。

如果把PE文件的这两个字节修改成其他数据,运行该PE文件就会无法正常运行(跳出黑窗口打印Program too big to fit in memory然后闪退,有兴趣的朋友可以尝试下)。

这里可以证明当PE文件运行时,首先就会检测这两个字节,如果不是MZ则会退出运行。


在该结构体中另一个重要成员就是最后一个成员e_lfanew。该成员的大小是LONG类型4个字节。

之所以说它重要是因为它保存着IMAGE_NT_HEADERS32这个结构体在PE文件中的偏移地址,PE文件运行时只有通过该成员才能定位到PE签名(也就是IMAGE_NT_HEADERS32结构体的起始位置)。


第三节:IMAGE_DOS_STUB (了解)


在IMAGE_DOS_HEADER结构体后面紧跟着就是IMAGE_DOS_STUB程序,它是运行在MS-DOS下的可执行程序。

当可执行文件运行于MS-DOS下时,这个程序会打印This program cannot be run in DOS mode这条消息。

用户可以自己更改该程序,MS-DOS程序当前是可有可无的,如果你想使文件大小尽可能的小可以省掉MS-DOS程序,同时把前面的参数都清0。

第四节:IMAGE_NT_HEADERS32


IMAGE_NT_HEADERS32的结构体定义如下:

typedef struct _IMAGE_NT_HEADERS {
DWORD Signature; **重要成员 PE签名 相对该结构的偏移0x00**
IMAGE_FILE_HEADER FileHeader; **重要成员 结构体 相对该结构的偏移0x04**
IMAGE_OPTIONAL_HEADER32 OptionalHeader; **重要成员 结构体 相对该结构的偏移0x18**
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;

这个结构体是整个PE文件的核心,它是由一个Signature、一个IMAGE_FILE_HEADER结构体、一个IMAGE_OPTIONAL_HEADER32结构体组成的。

所以从整体看来这个结构比较简单,但实际上其内部结构较为复杂,我将会在下方对两个结构体进行详细的介绍。

Signature也称作PE签名,这个成员和DOS头的MZ标记一样都是一个PE文件的标准特征,只不过这个成员是DWORD类型大小为4字节。

如果把这个PE签名修改后,程序也是不会正常运行的(跳出黑窗口打印This program cannot be run in DOS mode然后闪退,可能是因为修改PE签名后无法识别后续内容的关系吧)。

修改PE签名后的运行结果:


如果把MZ标志和PE签名同时改变的话,其效果和只修改MZ是一样的,可见程序在载入时是先检测MZ标志然后才检测PE签名的:


第五节:IMAGE_FILE_HEADER


IMAGE_FILE_HEADER的结构体定义如下:

typedef struct _IMAGE_FILE_HEADER {
WORD Machine; ** 机器号 相对该结构的偏移0x00**
WORD NumberOfSections; **重要成员 节区数量 相对该结构的偏移0x02**
DWORD TimeDateStamp; ** 时间戳 相对该结构的偏移0x04**
DWORD PointerToSymbolTable; ** 符号表偏移 相对该结构的偏移0x08**
DWORD NumberOfSymbols; ** 符号表数量 相对该结构的偏移0x0C**
WORD SizeOfOptionalHeader; **重要成员 可选头大小 相对该结构的偏移0x10**
WORD Characteristics; **重要成员 PE文件属性 相对该结构的偏移0x12**
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;

Machine所表示的是计算机的体系结构类型,也就是说这个成员可以指定该PE文件能够在32位还是在64位CPU上执行。

如果强行更改该数值程序就会报错。该成员可以是以下的数值:
 


NumberOfSections,它的含义就是当前PE文件的节区数量,虽然它是大小是两个字节,但是在windows加载程序时会将节区的最大数量限制为96个。

从010 Editor模版上验证节区数量:


TimeDateStamp,它的含义是时间戳,用于表示该PE文件创建的时间,时间是从国际协调时间也就是1970年1月1日00:00起开始计数的,计数单位是秒。

例如0x5CFBB225的计算方法如下:

通过计算,从1970年到2019年一共有12个闰年(通过闰年计算器获得),到6月有151天。

秒:0x5CFBB225 % 60 = 33
分:0x5CFBB225 / 60 % 60 = 3
时:0x5CFBB225 / 3600 % 24 = 13
日:0x5CFBB225 / 3600 / 24 - (365 * 49 + 12) - 151 + 1 = 8
月:(0x5CFBB225 / 3600 / 24 - (365 * 49 + 12)) / 30 + 1 = 6
年:0x5CFBB225 / 3600 / 24 / 365 + 1970 = 2019
结果为:2019年6月8日 13:03:33


SizeOfOptionalHeader,它存储该PE文件的可选PE头的大小,在32位PE文件中可选头大小为0xE0,64位可选头大小为0xF0。

正因为如此,所以就必须通过该成员来确定可选PE头的大小。

Characteristics,它描述了PE文件的一些属性信息,比如是否可执行,是否是一个动态连接库等。

该值可以是一个也可以是多个值的和,具体定义如下:


第六节:IMAGE_OPTIONAL_HEADER32


IMAGE_OPTIONAL_HEADER32的结构体定义如下(大部分成员不重要):

typedef struct _IMAGE_OPTIONAL_HEADER {
WORD Magic; **魔术字 偏移0x00
BYTE MajorLinkerVersion; **链接器主版本 偏移0x02
BYTE MinorLinkerVersion; **链接器副版本 偏移0x03
DWORD SizeOfCode; **所有含代码的节的总大小 偏移0x04
DWORD SizeOfInitializedData; **所有含初始数据的节的总大小 偏移0x08
DWORD SizeOfUninitializedData; **所有含未初始数据的节的总大小 偏移0x0C
DWORD AddressOfEntryPoint; **程序执行入口地址 偏移0x10 重要
DWORD BaseOfCode; **代码节的起始地址 偏移0x14
DWORD BaseOfData; **数据节的起始地址 偏移0x18
DWORD ImageBase; **程序首选装载地址 偏移0x1C 重要
DWORD SectionAlignment; **内存中节区对齐大小 偏移0x20 重要
DWORD FileAlignment; **文件中节区对齐大小 偏移0x24 重要
WORD MajorOperatingSystemVersion; **操作系统的主版本号 偏移0x28
WORD MinorOperatingSystemVersion; **操作系统的副版本号 偏移0x2A
WORD MajorImageVersion; **镜像的主版本号 偏移0x2C
WORD MinorImageVersion; **镜像的副版本号 偏移0x2E
WORD MajorSubsystemVersion; **子系统的主版本号 偏移0x30
WORD MinorSubsystemVersion; **子系统的副版本号 偏移0x32
DWORD Win32VersionValue; **保留,必须为0 偏移0x34
DWORD SizeOfImage; **镜像大小 偏移0x38 重要
DWORD SizeOfHeaders; **PE头大小 偏移0x3C 重要
DWORD CheckSum; **校验和 偏移0x40
WORD Subsystem; **子系统类型 偏移0x44
WORD DllCharacteristics; **DLL文件特征 偏移0x46
DWORD SizeOfStackReserve; **栈的保留大小 偏移0x48
DWORD SizeOfStackCommit; **栈的提交大小 偏移0x4C
DWORD SizeOfHeapReserve; **堆的保留大小 偏移0x50
DWORD SizeOfHeapCommit; **堆的提交大小 偏移0x54
DWORD LoaderFlags; **保留,必须为0 偏移0x58
DWORD NumberOfRvaAndSizes; **数据目录的项数 偏移0x5C
IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];
} IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32;

下方只对一些相对重要的成员进行讲解:

Magic

这个无符号整数指出了镜像文件的状态,此成员可以是以下的值:



AddressOfEntryPoint

该成员保存着文件被执行时的入口地址,它是一个RVA。如果想要在一个可执行文件中附加了一段代码并且要让这段代码首先被执行,就可以通过更改入口地址到目标代码上,然后再跳转回原有的入口地址。

 
ImageBase

该成员指定了文件被执行时优先被装入的地址,如果这个地址已经被占用,那么程序装载器就会将它载入其他地址。


当文件被载入其他地址后,就必须通过重定位表进行资源的重定位,这就会变慢文件的载入速度。而装载到ImageBase指定的地址就不会进行资源重定位。

对于EXE文件来说,由于每个文件总是使用独立的虚拟地址空间,优先装入地址不可能被其他模块占据,所以EXE总是能够按照这个地址装入,这也意味着EXE文件不再需要重定位信息。

对于DLL文件来说,由于多个DLL文件全部使用宿主EXE文件的地址空间,不能保证优先装入地址没有被其他的DLL使用,所以DLL文件中必须包含重定位信息以防万一。

因此,在前面介绍的 IMAGE_FILE_HEADER 结构的 Characteristics 成员中,DLL 文件对应的IMAGE_FILE_RELOCS_STRIPPED位总是为0,而EXE文件的这个标志位总是为1。
 
SectionAlignment

该成员指定了文件被装入内存时,节区的对齐单位。节区被装入内存的虚拟地址必须是该成员的整数倍,以字节为单位,并且该成员的值必须大于等于FileAlignment的值。该成员的默认大小为系统的页面大小。


FileAlignment

该成员指定了文件在硬盘上时,节区的对齐单位。节区在硬盘上的地址必须是该成员的整数倍,以字节为单位,并且该成员的值必须大于等于FileAlignment的值。


该值应为200h到10000h(含)之间的2的幂。默认为200h。如果SectionAlignment的值小于系统页面大小,则FileAlignment的值必须等于SectionAlignment的值。
 
SizeOfImage

该成员指定了文件载入内存后的总体大小,包含所有的头部信息。并且它的值必须是SectionAlignment的整数倍。


SizeOfHeaders

该成员指定了PE文件头的大小,并且向上舍入为FileAlignment的倍数,值的计算方式为:


SizeOfHeaders = (e_lfanew/*DOS头部*/ + 4/*PE签名*/ +
sizeof(IMAGE_FILE_HEADER) +
SizeOfOptionalHeader + /*NT头*/
sizeof(IMAGE_SECTION_HEADER) * NumberOfSections) / /*节表*/
FileAlignment *
FileAlignment +
FileAlignment; /*向上舍入 一般该结果不可能是FileAlignment的整数倍,所以直接加上FileAlignment还是没问题的 */

NumberOfRvaAndSizes

该成员指定了可选头中目录项的具体数目,由于以前发行的Windows NT的原因,它只能为10h。


该结构体中剩下的那一个成员比较特殊,会在下一章进行详细讲解。


第七节:IMAGE_SECTION_HEADER


IMAGE_SECTION_HEADER的结构体定义如下:

Name

这是一个8字节的ASCII字符串,长度不足8字节时用0x00填充,该名称并不遵守必须以"\0"结尾的规律,如果不是以"\0"结尾,系统会截取8个字节的长度进行处理。


可执行文件不支持长度超过8字节的节名。对于支持超过字节长度的文件来说,此成员会包含斜杠(/),并在后面跟随一个用ASCII表示的十进制数字,该数字是字符串表的偏移量。

Misc.VirtualSize

这个成员在一个共用体中,这个共用体中还有另外一个成员,由于用处不大我们就不讲解了,主要讲解VirtualSize的含义。


这个成员指定了该节区装入内存后的总大小,以字节为单位,如果此值大于SizeOfRawData的值,那么大出的部分将用0x00填充。

这个成员只对可执行文件有效,如果是obj文件此成员的值为0。

VirtualAddress

指定了该节区装入内存虚拟空间后的地址,这个地址是一个相对虚拟地址(RVA),它的值一般是SectionAlignment的整数倍。它加上ImageBase后才是真正的虚拟地址。


SizeOfRawData

指定了该节区在硬盘上初始化数据的大小,以字节为单位。它的值必须是FileAlignment的整数倍,如果小于Misc.VirtualSize,那么该部分的其余部分将用0x00填充。如果该部分仅包含未初始化的数据,那么这个值将会为零。


PointerToRawData

指出零该节区在硬盘文件中的地址,这个数值是从文件头开始算起的偏移量,也就是说这个地址是一个文件偏移地址(FOA)。它的值必须是FileAlignment的整数倍。如果这个部分仅包含未初始化的数据,则将此成员设置为零。


Characteristics

该成员指出了该节区的属性特征。其中的不同数据位代表了不同的属性,这些数据位组合起来就是这个节的属性特征,具体数值定义如下:



节表在PE文件头中的排列位置比较特殊,节表是紧跟在NT头(也可以说是可选PE头后)后的,它实际上是一个IMAGE_SECTION_HEADER类型的数组,数组的成员个数被定义在IMAGE_FILE_HEADER中的NumberOfSections成员上,需要注意的是在最后一个节表后最好应该有一个与节表同样大小的用0x00填充的空白数据。
 
节表各个成员意义图解:


第八节:章节练习


1. 通过编写控制台程序,将一个EXE文件读取到内存,打印出它所有的文件信息。(与LordPE的结果进行对照)

2. 通过编写控制台程序,将一个EXE文件读取到内存(FileBuffer),在内存中将它进行拉伸(ImageBuffer),再压缩(NewFileBuffer),然后将压缩后的NewFileBuffer存盘并可以正常运行,实现PE加载过程。

3. 通过编写控制台程序,将一个EXE文件读取到内存,在它的节表中新增一个节表和节区,存盘后让他可以正常运行。

4. 通过编写控制台程序,将一个EXE文件读取到内存,把该文件的最后一个节扩大1000h,并保证程序的正常运行。

5. 通过编写控制台程序,将一个EXE文件读取到内存,把该文件的所有节进行合并,并保证程序的正常运行。

6. 通过编写控制台程序,将一个EXE文件读取到内存,在它的可执行节(代码节)中加一个弹出对话框(MessgeBox)的ShellCode,通过修改程序执行入口实现文件感染,可以正常运行。
 
练习小提示:

1、
读取文件的关键代码:
//函数声明
//**************************************************************************
//MyReadFile:将文件读取到缓冲区
//参数说明:
//pFileAddress 缓冲区地址
//返回值说明:
//成功返回0
//**************************************************************************
int MyReadFile(void** pFileAddress);
{
int ret = 0;
DWORD Length = 0;
//打开文件
FILE* pf = fopen(FILE_PATH, "rb");
if (pf == NULL)
{
ret = -1;
printf("func ReadFile() Error!\n");
return ret;
}

//获取文件长度
ret = GetFileLength(pf, &Length);
if (ret != 0 && Length == -1)
{
ret = -2;
printf("func GetFileLength() Error!\n");
return ret;
}

//分配空间
*pFileAddress = (PVOID)malloc(Length);
if (*pFileAddress == NULL)
{
ret = -3;
printf("func malloc() Error!\n");
return ret;
}
memset(*pFileAddress, 0, Length);

//读取文件进入内存
fread(*pFileAddress, Length, 1, pf);

fclose(pf);
return ret;
}

2、
1)、根据SizeOfImage的大小,开辟一块缓冲区(ImageBuffer).
2)、根据SizeOfHeader的大小,将头信息从FileBuffer拷贝到ImageBuffer
3)、根据节表中的信息循环讲FileBuffer中的节拷贝到ImageBuffer中.
读取文件的关键代码:
//函数声明
//**************************************************************************
//MyWriteFile:将缓存写入硬盘
//参数说明:
//pFileAddress 缓冲区地址
//FileSize 写入大小
//FilePath 写入路径
//返回值说明:
//成功返回0
//**************************************************************************
int MyWriteFile(PVOID pFileAddress, DWORD FileSize, LPSTR FilePath)
{
int ret = 0;

FILE *pf = fopen(FilePath, "wb");
if (pf == NULL)
{
ret = -5;
printf("func fopen() error :%d!\n", ret);
return ret;
}

fwrite(pFileAddress, FileSize, 1, pf);

fclose(pf);

return ret;
}
3、
1)、判断是否有足够的空间,可以添加一个节表.
判断条件:
剩余空间 = SizeOfHeader -
(e_lfanew + 4/*PE标记*/ +
sizeof(IMAGE_FILE_HEADER) +
SizeOfOptionalHeader + /*NT头*/
sizeof(IMAGE_SECTION_HEADER) * NumberOfSections) /*节表大小*/
>= sizeof(IMAGE_SECTION_HEADER) * 2 /*2个节表的大小*/
2)、需要修改的数据
1> 添加一个新的节(可以copy一份)
2> 在新增节后面 填充一个节大小的0x00
3> 修改PE头中节的数量
4> 修改sizeOfImage的大小
5> 再原有数据的最后,新增一个节的数据(内存对齐的整数倍).
6> 修正新增节表的属性
4、
1)、拉伸到内存
2)、分配一块新的空间:SizeOfImage + 1000h
3)、修改最后一个节的SizeOfRawData和VirtualSize
4)、修改SizeOfImage的大小
5、
1)、拉伸到内存
2)、将第一个节的内存大小、文件大小改成一样
3)、将第一个节的属性改为包含所有节的属性
4)、修改节的数量为1

6、
1)、获取MessageBox地址,构造ShellCode代码.
2)、E8 E9计算公式
3)、在代码区手动添加代码
4)、修改OEP,指向ShellCode

char shellcode[] =
{
0x6A, 00, 0x6A, 00, 0x6A, 00, 0x6A, 00,
0xE8, 00, 00, 00, 00,
0xE9, 00, 00, 00, 00
};
注:0xE8 是call的机器码;0xE9是jmp的机器码;它们后面跟着的4个字节需要通过计算获得。
计算方式: X = 真正要跳转的地址 - 这条指令的下一行地址, 修改地址时注意小端对齐。
AddressOfEntryPoint是一个RAV

//RVA与FOA相互转换相关代码:

//函数声明
//**************************************************************************
//FOA_TO_RVA:将FOA转换成RVA
//参数说明:
//FileAddress 缓冲区地址
//FOA FOA值
//pRVA RVA地址
//返回值说明:
//成功返回0
//**************************************************************************
int FOA_TO_RVA(PVOID FileAddress, DWORD FOA, PDWORD pRVA)
{
int ret = 0;

PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)(FileAddress);
PIMAGE_FILE_HEADER pFileHeader = (PIMAGE_FILE_HEADER)((DWORD)pDosHeader + pDosHeader->e_lfanew + 4);
PIMAGE_OPTIONAL_HEADER32 pOptionalHeader = (PIMAGE_OPTIONAL_HEADER32)((DWORD)pFileHeader + sizeof(IMAGE_FILE_HEADER));
PIMAGE_SECTION_HEADER pSectionGroup = (PIMAGE_SECTION_HEADER)((DWORD)pOptionalHeader + pFileHeader->SizeOfOptionalHeader);

//FOA在文件头中 或 SectionAlignment 等于 FileAlignment 时RVA等于FOA
if (FOA < pOptionalHeader->SizeOfHeaders || pOptionalHeader->SectionAlignment == pOptionalHeader->FileAlignment)
{
*pRVA = FOA;
return ret;
}

//FOA在节区中
for (int i = 0; i < pFileHeader->NumberOfSections; i++)
{
if (FOA >= pSectionGroup[i].PointerToRawData && FOA < pSectionGroup[i].PointerToRawData + pSectionGroup[i].SizeOfRawData)
{
*pRVA = pSectionGroup[i].VirtualAddress + FOA - pSectionGroup[i].PointerToRawData;
return ret;
}
}

//没有找到地址
ret = -4;
printf("func FOA_TO_RAV() Error: %d 地址转换失败!\n", ret);
return ret;
}


//函数声明
//**************************************************************************
//RVA_TO_FOA:将RVA转换成FOA
//参数说明:
//FileAddress 缓冲区地址
//RVA RVA值
//pFOA FOA地址
//返回值说明:
//成功返回0
//**************************************************************************
int RVA_TO_FOA(PVOID FileAddress, DWORD RVA, PDWORD pFOA)
{
int ret = 0;

PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)(FileAddress);
PIMAGE_FILE_HEADER pFileHeader = (PIMAGE_FILE_HEADER)((DWORD)pDosHeader + pDosHeader->e_lfanew + 4);
PIMAGE_OPTIONAL_HEADER32 pOptionalHeader = (PIMAGE_OPTIONAL_HEADER32)((DWORD)pFileHeader + sizeof(IMAGE_FILE_HEADER));
PIMAGE_SECTION_HEADER pSectionGroup = (PIMAGE_SECTION_HEADER)((DWORD)pOptionalHeader + pFileHeader->SizeOfOptionalHeader);

//RVA在文件头中 或 SectionAlignment 等于 FileAlignment 时RVA等于FOA
if (RVA < pOptionalHeader->SizeOfHeaders || pOptionalHeader->SectionAlignment == pOptionalHeader->FileAlignment)
{
*pFOA = RVA;
return ret;
}

//RVA在节区中
for (int i = 0; i < pFileHeader->NumberOfSections; i++)
{
if (RVA >= pSectionGroup[i].VirtualAddress && RVA < pSectionGroup[i].VirtualAddress + pSectionGroup[i].Misc.VirtualSize)
{
*pFOA = pSectionGroup[i].PointerToRawData + RVA - pSectionGroup[i].VirtualAddress;
return ret;
}
}

//没有找到地址
ret = -4;
printf("func RAV_TO_FOA() Error: %d 地址转换失败!\n", ret);
return ret;
}


第二章:打造自己的PE解释器——目录信息


本章目的:在上一章中我们留下来一个知识点没有讲解,那就是IMAGE_OPTIONAL_HEADER32中的最后一个成员DataDirectory。

虽然他只是一个结构体数组,每个结构体的大小也不过是个字节,但是它却是PE文件中最重要的成员。PE装载器通过查看它才能准确的找到某个函数或某个资源。

重点掌握:

(1) 0x01 IMAGE_EXPORT_DIRECTORY——导出表

(2) 0x03 IMAGE_BASE_RELOCATION——重定位表

(3) 0x05 IMAGE_IMPORT_DESCRIPTOR——导入表

(4) 0x07 IMAGE_BOUND_IMPORT_DESCRIPTOR——绑定导入表

(5) 0x09 IMAGE_RESOURCE_DIRECTORY——资源表


第一节:IMAGE_DATA_DIRECTORY——数据目录结构


IMAGE_DATA_DIRECTORY的结构体定义如下:

typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress; /**指向某个数据的相对虚拟地址 RAV 偏移0x00**/
DWORD Size; /**某个数据块的大小 偏移0x04**/
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;

在这个数据目录结构体中只有两个成员VirtualAddress和Size,这两个成员的含义比较简单,VirtualAddress指定了数据块的相对虚拟地址(RVA)。

Size则指定了该数据块的大小,有时并不是该类型数据的总大小,可能只是该类型数据一个数据项的大小。

这两个成员(主要是VirtualAddress)成为了定位各种表的关键,所以一定要知道每个数组元素所指向的数据块类型,以下表格就是它的对应关系:





//定位目录项的方法(以导出表为例):所有操作都在FileBuffer状态下完成


//1、指向相关内容
PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)(FileAddress);
PIMAGE_FILE_HEADER pFileHeader = (PIMAGE_FILE_HEADER)((DWORD)pDosHeader + pDosHeader->e_lfanew + 4);
PIMAGE_OPTIONAL_HEADER32 pOptionalHeader = (PIMAGE_OPTIONAL_HEADER32)((DWORD)pFileHeader + sizeof(IMAGE_FILE_HEADER));

//2、获取导出表的地址(目录项的第0个成员)
DWORD ExportDirectory_RAVAdd = pOptionalHeader->DataDirectory[0].VirtualAddress;
DWORD ExportDirectory_FOAAdd = 0;
// (1)、判断导出表是否存在
if (ExportDirectory_RAVAdd == 0)
{
printf("ExportDirectory 不存在!\n");
return ret;
}
// (2)、获取导出表的FOA地址 转换函数看上一章作业提示
ret = RVA_TO_FOA(FileAddress, ExportDirectory_RAVAdd, &ExportDirectory_FOAAdd);
if (ret != 0)
{
printf("func RVA_TO_FOA() Error!\n");
return ret;
}

//3、指向导出表
PIMAGE_EXPORT_DIRECTORY ExportDirectory = (PIMAGE_EXPORT_DIRECTORY)((DWORD)FileAddress + ExportDirectory_FOAAdd);


第二节:IMAGE_EXPORT_DIRECTORY——导出表


IMAGE_EXPORT_DIRECTORY的结构体定义如下:

typedef struct _IMAGE_EXPORT_DIRECTORY {
DWORD Characteristics; // 未使用,总为0
DWORD TimeDateStamp; // 文件创建时间戳
WORD MajorVersion; // 未使用,总为0
WORD MinorVersion; // 未使用,总为0
DWORD Name; // **重要 指向一个代表此 DLL名字的 ASCII字符串的 RVA
DWORD Base; // **重要 函数的起始序号
DWORD NumberOfFunctions; // **重要 导出函数地址表的个数
DWORD NumberOfNames; // **重要 以函数名字导出的函数个数
DWORD AddressOfFunctions; // **重要 导出函数地址表RVA
DWORD AddressOfNames; // **重要 导出函数名称表RVA
DWORD AddressOfNameOrdinals; // **重要 导出函数序号表RVA
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;

导出表简介:在导出表中前四个成员基本没有用,我们就不用去管他,但是剩下的成员都是非常重要的,我们会通过讲解导出表的结构时顺带介绍。

现在我们来说说导出表的作用,简单来说导出表就是用来描述模块中的导出函数的结构,导出函数就是将功能的提供给外部使用的函数。

如果一个PE文件导出了函数,那么这个函数的信息就会记录PE文件的导出表中,方便外部程序加载该文件进行动态调用。

可能有时函数在导出表中只有一个序号而没有名字,也就造成了导出表中有了三个子表的存在,分别是:函数地址表、函数名称表和函数序号表。

使得外部程序可以通过函数名称和函数序号两种方式获取该函数的地址。

//系统中获取函数地址的两种方法:
HMODULE hModule = LoadLibraryA("User32.dll");
//1、函数名获取
DWORD FuncAddress = GetProcAddress(hModule, "MessageBoxA");
//2、序号获取
DWORD FuncAddress = GetProcAddress(hModule, 12);

AddressOfFunctions

这个值是一个4字节的RVA地址,他可以用来定位导出表中所有函数的地址表,这个地址表可以当作一个成员宽度为4的数组进行处理。

它的长度由NumberOfFunctions进行限定,地址表中的成员也是一个RVA地址,在内存中加上ImageBase后才是函数真正的地址。

AddressOfNames

这个值是一个4字节的RVA地址,他可以用来定位导出表中所有函数的名称表,这个名称表也可以当作一个成员宽度为4的数组进行处理。


它的长度由NumberOfNames进行限定,名称表的成员也是一个RVA地址,在FIleBuffer状态下需要进行RVA到FOA的转换才能真正找到函数名称。

AddressOfNameOrdinals

这个值是一个4字节的RVA地址,他可以用来定位导出表中所有函数的序号表,这个序号表可以当作一个成员宽度为2的数组进行处理,它的长度由NumberOfNames进行限定,名称表的成员是一个函数序号,该序号用于通过名称获取函数地址。


NumberOfFunctions

注意,这个值并不是真的函数数量,他是通过函数序号表中最大的序号减去最小的序号再加上一得到的,例如:一共导出了3个函数,序号分别是:0、2、4,NumberOfFunctions = 4 - 0 + 1 = 5个。

 
导出表结构图:


通过导出表查找函数地址的两种方法:
 
1、通过函数名查找函数地址:


(1) 首先定位函数名表,然后通过函数名表中的RVA地址定位函数名,通过比对函数名获取目标函数名的在函数名表中的索引。

(2) 通过获取函数名表的索引获取函数序号表中对应索引中的函数序号。

(3) 通过把该序号当作函数地址表的下标,就可以得到该下标中的函数地址。

 
2、通过函数序号查找函数地址:


(1)首先计算函数地址表的索引:index = 目标函数的函数序号 - 导出表的Base。
(2)通过计算出的索引就可以在函数地址表中获取到目标序号的函数地址。
注:通过序号获取函数地址不需要使用函数名称表和函数序号表就可以直接获取函数地址,实现上相对来说比较方便。


第三节:导出表小练习


1. 通过编写控制台程序,打印导出表信息,并打印出函数地址表、函数名表、序号表。

2. 写出按名字查找函数地址、按序号查找函数地址相关函数。

3. 在PE文件中创建一个新节,然后将导出表的所有信息移动到新节中。最后将文件写入硬盘,并可以正确解析导出表。
 
练习小提示:

1、
// 1)定位导出表
// 2)打印导出表所有信息
// 3)定位函数地址表,并打印相关信息
// 4)定位函数序号表,并打印相关信息
// 5)定位函数名称表,并打印相关信息

2、可以通过上一节的图片提示编程
//参考代码声明
//**************************************************************************
//GetProcAddressByName:按名字查找函数地址
//参数说明:
//FileAddress 缓冲区地址
//pFuncName 要查找的函数名
//FuncAddressRVA 查找出的函数地址指针
//返回值说明:
//成功返回0
//**************************************************************************
int GetProcAddressByName(PVOID FileAddress, PCHAR pFuncName, PDWORD FuncAddressRVA);

//**************************************************************************
//GetProcAddressByOrdinal:按序号查找函数地址
//参数说明:
//FileAddress 缓冲区地址
//wFuncOrdinal 要查找的函数序号
//FuncAddressRVA 查找出的函数地址指针
//返回值说明:
//成功返回0
//**************************************************************************
int GetProcAddressByOrdinal(PVOID FileAddress, WORD wFuncOrdinal, PDWORD FuncAddressRVA);

3、移动导出表的步骤:
1)创建一个新节
2)移动函数地址表到新节区
3)移动函数序号表
4)移动函数名称表
5)将函数名称移动到函数名称表之后,并修正函数名表中的数据
6)将文件名移动到函数名后
7)将整个导出表移动到文件名后
8)修复导出表数据:Name、AddressOfFunctions、AddressOfNames、NumberOfFunctions
9)修正目录项的RVA地址

第四节:IMAGE_BASE_RELOCATION——重定位表


IMAGE_EXPORT_DIRECTORY的结构体定义如下:
typedef struct _IMAGE_BASE_RELOCATION {
DWORD VirtualAddress; 重定位数据所在页的RVA
DWORD SizeOfBlock; 当前页中重定位数据块的大小
} IMAGE_BASE_RELOCATION;
typedef IMAGE_BASE_RELOCATION UNALIGNED * PIMAGE_BASE_RELOCATION;

重定位表简介:正如我们所知,在程序运行时系统首先会给程序分配一个4GB的虚拟内存空间,低2G空间用于放置EXE文件和DLL文件,高2G空间则是用于取得程序使用(这个空间所有程序共享)。

系统随后就会将EXE文件第一个贴入低2G空间占据文件指定的ImageBase,所以EXE文件有时会没有重定位表,因为ImageBase区域大多数情况是可以使用的,也就不需要重定位。

贴完EXE文件后接下来就会将大量程序使用的DLL文件贴入虚拟空间,然而这些DLL文件的ImageBase可能会发生冲突。

所以有些DLL文件就不会被贴入指定的地址,但是为了让程序正常运行就只能将这些DLL贴入其他的地址。

但是在PE文件中很多地址都是被编译器写死固定的(例子在下方代码块),如果基址改变这些地址就会无法使用,为了避免这样的事情发生就需要修正这些固定的地址,所以就有了重定位表。

重定位表就是记录了这些需要修正的地址,在ImageBase发生改变时就会进行修正重定位表。

修正方法:需要重定位的地址 - 以前的基址 + 当前的基址。

//需要重定位的值013EBC98h和013ED49Ch
printf("Helloworld %s", "hahaha");
013E64B2 68 98 BC 3E 01 push offset string "hahaha" (013EBC98h)
013E64B7 68 9C D4 3E 01 push offset string "Helloworld %s" (013ED49Ch)
013E64BC E8 A3 AB FF FF call _printf (013E1064h)
013E64C1 83 C4 08 add esp,8

VirtualAddress

这个虚拟地址是一组重定位数据的开始RVA地址,只有重定位项的有效数据加上这个值才是重定位数据真正的RVA地址。


SizeOfBlock

它是当前重定位块的总大小,因为VirtualAddress和SizeOfBlock都是4字节的,所以(SizeOfBlock - 8)才是该块所有重定位项的大小,(SizeOfBlock - 8) / 2就是该块所有重定位项的数目。


重定位项

重定位项在该结构中没有体现出来,他的位置是紧挨着这个结构的,可以把他当作一个数组,宽度为2字节,每一个重定位项分为两个部分:高4位和低12位。

高4位表示了重定位数据的类型(0x00没有任何作用仅仅用作数据填充,为了4字节对齐。

0x03表示这个数据是重定位数据,需要修正。0x0A出现在64位程序中,也是需要修正的地址),低12位就是重定位数据相对于VirtualAddress的偏移,也就是上面所说的有效数据。

之所以是12位,是因为12位的大小足够表示该块中的所有地址(每一个数据块表示一个页中的所有重定位数据,一个页的大小位0x1000)。
 
注:如果修改了EXE文件的ImageBase,就要手动修复它的重定位表,因为系统会判断程序载入地址和ImageBase是否一致,如果一致就不会自动修复重定位表,双击运行时就会报错。
 
重定位表结构:


通过重定位表找到需要修正的数据:


第五节:重定位表小练习


1. 通过编写控制台程序,打印出重定位表所有信息以及重定位项,同时找到需要修正的数据。
  
2. 在PE文件中创建一个新节,然后将重定位表的所有信息移动到新节中。最后将文件写入硬盘,并可以正确解析重定位表。
  
3. 改变EXE文件中的ImageBase,然后手动修复重定位表,使其能够正常运行。(EXE文件必须包含重定位表,否则会失败)
 
练习小提示:

1、代码比较简单,参考图片可以轻松完成,提供下参考打印格式:


2、移动重定位表的步骤:

(1)在PE文件中创建一个新节

(2)将重定位表的数据块循环拷贝到新的节区

(3)修复目录项对应的虚拟地址


3、修复重定位表的方式:


修复结果 = 需要重定位的数据 - 以前的ImageBase + 现在的ImageBase;


第六节:IMAGE_IMPORT_DESCRIPTOR——导入表


IMAGE_IMPORT_DESCRIPTOR——导入表的结构体定义如下:

typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics;
DWORD OriginalFirstThunk; //导入名称表(INT)的RVA地址
} DUMMYUNIONNAME;
DWORD TimeDateStamp; //时间戳多数情况可忽略 如果是0xFFFFFFFF表示IAT表被绑定为函数地址
DWORD ForwarderChain;
DWORD Name; //导入DLL文件名的RVA地址
DWORD FirstThunk; //导入地址表(IAT)的RVA地址
} IMAGE_IMPORT_DESCRIPTOR;
typedef IMAGE_IMPORT_DESCRIPTOR UNALIGNED *PIMAGE_IMPORT_DESCRIPTOR;


导入表简介:PE文件使用来自于其他DLL的代码或数据是,称作导入(或者输入)。当PE文件装入时,Windows装载器的工作之一就是定位所有被输入的函数和数据,并且让正在被装入的问渐渐可以使用这些地址。

这个过程就是通过PE文件的导入表来完成的,导入表中保存的是函数名和其驻留的DLL名等动态链接所需的信息。
 
OriginalFirstThunk

这个值是一个4字节的RVA地址,这个地址指向了导入名称表(INT),INT是一个IMAGE_THUNK_DATA结构体数组,这个结构体的最后一个成员内容为0时数组结束。


这个数组的每一个成员又指向了一个IMAGE_IMPORT_BY_NAME结构体,这个结构体包含了两个成员函数序号和函数名,不过这个序号一般没什么用,所以有的编译器会把函数序号置0。

函数名可以当作一个以0结尾的字符串。(注:这个表不在目录项中。)

Name

DLL名字的指针,是一个RVA地址,指向了一个以0结尾的ASCII字符串。
  

FirstThunk

这个值是一个4字节的RVA地址,这个地址指向了导入地址表(IAT),这个IAT和INT一样,也是一个IMAGE_THUNK_DATA结构体数组。


不过它在程序载入前和载入后由两种状态,在程序载入前它的结构和内容和INT表完全一样,但却是两个不同的表,指向了IMAGE_IMPORT_BY_NAME结构体。

在程序载入后,他的结构和INT表一样,但内容就不一样了,里面存放的都是导入函数的地址。(注:这个表在目录项中,需要注意。)

IMAGE_THUNK_DATA——INT、IAT的结构体定义如下:

typedef struct _IMAGE_THUNK_DATA32 {
union {
DWORD ForwarderString; // PBYTE
DWORD Function; // PDWORD
DWORD Ordinal;
DWORD AddressOfData; // PIMAGE_IMPORT_BY_NAME
} u1;
} IMAGE_THUNK_DATA32;
typedef IMAGE_THUNK_DATA32 * PIMAGE_THUNK_DATA32;

//注:这个结构体是联合类型的,每一个成员都是4字节,所以为了编程方便,完全可以用一个4字节的数组取代它。

MAGE_IMPORT_BY_NAME 结构体定义如下:

typedef struct _IMAGE_IMPORT_BY_NAME {
WORD Hint;
CHAR Name[1];
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;

//注:这个结构体由两个成员组成,大致一看它的大小是3个字节,其实它的大小是不固定的,
// 因为无法判断函数名的长度,所以最后一个成员是一个以0结尾的字符串。

EXE文件载入后IAT表的状态:


注:我们随便用OD载入一个EXE文件,找到一个Kernel32.DLL的函数GetStartupInfoA。

双击这条反汇编看看它的指令,发现call的是0x41D034中存放的内容,接着我们搜索这个地址发现里面存放了一个函数的地址,而这个函数正好就是GetStartupInfoA。

于是我们得知在程序载入后,IAT表中存放的是函数的地址,而不是一个RVA地址。
 
EXE文件载入后对应的导入表结构图:


EXE文件载入前IAT表的状态:


注:为了查看0x41D034这个地址在程序载入前存放的内容,我们就要将这个地址减去ImageBase得到一个RAV地址:0x01D034,由于这个PE文件的FileAlignment和SectionAlignment是一样的(都是0x1000),用16进制编辑器打开这个文件直接跳转到0x01D034这个地址就可以获得里面的内容了。

跳转到这个地址后发现里面存储的是一个RVA地址,并不是函数地址。我们就进行跳转到0x23256这个RVA地址,我们就可以发现它指向了IMAGE_IMPORT_BY_NAME结构体,这个结构体存储的函数名刚好就是GetStartupInfoA。所以我们就可以断定载入前和载入后的IAT表是不一样的。
 
EXE文件载入前对应的导入表结构图:


第七节:导入表小练习


1. 通过编写控制台程序,打印导入表的导入文件名、INT表和IAT表。
  
2. 在PE文件中创建一个新节,然后将导入表、INT表以及函数名、文件名移动到新节中。最后将文件写入硬盘,并可以执行。(有点难度)
 
练习小提示:

1、打印流程:
(1)定位导入表
(2)打印导入文件名
(3)遍历INT表,打印出序号和函数名
(4)遍历IAT表,打印出序号和函数名(不要使用notepad.exe等系统程序练习,等学完绑定导入表后进行修改代码)

2、移动流程:
(1)增加新的节区,注意节区的大小等于导入表、INT表和各种名字的总大小,如果觉得计算麻烦可以直接在导入表大小的基础上加上0x5000并对齐。
(2)将所有的导入表移动到新节
(3)获取INT表中项目的个数
(4)将INT表移动到新节,同时移入函数名结构,修正数据。
(5)将导入文件名移入新节
(6)依次修复导入表数据信息(OriginalFirstThunk和Name)
(7)修复目录项中导入表的RVA地址

注:大家可能注意到我们没有移动IAT表,这是因为IAT表在程序加载后存储的是函数地址。程序通过访问IAT表才能获取函数的地址,如果移动IAT表,程序就无法获取函数地址,要想解决这个问题由两种方法:一是把程序中所有访问IAT表的地方进行修正,事实上目前来说无法实现。二是放弃移动IAT表。

第八节:IMAGE_BOUND_IMPORT_DESCRIPTOR——绑定导入表


IMAGE_BOUND_IMPORT_DESCRIPTOR的结构体定义如下:

typedef struct _IMAGE_BOUND_IMPORT_DESCRIPTOR {
DWORD TimeDateStamp; //时间戳
WORD OffsetModuleName; //DLL名的地址偏移
WORD NumberOfModuleForwarderRefs; //该结构后IMAGE_BOUND_FORWARDER_REF数组的数量
// Array of zero or more IMAGE_BOUND_FORWARDER_REF follows
} IMAGE_BOUND_IMPORT_DESCRIPTOR, *PIMAGE_BOUND_IMPORT_DESCRIPTOR;

绑定导入表简介:绑定导入是一个文件快速启动的技术,但是只能起到辅助的效果,它的存在只会影响到PE文件的加载过程,并不会影响PE文件的运行结果,这也就是说把绑定导入的信息从PE文件中清除后对这个PE文件的运行结果没有任何影响。

从导入表部分我们可以知道,FirstThunk这个成员指向了IAT表,在程序加载时加载器会通过INT表来修复IAT表,使里面存放上对应函数的地址信息,但是如果导入的函数太多在加载过程中就会使程序启动变慢,绑定导入就是为了减少IAT表的修复时间。

它会在程序加载前修复IAT表,然后在PE文件中声明绑定导入的数据信息,让操作系统知道这些事情已经提前完成。这就是绑定导入表的作用。
 
TimeDateStamp

这个时间戳相对来说还是比较重要的,因为这个值只有和导入DLL的IMAGE_FILE_HEADER中的TimeDateStamp值相同才能起到绑定导入的效果,如果不一致加载器就会重新计算IAT表中的函数地址。(由于DLL文件的版本不同或者DLL文件的ImageBase被重定位时,IAT绑定的函数的地址就会发生变化)

 
OffsetModuleName

这个偏移不是RVA页不是FOA,所以模块名的定位与之前的方法不同,它的定位方式是以第一个IMAGE_BOUND_IMPORT_DESCRIPTOR的地址为基址,加上OffsetModuleName的值就是模块名所在的地址了,这个模块名是以0结尾的ASCII字符串。

 
NumberOfModuleForwarderRefs

这个值是在IMAGE_BOUND_IMPORT_DESCRIPTOR结构后跟随的IMAGE_BOUND_FORWARDER_REF结构的数量。


在每一个IMAGE_BOUND_IMPORT_DESCRIPTOR结构后都会跟随着大于等于0个IMAGE_BOUND_FORWARDER_REF结构,然后在其后面又会跟上绑定表结构体,直至全部用0填充的绑定表结构。
IMAGE_BOUND_IMPORT_DESCRIPTOR的结构体定义如下:

typedef struct _IMAGE_BOUND_FORWARDER_REF {
DWORD TimeDateStamp; //时间戳
WORD OffsetModuleName; //DLL名的地址偏移
WORD Reserved; //保留
} IMAGE_BOUND_FORWARDER_REF, *PIMAGE_BOUND_FORWARDER_REF;
//注:
// 该结构中的成员和绑定导入表的成员含含义一致,所以不再过多叙述。
// 由于IMAGE_BOUND_IMPORT_DESCRIPTOR和IMAGE_BOUND_FORWARDER_REF的大小结构相同,所以可以相互转型,方便编程。


绑定导入表结构图:



第九节:绑定导入表小练习


通过编写控制台程序,打印绑定导入表的信息。(很多文件没有绑定导入表,需要判断)

练习小提示:

1、代码比较简单,参考图片可以轻松完成,提供下参考打印格式:


第十节:IMAGE_RESOURCE_DIRECTORY——资源表


IMAGE_RESOURCE_DIRECTORY的结构体定义如下:(由四种结构体组成)

//资源目录头
typedef struct _IMAGE_RESOURCE_DIRECTORY {
DWORD Characteristics; //资源属性 一般为0
DWORD TimeDateStamp; //资源创建时间戳 一般为0
WORD MajorVersion;
WORD MinorVersion;
WORD NumberOfNamedEntries; //以名称命名的目录项数量 重要
WORD NumberOfIdEntries; //以ID命名的目录项数量 重要
// IMAGE_RESOURCE_DIRECTORY_ENTRY DirectoryEntries[];
} IMAGE_RESOURCE_DIRECTORY, *PIMAGE_RESOURCE_DIRECTORY;

//资源目录项
typedef struct _IMAGE_RESOURCE_DIRECTORY_ENTRY {
union {
struct {
DWORD NameOffset:31; //字符串的偏移(不是RVA、FOA,相对特殊)
DWORD NameIsString:1; //判断名字是否是字符串 1:是 0:不是
} DUMMYSTRUCTNAME;
DWORD Name;
WORD Id; //目录项的ID(在一级目录指资源类型,二级目录指资源编号,三级目录指代码的页号)
} DUMMYUNIONNAME;
union {
DWORD OffsetToData; //如果不是目录,这里指数据的偏移(不是RVA、FOA,相对特殊)
struct {
DWORD OffsetToDirectory:31;//目录的偏移(不是RVA、FOA,相对特殊)
DWORD DataIsDirectory:1; //判断子资源项是否是目录 1:是 0:不是
} DUMMYSTRUCTNAME2;
} DUMMYUNIONNAME2;
} IMAGE_RESOURCE_DIRECTORY_ENTRY, *PIMAGE_RESOURCE_DIRECTORY_ENTRY;

//数据项
typedef struct _IMAGE_RESOURCE_DATA_ENTRY {
DWORD OffsetToData; //数据的偏移 重要
DWORD Size; //数据的大小 重要
DWORD CodePage; //代码页(一般为0)
DWORD Reserved; //保留
} IMAGE_RESOURCE_DATA_ENTRY, *PIMAGE_RESOURCE_DATA_ENTRY;

//名字字符串结构
typedef struct _IMAGE_RESOURCE_DIR_STRING_U {
WORD Length; //Unicode字符串长度
WCHAR NameString[ 1 ]; //Unicode字符串
} IMAGE_RESOURCE_DIR_STRING_U, *PIMAGE_RESOURCE_DIR_STRING_U;

资源表简介:在Windows程序中其各种界面被称作为资源,其中被系统预先定义的资源类型包括:鼠标指针、位图、图标、菜单、对话框、字符串列表、字体目录、字体、加速键、非格式化资源、消息列表、鼠标指针组、图标组、版本信息。

当然还有用户自定义的资源类型,这些资源的就不举例了。这些资源都是以二进制的形式保存到PE文件中,而保存资源信息的结构就是资源表,它位于目录项的第三位。

在PE文件的所有结构中,资源表的结构最为复杂,这是因为资源表用类似于文件目录结构的方式进行保存的,从根目录开始,下设一级目录、二级目录和三级目录,三级目录下才是资源文件的信息,而且资源表的结构定位也是最为特殊的,希望重点掌握。

一级目录是按照资源类型分类的,如位图资源、光标资源、图标资源。

二级目录是按照资源编号分类的,同样是菜单资源,其子目录通过资源ID编号分类,例如:IDM_OPEN的ID号是2001h,IDM_EXIT的ID号是2002h等多个菜单编号。

三级目录是按照资源的代码页分类的,即不同语言的代码页对应不同的代码页编号,例如:简体中文代码页编号是2052。

三级目录下是节点,也称为资源数据,这是一个IMAGE_RESOURCE_DATA_ENTRY的数据结构,里面保存了资源的RVA地址、资源的大小,对所有资源数据块的访问都是从这里开始的。


注:资源表的一级目录、二级目录、三级目录的目录结构是相同的都是由一个资源目录头加上一个资源目录项数组组成的,可以将这个结构称作资源目录结构单元。

 
IMAGE_RESOURCE_DIRECTORY.NumberOfNamedEntries和IMAGE_RESOURCE_DIRECTORY.NumberOfIdEntries
在资源目录头结构中这两个字段是最为重要的,其他字段大部分为0。

NumberOfNamedEntries表示在该资源目录头后跟随的资源目录项中以IMAGE_RESOURCE_DIR_STRING_U结构命名的资源目录项数量。

NumberOfIdEntries表示在该资源目录头后跟随的资源目录项中以ID命名的资源目录项数量。两个字段加起来就是本资源目录头后的资源目录项的数量总和。也就是后面IMAGE_RESOURCE_DIRECTORY_ENTRY结构的总数量。
 
IMAGE_RESOURCE_DIRECTORY_ENTRY.DUMMYUNIONNAME

在资源目录项中该字段是一个联合体类型,大小为4个字节,它决定这个资源目录的名字是字符串还是ID号。

如果这个字段的最高位是1,则表示该资源的名字是字符串类型,该字段的低31位是IMAGE_RESOURCE_DIR_STRING_U结构的偏移,但这个偏移既不是FOA也不是RVA,它是以首个资源表的地址为基址,加上低31位的值才是字符串结构的地址。

如果最高位为0,则表示该资源的名字是一个ID号,整个字段的值就是该资源的ID。(如果是一级目录的资源项,该ID有14个号码被预先定义了)
 
一级目录中预定义的资源ID:

 
IMAGE_RESOURCE_DIRECTORY_ENTRY.DUMMYUNIONNAME2

在资源目录项中该字段是一个联合体类型,大小为4个字节,它决定这个资源目录的目录中子节点的类型(是目录还是节点)。


如果这个字段的最高位是1,则表示该资源的子节点是一个目录类型,该字段的低31位是子目录的资源目录头结构的偏移,但这个偏移既不是FOA也不是RVA,它是以首个资源表的地址为基址,加上低31位的值才是资源目录头结构的地址。

如果最高位为0,则表示该资源的子节点是一个节点,它也以首个资源表的地址为基址,整个字段的值就是该资源节点的偏移。这个节点是IMAGE_RESOURCE_DATA_ENTRY类型的结构体。(一般在三级目录中该字段的最高位位0,而在其他两个目录中该字段的最高位为1)


注:为了编程方便,IMAGE_RESOURCE_DIRECTORY_ENTRY的联合体中出现了一组特殊的struct结构体,其成员声明格式为:[类型] [变量名] : [位宽表达式], 这个格式就是C语言中位段的声明格式。


NameOffset字段的值等于该联合体的低31位,NameIsString字段的值等于该联合体的最高位。将一个4字节的类型拆成这样两个字段就可以方便的避免了繁琐的位操作了,而且该结构的总大小不会发生变化。
 
IMAGE_RESOURCE_DATA_ENTRY

这个结构体就是目录资源的三级目录下的子目录,里面存储的就是资源文件的信息,如OffsetToData字段存储的就是资源文件的RVA地址,它指向了资源的二进制信息,Size字段存储的就是资源文件的大小,CodePage字段存储资源的代码页但大多数情况为0。


注:在其指向的资源数据中,字符串都是Unicode的编码方式,每个字符都是由一个16位(一个单字)的值表示,并且都是以UNICODE_NULL结束(其实就是两个0x00)。

IMAGE_RESOURCE_DIR_STRING_U

该结构体就是目录资源的名称结构,里面存在两个字段,都是2个字节,Length字段存储的是目录资源名称的长度,以2个字节为单位。NameString字段是一个Unicode字符串的第一个字符,并不以0结尾,其长度是由Length字段限制。


该结构的总大小并不是表面上的4个字节,而是根据名字长度变化的,计算方式为:Size = SizeOf(WCHAR) * (Length + 1); 这里的1是Length字段的大小。
 
资源表的结构图(简图):


手动寻找资源数据(为了方便程序的两个对齐方式的值都是0x1000):
 
1、通过可选PE头定位资源表,解析第一个资源目录项:

(1) 可以得到资源目录头中一共有9个目录项。

(2) 在第一个目录项中以ID号命名,资源类型位2也就是位图资源。

(3) 在目录项的二个字段可以得知该目录的子节点也是目录,偏移是0x58,RVA = 0x2B000 + 0x58 = 0x2B058。

 
2、定位二级资源目录并解析:

(1) 可以得到资源目录头中一共有1个目录项。

(2) 目录项以ID号命名,ID号为0x80。

(3) 在目录项的二个字段可以得知该目录的子节点也是目录,偏移是0x268,RVA = 0x2B000 + 0x268 = 0x2B268。

 

3、定位三级资源目录并解析:

(1) 可以得到资源目录头中一共有1个目录项。

(2) 目录项以ID号命名,ID号为0x804,表示使用简体中文的代码页。

(3) 在目录项的二个字段可以得知该目录的子节点是数据项,偏移是0x6E8,RVA = 0x2B000 + 0x6E8 = 0x2B6E8。

 
4、定位数据项:


(1) 资源数据的RVA地址:0x2C260.

(2) 资源数据的大小:0x680.


第十一节:资源表小练习


1. 通过编写控制台程序,打印资源表的信息。(可以考虑递归实现)
  
2. 在PE文件中创建一个新节,然后将资源表移动到新节中。最后将文件写入硬盘,并可以正确解析资源表。
 
练习小提示:

1、打印流程:
(1)定位资源表
(2)打印资源表目录头信息,并循环打印目录项。(一级目录)
(3)定位二级目录头,并打印资源表目录头信息,循环打印目录项。(二级目录)
(4)定位三级目录头,并打印资源表目录头信息,循环打印目录项。(三级目录)
(5)定位资源数据项,并打印资源数据项信息。
2、移动资源表的步骤:
(1)在PE文件中创建一个新节
(2)将资源表的一级目录全部copy到新节
(3)循环一级资源表信息,定位二级目录。
(4)将资源表的二级目录全部copy到指定位置。
(5)循环二级资源表信息,定位三级目录。
(6)将资源表的三级目录全部copy到指定位置。
(7)定位资源数据项,将资源数据项全部copy到指定位置。
(8)将资源数据copy到新节中,并修复数据项。
(9)修复目录项信息。

注:由于资源表都是通过首个资源目录头定位数据的,而且都是在资源目录头之后,所以可以直接按照目录项中的资源大小,将资源从首个资源目录头开始全部copy到新节中。但是不推荐这样完成。


第三章:打造自己的PE解释器——全文总结


文章写到这里,我们打造PE解析器的基础理论已经全部介绍完毕了,相信大家也都跟着文章完成了其中的练习题,那么接下来的事情就非常简单了,我们只要把所有的代码整合起来就可以实现一个简单PE解析器了,当然只是一个控制台程序。如果想要打造窗口程序就要具备一定的Windows编程经验了,然后将对应的内容输出出来就大功告成了。
  
希望同学们完成这最后一个任务!
 
写了一个月的文章完成,第一次发帖语言上有点生涩,如果文章有错误的地方希望能够指出,谢谢!



- End -




看雪ID:QiuJYu

https://bbs.pediy.com/user-813468.htm  


*本文由看雪论坛 QiuJYu 原创,转载请注明来自看雪社区


往期热门回顾

1、HW行动 rdpscan后门简单分析

2、x32下逆向分析PsSetCreateProcessNotifyRoutine

3、CVE-2018-0802个人浅析

4、C++中基本数据类型的表现形式

5、密码学基础:Base64编码







公众号ID:ikanxue

官方微博:看雪安全

商务合作:wsc@kanxue.com




      ↙点击下方“阅读原文”,查看更多干货

Modified on

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存