PE结构

Windows逆向最基础的就是要对PE结构烂熟于心,我之前做了很多题又怎样呢,还是对PE结构一知半解,地基打不好就别梦想盖高楼。知其然知其所以然,千万不要图“快”而忽略一些重要的细节,慢就是快,这也是我这段时间悟出来的道理。

1. PE的基本概念

EXE和DLL文件之间的区别完全是语义上的,因为它们使用的是完全相同的PE格式,而唯一的区别就是用一个字段标识出这个文件是EXE还是DLL。

64位Windows只是对PE格式做了一些简单的修饰,格式为PE32+,并没有任何新的结构加进去,改变的只是将32位字段扩展为64位。

PE格式的定义主要位于头文件winnt.h,这个头文件几乎能找到关于PE文件的所有定义。

PE文件中的数据结构一般都有32位和64位之分,一般名称上会表现出来,例如:IMAGE_NT_HEADERS32IMAGE_NT_HEADER64

PE文件使用的是一个平面地址空间,所有代码和数据都合并在一起,组成一个很大的结构。文件的内容被分割为不同的区块,块中包含代码或数据。各个区块按页边界来对齐,区块没有大小限制,是一个连续的结构。此外,每个块有自己在内存中的一套属性,比如区块是否包含代码、是否只读或可读可写等。

PE文件不是作为单一内存映射文件被装入内存的。Windows加载器(又称PE装载器)遍历PE文件并决定文件的哪一部分被映射,这种映射方式是将文件较高的偏移位置映射到较高的内存地址中。当磁盘文件一旦被装入内存中,磁盘上的数据结构布局和内存中的数据结构布局是一致的。但数据之间的相对位置可能改变,其某项偏移地址可能不同于原始的偏移地址。

由上图可以看出,文件被映射到内存,DOS文件头、PE文件头、区块表的偏移位置和大小都没有发生改变。而各区块映射到内存后,其偏移位置发生了改变。

当PE文件被Windows加载器加载到内存后,内存中的PE文件称之为模块(module)。映射文件的起始地址被称之为模块的句柄(handle)。

在Windows NT中,获取了模块的句柄相当于获取了基地址,获取了基地址相当于获取了PE结构的文件头。

  • 基地址(ImageBase):整个PE结构中的头地址,也就是模块的句柄。可用HMODULE GetModuleHandle(LPCTSTR lpModuleName);去获取模块的句柄。
  • 文件偏移地址(File Offset Address):当PE文件存放在磁盘中,各个区块对于文件头的相对偏移地址,文件头的起始地址从0开始。
  • 虚拟地址(Virtual Address):PE文件映射到内存中会发生比例的变化,导致与在磁盘中的地址不一致,这个内存中的地址就是虚拟地址。
  • 相对虚拟地址(Relative Virtual Address):相对于基地址的偏移。

2. MS-DOS头部

每个PE文件是以一个DOS程序开始的,有了它,一旦程序在DOS下执行,DOS才能识别出这是有效的执行体。

PE文件的第一个字节起始于一个传统的MS-DOS头部,被称作IMAGE_DOS_HEADER

2.1 IMAGE_DOS_HEADER

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
IMAGE_DOS_HEADER STRUCT 
{
+00h WORD e_magic // Magic DOS signature MZ(4Dh 5Ah) DOS可执行文件标志
+02h WORD e_cblp // Bytes on last page of file
+04h WORD e_cp // Pages in file
+06h WORD e_crlc // Relocations
+08h WORD e_cparhdr // Size of header in paragraphs
+0ah WORD e_minalloc // Minimun extra paragraphs needs
+0ch WORD e_maxalloc // Maximun extra paragraphs needs
+0eh WORD e_ss // intial(relative)SS value DOS代码的初始化堆栈SS
+10h WORD e_sp // intial SP value DOS代码的初始化堆栈指针SP
+12h WORD e_csum // Checksum
+14h WORD e_ip // intial IP value DOS代码的初始化指令入口[指针IP]
+16h WORD e_cs // intial(relative)CS value DOS代码的初始堆栈入口
+18h WORD e_lfarlc // File Address of relocation table
+1ah WORD e_ovno // Overlay number
+1ch WORD e_res[4] // Reserved words
+24h WORD e_oemid // OEM identifier(for e_oeminfo)
+26h WORD e_oeminfo // OEM information;e_oemid specific
+29h WORD e_res2[10] // Reserved words
+3ch DWORD e_lfanew // Offset to start of PE header 指向PE文件头
}IMAGE_DOS_HEADER ENDS

两个重要字段:

  • e_magic:必须为MZ(4D 5A)开头,大小为2个字节。
  • e_lfanew:PE文件头的起始偏移地址,大小为4个字节。

DOS头后跟一个DOS stub数据,是链接器链接执行文件的时候加入的部分数据,一般是“This program must be run under Microsoft Windows”。这个可以通过修改链接器的设置来修改成自己定义的数据。

3. PE文件头

紧跟着DOS stub的是PE头文件(PE Header)。PE Header是PE相关结构NT映像头(IMAGE_NT_HEADER)的简称,其中包含许多PE装载器用到的重要字段。执行体在支持PE文件结构的操作系统中执行时,PE装载器将从IMAGE_DOS_HEADER结构中的e_lfanew字段里找到PE Header的起始偏移量,加上基地址得到PE文件头的指针。

PE头的数据结构被定义为IMAGE_NT_HEADERS

3.1 IMAGE_NT_HEADERS

1
2
3
4
5
6
IMAGE_NT_HEADERS STRUCT
{
+0h DWORD Signature
+4h IMAGE_FILE_HEADER FileHeader
+18h IMAGE_OPTIONAL_HEADER32 OptionalHeader
}IMAGE_NT_HEADERS ENDS
  • Signature:在一个有效的PE文件里,被设置为00004550h,也就是“PE..”,标志这是PE文件头的开始。
  • FileHeader:映像头文件结构包含了文件的物理层信息及文件属性,占20字节。
  • OptionalHeader:可选映像头是一个可选的结构,实际上IMAGE_FILE_HEADER结构不足以定义PE文件属性,因此可选映像头中定义了更多的数据。总共224个字节,最后128个字节为数据目录(Data Directory)。

3.2 IMAGE_FILE_HEADER

从“+4h”开始是相对于IMAGE_NT_HEADERS结构中的。

