pe文件结构只能说算一点点基础,顺带学习PE的时候再熟悉熟悉c语言和Windows API
PE文件即Portable Execute Windows下可执行文件的总称,常见的有 DLL,EXE,OCX,SYS 等,PE文件可以说是在各个领域都有涉及,特别是病毒领域,内网渗透中的免杀对抗
(免杀对抗环境从普通的杀软到edr,xdr等设备)
基础知识PE文件的结构有两种表现形式:一是存储在在硬盘中的文件,二是加载在内存中
如上图可见,当PE文件加载到内存中后,DOS头到最后一个节区头的部分是一致的,而之后节区与节区之间的间隔,内存中的间隔会更大
产生差异是因为内存对齐,主要是下面两点:
操作系统通常以内存分页为单位(通常是 4 KB 或 2 MB 等)来管理内存。加载 PE 文件时,每个节会被分配到与分页边界对齐的内存地址
在 PE 文件的头信息中,SectionAlignment 字段定义了各个节(段)在内存中的对齐方式。这种对齐方式一般也选择 4 KB 或更大,以保证节在内存中符合分页要求
在开始之前,先来点基础概念。对于学过操作系统的来说,这应该很好理解
地址
一般是指虚拟地址,而非物理地址,我们知道程序运行时候是使用操作系统分配的内存空间,所以用户并不知道具体的物理地址。
镜像文件
包含以 EXE 文件为代表的 “可执行文件”、以DLL文件为代表的“动态链接库”。因为他们常常被直接“复制”到内存,有“镜像”的某种意思。
RAV
Relatively Virtual Address。偏移(又称“相对虚拟地址”)。相对镜像基址的偏移。
一般来说,PE文件在硬盘上和在内存里是不完全一样的。各个节在硬盘上是连续的,而在内存中是按页对齐的,所以加载到内存以后节之间会出现一些 “空洞”,这样占用的空间就会大一些 。
因为存在这种对齐,所以在 PE 结构内部,表示某个位置的地址采用了两种方式:
针对在硬盘上存储文件中的地址,称为 原始存储地址 或 物理地址,表示距离文件头的偏移。
针对加载到内存以后映象中的地址,称为 相对虚拟地址(RVA),表示相对内存映象头的偏移。
RVA 是当PE 文件被装到内存中后,某个数据位置相对于文件头的偏移量。
VA
Virtual Address。虚拟地址,程序在虚拟内存中被装载的位置
ImageBase:基址
PE文件的优先装载地址。比如,如果该值是400000h,PE装载器将尝试把文件装到虚拟地址空间的400000h处。字眼”优先”表示若该地址区域已被其他模块占用,那PE装载器会选用其他空闲地址。简而言之,就是指定PE文件载入内存时,优先尝试载入的内存起始地址。
作为web手,来看这些的时候,不得不重新审视一下结构体
首先结构体其实类似于数组,也是一段连续的内存块,只不过他内存块中每一块的大小由结构体成员决定
c语言中结构体访问成员的操作,实际转化为汇编中访问 :结构体的基址+成员的偏移量(一般都是一个立即数)
如果已知结构体的定义,并且有结构体在内存中的基址,就可以解析对应的内存区域并读取其中的数据结构。(这些偏移量是在编译期由编译器根据结构体定义、数据类型大小以及对齐规则计算得到的)
PE结构可以大致分为:
DOS部分
PE文件头
节表(块表)
节数据(块数据)
调试信息
结构如图:
PE 指纹
首先是根据文件的前两个字节是否为4D 5A,也就是’MZ’,
然后后面还存在50 45,也就是PE
DOS头 DOS部分主要是为了兼容以前的DOS系统,DOS部分可以分为DOS MZ文件头(IMAGE_DOS_HEADER)和DOS块(DOS Stub)组成,PE文件的第一个字节位于一个传统的MS-DOS头部,称作IMAGE_DOS_HEADER,其结构如下:
(WORD 2字节 16位
DWORD 4字节 32位)
typedef struct _IMAGE_DOS_HEADER { // DOS .EXE header WORD e_magic; // Magic number 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 } IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;
如果为DOS系统就会执行,打印输出一句话
然后其中比较重要的是e_lfanew 字段 ,是PE文件头的偏移地址
解析的时候读取为char* , 然后赋值给一个结构体变量即可
(相当于把指针赋值给结构体变量,能通过对应的属性偏移值来访问属性)
PE 头PE 头也叫NT头
PE头位于DOS Stub 后面,是以PE00为起始标记`
对应c语言中如下结构体:
typedef struct _IMAGE_NT_HEADERS { DWORD Signature; //PE标志,4字节 ,8位16进制数据 IMAGE_FILE_HEADER FileHeader; //文件头 IMAGE_OPTIONAL_HEADER32 OptionalHeader; //可选PE头} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;
其中文件头:
typedef struct _IMAGE_FILE_HEADER { WORD Machine; //程序允许的CPU型号 如果为0表示能在任何CPU上允许 WORD NumberOfSections; //文件中存在区段的数量 DWORD TimeDateStamp; //时间戳 DWORD PointerToSymbolTable; DWORD NumberOfSymbols; WORD SizeOfOptionalHeader; //可选PE头的大小 WORD Characteristics; // 文件属性} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;
可选PE头可选PE头,虽说是可选PE头,但里面包含了很多重要的信息
typedef struct _IMAGE_OPTIONAL_HEADER { WORD Magic; //值为10B 表示32位程序,若是20B 表示64位程序 BYTE MajorLinkerVersion; BYTE MinorLinkerVersion; DWORD SizeOfCode; //所有代码段的总大小,按照FileAlignment对齐后的大小 DWORD SizeOfInitializedData; //已初始化的数据大小,按照FileAlignment对齐后的大小 DWORD SizeOfUninitializedData; //未初始化的数据段大小,按照FileAlignment对齐后的大小 DWORD AddressOfEntryPoint; //程序入口 OEP DWORD BaseOfCode; //代码段开始地址 DWORD BaseOfData; //数据段开始地址 DWORD ImageBase; //内存镜像大小 DWORD SectionAlignment; //内存对齐方式 DWORD FileAlignment; //文件对齐大小 WORD MajorOperatingSystemVersion; WORD MinorOperatingSystemVersion; WORD MajorImageVersion; WORD MinorImageVersion; WORD MajorSubsystemVersion; WORD MinorSubsystemVersion; DWORD Win32VersionValue; DWORD SizeOfImage; //文件在内存中的大小 DWORD SizeOfHeaders; // DOS头+标准PE头+可选PE头+区段头 的大小 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]; //数据目录表,数组大小为16 } IMAGE_OPTIONAL_HEADER32,*PIMAGE_OPTIONAL_HEADER32;
可选PE头中DataDirectory 数据目录又存放了很多表的地址,如导入\导出表、重定位表等等是一个十分重要的字段,之后会经常使用, 且DataDirectory是一个长度为16的数组
程序在硬盘(文件)和内存中的对齐方式不相同,导致会有rawSize
RAV与FOA
pe文件在硬盘和内存中的对齐方式不尽相同,导致内存中的偏移(RAV)与在文件中的偏移(FOA )不相同(有些地方又把FOA称之为RAW)
很多时候字段表示的是在内存中的偏移,而我们解析pe文件的时候,只是将文件的内容放入一个char* 的buffer 中而不是加载进内存来运行,因此要将RAV转化为FOA
这些不同只存在于不同的区段(区段对齐大小不一样)
区段(节区)头学过汇编就知道,一个可执行程序是分段的,指令存放在代码段,数据存放在数据段,等等还有其他的区段
常见节区有code、text、data、resource等。
(区段头就是例如数据段、代码段等等的区段的信息)
把PE文件创建成多个节区结构的好处是,可以保证程序的安全性。若把code与data放在一个节区中相互纠缠很容易引发安全问题,即使忽略过程中的烦琐。假设向字符串data写入数据时,由于某个原因导致溢出,那么其下的code就会被覆盖,应用程序就会崩溃。
类别
访问权限
code节区
执行、读取权限
data节区
非执行、读写权限
resource节区
非执行、读取权限
结构体如下:
typedef struct _IMAGE_SECTION_HEADER { BYTE Name[IMAGE_SIZEOF_SHORT_NAME]; //区段名称,此处非字符串,不会以0结尾(不能直接使用char) union { DWORD PhysicalAddress; DWORD VirtualSize; } Misc; //在内存中的真实大小(未对齐) DWORD VirtualAddress; //区段在内存中的偏移值 + ImageBase 为真正的地址 DWORD SizeOfRawData; // 区段在文件中对齐后的大小 DWORD PointerToRawData; //区段在文件中的偏移地址 DWORD PointerToRelocations; DWORD PointerToLinenumbers; WORD NumberOfRelocations; WORD NumberOfLinenumbers; DWORD Characteristics; // 节属性如可读,可写,可执行等 } IMAGE_SECTION_HEADER,*PIMAGE_SECTION_HEADER;
解析第一个区段头使用官方定义的宏函数
IMAGE_FIRST_SECTION(pNtHeaders);
在PE文件中,节区头部是一个连续的结构体数组,每个 IMAGE_SECTION_HEADER 结构体大小固定,因此可以通过简单递增指针的方式访问下一个节区。
简单解析的代码
void ParsePEFromBuffer(char *buffer, bool debug) { //PIMAGE_DOS_HEADER为指针 ,将buffer的地址直接赋值 ,解析结构体属性的时候就直接能按照属性偏移获取 PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)buffer; //检查DOS头 签名 if (pDosHeader->e_magic != 0x5A4D) { printf("不是有效的PE文件\n"); delete[] buffer; return; } //NT头偏移= buffer 的偏移地址+ PE文件头偏移 PIMAGE_NT_HEADERS pNtHeaders =(PIMAGE_NT_HEADERS) (pDosHeader->e_lfanew +(uintptr_t) buffer); if (0x4550 != pNtHeaders->Signature) { printf("不是有效的PE文件结构"); delete[] buffer; return; } //获取PE 文件头 PIMAGE_FILE_HEADER pFileHeader = &pNtHeaders->FileHeader; if (debug) { printf("Machine:%x\n", pFileHeader->Machine); printf("区段数:%d\n", pFileHeader->NumberOfSections); printf("可选头大小:%d\n", pFileHeader->SizeOfOptionalHeader); } //获取可选PE头 PIMAGE_OPTIONAL_HEADER pOptionHeader=&pNtHeaders->OptionalHeader; if(debug){ if(0x20b==pOptionHeader->Magic){ printf("64位程序\n"); } else{ printf("32位程序\n"); } printf("程序入口地址偏移:%x\n",pOptionHeader->AddressOfEntryPoint); } //使用官方的宏函数 获取第一个区段头 PIMAGE_SECTION_HEADER pSectionHeader= IMAGE_FIRST_SECTION(pNtHeaders) ; for(int i=0;i
FOA 和RAV转化
经过上面变化,才有了RAV和FOA区分,但是只是各个区段部分,发生了变化,PE头和DOS头其实还是没变,DOS头和PE头中RAV=FAO的
大多数的时候都是RAV,如何将其转化为FOA?
尽管相对于的文件头的偏移变了,但是相对于区段起始的偏移并未改变
因此可以得到公式:
FOA-所在区段FOA = RAV- 所在区段的RAV =>FOA = RAV- 所在区段的RAV + 所在区段FOA
还有一点就是如何确定所在的区段,简单的方法就是遍历区段表
比较是否在区段的RAV范围:
RAV>=pSectionHeader->VirtualAddress && RAV< pSectionHeader->VirtualAddress+pSectionHeader->Misc.VirtualSize
转化代码:
DWORD CPeUtil::RavToFoa(DWORD RAV) { //FOA=数据的FOA+数据的RVA-区段的RVA PIMAGE_SECTION_HEADER pSectionHeader=IMAGE_FIRST_SECTION(peHeader->ntHeaders); // 遍历区段,比较RAV,获取所在的区段 for(int i=0;i
导入/导出表导出表数据目录DataDirectory是可选PE头中的字段是一个数组,每一个元素都指向了一些结构(如导入、导出表、重定位表)
数据目录元素的结构体IMAGE_DATA_DIRECTORY如下:
typedef struct _IMAGE_DATA_DIRECTORY { DWORD VirtualAddress; //内存中的偏移 DWORD Size; } IMAGE_DATA_DIRECTORY,*PIMAGE_DATA_DIRECTORY;
dll动态链接库中需要的导出的函数,都会写入导出表 ,导出表是数据目录表中的第一个元素
然而不是只有dll有导出表(一般情况下exe没有导出表)
导出表IMAGE_EXPORT_DIRECTORY 结构体如下:
typedef struct _IMAGE_EXPORT_DIRECTORY { DWORD Characteristics; DWORD TimeDateStamp; WORD MajorVersion; WORD MinorVersion; DWORD Name; //指向导出表文件名 RAV DWORD Base; //导出函数起始序列 DWORD NumberOfFunctions; //导出函数的个数 DWORD NumberOfNames; //以名称导出函数个数 DWORD AddressOfFunctions; //导出函数地址表 RAV DWORD AddressOfNames; //导出函数名称表 RAV DWORD AddressOfNameOrdinals; //导出函数序号表 RAV } IMAGE_EXPORT_DIRECTORY,*PIMAGE_EXPORT_DIRECTORY;
按照名称取出函数(地址表、序列表、名称表之间的关系)
名称表中函数名称所在的索引-> 序列表中对应元素中的序列号 -> 地址表中所对应的元素
解析导出表代码:
list
导入表简单的就是:告诉系统你需要用到哪些dll,用哪些函数。
需要导入调用外部函数
调用多个dll就会有如下多个_IMAGE_IMPORT_DESCRIPTOR结构,导入描述符
typedef struct _IMAGE_IMPORT_DESCRIPTOR { union { DWORD Characteristics; //标志 为0表示结束 没有导入描述符了 DWORD OriginalFirstThunk; //RVA指向IMAGE_THUNK_DATA结构数组 } DUMMYUNIONNAME; DWORD TimeDateStamp; DWORD ForwarderChain; //链表的前一个结构 DWORD Name; //RVA,指向DLL名字,该名字以’’\0’’结尾 DWORD FirstThunk; //RVA指向IMAGE_THUNK_DATA结构数组 } IMAGE_IMPORT_DESCRIPTOR;
上述结构体中的OriginalFirstThunk和FirstThunk分别指向其对应的INT表和IAT表
IAT (Import Address Table) 导入函数地址表
INT(Import Name Table)导入函数名称表
加载DLL的方式实际有两种:一种是显示链接(Explicit Linking),程序使用DLL时加载,函数使用完毕时再释放内存; 一种是隐式链接(Implicit Linking),程序开始时就加载DLL,程序终止时再释放占用的内存。(以后遇到再说(:
OriginalFirstThunk: 指向IMAGE_THUNK_DATA结构数组的RVA, 其内容在程序未运行下,和OriginalFirstThunk内容一样,,如下:
typedef struct _IMAGE_THUNK_DATA64 { union { ULONGLONG ForwarderString; ULONGLONG Function; //导入函数的地址 ULONGLONG Ordinal; //导入函数的序列号 ULONGLONG AddressOfData; //指向IMAGE_IMPORT_BY_NAME,导入名称 } u1; } IMAGE_THUNK_DATA64; typedef IMAGE_THUNK_DATA64 *PIMAGE_THUNK_DATA64;
这4个成员是一个共用体, 在不同情况下代表不同的数据
这个值最高位为1的时候,表示函数是一个序号输出值, 低31位会被看做API的导出序号, 当最高位为0时,这时候这个值是一个指向IMAGE_IMPORT_BY_NAME结构的RVA
typedef struct _IMAGE_IMPORT_BY_NAME { WORD Hint; CHAR Name[1]; //需导入的函数名称(不定长且以\0结尾) } IMAGE_IMPORT_BY_NAME,*PIMAGE_IMPORT_BY_NAME;
FirstThunk : 指向IAT,不过在不同的情况下IAT内容不一样。
当TimeDateStamp为0的时候表示未绑定,该字段其实跟OriginalFirstThunk 指向的差不多,对应上图PE加载前
当TimeDateStamp不为0,这时候指向的是函数真实地址表,对应上图PE加载后
解析代码:
typedef struct ImportFunc{ char* dllName; //dll名称 bool useName; //是否使用名字导入 char* name; //函数名 DWORD Ordinal; //函数序列号}ImportFunc,*PImportFunc;
解析函数,(接着前面的代码)
list
重定位表重定位(Relocation):代码重定位是把可执行代码从内存的一块区域移动到另外一块地方。但是如果指令中某些操作数没有随着地址的改变而改变,这样势必导致运行出错。如:全局变量的地址包含在机器码中,而局部变量没有包含绝对地址。
重定位信息是在编译时期由编译器生成,并且保存在应用程序中,在程序执行的时候由操作系统予以修正。如果在装载时该位置已经被别的应用程序使用,操作系统会重新选择一个新的基地址。此时,就需要对所有重定位信息进行纠正,纠正的依据就是PE中的重定位表。
重定位表是数据目录中的第6项,索引为5
对应IMAGE_BASE_RELOCATION结构体:
typedef struct _IMAGE_BASE_RELOCATION {DWORD VirtualAddress;DWORD SizeOfBlock;} IMAGE_BASE_RELOCATION;typedef IMAGE_BASE_RELOCATION UNALIGNED *PIMAGE_BASE_RELOCATION;
其结构也如下:
解析代码:
//解析重定位表list
然后中间的部分高四位表示的是类型。低十二位表示的重定位地址
解析代码
list
扩展学习,可以看看另一位师傅 的文章:
PE文件结构从初识到简单shellcode注入
Reference
https://tttang.com/archive/1553