PE文件与加载(提高)

PE文件与加载(提高)

写在最前面:本片文章不会细致介绍PE文件的基础知识,该内容详见博客。这篇文章主要是以书目录为主干,结合已有知识,梳理下PE文件相关知识,同时在学习的过程中整理下进程、DLL、内存的知识,与专业课博客结合,为后续学内核做铺垫。

目标

  1. 回顾PE文件格式,过一下重点,记忆。
  2. 重点学习下一些常用的数据目录表,实践。
  3. 理清PE文件加载的流程,要规范语言、对应内容。
  4. 回顾操作系统博客,补充知识点,有话可说关于操作系统。
  5. 会涉及到一些加壳脱壳的内容,找一两个实践下。

参考资料

  1. 《逆向工程核心原理》第二篇,PE文件格式。
  2. 吾爱上的一篇精华:https://www.52pojie.cn/thread-1820306-1-1.html

PE文件格式

能不能用自己的话大概解释一下PE文件的相关知识

从下面四个方面介绍:

  1. PE文件的定义
  2. PE文件的分类
  3. PE文件的结构,总分介绍,大体意思即可
  4. PE文件的作用

下面具体说下

  1. PE文件的定义

    PE文件是一种可执行文件格式,一般是指Win32下的可执行文件。

    可执行文件是一种包含可执行代码的二进制文件,它包含了计算机程序的机器指令和其他必要的数据,以便操作系统能够加载和执行该程序

  2. PE文件分类

    可以将PE文件分为四类:

    • 可执行系列:如exe文件,是Windows操作系统中的可执行文件格式,通常是用C、C++、C#、Visual Basic等编程语言编写的应用程序的输出文件;又如SCR文件,是exe文件的一种特殊格式,即屏幕保护程序

    • 库系列:如DLL动态链接库文件存储可重用的代码和数据、LIB静态链接库是文件编译时与应用程序静态链接的代码和数据集合。

    • 驱动程序系列:如SYS,驱动程序是用于与硬件设备进行交互的软件模块

    • 对象文件系列:如OBJ文件,用于存储编译器生成的目标代码(Object Code)和相关的符号信息。OBJ文件通常是编译器生成的中间文件,用于在链接器(Linker)的过程中将多个目标文件和库文件合并成可执行文件。

    注意:无论是可执行文件、动态链接库还是其他类型的PE文件,它们都遵循PE文件格式的规范,以便在Windows操作系统中正确加载和执行

  3. PE文件格式:

    学习PE文件结构就是学习PE头里的结构体,关于这些结构体的分类、各个字段的含义在另一篇博客详细介绍过,这里不再赘述。我们说下没有涉及的点。

    • 首先是DOS头,该头部主要是考虑对DOS文件的兼容,即在DOS模式下也可以执行该文件,只是会根据DOS存根进行执行,比如一般会输出字符串提示:This is progarm cannot be run in DOS mode;同时在DOS头里要注意两个字段:e_magice_ifanew,前者是双字节,存储”MZ”即DOS签名,后者是Long,存储PE标识的偏移

    • 然后是PE头,该头部由三部分组成:PE标识文件头可选头

      PE标识就是前面DOS头里的ifanew字段指向的数据,4个字节-“PE”00,属于文件签名。

      PE文件头存储文件本身的一些基本信息,如机器架构(machine)、文件信息(0002h为exe文件、2000h为DLL文件)、可选头大小、节表数量、创建时间等。

      PE可选头描述文件加载过程的相关信息,虽说是可选头,其实是必须具备的,甚至可以说是最重要的头部。有几个需要重点关注的字段:magic=文件类型(10B是32位,20B是64位)、EP=入口地址的RVA、ImageBase=加载的基址、Alignment=文件、内存的对其粒度即节区的最小单位、SizeOfImage=内存中文件镜像的大小、SizeOfHeader=PE头的大小,还有数据目录的大小与数据目录表。

      关于数据目录,需要重点关注导入、导出、重定向、资源、调试、TLS这几个即可。

    • 接着是节表,每一个节表对应一个节区,其中包含节区的名称、大小、RVA、FOA、属性等。注意以虚拟大小确定节区数据的实际大小

    OK,到这里就算是简单了解了PE文件的基本格式,在实际使用中,可以提高PE文件工具查看PE文件的具体信息,如下:

    image-20230918165444418
  4. PE文件的作用

    PE文件为所有的WIN32下可执行文件设置统一的结构和格式,以便操作系统能够正确地加载和执行程序。同时保证文件的兼容性、可移植性。

歌曰:在你向别人描述你对于PE文件的理解时,重点在整体宏观,而不是某个细节的字段;在进行具体的分析调试时,很多字段也没什么用,关键在于理解内存中的布局以及各个区域的作用

几个重要的数据目录

了解基本的PE文件格式只是基础,在实际分析中,需要重点关注下面几个重要的数据结构。导入、导出表已经学习过了,这里重点放在重定位表

导入表

导入表的具体结构不再赘述,理解之后就很好记忆了。这里主要强调一下,导入表与IAT表是关联在一起的,在载入时会通过导入表信息解析得到需要导入的DLL和函数,然后将函数的实际地址写入IAT里。

