PE结构相关


整理一波PE结构

0x00 概述

PE文件格式是Windows操作系统从32位开始采用的可执行文件格式,主要用于EXE文件(可执行程序)、DLL文件(动态链接库)以及SYS文件(驱动程序)中等等。开头为DOS头部,是16位系统时在使用的结构。

与其对应的是ELF格式(Linux和大多数UNIX版本中)以及Mach-O(Mac OS X中)

PE中封装着加载可执行程序代码时所需要的一些必要信息。如果能够详细了解PE结构各部分的作用以及PE文件的加载过程,那么操纵程序加载过程将变为可能。

0x01 结构概览

要知道一个程序可以简单概括为两个部分:头部和各种节;

而这篇文章里说到的都是程序的头部

PE结构总览

首先可以看一张看起来很友好的参考图(如字面意思的”友好”),图片来源为看雪学院。

可以看到程序在文件中的起始部分结构分布大体有三个:DOS头部,NT头,以及节表(区块)

  • DOS头部

    兼容DOS程序的内容

    包含 IMAGE_DOS_HEADER 结构体 以及 DOS Stub

    DOS Stub是一堆冗余数据 不用管

  • NT头(248 或 264 个字节)

    PE头部结构体叫做 _IMAGE_NT_HEADERS (NT头)包含一个 Signature(DWORD 4字节)加上两大部分分别为_IMAGE_FILE_HEADER (文件头 20个字节)以及 _IMAGE_OPTIONAL_HEADER (可选头 96 + 128 或 112 + 128 个字节),可选·头中

    有一个非常重要的结构叫做数据目录_IMAGE_DATA_DIRECTORY DataDirectory[16] ,一项8字节,一共16项,共占128个字节,存储着各种表的索引,是我们学习的重点之一。

  • 节表

    每个节都会产生一个表项

    包含节的偏移、大小以及属性等等

0x02 结构细节

I. DOS头

结构体大小为 0x40

typedef struct _IMAGE_DOS_HEADER {      // DOS .EXE header
    WORD   e_magic;                     // Magic number
    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;                      // Checksum
    WORD   e_ip;                        // Initial IP value
    WORD   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;                    // File address of new exe header
  } IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;

DOS 前部分结构体大小是 64 个字节,十六进制是 0x40 首个WORD以及最后一个DWORD会用到。

DOS 头是在 16 位程序下使用的。所以不用全部关心。只需要关心第一个跟最后一个成员记住就行了,即e_magic e_lfanew

e_magicMZSignature ,长度两字节,用于标识文件类型。EXE 文件中,这字段存的值是 0x5A4D,按字节存储显示 ASCII 码值为 MZ(小端)可用于文件判断 如下

IMAGE_DOS_HEADER dosheader = { 0 };
__getBytes((char*)& dosheader, sizeof(IMAGE_DOS_HEADER), 0, fp);
if (dosheader.e_magic == 0x5a4d) {
	printf("Is MZ");
}
else {
	printf("Not MZ");
}

e_lfanew指向NT头开始的位置。这么设置是有原因的,实际上DOS头后面有一串DOS Stub区域,在链接时生成的数据,而这块区域往往是不定长的。所以e_lfanew这一设计就起了重要作用,它会记录NT头开始的地方,以方便跳过DOS Stub这个区域。

II. NT头

结构体大小为 0xF80x108(包含数据目录)