1
2
3
4
5
6
7
8
9
typedef struct _IMAGE_FILE_HEADER {
+4h WORD Machine; //运行平台
+6h WORD NumberOfSections; //文件的区块数目
+8h DWORD TimeDateStamp; //文件创建日期和时间
+0Ch DWORD PointerToSymbolTable; //指向符号表(用于调试)
+10h DWORD NumberOfSymbols; //符号表中符号个数(用于调试)
+14h WORD SizeOfOptionalHeader; //IMAGE_OPTIONAL_HEADER32结构大小
+16h WORD Characteristics; //文件属性
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;
  • Machine:可执行文件的目标CPU类型。
Value Meaning
IMAGE_FILE_MACHINE_I386
0x014c
x86
IMAGE_FILE_MACHINE_IA64
0x0200
Intel Itanium
IMAGE_FILE_MACHINE_AMD64
0x8664
x64
  • NumberOfSections:区块的数目。(区块表是紧跟在IMAGE_NT_HEADERS后面的)

  • TimeDataStamp:表明文件是何时被创建的。这个值是自1970-1-1以来用格林威治时间计算的秒数。

  • PointerToSymbolTable:COFF符号表的文件偏移位置,现在基本没用了。

  • NumberOfSymbols:如果有COFF符号表,它代表其中的符号数目,COFF符号是一个大小固定的结构,如果想找到COFF符号表的结束位置,则需要这个变量。

  • SizeOfOptionalHeader:紧跟着IMAGE_FILE_HEADER后面的数据结构(IMAGE_OPTIONAL_HEADER)的大小。对于32位PE文件,这个值通常为00E0h,对于64位,值为00F0h。

  • Characteristics:文件属性,有选择地通过几个值可以运算得到。这些标志的有效值是定义于winnt.h内的IMAGE_FILE_xxx的值,具体含义见下表。普通EXE文件的字段值一般为0100h,DLL文件值为210Eh。多种属性可通过“或运算”同时拥有。

Value Meaning
IMAGE_FILE_RELOCS_STRIPPED
0x0001
Relocation information was stripped from the file. The file must be loaded at its preferredbase address. If the base address is notavailable, the loader reports an error.
IMAGE_FILE_EXECUTABLE_IMAGE
0x0002
The file is executable (there are no unresolved external references).
IMAGE_FILE_LINE_NUMS_STRIPPED
0x0004
COFF line numbers were stripped from the file.
IMAGE_FILE_LOCAL_SYMS_STRIPPED
0x0008
COFF symbol table entries were stripped from file.
IMAGE_FILE_AGGRESIVE_WS_TRIM
0x0010
Aggressively trim the working set. This value is obsolete as of Windows 2000.
IMAGE_FILE_LARGE_ADDRESS_AWARE
0x0020
The application can handle addresses larger than 2 GB.
IMAGE_FILE_BYTES_REVERSED_LO
0x0080
The bytes of the word are reversed. This flag is obsolete.
IMAGE_FILE_32BIT_MACHINE
0x0100
The computer supports 32-bit words.
IMAGE_FILE_DEBUG_STRIPPED
0x0200
Debugging information was removed and stored separately in another file.
IMAGE_FILE_REMOVABLE_RUN_FROM_SWAP
0x0400
If the image is on removable media, copy it toand run it from the swap file.
IMAGE_FILE_NET_RUN_FROM_SWAP
0x0800
If the image is on the network, copy it to and run it from the swap file.
IMAGE_FILE_SYSTEM
0x1000
The image is a system file.
IMAGE_FILE_DLL
0x2000
The image is a DLL file. While it is an executable file, it cannot be run directly.
IMAGE_FILE_UP_SYSTEM_ONLY
0x4000
The file should be run only on a uniprocessor computer.
IMAGE_FILE_BYTES_REVERSED_HI
0x8000
The bytes of the word are reversed. This flag is obsolete.

3.3 IMAGE_OPTIONAL_HEADER32

从“+18h”是相对于IMAGE_NT_HEADERS结构中的。

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
typedef struct _IMAGE_OPTIONAL_HEADER {
+18h WORD Magic; //标志字
+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
//以下属于NT结构增加的领域
+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; //保留,通常设置为0
+50h DWORD SizeOfImage; //映像装入内存后的总大小
+54h DWORD SizeOfHeaders; //DOS头、PE头、区块表总大小
+58h DWORD CheckSum; //映像校验和
+5Ch WORD Subsystem; //可执行文件期望的子系统【重要】
+5Eh WORD DllCharacteristics; //显示DLL特性的旗标
+60h DWORD SizeOfStackReserve; //初始化堆栈大小
+64h DWORD SizeOfStackCommit; //初始化实际提交堆栈大小
+68h DWORD SizeOfHeapReserve; //初始化保留堆栈大小
+6Ch DWORD SizeOfHeapCommit; //初始化实际保留堆栈大小
+70h DWORD LoaderFlags; //与调试相关,默认值为0
+74h DWORD NumberOfRvaAndSizes; //数据目录表的项数(总是16)
+78h IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];//数据目录表【重要】
} IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32;

事实上,这个结构中的大部分字段都不重要,但有些病毒恰恰利用这些字段做手脚。

  • AddressOfEntryPoint:文件被执行时的入口地址,这是一个RVA地址。如果在一个可执行文件上附加了一段代码并想让这段代码首先被执行,那么只需要将这个入口地址指向附加的代码即可。

  • ImageBase:文件的优先装入地址。文件被执行时,如果可能的话,Windows优先将文件装入到由ImageBase字段指定的地址中,只有指定的地址已经被其它模块使用时,文件才被装入到其它地址中。链接器产生可执行文件的时候对应这个地址来生成机器码,所以当文件被装入这个地址时不需要进行重定位操作,装入的速度最快,如果文件被装载到其它地址的话,将不得不进行重定位操作,这样就要慢一点。

    对于EXE文件来说,由于每个文件总是使用独立的虚拟地址空间,优先装入地址不可能被其它模块占据,所以EXE总是能够按照这个地址装入,这也意味着EXE文件不再需要重定位信息。

    对于DLL文件来说,由于多个DLL文件全部使用宿主EXE文件的地址空间,不能保证优先装入地址没有被其它的DLL使用,所以DLL文件中必须包含重定位信息以防万一。因此,在前面介绍的IMAGE_FILE_HEADER结构的Characteristics字段中,DLL文件对应的IMAGE_FILE_RELOCS_STRIPPED位总是为0,而EXE文件的这个标志位总是为1。

    在链接的时候,可以通过对link.exe指定/base:address选项来自定义优先装入地址,如果不指定这个选项的话,一般EXE文件的默认优先装入地址被定为00400000h,而DLL文件的默认优先装入地址被定为10000000h。

  • SectionAlignment和FileAlignment:SectionAlignment字段指定了节被装入内存后的对齐单位。也就是说,每个节被装入的地址必定是本字段指定数值的整数倍。在内存中对齐默认以1000h为单位。而FileAlignment字段指定了节存储在磁盘文件中时的对齐单位。在磁盘中对齐默认以200h为单位。

    在64位下内存对齐以2000h为单位,如果32位程序想在64位系统下实现兼容,将SectionAlignment值设为2000h即可。

  • Subsystem:指定使用界面的子系统,它的取值如下表所示。这个字段决定了系统如何为程序建立初始的界面,链接时的/subsystem:xxx选项指定的就是这个字段的值。如果将子系统指定为Windows CUI,那么系统会自动为程序建立一个控制台窗口,而指定为Windows GUI的话,窗口必须由程序自己建立。

