PE结构

先来贴三张图

1

2

3

付上下载链接,附赠一张炒鸡详细图。

链接:https://pan.baidu.com/s/1RkqIDko51raMIsiRymtm9g
提取码:l0j1

本文又臭又长,学起来挺枯燥的,不过PE倒是不难,坚持下去就好啦。

基本名词解释

为了更好的理解下图中的硬盘与内存映射结构图。

基地址:

PE文件通过Windows载入内存后,内存中的版本称为模块(Module). 映射文件的起始地址称为模块句柄(hModule),可以通过模块句柄访问内存中的其他数据. 这个初始内存地址就称为基地址(ImageBase). 基地址的值是由PE文件本身设定的. 按照默认设置 VC++ 创建的exe基地址为400000h , dll文件的基地址是10000000h. 可以在创建程序的exe文件时改变这个地址, 方法是在链接应用时 使用链接程序的 /BASE选项, 或者在链接后通过REBASE应用程序时进行设置.

基地址可以理解为 内存中整个PE文件的头地址

虚拟地址(Virtual Address,VA)

在Windows操作系统中, PE文件被系统加载器映射到内存中,每个程序都有自己的虚拟空间, 这个虚拟空间的内存地址 称为虚拟地址。

坦白来说就是内存中的地址。

相对虚拟地址(Relative Virtual Address ,RVA)

在可执行文件中,有许多地方需要指定内存中的地址。例如,引用全局变量时需要制定他的地址。PE文件尽管有一个首选的载入地址(基地址),但是他们可以载入进程空间的任何地方,所以不能依赖PE的载入点。因此,必须有一个方法来指定地址(不依赖PE载入点的地址)。

为了避免在PE文件中出现绝对内存地址引用了相对虚拟地址的概念。RVA只是内存中一个简单的、相对于PE文件载入地址的偏移位置,它是一个“相对地址”(或称偏移量)。例如,假设一个EXE文件从400000h处载入,而且它的代码区块开始于401000h处,代码区块的RVA计算方法如下:

​ 目标地址401000h - 载入地址 400000h - RVA 1000h

即 虚拟地址(VA)= 基地址 (ImageBase)+ 相对虚拟地址 (RVA)

准确的说,RVA是当PE文件被装载到内存中后,某个数据位置相对于文件头的偏移量。举个栗子,如果Windows装载器将一个PE文件装入到 00400000h处的内存中,而某个区块中的某个数据被装入 0040xxxxh处 ,那么这个数据的RVA 就是(0040xxxxh - 00400000h )= xxxxh,反过来说,将RVA的值加上文件被装载的基地址,就可以找到数据在内存中的实际地址。

小结一下,VA是进程虚拟内存的绝对地址,RVA是指从某基准位置开始的相对地址。

文件偏移地址(Offset)

当PE文件存储在磁盘中时,某个数据的位置相对于文件头的偏移量称为文件偏移地址(FIle Offset)或物理地址(Raw Offset)。文件偏移地址从PE文件的第1个字节开始计数,起始值为0。 用16进制工具(Winhex等) 打开文件时所显示的地址就是文件偏移地址。

Offset = RVA - ImageBase - 节偏移

PE文件

PE文件使用的是一个平面地址空间,所有代码和数据都合并在-起,组成了一个很大的结构。文件的内容被分割为不同的区块( Section,又称区段、节等,在本章中不区分“区块”与“块”), 区块中包含代码或数据,各个区块按页边界对齐。区块没有大小限制,是-一个连续结构。每个块都有它自己在内存中的一套属性,例如这个块是否包含代码、是否只读或可读/写等。认识到PE文件不是作为单- -内存映射文件被载人内存是很重要的。Windows 加载器(又称PE装载器)遍历PE文件并决定文件的哪一部分被映射, 这种映射方式是将文件较高的偏移位置映射到较高的内存地址中。

磁盘文件一 旦被载人内存,磁盘上的数据结构布局和内存中的数据结构布局就是一致的。这样,如果在磁盘的数据结构中寻找一些内容,那么几乎都能在被载人的内存映射文件中找到相同的信息,但数据之间的相对位置可能会改变,某项的偏移地址可能区别于原始的偏移位置。
不管怎样,对所有表现出来的信息,都允许进行从磁盘文件偏移到内存偏移的转换。