具体的解析过程如下:

  1. 读取IID的Name成员,获取库名称字符串
  2. 装载相应库–使用Loadlibrary函数
  3. 读取IID的OriginalFirstThunk成员,获取INT地址(也就是导入函数名称表)
  4. 逐一读取INT数组的值,获取IMAGE_IMPORT_NAME地址
  5. 根据上地址的内容使用GetProcAddress获取该函数的实际地址
  6. 读取IID的FirstThunk内容,获取IAT的地址(IAT就是导入地址表)
  7. 将5的地址填入IAT数组里。
  8. 重复4~7步骤,直到INT结束。

重定位表

先说说为什么要有重定位表?

原因很简单,有些可执行文件在加载到内存后,其镜像基址并没有遵循PE头中的默认位置,比如exe的400000h,又或者DLL的10000000h。但是在这个可执行文件内部,有很多地方都是使用硬编码地址进行代码的指向或者数据的引用。(这个硬编码地址就是VA = RVA+IB)

所以一旦这些文件的加载基址发生了变化,这些硬编码的地址也就失去了作用,这时候就需要对硬编码地址进行修正。如何修正呢?使用重定位表

换句话说,修正硬编码地址的过程就是重定位

重定位表的结构是什么样的?

重定位表的结构如下,主要有两个字段:VirtualAddressTypeOffset[1]sizeOfBlock。具体介绍如下:

  • VirtualAddress:4字节,是一个页面的起始地址,也就是基址。
  • sizeOfBlock:重定位块的大小。
  • TypeOffset[1]:需要修正数据的地址偏移。
1
2
3
4
5
6
7
8
typedef struct _IMAGE_BASE_RELOCATION {
DWORD VirtualAddress; // 重定位数据页面起始地址
DWORD SizeOfBlock; // 重定位块的长度
//WORD TypeOffset[1]; // 重定位项数组
//该数组每个元素占2字节,加上VirtualAddress后才是真实地址
} IMAGE_BASE_RELOCATION;
//最后一个块的值全为0
typedef IMAGE_BASE_RELOCATION*,PIMAGE_BASE_RELOCATION;

歌曰:每一个重定位块都是以一个页为单位进行指向的,而一个页的大小一般为4K,也就是说只需要12位就可以表示,所以TypeOffset[1]的16位用不完。实际处理是:将前4位作为标志位,当其为0011时表示需要修改;而后12位作为偏移位,与VirtualAddress相加得到具体的RVA,从而定位到硬编码地址处。

**借一个图**:

image-20230925143421304

什么时候要进行重定位嘞?

答案也很明确,可执行文件加载时进行。一般来说是先将文件映射到虚拟内存空间而后检查重定位表进行重定位操作。

歌曰:重定位操作修正的已经加载到内存中的数据,不会对原始DLL造成影响。

歌曰:并不是只有DLL才进行重定位,现在系统都会采用ASLR机制即随机空间布局随机化,每一次可执行文件的加载都会有不同的基址。

如何进行重定位呢?

  • 定位数据:加载后会先把文件数据映射到虚拟内存空间,之后会根据PE头的数据定位到重定位表,每一个重定位块对应一个页,根据Vir+off = RVA得到某个硬编码数据的地址,将该地址与IB相加后得到VA,也就可以精确定位到该硬编码数据;
  • 修改数据:将原始数据-原始IB+现在IB即可
  • 循环执行:直到把需要修改的硬编码数据修改完毕

下面是一个小小的实践

image-20230925150648365

如上图所示,该重定位块标注了第一个页里需要修改的数据RVA。我们以第一个地址为例,其RVA=1000h+420h=1420h,该地址处的数据为 010010C4h(见下图ida数据);而将其加载到内存后该硬编码地址为 00481420h(见下OD图),可以看到数据变了,也可以借此计算出本次的基址为00480000h

image-20230925152208255

image-20230925152551871

PE文件的加载流程

下面我们来简单整理下PE文件的加载流程,经过前面的学习与积累,这一步总结就很简单了。

  1. 定位文件:操作系统根据应用程序的路径或者相对路径,通过文件系统定位到PE文件的位置。
  2. 解析PE头:操作系统读取PE文件的头部信息,即PE头。PE头包含了文件的基本信息,如文件格式版本、入口点地址、节表等。
  3. 加载PE文件:操作系统根据PE头中的信息,为PE文件分配内存空间。这包括分配可执行代码的内存区域、数据区域和导入表等。同时,操作系统会建立PE文件与内存空间的映射关系
  4. 进行重定位:如果当前加载到内存当中的基址与op的IB一样,则无需要重定位。否则获取到重定位表的块数据后,根据他的(块长度-8)/2得到该块的地址数量,前8字节存放着该块的偏移和大小,每个占4字节,一个重定位地址占2字节,通过块地址+8+(2i)取出需要重定位的地址,与0x3000进行异或,如果首位为3,则后12位为地址偏移,则重定位地址=后12位(块中偏移)+块的起始位置+内存起始位置 重定位则为重定位地址=重定位地址+(理想基址和实际基址的偏移) 即*重定位地址+=(实际基址-理想基址)。如果首位为0,则说明该偏移为对齐使用,遍历下一个(当前块基址+当前块长度)。将全部块遍历重定位完后,将op的IB也替换成当前加载到内存的基址。
  5. 解析导入表:PE文件中包含有关其依赖的其他模块(如DLL)的导入表。操作系统会解析导入表,对每个依赖的模块进行加载和链接
  6. 执行入口点:找到PE文件的入口点地址,即程序的起始执行地址。操作系统将控制权转移给PE文件的入口点,开始执行应用程序的代码。

先到这里吧,后面又新的学习成果再进行补充。