取值 Windows.inc中的预定义值 含义
0 IMAGE_SUBSYSTEM_UNKNOWN 未知的子系统
1 IMAGE_SUBSYSTEM_NATIVE 不需要子系统(如驱动程序)
2 IMAGE_SUBSYSTEM_WINDOWS_GUI Windows图形界面
3 IMAGE_SUBSYSTEM_WINDOWS_CUI Windows控制台界面
5 IMAGE_SUBSYSTEM_OS2_CUI OS2控制台界面
7 IMAGE_SUBSYSTEM_POSIX_CUI POSIX控制台界面
8 IMAGE_SUBSYSTEM_NATIVE_WINDOWS 不需要子系统
9 IMAGE_SUBSYSTEM_WINDOWS_CE_GUI Windows CE图形界面
  • DataDirectory:最重要字段之一,它由16个相同的IMAGE_DATA_DIRECTORY结构组成,虽然PE文件中的数据是按照装入内存后的页属性归类而被放在不同的节中的,但是这些处于各个节中的数据按照用途可以被分为导出表、导入表、资源、重定位表等数据块,这16个IMAGE_DATA_DIRECTORY结构就是用来定义多种不同用途的数据块的。IMAGE_DATA_DIRECTORY结构的定义很简单,它仅仅指出了某种数据块的位置和长度。

    DataDirectoryOptionalHeader的最后128个字节,也是IMAGE_NT_HEADERS的最后一部分数据。

3.4 IMAGE_DATA_DIRECTORY

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

16个数据目录的含义:

在PE文件中寻找特定的数据时就是从这些IMAGE_DATA_DIRECTORY结构开始的,比如要存取资源,那么必须从第3个IMAGE_DATA_DIRECTORY结构(索引为2)中得到资源数据块的大小和位置;同理,如果要查看PE文件导入了哪些DLL文件的哪些API函数,那就必须首先从第2个IMAGE_DATA_DIRECTORY结构得到导入表的位置和大小。

4. 区块表(节表)

4.1 PE文件到内存的映射

在执行一个PE文件的时候,Windows并不在一开始就将整个文件读入内存,而是采用与内存映射文件类似的机制。也就是说,Windows装载器在装载的时候仅仅建立好虚拟地址和PE文件之间的映射关系。当且仅当真正执行到某个内存页中的指令或者访问某一页的数据时,这个页面才会被从磁盘提交到物理内存,这种机制使文件装入的速度和文件大小没有太大关系。

需要注意的是,系统装载可执行文件的方法又不完全等同于内存映射文件。当使用内存映射文件的时候,数据本身和数据之间的相对位置是完全相同的;而在装载可执行文件的时候,有些数据在装入前会被预处理,如重定位等,正因此,装入以后,数据之间的相对位置可能发生微妙的变化。

Windows装载器装载DOS头部、PE文件头和区块表部分是不进行任何特殊处理的,而在装载区块的时候则会自动按区块的属性做不同的处理。

一般情况下,它会处理以下几个方面的内容:

  • 内存页的属性:对于磁盘映射文件来说,所有的页都是按照磁盘映射文件函数指定的属性设置的。但是在装载可执行文件时,与节对应的内存页属性要按照节的属性来设置。所以,在同属于一个模块的内存页中,从不同节映射过来的内存页的属性是不同的。

  • 区块的偏移地址:节的起始地址在磁盘文件中是按照IMAGE_OPTIONAL_HEADER32结构的FileAlignment字段的值进行对齐的,而当被加载到内存中时是按照同一结构中的SectionAlignment字段的值对齐的,两者的值可能不同,所以一个节被装入内存后相对于文件头的偏移和在磁盘文件中的偏移可能是不同的。

    注意,节事实上就是相同属性数据的组合。当节被装入到内存中的时候,相同一个节所对应的内存页都将被赋予相同的页属性, 事实上,Windows 系统对内存属性的设置是以页为单位进行的,所以节在内存中的对齐单位必须至少是一个页的大小(对于32位操作系统来说,这个值一般是4KB(1000H);对于64位操作系统这个值一般是8KB(2000H))。

    节在磁盘中就没有最小4KB的限制,为了减少磁盘文件的大小,文件对齐的单位一般要小于内存对齐的单位(FileAlignment的值一般为200h),这样,在磁盘中就不必为每个节对齐4KB的大小了。

  • 区块的大小:对节的尺寸的处理主要分为两个方面:

    第一个方面,由于磁盘映像和内存映像中节对齐存储单位的不同而导致了长度扩展不同;

    第二个方面,是对于包含未初始化数据的节的处理问题。既然是未初始化,那么没有必要为其在磁盘中浪费空间资源,但在内存中不同,因为程序一运行,之前未初始化的数据便有可能要被赋值初始化,那么就必须为它们留下空间。

  • 不进行映射的区块:有些节并不需要被映射到内存中,例如.reloc节,重定位数据对于文件的执行代码来说是透明的,无作用的,它只是提供Windows装载器使用,执行代码根本不会去访问到它们,所以没有必要将它们映射到物理内存中。

4.2 节表

PE文件中所有节的属性都被定义在节表中,节表由一系列的IMAGE_SECTION_HEADER结构排列而成,每个结构用来描述一个节,结构的排列顺序和它们描述的节在文件中的排列顺序是一致的。全部有效结构的最后以一个空的IMAGE_SECTION_HEADER结构作为结束,所以节表中IMAGE_SECTION_HEADER结构数量等于节的数量加一。节表总是被存放在紧接在PE文件头的地方。

另外,节表中IMAGE_SECTION_HEADER结构的总数总是由PE文件头 IMAGE_NT_HEADERS结构中的FileHeader.NumberOfSections字段来指定的。

4.3 IMAGE_SECTION_HEADER

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
typedef struct _IMAGE_SECTION_HEADER {
BYTE Name[IMAGE_SIZEOF_SHORT_NAME]; //节表名称,如.text
//IMAGE_SIZEOF_SHORT_NAME=8
union
{
DWORD PhysicalAddress; //物理地址
DWORD VirtualSize; //区块尺寸
//这两个值是一个联合结构,可以使用其中的任何一个,一般取后者
}Misc;
DWORD VirtualAddress; //区块的RVA地址
DWORD SizeOfRawData; //在文件中对齐后的尺寸
DWORD PointerToRawData; //在文件中偏移
DWORD PointerToRelocations; //在OBJ文件中使用,重定位的偏移
DWORD PointerToLinenumbers; //行号表的偏移(供调试使用)
WORD NumberOfRelocations; //在OBJ文件中使用,重定位项数目
WORD NumberOfLinenumbers; //行号表中行号的数目
DWORD Characteristics; //区块属性如可读,可写,可执行等
}IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;

