第二十三课 PE头手动解析
参考文章https://blog.csdn.net/Edimade/article/details/124540050?spm=1001.2014.3001.5502
1.PE结构前言
a.硬盘和加载到内存的文件结构异同
- 硬盘上的exe打开后首地址是从0开始(逻辑地址);内存中文件是从0x10000000开始的(物理地址)
- 最开始一大段数据相同,出现一堆00后又是一大段数据
- 在硬盘的文件比运行时在内存中的空白区小 ,但是两者都满足分节方式(数据-空白区-数据-空白区)
b.分节 (段)
PE文件在硬盘和内存都是分节存储的。把数据分成一段一段,比如我们看到的开始一段数据,然后一片空白(0),然后一段数据,然后又是一片空白(0)
从硬盘到内存:内存拉长
为什么需要分节
1、节省硬盘空间:内存中文件段与段之间空隙更大,硬盘上段与段的空隙比较小,所以节省硬盘空间(只对老式编译器成立)
早期硬盘空间较小,为了节省空间只能牺牲速度,但后期一些编译器内存和硬盘是可以对齐的,以此提高运行速度,因为都以1000字节大小对齐后少去一些换算,运行速度自然就提升了
2、节省内存空间:我们需要多开一个exe,比如qq同时登陆几个号,我们需要用到两块数据,一块只读数据,我们只需一块就可以反复利用,一块可读可写的数据我们每个exe都不一样就多复制几块
为什么不节省内存空间呢?内存条不是更贵
我们要知道这里的文件运行时所在内存和我们说的内存条不是一个概念,任何一个exe文件在32位计算机上运行时都有自己独立的4GB(2的32次方,即寻址范围最大是4GB)虚拟内存----其中有2GB是供应用程序使用的,另外2GB是操作系统用的。
我们可以想象成凡是运行后的程序虚拟上会有这样的4GB内存结构,但是实际上程序的数据都要经过操作系统帮我们管理按照特定的方式存到真实的内存条中
c.对齐的概念
写的比较前面,后面就会有一些模块对齐,内存对齐的概念,再这里统一记一下,这几种对齐的宏观理念其实是差不多的,比如模块对齐,一个exe有很多模块,系统在运行exe的时候是很快的,他总不能一个一个模块去找,所以就固定了一个对齐的值,比如4000000,那么就每4000000就放一个模块,不管这个模块有没有占满4000000,剩下的空间浪费就浪费了,为了运行效率。然后系统就可以快速的找出每个模块的位置,反正就是每隔4000000个字节就是一个模块的开始,这就是模块对齐大概的个人理解,内存对齐的都同理
2.PE结构概况
节表
PE文件中有很多个节,每个节在文件或内存中从哪里开始,有多大,这些都记录在节表
PE文件头和DOS头
这两个结构记录了关于可执行文件的概要信息和特征:比如内存中拉伸后多大空间,程序启动后分多大的堆和堆栈
总结:
所以一个文件的pe结构除了存储数据的节,还有存储文件和节的相关信息的结构
3.解析PE文件结构
整体
DOS头和NT头
整个DOS头只需看最前面和最后面两个参数即可,e_magic表示是否为pe文件,5A4D即是PE文件 ;e_ifanew表示真正的pe文件开始地址,也是NT头的位置
PE签名:0x00004550
NT头由三部分组成:PE签名 + PE文件头 + PE可选头
PE文件头
PE可选头
DOS头PE头字段说明
1、DOC头:
WORD e_magic * "MZ标记" 用于判断是否为可执行文件.
DWORD e_lfanew; * PE头相对于文件的偏移,用于定位PE文件
2、标准PE头:
WORD Machine; * 程序运行的CPU型号:0x0 任何处理器/0x14C 386及后续处理器
WORD NumberOfSections; * 文件中存在的节的总数,如果要新增节或者合并节 就要修改这个值.大小表示不包括DOS头、NT头、节表,此文件分为几个节(例如.text、.idata等)
DWORD TimeDateS tamp; * 时间戳:文件的创建时间(和操作系统的创建时间无关),编译器填写的.
DWORD PointerToSymbolTable;
DWORD NumberOfSymbols;
WORD SizeOfOptionalHeader; * 可选PE头的大小,32位PE文件默认E0h 64位PE文件默认为F0h 大小可以自定义.
WORD Characteristics; * 每个位有不同的含义,可执行文件值为10F 即0 1 2 3 8位置1
3、可选PE头:
WORD Magic; * 说明文件类型:10B 32位下的PE文件 20B 64位下的PE文件
BYTE MajorLinkerVersion;
BYTE MinorLinkerVersion;
DWORD SizeOfCode;* 所有代码节的和,必须是FileAlignment的整数倍 编译器填的 没用
DWORD SizeOfInitializedData;* 已初始化数据大小的和,必须是FileAlignment的整数倍 编译器填的 没用
DWORD SizeOfUninitializedData;* 未初始化数据大小的和,必须是FileAlignment的整数倍 编译器填的 没用
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;* 内存中整个PE文件的映射的尺寸,可以比实际的值大,但必须是SectionAlignment的整数倍
DWORD SizeOfHeaders;* 所有头+节表按照文件对齐后的大小,否则加载会出错
DWORD CheckSum;* 校验和,一些系统文件x有要求.用来判断文件是否被修改.
WORD Subsystem;
WORD DllCharacteristics;
DWORD SizeOfStackReserve;* 初始化时保留的堆栈大小
DWORD SizeOfStackCommit;* 初始化时实际提交的大小
DWORD SizeOfHeapReserve;* 初始化时保留的堆大小
DWORD SizeOfHeapCommit;* 初始化时实践提交的大小
DWORD LoaderFlags;
DWORD NumberOfRvaAndSizes;* 目录项数目
Characteristics把内存中的值读出来后,即读作0x010F,此时化成二进制,**第七位省略**,其他的每一位都表示一个特征,如果为1,则表示此文件有此位对应的特征;为0表示没有此特征
AddressOfEntryPoint程序入口点OEP(程序真正执行的起始地址):这个值是偏移量,而不是真正运行在内存中的程序入口地址。需要再加上加载到内存的基址(imagebase),才是程序运行在内存中(4GB虚拟内存)的程序入口。这个值不是确定的
- 注意:程序入口在默认情况下一般都在.code代码节当中,且OEP不是只能在.code代码节开始的位置,可以从此节当中的任何合理位置开始,也可以在其他节(如.text等)的任意合理位置开始。OEP可以人为修改,但是最后一定要让.exe文件能运行起来
- 注:程序入口不能理解为C语言的main函数,那只是我们写的代码的执行入口,因为在main函数被调用前还做了很多事情,所以OEP一定是.exe双击开始运行时程序开始的那个地址,可以用OD打开看一下,如下
- 内存中的程序入口地址:使用OD打开文件(完全模拟文件运行时加载到内存中的状态,不是硬盘上的状态)。所以OD打开一个可执行文件后,会在程序入口地址处设置断点,让程序停下来,这里就是文件在内存中真正的入口点。即文件装入到4GB虚拟内存中的起始基地址 +相对于文件首地址的偏移的程序入口地址,即imagebase + AddressOfEntryPoint
ImageBase
- 内存镜像基址:
我们知道每一个.exe程序都有属于自己的4GB虚拟内存,这个值就是当程序运行装入到自己的虚拟4GB内存中后的文件的起始位置。imagebase一般都是0x00400000(也可以改),不能超过0x80000000,因为我们写的程序的数据只能在内存的2GB用户区中,不能占用2GB系统区
- 内存镜像地址可能会重复,但是加载的时候操作系统会转换镜像地址
- 为什么imagebase不从0开始?
因为内存保护!我们前面学过,free一个动态分配内存的指针后,一定要将指针 = NULL,那么指针等于NULL后,这个指针指向的地址就是0x0,那么如果此时访问此指针指向的数据,或者向后偏移一定大小的范围内的数据,编译器会立马报错。所以4GB内存中开始空出来一些内存空间就是为了内存保护的
因为查找效率更高。可以理解为模块对齐
- 模块的概念:
一个exe内可能有多个pe文件结构,例如一些dll,exe本身就是一个pe文件满足pe结构,但是exe中可能用到的多个dll也是pe文件,也满足pe结构,相当于一个exe里面有很多个模块,每个dll都是一个模块
通过od可以看到exe的PE文件里面还有很多PE结构
随便点一个pe header进去,可以看到dos头
来到pe可选头,可以看到内存镜像基址为61400000和程序入口偏移为765E0
4.可执行文件加载进内存的过程
编译器生成exePE文件
我们在vs编译后,编译器就会自动帮我们计算生成PE文件所需的数据,比如imagebase或者OEP等字段信息,然后存入硬盘
硬盘文件数据读到FileBuffer
FileBuffer:通过winhex或者十六进制编译器打开一个存储在硬盘上的可执行文件,打开后显示的数据就是文件在硬盘上的状态。此过程只是将文件在硬盘上时的数据原封不动的复制一份到内存(FileBuffer)中,我们称这块内存叫FileBuffer,通过软件显示出来。此时文件的格式还不具备windows运行格式
文件从FileBuffer加载到ImageBuffer
即文件对齐拉伸成内存对齐,将exe加载进自己的4GB虚拟内存中,此过程叫做PE loader有些文件对齐和内存对齐则不用拉伸
所以imageBase就是文件在4GB虚拟内存中的起始地址(基址),用ImageBase加上一些偏移值即可得到文件其他内容在虚拟内存中的地址
操作系统会将虚拟地址转化成物理地址
上面的两个FileBuffer和ImageBuffer提到的所有地址,其实都是虚拟地址,我们学过操作系统知道,操作系统最后还要将这些虚拟地址转换为物理地址,才是真正的装入到真实内存中。这个过程操作系统帮我们做了不需要手动做,所以现在先了解到上面两个过程即可,就是文件在硬盘上时的数据格式,复制一份到FileBuffer中显示出来;运行时文件经过PE Loader将文件拉伸,装载到ImageBuffer中
所以ImageBuffer中的文件格式虽然满足了windows运行格式,但是此时这个文件还没有执行。即还没有分配CPU,后面操作系统还要做很多事情,才能让imageBuffer中的文件真正装入实际内存中,执行起来。在imageBuffer中其实是一个4GB虚拟内存,装入imagebuffer时有一个文件被拉伸的过程,此时已经无限接近于可被执行的格式了,但是还没有执行
作业
#pragma warning(disable:4996)
#include <iostream>
#include <windows.h>
#include <winnt.h>
using namespace std;
int F_Size(FILE* fp)
{
fseek(fp,0,2);
int len = ftell(fp);
fseek(fp, 0, 0);
return len;
}
LPVOID Read_PE()
{
FILE* fp;
LPVOID pFileBuffer;
fp = fopen("C:\\Windows\\notepad.exe", "rb");
int F_size = F_Size(fp);
pFileBuffer = malloc(F_size);
if (!pFileBuffer)
{
printf("分配空间失败");
fclose(fp);
return NULL;
}
size_t read = fread(pFileBuffer,F_size,1,fp);
if (!read)
//如果读取失败,返回读取到的元素为0,!read则为!0=1,则会运行下面代码
{
printf("读取文件失败");
fclose(fp);
}
fclose(fp);
return pFileBuffer;
}
VOID PrintfHeader()
{
LPVOID pFileBuffer = NULL;
PIMAGE_DOS_HEADER pDos_header = NULL;
PIMAGE_NT_HEADERS pNT_header = NULL;
PIMAGE_FILE_HEADER pPE_header = NULL;
PIMAGE_OPTIONAL_HEADER pOption_header = NULL;
PIMAGE_SECTION_HEADER pSection_header = NULL;
//读取文件函数读取
pFileBuffer = Read_PE();
//验证MZ标志
pDos_header = (PIMAGE_DOS_HEADER)pFileBuffer;
if (pDos_header->e_magic != IMAGE_DOS_SIGNATURE)
{
printf("不是有效的MZ标志\n");
free(pFileBuffer);
}
//打印DOS头
printf("********DOS头信息********\n");
printf("PE文件偏移%x\n",pDos_header->e_lfanew);
//计算NT头起始地址*
pNT_header = (PIMAGE_NT_HEADERS)((DWORD)pFileBuffer + pDos_header->e_lfanew);
//验证NT头
if (pNT_header->Signature != IMAGE_NT_SIGNATURE)
{
printf("不是有效的PE标志\n");
free(pFileBuffer);
return ;
}
//打印标准PE头和可选PE头
pPE_header = (PIMAGE_FILE_HEADER)((DWORD)pNT_header + 4);
printf("********打印标准PE头********\n");
printf("PE程序运行的CPU型号:%x\n",pPE_header->Machine);
printf("节的数量:%x\n",pPE_header->NumberOfSections);
printf("时间戳:%x\n",pPE_header->TimeDateStamp);
pOption_header = (PIMAGE_OPTIONAL_HEADER)((DWORD)pPE_header + IMAGE_SIZEOF_FILE_HEADER);
printf("********打印可选PE头********\n");
printf("说明文件类型:%x\n",pOption_header->Magic);
printf("内存镜像基址:%x\n", pOption_header->ImageBase);
printf("程序入口:%x\n", pOption_header->AddressOfEntryPoint);
//释放内存
free(pFileBuffer);
}
注:
- pDos_header->e_magic直接就是取出pDos_header结构体指针的e_magic值
- PWORD(pDos_header->e_magic)可以强转成指针地址
- IMAGE_DOS_SIGNATURE 是一个常量,用于表示在可执行文件(PE 文件)的 DOS 头的 e_magic 字段中的标志值,在winnt.h头文件中定义好的
- 计算64位的PE需要用这个结构PIMAGE_OPTIONAL_HEADER64,不然前面的信息其实都对,但是可选PE头那里有些是错的
- 下图有个经典错误,这里的pNT_header需要强转成DWORD再去+4,不然他就是以结构体指针去运算
PWORD和DWORD的选择
- 又犯了个大错误,用成了PWORD类型,选择使用 DWORD 或 PWORD 主要取决于对 pDos_header->e_lfanew 的解释。如果 e_lfanew 表示一个以字节为单位的偏移量,则通常使用 DWORD。如果 e_lfanew 表示以字为单位的偏移量,则可能需要使用 PWORD。在大多数现代系统上,DWORD 是更为常见和通用的。
- 所以基本地址计算都转DWORD就好
下图的return可以直接结束函数
第二十四课 节表
1.联合体
意义:
特点:
定义两种方式
第一种:定义TestUnion为一种类型,用的时候需要定义一个变量:TestUnion t;再用变量x.y去使用
第二种:只使用一次的话,直接创建一个匿名类型,变量名为TestUnion,就可以直接使用TestUnion.y了
2.节表
概述
- 通过前面学习我们也知道了一个PE文件的结构是由DOS头+PE标记+标志PE头+可选PE头+节表+多个节组成
- 那么一个可执行文件分的多个节在硬盘上和内存中应该从哪个地址位置开始存储,到哪里结束等都需要一个东西来管理、来记录。这个东西就是节表
- 节表相当于各个节(.text、.idata等)的一个目录;而DOS和NT头相当于对整个文件的一个描述
定位节表位置
- 节表是表,肯定不止一个数。一个PE文件有多少个节,就有多少个节表。每一个节表的大小是确定的,40字节
- 如何确定有多少个节:可以通过标准PE头中的NumberOfsections字段的值来确定;确定了有多少个节,就确定了有多少一个节表,一个节表记录管理一个节的信息
- 一个PE文件从哪里开始是节表(硬盘上的地址):DOS头大小 + 垃圾空位 + PE签名大小 + 标准PE头大小 + 可选PE头大小(需要查);我们知道DOS头大小固定为64字节;PE签名大小为4字节;标准PE头大小固定为20字节;可选PE头大小可以通过标准PE头中的SizeOfOptionalHeader字段的值来确定
- 节表开始地址 = e_lfanew + 4 + 20 + SizeOfOptionalHeader
节表结构
一个节对应一张节表。节表数据紧跟再可选PE头后面,然后每个节表都循环下面的结构存放在可选pe头后面,几帐节表就循环几次
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;
1、Name
8个字节 一般情况下是以"\0"结尾的ASCII吗字符串来标识的名称,内容可以自定义.
容易出现的安全问题:
因为Name数组的长度最大为8字节,如果定义节的名称为.text:.的ASCII码为0x2E;t的ASCII码为0x74;e的ASCII码为0x65;x的ASCII码为0x78。所以Name数组中的数据为2E 74 65 78 74 00 00 00,一共8字节,如果此时用一个指针指向Name数组首地址,即char* np = Name所在的地址,接着使用printf("%s",np);来打印此节表的名字,那么由于使用%s和char的方式来访问一个数组会从数组首地址一直打印直到遇到\0,即0x00就结束打印,所以此时就会打印出来.text,没有任何问题
但是如果我们自定义名字或者编译器帮我们定义的名字长度等于8,把这8位全占了,没有给\0留位置存储。比如.abcdefg,每个字符转换成1字节的ASCII码,最后Name数组中的数据为2E 61 62 63 64 65 66 67,一共8字节。那么如果我们还是使用char p = Name的起始地址,使用printf("%s",p);来打印名字,就会有越界问题,因为此时数组由于没有\0结尾了,那么就会接着把Name后面的内存中的数据打印出来,直到遇到一个0x00为止才停止打印。那么打印的名字就可能是.abcdefg癑5J@.??.等乱码
解决方法:
自己定义一个char arr[9] = "";然后使用库函数strncpy(arr,name,8);,或者自己写一个循环一个一个赋值进我们自定义的数组中,最后一位补\0即可,然后使用%s的方法打印我们自定义的数组中的值即可
2、Misc
双字 是该节在没有对齐前的真实尺寸,该值可以不准确。
3、VirtualAddress
节区在内存中的偏移地址。加上ImageBase才是在内存中的真正地址。
4、SizeOfRawData
节在文件中对齐后的尺寸。
5、PointerToRawData
节区在文件中的偏移地址。和VirtualAddress的区别就是一个是文件中一个是内存中的偏移地址
6、PointerToRelocations 在obj文件中使用 对exe无意义
7、PointerToLinenumbers 行号表的位置 调试的时候使用
8、NumberOfRelocations 在obj文件中使用 对exe无意义
9、NumberOfLinenumbers 行号表中行号的数量 调试的时候使用
10、Characteristics
节的属性
比如读出来的值为60000020h,那么这个节的属性为该块可读可执行,包含可执行代码
总结:
作业
//打印节表
pSection_header = (PIMAGE_SECTION_HEADER)((DWORD)pOption_header + pPE_header->SizeOfOptionalHeader);
printf("********打印节表********\n");
printf("name:%x\n", pSection_header->Name);
printf("节区在内存中的偏移地址:%x\n", pSection_header->VirtualAddress);
printf("节在文件中对齐后的尺寸:%x\n", pSection_header->SizeOfRawData);
printf("节区在文件中的偏移地址:%x\n", pSection_header->PointerToRawData);
标签:24,文件,23,节表,header,内存,PE,DWORD
From: https://www.cnblogs.com/xiaoxin07/p/18076791