PE文件详解之01
1.PE文件:
PE文件是windows平台下的一种可执行的文件格式,包括可执行文件(后缀名exe),动态链接库(后缀名dll),驱动文件(sys文件)。在进程空间,通常由一个exe(主模块)和其他多个dll模块构成。
2.PE文件的大概组成:
PE文件主要由两个部分组成,头部和主体部分。头部有DOS头,NT头,区段头组成,主体由各个区段组成,还有一些调试信息。主体各个区段主要包含的是要执行的代码和执行代码需要的数据。
PE文件的主要结构如下:
内容 | 描述 |
---|---|
DOS头部 | 为兼容DOS程序而设立 |
NT头部 | 存储PE文件的全部属性,初始化信息等 |
区段头表 | 对于PE文件主体属性的分段描述,个数不定 |
各个区段 | PE文件的主体,分段存储着可执行的代码,各种数据,资源等 |
一些调试信息 | 存储PE文件的调试信息 |
PE 文件的整体结构:
3.学习PE文件结构的先前概念:
3.1 虚拟地址:
每个进程运行在4GB的虚拟空间中,这个空间中的地址叫做虚拟地址(VA)。
3.2 相对虚拟地址:
进程运行过程中的虚拟空间中,一般会有一个加载进来的可执行文件(exe),和多个支持这个exe文件的动态链接库文件(dll),这些文件在进程空间中称为模块,每个模块加载到进程空间中的起始地址是随机的,模块的文件头在进程空间中的起始称为实际的加载基址,每个模块中的扩展头中有一个字段ImageBase,这个值时模块加载的默认加载基址,如果这个位置已经加载了其他模块,就会将要加载的模块安排到其他位置,所以模块中其他数据的定位就使用的相对虚拟地址。要定位这些数据需要知道实际的加载基址,用实际的加载基址减去模块中的ImageBase的值,加上模块中数据所在位置的相对虚拟地址(RVA)。RVA就是PE文件中各个部分相对于默认加载基址(ImageBase)的偏移。
公式:虚拟地址(VA) = 默认加载基址(ImageBase)+相对虚拟地址(RVA)
3.3 文件偏移地址
文件偏移地址(File Offset Address,FOA)和内存无关,它是指某个位置距离文件头的偏移。
3.4 对齐粒度
3.4.1 文件对齐:
PE文件中的区块有对齐的概念,PE未加载到内存中是,即存储在磁盘上的区段间的对齐通常按照磁盘物理扇区的大小作为对齐粒度,即512字节,十六进制200h。
3.4.2 内存对齐
windows操作系统内存属性的设置以页为单位,所以一步情况区块在内存中的对齐粒度是一个页的大小4KB,十六进制1000h,64位系统是8KB(2000h)
3.4.3 资源数据的对齐
资源文件中,资源字节码部分一般以双字(4个字节)方式对齐。
3.5 数据目录
数据目录表在PE文件的扩展头中,是一个数组,这个数组有16个元素,其中的15个是有意义的,记录了导入表,导出表,资源表,异常表,属性证书表,重定位表,调试数据,Architecture,Global Ptr,线程局部存储,加载配置表,绑定导入表,IAT,延迟导入表和cLR 运行时头部。数据目录表存储了各种数据的信息,从数据目录表中可以检索到存储在区块中的各种数据。
3.6 PE 文件中的区块(区段),也叫做节
不同的区块存储不同用途的数据,不同的区块的属性不同,比如代码段一般不运行用户修改,数据段则运行在程序运行过程中读和写,常量只能读。windows操作系统在加载PE文件时,会为不同属性的数据分配标记不同属性的页面,确保程序运行的安全。区块是PE文件中存放代码和数据的基本单元,存放不同类型的数据,比如代码,数据,常量,资源等。从操作系统加载的角度来看,节是相同属性数据的组合。
3.7 字段:结构体中的某个成员
3.8 镜像:程序在磁盘上的文件
3.9 映像:将程序的镜像加载到内存,按内存对齐后的内存数据
4.PE文件各个结构的详细解析:
PE结构中的详细结构图:
4.1 DOS头的详解:
typedef struct _IMAGE_DOS_HEADER { // DOS .EXE header
WORD e_magic; // Magic number 有用,此值为0x4D5A,说明是一个PE文件
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; // File address of new exe header,PE 头的位置
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;
以上是PE文件中DOS头的结构体及每个字段的含义,大部分字段描述的dos文件(dos系统下的可执行文件)的描述信息,因为现在的PE文件都大部分的运行环境都是windows,所以dos头中的很多字段没有意义了,但是为了与dos系统兼容,保留了dos头,我们只要关注两个字段,第一个字段e_magic和最后一个字段e_lfanew。
字段1:e_magic,魔数,此值如果是0x4D5A,说明这个文件是一个dos文件,这个字段作为dos文件的标志,在系统中有宏定义 #define IMAGE_DOS_SIGNATURE 0X4D5A。
用途:判断一个文件是否是dos,或者PE文件
假设PE文已经读取到内存中,首地址是pFile,pFile是一个void类型的指针
if (((PIMAGE_DOS_HEADER)pFile->e_magic) != IMAGE_DOS_SIGNATURE ){
// 不是dos头,返回
return;
}
字段2:e_lfanew, PE文件中,NT头相对于整个PE文件的偏移。使用它可以在文件中找到NT头。
用途:在内存中找到NT头
假设PE文已经读取到内存中,首地址是pFile,pFile是一个void类型的指针
// NT头的地址
(long)pFile +(PIMAGE_DOS_HEADER)pFile->e_lfanew;
dos头和NT之间的数据:在dos头和NT 头之间有一段数据,将PE 文件拖入010Editor,使用模块,可以查看各个数据结构对应的数据及其含义:
然后将PE文件拖入010Editor,就可以查看PE文件中对应的各种结构的数据:
这些数据用处不大,我们可把dos头和NT头重合,把它们之间的数据除去,可以缩小PE的体积。
4.2 PE文件中的NT头详解:
typedef struct _IMAGE_NT_HEADERS {
DWORD Signature; // [0x00]PE标识 ,判断一个文件是否是PE文件,此值是0x00004550,则此文件是一个PE文件
IMAGE_FILE_HEADER FileHeader; // [0x04]文件头
IMAGE_OPTIONAL_HEADER32 OptionalHeader; // [0x18]扩展头
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;
NT头中的关键字段解释:
字段1:PE标识 ,判断一个文件是否是PE文件的第二个标志,此值是0x00004550,对应的Ascii码为’PE00’则此文件是一个PE文件。
如何判断一个文件是PE文件?
前提:将一个文件读取到内存中,并且在内存中文件的起始地址为pFile,pFile为一个void类型的指针。
// 获取nt头的地址值
DWORD dwNewPos = (DWORD)pFile + pFile((PIMAGE_DOS_HEADER)pFile)->e_lfanew;
// 将此地址转换为nt头结构体指针
PIMAGE_NT_HEADERS32 pNTHeader = (PIMAGE_NT_HEADERS32)dwNewPos;
// 使用结构体指针取出nt头中的Signature字段,与宏 IMAGE_NT_SIGNATURE比较
if(pNTHeader->Signature != IMAGE_NT_SIGNATURE)
{
// 不是PE文件,返回
return;
}
4.2.1 NT头中的字段2:文件头
文件头,文件头是一个结构体,存储了PE文件的基本信息。
typedef struct _IMAGE_FILE_HEADER {
WORD Machine; //[0x04] (1)运行平台
WORD NumberOfSections; //[0x06] (2)区段的数量*,用于遍历区段
DWORD TimeDateStamp; //[0x08] (3)PE文件创建时间
DWORD PointerToSymbolTable; //[0x0C] (4)符号表指针
DWORD NumberOfSymbols; //[0x10] (5)符号的数量
WORD SizeOfOptionalHeader; //[0x14] (6)扩展头大小* 32大小E0,64位F0
WORD Characteristics; //[0x16] (7)PE文件属性
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;
关键字段:
字段2:NumberOfsections:记录了区段的数量,用于遍历区段
字段6:扩展头的大小,记录了扩展头的大小
其他字段:
字段1:Machine:PE文件运行的CPU平台,0x014表示i386,也就是intel32位平台,0x0200代表intel 64位平台。
字段3:TimeDateStamp:PE文件被创建的时间,是一个非常大的32数字,解析这个数据使用如下函数:
struct tm* gmtime(const time_t *timer);
将TimeDateStamp的地址强制转换后作为参赛,之后使用一个tm结构体接收就可以得到具体的时间。
字段4,字段5没有用
字段6:SizeOfOptinalHeader:扩展头的大小,32bitPE一般是0x00E0,64bitPE一般是0x00F0。
字段7:PE文件的属性。dll的属性值一般是0x0210,exe的属性值一般是0x010F。其中的属性具体含义可以将数值转换为二进制数据查看。
PE文件头中共有7个字段,但是只有5个有用。
用代码获取文件头中的 TimeDateStamp,然后转换成可读的时间:
前提:PE文件已经读取到了内存中,首地址是pFile,void类型的指针。
// 计算NTHeader地址
PIMAGE_NT_HEADER32 pNTHeader = (PIMAGE_NT_HEADERS32)((long)pFile + (PIMAGE_DOS_HEADER)pFile->e_flanew);
// 获取文件头结构体的指针
PIAMGE_FILE_HEADER pFileHeader = &(pNTHeader->FileHeader);
// 使用文件头指针获取文件头结构体中的数据,并使用gmtime函数转换为可读的时间
tm * FileTime gmtime((time_t*)&pFileHeader->TimeDateStamp)
FileTime结构体内容如下:
struct tm
{
int tm_sec; // seconds after the minute - [0, 60],秒
int tm_min; // minutes after the hour - [0, 59],分
int tm_hour; // hours since midnight - [0, 23],时
int tm_mday; // day of the month - [1, 31],日
int tm_mon; // months since January - [0, 11],月
int tm_year; // years since 1900,年
int tm_wday; // days since Sunday - [0, 6],星期几
int tm_yday; // days since January 1 - [0, 365],这一年的第几天
int tm_isdst; // daylight savings time flag
};
// 注意日从1开始,其他都从0开始
4.2.3 NT头中关键字段3:NT头中的扩展头
扩展头:
// 注意:* 号标注字段为重要字段,+为有用字段,-号标注的为无用字段
typedef struct _IMAGE_OPTIONAL_HEADER {
// 标准域
WORD Magic; //[0x18] (1) 标志位,标志什么类型文件,32bit是0x010B,64bit是0x020B,ROM镜像是0x0170+
BYTE MajorLinkerVersion; //[0x1A] (2) 连接器主版本号-
BYTE MinorLinkerVersion; //[0x1B] (3) 连接器子版本号 -
DWORD SizeOfCode; //[0x1C] (4) 所有代码段 的总大小 +
DWORD SizeOfInitializedData; //[0x20] (5) 所有初始化段总大小 +
DWORD SizeOfUninitializedData; //[0x24] (6) 所有未初始化段总大小,在磁盘中不占用大小,在加载到内存之后,将预留的大小,一般存储在.bss区段中 +
DWORD AddressOfEntryPoint; //[0x28] (7) 程序执行入口RVA*
DWORD BaseOfCode; //[0x2C] (8) 代码段起始RVA +
DWORD BaseOfData; //[0x30] (9) 数据段起始RVA +
// NT 附加域
DWORD ImageBase; //[0x34] (10) 程序默认载入基地址*
DWORD SectionAlignment; //[0x38] (11) 内存中的段对齐值* 内存中区块对齐值,0x1000
DWORD FileAlignment; //[0x3C] (12) 文件中的段对齐值*,硬盘上区块对齐值,0x200
WORD MajorOperatingSystemVersion; //[0x40] (13) 系统主版本号 -
WORD MinorOperatingSystemVersion; //[0x42] (14) 系统子版本号 -
WORD MajorImageVersion; //[0x44] (15) 自定义的主版本号 -
WORD MinorImageVersion; //[0x46] (16) 自定义的子版本号 -
WORD MajorSubsystemVersion; //[0x48] (17) 所需子系统主版本号 -
WORD MinorSubsystemVersion; //[0x4A] (18) 所需子系统子版本号 -
DWORD Win32VersionValue; //[0x4C] (19) 保留,通常为0x00 -
DWORD SizeOfImage; //[0x50] (20) 内存中映像总尺寸*
DWORD SizeOfHeaders; //[0x54] (21) 各个文件头的总尺寸,所有文件头*
DWORD CheckSum; //[0x58] (22) 映像文件校验和 -
WORD Subsystem; //[0x5C] (23) 文件子系统 -
WORD DllCharacteristics; //[0x5E] (24) DLL标志位 +
DWORD SizeOfStackReserve; //[0x60] (25) 初始化栈大小,进程中栈可以增长的最大值,一般1MB +
DWORD SizeOfStackCommit; //[0x64] (26) 初始化实际提交栈大小,一般一次4KB +
DWORD SizeOfHeapReserve; //[0x68] (27) 初始化堆大小,一般可以增长最大值1MB +
DWORD SizeOfHeapCommit; //[0x6C] (28) 堆每次提交的大小 +
DWORD LoaderFlags; //[0x70] (29) 调试相关,默认0x00
DWORD NumberOfRvaAndSizes; //[0x74] (30) 数据目录表的数量*
IMAGE_DATA_DIRECTORY DataDirectory[0x10]; //[0x78] (31) 数据目录表*
} IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32;
注意:扩展头最后一个字段是一个暑假目录表,默认有16项,有宏定义IMAGE_NUMBEROF_DIRECTORY_ENTRIES,值为0x10,十进制16,每一项是一个结构体,结构体如下:
结构体中的VirtualAddress记录了各表在文件中的RVA,这些数据都是在PE的区段中。
typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress; // 数据块的起始RVA地址*
DWORD Size; // 数据块的长度
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
各表在数据目录表中的索引 | 描述 |
---|---|
IMAGE_DIRECTORY_ENTRY_EXPORT 0x0 | 索引导出表(IMAGE_EXPORT_DIRECTORY) |
IMAGE_DIRECTORY_ENTRY_IMPORT 0x1 | 索引导入表(IMAGE_IMPORT_DESCRIPTOR结构体数组) |
IMAGE_DIRECTORY_ENTRY_RESOURCE 0x2 | 索引资源(IAMGE_RESOURCE_DIRECTORY结构) |
IMAGE_DIRECTORY_ENTRY_EXCEPTION 0x3 | 索引异常处理程序表(IMAGE_RUNTIME_FUNCTION_ENTRY结构数组) |
IMAGE_DIRECTORY_ENTRY_SECURITY 0x4 | 索引安全结构,它不加载入内存,它的地址成员是文件偏移,而不是RVA |
MAGE_DIRECTORY_ENTRY_BASERELOC 0x5 | 索引基址重定位信息 |
IMAGE_DIRECTORY_ENTRY_DEBUG 0x6 | 索引调试信息 |
IMAGE_DIRECTORY_ENTRY_ARCHITECTURE 0x7 | 版权 |
IMAGE_DIRECTORY_ENTRY_GLOBALPTR 0x8 | 全局指针目录,用在64位平台 |
IMAGE_DIRECTORY_ENTRY_TLS 0x9 | 指向线程局部存储初始化字节 |
IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG 0xA | 载入配置表 |
IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT 0xB | 绑定输入目录 |
IMAGE_DIRECTORY_ENTRY_IAT 0xC | 导入地址表 |
IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT 0xD | 延迟载入描述 |
IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR 0xE | COM信息 |
4.2.4 数据目录表的遍历:
假设:PE文件已经读取到内存中的pFile位置,pFile为一个void类型的指针。
// 获取dos头
PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)pFile;
// 获取nt头
PIMAGE_NT_HEADERS32 pNTHeader = (PIMAGE_NT_HEADERS32)(pDosHeader->e_flanew + (long)pFile);
// 获取扩展头
PIMAGE_OPTIONAL_HEADER32 pOptionalHeader = &(pNTheader->OptionalHeader);
// 获取数据目标表的数组元素首地址
PIMAGE_DATA_DIRECTORY pDataDirectory = (PIMAGE_DATA_DIRECTORY)&(pOptionalHeader->DataDirectory);
// 遍历数据目录表中的数据
DWORD i = 0;
while(i != 0x10)
{
print("%x",pDataDirectory[i].VirtualAddresss); // 相对虚拟地址
print("%x",pDataDirectory[i].Size); // 大小
i++;
}
5.PE文件中的区块头表
扩展头后就是区块头,通过宏IMAGE_FIRST_SECTION32(NTHeader),这个宏对应一个函数,可以将NT头转换成第一个区块的头。区块头表由多个这样的结构体构成一个数组,以一个全0的结构体结尾。
typedef struct _IMAGE_SECTION_HEADER {
BYTE Name[0x8]; // (1) 区段名
union {
DWORD PhysicalAddress;
DWORD VirtualSize; // (2) *区段大小,该区段在内存中的大小,未对齐的值
} Misc;
DWORD VirtualAddress; // (3)区段的RVA地址*
DWORD SizeOfRawData; // (4) 文件中的区段对齐后的大小*
DWORD PointerToRawData; // (5) 区段在文件中的偏移*
DWORD PointerToRelocations; // (6) 重定位的偏移(OBJ)
DWORD PointerToLinenumbers; // (7) 行号表的偏移(调试)
WORD NumberOfRelocations; // (8) 重定位项数量(OBJ)
WORD NumberOfLinenumbers; // (9) 行号表项数量
DWORD Characteristics; // (10) 区段的属性*
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;
5.2 如何解析区段头表信息:假设PE文件已经读取到了内存中的pbuff位置:
//显示区段信息
void Show_SectionInfo(char * pbuff) {
//1.获取DOS
//2.获取NT
//3.获取区段头
//4.遍历区段显示信息
PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)pbuff;
PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)(pDos->e_lfanew + (DWORD)pbuff);
// 区段头位置= pNT+4+sizeof(IMAGE_FILE_HEADERS)+sizeof(IMAGE_OPTIONAL_HEADERS) PIMAGE_SECTION_HEADER pSection = IMAGE_FIRST_SECTION(pNt);
for (int i = 0; i < pNt->FileHeader.NumberOfSections; i++) {
//输出区段信息
char name[9];
strncpy_s(name, (char*)pSection[i].Name, 8);
printf("区段名字:%s\n", name); printf("区段RVA: %08x\n", pSection[i].VirtualAddress);
}
}
常见的区段及其描述:区段名和区段属性功能没有必然联系,区段名是约定成俗的一种叫法,可以修改的。
区段名 | 描述 |
---|---|
.text | 代码段,存储可执行的代码 |
.data | 数据段,程序运行中需要的数据 |
.bss | 为初始化的数据,比如static修饰的静态变量,为初始化的全局变量等 |
.rdata | 只读数据段,存储常量,比如字符串 |
.textbss | 和代码有关 |
.idata和.edata | 存储导入表和导出表的信息 |
.rsrc | 存储资源的区段 |
.reloc段 | 存储重定位信息的区段 |
注意:有些区段出现的时机是不同的,.bss段在磁盘上不存在(里边的为初始化的数据在内存中分配空间进行初始化),加载到内存中才存在,而.reloc在磁盘上存在,在内存中不存在。
5.3 RVA to FOA:
PE中很多字段值是RVA,RVA是相对虚拟地址,要在文件中修改PE文件,需要使用FOA。
将一个RVA值转换为FOA, 我们需要将RVA在内存映像中比较此值落在那个区段中,然后使用RVA减去此区块头的RVA,得到了一个偏移,再加上文件中,此区块头的FOA,就计算出了此区块中某个位置的FOA。
公式:Offect(转) = RVA(转) -RVA(所在区段起始) + Offect(文件中所在区段起始)
假设:一个PE文件已经读取到内存中,且在内存中的地址为pFile,pFile为一个void类型的指针。
// 输入一个RVA,计算返回此RVA对应的FOA
DWORD CalcOffect(DWORD RVA)
{
// 1. 获取NT头
PIMAGE_NT_HEADERS32 pNTHeader = PIMAGE_NT_HEADERS32((long)pFile + (PIAMGE_DOS_HEADER)pFile->e_flanew);
// 2. 获取区段头表
PIMAGE_SECTION_HEADER pSectionHeader = IMAGE_FIRST_SECTION(pNTHeader);
// 3. 循环比较它在哪一个区段中,不在此区段继续循环,判断时注意使用的是SizeOfRawData(文件中的大小),
// 而不是VirtualSize(内存中的大小)
while(!(RVA >= pSectionHeader->VirtualAddress && RVA < (pSectionHeader->VirtualAddress + pSectionHeader->SizeOfRawData)){
pSectionHeader++;
// 防止错误的PE文件中PointerToRawData为0引发的错误
if(pSectionHeader->PointerToRawData == 0)
return 0;
}
// 在就计算FOA
return RAV - pSectionHeader->VirtualAddress + pSectionHeader->PointerToRawData;
}
大小)
while(!(RVA >= pSectionHeader->VirtualAddress && RVA < (pSectionHeader->VirtualAddress + pSectionHeader->SizeOfRawData)){
pSectionHeader++;
// 防止错误的PE文件中PointerToRawData为0引发的错误
if(pSectionHeader->PointerToRawData == 0)
return 0;
}
// 在就计算FOA
return RAV - pSectionHeader->VirtualAddress + pSectionHeader->PointerToRawData;
}
到此,我们PE已经解析了dos头,nt头,区块头,nt头之中扩展头中的数据目录表中的数据,接下来将要讲解数据目录表指向的具体数据的解析,内容将放在下一章博客中讲解。
--------------------------------------------------------------------------------------------------------------------------------------------ps:文章中的PE结构详解图来自于看雪论坛