重要字段说明如下:

  • Name:区块名。这是一个由8位的ASCII码名,用来定义区块的名称。多数区块名都习惯性以一个“.”作为开头(例如:.text),但这个“.” 实际上不是必须的。值得注意的是,如果区块名超过8个字节,则没有最后的终止标志“NULL”字节。并且前边带有一个“$”的区块名字会从链接器那里得到特殊的待遇,前边带有“$”的相同名字的区块在载入时候将会被合并,在合并之后的区块中,它们是按照“$”后边的字符的字母顺序进行合并的。

    每个区块的名称都是唯一的,不能有同名的两个区块。但事实上节的名称不代表任何含义,它的存在仅仅是为了正规统一编程的时候方便程序员查看方便而设置的一个标记而已。所以将包含代码的区块命名为“.Data”或者将包含数据的区块命名为“.Code”都是合法的。

    当我们要从PE文件中读取需要的区块的时候,不能以区块的名称作为定位的标准和依据,正确的方法是按照IMAGE_OPTIONAL_HEADER32结构中的数据目录字段结合进行定位。

  • VirtualSize:区块的大小,这是区块的数据在没有进行对齐处理前的实际大小。

  • VirtualAddress:该区块装载到内存中的RVA 地址。这个地址是按照内存页来对齐的,因此它的数值总是SectionAlignment的值的整数倍。

  • SizeOfRawData:该区块在磁盘中所占的大小,这个数值等于VirtualSize字段的值按照FileAlignment的值对齐以后的大小。

  • PointerToRawData:该区块在磁盘文件中所处的位置。这个数值是从文件头开始算起的偏移量。

依靠上面4个字段的值,装载器就可以从PE文件中找出某个节(从PointerToRawData偏移开始的SizeOfRawData字节)的数据,并将它映射到内存中去(映射到从模块基地址偏移VirtualAddress的地方,并占用以VirtualSize的值按照页的尺寸对齐后的空间大小)。

  • Characteristics:该区块的属性。该字段是按位来指出区块的属性(如代码/数据/可读/可写等)的标志。可通过链接器的/SECTION选项设置, 下面是比较重要的标志:
字段值 用途
IMAGE_SCN_CNT_CODE
0x00000020
包含代码,常与0x10000000一起设置
IMAGE_SCN_CNT_INITIALIZED_DATA
0x00000040
该块包含已初始化的数据
IMAGE_SCN_CNT_UNINITIALIZED_DATA
0x00000080
该块包含未初始化的数据
IMAGE_SCN_MEM_DISCARDABLE
0x02000000
该块可被丢弃,一旦加载可被丢弃的块.reloc(重定位块)
IMAGE_SCN_MEM_SHARED
0x10000000
共享块
IMAGE_SCN_MEM_EXECUTE
0x20000000
该块可执行,通常与0x00000020标志一起被设置
IMAGE_SCN_MEM_READ
0x40000000
该块可读
IMAGE_SCN_MEM_WRITE
0x80000000
该块可写

5. 区块(节)

通常,区块中的数据在逻辑上是关联的。PE 文件一般至少都会有两个区块:一个是代码块,另一个是数据块。每一个区块都需要有一个截然不同的名字,这个名字主要是用来表达区块的用途。例如有一个区块叫.rdata,表明它是一个只读区块。注意:区块在映像中是按起始地址(RVA)来排列的,而不是按字母表顺序。另外,使用区块名字只是人们为了认识和编程的方便,而对操作系统来说这些是无关紧要的。微软给这些区块取了个有特色的名字,但这不是必须的。当编程从PE 文件中读取需要的内容时,如输入表、输出表,不能以区块名字作为参考,正确的方法是按照数据目录表中的字段来进行定位。

区块名称以及意义:

名称 描述
.text 默认的区块代码,它的内容全是指令代码
.data 默认的读/写数据区块,全局变量、静态变量一般放这
.rdata 默认的只读数据区块
.idata 包含其他外来DLL的函数及数据信息,即输入表
.edata 输出表
.rsrc 资源,包含模块的全部资源,如图标、菜单、位图等
.bss 未初始化数据
.tls 线程局部存储器,包含数据的初始化值,运行时所需要的额外变量
.reloc 可执行文件的基址重定位,基址重定位一般仅是DLL文件才需要
.sdata 通过全局指针相对寻址的“短”可读/写数据
.srdata 通过全局指针相对寻址的“短”只读数据
.pdata 异常表
.debug$S OBJ文件中Codeview格式的符号,是一个可变长的Codeview格式符号记录流
.debug$T OBJ文件中Codeview格式的类型记录,是一个可变长的Codeview格式类型记录流
.debug$P 使用预编译头时会出现在OBJ文件中
.drectve 只用于OBJ文件,包含一些链接器指令
.didat 延迟加载的导入数据

在Visual C++中也可以自定义区块名字,用#pragma来声明,告诉编译器插入数据到这个区块内:

1
#pragma data_seg("MY_DATA") 

以上语句告诉编译器将数据都放进一个叫“MY_DATA”的区块内,而不是默认的.data区块。区块一般是从OBJ文件开始,被编译器放置的。链接器的工作就是合并左右OBJ和库中需要的块,使其成为一个最终合适的区块。链接器会遵循一套相当完整的规则,它会判断哪些区块被合并以及如何被合并。

链接器的一个有趣特征就是能够合并区块。如果两个区块有相似、一致的属性(读、写、执行),那么它们在链接的时候能被合并成一个单一的区块。这取决于是否开启编译器的/merge开关。事实上合并区块有一个好处就是可以节省磁盘的内存空间。

下面的链接器选项将.rdata.text区块合并为一个.text区块:/MERGE : .rdata = .text

注意:合并区块时并没有什么硬性规定。例如,把.rdata合并到.text里不会有什么问题,但是不应该将.rsrc.reloc.pdata合并到其它的区块里。

5.1 区块的对齐

区块大小是要对齐的,有两种对齐值,一种用于磁盘文件内,另一种用于内存中。PE文件头指出了这两个值,它们可以不同。

PE 文件头里边的FileAligment 定义了磁盘区块的对齐值。每一个区块从对齐值的倍数的偏移位置开始存放。而区块的实际代码或数据的大小不一定刚好是这么多,所以在多余的地方一般以00h来填充,这就是区块间的间隙。例如,在PE文件中,一个典型的对齐值是200h,这样,每个区块都将从200h的倍数的文件偏移位置开始,假设第一个区块在400h处,长度为90h,那么从文件400h到490h为这一区块的内容,而由于文件的对齐值是200h,所以为了使这一区块的长度为FileAlignment的整数倍,491h 到 600h 这一个区间都会被00h 填充,这段空间称为区块间隙,下一个区块的开始地址为600h 。

PE 文件头里边的SectionAligment定义了内存中区块的对齐值。PE 文件被映射到内存中时,区块总是至少从一个页边界开始。一般在x86 系列的CPU 中,页是按4KB(1000h)来排列的;在IA-64上,是按8KB(2000h)来排列的。所以在x86 系统中,PE文件区块的内存对齐值一般等于1000h,每个区块按1000h的倍数在内存中存放。

※5.2 RVA到FOA的转换

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

RVA使文件装入内存后的数据定位变得方便,然后却给我们要定位位于磁盘上的静态PE文件带来了麻烦。

