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_HEADERS32
或IMAGE_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 | IMAGE_DOS_HEADER STRUCT |
两个重要字段:
- 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 | IMAGE_NT_HEADERS STRUCT |
- 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 | typedef struct _IMAGE_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 | typedef struct _IMAGE_OPTIONAL_HEADER { |
事实上,这个结构中的大部分字段都不重要,但有些病毒恰恰利用这些字段做手脚。
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
结构的定义很简单,它仅仅指出了某种数据块的位置和长度。DataDirectory
是OptionalHeader
的最后128个字节,也是IMAGE_NT_HEADERS
的最后一部分数据。
3.4 IMAGE_DATA_DIRECTORY
1 | typedef struct _IMAGE_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 | typedef struct _IMAGE_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 |
以上语句告诉编译器将数据都放进一个叫“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的换算,才能用来定位并访问文件中的数据,但换算却无法用一个简单的公式来完成。事实上,唯一可用的方法就是穷举,步骤如下:
- 在内存中得到一个地址VA。
- 计算相对地址RVA = VA -
ImageBase
。 - 判断RVA是否在
SizeOfHeaders
中(DOS部首+PE文件头+块表)。是则FOA==RVA;否则在块中,需进一步分析。 - 判断RVA在哪个区块中。循环扫描区块表得出每个区块在内存中的起始地址(
IMAGE_SECTION_HEADER
的VirtualAddress
字段),并根据区块大小(IMAGE_SECTION_HEADER
的VirtualSize
字段)算出区块的结束地址(两者相加即可),最后判断RVA是否落在该区块内。 - 已知RVA在哪个区块后,用RVA减去该区块的起始地址,这样就能得到RVA相对于该区块起始地址的偏移量RVA2。
- 在区块表中获取该区块在文件中所处的偏移地址(根据
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的转换:
- 在PE文件中得到一个地址FOA。
- 判断FOA是否在
SizeOfHeaders
中。是则FOA==RVA;否则在区块中需进一步分析。(或在不映射的区间中,此时无RVA) - 判断FOA在哪个区块。
- FOA在该区块的偏移为RVA2 = FOA -
PointerOfRawData
。 - 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 | typedef struct _IMAGE_IMPORT_DESCRIPTOR { |
重要字段如下:
- OriginalFirstThunk:指向输入名称表(INT)RVA的结构数组,数组类型为
IMAGE_THUNK_DATA
。 - Name:指向存有DLL名称的相对虚拟地址。
- FirstThunk:指向输入地址表(IAT)RVA的结构数组,数组类型为
IMAGE_THUNK_DATA
。
5.3.3 IMAGE_THUNK_DATA32
1 | typedef struct _IMAGE_THUNK_DATA32{ |
IMAGE_THUNK_DATA
只占一个双字字节,当双字字节的最高位为1时,表示函数以序号方式输入,这时候低31位被看作一个函数序号。当双字字节的最高位为0时,表示函数以字符串类型的函数名方式输入,这时双字的值是一个RVA,指向一个IMAGE_IMPORT_BY_NAME
结构。
5.3.4 IMAGE_IMPORT_BY_NAME
1 | typedef struct _IMAGE_IMPORT_BY_NAME { |
结构中的Hint
字段是可选的,有些编译器总是将它设置为 0,如果它不是0,则它表示函数的序号。Name
数组只包含一个元素,但其实它是一个变长数组,保存的是一个以0结尾的字符串,也就是函数名。
※5.3.5 输入地址表(IAT)
在文件中时,OriginalFirstThunk
和FirstThunk
分别指向一个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 |
|
编译如果出现如下问题:
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.exe
和Project4.exe
有什么区别呢?Dumped.exe
是Project4.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 | typedef struct _IMAGE_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 装载器的工作步骤如下:
- 定位到PE 文件头
- 从PE 文件头中的
IMAGE_OPTIONAL_HEADER32
结构中取出数据目录表,并从第一个数据目录中得到导出表的RVA - 从导出表的
Base
字段得到起始序号 - 将需要查找的导出序号减去起始序号,得到函数在入口地址表中的索引
- 检测索引值是否大于导出表的
NumberOfFunctions
字段的值,如果大于后者的话,说明输入的序号是无效的 - 用这个索引值在
AddressOfFunctions
字段指向的导出函数入口地址表中取出相应的项目,这就是函数入口地址的RVA 值,当函数被装入内存的时候,这个RVA 值加上模块实际装入的基地址,就得到了函数真正的入口地址
5.4.3.2 从函数名称查找入口地址
Windows 装载器的工作步骤如下:
- 最初的步骤是一样的,那就是首先得到导出表的地址
- 从导出表的
NumberOfNames
字段得到已命名函数的总数,并以这个数字作为循环的次数来构造一个循环 - 从
AddressOfNames
字段指向得到的函数名称地址表的第一项开始,在循环中将每一项定义的函数名与要查找的函数名相比较,如果没有任何一个函数名是符合的,表示文件中没有指定名称的函数 - 如果某一项定义的函数名与要查找的函数名符合,那么记下这个函数名在字符串地址表中的索引值,然后在
AddressOfNamesOrdinals
指向的数组中以同样的索引值取出数组项的值,我们这里假设这个值是x - 最后,以 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 | typedef struct _IMAGE_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 | typedef struct _IMAGE_RESOURCE_DIRECTORY { |
唯一要注意的就是NameberOfNamedEntries
和NumberOfIdEntries
,它们说明了本目录中目录项的数量,两者加起来就是本目录中的目录项总和,也就是后边跟着的IMAGE_RESOURCE_DIRECTORY_ENTRY
数目。
5.6.3 IMAGE_RESOURCE_DIRECTORY_ENTRY
IMAGE_RESOURCE_DIRECTORY_ENTRY
紧跟在资源目录结构后,此结构长度为 8 个字节,包含 2 个字段。该结构定义如下:
1 | typedef struct _IMAGE_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
4typedef 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。
注意,将Name
和OffsetToData
用作指针时,该指针是从资源区块开始的地方算起的偏移量,即根目录的起始位置的偏移量。
5.6.4 IMAGE_RESOURCE_DATA_ENTRY
第三层目录结构中的OffsetToData
指向IMAGE_RESOURCE_DATA_ENTRY
结构,该结构描述了资源数据的位置和大小。
1 | typedef struct _IMAGE_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文件。