PE加载过程

PE文件结构详见上一篇博客:

概要

总共可以分为八个步骤:

1.将PE文件用ReadFile读取数据

2.根据PE结构获取镜像大小,在自己的程序中申请可读可写可执行的内存

3.将申请的空间全部填为0

4.将用ReadFile读取的数据映射到内存中

5.修复重定位

6.根据PE结构的导入表,加载所需要的dll,并获取导入函数的地址并写入导入表中。

7.修改PE文件的加载基址

8.跳转到PE的入口点处执行

数据目录

用途:

用来找到编译器加到PE文件中的信息,这些信息包含了如:

  • PE程序的图标信息
  • 用了哪些系统提供的函数(导入的函数)
  • 为其他程序提供了哪些函数(导出的函数)
定位:

可选PE头的最后一个成员,就是数据目录,一共16个:

1
2
3
4
5
6
typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress;
DWORD Size;
} IMAGE_DATA_DIRECTORY, * PIMAGE_DATA_DIRECTORY;

#define IMAGE_NUMBEROF_DIRECTORY_ENTRIES 16

分别是:导出表、导入表、资源表、异常信息表、安全证书表、重定位表、调试信息表、版权所以表、全局指针表、TLS表、加载配置表、绑定导入表、IAT表、延迟导入表、COM信息表 最后一个保留未使用。

和程序执行有关的:

和程序运行时息息相关的表有:

导出表

导入表

重定位表

IAT表

导出表

定位导出表

数据目录项里的第一个结构就是导出表。

1
2
3
4
5
typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress;
DWORD Size;
} IMAGE_DATA_DIRECTORY, * PIMAGE_DATA_DIRECTORY;

VirtualAddress 导出表的RVA

Size 导出表大小

导出表结构

数据目录里的只是导出表在哪里的信息,并不包含导出表真正的信息

如何在FileBuffer中找到这个结构呢?在VirtualAddress中存储的是RVA,想在FileBuffer中定位,则需要先转换成FOA

真正的导出表结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
typedef struct _IMAGE_EXPORT_DIRECTORY {
DWORD Characteristics; //未使用
DWORD TimeDateStamp; //时间戳
WORD MajorVersion; //未使用
WORD MinorVersion; //未使用
DWORD Name; //指向该导出表文件名字符串
DWORD Base; //导出函数起始序号
DWORD NumberOfFunctions; //所有导出函数的个数
DWORD NumberOfNames; //以函数名字导出的函数个数
DWORD AddressOfFunctions; // 导出函数地址表RVA
DWORD AddressOfNames; // 导出函数名称表RVA
DWORD AddressOfNameOrdinals; // 导出函数序号表RVA
} IMAGE_EXPORT_DIRECTORY, * PIMAGE_EXPORT_DIRECTORY;

AddressOfFunctions说明:

该表中元素宽度为4个字节

该表中存储所有导出函数的地址

该表中个数由NumberOfFunctions决定

该表项中的值是RVA, 加上ImageBase才是函数真正的地址

定位:

IMAGE_EXPORT_DIRECTORY->AddressOfFunctions 中存储的是该表的RVA 需要先转换成FOA

AddressOfNames说明:

该表中元素宽度为4个字节

该表中存储所有以名字导出函数的名字的RVA

该表项中的值是RVA, 指向函数真正的名称

AddressOfNameOrdinals

导出表总结:

为什么要分成3张表?

1、函数导出的个数与函数名的个数未必一样.所以要将函数地址表和函数名称表分开.

2、函数地址表是不是一定大于函数名称表?

未必,一个相同的函数地址,可能有多个不同的名字.

3、如何根据函数的名字获取一个函数的地址?

4、如何根据函数的导出序号获取一个函数的地址?

程序加载的过程

1、一般情况下,EXE都是可以按照ImageBase的地址进行加载的。因为EXE拥有自己独立的4GB虚拟内存空间,但是DLL不是,有EXE使用它时,DLL才加载到相关EXE的进程空间

2、为了提高搜索的速度,模块间地址也是要对齐的,模块地址对齐为10000H,也就是64K

引出重定位表:

1、也就是说,如果程序能够按照预定的ImageBase来加载的话,那么就不需要重定位表
这也是为什么exe很少有重定位表,而DLL大多都有重定位表的原因。一旦位置冲突,由于编译过程中编译器把值已经写死了(写的不是偏移地址,写的是偏移加基址(编译器默认认为不会发生位置冲突)),很有可能导致程序出错

2、一旦某个模块没有按照ImageBase进行加载,那么所有类似上面中的地址就都需要修正,否则,引用的地址就是无效的.

3、一个EXE中,需要修正的地方会很多,那我们如何来记录都有哪些地方需要修正呢?

答案就是重定位表

重定位表

定位:

数据目录项的第6个结构,就是重定位表

在内存中的形式类似这样,按块划分

说明:

1、通过IMAGE_DATA_DIRECTORY结构的VirtualAddress
属性 找到第一个IMAGE_BASE_RELOCATION

2、判断一共有几块数据:

最后一个结构的标记是VirtualAddress与SizeOfBlock都为0

3、具体项 宽度:2字节

也就是这个数据

内存中的页大小是1000H 也就是说2的12次方 就可以表示
一个页内所有的偏移地址 具体项的宽度是16字节 高四位
代表类型:值为3 代表的是需要修改的数据 值为0代表的是
用于数据对齐的数据,可以不用修改.也就是说 我们只关注
高4位的值为3的就可以了.
4、VirtualAddress 宽度:4字节

