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 { WORD e_magic; 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; } 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; IMAGE_FILE_HEADER FileHeader; IMAGE_OPTIONAL_HEADER32 OptionalHeader; } IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;
其中文件头:
typedef struct _IMAGE_FILE_HEADER { WORD Machine; WORD NumberOfSections; DWORD TimeDateStamp; DWORD PointerToSymbolTable; DWORD NumberOfSymbols; WORD SizeOfOptionalHeader; WORD Characteristics; } IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;
可选PE头 可选PE头,虽说是可选PE头,但里面包含了很多重要的信息
typedef struct _IMAGE_OPTIONAL_HEADER { WORD Magic; BYTE MajorLinkerVersion; BYTE MinorLinkerVersion; DWORD SizeOfCode; DWORD SizeOfInitializedData; DWORD SizeOfUninitializedData; DWORD AddressOfEntryPoint; 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; 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]; } 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]; union { DWORD PhysicalAddress; DWORD VirtualSize; } Misc; DWORD VirtualAddress; 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 pDosHeader = (PIMAGE_DOS_HEADER)buffer; if (pDosHeader->e_magic != 0x5A4D ) { printf ("不是有效的PE文件\n" ); delete[] buffer; return ; } PIMAGE_NT_HEADERS pNtHeaders =(PIMAGE_NT_HEADERS) (pDosHeader->e_lfanew +(uintptr_t ) buffer); if (0x4550 != pNtHeaders->Signature) { printf ("不是有效的PE文件结构" ); delete[] buffer; return ; } PIMAGE_FILE_HEADER pFileHeader = &pNtHeaders->FileHeader; if (debug) { printf ("Machine:%x\n" , pFileHeader->Machine); printf ("区段数:%d\n" , pFileHeader->NumberOfSections); printf ("可选头大小:%d\n" , pFileHeader->SizeOfOptionalHeader); } 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<pFileHeader->NumberOfSections;i++){ char name[9 ]{0 }; memcpy_s(name,9 ,pSectionHeader->Name,8 ); printf ("----------------第%d个区段--------------------------\n" ,i+1 ); printf ("名称:%s\n" ,name); printf ("内存地址偏移: %x\n" ,pSectionHeader->VirtualAddress); printf ("区段大小:%d\n" ,pSectionHeader->SizeOfRawData); pSectionHeader++; }
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) { PIMAGE_SECTION_HEADER pSectionHeader=IMAGE_FIRST_SECTION(peHeader->ntHeaders); for (int i=0 ;i<peHeader->ntHeaders->FileHeader.NumberOfSections;i++){ if (RAV>=pSectionHeader->VirtualAddress && RAV< pSectionHeader->VirtualAddress+pSectionHeader->Misc.VirtualSize){ return RAV-pSectionHeader->VirtualAddress+pSectionHeader->PointerToRawData; } pSectionHeader++; } return 0 ; }
导入/导出表 导出表 数据目录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; DWORD Base; DWORD NumberOfFunctions; DWORD NumberOfNames; DWORD AddressOfFunctions; DWORD AddressOfNames; DWORD AddressOfNameOrdinals; } IMAGE_EXPORT_DIRECTORY,*PIMAGE_EXPORT_DIRECTORY;
按照名称取出函数(地址表、序列表、名称表之间的关系)
名称表中函数名称所在的索引-> 序列表中对应元素中的序列号 -> 地址表中所对应的元素
解析导出表代码:
list <PExportFunc> CPeUtil::GetExportTable () { list <PExportFunc> exportFuncList; IMAGE_DATA_DIRECTORY exportDirectory =pOptionHeader->DataDirectory[0 ]; if (exportDirectory.VirtualAddress==0 ){ printf ("not exist export table\n" ); return exportFuncList; } DWORD offset= RavToFoa(exportDirectory.VirtualAddress); if (offset==0 ){ printf ("not found export table\n" ); return exportFuncList; } PIMAGE_EXPORT_DIRECTORY peExport=PIMAGE_EXPORT_DIRECTORY (buffer+offset); char * dllName=RavToFoa(peExport->Name)+ buffer; printf ("dll name:%s\n" ,dllName); DWORD* funcAddr=(DWORD *)(RavToFoa(peExport->AddressOfFunctions)+buffer); WORD* ordinal= (WORD*)(RavToFoa(peExport->AddressOfNameOrdinals)+buffer); DWORD* names=(DWORD *)(RavToFoa(peExport->AddressOfNames)+buffer); for (int i=0 ;i<peExport->NumberOfFunctions;i++){ PExportFunc func=new ExportFunc{}; func->VirtualAddress=funcAddr; for (int j=0 ;j<peExport->NumberOfNames;j++){ if (ordinal[j]==i){ char * name= RavToFoa(names[j])+buffer; func->Ordinal=ordinal[j]; func->Name=name; break ; } } exportFuncList.push_back(func); funcAddr++; } return exportFuncList; }
导入表 简单的就是:告诉系统你需要用到哪些dll,用哪些函数。
需要导入调用外部函数
调用多个dll就会有如下多个_IMAGE_IMPORT_DESCRIPTOR结构,导入描述符
typedef struct _IMAGE_IMPORT_DESCRIPTOR { union { DWORD Characteristics; DWORD OriginalFirstThunk; } DUMMYUNIONNAME; DWORD TimeDateStamp; DWORD ForwarderChain; DWORD Name; DWORD FirstThunk; } 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; } 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 ]; } IMAGE_IMPORT_BY_NAME,*PIMAGE_IMPORT_BY_NAME;
FirstThunk : 指向IAT,不过在不同的情况下IAT内容不一样。
当TimeDateStamp为0的时候表示未绑定,该字段其实跟OriginalFirstThunk 指向的差不多,对应上图PE加载前
当TimeDateStamp不为0,这时候指向的是函数真实地址表,对应上图PE加载后
解析代码:
typedef struct ImportFunc { char * dllName; bool useName; char * name; DWORD Ordinal; }ImportFunc,*PImportFunc;
解析函数,(接着前面的代码)
list<PImportFunc> CPeUtil::GetImportTable () { list<PImportFunc> importFuncList; IMAGE_DATA_DIRECTORY importDirectory =pOptionHeader->DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT]; PIMAGE_IMPORT_DESCRIPTOR pImportTable=(PIMAGE_IMPORT_DESCRIPTOR)(RavToFoa (importDirectory.VirtualAddress)+buffer); while (pImportTable->OriginalFirstThunk){ char * dllName= RavToFoa (pImportTable->Name)+buffer; PIMAGE_THUNK_DATA pThunkData=(PIMAGE_THUNK_DATA)(RavToFoa (pImportTable->OriginalFirstThunk)+buffer); while (pThunkData->u1.Function) { PImportFunc func=new ImportFunc (); if (pThunkData->u1.Ordinal & 0x80000000 ) { func->dllName=dllName; func->useName= false ; func->Ordinal=pThunkData->u1.Ordinal & 0x7FFFFFFF ; } else { PIMAGE_IMPORT_BY_NAME pImportByName = (PIMAGE_IMPORT_BY_NAME) (RavToFoa (pThunkData->u1.AddressOfData) + buffer); func->dllName=dllName; func->useName= true ; func->name = pImportByName->Name; } importFuncList.push_back (func); pThunkData++; } pImportTable++; } return importFuncList; }
重定位表 重定位(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<DWORD> CPeUtil::GetReLocation () { list<DWORD> rvaList; IMAGE_DATA_DIRECTORY relocationDirectory =pOptionHeader->DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC]; PIMAGE_BASE_RELOCATION pRelocation=(PIMAGE_BASE_RELOCATION)(RavToFoa (relocationDirectory.VirtualAddress)+buffer); while (1 ){ if (pRelocation->VirtualAddress==0 ){ break ; } DWORD* prelocOffset =(DWORD*)pRelocation+4 ; DWORD number= (pRelocation->SizeOfBlock-sizeof (IMAGE_BASE_RELOCATION))/2 ; for (int i=0 ;i<number;i++){ if ((*prelocOffset & 0x3000 )== 0x3000 ){ DWORD rva =((*prelocOffset) & 0x0FF ) + pRelocation->VirtualAddress; rvaList.push_back (rva); } prelocOffset++; } pRelocation=(PIMAGE_BASE_RELOCATION)((uintptr_t )pRelocation+pRelocation->SizeOfBlock); } return rvaList; }
然后中间的部分高四位表示的是类型。低十二位表示的重定位地址
解析代码
list<DWORD> CPeUtil::GetReLocation () { list<DWORD> rvaList; IMAGE_DATA_DIRECTORY relocationDirectory =pOptionHeader->DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC]; PIMAGE_BASE_RELOCATION pRelocation=(PIMAGE_BASE_RELOCATION)(RavToFoa (relocationDirectory.VirtualAddress)+buffer); while (1 ){ if (pRelocation->VirtualAddress==0 ){ break ; } DWORD* prelocOffset =(DWORD*)((uintptr_t )pRelocation + sizeof (IMAGE_BASE_RELOCATION));; DWORD number= (pRelocation->SizeOfBlock-sizeof (IMAGE_BASE_RELOCATION))/2 ; for (int i=0 ;i<number;i++){ if ((*prelocOffset & 0x3000 )== 0x3000 ){ DWORD rva =((*prelocOffset) & 0x0FF ) + pRelocation->VirtualAddress; rvaList.push_back (rva); } prelocOffset++; } pRelocation=(PIMAGE_BASE_RELOCATION)((uintptr_t )pRelocation+pRelocation->SizeOfBlock); } return rvaList; }
扩展学习,可以看看另一位师傅 的文章:
PE文件结构从初识到简单shellcode注入
Reference