当处理PE文件的时候,任何的RVA必须经过到FOA的换算,才能用来定位并访问文件中的数据,但换算却无法用一个简单的公式来完成。事实上,唯一可用的方法就是穷举,步骤如下:

  1. 在内存中得到一个地址VA。
  2. 计算相对地址RVA = VA - ImageBase
  3. 判断RVA是否在SizeOfHeaders中(DOS部首+PE文件头+块表)。是则FOA==RVA;否则在块中,需进一步分析。
  4. 判断RVA在哪个区块中。循环扫描区块表得出每个区块在内存中的起始地址(IMAGE_SECTION_HEADERVirtualAddress字段),并根据区块大小(IMAGE_SECTION_HEADERVirtualSize字段)算出区块的结束地址(两者相加即可),最后判断RVA是否落在该区块内。
  5. 已知RVA在哪个区块后,用RVA减去该区块的起始地址,这样就能得到RVA相对于该区块起始地址的偏移量RVA2。
  6. 在区块表中获取该区块在文件中所处的偏移地址(根据IMAGE_SECTION_HEADER中的PointerToRawData字段),将这个偏移值加上RVA2得到真正的文件偏移地址(FOA)。

举个例子,求目标RVA为0x198000的文件偏移地址。

.text结束RVA = 0x1000 + 0x176ade = 0x177ade < 0x198000

.rdata结束RVA = 0x178000 + 0x4d896 = 0x1c5896 > 0x198000

所以目标RVA在.rdata段。

目标RVA相对于.rdata起始RVA的偏移 = 0x198000 - 0x178000 = 0x20000

.rdata段在磁盘文件中的偏移地址 = 0x177000

所以目标RVA在磁盘文件的偏移地址 = 0x177000 + 0x20000 = 0x197000

FOA到RVA的转换:

  1. 在PE文件中得到一个地址FOA。
  2. 判断FOA是否在SizeOfHeaders中。是则FOA==RVA;否则在区块中需进一步分析。(或在不映射的区间中,此时无RVA)
  3. 判断FOA在哪个区块。
  1. FOA在该区块的偏移为RVA2 = FOA - PointerOfRawData
  2. FOA在内存中的偏移为RVA = VirtualAddress + RVA2。

5.3 输入表

5.3.1 输入函数

在代码分析或编程中经常遇到“输入函数”的概念,输入函数就是被程序调用但其执行代码又不在程序中的函数,这些函数的代码位于相关的DLL文件中,在调用者程序中只保留相关的函数信息(如函数名、DLL文件名等)即可。对于磁盘上的PE文件来说,它无法得知这些输入函数在内存中的地址,只有当PE文件被装入内存后,Windows加载器才将相关DLL装入,并将调用输入函数的指令和函数实际所处的地址联系起来,这就是“动态链接”的概念。动态链接是通过PE文件中定义的“输入表”来完成的,输入表中保存的正是函数名和其驻留的DLL名等。

5.3.2 IMAGE_IMPORT_DESCRIPTOR

PE文件头的IAMGE_OPTIONAL_HEADER结构中的DataDirectory数组的第二个元素就是指向输入表。而输入表是一个IMAGE_IMPORT_DESCRIPTOR(简称IID)数组。每个被PE文件链接进来的DLL文件都分别对应一个IID数组结构。在这个IID数组中,并没有指出有多少个链接文件,但它最后是以一个全为0(NULL)的IID作为结束的标志

1
2
3
4
5
6
7
8
9
10
typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union{//共用体类型,共占同一个地址,分时用
DWORD Characteristics;
DWORD OriginalFirstThunk; //指向输入名称表(INT)RVA的结构数组
};
DWORD TimeDateStamp;
DWORD ForwarderChain;
DWORD Name; //指向被输入的DLL名称
DWORD FirstThunk; //指向输入地址表(IAT)RVA,IAT是一个IMAGE_THUNK_DATA结构的数组
} IMAGE_IMPORT_DESCRIPTOR, *PIMAGE_IMPORT_DESCRIPTOR;

重要字段如下:

  • OriginalFirstThunk:指向输入名称表(INT)RVA的结构数组,数组类型为IMAGE_THUNK_DATA
  • Name:指向存有DLL名称的相对虚拟地址。
  • FirstThunk:指向输入地址表(IAT)RVA的结构数组,数组类型为IMAGE_THUNK_DATA

5.3.3 IMAGE_THUNK_DATA32

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结构
}u1;
}IMAGE_THUNK_DATA32;
//IMAGE_THUNK_DATA64与IMAGE_THUNK_DATA32的区别,仅仅是把DWORD换成了64位整数。

IMAGE_THUNK_DATA只占一个双字字节,当双字字节的最高位为1时,表示函数以序号方式输入,这时候低31位被看作一个函数序号。当双字字节的最高位为0时,表示函数以字符串类型的函数名方式输入,这时双字的值是一个RVA,指向一个IMAGE_IMPORT_BY_NAME结构。

5.3.4 IMAGE_IMPORT_BY_NAME

1
2
3
4
typedef struct _IMAGE_IMPORT_BY_NAME {
WORD Hint;//指出函数在所在的dll的输出表中的序号
BYTE Name[1];//指出要输入的函数的函数名
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;

结构中的Hint字段是可选的,有些编译器总是将它设置为 0,如果它不是0,则它表示函数的序号。Name数组只包含一个元素,但其实它是一个变长数组,保存的是一个以0结尾的字符串,也就是函数名。

※5.3.5 输入地址表(IAT)

在文件中时,OriginalFirstThunkFirstThunk分别指向一个RVA地址。这个地址转换到文件中,分别对应两个以IMAGE_THUNK_DATA为元素的数组,这两个数组是以一个填充为 0 的IMAGE_THUNK_DATA作为结束标识符。虽然它们这两个表位置不同,但实际内容是一模一样的。此时,每个 IMAGE_THUNK_DATA元素指向的是一个记录了函数名和相对应的DLL文件名的IMAGE_IMPORT_BY_NAME结构体。

为什么会有两个一模一样的数组呢?OriginalFirstThunk指向的数组通常叫做hint-name table,即HNT ,它在 PE 加载到内存中时被保留了下来且永远不会被修改。但是在 Windows 加载过 PE 到内存之后,Windows 会重写FirstThunk所指向的数组元素中的内容,使得数组中每个IMAGE_THUNK_DATA元素不再表示指向带有函数描述的 IMAGE_IMPORT_BY_NAME数组元素,而是直接指向了函数地址。此时,FirstThunk所指向的数组就称之为输入地址表(Import Address Table ,即经常说的 IAT)。

5.3.6 查找导入表地址

让我们从头开始。编写一个EXE程序,里面只有两个API函数:MessageBox()SetWindowTextA()。目的是找到导入表并且找到MessageBox()在PE文件中的地址和被加载进内存的地址。

1
2
3
4
5
6
7
8
#include<windows.h>

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, PSTR szCmdLine, int iCmdShow)
{
MessageBox(NULL, TEXT("这是我写的第一个程序"), TEXT("v5le0n9"), MB_OK);
SetWindowTextA(0, "hello");
return 0;
}

编译如果出现如下问题:

MSVCRTD.lib(exe_main.obj) : error LNK2019: 无法解析的外部符号 _main,该符号在函数 “int __cdecl invoke_main(void)” (?invoke_main@@YAHXZ) 中被引用

请看:https://www.tianqiweiqi.com/msvcrtd-lib-error-lnk2019.html

使用PEview查看PE文件结构。