typedef struct _IMAGE_NT_HEADERS {
    DWORD Signature;                      PE标识
    IMAGE_FILE_HEADER FileHeader;         文件头
    IMAGE_OPTIONAL_HEADER32 OptionalHeader;扩展头
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;

1. Signature

大小为 0x4 / 4

NT 头的第一个成员Signature是 PE 标识,占 4 个字节。通常值为 0x50450000。值变动后PE程序将变得无法运行。

2. _IMAGE_FILE_HEADER

大小为 0x14 / 20

typedef struct _IMAGE_FILE_HEADER
{
    WORD Machine;    // 文件运行平台
    WORD NumberOfSections;    // 区段数量
    DWORD TimeDateStamp;    // 文件创建时间
    DWORD PointerToSymbolTable;    // 符号表偏移
    DWORD NumberOfSymbols;    // 符号个数
    WORD SizeOfOptionalHeader;    //扩展头大小
    WORD Characteristics;    // PE文件属性
}

Machine

表示文件运行的平台,比如:0x014c 代表 i386

NumberOfSections

区段个数,标识文件的主体被分成了多少部分,一般有 text 段、data 段……

TimeDateStamp
文件创建时间,值需要经过比较复杂的转化,才能显示成正确的时间

SizeOfOptionalHeader

扩展头大小,通常 32 位程序里值为 00E0,64 位程序里值为 00F0

Characteristics

文件属性,里面是按位进行的标识。知道 0x0210 代表 DLL,0x010F 代表 EXE 初期应该就够了

3. IMAGE_OPTIONAL_HEADER

32 位程序大小为 00E0 / 224 64 位程序大小为 00F0 / 240 其中包含0x80 / 128字节的数据目录

64位PE称为 PE32+, 它并没有增加任何新域,仅从 PE 格式中删除了一个域。其余的改变就是简单地把某些域从 32 位扩展到 64 位。

typedef struct _IMAGE_OPTIONAL_HEADER {
    //
    // Standard fields.
    //

    WORD    Magic;              //标志.表名了我们的PE是x86还是x64
    BYTE    MajorLinkerVersion;          //连接器主要版本号
    BYTE    MinorLinkerVersion;          //连接器次要版本号 例如 3.54 主要版本就是3.次要就是54
    DWORD   SizeOfCode;                  //代码段大小,以字节为单位.
    DWORD   SizeOfInitializedData;       //初始化数据部分的大小.
    DWORD   SizeOfUninitializedData;     //未知初始化数据的大小
    DWORD   AddressOfEntryPoint;         //OEP 程序入口点,驱动程序也是入口点.对于DLL而言.是可选的.没有入口则为0
    DWORD   BaseOfCode;                  //指向代码部分的指针              
    DWORD   BaseOfData;                  //指向数据部分开头的指针

    //
    // NT additional fields.
    //

    DWORD   ImageBase;                  //基址.PE文件加载到内存中的基址.这个值是64k的倍数.DLL默认值是0x100000000,应用程序默认是0x00400000
                                         //windows CE除外.他是0x00010000
    DWORD   SectionAlignment;           //PE文件加载到内存中.的内存对齐.按照这个成员进行对齐
    DWORD   FileAlignment;              //文件对齐,PE存数据存放在文件中.按照文件对其值对其
    WORD    MajorOperatingSystemVersion;//所需要操作系统的主要版本号.
    WORD    MinorOperatingSystemVersion;//所需要操作系统的次要版本号.
    WORD    MajorImageVersion;          //PE主版本号
    WORD    MinorImageVersion;          //PE次版本号
    WORD    MajorSubsystemVersion;      //子系统主要版本号.
    WORD    MinorSubsystemVersion;      //子系统次要版本号.
    DWORD   Win32VersionValue;          //保留成员,必须为0
    DWORD   SizeOfImage;                //PE镜像大小.必须是内存对齐的倍数. sizeofImage/SectionAllignment == 0 才可以
    DWORD   SizeOfHeaders;               // DOS头+NT头+节表的总大小.按照文件对齐存放 sizeofHeaders / FileAlignment == 0
   DWORD   SubSystem             //表名PE文件是什么程序. 1驱动程序2窗口程序3控制台程序(DLL)
    DWORD   CheckSum;                   
    WORD    DllCharacteristics;         //P的文件属性
    DWORD   SizeOfStackReserve;         //堆栈保留字节数.我们的程序使用的栈空间多大靠这个成员.不过操作系统只作为参考
    DWORD   SizeOfStackCommit;          //要为堆栈提交的字节数.不做参考
    DWORD   SizeOfHeapReserve;          //堆保留字节数.
    DWORD   SizeOfHeapCommit;           //本地堆提交的字节数. PS: 栈堆保留数值.跟自己的sizeof(Head/stack)Commit成员有关.
    DWORD   LoaderFlags;                //成员已经过时
    DWORD   NumberOfRvaAndSizes;        //数据目录数组的大小
    IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];//数据目录
} IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32;

SizeOfCode
代码区段(一般是.text 段)文件对齐(200 的整数倍对齐,后面会讲到)大小。

​ 该字段可被用于校验文件是否被改动。

AddressOfEntryPoint
简称 OEP,程序入口点,程序开始执行的相对虚拟地址,非常重要的字段。
以前学开发时,一直以为程序是从 Main 函数开始的,其实 Main 函数只是用户编写程序的开始,在此之前还有很多初始化、预处理的程序需要由系统执行,那么这些程序开始的地址,就是 OEP 指向的地址。

ImageOfBase
加载基址,非常重要。
PE 文件没加载进内存前,在文件内都是从 0x0000H 开始的,在 DOS 头部分已经讲过。
PE 文件加载进内存后,其起始地址就不确定了。
通常 PE 文件 ImageOfBase 的值都是 0x400000H,但这只是首选地址,如果被占用,则会加载到其他地址。
DLL 的加载基址是 0x10000000H。

SectionAlignment、FileAlignment
内存对齐 和 文件对齐。默认值分别是 0x1000H 和 0x200H。
当文件加载到内存后,所有头部在一起所占空间必须是 0x1000H 的整数倍,每个区段所占空间必须是 0x1000H 的整数倍。
文件未加载到内存时,所有头部在一起所占空间必须是 0x200H 的整数倍,每个区段所占空间必须是 0x200H 的整数倍。

切记:文件加载前后,其各部分的地址和大小是会发生变化的。转化根据就是这两个对齐。

DllCharacteristics
DLL 特性标志。该字段也是按位来进行各种特性的标识。

​ 其中有一个比较重要 IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE

​ 这个位标识是否启用动态基址,1 启用、0 关闭

NumberOfRvaAndSizes
数据目录个数
通常该值都为 0x10。也就是有 16 个数据目录。

DataDirectory
数据目录,下面详细介绍

III. 数据目录

注意!数据目录是属于NT头的OptionalHeader里的最后一个结构体,因其复杂而重要,所以单独说明其结构。

数据目录内容繁杂,建议分清重点,不是每一个表项都对我们有用,只需要选取自己最关心的地方。如果才开始接触建议先跳过这个部分,先把握整体,找到重点后直接看相关部分,最后再弥补其它地方。

数据目录结构体大小为 0x80 / 128

数据目录表。这是一个结构体数组。数组里的每个元素对应一个数据表。通常有 16 个。
数组每个元素都是一个结构体,一个单位8个字节。具体如下

typedef struct _IMAGE_DATA_DIRECTORY
{
    DWORD VirtualAddress;    // 数据表的起始虚拟地址
    DWORD Size;    // 数据表大小
}IMAGE_DATA_DIRECTORY,*IMAGE_DATA_DIRECTORY

复杂的地方在哪里呢?那就是这数组里每一个成员都能够对应一个表项(实际上有些没有用到),而表项的内容并不在PE结构里,而是在节当中。尽管如此,它们对程序启动也是必须的,所以依然要学习它们。

16 个数据表依次如下:
导出表、导入表、资源表、异常处理表、安全表、重定位表、调试表、版权、指针目录、TLS、载入配置、绑定输入目录、导入地址表、延迟载入、COM 信息。

