8. PE文件格式

一、前言

PE是Portable Executable File Format的简称,它是Windows系统上主要的可执行文件格式。

我们常看到的.exe.dll等程序,就是这种格式的。

PE格式也有32位与64位的区别,但64位并没有引入新的结构,只是简单的将原本的32位字段扩展为了64位而已。

一个PE文件的结构大致如下图:

image-20231106102635547

注意是从下往上看的。

二、基本概念

PE格式的文件本身也是一个文件,和你看到的.txt文件是一样的,同样存储在硬盘上,并且就放在一个.exe文件内。

所以很自然的,我们就可以得出结论,我们在代码中写的所有代码与数据,都会被统一按一定格式放入这一个文件中。

放在一起就很容易出问题,比如你解析的时候,这都是一堆二进制数据,你怎么知道这是代码还是数据呢?

所以PE格式的作用就出现了,它会将不同类型的数据存放在不同的块中,也就是上图的Section,也被称为区段、节等。

为了方便解析,每个块的大小都是大小的整数倍,这里的大小是当前系统分配的,我们不用管,其目的是为了提高系统检索数据的速度。

也正因如此,各个块之间是首位相连的,因为它们占用的大小实际上就是占用多少个页大小,比如页大小为4kb,那么它们的开始位置与结束位置都很容易被计算出来。

同时为了能够判断出某个区块中数据是什么类型,每个区块都会进行记录,比如该区块是否包含代码,是否只读,或者可读可写。

然后当你双击这个可执行文件时,系统就会按照其存储的顺序,将其载入内存中去:

image-20231106104400541

正如上图所示,它并不会直接将其复制进内容,而是会调整一下各个区块的大小后,再填入内容,至于各个区块之间多出来的数据,则会用0填充。

这样做的原因是硬盘的页大小与内存的页大小并不完全相同,为了提高速度,内存的页大小比硬盘的页大小要大。

所以总体看上去,就是将原本的数据‘拉伸’了一下,但数据本身并没有发生变化。

一般PE文件被载入内存后,就被称为了模块(Module),而模块句柄,实际上就是文件被载入内存后的起始地址。

在编程中,我们可以通过这个句柄访问其内存结构数据,而这个起始地址,也被称为基地址(ImageBase)。

在代码中,我们可以通过函数GetModuleHandle来获取指定名称的模块句柄,如果传入NULL,则是获取当前可执行文件的模块句柄。

值得注意的是,虽然这个起始地址,也就是基地址会有一个默认的地址,一般为0x400000h,而dll一般会被默认加载到地址0x10000000h,但这个地址是可以被改动的。

所以一般我们逆向数据的时候,并不会去寻找某个数据、函数的具体地址,而是会去找它相对于基地址偏移了多少。

比如一个数据的地址为0x401000h,那么此时它偏移基地址的距离为0x1000,那么这个偏移的地址才是我们需要记录的。

因为基地址无论怎么变,我们都可以直接通过一些函数拿到,然后就可以通过这个偏移地址来计算出数据的真实位置。

这个相对地址,或者说是偏移量,就被称为:RVA(Relative Virtual Address),即相对虚拟地址。

与之对应的则是RAW(raw offset)或者说File offset,即物理地址,也就是你直接用十六进制编辑器打开一个可执行文件,看到数据的地址就是它的文件偏移地址。

三、PE格式

从前面的图可以看出来,PE格式是由很多部分组成的,所以下面我们就来简单介绍一下各个部分的作用。

1.MS-DOS

最前面的就是这个MS-DOS头部,这是远古时期的产物了,对于我们分析32位、64位程序来说,只用的到它内部的两个结构:

  • e_magic:标识这是一个dos头,固定为‘MZ’
  • e_lfanew:指向PE头的位置

在vs中我们可以直接使用这个结构体:

image-20231106123416011

这里的IMAGE_DOS_HEADER结构体就是DOS结构体,我们这里直接将文件头读取到这个结构体对象中,然后打印出来这两个属性。

由于这是用的是十六进制打印,MZ两个字母对应的就是这两个十六进制数字:5A、4D。

而下面的110则是将要介绍的PE结构体的开头所在位置。

我们直接通过十六进制编辑器打开这个文件,也能直接找到这两个数据:

image-20231106123750212

这里存在顺序的问题,由于是小端序,高位放在高地址,低位放在低地址,所以5A放在4D后面,01放在10后,转换成数字之后你需要自己将其转换一下,而在代码中,编译器会自动将其转换,所以无需我们手动操作:

#include<Windows.h>
#include<iostream>
#include<fstream>
using namespace std;

int main()
{
	IMAGE_DOS_HEADER dos;
	ifstream f("TraceMe32.exe");
	f.read((char*)&dos, sizeof(dos));
	printf("%X\n", dos.e_magic);
	printf("%X", dos.e_lfanew);
}

2.PE头

PE头才是我们研究的重点,上面的DOS头如今一般只是用来定位这个PE头位置的。

当操作系统执行PE文件时,也是通过imageBase+dosHeader->e_lfanew计算得到PE结构所在位置的。

#include<Windows.h>
#include<iostream>
#include<fstream>
using namespace std;

int main()
{
	ifstream f("TraceMe32.exe",ios::binary);
	f.seekg(ios::end);
	int len = f.tellg();
	f.seekg(ios::beg);
	char* buf = new char[len];
	f.read(buf, len);
	//上面将数据全部读取buf内
	IMAGE_DOS_HEADER* dos = (IMAGE_DOS_HEADER*)buf; //更改其访问方式
	int pe_pos=dos->e_lfanew; //得到PE格式的位置
	IMAGE_NT_HEADERS32* pe = (IMAGE_NT_HEADERS32*)(buf + pe_pos);//得到PE结构
}

大致逻辑如上:

作者:余识
全部文章:0
会员文章:0
总阅读量:0
c/c++pythonrustJavaScriptwindowslinux