当前这一个块的数据,每一个低12位的值+VirtualAddress 才是
真正需要修复的数据的RVA

真正的RVA = VirtualAddress + 具体项的低12位

5、SizeOfBlock 宽度:4字节

当前块的总大小

只有知道块的总大小才能索引到下一个块,因为没有其他数据来记录下一个块的地址
具体项的数量 = (SizeOfBlock - 8)/2

6、分块的目的是为了节省空间开销,如果每次都存完整的地址,那么32bit的系统每个地址都需要4B,则远远不够用,所以对高位相同的地址进行这样的简化处理。

分块是以地址块为单位(一整块的地址用一块重定位表来表示)

回过头来看重定位表的作用:

重定位表只是为了让自己这个DLL程序里的地址在运行时是正确的

所以PE文件中的重定位表是为了自身程序(EXE、DLL)在被其他程序加载时,没有被正常加载到预期位置,但为了让主程序正常调用,而给系统的一种指导(就是告诉系统,如果我被加载到的位置和我预计的位置不一样,那么就要按照我给出的重定位表进行修改)

注:如果一个程序不会被别的程序加载,那么这个程序的重定位表是用不上的,因为除了内核重载的情况,主程序的2G虚拟内存空间不会被占用。

导入表

定位:

数据目录项的第2个结构存储了导入表的信息

导入表的结构:

1
2
3
4
5
6
7
8
9
10
11
12
typedef struct _IMAGE_IMPORT_DESCRIPTOR {									
union {
DWORD Characteristics;
DWORD OriginalFirstThunk; //RVA 指向IMAGE_THUNK_DATA结构数组
};
DWORD TimeDateStamp; //时间戳
DWORD ForwarderChain;
DWORD Name; //RVA,指向dll名字,该名字已0结尾
DWORD FirstThunk; //RVA,指向IMAGE_THUNK_DATA结构数组
} IMAGE_IMPORT_DESCRIPTOR;
typedef IMAGE_IMPORT_DESCRIPTOR UNALIGNED *PIMAGE_IMPORT_DESCRIPTOR;

PE文件加载前(这张图也可以更形象的认识导入表的结构):

PE文件加载后:

相关的两个数据结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
typedef struct _IMAGE_THUNK_DATA32 {												
union {
PBYTE ForwarderString;
PDWORD Function;
DWORD Ordinal; //序号
PIMAGE_IMPORT_BY_NAME AddressOfData; //指向IMAGE_IMPORT_BY_NAME
} u1;
} IMAGE_THUNK_DATA32;
typedef IMAGE_THUNK_DATA32 * PIMAGE_THUNK_DATA32;


typedef struct _IMAGE_IMPORT_BY_NAME {
WORD Hint; //可能为空,编译器决定 如果不为空 是函数在导出表中的索引
BYTE Name[1]; //函数名称,以0结尾
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;

输出导入表的过程:

1、定位导入表:
1
2
3
4
5
6
目录项目的第2个结构就是导入表									

typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress; //RVA 指向导入表结构
DWORD Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;

导入表结构中有若干个导入表,依靠最后的全0结构来判断结束

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
	将RVA转换成FOA								

typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics;
DWORD OriginalFirstThunk;
};
DWORD TimeDateStamp;
DWORD ForwarderChain;
DWORD Name;
DWORD FirstThunk;
} IMAGE_IMPORT_DESCRIPTOR;
typedef IMAGE_IMPORT_DESCRIPTOR UNALIGNED *PIMAGE_IMPORT_DESCRIPTOR;

......

typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics;
DWORD OriginalFirstThunk;
};
DWORD TimeDateStamp;
DWORD ForwarderChain;
DWORD Name;
DWORD FirstThunk;
} IMAGE_IMPORT_DESCRIPTOR;
typedef IMAGE_IMPORT_DESCRIPTOR UNALIGNED *PIMAGE_IMPORT_DESCRIPTOR;

1
sizeOf(IMAGE_IMPORT_DESCRIPTOR) 个 0  代表导入表结束							

2、输出DLL名字

每个结构代表一个DLL

1
2
3
4
5
6
7
8
9
10
11
typedef struct _IMAGE_IMPORT_DESCRIPTOR {									
union {
DWORD Characteristics;
DWORD OriginalFirstThunk;
};
DWORD TimeDateStamp;
DWORD ForwarderChain;
DWORD Name; RVA 指向一个以0结尾的字符串 是DLL的名字
DWORD FirstThunk;
} IMAGE_IMPORT_DESCRIPTOR;
typedef IMAGE_IMPORT_DESCRIPTOR UNALIGNED *PIMAGE_IMPORT_DESCRIPTOR;

3、遍历OriginalFirstThunk(INT)

4、遍历FirstThunk(IAT)

在没加载时,3和4中的数据是一样的

GetProcAddr()实际上就是根据DLL加载的地址去读取相应的导出表,通过序号或名字查找到真正的函数地址

注意:

在加载前,IAT表和INT表存储的数据完全一样,都指向同一个IMAGE_IMPORT_BY_NAME表。

编译的时候,每加载一个DLL,就会生成一个对应的导入表,所以导入表的数量不确定,并不是唯一的。

结尾标志都是全0的同结构