#define IMAGE_DIRECTORY_ENTRY_EXPORT		 0 //导出表
#define IMAGE_DIRECTORY_ENTRY_IMPORT		 1 //导入表 
#define IMAGE_DIRECTORY_ENTRY_RESOURCE		 2 //资源目录
#define IMAGE_DIRECTORY_ENTRY_EXCEPTION		 3 //异常目录
#define IMAGE_DIRECTORY_ENTRY_SECURITY		 4 //安全目录
#define IMAGE_DIRECTORY_ENTRY_BASERELOC	         5 //重定位基本表
#define IMAGE_DIRECTORY_ENTRY_DEBUG		 6 //调试目录
#define IMAGE_DIRECTORY_ENTRY_COPYRIGHT		 7 //描术字串
#define IMAGE_DIRECTORY_ENTRY_GLOBALPTR		 8 //机器值
#define IMAGE_DIRECTORY_ENTRY_TLS		 9 //TLS目录
#define IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG	 10 //载入配值目录
#define IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT       11 //绑定输入表
#define IMAGE_DIRECTORY_ENTRY_IAT		 12 //导入地址表
#define IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT	 13 //延迟载入描述
#define IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR     14 //COM信息

1. 导出表

对应节.edata

根据_IMAGE_DATA_DIRECTORY 结构体数组的第 1 个元素索引处导出表。一般情况下,dll 的函数导出供其他人使用,exe 将别人的 dll 的函数导入运行。
所以,一般.exe 没有导出表(但是并非说.exe 一定没有导出表)。

导出表结构

大小为0x28 / 40

