首页
社区
课程
招聘
[原创]PE文件结构基础详解
发表于: 2023-6-18 14:43 25445

[原创]PE文件结构基础详解

2023-6-18 14:43
25445

逆向是一个长期的过程,不积硅步,无以至千里;不积小流,无以成江海。在学逆向的前期,初学者都有一个绕不开的东西,那就是PE文件结构,这东西不难,但是内容有点多,这一篇文章面向新手,就先讲讲PE文件的整体结构。我文章中如有错误,请大佬指正!

PE文件是可移植的可执行的文件,什么是可执行文件?可执行文件是为进程创建所服务的,进程在运行之前,需要将该进程所需要运行的代码、该进程支持的相关数据等一个进程创建所必须的信息以某种格式存储在磁盘中,而这种格式就是可执行文件格式。简单来说就是在进程创建之前会将该进程创建所需要的信息以可执行文件格式存储在磁盘中,同样也可以说进程创建所需要的信息会在可执行文件中详细记录。有了该进程的详细记录,那么操作系统在创建进程的时候就可以根据该进程的详细记录来创建进程,也就是说可执行文件是进程创建的前身。但可执行文件和进程不能完全划等号,因为进程是动态的,是会根据你的操作变化而变化的;而可执行文件是静态的,是没有执行权、是死板的、是不会根据你的变化而变化的。而可移植的是因为微软在早期的一个战略目标,他想要达到只要是PC端就会有他们的影子,可以见到当时微软的野心是十分的大的,所以想要在任何平台上都可以运行那可移植性就十分重要了!

我们来看一下PE的整体结构,具体的内容下面会逐一的讲解,特别提醒:该图的PE结构是从下往上看。

这里还讲一下其他的东西:附加数据;附加数据不属于PE文件结构中,但有的PE文件中会有存在,附加数据是干什么的呢?主要是为了省事,当PE文件执行某件事的时候需要用到一些相对重要的数据,但又不想搞两个文件,就将那些相对重要的数据写在了PE文件的附加部分(PE结构的后面),当需要使用附加数据的时候就跳转到那PE结构的后面执行附件数据相关的代码,执行完成后再跳转回去。

DOS头由DOS-MZ文件头和DOS块组成,我们一个一个来讲解。

我们先来看一下DOS-MZ文件头的结构:

这上面的结构初学者可能看不懂,这里简单的解释一下结构体,结构体可以通过基础类型来构造,常见的基础类型有整形、浮点型、字符型等,我们可以通过这些基础类型作为基石来搭建一个新的结构体,这个新的结构体也可以作为一个新的类型来使用;我们再来讲讲什么是DOS文件头;DOS文件头是为了兼容DOS系统所遗留下来的产物,如果使用的不是DOS系统,其实DOS头只有两个数据是比较重要的。

一个是e_magic,大小为word(两个字节),作用是表示DOS 可执行文件的标识符,占用 2 字节,该位置保存着的字符是“MZ”。如果新手不是很理解也没有关系,你可以想象,当你出门在外的时候,这时你看到附近停了一辆车,你瞄了一眼看了下车标,说了句:“哦,原来是这个牌子的车啊!”,而这个“MZ”字符就类似于车标的作用,是用来标识这个文件的。

另外一个是e_lfanew,大小为long(四个字节),这里存有PE 签名的文件偏移量,可以指出PE文件头的位置,至于PE文件头等下就讲。

DOS-MZ文件头简单的介绍完了,这里也顺带说一下DOS块,DOS块中存储的是链接器填充的信息,不重要,大小也不确定。讲实话如果不是使用DOS系统的话那么DOS块对文件没有什么影响的,可以不用管这一块大小不确定的DOS块,也可以用来填充你的代码或者数据。

我们来看一下十六进制模式下的PE文件,这里使用的工具是C32Asm_v2.0.1.0,爱盘中可以直接下载压缩包。

先把PE文件丢进C32Asm,DOS-MZ文件头大小为64字节,WORD为2字节,DWORD为4字节,LONG为4字节,也可以算算上面的_IMAGE_DOS_HEADER结构体大小是不是64字节。

我们看下图:

我所划出来的部分正是DOS-MZ文件头,一行16字节,一共64字节,正好4行;我们先来看一下DOS 可执行文件的标识符,它为_IMAGE_DOS_HEADER结构体的第一个变量e_magic,长度为word,那么我们往左看去可以看到位于第一行的前两个字节4D 5A,然后再看向右边的窗口可以看到第一行数据的前两个字符为“MZ”,之前讲过e_magic当中存放着DOS 可执行文件的标识符,占用 2 字节,该位置存储的字符就是“MZ”。之前也说过DOS-MZ文件头当中只有两个变量比较重要,讲完了第一个变量e_magic,接下来讲第二个重要变量e_lfanew,它位于DOS-MZ文件头的最后四个字节,也就是位置0x3c,可以看上图所选定的DOS-MZ文件头的最后四位字节为00 00 00 B0,你问我为什么前面的DOS 可执行文件的标识符是正着读,而这里是倒着读,我只能告诉你这是因为内存地址在内存中存放的时候是一个反序或者说是一个倒序,貌似这些DOS 可执行文件的标识符、PE签名什么的都是正着读,或许只是正着读比较顺口,数据还是以一个倒序的方式存储的。我对此不是很清楚,评论区有没有大佬出来讲解一下呢?作为新手就先当在十六进制模式下看变量中存储的数据是倒着看的吧!我们省去前面一大串0后值为0xB0,根据这个相对地址去找到对应的位置,我们直接看0xB0位置的值为50 45 00 00,这四个字节为PE签名,用于将该文件标识为 PE 格式图像文件。总之e_lfanew变量中存储的这四个字节为 PE 签名的文件偏移量,此信息使Windows能够正确执行映像文件。此签名是“PE\0\0” (字母“P”和“E”,后跟两个 null 字节) 。

PE文件头由三部分组成,分别是PE签名、COFF 文件头(又称‘标准PE头’)、可选标头(又称‘扩展PE头’)。而且PE文件头自身也是一个结构体,我们来看一下(下方展示的为32位对应的PE文件结构体):

我们可以看到上面这个结构体由三部分组成,第一部分PE签名在讲DOS头的时候讲过,是用于将该文件标识为 PE 格式图像文件。我们可以从上面清楚的看到PE签名类型为DWORD,大小为四字节,存储在signature变量当中。第二个变量FileHeader是标准PE头,类型为结构体IMAGE_FILE_HEADER,听到这里新手可能会很诧异,为什么结构体也可以用来充当类型定义变量?这里你可以想一下,变量为一种容器,类型可以用来定义这个容器的结构,但基础的结构有限,当处理某些比较麻烦的数据时可能会出现基础的结构不好用了,用起来比较麻烦了,那这时候就出现了用多个基础的类型结构组成的结构体类型,这样变量存储数据时能定义的类型就变得灵活多变了。

我们来看一下标准PE头的结构:

看完上图有的可能会让新手很懵逼,没事,我们慢慢来讲。首先上方的结构体总大小为20字节,其中只有四个变量值得注意,分别是Machine、NumberOfSections、SizeOfOptionalHeader、Characteristics。我们先来讲Machine,作用是标识目标计算机类型的数字,什么是标识目标计算机类型的数字呢?其实就是说明PE文件只能在哪些CPU类型或模拟指定计算机的系统上运行。先来看一下官方给出的计算机类型表:

上面这张图看不懂没关系,我们找一个PE文件来讲解一下怎么用这张图来看PE文件适用的CPU类型。

看上图,所划的区域为标准PE头,你可以看到所划的区域前面为50 45 00 00,这正是PE签名,PE签名后面的二十个字节就是标准PE头。我们看前两个字节就是大小为WORD的Machine,这里记得要倒着看,即为01 4C,我们拿着014C去上面的计算机类型表去寻找解释,可以在value列看到0x14c以及对应的说明。

讲完了Machine,我们接着讲NumberOfSections,大小为两字节,作用是表示PE文件的区段数量。还是看上图,Machine后面的两个字节为00 04,说明此PE文件的区段数量为4,我们打开PE查看工具来看看是不是四个区段:

使用PE文件查看工具可以看到区段数量为4个,与NumberOfSections当中存储的区段数量相同。

讲完了NumberOfSections,我们接着讲第三个重要变量SizeOfOptionalHeader,作用是表示可选标头的大小,这是可执行文件所必需的。看上图所划的区域内第17、18个字节00 E0,我们将E0换算成10进制为224,这里可能有新手要问进制是怎么转换的?其实为了省事一般都会用工具去转换进制,如果在没有工具的情况下需要进制转换,自己虽然算起来比较麻烦,但是也是没有办法的事情,这里只讲一下16进制转换10进制,不做过多的延伸,感兴趣的可以自己去了解一下。这里用的方法说起来也非常的简单,假设从末尾开始为第0位,每往前位置就加一,那么就从末尾开始用每一位的数去乘以16的n次方,这个n为这个数的位置。比如E0转化十进制就为E乘以16的1次方+0乘以16的0次方=14乘以16+0乘以1=224。补充一下知识点,十六进制是逢十六进一,十六进制由0、1、2、3、4、5、6、7、8、9、A、B、C、D、E、F组成。SizeOfOptionalHeader变量中的数据对于32位PE文件通常为00E0h,对于64位PE文件通常为00F0h。这里的h是用来表示这个数据为16进制。上图我们可以看到SizeOfOptionalHeader变量当中的数据为00 E0,扩展PE头大小为224,文件为32位PE文件。

