PE文件概述
- PE文件的全称是Portable Executable,意为可移植的可执行的文件,常见的EXE、DLL、OCX、SYS、COM都是PE文件,PE文件是微软Windows操作系统上的程序文件(可能是间接被执行,如DLL) (摘自百度百科)
- 可执行文件在内存中都以4D 5A(H)开头
- 不同操作系统的PE文件结构是不同的,如果一个可执行文件的结构不符合操作系统的要求,则这个文件不能在这个操作系统上运行
- 一个可执行程序的数据在硬盘和内存中基本是一样的,只是数据存放方式不同(映射)
- 一个可执行文件是由几个PE文件组成的
程序运行过程
可执行文件(PE文件/硬盘结构)->读到FileBuffer文件中->读到ImageBuffer内存中(4GB)
:操作系统的PE loader进行加载(拉伸),此时文件格式符合操作系统运行条件,且起始地址为ImageBase,所以程序执行时真正的入口点要加上ImageBase。
注:程序经过拉伸之后才能运行。ImageBuffer所用的地址为虚拟地址,由操作系统将虚拟地址转换为物理地址。
内核重载
内核有一个.exe文件,用于启动程序。内核重载即重新写该.exe程序,也就是重写PE loader。
注:PE文件执行中的状态(ImageBuffer)与不执行的状态(FileBuffer)在工具里打开的样子是不同的,具体问题具体分析,若打开的文件处在FileBuffer的状态,则全看文件地址及文件偏移;若打开的文件处于ImageBuffer状态,则全看虚拟地址,RVA。
内存地址转换
- 虚拟内存地址 (Virtual Address, VA)PE文件中的指令被装入内存后的地址
- 相对虚拟地址(Relative Virtual Address,RVA)相对虚拟地址是内存地址相对于映射基地址偏移量
- 文件偏移地址(file offset)文件相对于文件开头的偏移
- 装载基址(image base)PE装入内存时的基地址,EXE在内存中的基地址是0x00400000,DLL的基地址是0x10000000,这些位置可以通过修改编译选项更改
计算RVA
RVA = VA - imageBase
计算VA
VA = ImageBase+RVA
计算fileOffset
fileOffset = RVA-节的RVA+节的文件偏移地址
图例
此图例是文件在FileBuffer中的状态
分节
PE文件格式中一段数据前后会有长达几行的全0隔开,这种划分方式叫做分节。分节的原因:节省内存空间(老式编译器)、节省内存
节省硬盘空间
上述所说的内存空间是虚拟内存空间,就算内存条只有1GB,每个PE都有独立的4GB的内存空间(其中2GB是给应用程序用(0-7FFFFFFFH),其他2GB给操作系统用)
注:不同的编译器,间隙不同。现在新式的编译器硬盘和内存空隙可以相同。
硬盘/内存对齐
硬盘/内存对齐相当于我们看书,有的时候就只有几个字,但是这几个字仍然占了一页,硬盘和内存也同样如此,就算数据不满一页,这些数据也会占据一页的空间。
- 对齐有利于查看,加快查看速度
- 现在有些编译器的硬盘对齐和内存对齐是一样
磁盘与内存存储方式不同时,PE磁盘文件与内存映射结构图:
.text节即为.code节
节省内存
多开的时候(运行多个相同程序),避免数据段重复利用。
解析PE文件
DOS头
DOS头的数据结构(64字节):最重要的是第一个和最后一个
struct _IMAGE_DOS_HEADER { // DOS .EXE header
* WORD e_magic; // 5A4DH
WORD e_cblp; // 0090H
WORD e_cp; // 0003H
WORD e_crlc; // 0000H
WORD e_cparhdr; // 0004H
WORD e_minalloc; // 0000H
WORD e_maxalloc; // FFFFH
WORD e_ss; // 0000H
WORD e_sp; // 00B8H
WORD e_csum; // 0000H
WORD e_ip; // 0000H
WORD e_cs; // 0000H
WORD e_lfarlc; // 0040H
WORD e_ovno; // 0000H
WORD e_res[4]; // 长度为4的数组,要往后数8个字节 00 00 00 00 00 00 00 00H
WORD e_oemid; // 0000H
WORD e_oeminfo; // 0000H
WORD e_res2[10]; //长度为10的数组,往后数20个字节
* DWORD e_lfanew; // 4字节,000000B0H 指向PE文件真正开始的位置,从文件开始的地方偏移B0个字节的位置就是PE文件开始的地方,中间偏移的位置都是垃圾数据(PE字样所在位置)
}
注:BYTE是1字节;WORD是指unsigned short类型,占2字节;DWORD是指unsigned long类型,占4字节
NT头(PE头)
NT头包括标准PE头和可选PE头,可选PE头不是可有可无的。
NT头的数据结构:
struct _IMAGE_NT_HEADERS
{
DWORD Signature; //标记 (重要,判断是否为PE文件的第二个标志)00004550H
IMAGE_FILE_HEADER FileHeader; //标准PE头,文件头(重要,存储着PE文件的基本信息)
IMAGE_OPTIONAL_HEADER32 OptionalHeader; //可选PE头(重要,存储着关于PE文件时加载的信息)
标准PE头
标准PE头的数据结构:打* 为需要记忆的部分
(20字节)
struct _IMAGE_FILE_HEADER{
* WORD Machine; // 程序运行的CPU型号 014CH
* WORD NumberOfSections; // 文件中存在的节的总数(除了DOS头和NT头),如果要新增节或合并节,就要修改这个值 0003H
* DWORD TimeDateStamp; // 文件创建时间 428F4D9BH
DWORD PointerToSymbolTable; // 符号表偏移 00000000H
DWORD NumberOfSymbols; // 符号个数 00000000H
* WORD SizeOfOptionalHeader; //可选PE头大小 00E0H
WORD Characteristics; // PE文件属性 010FH
}
可选PE头
可选PE头的数据结构:
struct _IMAGE_OPTIONAL_HEADER {
* WORD Magic; //机器型号,判断是PE是32位还是64位
BYTE MajorLinkerVersion; //连接器版本号高版本
BYTE MinorLinkerVersion; //连接器版本号低版本,组合起来就是 5.12 其中5是高版本,C是低版本
* DWORD SizeOfCode; //代码节的总大小(必须为文件节对齐大小(FileAlignment字段大小)的整数倍),没用
* DWORD SizeOfInitializedData; //初始化数据的节的总大小,也就是.data,没用
* DWORD SizeOfUninitializedData; //未初始化数据的节的大小
* DWORD AddressOfEntryPoint; //程序执行入口(OEP) RVA(相对偏移)
* DWORD BaseOfCode; //代码段起始位置RVA,偏移+模块首地址定位代码区
* DWORD BaseOfData; //数据节的起始偏移(RVA),同上
* DWORD ImageBase; //内存映像基址,ImageBuffer的起始地址(见下文)
* DWORD SectionAlignment; //内存中的节对齐大小
* DWORD FileAlignment; //文件(硬盘)中的节对齐大小
WORD MajorOperatingSystemVersion; //操作系统版本号高位
WORD MinorOperatingSystemVersion; //操作系统版本号低位
WORD MajorImageVersion; //PE版本号高位
WORD MinorImageVersion; //PE版本号低位
WORD MajorSubsystemVersion; //子系统版本号高位
WORD MinorSubsystemVersion; //子系统版本号低位
DWORD Win32VersionValue; //32位系统版本号值,注意只能修改为4 5 6表示操作系统支持nt4.0 以上,5的话依次类推
* DWORD SizeOfImage; //整个程序在内存中占用的空间(PE映尺寸),是SectionAlignment的整数倍,即ImageBuffer的大小
* DWORD SizeOfHeaders; //所有头和节表严格按照SectionAlignment对齐后的大小,否则会加载出错(包括DOS头、NT头、节表)
DWORD CheckSum; //出校验和,一些系统文件有要求,用来判断文件是否被修改
WORD Subsystem; //文件的子系统
WORD DllCharacteristics; //DLL文件属性,也可以成为特性,可能DLL文件可以当做驱动程序使用
* DWORD SizeOfStackReserve; //初始化时保留的堆栈大小
* DWORD SizeOfStackCommit; //初始化时实际提交的堆栈大小
* DWORD SizeOfHeapReserve; //初始化时保留的堆的大小
* DWORD SizeOfHeapCommit; //初始化时实际提交的堆的大小
DWORD LoaderFlags; //与调试有关
* DWORD NumberOfRvaAndSizes; //目录项数目
* typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress;
DWORD Size;
} IMAGE_DATA_DIRECTORY,*PIMAGE_DATA_DIRECTORY//数据目录,默认16个,16是宏,这里方便直接写成16
注:代码入口不等于程序入口。程序在内存中真正的入口= ImageBase+AddressOfEntryPoint
联合类型
如果我们想存储一个人的学号和身份证号,可以设计成下面的形式:
struct Student{
char 学号;
int 身份证号;
}
但是上述的形式每次最多只会用到一个成员,另一个成员的空间永远都是浪费的。
联合类型
特点:
- 联合体的成员是共享内存空间的
- 联合体的内存空间大小是联合体成员中对内存空间要求最大的空间大小
- 联合体最多只有一个成员有效
union TestUnion{
char x;
int y;
}
节表
- 节表是对节的具体描述信息,节是线性存储的。
- 节表位置定位:DOS头找到PE标准头,紧接着是可选PE头,可选PE头里的SizeOfOptionalHeaders指定PE可选头大小,之后就是节表。
- 或从PE标准头跳转F8个位置到节表
- 从节表开始每40个字节表示一个区段
节表的数据结构:
```bash
IMAGE_SECTION_HEADER STRUCT
BYTE Name[IMAGE_SIZEOF_SHORT_NAME]; // 8个字节的节区名称
union Misc{ //4字节
DWORD PhysicalAddress;
- DWORD VirtualSize; //内存中对齐块的大小,指定节的大小
}
DWORD VirtualAddress; // 节区在内存ImageBuffer中的偏移地址,加上ImageBase才是在内存中的真正地址
DWORD SizeOfRawData; // 在文件中对齐块的尺寸(在FileBuffer中的尺寸)
DWORD PointerToRawData; // 在文件FileBuffer中的偏移量(离PE文件开始的地方的偏移)
DWORD PointerToRelocations; // 在OBJ文件中使用,重定位的偏移
DWORD PointerToLinenumbers; // 行号表的偏移(供调试使用地)
WORD NumberOfRelocations; // 在OBJ文件中使用,重定位项数目
WORD NumberOfLinenumbers; // 行号表中行号的数目
DWORD Characteristics; // 执行权限,如可读,可写,可执行等,占了4字节,20H表示包含了可执行代码,40H表示包含已初始化数据,80H表示包含未初始化数据
> 注:相对虚拟地址(RVA)即指ImageBuffer里的偏移地址
## 节(Sections)
采用节的方式进行属性的划分。
+ 判断节有几段:标准PE头NumberOfSections
## 导入表
导入表信息包含在可选PE头中的IMAGE_DATA_DIRECTORY里,即可选PE头96字节后就是导入表的位置(十六进制加60H),规定文件中的数据段中的数据怎样使用:
```bash
----8字节------- //IMAGE_DATA_DIRECTORY的结构体占8字节
Image_Directory_entry_export //8字节,输出表的RVA值(前4位)及大小
Image_Directory_entry_import //8字节,输入表的RVA值及大小
-----24个字节---------- //24个字节代表的导入表此处省略
解析导入表
此例中没有输出表
解析输入表
- 输入表的RVA为00002014,大小为0000003C,要将RVA转换成FileOffset
- 查看节表中的VituralAddress,找到输入表属于哪个段(此例中属于.rdata段)
- 输入表的File Offset = 2014H-2000H(.rdata段起始地址)+600H(.rdata段的文件偏移 节表中的PointToRawData) = 614H
- 从文件起始偏移614H大小为3CH的区域即为输入表信息。
对于输入表来说,每20个字节对应一个DLL的调用,这些表是采用数组的指针的方式进行保存的。
第一个dll
动态链接库名
每个调用的表偏移12个字节之后4个字节代表该dll的名字的RVA。此例中名字RVA为2072H
- 将RVA转换成文件偏移地址 = 2071H-2000H+600H = 671H ->672H位置显示的名字为kernel32.dll
查看kernel32.dll调用的导入函数(API)
- 查看输入表信息的最开始的4字节,此例中为2050H,这个2050H就是API开始的RVA
- 将这个RVA转换成文件偏移:2050H-2000H+600H = 650H
- 从文件起始偏移650H即为导入函数的位置
- 此块每4个字节代表一个导入函数RVA,当4个字节全为0时,API信息结束(此例中导入了1个函数)
查看导入函数的信息
- 将导入函数的RVA转换成文件偏移地址: 2064H-2000H+600H = 664H 该导入函数名称为ExitProcess
第二个dll
同理,第二个dll的名字RVA为209AH,则文件偏移地址为209A-2000H+600H=69AH->名称为User32.dll
该dll调用的API,查看最开始的4个字节:2058H-2000H+600H=658H->此dll调用了2个API
- 第一个API:208CH-2000H+600H=68CH ->函数名称为MessageBox
- 第二个API:2080H-2000H+600H = 680H ->函数名称为wsprintf