目录
一、PE文件概述
二、DOS头部
三、DOS头部与NT头部之间
四、NT头部
五、文件头区段
六、了解个别概念
七、扩展头
八、区段头表
一、PE文件概述
PE文件是有特定格式的文件,像后缀名是EXE的可执行文件、后缀名是DLL的动态链接库文件、sys格式的驱动文件,这些都属于PE格式文件。
PE文件主要分成头和主体两部分,这两部分里面还会再细分。文件头是由几个结构体组成的,包含了文件的一些描述信息;文件主体由多个段构成,里面有文件的可执行代码、执行时要用的数据,还有资源(像Windows程序里的图标、一些界面等)。一般来说,不用于执行的就是数据,用于执行的就是代码,所以主体大概能分成代码和数据两部分,实际上按照不同作用,还能再细分成多个部分。
PE文件是按下面的结构顺序组成的:
1. DOS头部:是为了和DOS程序兼容才设置的。
2. NT头部:存着PE文件的所有属性、初始化信息等内容。
3. 区段头表:对PE文件主体属性进行分段描述,数量不固定。
4. 各个区段:是PE文件的主体,分段存着可执行代码、各种数据和资源等。
5. 一些调试信息:先不详细说。
接下来会逐个介绍这些部分,总结每个部分的关键内容,把这些要点记住,基本上就能了解PE文件的结构了。同时,会用LoadPE做例子来讲怎么使用,为后面写PE解析工具做准备。
二、DOS头部
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;
说明
1. 这个结构体从文件的第一个字节开始。
2. 真正有用的数据成员是第一个和最后一个,也就是 `e_magic` 和 `e_lfanew`。
3. `e_magic` 是魔数,它是DOS头的标志位,其值始终为4D5A,在系统里用宏定义成 `IMAGE_DOS_SIGNATURE`,用来代表DOS标志。
4. `e_lfanew` 指的是新EXE文件的偏移量,也就是NT头部在文件中的偏移位置。
5. 其他的数据成员大多没什么用,里面的内容很多都是0。
使用
1. 可以利用 `e_magic` 来判断一个文件是不是PE文件。要是从文件开头用 `PIMAGE_DOS_HEADER` 进行解析,`e_magic` 成员的值不是 `IMAGE_DOS_SIGNATURE`,那就说明这个文件不是PE文件。示例代码如下:
if(((PIMAGE_DOS_HEADER)pFile)->e_magic != IMAGE_DOS_SIGNATURE){//不是DOS头,返回return;
}
2. `e_lfanew` 用来计算偏移量,从而找到NT头在文件中的位置。假设文件已经被读入内存,内存首地址是 `pFile`(`void` 类型的指针),通过 `(long)pFile+((PIMAGE_DOS_HEADER)pFile)->e_lfanew` 就能得到NT头的位置。
3. 在加载器里,这部分内容能让加载器找到NT头,在DOS环境下还能提示程序的运行环境是Windows。
三、DOS头部与NT头部之间
在DOS头部和NT头部之间有一块区域,这里存储着一些会被DOS头用到的数据,比如提示字符串等。这部分区域的大小不固定,NT头的具体位置由DOS头的最后一个成员 `e_lfanew` 来确定。
使用
DOS头和这部分空间的作用比较小。可以把PE头放到这个区域,甚至让DOS头和PE头重合,以此来实现一些特殊的用途,比如缩小PE文件的体积。
四、NT头部
typedef struct _IMAGE_NT_HEADERS {DWORD Signature;IMAGE_FILE_HEADER FileHeader;IMAGE_OPTIONAL_HEADER32 OptionalHeader;
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;
说明
1. NT头由一个简单标记、一个不算复杂的文件头和一个相对复杂的扩展头构成。
2. 如果是PE文件,这个标记的值一直是0x00004550,对应的ASCII码是 `PE00`,在系统里用宏定义成 `IMAGE_NT_SIGNATURE`。
3. 另外两个成员是结构体,里面存的信息很有用,对这两个结构体进行解析才是真正开始解析PE文件。
使用
1. `Signature` 的作用和DOS头里的 `e_magic` 差不多,都是用来判断文件是不是PE文件的。示例代码如下:
DWORD dwNewPos =(DWORD)pFile+((PIMAGE_DOS_HEADER)pFile)->e_lfanew;
PIMAGE_NT_HEADERS32 pNTHeader =(PIMAGE_NT_HEADERS32)(dwNewPos);
if (pNTHeader->Signature != IMAGE_NT_SIGNATURE){//不是NT头,说明不是PE文件,返回return;
}
2. 因为另外两个成员是结构体,而且比较复杂,所以可以用指针指向这些数据成员,比如 `pFileHeader = &(pNTHeader->FileHeader);` 和 `pOptionalHeader = &(pNTHeader->OptionalHeader);`,然后通过指针来读取信息。
五、文件头区段
NT头的第二个成员是文件头结构体,存储着关于PE文件的一些信息。
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;
说明:
1. Machine:表示文件可运行的CPU平台,如0x014c代表i386(Intel 32位平台),0x0200代表Intel 64位平台,更多信息可查阅相关书籍。
2. NumberOfSections:区段的个数,即PE文件主体被分成的部分,一般有代码、只读数据、数据、重定位等区段。
3. TimeDateStamp:文件创建时间,是32位数值,可使用 `struct tm*gmtime(const time_t*timer);` 函数解析,将 `TimeDateStamp` 地址强制转换后作为参数,用 `tm` 结构体接收得到具体时间。
4. PointerToSymbolTable:一般没用,多为0,后面的符号个数同样用处不大。
5. SizeOfOptionalHeader:扩展头的大小,32位程序中一般是00E0,64位程序中一般是00F0。
6. Characteristics:PE文件属性值很重要,如DLL一般是0x0210,EXE一般是0x010F,不同属性值对应不同含义,可转换为二进制对照相关表查看。
使用:
1. 文件头7个数据成员中,5个有用。
2. 有用的数据成员在PE解析工具中主要用于显示,如LoadPE软件显示了其中4个信息。
示例代码如下:
PIMAGE_NT_HEADERS32 pNTHeader =(PIMAGE_NT_HEADERS32)((long)pFile + ((PIMAGE_DOS_HEADER)pFile)->e_lfanew);
PIMAGE_FILE_HEADER pFileHeader =&(pNTHeader->FileHeader);
`pFileHeader` 所指数据都可显示。
3. 时间转换代码:
tm*FileTime = gmtime((time_t*)&pFileHeader->TimeDateStamp);
`FileTime` 包含时间的所有信息,`tm` 结构体定义如下:
struct tm{int tm_sec; /*seconds after the minute -[0,59]秒*/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*/
};
需注意月从0开始,日从1开始,LoadPE中的时间标志功能可用此实现。
六、了解个别概念
(1)虚拟地址与相对虚拟地址:每个程序都配有4GB的虚拟内存地址。当加载PE文件时,不是直接把文件原样复制到内存里,而是要经过扩充或调整。PE文件无法提供各部分在内存中的准确加载位置,也就是虚拟地址(VA),它给出的是相对于自身起始加载位置的偏移量,即相对虚拟地址(RVA)。虚拟地址的计算公式为VA = 加载基址(ImageBase) + RVA。PE文件有默认的加载基址,这个基址由扩展头的ImageBase成员确定。要是默认加载基址被占用,文件就会被加载到其他地址。
(2)文件偏移:文件偏移指的是文件在磁盘这类存储设备里,各部分相对于文件开头的偏移量,它代表加载前PE文件的位置。在扩展头中,描述PE文件结构位置用的是RVA。当把目标文件读入内存后,需要将RVA转换为文件偏移,具体转换方法会在介绍区段表时详细讲解。
(3)对齐的概念:区块无论是在内存还是磁盘中存放,都需要进行对齐操作,不过内存和磁盘的对齐值不一样。
- 磁盘区块对齐:PE文件头中的`FileAligment`确定了磁盘区块的对齐值。每个区块都从对齐值倍数的偏移位置开始存放,要是区块实际大小不足对齐值的倍数,多余部分会用00h填充,这部分填充区域就形成了区块间隙。举例来说,如果对齐值是200h,第一个区块起始于400h,长度为90h,那么490h到600h就会被00h填充,下一个区块则从600h开始。
- 内存区块对齐:PE文件头的`SectionAligment`规定了内存中区块的对齐值。当PE文件映射到内存时,区块至少要从一个页边界开始。在X86系列CPU中,一页的大小是4KB(即1000h);在IA - 64架构中,一页大小为8KB(2000h)。在X86系统里,PE文件区块的内存对齐值通常为1000h,每个区块都按1000h的倍数在内存中存放。
七、扩展头
typedef struct _IMAGE_OPTIONAL_HEADER {//Standard fields.WORD Magic;BYTE MajorLinkerVersion;BYTE MinorLinkerVersion;DWORD SizeOfCode;DWORD SizeOfInitializedData;DWORD SizeOfUninitializedData;DWORD AddressOfEntryPoint;DWORD BaseOfCode;DWORD BaseOfData;//NT additional fields.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;
说明:
1. 共31个成员,重要的有6个,没用的有11个,还有一个极度重要的数据目录。成员多为记忆性内容,在PE解析器中有选择地显示,重点是数据目录。
2. 扩展头是NT头部的第三部分,紧随文件头结构之后,存储加载文件时的初始化信息,扩展头大小一般为E⁰ 。扩展头也叫可选头,但不是真正可选,而是必须有。
3. `IMAGE_NUMBEROF_DIRECTORY_ENTRIES` 是宏定义,值为0x10,表示一般有16个数据目录。数据目录定义如下:
typedef struct _IMAGE_DATA_DIRECTORY {DWORD VirtualAddress;DWORD Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
数据目录表指示数据存储的相对虚拟地址(RVA)和大小,帮助找到数据,数据真身都在PE文件的区段中。具体每个数据目录的解析涉及相对虚拟地址到文件偏移的转换,后续介绍。先列出数据目录名字与介绍:
- `IMAGE_DIRECTORY_ENTRY_EXPORT`:索引导出表(`IMAGE_EXPORT_DIRECTORY` 结构)。
- `IMAGE_DIRECTORY_ENTRY_IMPORT`:索引导入表(`IMAGE_IMPORT_DESCRIPTOR` 结构数组)。
- `IMAGE_DIRECTORY_ENTRY_RESOURCE`:索引资源(`IMAGE_RESOURCE_DIRECTORY` 结构)。
- `IMAGE_DIRECTORY_ENTRY_EXCEPTION`:索引异常处理程序表(`IMAGE_RUNTIME_FUNCTION_ENTRY` 结构数组)。
- `IMAGE_DIRECTORY_ENTRY_SECURITY`:索引安全结构,不加载入内存,地址成员是文件偏移,不是相对虚拟地址。
- `IMAGE_DIRECTORY_ENTRY_BASERELOC`:索引基址重定位信息。
- `IMAGE_DIRECTORY_ENTRY_DEBUG`:索引调试信息。
- `IMAGE_DIRECTORY_ENTRY_ARCHITECTURE`:版权。
- `IMAGE_DIRECTORY_ENTRY_GLOBALPTR`:全局指针目录,用在64位平台。
- `IMAGE_DIRECTORY_ENTRY_TLS`:指向线程局部存储(`Thread Local Storage`)初始化节。
- `IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG`:载入配置。
- `IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT`:绑定输入目录。
- `IMAGE_DIRECTORY_ENTRY_IAT`:导入地址表。
- `IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT`:延迟载入描述。
- `IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR`:COM信息。
- 还有一个全零的保留目录,一般数组长度为16,文件可拥有更多数据目录,此时扩展头大小不是E0。
使用:
1. 显示扩展头信息可自行决定,LoadPE软件显示了其中12个。示例代码如下:
DWORD dwNewPos =(DWORD)pFile+((PIMAGE_DOS_HEADER)pFile)->e_lfanew;
PIMAGE_NT_HEADERS32 pNTHeader =(PIMAGE_NT_HEADERS32)(dwNewPos);
PIMAGE_OPTIONAL_HEADER32 pOptionalHeader =&(pNTHeader->OptionalHeader);
用 `pOptionalHeader` 指针指向要显示的信息即可。
2. 读取数据目录表信息的伪代码如下:
PIMAGE_FILE_HEADER pFileHeader =&(pNTHeader->FileHeader);
PIMAGE_OPTIONAL_HEADER32 pOptionalHeader =&(pNTHeader->OptionalHeader);
PIMAGE_DATA_DIRECTORY pDataDirectory = pOptionalHeader->DataDirectory;
DWORD i=0;
while(i !=0x10) {//显示pDataDirectory[i].VirtualAddress;//显示pDataDirectory[i].Size;
}
LoadPE的数据目录信息界面可显示数据目录信息,点击基本界面中的目录即可,显示顺序与上述表格一致。数据目录具体解析后续详细介绍,至此NT头内容结束,接下来是区段头表。
八、区段头表
一般称这部分为区段表,叫区段头表可能更合适。回顾PE文件结构:
1. DOS头
2. DOS头用的数据
3. NT头(包括文件头与扩展头)
4. 区段(节)头表
5. 各个区段(节)
区段无需直接解析,区段头表是直接探索的最后位置。区段头表存储PE文件主体的一些属性,由若干个结构体依次排列组成(即结构体数组),每个结构体代表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. 区段头表是由多个这样的结构体组成的,最后以一个全为 0 的结构体结束。
2. 区段名字的规则:
- `.text 段`:通常是代码段,很重要。
- `.data 段`:一般是数据段。
- `.bss 段`:代表未初始化的数据,像 static 变量,可能在进入函数时才会被初始化。
- `.rdata 段`:表示只读的数据,例如字符串。
- `.textbss 段`:和代码有关,但具体作用不太清楚。
- `.idata 和.edata`:存放导入表和导出表的信息。
- `.rsrc 段`:存储资源的区段。
- `.reloc 段`:存储重定位信息的区段。
3.VirtualSize:这个区段在虚拟内存里会用到的总大小,没有经过对齐处理。要注意这是个联合体。
4. VirtualAddress:该区段起始的相对虚拟地址,也就是说这个区段加载时,是以 PE 文件加载基地址加上这个数据成员的值为起始点的。
5. SizeOfRawData:这个区段在磁盘文件里的大小,这个值经过了文件对齐处理。
6. PointerToRawData:区段的文件偏移,也就是这个区段在磁盘文件中的起始位置,之前已经讲过文件偏移的概念。
7. Characteristics:这一区段的属性,属性的具体值可以参考《黑客免杀攻防》121 页。
8. 这个结构体一共有 10 个成员,其中 4 个没什么用,有用的是 6 个。这个结构体的大小是 40 字节,也就是 0x28。
使用方式
1. 虽然区段头表就在 NT 头的后面,但是找它还是有点麻烦。系统提供了一个宏可以方便地找到它的位置,即 `IMAGE_FIRST_SECTION(pNTHeader)`,参数是 NT 头的指针。
2. 用于相对虚拟地址(RVA)和文件偏移(Offset)的转换,这是解析数据目录表的基础。
在之前提到的那些结构里,有很多相对虚拟地址(RVA),实际上这些地址都在 PE 主体的某个区段中。但当我们想在文件里找到对应的位置时,不能直接用相对虚拟地址(RVA),之前说过需要进行转换。这里给出转换的方法:
要转换的相对虚拟地址肯定会落在某个区段中,这时我们要看看它落在了哪个区段,就要分别和各个区段起始的相对虚拟地址(RVA)作比较(也就是和 `VirtualAddress` 比较)。如果落在了某个区段中,就用要转换的相对虚拟地址减去这个区段起始的相对虚拟地址,得到这个地址相对于该区段的偏移。然后用这个偏移加上区段在文件中的起始位置,也就是 `PointerToRawData` 成员的值,就得到了要转换的相对虚拟地址在磁盘文件中的位置,也就是文件偏移。
用公式表示就是:`Offset(转换) = RVA(转换) - RVA(区段) + Offset(区段)`
这里给出一个转换函数,这个函数就是上面这段话的代码形式。同样假设已经把目标文件读入内存,首地址是 `pFile`,是 `void` 类型的指针,可以把 `pFile` 看作一个全局变量,实际上在编写工具时,它是 PE 处理类里的一个数据成员。
DWORD CalcOffset(DWORD Rva) {//1获取NT头PIMAGE_NT_HEADERS32 pNTHeader =(PIMAGE_NT_HEADERS32)((long)pFile + ((PIMAGE_DOS_HEADER)pFile)->e_lfanew);//2获取区段头表PIMAGE_SECTION_HEADER pSectionHeader = IMAGE_FIRST_SECTION(pNTHeader);//3循环比较它在哪一个区段中,不在这个区段就继续循环,注意假如VirtualAddress比SizeOfRawData大的话,说明大出来的部分是未初始化的数据,所以这里用要用SizeOfRawDatawhile(!(Rva >= pSectionHeader->VirtualAddress && Rva < pSectionHeader->VirtualAddress + pSectionHeader->SizeOfRawData)) {++pSectionHeader;//防止错误的PE文件引发崩溃。if(pSectionHeader->PointerToRawData == 0)return 0;}return Rva - pSectionHeader->VirtualAddress + pSectionHeader->PointerToRawData;
}
LoadPE中有一个功能(位置计算器),用这个函数就可以实现。`VA`是用镜像基址+`RVA`。偏移量,可以用上面的方法算出,附加信息也可以在之前那个函数中改动一下,可以一起查找到。
3. 区段信息的循环获取,其实和上面的代码非常像,只是在循环体中输出数据即可。
void ViewSectionInfo() {//1获取NT头PIMAGE_NT_HEADERS32 pNTHeader =(PIMAGE_NT_HEADERS32)((long)pFile + ((PIMAGE_DOS_HEADER)pFile)->e_lfanew);//2 获取区段头表PIMAGE_SECTION_HEADER pSectionHeader = IMAGE_FIRST_SECTION(pNTHeader);//3循环输出,区段表的信息,可以用两种方式判断结束,一个是文件头给出了区段的数量,可以用那个,也可以判断最后一个全零的区段头while (pSectionHeader->PointerToRawData) {//输出或者获取每一个pSectionHeader中的成员++pSectionHeader;}
}
区段表上显示的名称对应叫区段名称,voffset 对应叫起始相对虚拟地址,VSize对应叫加载后的区段大小,RDffset对应叫文件偏移,RSize对应叫文件中的区段大小,特征对应是区段的标志属性。
上面介绍的就是PE文件所有头部信息,接下来讲讲PE文件的主体部分。当PE文件主体加载到内存时,会依据区段表,一块一块地进行加载。多数情况下,我们无需直接处理区段,因为PE文件的关键信息都包含在文件头里。借助文件头的引导,我们就能对主体中的数据展开解析。像之前未深入分析的数据目录表,后续将重点解析导出表、导入表、资源表以及重定位表等数据。