最后一个重要变量Characteristics,作用为指示文件属性的标志。这是什么意思呢?我们看上图所划区域内最后两个字节01 03,即为103,这里我们需要将这个值转换为二进制,十六进制怎么转换为二进制呢?十六进制数的每一位都可以转换为四位二进制数,比如3可以转换为0011,1可以转换为0001,那么103的二进制就呼之欲出了,那就是0001 0000 0011,这里就不详细讲二进制了,不清楚的可以去网上搜索二进制。将103成功转换成二进制后,我们需要来看下面这张表,它会告诉我们这个PE文件有哪些属性。

我们将103的二进制0001 0000 0011中为1的位置第0位、第1位、第8位对应上图的数据位为1时的含义,得出‘文件中不存在重定位信息’、‘文件是可执行的’、‘只在32位平台上运行’这三个信息。

我们基本了解了标准PE头,标准PE头中的SizeOfOptionalHeader用于标识扩展PE头的大小,请注意,扩展PE头的大小未固定。每个PE文件都有一个扩展PE头,用于向加载程序提供信息。 此标头是可选的,因为某些文件 (具体来说,对象文件) 没有它。 对于PE文件,此标头是必需的。 对象文件可以有一个可选的标头,但通常此标头在对象文件中没有函数,只是为了增大其大小。

此外,请务必验证可选的标头 magic 编号,以确保格式兼容性。可选的标头 magic 用于确定PE文件是 PE32 还是 PE32+ 可执行文件。

可选标头本身有三个主要部分:

在讲扩展PE头的标准字段之前,我们先简单讲一下什么是COFF:COFF是通用对象文件格式,而通用对象文件格式是指可执行文件(映像)和对象文件 32 位编程的格式,该格式可跨平台移植。这里要注意一下,PE文件是可移植的可执行的文件,属于COFF当中的可执行文件(映像)的格式。之前之后所讲的对象文件就是COFF当中的对象文件 32 位编程的格式。

扩展PE头的前八个字段是为每个 COFF 实现而定义的标准字段。 这些字段包含用于加载和运行可执行文件的常规信息。 对于 PE32+ 格式,它们保持不变。

上图中有两个字段来标识已初始化数据大小和未初始化数据大小,那已初始化数据和未初始化数据有何区别呢?听我娓娓道来:

已初始化数据其实就是定义了且成功赋值的变量;下面拿C语言做例子,例如:int a=6;这句C语言代码中变量a就是一个已初始化数据,变量a已被定义为整形且将6赋值给变量a。可执行文件中必须记录已初始化数据的初值。未初始化数据其实就是定义了但是没有被赋值的变量;例如:int a;这句C语言代码中变量a就是一个未初始化数据,变量a已被定义为整形但是没有被赋值。可执行文件中不用记录未初始化数据的初值,只用统计出有多少个未初始化数据,给它预留相应的空间即可。

上图中的字段除去Magic字段和AddressOfCode字段,其余字段仅供参考,为什么是仅供参考呢?是它们不准确吗?并不是,而是那些字段被修改不会对PE文件运行产生影响,所以想怎么修改就怎么修改,如此这般就只能仅供参考了!

PE32 包含此附加字段,此字段在 PE32+ 中不存在,遵循 BaseOfCode。

接下来的 21 个字段是 COFF 可选标头格式的扩展。 它们包含链接器和加载程序在 Windows 中所需的其他信息。


这张表实在是太长了,我就分成了两张表,新手可能有很多东西不是很明白,我挑几个新手可能容易不清楚的来讲。首先是第一个字段ImageBase,图中讲加载到内存中的第一个PE文件字节的首选地址;必须是 64 K 的倍数。这里64k换算成十六进制是10000,64k的十进制大小是65536,可以去用进制转换器去试试看,这里提一下加载到内存中的地址是以十六进制的格式存储的,所以可以看到那些默认的加载到内存中的第一个图像字节的首选地址都是10000的倍数。所以可以看到加载到内存当中PE文件的第一个字节的首选位置是10000的倍数。

上图中应该有个让新手摸不着头脑,那就是内存对齐,什么是内存对齐?为什么要内存对齐?