typedef struct _IMAGE_EXPORT_DIRECTORY {
    DWORD   Characteristics;    //未使用
    DWORD   TimeDateStamp;      //时间戳
    WORD    MajorVersion;       //未使用
    WORD    MinorVersion;       //未使用
    DWORD   Name;               //指向改导出表文件名字符串,也就是这个DLL的名称
    DWORD   Base;               //导出表的起始序号
    DWORD   NumberOfFunctions;  //导出函数的个数(更准确来说是AddressOfFunctions的元素数,而不是函数个数)
    DWORD   NumberOfNames;      //以 函数名字 导出的函数个数
    DWORD   AddressOfFunctions;     //导出函数地址表RVA:存储所有导出函数地址(表元素宽度为4,总大小NumberOfFunctions * 4)
    DWORD   AddressOfNames;         //导出函数名称表RVA:存储函数名字符串所在的地址(表元素宽度为4,总大小为NumberOfNames * 4)
    DWORD   AddressOfNameOrdinals;  //导出函数序号表RVA:存储函数序号(表元素宽度为2,总大小为NumberOfNames * 2)
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;

① Name: DLL名字

② Base: 定义的导出函数序号的最小值

③ NumbersOfFunctions/ NumbersOfNames: 所有导出函数的个数 和 以名称导出的函数的个数

④ AddressOfFunctions: 指向所有导出函数的地址表

DLL导出方式

分为名称导出、序号导出以及名称+序号导出

  • 名称导出

    __declspec(dllexport)
    
        
    LIBRARY DLL
    EXPORTS
      FuncDll  
  • 序号导出

    LIBRARY DLL
    EXPORTS
      FuncDll @ 1 NONAME  
  • 名称+序号导出

    LIBRARY DLL
    EXPORTS
      fnDll1 @ 1 NONAME  
      fnDll2 @ 2 // 名称加序号

查找函数地址

Win32库里的GetProcAddress 函数获取函数地址用于

第一个参数是DLL名称,第二个参数是目标函数的 名称或者地址

这也就是两种查找目标函数的方法——通过函数名称 或者 通过函数序号

简单总结来说,所有函数都有自己的序号,以名称导出的函数相当于多了一种索引方式。

还是来看看整个过程

通过函数序号

因为每个函数都有序号,所以通过序号查找相对简单一些。

只需要一张表 函数地址表

比如我们寻找 14 序号。他会先根据导出表中 Base 成员属性。将表的起始位置。先看看 Base 起始位置是多少,假设为 13 那么我们函数地址表中

索引0 相当于 13

索引1 相当于 14

索引2 相当于 15 了。

依次类推,14序号的函数地址表下标为 14 - 13

总结:序号 - base 对应找到 AddressOfFunctions 的第几项

通过函数名称

三张表 函数地址表 函数序号表 函数名称表

先去遍历函数名称表,找到名称记录对应表项下标

然后拿着这个索引。去函数序号表中进行查找对比, 在序号表中查到了。对比成功。

序号表中第 2 项的值跟这个索引一样的。所以就拿序号表的序号。去函数地址表中获取函数地址.

总结:查找 AddressOfNames ,对应到 a 项,取 AddressOfNamesOrdinals 的第 a 项的值得到 b,取 AddressOfFunctions 的第 b 项

2. 导入表

导入表结构

对应节 .idata

导入表是记录 PE 文件中用到的动态连接库的集合,一个 dll 库在导入表中占用一个元素信息的位置,这个元素描述了该导入 dll 的具体信息。如 dll 的最新修改时间、dll 中函数的名字 / 序号、dll 加载后的函数地址等。

一个DLL对应一张导入表, 结构为 IMAGE_DIRECTORY_ENTRY_IMPORT ,每一个单位大小为0x14 / 20

typedef struct _IMAGE_IMPORT_DESCRIPTOR {
    union {
        DWORD   Characteristics;            //导入表结束标志
        DWORD   OriginalFirstThunk;         //RVA指向一个结构体数组(INT表)
    };
    DWORD   TimeDateStamp;                  //时间戳
    DWORD   ForwarderChain;                 // -1 if no forwarders
    DWORD   Name;                           //RVA指向dll名字,以0结尾
    DWORD   FirstThunk;                     //RVA指向一个结构体数组(IAT表)
} IMAGE_IMPORT_DESCRIPTOR, *PIMAGE_IMPORT_DESCRIPTOR;

①联合体值为 0 时(一般用 Characteristics 判断是否是 0),表示这是导入表结构体数组最后一个元素,除了最后这一个元素,其它每一个结构体都保存了一个 dll 信息。联合体的值不为 0 时, 用 OriginalFirstThunk(RVA)来索引 INT 的地址。这张 INT 表存放了该 dll 的导出函数的信息(序号与函数名)。

TimeDateStamp:当时间戳值为 0 时,表示未加载前 IAT 表与 INT 表完全相同;当时间戳不为 0(为 - 1)时,表示 IAT 与 INT 表不同,IAT 存储的是该 dll 的所有函数的绝对地址,这样在未加载前就直接填充函数地址的方式为函数地址的绑定,其地址是根据绑定导入表来确定的。也就是说当时间戳为 - 1 时绑定导入表才有效,而真正的时间戳存放到绑定导入表中,否则无效。

ForwarderChain:一般情况下我们也可以忽略该字段。在老版的绑定中,它引用 API 的第一个 forwarder chain(传递器链表)。

Name:RVA 指向 dll 的名字字符串。

FirstThunk:RVA 指向 IAT 表。

在程序加载以前,其具体成员的结构关系如下所示:

确定导入DLL名字

查看Name成员 注意值是RVA

静态查看需转文件偏移 动态需加上ImageBase

确定函数名称

主要看 OriginalFirstThunkFirstThunk两个成员,这个过程稍微复杂一些。

``OriginalFirstThunk 指向了 INT 表(导入名称表)

FirstThunk 指向了 IAT 表(导入地址表)

根据上图所示。两张表内容是一样的。但是所在位置是不一样的名字也不一样。一个叫做 INT 一个叫做 IAT 。

看到这里,你可能会觉得这个设计很迷。确实,IAT表是函数地址表,现在指着一堆名字肯定是有问题的。其实随着程序启动,程序实际启动的时候还会发生变化。至于为什么这么做,可以自己站在设计者的角度琢磨琢磨。

现在再看INT表里的内容

有个叫做IMAGE_THUNK_DATA的结构体,进入康康

typedef struct _IMAGE_THUNK_DATA32 {
    union {
        DWORD ForwarderString;      // PBYTE 
        DWORD Function;             // PDWORD
        DWORD Ordinal;
        DWORD AddressOfData;        // PIMAGE_IMPORT_BY_NAME
    } u1;
} IMAGE_THUNK_DATA32;
typedef IMAGE_THUNK_DATA32 * PIMAGE_THUNK_DATA32;

注意这是一个联合体,共用内存四选一。

常用的是下面两个成员,Ordinal 以及 AddressOfData

也就分别对应DLL导出函数的两种方式:序号导出(个人理解) 以及 函数名导出

也就是说有的时候需要用第三个成员,有的时候需要用第四个成员。

AddressOfData指向的是一个叫做IMAGE_IMPORT_BY_NAME结构体

typedef struct _IMAGE_IMPORT_BY_NAME {
    WORD    Hint;                 //编译器决定,不是空的话,就是函数在导出表中的 函数地址表的导出索引.
    CHAR   Name[1];               //函数名称,0结尾.
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;

了解完这些结构之后可以来看看如何寻找到对应的函数

上图展现了找到函数的方法 总结如下

  • 若为序号导出

    通过序号导出的话_IMAGE_THUNK_DATA32最高位会被置为1,选用联合体中的Ordinary,除去最高位(视为0),剩下的就是函数的导出序号。

  • 若为名称导出

    最高位不为1,则为以函数名称导出

    _IMAGE_THUNK_DATA32的值为一个RVA,指向一个_IMAGE_IMPORT_BY_NAME结构体

    结构体上面已经介绍过了,可以在这个结构体中找到名称。

不管是 INT 表还是 IAT 表。主要看其高位值,高位为 1, 那么去掉高位,就是函数的序号。高位为 0. 指向一个结构。这个结构保存了函数的导出序号。以及函数名称.

IMAGE_IMPROT_BY_NAME 结构中的 HINT 如果不是空,那么这个序号 (索引) 就是导出表的函数地址表的索引。我们可以直接拿着这个索引去导出表中获取函数地址.

寻找函数地址

看不到地址呢

还那张多余的IAT表吗 程序在启动时会将函数地址拷贝到IAT表中

获取函数地址的过程参见导出表

IAT表也在数据目录中 后续还会写到

3. 资源表

待填

.rsrc

4. 异常表

.pdata

x86系统采用动态的方式构建SEH结构,相比而言x64系统下采用静态的方式处理SEH结构,它保存在PE文件中,通常在.pdata区段。

5. 安全表

待填

如果一个应用程序有数字签名,那么它的安全表就不会为空。

6. 重定位表

对应节.reloc

重定位表的作用

重定位表(Relocation Table)用于在程序加载到内存中时,进行内存地址的修正。为什么要进行内存地址的修正?我们举个例子来说:test.exe 可执行程序需要三个动态链接库 dll(a.dll,b.dll,c.dll),假设 test.exe 的 ImageBase 为 400000H,而 a.dll、b.dll、c.dll 的基址 ImageBase 均为 1000000H。

那么操作系统的加载程序在将 test.exe 加载进内存时,直接复制其程序到 400000H 开始的虚拟内存中,接着一一加载 a.dll、b.dll、c.dll:假设先加载 a.dll,如果 test.exe 的 ImageBase + SizeOfImage + 1000H 不大于 1000000H,则 a.dll 直接复制到 1000000H 开始的内存中;当 b.dll 加载时,虽然其基址也为 1000000H,但是由于 1000000H 已经被 a.dll 占用,则 b.dll 需要重新分配基址,比如加载程序经过计算将其分配到 1200000H 的地址,c.dll 同样经过计算将其加载到 150000H 的地址。

那么问题就来了,DLL中有许多编译器写死了的地址(绝对地址),比如全局变量/函数等等。这些机器码需要在ImageBase不变的情形下才能正常工作!

比如 b.dll 中存在一个 call 0X01034560,这是一个绝对地址,其相对于 ImageBase 的地址为 δ = 0X01034560 - 0X01000000 = 0X34560H;而此时的内存中 b.dll 存在的地址是 1200000H 开始的内存,加载器分配的 ImageBase 和 b.dll 中原来默认的 ImageBase(1000000H)相差了 200000H,因此该 call 的值也应该加上这个差值,被修正为 0X01234560H,那么 δ = 0X01234560H - 0X01200000H = 0X34560H 则相对不变。否则 call 的地址不修正会导致 call 指令跳转的地址不是实际要跳转的地址,获取不到正确的函数指令,程序则不能正常运行s。

重定位表的结构

typedef struct _IMAGE_BASE_RELOCATION {
    DWORD   VirtualAddress;
    DWORD   SizeOfBlock;         //存储的值以字节为单位.字节多大.表示了一个重定位快有多大.
//  WORD    TypeOffset[1];
} IMAGE_BASE_RELOCATION;
typedef IMAGE_BASE_RELOCATION UNALIGNED * PIMAGE_BASE_RELOCATION;

看起来重定位表就两个成员,其实没那么简单。

重定位表有两个成员. VirtuallAddress sizeofBlock

1.virtualladdress 记录了当前物理页需要进行重定位的起始地址.

2.sizeofBlock 记录了重定位表多大。去掉 8 个字节 (重定位表大小) 下面都是记录了重定位表需要重定位的偏移.

3.偏移是 2 个字节存储. 12 位存储偏移。高 4 位存储是否进行重定位。高 4 位为 3 则需要进行重定位. virtuall + 低 12 位 就是要修正的 RVA 偏移.

如何重定位

比如我们有地址 101234 101235 101236 这种修正的地址有 10000 个.

那么每个地址有 4 个字节的。那么 4 * 10000 = 4 万个字节。也就是我们要准备一张 4 万个字节的表来保存重定位的.

但是我们发现一个规律。我们要修正的表的偏移都很近,1234 1235 ….

那么我们把 100000 取出来。两个字节存储 1234 另外两个地址存储 1235, 不用准备四个字节了。小的偏移我们两个字节存储。这样的话我们的表的字节就会缩小一半.

VirtuallAddress 就是存储了 100000 这个值,也就是需要 “”基址”” 公用的地址

我们要修正的偏移是 VirtualAddress + 表项值.

重定位表,是按照一个物理页 (4kb) 进行存储的。也就是一个 4kb 内存,有一个重定位块,一个重定位表只管自己当前的物理页的重定位.

一个重定位表的记录偏移的大小是 2 个字节 , 也就是 16 位。而记录偏移的大小。是由 SizeofBlock 决定的.

但是我们记录偏移的位置,12 位就够了。高 4 位。挪作他用。并不是记录的才会修正偏移。只有高 4 位为 3 的时候。才会进行重定位 (基址 + 偏移)

真正修复的位置 virtualaddress + (高四位为 3 ? 低 12 位偏移:无所谓的值.)

也就是高四位为 3 Vir + 低 12 位偏移就等于真正要修复的 RVA 例如 36b0 高位为 3 低 12 位就是 6b0 要修复的 RVA = vir + 6b0 , 如果加上当前 DLL 的 ImagebASE 才是真正要修复的虚拟地址 (VA) 我们计算出的是 RVA

7. 调试表

待填

8. 版权

待填

9. 指针目录

待填

10. TLS表

相关节.tls

主要用于存储TLS回调函数需要用到的一些数据

要学习TLS表需要先对Win32线程执行以及TLS回调函数函数有了解

TLS 全称 Thread Local Storage 线程局部存储器,它用来保存变量或回调函数。

TLS 里面的变量和回调函数都在程序入口点(AddressOfEntry)之前执行,也就是说程序在被调试时,还没有在入口点处断下来之前,TLS 中的变量和回调函数就已经执行完了,所以 TLS 可以用作反调试之类的操作。

TLS 中的变量单独存在于每个独立的线程当中,每个线程中对该变量的操作都不会影响到其他线程中的 TLS 变量。

TLS 变量的创建方法有两种方式,分别是动态方式和静态方式

动态方法会用到 TlsAlloc、TlsFree、TlsSetValue、TlsGetValue 这几个函数来操作变量

静态方法会用声明__declspec (thread) int xx = 1; 这样的方式来创建。

需要注意的是静态创建的 TLS 变量不能用于 DLL 动态库中。

动态使用的TLS变量不会生成TLS表

TLS表结构

typedef struct _IMAGE_TLS_DIRECTORY32 
{
    DWORD   StartAddressOfRawData;/* tls節區的起始地址 */
    DWORD   EndAddressOfRawData;/* tls節區的最終地址 */
    PDWORD  AddressOfIndex;/* tls節區的索引 */
    PIMAGE_TLS_CALLBACK *AddressOfCallBacks;/* 指向回調函數的指針 */
    DWORD   SizeOfZeroFill;
    DWORD   Characteristics;
} IMAGE_TLS_DIRECTORY32

①StartAddressOfRawData:tls 模板在内存中的起始 VA,模板是用于创建线程时初始化 TLS 数据的,对应上图中的 0x404000, 因为是 VA,所以我们将 0x4000 转换成 offset 得到 0x1800, 我们看到 0x1800 处的数据如下,可以看到模板中的内容其实就是 TLS 中创建的变量:

②EndAddressOfRawDataL:tls 模板在内存中的结束 VA, 对应上图中的 0x404020

③AddressOfIndex:存储 TLS 索引的位置,对应上图中的 0x40337c,这里为 0

④AddressOfCallBacks:指向 TLS 注册的回调函数的函数指针数组

首先需要注意的是,这个结构体里的字段都是 VA,也就是起始虚拟地址,已经加上了ImageBase。

通过PE找到TLS回调函数的方法

  • 先找到TLS表
  • TLS 表会记录 TLS 结构的起始地址,这个地址通常是 .tls 所在的地址
  • TLS 表中还会有回调函数的指针,从指针的地址就能定位到回调函数的地址

11. 载入配置

载入配置表早期是用于描述当 PE 文件头或 PE 可选头无法描述或者因为太大而无法描述的各种功能。
后来以 XP 及以后的系统主要是为了存储 SEH 句柄,称为安全结构化异常处理程序列表,如果 SEH 异常处理没有经过注册,在载入配置表中没有句柄,这个异常处理就不会被执行。
据微软官方说明,这个载入配置表的作用是为了防止 “x86 异常处理程序劫持” 的漏洞。

PE 文件头可选映像头中数据目录表的第 11 成员 IMAGE_DATA_DIRECTORY DataDirectory [IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG] 指向加载配置表。

载入配置表结构

typedef struct _IMAGE_LOAD_CONFIG_DIRECTORY {
    DWORD   Characteristics;              //属性,当前没使用
    DWORD   TimeDateStamp;                //(GMT时间)
    WORD    MajorVersion;                 //主版本号
    WORD    MinorVersion;                 //子版本号
    DWORD   GlobalFlagsClear;             //启动时清除全局标志
    DWORD   GlobalFlagsSet;               //启动时设置全局标志
    DWORD   CriticalSectionDefaultTimeout;//程序关键部分默认超时值
    DWORD   DeCommitFreeBlockThreshold;   //返回系统前必须释放的内存,以字节为单位
    DWORD   DeCommitTotalFreeThreshold;   //总共释放的内存
    PVOID   LockPrefixTable;              //预加锁表
    DWORD   MaximumAllocationSize;        //最大配置体积
    DWORD   VirtualMemoryThreshold;       //最大虚拟内存尺寸
    DWORD   ProcessHeapFlags;             //进程堆栈标志
    DWORD   ProcessAffinityMask;          //进程内部掩码
    WORD    CSDVersion;                   //CSD版本
    WORD    Reserved1;                    //保留,必须为0
    PVOID   EditList;                     //保留
    DWORD   Reserved[ 1 ];
} IMAGE_LOAD_CONFIG_DIRECTORY, *PIMAGE_LOAD_CONFIG_DIRECTORY;

12. 绑定导入表

绑定导入表结构

本来函数地址都是要PE加载后 通过环境确定函数地址 修改IAT表

但有一种结构能够让我们在PE加载前确定好函数地址 加速启动过程

而这种结构就是——绑定导入表

现在大多数情况,导入表的 TimeDateStamp 都为 0,而 Windows 早期的自带软件(如 WinXP 的 notepad.exe)基本都采用了 TimeDateStamp 为 - 1 的情况即包含绑定导入表的情况。PE 中包含导入表的优点是程序启动快,但是其缺点也十分明显,当存在 dll 地址重定位和 dll 修改更新,则绑定导入表也需要修改更新。

typedef struct _IMAGE_BOUND_IMPORT_DESCRIPTOR {
    DWORD   TimeDateStamp;      //表示绑定的时间戳,如果和PE头中的TimeDateStamp不同则可能被修改过
    WORD    OffsetModuleName;   //dll名称地址
    WORD    NumberOfModuleForwarderRefs;    //依赖dll个数
// Array of zero or more IMAGE_BOUND_FORWARDER_REF follows
} IMAGE_BOUND_IMPORT_DESCRIPTOR,  *PIMAGE_BOUND_IMPORT_DESCRIPTOR;
//最后一个结构全0表示绑定导入表结束

绑定导入表成员介绍

TimeDateStamp,也就是时间戳。

当 PE 文件中不存在绑定导入表时,IAT 就与 INT 一样,此时导入表中的时间戳就为 0。

导入表中的时间戳为 - 1 时,则象征着存在绑定导入表,dll 的真正时间戳存放于我们这里要讲解的绑定导入表中。

NumberOfModuleForwarderRefs 指该 dll 自身依赖的 dll 的个数。

值为 n 代表该结构后面紧跟了 n 个 IMAGE_BOUND_FORWARDER_REF 结构。

之后才是导入表导入的下一个 dll 的结构。 IMAGE_BOUND_FORWARDER_REF 结构体如下所示:

typedef struct _IMAGE_BOUND_FORWARDER_REF {
    DWORD   TimeDateStamp;  //时间戳,同样的作用检查更新情况
    WORD    OffsetModuleName;   //dll名称地址
    WORD    Reserved;   //保留
} IMAGE_BOUND_FORWARDER_REF, *PIMAGE_BOUND_FORWARDER_REF;

注意:这两个结构体中所有的 OffsetModuleName 均不是相对于 ImageBase 的 RVA 也不是 FOA,而是相对于绑定导入表首地址的偏移地址,即:绑定导入表首地址 + OffsetModuleName= RVA

看累了来张图片

如何判断这个 DLL 是否基址改变是否更新。就是绑定导入表的时间戳 跟 文件头中的时间戳进行对比。如果不一样。就要用重新计算地址。进行填写.

确定函数地址

寻找地址还是靠IAT表,绑定导入表是预先填入了函数的地址

怎么判断导入表中的 IAT 表函数地址是否绑定。这需要根据 TimeDataStamp 进行判断. 0 未绑定,-1 绑定。而真正的绑定时间存放在绑定导入表中.

绑定失效修复IAT表

如果 DLL 的 ImageBase 变了。那么就需要进行重定位。因为在文件中你填写的地址是固定的地址.

如何判断这个 DLL 是否基址改变是否更新。就是绑定导入表的时间戳 跟 文件头中的时间戳进行对比。如果不一样。就要用重新计算地址。进行填写.

如果,该可执行文件已经和 dll 绑定。但是这个 dll 后来又被更改了,这些被导入的函数依然在该 dll 中存在,但是实际地址已经改变了。还有,我们保留过一个 IAT 的副本,它就是 INT.(这就是为什么我们称之为 Original FirstThunk). 根据 INT 中的内容,我们可以重建 IAT 表。

13. IAT表

IAT表结构

根据导入表载入过程

可以看到 IAT 表与 INT 表内容最开始是一样的

一个导出函数对应一个叫做IMAGE_THUNK_DATA的结构体 结束时以0作为结束符

结构体如下

typedef struct _IMAGE_THUNK_DATA32 {
    union {
        DWORD ForwarderString;      // PBYTE 
        DWORD Function;             // PDWORD
        DWORD Ordinal;
        DWORD AddressOfData;        // PIMAGE_IMPORT_BY_NAME
    } u1;
} IMAGE_THUNK_DATA32;
typedef IMAGE_THUNK_DATA32 * PIMAGE_THUNK_DATA32;

注意这是一个联合体,共用内存四选一。

成员说明见导入表项

确定函数地址

实际上在程序运行后 IAT表这个地方已经变成函数地址了

IMAGE_THUNK_DATA刚好被函数地址覆盖 神奇吧

PE加载前,那么 IAT 跟 INT 一样。都可以找到依赖的函数名称.

PE加载后。也就是在内存中的话。那么 IAT 表保存的就是函数的地址.

即加载后 INT 不变依旧保存 dll 函数名与函数序号的地址信息。而 IAT 则根据导入表 INT(IAT 加载前)的内容和导出表信息,修改为对应的函数的地址信息,如下所示:

14. 延迟绑定表

对应节.didata

Delay Load Import Table(延迟加载导入表)是 PE 中引入的专门用来描述与动态链接库延迟加载相关的数据,延迟加载导入表和导入表是相互分离的。与导入表不同的是,它记录的这些动态链接库并不会被操作系统的 PE 加载器加载。只有等到它登记的相关函数被应用程序调用时,PE 中注册的延迟加载函数才会根据延迟加载导入表中对该函数的描述,动态加载相关链接并修正函数的 AV 地址,实现对函数调用

延迟加载导入是一种合理利用进程加载机制提高进程加载效率的技术,使用延迟加载导入能跳过加载前对引入函数的检测及加载后对 IAT 的修正。

延迟加载表不是系统支持的一个特性,它是由编译器控制的。

延迟加载导入的使用场景:

  • 提高应用程序加速速度

    如果一个应用程序使用了很多的 DLL,PE 加载器在将程序映像加载到虚拟地址空间的时候,同时也会把所有的 DLL 一起提前加载到进程空间,而且在加载每个 DLL 时还会调用 DLL 的入口函数,对 DLL 进行初始化,这个时候并没有开始调用这些引入函数的动态链接库的函数。但是这些操作的存在使得进程加载的时候会耗费一些时间。

  • 提供应用程序兼容性

    同一个 DLL 在不同的时候有不同的版本,新的 DLL 除了对原有函数继承和优化以外,通常还会增加一些新的函数。如果我们在应用程序中调用了一个 DLL 的新韩淑,运行时环境却存在的是老的 DLL,那么加载时系统就会提示错误,然后拒绝执行应用程序。

  • 提供应用程序可整合性

    在一个文件中与文件有关的配置信息、数据库、链接库等都放在一个文件里

延迟绑定表结构

typedef struct ImgDelayDescr {  
    DWORD        grAttrs;       //延迟导入结构的属性,0x1为新版本,0x0为老版本
    RVA          rvaDLLName;    //dll名字的RVA
    RVA          rvaHmod;       //dll句柄的RVA
    RVA          rvaIAT;        //IAT表的RVA
    RVA          rvaINT;        //INT表的RVA
    RVA          rvaBoundIAT;   //绑定导入表的RVA
    RVA          rvaUnloadIAT;  //原始IAT的可选拷贝的RVA
    DWORD        dwTimeStamp;   //延迟载入DLL的时间戳,通常为0
} ImgDelayDescr, * PImgDelayDescr;

15. COM 信息

待填

IV. 节表

节表就是对每个节的说明,一个节 对应一个 记录节表信息的结构体

每个结构体的大小为 0x28/ 40

typedef struct _IMAGE_SECTION_HEADER {
    BYTE    Name[IMAGE_SIZEOF_SHORT_NAME];		// 区段的名字,最长8字节
    union {
            DWORD   PhysicalAddress;			
            DWORD   VirtualSize;
    } Misc;						// 虚拟内存中,会使用的总大小,未对齐
    DWORD   VirtualAddress;				// 区段起始的相对虚拟基址RVA,VA=程序基址+VirtualAddress
    DWORD   SizeOfRawData;				// 区段在文件中的大小,进行了文件对齐
    DWORD   PointerToRawData;				// 区段的文件偏移
    DWORD   PointerToRelocations;				
    DWORD   PointerToLinenumbers;
    WORD    NumberOfRelocations;
    WORD    NumberOfLinenumbers;
    DWORD   Characteristics;
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;

最后一个表项象征着结束全为0。所以整个区段头表,是若干 40 个字节大小的数据块,加上一个 40 个字节大小的 0 结束

常见区段简介:
.text 段。一般是代码段。
.data 段。一般是数据段。
.bss 段。未初始化数据段。比如 static 变量,有时在函数内才初始化。
.rdata 段。只读数据段。比如字符串。
.idata 和.edata 段。导入表、导出表信息。
.rsrc 段。资源段。
.reloc 段。重定位信息段。

之后详细学习下常用区段

节表属性

决定节表属性的成员——Characteristics

具体内容可以参考 MSDN 在线文档: https://docs.microsoft.com/zh-cn/windows/win32/api/winnt/ns-winnt-image_section_header

Name 对应属性
IMAGE_SCN_CNT_CODE0x00000020 The section contains executable code.包含代码,常与 0x10000000 一起设置。
IMAGE_SCN_CNT_INITIALIZED_DATA0x00000040 The section contains initialized data.该区块包含以初始化的数据。
IMAGE_SCN_CNT_UNINITIALIZED_DATA0x00000080 The section contains uninitialized data.该区块包含未初始化的数据。
IMAGE_SCN_MEM_DISCARDABLE0x02000000 The section can be discarded as needed. 该区块可被丢弃,因为当它一旦被装入后, 进程就不在需要它了,典型的如重定位区块。
IMAGE_SCN_MEM_SHARED0x10000000 The section can be shared in memory. 该区块为共享区块。
IMAGE_SCN_MEM_EXECUTE0x20000000 The section can be executed as code. 该区块可以执行。通常当 0x00000020 被设置 时候,该标志也被设置。
IMAGE_SCN_MEM_READ0x40000000 The section can be read. 该区块可读,可执行文件中的区块总是设置该 标志。
IMAGE_SCN_MEM_WRITE0x80000000 The section can be written to. 该区块可写。

return

0x03 疑点总结

I. 导入表、绑定导入表、延迟导入表、导入地址表

注意不要混淆

  • IMAGE_DIRECTORY_ENTRY_IMPORT 就是我们通常所知道的导入表,在 PE 文件加载时,会根据这个表里的内容加载依赖的 DLL,并填充所需函数的地址。
  • IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT 叫做绑定导入表,在第一种导入表导入地址的修正是在 PE 加载时完成,如果一个 PE 文件导入的 DLL 或者函数多那么加载起来就会略显的慢一些,所以出现了绑定导入,在加载以前就修正了导入表,这样就会快一些。
  • IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT 叫做延迟导入表,一个 PE 文件也许提供了很多功能,也导入了很多其他 DLL,但是并非每次加载都会用到它提供的所有功能,也不一定会用到它需要导入的所有 DLL,因此延迟导入就出现了,只有在一个 PE 文件真正用到需要的 DLL,这个 DLL 才会被加载,甚至于只有真正使用某个导入函数,这个函数地址才会被修正。
  • IMAGE_DIRECTORY_ENTRY_IAT 是导入地址(IAT)表,前面的三个表其实是导入函数的描述,真正的函数地址是被填充在该IAT表中的。

最基本的是导入表,用于加载外部函数。

在导入表中有记录导入地址表(IAT)的地址,这个表其在运行时会被填入真正的函数地址。

绑定导入表和延迟导入表都是为了优化加载函数速度而诞生的结构

II. IAT表 和 重定位表

第一次接触PE的时候这个地方就搞混了

IAT表

为当前应用程序服务

用于填入导入函数的地址

方便程序调用外部函数

重定位表

是DLL给DLL自己提供服务的结构

当DLL加载地址不符合ImageBase的时候

将DLL代码中写死地址的部分进行修复

III. 添加节需要修改什么

步骤

① 添加节表

判断PE是否留下了足够的空间添加一个节表结构

节表大小为0x28 / 40

足够则可以添加节表结构

② 修改节的数量

位于FileHeader中的第二个成员NumberOfSections

这个值必须修正 添加一个节表就要加上一

③ 修改大小

  • 位于OptionalHeader中的SizeOfImage,PE镜像大小.必须是内存对齐的倍数. sizeofImage/SectionAllignment == 0 才可以

    需要加上添加节的大小

④ 修改节表属性

不用记住节表属性 改的时候可以参考着对各个属性进行修改

细节

  • 第①步中节表空间不够怎么办

    利用DOS Stub的空间

    可以提升整个PE头部 修改DOS头部

    PE头内容不用改动

    节表空间就有了

    如果提升空间还不够(基本不可能发生) 那就扩大节

  • 第①步中需要准备的信息

    VitualAddress/PointerToRelocation: 通过前一个表计算

    Characterristics: 依据我们的用途来定 重要!

更多操作

  • 扩大节

    扩大最后一个节的内容

    修改节表中的信息 在节中添加自己的内容

  • 合并节

IV. 修改OEP及返回

从PE头里找到原来的OEP 并记录

然后覆盖OEP为我们的地址

在我们代码的最后跳回OEP

V. PE加载过程

简要

  • 读取文件
  • 通过文件的SizeOfImage 分配内存空间大小
  • 按内存对齐的粒度将文件读取到内存
  • 修复重定位表
  • 修复IAT表
  • 修复基址(ASLR)
  • 调用线程 执行代码

VI. 脱壳修复

需要修改的地方主要是 导入表 和 OEP IAT

  • 修改为原来的OEP

  • 找到程序原本的导入表 将打乱的信息修复

    或者自己重建一份导入表

  • IAT表在DUMP下来后已经记录了函数的地址

    这个地址是不准确的 随操作系统变化地址也会跟着变化 应该修复

    其内容应该修复为INT表的内容 让操作系统帮助填充

https://bbs.pediy.com/thread-248111.htm

https://bbs.pediy.com/thread-151939.htm

0x04 参考链接

1. Portable Executable – wikipedia

2. PE结构详解

3. 导入表

4. IAT以及与导入表等的关系…

5. 延迟绑定

6. PE 文件解析 - 加载配置表、绑定导入表、导入地址表与延迟导入表

7. 导出表

8. 重定位表

9. TLS


Author: Luluting
Reprint policy: All articles in this blog are used except for special statements CC BY 4.0 reprint policy. If reproduced, please indicate source Luluting !
  TOC