已知PE文件头的IAMGE_OPTIONAL_HEADER结构中的DataDirectory数组的第二个元素就是指向导入表。IMAGE_DATA_DIRECTORY数据类型中有两个成员,一个是数据块在内存中的偏移地址,另一个是数据块的大小。

知道了导入表的RVA为0x1B1E4后,遍历IMAGE_SECTION_HEADER数组,最终找到导入表在idata这个区块中(0x1B000 <= 0X1B1E4 <= 0x1BB15)。所以RVA2=0x1E4。

在PE文件中的偏移为0x8000,大小为0xC00。所以导入表在PE文件中的偏移为0x81E4。

此时,导入表的地址已经找到了,接下来找MessageBox()的地址。

从上图可知,该PE文件导入了4个DLL,每个对应一个IID,最后以一个空的IID结尾。已知MessageBox()函数在USER32.DLL中,所以应该在第一个IID里找。可以根据OriginalFirstThunk找,也可以根据FirstThunk找。无论哪个,它们都指向相同的数据类型IMAGE_THUNK_DATA32

我们先根据OriginalFirstThunk找。OriginalFirstThunk的值为0x1B2E0,即表示INT的IMAGE_THUNK_DATA32结构数组首地址。该RVA也在idata块中,所以IMAGE_THUNK_DATA32数组在PE文件偏移为0x82E0的地方。

可以看到有3个IMAGE_THUNK_DATA32型变量,最后一个为空表示结尾。IMAGE_THUNK_DATA32只占4个字节,当双字字节的最高位为0时,表示函数以字符串类型的函数名方式输入,这时双字的值是一个RVA,指向一个IMAGE_IMPORT_BY_NAME结构。我们找的是MessageBox()函数的地址,所以应找RVA为0x1B43E相对于PE文件的偏移。该RVA也在idata中,所以表示MessageBox()函数的IMAGE_IMPORT_BY_NAME结构在PE中的偏移为0x843E。

再根据FirstThunk找。IMAGE_THUNK_DATA32的偏移在0x8098。

代表MessageBox()函数的IMAGE_IMPORT_BY_NAME结构在0x843E。

故在静态存放时,IAT和INT都是指向IMAGE_IMPORT_BY_NAME结构。

那将它加载进内存,会有什么不一样呢?运行EXE程序,使用PE Tools将运行中的EXE程序全部dump下来。

Dumped.exeProject4.exe有什么区别呢?Dumped.exeProject4.exe通过Windows加载器加载进内存中之后,再按照内存大小1:1映射回磁盘的文件。

用PEview查看Dumped.exe,重复上述操作查看IAT和INT的指针。

可以看出MessageBox()函数在内存中的真正地址为0x761410C0。

5.4 输出表

当PE文件被执行的时候,Windows加载器将文件装入内存并将导入表登记的动态链接库(一般是DLL格式)文件一并装入地址空间,再根据DLL文件中的函数导出信息,对可执行文件的IAT进行修正。

动态链接库是被映射到其它应用程序的地址空间中执行的,它和应用程序可以看成是“一体”的,动态链接库可以使用应用程序的资源,它所拥有的资源也可以被应用程序使用,它的任何操作都是代表应用程序进行的,当动态链接库进行打开文件、分配内存和创建窗口等操作后,这些文件、内存和窗口都是为应用程序所拥有的。

导出表就是记载着动态链接库的一些导出信息。通过导出表,DLL 文件可以向系统提供导出函数的名称、序号和入口地址等信息,以便Windows 加载器通过这些信息来完成动态连接的整个过程。

注意:扩展名为.exe的PE文件一般不存在导出表,而大部分的.dll文件中都包含导出表。但注意,这并不是绝对。比如纯粹用作资源的.dll文件不需要导出函数,有些特殊功能的.exe文件也会存在导出函数。

5.4.1 导出表结构

PE文件头的IAMGE_OPTIONAL_HEADER结构中的DataDirectory的第一个成员就是指向导出表。导出表是用来描述模块中的导出函数的结构,如果一个模块导出了函数,那么这个函数会被记录在导出表中,这样通过GetProcAddress()函数就能动态获取到函数的地址。函数导出的方式有两种,一种是按名字导出,一种是按序号导出。这两种导出方式在导出表中的描述方式也不相同。

导出表(Export Table)中的主要成分是一个表格,内含函数名称、输出序数等。序数是指定DLL 中某个函数的16位数字,在所指向的DLL 文件中是独一无二的。在此不提倡仅仅通过序数来索引函数的方法,这样会给DLL 文件的维护带来问题。例如当DLL 文件一旦升级或修改就可能导致调用该DLL 的程序无法加载到需要的函数。

