前言:想着把一些远古的学习内容总结一下,于是打算用几篇文章回顾PE结构,也希望能帮助上刚入门的师傅。本人水平有限,如有不足的地方,欢迎指出。本篇文章只讲述PE的大概结构
工具的使用借助CFF Explorer、010这些工具学习PE结构会方便的多,组成PE的结构体可以在微软的官方文档上找到
CFF Explorer可以直观的给出PE的各个结构以及PE的相关信息,如上图Project.exe是32位程序
010是很好的二进制查看、编辑器,010自带解析PE结构的功能,如果没有可以添加Templates文件。010通过各种bt文件的模板来解析各种结构的文件的
可以看到各种自带的模板,如果不能解析PE的话可以去官网下载对应的bt文件
PE的各个结构从上述图中其实就可以发现,这些工具都将PE分成DosHeader、DosStub、NtHeader等部分,https://learn.microsoft.com/zh-cn/windows/win32/debug/pe-format中描述了PE的结构,不过比较乱
其实总的来说PE文件就分为这4个部分
对应010中DosHeader与DosStub就是Dos头,NtHeader就是Nt头,SectionHeaders[5]就是节表,上图表示该PE文件有5个节表,也就是有5个节区,SectionHeaders[5]下面的部分就是各个节区
Dos头没有实质的作用,主要是为了保证PE文件在DOS环境下也能有一定的兼容性和可执行性,Nt头是真正意义上的头部,保存了PE文件的各种属性信息,节表描述了对应节区的信息,如大小、位置等,节区是保存程序内容的地方,如程序的代码、各种全局变量。接下来详细学习各个部分
DosHeader:DosHeader部分是一个叫做IMAGE_DOS_HEADER的结构体,定义如下
typedef struct _IMAGE_DOS_HEADER {
WORD e_magic; // MZ标记 0x5A4D
WORD e_cblp; // 最后(部分)页中的字节数
WORD e_cp; // 文件中的全部和部分页数
WORD e_crlc; // 重定位表中的指针数
WORD e_cparhdr; // 头部尺寸以段落为单位
WORD e_minalloc; // 所需的最小附加段
WORD e_maxalloc; // 所需的最大附加段
WORD e_ss; // 初始的SS值(相对偏移量)
WORD e_sp; // 初始的SP值
WORD e_csum; // 补码校验值
WORD e_ip; // 初始的IP值
WORD e_cs; // 初始的SS值
WORD e_lfarlc; // 重定位表的字节偏移量
WORD e_ovno; // 覆盖号
WORD e_res[4]; // 保留字
WORD e_oemid; // OEM标识符(相对m_oeminfo)
WORD e_oeminfo; // OEM信息
WORD e_res2[10]; // 保留字
LONG e_lfanew; // NT头相对于文件的偏移地址
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;
其大小在64位、32位上都是64个字节,需要特别关注的是第一个与最后一个字段,e_magic字段是标识,e_lfanew字段保存了Nt头的相对文件地址
PE文件开头的两个字节就是MZ
为什么需要单独保存这个字段呢,Dos头后面紧跟着不是Nt头吗。Dos头后面确实紧跟着Nt头,但是Dos头的大小是不确定的,因为DosStub部分是不确定的,该部分的结构体为MS_DOS Stu Program
当前PE文件的DosStub部分大小是A8H
可以看到e_lfanew字段的值为100H,说明PE头从100H开始
NtHeader:
一般来说Dos头后面紧跟着Nt头,但是如上图40H+A8H!=100H,说明可能存在某种补丁之类的东西,不过我记得这玩意我好像没动过,不管了,换一个PE好了
这下Dos头后面紧跟着Nt头了
https://learn.microsoft.com/zh-cn/windows/win32/api/winnt/ns-winnt-image_nt_headers64(微软IMAGE_NT_HEADERS64结构的定义)
Nt头有两种结构
typedef struct _IMAGE_NT_HEADERS64 {
DWORD Signature;
IMAGE_FILE_HEADER FileHeader;
IMAGE_OPTIONAL_HEADER64 OptionalHeader;
} IMAGE_NT_HEADERS64, *PIMAGE_NT_HEADERS64;
typedef struct _IMAGE_NT_HEADERS {
DWORD Signature;
IMAGE_FILE_HEADER FileHeader;
IMAGE_OPTIONAL_HEADER32 OptionalHeader;
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;
体现在OptionalHeader字段的不同,本篇就分析64位的了
Signature:恒为”PE/0/0”,是PE文件的标识
FileHeader:是一个IMAGE_FILE_HEADER结构,标准PE头,固定20字节,保存PE的一些属性
typedef struct _IMAGE_FILE_HEADER {
WORD Machine; //标记可以程序可以运行在什么样的CPU上
WORD NumberOfSections; //记录节的数目
DWORD TimeDateStamp; //时间戳,可以更改
DWORD PointerToSymbolTable; //符号表的偏移量,与debug有关,没有则为零
DWORD NumberOfSymbols; //符号表中的符号数
WORD SizeOfOptionalHeader; //记录MAGE_OPTIONAL_HEADER的大小
WORD Characteristics; //记录文件属性
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;
https://learn.microsoft.com/zh-cn/windows/win32/api/winnt/ns-winnt-image_file_header(微软IMAGE_FILE_HEADER结构的定义)
关注一下SizeOfOptionalHeader记录的是MAGE_OPTIONAL_HEADER,因为MAGE_OPTIONAL_HEADER的大小也是不固定的,NumberOfSections表明节表也就是节区的数量,Characteristics字段表明PE文件的类型,如IMAGE_FILE_DLL(0x2000)表明是DLL文件,IMAGE_FILE_EXECUTABLE_IMAGE(0x0002)表明是EXE可执行文件
图中蓝色部分的第3、4个字节为000007,表明有7个节表,最后两个字节是030F,表明有如下下图的一些性质,其值是下图等value的累和
OptionalHeader:是一个IMAGE_OPTIONAL_HEADER结构,定义如下
typedef struct _IMAGE_OPTIONAL_HEADER64 {
WORD Magic; //标志程序是32位还是64位,0B02为64位,0B01为32位
BYTE MajorLinkerVersion; //链接器的主版本号
BYTE MinorLinkerVersion; //链接器次要版本号
DWORD SizeOfCode; //代码节的大小(以字节为单位),如果有多个代码节,则为所有此类节的总和
DWORD SizeOfInitializedData; //初始化的数据节的大小(以字节为单位),如果有多个已初始化的数据节,则为所有此类节的总和
DWORD SizeOfUninitializedData; //未初始化的数据节的大小(以字节为单位),如果有多个未初始化的数据节,则为所有此类节的总和
DWORD AddressOfEntryPoint; //程序入口点,保存的是相对内存偏移地址
DWORD BaseOfCode; //代码段的开头点,也是相对内存的偏移地址
ULONGLONG ImageBase; //内存基址
DWORD SectionAlignment; //内存加载的节对齐大小,头也要对齐
DWORD FileAlignment; //文件对齐大小
WORD MajorOperatingSystemVersion; //所需操作系统的主版本号
WORD MinorOperatingSystemVersion; //所需操作系统的次要版本号
WORD MajorImageVersion; //映像的主版本号
WORD MinorImageVersion; //映像的次要版本号
WORD MajorSubsystemVersion; //子系统的主版本号
WORD MinorSubsystemVersion; //子系统的次要版本号
DWORD Win32VersionValue; //此成员为保留成员,必须为 0
DWORD SizeOfImage; //文件在内存中展开时的大小
DWORD SizeOfHeaders; //DOS头+NT头+节表按照文件对齐后的大小
DWORD CheckSum; //映像文件校验和
WORD Subsystem; //运行此映像所需的子系统
WORD DllCharacteristics; //DLL 特征
ULONGLONG SizeOfStackReserve; //要为堆栈保留的字节数
ULONGLONG SizeOfStackCommit; //要为堆栈提交的字节数
ULONGLONG SizeOfHeapReserve; //要为本地堆保留的字节数
ULONGLONG SizeOfHeapCommit; //要为本地堆提交的字节数
DWORD LoaderFlags; //此成员已过时
DWORD NumberOfRvaAndSizes; //可选标头的其余部分的目录条目数。 每个条目描述位置和大小
IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES]; //IMAGE_DATA_DIRECTORY结构
} IMAGE_OPTIONAL_HEADER64, *PIMAGE_OPTIONAL_HEADER64;
https://learn.microsoft.com/zh-cn/windows/win32/api/winnt/ns-winnt-image_optional_header64(微软IMAGE_OPTIONAL_HEADER64结构定义)
没有固定大小,由IMAGE_FILE_HEADER的SizeOfOptionalHeader决定,32位默认是0xE0,64位默认是0xF0
该结构涉及到相对地址,我们先学习下前置知识
我们知道每个进程都有自己的虚拟内存空间,同一机器下的虚拟空间大小都是相同的,有虚拟空间那么就会有虚拟地址,Windows默认使用ASLR技术,即地址空间布局随机化,每一次将PE装载到内存中的位置都是不一样的
VA:内存虚拟地址,其实就是数据和代码在虚拟内存中的实际地址
RAV:相对虚拟地址,是指 PE 文件中某个数据或代码相对于起始地址的偏移量,结合上述所说,某个数据的地址是随机化的,每次运行地址都不同,但如果保存的是相对与某个基址的偏移量,那么每次都能准确找到其绝对地址了
FA:文件偏移地址,这个很容易理解,就是在文件中的地址
该结构很多字段需要重点关注,SizeOfCode表示代码段的大小,ImageBase表示该PE文件被装载到内存时的基址,不过默认使用ASLR,该字段会被忽略,然后在内存中操作系统会将随机的基地址覆盖ImageBase原来的值,注意并不是在PE文件中修改,BaseOfCode保存的是代码段相对与基址(ImageBase)的偏移量,所以它是一个RVA,AddressOfEntryPoint保存的是程序入口点对于基址的RVA
我们找cmd.exe来观察一下这些结构
可以看到cmd.exe的ImageBase为1400000000H,以及BaseOfCode和AddressOfEntryPoint的值,我们再在内存中查看
这是cmd在内存中的结构,要找到ImageBase字段,可以先找到PE标识,再偏移48个字节
偏移48个字节后找到的值为00007ff645810000H,跟工具解析的地址是一样的,确实与1400000000H不同
上图中箭头所指向的是BaseOfCode的值,说明代码段相对于基址偏移量00001000H个字节,又因为基址是00007ff645810000H,那么根据计算代码段的地址应该是00007ff645811000H,再来到内存中查看
该段是代码段,可以看到其地址确实是00007ff645811000H
SectionAlignment和FileAlignment分别表示PE的内存对齐大小和文件对齐大小,对齐是为了优化内存访问,方便内存管理,接着要搞清楚什么是对齐,对齐指的是将数据或代码按照特定的边界进行排列,简单来说就是某个结构的大小必须要是某个值的整数倍,比如下图
文件对齐值是200H,内存对齐值是1000H,为什么两者大小不同呢,有多种原因吧,比如节省磁盘空间,让PE装载更灵活。对齐对象一般是各种节区,PE头也是要对齐的(Dos头、Nt头、节表可以看作PE头),我们在010中看看
可以看到在文件中PE头的大小应该是290H,但是我们要求文件对齐200H,所以我们的PE头大小只能为400H,多出来的部分都是00,400H后就是相应的节区了,再来看看内存中长什么样
起始地址为00007ff742980000,多余部分用00填充,00007ff742980000+1000确实是下一节区的起始地址
SizeOfImage是文件在内存中展开时的大小
其值是9000H,我们来看看内存中是否是9000H个字节
可以看到程序的起始地址为00007ff742980000,最后一个节区地址为00007ff742988000,并且最后一个节区的有效大小是小于1000的,所以最后一个节区的大小就为1000H,那么文件在内存中展开时的大小为00007ff742988000H-00007ff742980000H+1000H=9000H
SizeOfHeaders表示PE头按文件对齐时的大小,就不在010看了
NumberOfRvaAndSizes和DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES]也是非常重要的两个字段,DataDirectory是一个IMAGE_DATA_DIRECTORY类型的数组,NumberOfRvaAndSizes字段表示该数组有几个元素。大家应该听过导入表、导出表等等,DataDirectory保存的就是这些表的信息,关于表的具体学习会在后面的文章。
NumberOfRvaAndSizes为10H,说明有16个数组元素
数一下确实是16个,看010是怎么解析的,第一个8字节是export表,也就是导出表,然后接着第二个、第三个,不过可以发现有些表没有值(00填充),说明该PE没有该表结构,比如导出表就没有
SectionHeaders:节表以结构体的形式描述节的信息,每个节表40字节,PE有多少个节就有多少个节表
Nt头后紧跟着节表,节表的数量由NumberOfSections确定
是6个没错,其结构如下
typedef struct _IMAGE_SECTION_HEADER {
BYTE Name[IMAGE_SIZEOF_SHORT_NAME]; //8字节,当前节的名字,可以随意更改
union {
DWORD PhysicalAddress; //文件地址
DWORD VirtualSize; //当前节在内存中未对齐时的大小,即真实大小
} Misc;
DWORD VirtualAddress; //当前节在内存中的偏移地址
DWORD SizeOfRawData; //当前节在文件中对齐后的大小
DWORD PointerToRawData; //当前节在文件中的偏移地址
DWORD PointerToRelocations; //指向节重定位条目开头的文件指针。 如果没有重定位,则此值为零
DWORD PointerToLinenumbers; //指向节行号条目开头的文件指针。 如果没有 COFF 行号,则此值为零
WORD NumberOfRelocations; //节的重定位条目数。 对于可执行映像,此值为零
WORD NumberOfLinenumbers; //节的行号条目数
DWORD Characteristics; //节的特征,如是否可执行等
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;
https://learn.microsoft.com/zh-cn/windows/win32/api/winnt/ns-winnt-image_section_header(微软节表定义)
Name字段用8个字节来表示节区的名称,这是给人看的,实际机器一般用不到这个字段,因为节区的作用不是通过名称来决定的,所以可以任意取名,但是一般编译器会使用特定的名称,比如.text一般表示代码节,.data表示数据节,存放已初始化的全局变量和静态变量
Misc字段表示文件地址或当前节在内存中未对齐时的大小,一般都是当前节在内存中未对齐时的大小
上图箭头所指的节区为.text,可以看到其真实大小为176CH,与Misc的值一致
VirtualAddress字段表示当前节在内存中的偏移地址就不细看了,还有SizeOfRawData、PointerToRawData,都是要关注的字段,也不一一看了
Characteristics字段定义了当前节的属性,比如是否可读、可写、可执行
当前值为60000020H,说明包含IMAGE_SCN_CNT_CODE,也就代表该节是代码节
节区:节表后面就是对应的节区了,本篇文章不详细讲
先了解下常见的节区作用,除了上面提到的.text、.data,还有常见的比如:
.pdata:它主要包含了与程序异常处理机制相关的信息,比如异常处理程序的地址、异常处理的范围等,该节只在64位PE中,32位PE文件没有该结构
.rdata:只读数据节,存储只读数据,如常量字符串、只读的全局变量等
.bss:未初始化数据节,用于存放未初始化的全局变量和静态变量
.rsrc:资源节,用于存放程序所使用的各种资源