PE(Portable Execute)文件是Windows操作系统可执行文件的总称,常见的有exe,dll,ocx,sys等。PE文件是指32位可执行文件,也称为PE32。**64位的可执行文件称为 PE+ 或 PE32+,是PE(PE32)的一种扩展形式(请注意不是PE64)**。

其实PE文件可以是任何后缀,辨识一个文件是不是PE文件,看它是否具有指纹就好了。

image-20210826161616288

如图所示,4D 5A 对应的就是MZ ,是DOS头部,而50 45 对应的PE,是PE文件头。有这两个就可以确定是PE文件格式了。

首先介绍一下PE基本结构

image-20210826162755371

PE结构整体上可以分为:

  1. Dos部分
  2. NT头 : 包含Windows PE文件的主要信息,包括一个’PE’字样的签名,PE文件头(IMAGE_FILE_HEADER) 和 PE可选头(IMAGE_OPTIONAL_HEADER32)
  3. 节表(区块表)
  4. 节数据(区块数据)
  5. 调试信息

DOS部分

DOS部分主要是为了兼容以前的MS_DOS系统,DOS部分可以分为DOS MZ文件头(IMAGE_DOS_HEADER)和DOS块(DOS Stub)组成,PE文件的第一个字节位于一个传统的MS-DOS头部,称作IMAGE_DOS_HEADER,其结构如下:

大小64个字节

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
typedef struct _IMAGE_DOS_HEADER {      
+0h WORD e_magic; //DOS可执行文件标记"MZ"
+2h WORD e_cblp;
+4h WORD e_cp;
+6h WORD e_crlc;
+8h WORD e_cparhdr;
+0ah WORD e_minalloc;
+0ch WORD e_maxalloc;
+0eh WORD e_ss;
+10h WORD e_sp;
+12h WORD e_csum;
+14h WORD e_ip; //DOS代码入口ip
+16h WORD e_cs; //DOS代码入口cs
+18h WORD e_lfarlc;
+1ah WORD e_ovno;
+1ch WORD e_res[4];
+24h WORD e_oemid;
+26h WORD e_oeminfo;
+28h WORD e_res2[10];
+3ch DWORD e_lfanew; //指向PE文件头"PE",0,0
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;

比较重要的字段就两个。

e_magic:

DOS可执行文件标识 。,其值为5A4D。 这个值有一个#define,名为IMAGE_DOS_SIGNATURE,在ASCII表里值为MZ。

e_lfanew:

是PE文件头的相对偏移地址(RVA ),指出真正的PE头的文件偏移位置,占用4字节,位于从文件开始偏移3ch处

说白了就是 指向pe文件头的32位的指针 (告诉了我们pe头在哪)

下面来分析一下:

偏移3c,找到3c处,大小4个字节,按照Intel的小端模式读取的话,字符存储时低位在前,高位在后,所以将次序恢复后,e_lfanew的值就是40,说明40开始就是PE文件头的偏移量,上文也提到40处的值是45 50 h,就是PE.

image-20210826170414922

DOS头部最重要的就是这两个字段,其余中间的部分都是由链接器所写入,可以随意修改,不影响程序的运行.emmmmm,思路是不是可以扩展一下下…..

PE文件头

PE Header 是 PE相关结构NT 映像头(IMAGE_NT_HEADERS)的简称,其中包含许多PE装载器能用到的重要字段。PE文件头由PE文件头标志,标准PE头,扩展PE头三部分组成。PE文件头标志即 “50 45 00 00 ”

PE文件头:

1
2
3
4
5
typedef struct _IMAGE_NT_HEADERS {
+0h DWORD Signature; //PE文件头标志 => 4字节
+4h IMAGE_FILE_HEADER FileHeader; //标准PE头 => 20字节
+18h IMAGE_OPTIONAL_HEADER32 OptionalHeader; //扩展PE头 => 32位下224字节(0xE0) 64位下240字节(0xF0)
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;

Signature字段

在PE文件中,Signture 字段被设置为 00004550h,ASCII码字符是“PE00”,就是PE文件头的开始。

image-20210826182814549

IMAGE_FILE_HEADER(标准PE头):

1
2
3
4
5
6
7
8
9
typedef struct _IMAGE_FILE_HEADER {
+04h WORD Machine; //运行平台
+06h WORD NumberOfSections; //文件的区块数(节的数量)
+08h DWORD TimeDateStamp; //编译器填写的时间戳
+0ch DWORD PointerToSymbolTable; //指向符号表(调试)
+10h DWORD NumberOfSymbols; //符号表中符号的个数(调试)
+14h WORD SizeOfOptionalHeader; //标识扩展PE头(IMAGE_OPTIONAL_HEADER32)大小
+16h WORD Characteristics; //文件属性 => 16进制转换为2进制根据哪些位有1,可以查看相关属性
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;

说一下比较重要的字段。

SizeofOptionalHeader:

紧跟IMAGE_FILE_HEADER,表示数据的大小。在PE文件中,这个数据结构叫做IMAGE_FILE_HEADER ,其大小取决于当前文件是32位还是64位。对32位的PE文件,这个值通常是00E0h;对64位PE32+ 文件,这个值是00F0h。

偏移14h 就是20d 找到 E0 00

image-20210826185017047

Characteristics:

文件属性,有选择地通过几个值的运算得到。这些标志的有效值是定义于winnt.h内的IMAGE _FILE xx值,具体如表 所示。普通EXE文件的这个字段的值一般是010fh,DLL文件的这个字段的值一般是2102h。

​ 属性字段的含义

image-20210826185935083

IMAGE_OPTIONAL_HEADER(扩展PE头):

32位:

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
28
29
30
31
32
33
34
35
36
37
typedef struct _IMAGE_OPTIONAL_HEADER {


+18h WORD Magic; //标准字。PE32: 10B PE64: 20B
+1Ah BYTE MajorLinkerVersion;
+1Bh BYTE MinorLinkerVersion;
+1Ch DWORD SizeOfCode; //所有含有代码的区块的大小 编译器填入 没用(可改)
+20h DWORD SizeOfInitializedData; //所有初始化数据区块的大小 编译器填入 没用(可改)
+24h DWORD SizeOfUninitializedData; //所有含未初始化数据区块的大小 编译器填入 没用(可改)
+28h DWORD AddressOfEntryPoint; //程序执行入口RVA
+2Ch DWORD BaseOfCode; //代码区块起始RVA
+30h DWORD BaseOfData; //数据区块起始RVA


+34h DWORD ImageBase; //内存镜像基址(程序默认载入基地址)
+38h DWORD SectionAlignment; //内存中区块对齐大小
+3Ch DWORD FileAlignment; //文件中区块对齐大小(提高程序运行效率)
+40h WORD MajorOperatingSystemVersion;
+42h WORD MinorOperatingSystemVersion;
+44h WORD MajorImageVersion;
+46h WORD MinorImageVersion;
+48h WORD MajorSubsystemVersion;
+4Ah WORD MinorSubsystemVersion;
+4Ch DWORD Win32VersionValue;
+50h DWORD SizeOfImage; //内存中整个PE文件的映射的尺寸,可比实际值大,必须是SectionAlignment的整数倍
+54h DWORD SizeOfHeaders; //MS-DOS头部、PE文件头、区块表总大小
+58h DWORD CheckSum; //映像校验和,一些系统.dll文件有要求,判断是否被修改
+5Ch WORD Subsystem;
+5Eh WORD DllCharacteristics; //文件特性,不是针对DLL文件的,16进制转换2进制可以根据属性对应的表格得到相应的属性
+60h DWORD SizeOfStackReserve;
+64h DWORD SizeOfStackCommit;
+68h DWORD SizeOfHeapReserve;
+6Ch DWORD SizeOfHeapCommit;
+70h DWORD LoaderFlags;
+74h DWORD NumberOfRvaAndSizes; //数据目录表的项数
+78h IMAGE_DATA_DIRECTORY ; //数据目录表,结构体数组
} IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32;

ps:

IMAGE_OPTIONAL_HEADER64结构有少许变化,PE32中的BaseOfData字段不纯在与PE32+中,在PE32+中Magic的值是020Bh.

首先根据偏移找下找下扩展PE头在哪, 18h=24d, 78h = 120 d

开始和结束

image-20210831221740333

还需要知道的是,程序的真正入口点 = ImageBase + AddressOfEntryPoint

几个重要的字段:

AddressOfEntryPoint(程序的入口点,免杀的最后一步)

程序执行入口RVA。对于dll,这个入口点在进程初始化和关闭时及线程创建和毁灭时被调用。在大多数可执行文件中,这个地址不直接指向Main、WinMain 或DllMain函数,而指向运行时的库代码并由它来调用上述函数。在DLL中,这个字段能被设置为0,链接器的 /NOENTRY开关可以设置这个字段为0。

验证一下

image-20210826201922104

找到了这个和基地址,找到了内存中程序真正的入口点。

image-20210826202510754

ImageBase

基址(映像的基地址),文件在内存中的首选载入地址。如果目前没有其他文件占据这块地址,加载器会尝试在这个地址载入PE文件。如果PE文件是在这个地址载入的,那么加载器将跳过应用基址重定位的步骤。

SectionAlignment

载入内存时的区块对齐大小(节对齐)。PE中的区块被加载到内存时会按照这个字段指定的值来对齐,每个区块被载入的地址必定是本字段指定数值的整数倍。比如这个值是0x1000,那么每个区块的起始地址的低12位都是0.

FileAlignment

磁盘上PE文件内的区块对齐大小。对于x86文件来说,这个值通常是200h或1000h,这是为了保证块总是从磁盘的扇区开始。SectionAlignment必须大于或等于FileAlignment。

Win32VersionValue

一个从来不用的字段,保留,通常被设置为0。

SizeOfImage

映像载入内存的总大小(占用虚拟空间的大小),这个大小指的是从ImageBase到最后一个块的大小。最后一个块根据其大小向上取整。

SizeOfHeaders

所有文件头(包括节表)的大小,即MS_Dos头部、PE文件头、区块表的总大小。这个值是以FileAlignment对齐的。

需要注意的是,FileAlignment 以及 SizeOfHeaders 这两个字段,因为SizeOfHeaders是根据FileAlignment来对齐的,如果所有的头加上节表的大小是300,FileAlignment 是200,那么SizeOfHeaders的大小就为400。这种对齐虽然牺牲了空间,但是可以提高程序运行效率。需要清楚的是,PE程序在运行时内存中的对齐值和没有运行时的对齐值可能是截然不同的。

CheckSum

映像的校验和。

DataDirectory

数据目录表。由数个相同的IMAGE_DATA_DIRECTORY结构组成,指向输出表、输入表、资源块等数据。IMAGE_DATA_DIRECTORY的结构如下:

1
2
3
4
typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress; //数据块的起始RVA
DWORD Size; //数据块的长度
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;

数据目录表的成员结构:

image-20210831233739807

对应的信息

PE文件信息

image-20210901001914087

PE文件数据目录表信息

image-20210901001549342

区块(节)(Section table)

在PE文件头和原始数据之间存在一个区块表。区块表中包含每个块在映像(内存)中的信息,分别指向不同的区块实体。

区块表(节表)

大小为40字节

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typedef struct _IMAGE_SECTION_HEADER {
BYTE Name[IMAGE_SIZEOF_SHORT_NAME]; //ASCII字符串 可自定义 只截取8个字节
union { //该节在没有对齐之前的真实尺寸,该值可以不准确
DWORD PhysicalAddress;
DWORD VirtualSize;
} Misc;
DWORD VirtualAddress; //内存中的偏移地址
DWORD SizeOfRawData; //节在文件中对齐的尺寸
DWORD PointerToRawData; //节在文件中的偏移
DWORD PointerToRelocations; //在obj文件中使用,重定位的偏移
DWORD PointerToLinenumbers;
WORD NumberOfRelocations;
WORD NumberOfLinenumbers;
DWORD Characteristics; //节的属性
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;

这里结合实例来分析一下,.test、.rdata、.data三个块的描述,每个块对应于一个IMAGE_SECTION_HEADER结构,

image-20210901003413827

在winhex里打开看下

从左向右依次排序,我这就不标序号了

image-20210901005747191

  1. name:块名
  2. VirtualSize:实际被使用的区块的大小,进行对齐处理前区块的实际大小,这里是6000h,指的是内存中的大小。

image-20210901010243392

VOffset:内存中的起始位置(虚拟地址)

VSize:内存中的大小(虚拟大小)

ROffset:文件中的起始位置

RSize:文件中的大小

3.VirtualAddress:内存中的偏移地址。是按照内存页对齐的,它的数值总是SectionAlignment的整数倍。

4.SizeOfRawData:节(区块)在文件(磁盘)中所占的空间。在可执行文件中,该字段是已经被FileAlignment 潜规则处理过的长度。例如,指定FileAlignment的值为200h,如果VIrtualSize中的块长度为19h字节,这块应该保存的长度为200h字节。

5.PointerToRawData:节在文件中的偏移,这个数值是从文件头开始算起的偏移量。程序经编译或汇编后生成原始数据,这个字段用于给出原始数据在文件中的偏移。如果程序自装载PE或COFF文件(而不是由操作系统载入的),这一字段将比VirtualAddress还重要。这种情况下,必须完全使用线性映像的方法载入文件,所以需要在该偏移处找到块的数据,而不是VirtualAddress字段中的RVA地址。

  1. Characteristic:区块的属性。该字段是按位来指出区块的属性(如代码/数据/可读/可写等)的标志。

    字段属性

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. 该区块可写。

常见区块与区块合并

一般来说,一个PE文件会包含至少代码块和数据块两个区块,诶个区块都有特定的名字用于区别区块的用途,区块在映像中是按照RVA排列的。EXE和OBJ文件一些常见的区块表如下图

image-20210901012832517

image-20210901012902813

用户还可以自己创建和命名自己的区块,在vc++中用#pragma来声明,告诉编译器将数据插入一个区块,代码如下

1
#pragma data_seg("MY_DATA")

这样,vc++处理的数据都将放进一个叫MY_DATA的区块内,而不是默认的.data区块。
如果两个区块拥有相似或相同的属性,那么他们在链接时就能够合并成一个区块。

合并区块的优点是节省磁盘和内存空间。合并区块没有什么硬性规定,但是不应该把.rsrc、.reloc或.pdata合并到其他区块里。因为部分输入数据是在载入内存时由Windows加载器写入的,所以对于那些只读区块,系统会临时修改那些包含输入数据的页属性为可读可写,初始化完成后恢复成原来的属性。

区块的对齐值

在PE文件头里,FileAlignment定义磁盘区块的对齐值。每一个区块从对齐值的倍数的偏移位置开始存放。而区块的实际代码或数据的大小不一定刚好是这么多,所以在多余的地方一般以00h 来填充,这就是区块间的间隙。
在PE文件头中,SectionAlignment定义了内存中区块的对齐值。PE 文件被映射到内存中时,区块总是至少从一个页边界开始。一般在X86 系列的CPU 中,页是按4KB(1000h)来排列的;在IA-64 上,是按8KB(2000h)来排列的。所以在X86 系统中,PE文件区块的内存对齐值一般等于 1000h,每个区块按1000h 的倍数在内存中存放。

有的PE文件为了减少体积,磁盘对齐值是200h。这类文件被映射到内存中后,数据相对于文件头的偏移量在内存中和磁盘文件中是不同的,这个时候就需要文件偏移地址与虚拟地址的转换问题,前文已经提到怎么转换。

image-20210901013215633

image-20210901082844364

注意到,MS-DOS头部、PE文件头和节表(区块表)的位置没有发生变化,而各个节(区块)被映射到内存中,偏移部分发生了变化,中间的空白以0填充。

输入表(Import Table):

可执行文件使用来自其他DLL的代码或数据的动作称为输入。输入函数就是被程序调用但是执行代码又不在程序中的函数,这些函数的代码位于相关的dll文件中,在调用程序中只保留线管的函数信息(函数名、DLL文件名等)。

对于磁盘上的文件,它无法得知这些输入函数在内存中的子hi,只有当PE文件被装入内存后,Windows加载器才将相关DLL装入,并将调用输入函数的指令和函数实际所处的地址联系起来。这就是”动态链接“的概念。

动态链接是通过PE文件中定义的”输入表“来完成的,输入表中保存的正是函数名和其驻留的DLL名等。

输入表的结构(IMAGE_IMPORT_DESCRIPTOR)

在PE文件的IMAGE_OPTIONAL_HEADER中, 数据目录表的第二个成员指向输入表,输入表以一个IMAGE_IMPORT_DESCRIPTOR(IID)数组开始。

1
2
3
4
5
6
7
8
9
10
11
typedef struct _IMAGE_IMPORT_DESCRIPTOR {
00h union {
DWORD Characteristics;
DWORD OriginalFirstThunk; // RVA 指向 INT (PIMAGE_THUNK_DATA结构数组)
} DUMMYUNIONNAME;
04h DWORD TimeDateStamp;
08h DWORD ForwarderChain;
0ch DWORD Name; //RVA指向dll名字,以0结尾
10h DWORD FirstThunk; // RVA 指向 IAT (PIMAGE_THUNK_DATA结构数组)
} IMAGE_IMPORT_DESCRIPTOR;
typedef IMAGE_IMPORT_DESCRIPTOR UNALIGNED *PIMAGE_IMPORT_DESCRIPTOR;

CharacteristicsOriginalFirstThunk:一个联合体,如果是数组的最后一项 Characteristics 为 0,否则 OriginalFirstThunk 保存一个 RVA,指向一个 IMAGE_THUNK_DATA 的数组,这个数组中的每一项表示一个导入函数。

name:DLL名字的指针(RVA),指向导入模块的名字,它是一个以”00“结尾的ASCII字符的RVA地址,该字符串中包含输入的DLL名。

FirstThunk:一个 RVA,指向输入地址表(Import_Address_Table),IAT是一个 IMAGE_THUNK_DATA 数组。

很显然,OriginalFirstThunk和FirstThunk相似。他们分别指向两个本质上相同的数组IMAGE_THUNK_DATA结构。即输入名称表(Import Name Table,INT)和输入地址表(Import Address Table, IAT).

下图表示PE文件加载前的情况,可执行文件正在从USER32.dll输入一些API。

image-20210901091654810

PE文件加载后的IAT(IMPORT_ADDRESS_TABLE):

此时,所有函数入口地址排列在一起,输入表中的其他部分就不重要了,程序依靠IAT提供的函数地址就可以正常运行。

image-20210902001836879

到这才发现LordPE的用法……

image-20210901085737878

image-20210901085841368

这里再介绍两种结构:

IMAGE_THUNK_DATA:

1
2
3
4
5
6
7
8
9
typedef struct _IMAGE_THUNK_DATA32 {
union {
DWORD ForwarderString; // 指向一个转向者字符串的RVA
DWORD Function; // 被输入的函数的内存地址
DWORD Ordinal; //被输入的API的序数值
DWORD AddressOfData; // 指向IMAGE_IMPORT_BY_NAME
} ;
} IMAGE_THUNK_DATA32;
typedef IMAGE_THUNK_DATA32 * PIMAGE_THUNK_DATA32;

当这个结构的高位为1的时候,表示该函数以序号的形式输入,这时低31位会被视作一个函数序号,当高位为0的时候,表示函数以字符串类型的函数名方式输入,这是其值是一个RVA,指向一个IMAGE_IMPORT_BY_NAME结构。

IMAGE_IMPORT_BY_NAME

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

HInt是本函数在其所驻留的DLL中的的序号。PE装在其可以用来在DLL的输出表里快速查询函数。
Name是函数名的ASCII字符串,实际上是一个变长结构,以NULL为结尾。

其实这两个结构的作用很明显,就是用来寻找当前的模块依赖哪些函数,可以用这几个函数体求到依赖函数的名字。

此外,除了一般的输入表,还有延时输入表,这个感兴趣的朋友可以自行去了解。

输出表(Export Table):

exe文件中一般不存在输出表,大部分dll文件中存在输出表.

image-20210902002938013

1
2
3
4
5
6
7
8
9
10
11
12
13
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;

可以看到导出表里面最后还有三个表,这三个表可以让我们找到函数真正的地址,在编写PE格式解析器的时候可以用到,AddressOfFunctions 是函数地址表,指向每个函数真正的地址,AddressOfNames 和 AddressOfNameOrdinals 分别是函数名称表和函数序号表,我们知道DLL文件有两种调用方式,一种是用名字,一种是用序号,通过这两个表可以用来寻找函数在 AddressOfFunctions 表中真正的地址。

如下图是一个典型的输出表.

image-20210902003034748

基址重定位:

这一节对研究病毒原理的影响比较大,emmm,dddd。

当向程序的虚拟内存加载PE文件时,文件会被加载到ImageBase所指向的地址。
对EXE文件来说,EXE文件会首先加载到内存,每个文件总是使用独立的虚拟地址空间,这就意味着EXE文件不用考虑基址重定位问题;

对于DLL文件来说,多个DLL文件使用调用其本身的宿主EXE文件的地址空间,不能保证ImageBase所指向的地址没有被其他DLL文件占用,所以DLL文件当中必须包含重定位信息

简单来说:

重定位就是你本来这个程序理论上要占据这个地址,但是由于某种原因,这个地址现在不能让你霸占,你必须转移到别的地址,这就需要基址重定位。

当PE文件被装载到虚拟内存的另一个地址中的时候,也就是载入时不将默认的值作为基地址载入,链接器登记的哪个地址是错误的,需要我们用重定位表来调整

总结来说,凡是涉及到直接寻址的指令都需要进行基址重定位。(凡是在机器码看到有地址的,都是直接寻址;其他的比如地址放在寄存器中,通过访问寄存器来获取地址叫间接寻址,详情参考汇编)

比如下面这些都是需要基址重定位的。

image-20210902135413148

重定位的算法:将直接寻址指令中的双字地址加上模块的实际装入地址与模块建议装入地址之差。

进行重定位需要三个因素:

1
2
3
1.需要修正的地址
2.建议装入的地址
3.实际装入的地址

那些信息应该保存在重定位表中?

1.建议装入的地址在PE 文件头中已经定义了

2.实际装入的地址在没有被装载器装入我们根本无从得知,也就是说这事天不知地不知我们不知只有装载器知道…

所以,PE 文件的重定位表(Base Relocation Table)中保存的就是文件中所有需要进行重定位修正的代码的地址。

在PE文件中,重定位表往往单独作为一块,用“.reloc”表示。

重定位表有许多个,以八个字节的 0 结尾

找基址重定位表的方法是通过数据目录表的IMAGE_DIRECTORY_ENTRY_BASERELOC 查找。

1
2
3
4
5
6
typedef struct _IMAGE_BASE_RELOCATION {
DWORD VirtualAddress; // 重定位数据的开始 RVA 地址
DWORD SizeOfBlock; // 重定位块的长度
// WORD TypeOffset[1]; // 重定位项数组
} IMAGE_BASE_RELOCATION;
typedef IMAGE_BASE_RELOCATION UNALIGNED * PIMAGE_BASE_RELOCATION;

VirtualAddress: 是 Base Relocation Table(当前重定位结构)开始 的位置,它是一个 RVA 值;

SizeOfBlock:是 Base Relocation Table 的大小;

TypeOffset :是一个数组,数组每项大小为两个字节(16位),它由高 4位和低 12位组成,高 4位代表重定位类型,低 12位是重定位地址,它与 VirtualAddress 相加即是指向PE 映像中需要修改的那个代码的地址。

重定位表示意图

用lordPE找一下重定位表的RVA,

image-20210902151144275

image-20210902151247681

重定位块的长度为58h,即28h有个重定位数据(58h-8h/2=28h),因为VirtualAddress和SizeOfBlock的大小都是固定的4字节,所以SizeOfBlock的值 减8 就是TypeOffSet数组的大小。

所以从这里开始的28h个数据都是需要被重定位的。

image-20210902153250980

执行PE文件前,加载程序会在进行重定位的时候,会用PE文件在内存中的实际映像地址减PE文件中所要求的映像地址,根据重定位类型的不同,将差值添加到相应的地址数据中。

总结一下那些项目需要重定位:

1
2
3
4
5
1.代码中使用全局变量的指令,因为全局变量一定是模块内的地址,而且使用全局变量的语句在编译后会产生一条引用全局变量基地址的指令。

2.将模块函数指针赋值给变量或作为参数传递,因为赋值或传递参数是会产生mov和push指令,这些指令需要直接地址。

3.C++中的构造函数和析构函数赋值虚函数表指针,虚函数表中的每一项本身就是重定位项。

参考链接:

https://blog.csdn.net/ski_12/article/details/80636568

资源目录

资源是PE文件中最复杂的结构了,资源在PE文件中是以目录结构的形式存在的,一般情况下分为3层

1
2
3
第一层          资源类型的数量(资源类型)
第二层 资源的数量(资源ID)
第三层 资源数据的数量(资源代码页)

示意图:

image-20210903003255746

IMAGE_RESOURCE_DIRECTORY(资源目录结构):

1
2
3
4
5
6
7
8
9
typedef struct _IMAGE_RESOURCE_DIRECTORY {
DWORD Characteristics;          //资源属性,通常为0
DWORD TimeDateStamp;           //时间戳
WORD MajorVersion;            //资源大版本号
WORD MinorVersion;            //资源小版本号
WORD NumberOfNamedEntries;       //按照名称命名的数量
WORD NumberOfIdEntries;         //按照ID命名的数量
// IMAGE_RESOURCE_DIRECTORY_ENTRY DirectoryEntries[];
} IMAGE_RESOURCE_DIRECTORY, *PIMAGE_RESOURCE_DIRECTORY;

按照惯例,有用的就两个字段:

1.NumberOfNamedEntries(按照名称命名的数量)

意思就是我们的资源是字符串命名加载的有多少个

2.NumberOfIdEntries(按照ID命名的数量)

意思就是我们的资源如果按照ID有多少个,一般都是用ID的.

最后两个字段主要是资源的标识,是以ID的有多少个,以字符串标识的有多少个.

IMAGE_RESOURCE_DIRECTORY_ENTRY(资源目录入口结构(子目录))

这个结构是一个共用体,长度为8字节。

其中第一个DWORD大小,看高位,如果高位是1,那么低31位是指向新的目录项名称的结构体IMAGE_RESOURCE_DIR_STRING_U

如果高位为0,则是ID号,这个ID号说的是 资源ID类型,比如3类型指的就是ICON

第二个DWORD量,也是RVA偏移,如果高位为1,那么代表它还是一个目录,也就是指向了一个新的根目录了,这是个不断递归的过程.

如果不是,则指向文件偏移结构体了.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
typedef struct _IMAGE_RESOURCE_DIRECTORY_ENTRY {
union {
struct {
DWORD NameOffset:31;       //位段: 低31位飘逝偏移 定义了目录项的名称或者ID
DWORD NameIsString:1;      //位段: 高位, 如果这位为1,则表示31位的偏移指向的是一个Unicode字符串的指针偏移
};                       
//这里列出结构体,自己去看,IMAGE_RESOURCE_DIR_STRING_U 里面是字符串长度还有字符串,不是\0结尾         
DWORD Name;                 
WORD Id;
};
union {
DWORD OffsetToData;           //偏移RVA因为是联合体,所以有不同的解释
struct {
DWORD OffsetToDirectory:31;    //看高位,如果高位是1,那么RVA偏移指向的是新的(根目录)
DWORD DataIsDirectory:1;      
};
};
} IMAGE_RESOURCE_DIRECTORY_ENTRY, *PIMAGE_RESOURCE_DIRECTORY_ENTRY;

IMAGE_RESOURCE_DATA_ENTRY(资源数据入口)

1
2
3
4
5
6
typedef struct _IMAGE_RESOURCE_DATA_ENTRY {
DWORD OffsetToData; //资源数据的RVA
DWORD Size; //资源数据的长度
DWORD CodePage;
DWORD Reserved;
} IMAGE_RESOURCE_DATA_ENTRY, *PIMAGE_RESOURCE_DATA_ENTRY;

OffsetToData是指向资源数据的指针。

参考链接:

https://www.cnblogs.com/iBinary/p/7712932.html

TLS表(Thread local storage,线程局部存储):

在研究TLS表之前,最好先了解Windows线程和进程之间的关系,这里不细说。所以这里的TLS也只是简单提一下。

TLS意思就是,每个线程都有自己的空间,局部存储。

作用:解决多线程程序设计中同步变量的问题。

实现TLS初始化有两种方式:

  • 动态线程局部存储技术
  • 静态线程局部存储技术

动态线程局部存储技术主要通过4个api函数。

1
2
3
4
TlsAlloc( ):分配线程局部存储空间/索引,该进程任何线程都可以通过该索引来存储和检索线程中的值。
TlsFree( ): 释放线程局部存储空间/索引。
TlsGetValue( ): 获得线程局部存储空间里面的值,按索引取值。
TlsSetValue( ): 设置线程局部存储空间的值,按索引存储。

TLS结构表

1
2
3
4
5
6
7
8
9
10
typedef struct _IMAGE_TLS_DIRECTORY32 
{
DWORD StartAddressOfRawData; // TLS初始化数据的起始地址
DWORD EndAddressOfRawData; // TLS初始化数据的结束地址 两个正好定位一个范围,范围放初始化的值
PDWORD AddressOfIndex; // TLS 索引的位置
PIMAGE_TLS_CALLBACK *AddressOfCallBacks; // Tls回调函数的数组指针
DWORD SizeOfZeroFill; // 填充0的个数
DWORD Characteristics;
} IMAGE_TLS_DIRECTORY32

很多实例没有分析,建议自行分析。