5.4.2 IMAGE_EXPORT_DIRECTORY

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct _IMAGE_EXPORT_DIRECTORY {
DWORD Characteristics; //未使用,总是定义为0
DWORD TimeDateStamp; //文件生成时间
WORD MajorVersion; //未使用,总是定义为0
WORD MinorVersion; //未使用,总是定义为0
DWORD Name; //模块名字
DWORD Base; //基数,加上序数就是函数地址数组的索引值
DWORD NumberOfFunctions; //所有导出函数的数量
DWORD NumberOfNames; //按名字导出函数的数量
DWORD AddressOfFunctions; //指向输出函数地址的RVA
DWORD AddressOfNames; //指向输出函数名字的RVA
DWORD AddressOfNameOrdinals; //指向输出函数序号的RVA
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;

重要字段说明如下:

  • Name:一个RVA 值,指向一个定义了模块名称的字符串。如即使Kernel32.dll文件被改名为Ker.dll,仍然可以从这个RVA值得知其在编译时的文件名是Kernel32.dll

  • NumberOfFunctions:文件中包含的导出函数的总数。

  • NumberOfNames:被定义函数名称的导出函数的总数。显然只有这个数量的函数既可以用函数名方式导出,也可以用序号方式导出。剩下的NumberOfFunctions减去NumberOfNames数量的函数只能用序号方式导出。该字段的值只会小于或者等于NumberOfFunctions字段的值,如果这个值是0,表示所有的函数都是以序号方式导出的。

  • AddressOfFunctions:一个RVA 值,指向包含全部导出函数入口地址的双字数组。数组中的每一项是一个RVA 值,数组的项数等于NumberOfFunctions字段的值。

  • Base:导出函数序号的起始值,将AddressOfFunctions字段指向的入口地址表的索引加上这个起始值就是对应函数的导出序号。

    假如Base 字段的值为x,那么入口地址表指定的第1个导出函数的序号就是x;第2个导出函数的序号就是x+1。总之,一个导出函数的导出序号等于Base 字段的值加上其在入口地址表中的位置索引值。

  • AddressOfNames 和 AddressOfNameOrdinals:均为RVA 值。前者指向函数名字符串地址表。这个地址表是一个双字数组,数组中的每一项指向一个函数名称字符串的RVA。数组的项数等于NumberOfNames字段的值,所有有名称的导出函数的名称字符串都定义在这个表中;后者指向一个word 类型的数组,数组项目与文件名地址表中的项目一一对应,项目值代表函数入口地址表的索引,这样函数名称与函数入口地址关联起来。

    假如函数名称字符串地址表的第n项指向一个字符串“MyFunction”,那么可以去查找AddressOfNameOrdinals指向的数组的第n项,假如第n项中存放的值是x,则表示AddressOfFunctions字段描述的地址表中的第x项函数入口地址对应的名称就是“MyFunction”。

※5.4.3 查找函数入口地址

5.4.3.1 从序号查找函数入口地址

Windows 装载器的工作步骤如下:

  1. 定位到PE 文件头
  2. 从PE 文件头中的IMAGE_OPTIONAL_HEADER32结构中取出数据目录表,并从第一个数据目录中得到导出表的RVA
  3. 从导出表的Base字段得到起始序号
  4. 将需要查找的导出序号减去起始序号,得到函数在入口地址表中的索引
  5. 检测索引值是否大于导出表的NumberOfFunctions字段的值,如果大于后者的话,说明输入的序号是无效的
  6. 用这个索引值在AddressOfFunctions字段指向的导出函数入口地址表中取出相应的项目,这就是函数入口地址的RVA 值,当函数被装入内存的时候,这个RVA 值加上模块实际装入的基地址,就得到了函数真正的入口地址

5.4.3.2 从函数名称查找入口地址

Windows 装载器的工作步骤如下:

  1. 最初的步骤是一样的,那就是首先得到导出表的地址
  2. 从导出表的NumberOfNames字段得到已命名函数的总数,并以这个数字作为循环的次数来构造一个循环
  3. AddressOfNames字段指向得到的函数名称地址表的第一项开始,在循环中将每一项定义的函数名与要查找的函数名相比较,如果没有任何一个函数名是符合的,表示文件中没有指定名称的函数
  4. 如果某一项定义的函数名与要查找的函数名符合,那么记下这个函数名在字符串地址表中的索引值,然后在AddressOfNamesOrdinals指向的数组中以同样的索引值取出数组项的值,我们这里假设这个值是x
  5. 最后,以 x 值作为索引值,在AddressOfFunctions字段指向的函数入口地址表中获取的 RVA 就是函数的入口地址

一般情况下病毒程序就是通过函数名称查找入口地址的,因为病毒程序作为一段额外的代码被附加到可执行文件中的,如果病毒代码中用到某些 API 的话,这些 API 的地址不可能在宿主文件的导出表中为病毒代码准备好。因此只能通过在内存中动态查找的方法来实现获取 API 的地址。

5.4.4 查找函数入口地址例子

比如我们用user32.dll作为研究对象。DLL程序不能直接双击运行,如果想要单纯把它加载进内存,可以在“运行”(徽标键 + R)中输入rundll32.exe C:\Users\Dell\Desktop\user32.dll

当然,我们查找函数入口地址不需要将它加载进内存,只需载入PEview分析。注意,PEview只支持查看在IAT/INT中前32个导入的DLL。

首先在IMAGE_OPTIONAL_HEADER32中得到导出表的RVA,为0x9D5B0。

遍历区块表,可知导出表在text区块中。RVA2=0x9D5B0-0x1000=0x9C5B0,导出表的FOA=0x400+0x9C5B0=0x9C9B0。

所有导出函数都有对应的序号,但不一定有函数名。用函数名导出会更安全。从上图可知,有0x4BF个导出函数,其中0x3E6个是用函数名导出的,而其余都是用序号导出的。导出函数数组的首地址RVA为0x9D5D8,转为FOA为0x9C9D8。

5.4.4.1 从序号查找函数入口地址

Base字段是导出函数序列的初始序号,为0x5DE。假设我们现在要找序号为0x5FF的函数地址,即要找序号偏移为0x21的函数地址。根据导出函数数组首地址、每个元素所占字节数与序号偏移就可以定位存放我们需要找的函数的地址。由于AddressOfFunctions指向的是双字数组,也就是每个元素所占字节数为4,可以定位序号0x5FF的函数FOA为0x9C9D8+4*0x21=0x9CA5C。

该FOA存放着0x5FF函数加载进内存的RVA,为0x3A170。

将DLL文件载入IDA,基址为0x69E00000,所以序号0x5FF的函数加载进内存的地址为0x69E3A170。

5.4.4.2 从函数名称查找入口地址

还是查找CallMsgFilterW()函数,在AddressOfNames数组中最多循环NumberOfNames次,假设索引为i时,索引i的内容是一个RVA值,转换为FOA恰好是“CallMsgFilterW\0”字符串的首地址,则在AddressOfNamesOrdinals数组中直接索引到i,得到CallMsgFilterW()函数在AddressOfFunctions数组中的索引值0x21。

这样同样找到了CallMsgFilterW()函数在加载进内存中的RVA。

5.5 基址重定位

链接器生成一个PE文件时,它会假设程序被装入时使用的默认ImageBase基地址(VC默认EXE基地址00400000h,DLL基地址10000000h),并且会把代码中所有指令中用到的地址都使用默认的基地址(例如程序代码中 push 10001000,就是把10000000h当做了基地址,把push 10001000写入到文件中)。如果一个EXE程序中一个DLL装载时的地址与其它DLL地址发生冲突(因为Windows程序是虚拟地址空间,EXE一般不会有地址冲突,加载DLL时可能会有地址冲突),就需要修改代码中的地址,这时就需要进行基址重定位。

凡是涉及到直接寻址的指令都需要进行重定位处理,如push 10001000,call 10002000等。假设重定位后的基地址由原来的10000000h变为20000000h,那么push 10001000应该改成push 20001000。

所以重定位的算法可以总结为:

重定位后的地址 = 直接寻址指令中的双字地址 + (模块实际装入地址 - 模块建议装入地址)

在PE文件中,基址重定位表一般放在一个单独的.reloc区,可以通过IMAGE_OPTIONAL_HEADER中的DataDirectory[5]查看基址重定位表的RVA。PE文件的重定位表中保存的就是文件中所有需要进行重定位修正的代码的地址。基址重定位表是由一个个IMAGE_BASE_RELOCATION结构构成的,每一个IMAGE_BASE_RELOCATION结构表示一个4KB页面范围内的基址重定位信息,它必须从32位边界开始。

5.5.1 IMAGE_BASE_RELOCATION

1
2
3
4
5
typedef struct _IMAGE_BASE_RELOCATION {
DWORD VirtualAddress;
DWORD SizeOfBlock;
WORD TypeOffset[];
} IMAGE_BASE_RELOCATION, * PIMAGE_BASE_RELOCATION;
  • VirtualAddress:所在页重定位数据开始的RVA地址。
  • SizeOfBlock:所在页重定位块的长度。
  • TypeOffset[]:一个数组,它的元素个数就是$\frac {SizeOfBlock-8}{2}$,每一个元素占用两个字节(16位),其中高4位表示重定位类型(x86下这个值基本都是3),低12位表示重定位地址。重定位地址与VirtualAddress相加即是指向PE映像中需要修改的地址数据的指针。

取某页重定位数据开始的RVA为0x1000,数据块长度为0x5A8,取TypeOffset数组中某个元素,比如0x3018。所以要进行重定位处理的数据RVA为0x1000 + ( 0x3018 & 0x0FFF ) = 0x1018。(&0x0FFF是因为要去掉高4位)

再加上DLL文件加载进内存的基址可得重定位数据在内存中的VA。

5.6 资源表

Windows程序的各种界面称为资源,包括加速键、位图、光标、对话框、图标、菜单、串表、工具栏和版本信息等。资源是PE文件中非常重要的部分,几乎所有的PE文件中都包含着资源,与导入表和导出表相比,资源的组织方式要复杂得多。

资源有很多种类型,每种类型的资源中可能存在多个资源项,这些资源项用不同的ID或名称来区分,采取类似于磁盘目录结构的方式保存。从上图可以看到,PE文件中的资源是按照 资源类型 -> 资源ID -> 资源代码页 的3层树型目录结构来组织资源的,通过层层索引才能够进入相应的子目录找到正确的资源。

5.6.1 资源目录结构

数据目录表中的IMAGE_DIRECTORY_ENTRY_RESOURCE条目(第三项)包含资源的RVA和大小。资源目录结构中的每一个结点都是由IMAGE_RESOURCE_DIRECTORY结构和紧跟其后的数个IMAGE_RESOURCE_DIRECTORY_ENTRY结构组成的。

5.6.2 IMAGE_RESOURCE_DIRECTORY

1
2
3
4
5
6
7
8
typedef struct _IMAGE_RESOURCE_DIRECTORY {
DWORD Characteristics;
DWORD TimeDateStamp;
WORD MajorVersion;
WORD MinorVersion;
WORD NumberOfNamedEntries; // 用字符串作为资源标识的条目个数
WORD NumberOfIdEntries; // 用数字ID作为资源标识的条目个数
} IMAGE_RESOURCE_DIRECTORY, *PIMAGE_RESOURCE_DIRECTORY;

唯一要注意的就是NameberOfNamedEntriesNumberOfIdEntries,它们说明了本目录中目录项的数量,两者加起来就是本目录中的目录项总和,也就是后边跟着的IMAGE_RESOURCE_DIRECTORY_ENTRY数目。

5.6.3 IMAGE_RESOURCE_DIRECTORY_ENTRY

IMAGE_RESOURCE_DIRECTORY_ENTRY紧跟在资源目录结构后,此结构长度为 8 个字节,包含 2 个字段。该结构定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typedef struct _IMAGE_RESOURCE_DIRECTORY_ENTRY {
union {
struct {
DWORD NameOffset:31; //资源名偏移
DWORD NameIsString:1; //资源名为字符串
} DUMMYSTRUCTNAME;
DWORD Name; //资源/语言类型
WORD Id; //资源数字ID
} DUMMYUNIONNAME; // 资源名称
union {
DWORD OffsetToData; //数据偏移地址
struct {
DWORD OffsetToDirectory:31; //子目录偏移地址
DWORD DataIsDirectory:1; //数据为目录
} DUMMYSTRUCTNAME2;
} DUMMYUNIONNAME2; // 资源位置
} IMAGE_RESOURCE_DIRECTORY_ENTRY,*PIMAGE_RESOURCE_DIRECTORY_ENTRY;

该结构体在每一层中都是以结构体数组的方式存在,但在每一层的含义都不同:

  • 第一层,每个元素代表一种资源类型
  • 第二层,每个元素代表一个资源名称
  • 第三层,每个元素代表一个资源的位置

5.6.3.1 第一个联合体(前4字节)

  • 如果最高位为0,在第一层低16位表示是16种标准资源类型之一,如ICON;在第二层低16位表示标准命名(预定义的类型);在第三层低16位表示标准代码页,如2052表示简体中文。
资源类型 资源类型
0x01 鼠标指针 0x08 字体
0x02 位图 0x09 快捷键
0x03 图标 0x0A 非格式化资源
0x04 菜单 0x0B 消息列表
0x05 对话框 0x0C 鼠标指针组
0x06 字符串列表 0x0E 图标组
0x07 字体目录 0x0F 版本信息
  • 如果最高位为1,字段的低31位作为指针使用,指向一个IMAGE_RESOURCE_DIR_STRING_U结构。

    1
    2
    3
    4
    typedef struct _IMAGE_RESOURCE_DIR_STRING_U {
    WORD Length; //字符串长度
    WCHAR NameString[1]; //UNICODE字符串
    }IMAGE_RESOURCE_DIR_STRING_U,*PIMAGE_RESOURCE_DIR_STRING_U;

字段NameString为字符串的起始地址,长度为Length,这个串不是以0结尾。

5.6.3.2 第二个联合体(后4字节)

  • 如果最高位为1,低31位数据指向下一层目录块的真实地址。通常,第一层和第二层,这个值都是1。
  • 如果最高位为0,低31位数据指向IMAGE_RESOURCE_DATA_ENTRY结构。通常,第三层,这个值为0。

注意,将NameOffsetToData用作指针时,该指针是从资源区块开始的地方算起的偏移量,即根目录的起始位置的偏移量。

5.6.4 IMAGE_RESOURCE_DATA_ENTRY

第三层目录结构中的OffsetToData指向IMAGE_RESOURCE_DATA_ENTRY结构,该结构描述了资源数据的位置和大小。

1
2
3
4
5
6
typedef struct _IMAGE_RESOURCE_DATA_ENTRY {
DWORD OffsetToData; // 资源数据的RVA
DWORD Size; // 资源数据的长度
DWORD CodePage; // 代码页,一般为0
DWORD Reserved; // 保留字段
} IMAGE_RESOURCE_DATA_ENTRY, *PIMAGE_RESOURCE_DATA_ENTRY;

5.6.5 查找资源数据例子

定位资源表与5.3.6 查找导入表的方法一模一样,不再赘述。我们直接从资源表开始,资源表的起始地址为0xAD200。

上图红框部分,第一个联合体的值为0x80002698,最高位为1,低31位指向IMAGE_RESOURCE_DIR_STRING_U结构,地址为 0xAD200 + 0x2698 = 0xAF898。

IMAGE_RESOURCE_DIR_STRING_U结构前2个字节表示字符串长度,为0x0D,后面紧跟字符串的名称,为WEVT_TEMPLATE。

第二个联合体的值为0x80000070,最高位为1,表示指向下一层目录,低31位指向下一层目录的真实地址,为0xAD270。该IMAGE_RESOURCE_DIRECTORY结构只有一个IMAGE_RESOURCE_DIRECTORY_ENTRY,它的第一个联合体的值为0x1,最高位为0,低16位表示资源名称。第二个联合体的值为0x80000748,最高位为1,低31位指向下一层目录的真实地址,为0xAD748。

继续找下一层目录的真实地址0xAE710。

第二个联合体的值为0x2348,最高位为0,表示已无下一层目录,低31位数据指向IMAGE_RESOURCE_DATA_ENTRY结构,地址为0xAF548。

该资源数据的RVA为0xFF1F0,大小为0x1EA9。0xFF1F0在rsrc区块,转换为FOA为0xAD200 + (0xFF1F0 - 0xB3000) = 0xF93F0。

该资源数据是一个PNG文件。

6. 总结