内存对齐是一种空间换时间的手段,进程空间当中的对齐大小默认值为系统页面大小 0x1000 B(4KB),在磁盘中存储时的对齐大小默认值为磁盘页面大小 0x200 B(512B),现在也有不少软件的磁盘页面大小为0x1000 B(4KB)。说了这么多那为什么要内存对齐呢?先用个生活的例子来比喻一下:你有几个用来装东西的盒子,但是你不爱整理,买了什么东西、得到了什么东西你一股脑的往盒子里丢,第一个盒子塞满了就塞第二个盒子。有一天你闲来没事,打算去翻翻之前买的书看,可是看到那盒子中的一大堆东西,你只能咬牙一个一个翻出来看是不是你要的东西,你找了好久终于找到了,从此刻你也认识到了这样乱装东西十分的麻烦,就打算改掉这个毛病,自此以后你将第一个盒子装书、第二个盒子装某某类型玩具……可是你发现诺大的第一个盒子里只装着寥寥几本书籍,而装玩具的盒子已然很多了,这个盒子放这类玩具,那个盒子放那类玩具,整理完后发现每个盒子都还有一大片空闲的空间,但是为了以后方便拿东西也只能如此了。

计算机读数据就类似于拿东西,如果没有内存对齐机制,数据可以任意存放,假设计算机读取内存数据以4字节为单位,有一片从0地址开始,大小为8字节的空间,我们要读取的数据存放在从地址1开始的连续四个字节地址中。计算机去取数据的时候,要先从0地址开始读取一个大小为4的字节块,然后去除不想要的字节(地址为0的字节);再然后从地址4开始读取下一个4字节块,同样去除不想要的字节(地址为5、6、7的字节),最后将留下来的两块数据进行合并得到要读取的数据。以这种方式进行读写数据是十分麻烦的,所以就出现的内存对齐这种解决方法。

其实内存对齐就好像上面的比喻将物品进行分类,一个盒子装一类物品。而计算机将数据以某种格式进行分划,被划分的每一组数据占据一页或多页空间,每组被划分的数据起始位置必须符合一定的要求。

看到这里你就发现当没有内存对齐了,计算机读一个大小为4字节的字节块,然后取出要读取的数据就好像上面故事中你抽出一个未进行分类的盒子,从中找出你要的东西;当你的东西装在了两个盒子里,你需要对两个盒子进行拿出、翻找、放回三个步骤,然后将找到的东西放在一起,这就好像计算机对两个大小为4的字节块进行读取、筛选、写入三个步骤,然后将要读取的数据合并。

这下你应该知道什么是内存对齐,为什么要内存对齐了吧!

再往后看,MajorOperatingSystemVersion、MinorOperatingSystemVersion、MajorImageVersion、MinorImageVersion、MajorSubsystemVersion、MinorSubsystemVersion、Win32VersionValue这些七里八里的版本号中除了子系统的版本号不可以修改之外,其他的版本号都可以进行修改。

在讲解SizeOfImage字段和SizeOfHeaders字段之前,得先提一嘴PE文件从磁盘映射到内存,这样就更好理解这两个字段。

PE 文件在执行的时候,映射到内存中的结构布局与该文件在磁盘中存储时的结构布局是一致的。Windows 装载器(又称 PE 装载器)在载入一个 PE 文件时,把磁盘中的 PE 文件映射到进程的内存地址空间,是一种从磁盘偏移地址到内存偏移地址的转换。

这是什么意思呢?之前讲SectionAlignment字段和FileAlignment字段时,有的PE文件进程空间中的对齐大小和磁盘中的对齐大小有区别,进程空间当中的对齐大小默认值为系统页面大小 0x1000 B(4KB),在磁盘中存储时的对齐大小默认值为磁盘页面大小 0x200 B(512B),带着这个信息看下图就清楚了:

看完上图你应该明白了PE文件映射了吧!那我们就开始讲SizeOfImage字段和SizeOfHeaders字段。

SizeOfImage字段是告诉你,将PE文件装载到内存(进程空间)中占多大,看上面关于PE文件映射的图,可以看到进行内存对齐的部分分为两类,第一类是DOS头和PE文件头以及节表,它们在进程空间中作为一部分一起按照SectionAlignment的对齐值进行对齐;比如DOS头和PE文件头以及节表凑一块的大小是1050字节,SectionAlignment的对齐值是1000字节,则DOS头和PE文件头以及节表它们一起占2000字节;第二类就是区段,区段在讲标准PE头的时候就有记录PE文件的区段个数的NumberOfSections字段了,每个区段独享一段SectionAlignment的对齐值大小的内存,与上同理。

我们来看个实例:

上图中所划的区域是该PE文件的SizeOfImage字段,该PE文件在进程空间中的大小为000BD000,再根据之前学的看SectionAlignment字段中存储的内存对齐值是1000,再看区段数量为4,我们将该PE文件载入OD看看效果:

第一个加载到内存的字节地址为004000000,PE文件头对齐后大小为1000大小,这里讲的PE文件头包含DOS头、PE头和节表。这个PE文件一共有四个区段,分别是.text、.rdata、.data、.rsrc,它们每个区段独享一块空间,空间大小经过对齐皆是1000的倍数,这五块空间加起来的总大小为000BD000。

接下来讲SizeOfHeaders字段,简单来说就是磁盘空间中PE文件头对齐后的大小,这个作为实例的PE文件的磁盘对齐大小为1000,该PE文件磁盘空间中PE文件头实际大小小于磁盘对齐值,所以该SizeOfHeaders字段的数值为1000。到现在新手应该容易理解这个吧?

我们接着讲下一个字段,作用是PE文件校验和。啥意思呢?简单来说就是可执行文件的数据通过校验得到一个四字节的校验值就会存储在该字段里面。你可能会问这个校验是校验什么呢?这个字段是校验PE文件是不是系统文件、在Windows下是不是一个驱动,如果是那就会用到这个字段,否则这个字段没用。

下一个字段是关于Windows子系统的字段,直接看下图:

还是看那个作为实例的PE文件,它这个字段记录的值为2,根据上图可以得出该PE文件是运行在Windows图形用户界面子系统。

下个字段DllCharacteristics,直接看下图对该字段的定义:

上图中的值这一列的1、2、4、8在该字段中存储的值皆为0,保留着,现在还不存在定义信息。

后面的四个字段是关于堆栈的保留和提交每个字段占4或8个字节大小,我们还是看那个实例程序:

上图所划区域为栈保留、栈提交、堆保留、堆提交这四个字段,因为该PE文件为32位PE文件,所以每个字段占4字节。新手可能会有点疑惑,什么是堆?什么是栈?这两个都是数据结构,简单讲一下,栈是一种线性数据结构,它只允许在一端进行插入或删除操作,先插入的数据最后出来,就好像一个装东西的盒子,先装入的东西会被后装入的东西压在底下,先要拿出先装入的东西必须先把压在它上面的东西给拿出来。

而堆是计算机科学中一类特殊的数据结构的统称。堆通常是一个可以被看做一棵树的数组对象。堆总是满足下列性质:

堆总是一棵完全二叉树。其实就是从第一个开始从左到右一排一排的看,直到看到最后一个没有断、没有空隙,那就是完全二叉树。

那什么是保留?什么是提交呢?保留就是最多可以占用多少空间,提交就是现在有多少空间可以立即使用。换个说法,保留就是最多有多少钱可以用,提交就是手上还有多少钱可以由自己支配。提交的空间会动态增加,就是开始手上的钱用完了,就去银行取。

这个值可以改吗?可以但得合理。用户内存也就是进程空间总共就4G,操作系统占2G,这2G还有头部保留,还有动态链接库要占位置,你要改也要给得出来空间才行。

扩展PE头Windows-Specific字段基本也讲解完了,这里有个NumberOfRvaAndSizes字段,它是用于记录数据目录表的个数,而数据目录表是接下来的一个重点!!!

每个数据目录都提供 Windows 使用的表或字符串的地址和大小。 这些数据目录条目全部加载到内存中,以便系统可以在运行时使用它们。 数据目录是具有以下声明的 8 字节字段:

第一个字段 VirtualAddress 实际上是表的 RVA。 RVA 是加载表时相对于映像基址的表地址,也就是在内存中相对于映像基址的偏移地址。 第二个字段提供大小(以字节为单位)。 下表列出了构成可选标头的最后一部分的数据目录。

请注意,目录数不是固定的。 在查找特定目录之前,请检查可选标头中的 NumberOfRvaAndSizes 字段。

问:导入表是干什么的?

答:当我们PE文件运行的时候,需要调用一些外部接口,导入表就是记录导入的那些外部接口的。

问:调用外部接口的大概流程是怎么样的?

答:当操作系统装载可执行文件的时候,首先会分析可执行文件需要哪些动态链接库,然后会分析每个动态链接库需要哪些函数,当把这些函数加载进内存后,会把这些函数加载到内存的所在地址填到操作系统和编译器约好的位置。在约好的地方填写完所需函数的加载地址后,当编译器在编译产生代码的时候要调用操作系统函数或者调用第三方函数时就会到约好的地方去间接的访问所需函数的地址,这样就完成了一次外部接口的调用。

问:编译器间接调用所需API地址是怎么样的?

答:如下图所示,7743F9C0便是编译器间接调用所需API地址:

问:操作系统与编译器约好存放API地址的位置是什么?

答:IAT(import address table/导入地址表)

问:操作系统怎么知道在与编译器约好的地方填入什么API?

答:这是一个数据关系问题,如果要将动态链接库中的API填入到操作系统与编译器约好的地方,我们就得想明白操作系统需要将哪些API填入到与编译器约好的位置。首先API被包含在动态链接库当中,API和包含API的动态链接库之间是一种一对多的数据关系,就好像是大学专业和大学生之间的关系,一个大学生只能选择一个专业,一个专业可以包含多个大学生。弄明白了动态链接库和API之间的数据关系,那我们还得理清楚在此种数据关系下怎么样才能有效的填入API;想要有效的填入API我们就得告诉操作系统需要哪些动态链接库,动态链接库当中又需要装载哪些API,那么我们就可以通过动态链接库信息表来查找PE文件需要调用动态链接库中的哪些API。所以当操作系统装载可执行文件的时候,首先会分析可执行文件需要哪些动态链接库,然后会分析每个动态链接库需要哪些函数,最后只要把需要装载的API往操作系统和编译器约好的地方填就完事了。

问:导入表的基本结构?

答:

上图看起来很蒙,别急,听我慢慢道来:

首先我们已知数据目录中记录的导入表位置为相对于可选标头起始位置偏移104字节,数据目录中存储导入表RVA和大小的结构体大小为8字节,如下图所示:

此处记录的导入表大小仅供参考,不能盲目信任。

我们根据数据目录中记录的导入表的RVA跳转到92000位置,导入表是由一个或多个IMAGE_IMPORT_DESCRIPTO结构(上面那张导入表结构图最左边的结构体)组成的,每个IMAGE_IMPORT_DESCRIPTO结构总大小为20字节,每个IMAGE_IMPORT_DESCRIPTO结构记录一个需要导入到内存的动态链接库的信息,当IMAGE_IMPORT_DESCRIPTO结构为零时表示导入表结束,所以我们可以通过IMAGE_IMPORT_DESCRIPTO结构是否为零来判断导入表是否结束。

如上图所示,每一种颜色表示导入表中一个需要导入到内存中的动态链接库的信息,只标了四个是因为加载到内存中的动态链接库太多了,总之记住当IMAGE_IMPORT_DESCRIPTO结构为零时表示结束;这个结构体由五个字段组成,每个字段各4字节大小,其中比较重要的是第一个字段——导入名称表、第四个字段——动态链接库名称、第五个字段——导入地址表。

导入表大概流程:

编译器在编译过程中会把所需要的动态链接库和动态链接库当中的API存储到导入表当中,编译完成后,运行PE文件时,操作系统会将PE文件加载到内存中,会先定位到动态链接库信息,然后获取动态链接库名称,再使用LoadLibrary()这个系统API去动态加载这个动态链接库到内存当中,成功加载动态链接库后,接着访问导入名称表字段提供的RVA。我们跟着红线所划的IMAGE_IMPORT_DESCRIPTO结构导入名称表字段所记录的RVA,去看看导入名称表的模样:

我们来到了RVA为92274的位置,这里的数据是非常多的,我们该怎么下手呢?其实很简单,以每四个字节为一组指向该动态链接库当中一个需要加载到内存的API名称所在位置的RVA,在跟这个RVA之前,我们先来看看这个导入名称表每一组的结构:

这是官方给出的导入名称表每一组存储API名称的RVA数据的结构图,导入查阅表格就是导入名称表。导入名称表为PE32格式,那么会将导入名称表中每一组数据和0x80000000进行与(and)运行,以判断最高位是零还是1;PE32+结构则是和0x8000000000000000进行与运算,当导入名称表中一组数据的最高位为1时便按照序号字段进行导入,最高位为零时按照名称导入。

接下来我们跟一个过去看看,就跟着第一个9392C去一探究竟:

上图所划的区域是一个需要导入到内存的API名称,这个名称是IMAGE_IMPORT_BY_NAME结构,我们来看一下IMAGE_IMPORT_BY_NAME结构:

看完IMAGE_IMPORT_BY_NAME结构,我们再去看导入的API名称,就可以看到前两个字节为编译时函数序号,操作系统不参考,可以随意修改,而后面的字节十分重要,为导入函数的名称。

我们获取到该动态链接库中一个要加载到内存的API名称后,将该API加载到内存中,通过GetProcAddress()系统API来获取该API加载到内存的地址,那这个地址存放在哪里呢?那就需要读取IMAGE_IMPORT_DESCRIPTO结构最后一个字段——导入地址表,我们还是跟着红线所划的IMAGE_IMPORT_DESCRIPTO结构导入地址表字段所记录的RVA,去一探究竟:

看上图,当获取到API加载到内存的地址后,会访问IMAGE_IMPORT_DESCRIPTO结构导入地址表字段所记录的RVA,然后将API的加载地址存放在此处。之前说过IMAGE_IMPORT_DESCRIPTO结构导入名称表字段所记录的RVA,每四个字节为一组指向该动态链接库当中一个需要加载到内存的API名称所在位置的RVA,而导入地址表也是以这种格式来存储API加载地址,每四个字节记录一个API加载到内存的地址,最后一条记录设置为零以标识表的结尾。当该API加载到内存中的地址填入到导入地址表后,继续循环下一个API名称,通过它将指定API加载到内存中来,直到值为零时,这个动态链接库里需要加载到内存的API就全部加载完毕。接下来就会到下一个动态链接库去执行这一套流程。

节表是用来描述数据块的,还记得上面讲的PE文件从磁盘映射到内存当中吗?而节表描述其实就是描述这种映射关系,描述磁盘中节数据在哪里、占多大空间,之后映射到内存中又在哪里,又占多少空间。节表的每一行实际上是一个节标题。 此表紧跟可选标头(如果有)。 此定位是必需的,因为文件头不包含指向节表的直接指针。 相反,节表的位置是通过计算标头后第一个字节的位置来确定的。 请确保使用文件标头中指定的可选标头的大小。

节表中的条目数由文件标头中的 NumberOfSections 字段提供。 节表中的条目从 1 (1) 开始编号。 代码和数据内存部分条目按链接器选择的顺序排列。

在图像文件中,节的 VA 必须由链接器分配,以便它们按升序和相邻,并且必须是可选标头中 SectionAlignment 值的倍数。

每个节标题 (节表项) 具有以下格式,每个条目总共 40 个字节。

第一个字段就是节名称,节名称就类似于注释一样,它描述一下这个数据块是做什么的。因为该字段的作用类似于注释,可以随意修改且不会对程序产生什么影响。注意:节数据所在不是找节名称!因为节名称可以随意修改没有参考价值,要找节数据要通过数据目录进行寻找。

在讲下面四个字段时,我们需要先了解这些信息:

VA:虚拟内存地址(Virtual Address),PE 文件被操作系统加载进内存后的地址。

RVA:相对虚拟地址(Relative Virual Address),相对于image(虚拟内存基址)的偏移地址。RVA=VA-应用程序实例句柄(该模块加载到线性进程空间中的首地址)。

句柄:句柄就是主键,主键是身份的唯一标识,所以句柄就是身份的唯一标识。

FOA:文件偏移地址(File Offset Address),和内存无关,它是指磁盘中某个位置距离文件头的偏移。

在RVA转FOA的情况下有一个小问题,简单来说问题是因为文件的内存对齐与内存的内存对齐之间的差异所产生的。举个例子:有的转换软件RVA转FOA是先找该RVA在哪个区段,还是在PE文件头。找到后会用RVA去减节(区段)基址相对于PE文件加载基址的偏移量,得到RVA相对于该节基址的偏移量。下一步获取到该节在文件中的基址,将之前得到的RVA相对于该节基址的偏移量与该节在文件中的基址进行相加就得到了FOA。比如RVA为10FA,在PE文件的代码段当中,代码段的基址为00401000,PE文件的加载基址为00400000,得出代码段的基址相对于PE文件的加载基址为1000,用10FA减去1000得到FA,FA即为10FA相对于该节基址的偏移量。代码段在文件中的基址是400,那么FOA就是4FA。知道了RVA转换成FOA的流程后,你想啊!内存对齐值比文件对齐值大的时候,那么有一大部分地址相对于节基址的偏移量比文件当中节的大小都大,那些RVA是没法转换成FOA的。

这四个字段是描述磁盘中节数据在哪里、占多大空间,之后映射到内存中又在哪里,又占多少空间。我们用一张图片来讲解这四个字段:

如上图所示,最后四个被紫色所圈起来的字节是PointerToRawData字段,该字段中存储的是该节的FOA;被黑色所圈起来的字节是SizeOfRawData字段,该字段中存储的是该节在磁盘中对齐后的大小;当这四个字段中只有PointerToRawData字段和SizeOfRawData字段为0时,那么该节就没有数据能映射到内存中,虽然该节没有文件映射到内存,但是内存中还是会在指定地方开辟一块指定大小求对齐后的空间,且这块空间没有数据;这块空间是未初始化的数据区,相当于高级语言中的未初始化的全局变量。被绿色所圈起来的字节是VirtualAddress字段,该字段中存储的是该节加载到内存后的RVA;被红色所圈起来的字节是VirtualSize字段,该字段中存储的是该节加载到内存中实际有效的大小,记住这里不是对齐后的大小!!!VirtualSize字段中存储的值可以进行修改且不影响PE文件运行,但有个要注意的是加载到内存后不改变分页数量,不然牵一发而动全身,得不偿失!所以修改范围尽量小于等于该节加载到内存中对齐后的大小且大于等于零。比如在磁盘中该节对齐后的大小为200,映射到内存后占一个分页,那可修改范围就是0到1000。注意:并不是VirtualSize字段的值必须小于等于SizeOfRawData字段的值加载到内存后进行对齐后的大小,而是因为谁大就按照谁在加载到内存后进行内存中的对齐。假设内存对齐为1000,磁盘对齐为200,VirtualSize字段的值为3050,而SizeOfRawData字段的值仅为400,那么加载到内存后会因为3050比400大而开辟一块4000大小的空间。举个例子:有的PE文件不会单独创建一个未初始化的数据区,而是找个合适的数据节做合并,这样VirtualSize字段的值就会大于甚至远大于SizeOfRawData字段,这样做是为了那些未初始化数据占据空间。

我们看上图,这16个字节的意义就很明了了:从磁盘文件地址为1000的位置复制7F000大小的字节拷贝到内存RVA为1000的位置,拷贝到内存中该节的数据实际有效的大小为7F77A字节。

PointerToRelocations字段、PointerToLinenumbers字段、NumberOfRelocations字段、NumberOfLinenumbers字段这十二个字节是微软为别的操作系统特性所准备的,微软自身并不需要用,但现在没有看到别的平台支持它。所以这十二个字节随便填,不影响PE文件的运行。

最后一个字段描述的是内存块的属性,有关详细信息看下图:



这张表特别长,没办法只能分成几部分凑一起,这张图怎么看呢?按位看,举个例子:某个PE文件该字段中存储的是60000020,结合上表说明了这些信息——部分可以作为代码执行、可以读取节、部分包含可执行代码。可能还有些人没有看懂,那我再讲具体点,你看60000020的最高位为6,你就去上图找位置相同的值加起来为6即可,其他位一样。这些属性当中最主要的是最高位的4个属性:可共享、可执行、可读、可写。其他位的属性可以不存在,但最高位的属性必须存在,起码要有可读属性,当PE文件存在DEP(数据执行保护)时,只要没有可执行属性就会运行出现异常,如果不存在DEP那么可执行属性就形同虚设。

还有一点节与节之间得是连续的,微软的Windows是有这方面的检查,起码现在是这样。

 
 
 
 
typedef struct _IMAGE_DOS_HEADER { // DOS-MZ文件头
    WORD e_magic;      // DOS 可执行文件的标识符
    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;      // ChecksumWORD e_ip;      // Initial IP valueWORD 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;     // PE 签名的文件偏移量
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;
typedef struct _IMAGE_DOS_HEADER { // DOS-MZ文件头
    WORD e_magic;      // DOS 可执行文件的标识符
    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;      // ChecksumWORD e_ip;      // Initial IP valueWORD 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)

[注意]传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!

收藏
免费 24
支持
分享
最新回复 (12)
雪    币: 1524
活跃值: (1712)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
2
大佬,牛逼阿
2023-6-18 15:22
0
雪    币: 29
活跃值: (41)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
3
最近无聊也没找到编程的乐趣,有时间我也要来混混这个论坛啦。
2023-6-20 13:49
0
雪    币: 3059
活跃值: (30876)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
4
感谢分享
2023-6-20 13:52
1
雪    币: 227
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
5
大佬牛逼!
2023-6-26 17:40
0
雪    币: 147
活跃值: (866)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
6
赞赞!好东西
2023-6-27 13:49
0
雪    币: 140
活跃值: (451)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
7
大佬牛逼!!!!
2023-6-29 23:41
0
雪    币: 208
活跃值: (427)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
8
好东西收藏了,谢谢大佬
2023-7-24 10:37
0
雪    币: 380
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
9
好牛
2023-12-6 15:51
0
雪    币: 199
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
10
真牛逼,看得我脑瓜子嗡嗡的
2023-12-13 14:21
0
雪    币:
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
11
好文章,收藏了慢慢看
2023-12-15 11:42
0
雪    币: 839
活跃值: (820)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
12
好东西,谢谢
2023-12-15 17:22
0
雪    币: 1069
活跃值: (1010)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
13
讲的非常详细了,辛苦了
2024-2-19 10:54
0
游客
登录 | 注册 方可回帖
返回
//