PE结构
PE的基本结构
exe文件和dll文件之间的区别完全是语义上的,因为他们使用完全相同的pe格式,而唯一的区别就是用一个字段标识出了这个文件是exe还是dll。
64位windows只是对PE格式做了一些简单的修饰,新格式叫做PE32+。并没有任何新的结构加进去,改变的只是将32位字段扩展成了64位。
pe格式定义主要地方位于头文件winnt.h中,这个头文件能找到关于pe文件的所有定义。
内容
文件的内容被分割成不同的区块,块中包含代码或数据,各个区块按页边界对齐。
windows加载器
又称PE装载器,遍历PE文件,决定文件的哪一部分被映射。这种映射方式将文件较高的偏移地址映射到较高的内存地址。
基地址(ImageBase)
PE文件被加载到内存后,被称为一个module,映射的起点位置被称为基地址,也是这个module的handle(句柄)。
可以用函数getModuleHandle(LPCTSTR IpModuleName)获取句柄。
文件的偏移地址
文件的偏移地址是各个区块对于文件头的偏移。
虚拟地址
区块被映射到内存后的地址
相对虚拟地址
虚拟地址相对基地址的偏移
MS-DOS头部
每个PE文件都是以一个DOS程序开始的,一旦程序在DOS下执行,只有有这个头部,DOS才能识别出这是有效的执行体。这个头部被称为IMAGE_DOS_HEADER
IMAGE_DOS_HEADER STRUCT
{
+0h WORD e_magic // Magic DOS signature MZ(4Dh 5Ah) DOS可执行文件标记
+2h WORD e_cblp // Bytes on last page of file
+4h WORD e_cp // Pages in file
+6h WORD e_crlc // Relocations
+8h 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
PE 文件头
PE文件头紧挨着DOS stub,是PE相关结构的NT影响头(IMAGE_NT_HEADER)的简称,里面包含着许多PE加载器用到的重要字段。PE HEADER的起始偏移量是通过IMAGE_DOS_HEADER结构中的e_lfanew字段找到的。
3ch处为e8h,PE HEADER起始地址为e8h
IMAGE_NT_HEADERS STRUCT
{
+0h DWORDSignature //
+4h IMAGE_FILE_HEADER FileHeader //
+18h IMAGE_OPTIONAL_HEADER32OptionalHeader //
} IMAGE_NT_HEADERS ENDS
在一个有效的 PE 文件里,Signature 字段被设置为00004550h, ASCII 码字符是“PE00”。标志这 PE 文件头的开始。
“PE00” 字符串是 PE 文件头的开始,DOS 头部的 e_lfanew 字段正是指向这里。
IMAGE_FILE_HEADER结构
typedef struct _IMAGE_FILE_HEADER
{
+04h WORD Machine; // 运行平台
+06h WORD NumberOfSections; // 文件的区块数目
+08h DWORD TimeDateStamp; // 文件创建日期和时间
+0Ch DWORD PointerToSymbolTable; // 指向符号表(主要用于调试)
+10h DWORD NumberOfSymbols; // 符号表中符号个数(同上)
+14h WORD SizeOfOptionalHeader; // IMAGE_OPTIONAL_HEADER32 结构大小
+16h WORD Characteristics; // 文件属性
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;
enum IMAGE_MACHINE Machine
第一个字0x014c表示了运行平台
value | Meaning |
---|---|
IMAGE_FILE_MACHINE_I386(0x014c) | x86 |
IMAGE_FILE_MACHINE_IA64(0x0200) | Intel Itanium |
IMAGE_FILE_MACHINE_AMD64(0x8664) | x64 |
WORD NumberOfSections
区块的数目
time_t TimeDateStamp
创建文件的时间,从1970年1月1日以来用格林威治时间(GMT)计算的秒数。
DWORD PointerToSymbolTable
符号表的偏移位置
DWORD NumberOfSymbols
如果有COFF符号表,它带包符号表的符号数目(调试的时候用)
WORD SizeOfOptionalHeader
紧跟着IMAGE_FILE_HEADER后边的数据结构的大小。
(对于32位pe文件这个值通常是00e0h,对于64位PE32+文件,这个值通常是00f0h)
struct FILE_CHARACTERISTICS Characteristics
文件属性
struct IMAGE_OPTIONAL_HEADER32 OptionalHeader
typedef struct _IMAGE_OPTIONAL_HEADER
{
//
// Standard fields.
//
+18h WORD Magic; // 标志字, ROM 映像(0107h),普通可执行文件(010Bh)
+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 additional fields. 以下是属于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; // 所有头 + 区块表的尺寸大小
+58h DWORD CheckSum; // 映像的校检和
+5Ch WORD Subsystem; // 可执行文件期望的子系统
+5Eh WORD DllCharacteristics; // DllMain()函数何时被调用,默认为 0
+60h DWORD SizeOfStackReserve; // 初始化时的栈大小
+64h DWORD SizeOfStackCommit; // 初始化时实际提交的栈大小
+68h DWORD SizeOfHeapReserve; // 初始化时保留的堆大小
+6Ch DWORD SizeOfHeapCommit; // 初始化时实际提交的堆大小
+70h DWORD LoaderFlags; // 与调试有关,默认为 0
+74h DWORD NumberOfRvaAndSizes; // 下边数据目录的项数,这个字段自Windows NT 发布以来 // 一直是16
+78h IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];
// 数据目录表
} IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32;
DWORD AddressOfEntryPoint
RVA地址,程序的入口地址。
DWORD ImageBase
指出文件的优先装入地址。当文件执行的时候,如果可能,windows优先将文件装入指定的地址中。如果指定的地址被其他的module占用,文件才能被装入其他地址中。连接器产生可执行文件的时候采用这个地址生成机器码,所以当文件被装入这个地址不需要进行地址的重定位操作,装入的速度最快。如果文件被装载到其他地方,要进行重定位操作,速度变慢。
DWORD SectionAlignment和DWORD FileAlignment
前者是在内存中Section的对齐单位,每个节被装入的地址必定是指定字段地址数值的整数倍,后者是在磁盘文中时的对齐单位。
DWORD NumberOfRvaAndSizes
下面数据目录的项数
struct IMAGE_DATA_DIRECTORY_ARRAY DataDirArray
由16给相同的IMAGE_DATA_DIRECTRY结构组成,虽然PE文件中的数据是按照装入内存后的页属性归类而放在不同的节中,但这些处于各个节中的数据按照用途可以被分为导出表,导入表,资源,重定位表等数据库。这16个IMAGE_DATA_DIRECTRY结构就是用来定义多种不同用途的数据块的。
IMAGE_DATA_DIRECTORY STRUCT
VirtualAddress DWORD ? ;数据的起始RVA
isize DWORD ? ;数据块的长度
IMAGE_DATA_DIRECTORY ENDS
索 引 | 索引值在Windows.inc中的预定义值 | 对应的数据块 |
---|---|---|
0 | IMAGE_DIRECTORY_ENTRY_EXPORT | 导出表 |
1 | IMAGE_DIRECTORY_ENTRY_IMPORT | 导入表 |
2 | IMAGE_DIRECTORY_ENTRY_RESOURCE | 资源 |
3 | IMAGE_DIRECTORY_ENTRY_EXCEPTION | 异常(具体资料不详) |
4 | IMAGE_DIRECTORY_ENTRY_SECURITY | 安全(具体资料不详) |
5 | IMAGE_DIRECTORY_ENTRY_BASERELOC | 重定位表 |
6 | IMAGE_DIRECTORY_ENTRY_DEBUG | 调试信息 |
7 | IMAGE_DIRECTORY_ENTRY_ARCHITECTURE | 版权信息 |
8 | IMAGE_DIRECTORY_ENTRY_GLOBALPTR | 具体资料不详 |
9 | IMAGE_DIRECTORY_ENTRY_TLS | Thread Local Storage |
10 | IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG | 具体资料不详 |
11 | IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT | 具体资料不详 |
12 | IMAGE_DIRECTORY_ENTRY_IAT | 导入函数地址表 |
13 | IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT | 具体资料不详 |
14 | IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR | 具体资料不详 |
15 | 未使用 |
块表(section table)
在执行PE文件的时候,windows并不将整个文件读入内存。windows装载器在装载PE文件的时候仅仅建立好虚拟地址和PE文件之间的映射关系,当真正执行到某个内存页的指令或访问到某个内存页的数据时,这个页面才会从磁盘提交到物理内存。
内存映射文件和系统装载可执行文件
这两个并不等同,内存映射文件的时候,数据本身和数据之间的相对位置完全相同,系统装载可执行文件的时候,数据之间的相对位置可能发生变化。
PE到内存的映射(装载)
windows装载器在装在到DOS部分,PE文件头部分和节表(区块表)部分是不进行任何特殊处理的,而在装在节(区块)的时候会自动按照区块的属性做不同的操作,一般情况下会处理以下几个方面:
- 内存页的属性
- 节的偏移地址
- 节的尺寸
- 不进行映射的节
内存页的属性
对于磁盘映射文件来说,所有的页都是按照磁盘映射文件函数指定的属性设置的。但是在装载可执行文件的时候,与节对应的内存页要按照节的属性来设置。所以对应属于同一个module的内存页来说,来自不同节的内存页的属性不同。
节的偏移地址
节的起始地址是按照IMAGE_OPTIONAL_HEADER32中的FileAlignment字段来对齐的,而在内存中是按照SectionAlignment对齐的,两者值不同,所以相对于基地址的偏移和相对于文件头的偏移不同。
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 结构中的数据目录字段结合进行定位。
Virtual Size:对表对应的区块的大小,这是区块的数据在没有进行对齐处理前的实际大小。
Virtual Address:该区块装载到内存中的RVA 地址。这个地址是按照内存页来对齐的,因此它的数值总是 SectionAlignment 的值的整数倍。在Microsoft 工具中,第一个快的默认 RVA 总为1000h。在OBJ 中,该字段没有意义地,并被设为0。
SizeOfRawData:该区块在磁盘中所占的大小。在可执行文件中,该字段是已经被FileAlignment 潜规则处理过的长度。
PointerToRawData:该区块在磁盘中的偏移。这个数值是从文件头开始算起的偏移量。
PointerToRelocations:这在EXE文件中没有意义,在OBJ 文件中,表示本区块重定位信息的偏移值。(在OBJ 文件中如果不是零,它会指向一个IMAGE_RELOCATION 结构的数组)
PointerToLinenumbers:行号表在文件中的偏移值,文件的调试信息,于我们没用,鸡肋。
NumberOfRelocations:这哥们在EXE文件中也没有意义,在OBJ 文件中,是本区块在重定位表中的重定位数目来着。
NumberOfLinenumbers:该区块在行号表中的行号数目,鸡肋。
Characteristics:该区块的属性。该字段是按位来指出区块的属性(如代码/数据/可读/可写等)的标志。
具体内容可以参考MSDN在线文档:http://msdn.microsoft.com/en-us/library/ms680341%28v=vs.85%29.aspx
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. 该区块可写。 |
当然我们在Visual C++ 中也可以自己命名我们的区块,用#pragma 来声明,告诉编译器插入数据到一个区块内,格式如下:
#pragma data_msg( "FC_data" )
大家还记得吧,#为宏处理符号,啥是宏?简单的说就是编译器的时候由编译器直接先进行翻译。或许说按照指定的格式机械替换。嘻嘻,学破解要懂编程呐~
以上语句告诉编译器将数据都放进一个叫“FC_data” 的区块内,而不是默认的.data 区块。区块一般是从OBJ 文件开始,被编译器放置的。链接器的工作就是合并左右OBJ 和库中需要的块,使其成为一个最终合适的区块。链接器会遵循一套相当完整的规则,它会判断哪些区块将被合并以及如何被合并。
合并区块:
链接器的一个有趣特征就是能够合并区块。如果两个区块有相似、一致性的属性,那么它们在链接的时候能被合并成一个单一的区块。这取决于是否开启编译器的 /merge 开关。事实上合并区块有一个好处就是可以节省磁盘的内存空间……注意:我们不应该将.rsrc、.reloc、.pdata 合并到的区块里。
区块的对齐值:
之前我们简单了解过区块是要对齐的,无论是在内存中存放还是在磁盘中存放~但他们一般的对齐值是不同的。
PE 文件头里边的FileAligment 定义了磁盘区块的对齐值。每一个区块从对齐值的倍数的偏移位置开始存放。而区块的实际代码或数据的大小不一定刚好是这么多,所以在多余的地方一般以00h 来填充,这就是区块间的间隙。
例如,在PE文件中,一个典型的对齐值是200h ,这样,每个区块都将从200h 的倍数的文件偏移位置开始,假设第一个区块在400h 处,长度为90h,那么从文件400h 到490h 为这一区块的内容,而由于文件的对齐值是200h,所以为了使这一区块的长度为FileAlignment 的整数倍,490h 到 600h 这一个区间都会被00h 填充,这段空间称为区块间隙,下一个区块的开始地址为600h 。
PE 文件头里边的SectionAligment 定义了内存中区块的对齐值。PE 文件被映射到内存中时,区块总是至少从一个页边界开始。
一般在X86 系列的CPU 中,页是按4KB(1000h)来排列的;在IA-64 上,是按8KB(2000h)来排列的。所以在X86 系统中,PE文件区块的内存对齐值一般等于 1000h,每个区块按1000h 的倍数在内存中存放.
RVA转换文件偏移
若有一个RVA为2000h,易得其于目前这个区块中,其相对于目前这个区块的虚拟地址差为1000h,那么他在文件中的偏移位置就是这个差值+区块在文件中的地址。即为1600h
输入表
首先,我们知道PE 文件中的数据被载入内存后根据不同页面属性被划分成很多区块(节),并有区块表(节表)的数据来描述这些区块(这里个人认为并不是载入内存后才分为了很多区块,因为很显然,在PE文件被生成的时候已经划分为了这么多区块,故而这些是在连接过程中就已经完成的)。这里我们需要注意的问题是:一个区块中的数据仅仅只是由于属性相同而放在一起,并不一定是同一种用途的内容。例如接着要讲的输入表、输出表等就有可能和只读常量一起被放在同一个区块中,因为他们的属性都是可读不可写的。
其次,由于不同用途的数据有可能被放入同一个区块中,因此仅仅依靠区块表是无法确定和定位的。那要怎么办?对了,PE 文件头中 IMAGE_OPTIONAL_DEADER32 结构的数据目录表来指出他们的位置,我们可以由数据目录表来定位的数据包括输入表、输出表、资源、重定位表和TLS等15 种数据。
输入函数
在代码分析或编程中经常遇到“输入函数(Import Functions,也称导入函数)”的概念。这里我们就来解释下,输入函数就是被程序调用但其执行代码又不在程序中的函数,这些函数的代码位于相关的DLL 文件中,在调用者程序中只保留相关的函数信息(如函数名、DLL 文件名等)就可以。对于磁盘上的PE 文件来说,它无法得知这些输入函数在内存中的地址,只有当PE 文件被装入内存后,Windows 加载器才将相关DLL 装入,并将调用输入函数的指令和函数实际所处的地址联系起来。这就是“动态链接”的概念。动态链接是通过PE 文件中定义的“输入表”来完成的,输入表中保存的正是函数名和其驻留的DLL 名等。
回顾一下,在 PE文件头的 IMAGE_OPTIONAL_HEADER 结构中的 DataDirectory(数据目录表) 的第二个成员就是指向输入表的。而输入表是以一个 IMAGE_IMPORT_DESCRIPTOR(简称IID) 的数组开始。每个被 PE文件链接进来的 DLL文件都分别对应一个 IID数组结构。在这个 IID数组中,并没有指出有多少个项(就是没有明确指明有多少个链接文件),但它最后是以一个全为NULL(0) 的 IID 作为结束的标志。
IMAGE_IMPORT_DESCRIPTOR 结构定义如下:
IMAGE_IMPORT_DESCRIPTOR STRUCT
union
Characteristics DWORD ?
OriginalFirstThunk DWORD ?
ends
TimeDateStamp DWORD ?
ForwarderChain DWORD ?
Name DWORD ?
FirstThunk DWORD ?
IMAGE_IMPORT_DESCRIPTOR ENDS
成员介绍:
OriginalFirstThunk
它指向first thunk,IMAGE_THUNK_DATA,该 thunk 拥有 Hint 和 Function name 的地址。
TimeDateStamp
该字段可以忽略。如果那里有绑定的话它包含时间/数据戳(time/data stamp)。如果它是0,就没有绑定在被导入的DLL中发生。在最近,它被设置为0xFFFFFFFF以表示绑定发生。
ForwarderChain
一般情况下我们也可以忽略该字段。在老版的绑定中,它引用API的第一个forwarder chain(传递器链表)。它可被设置为0xFFFFFFFF以代表没有forwarder。
Name
它表示DLL 名称的相对虚地址(译注:相对一个用null作为结束符的ASCII字符串的一个RVA,该字符串是该导入DLL文件的名称,如:KERNEL32.DLL)。
FirstThunk
它包含由IMAGE_THUNK_DATA定义的 first thunk数组的虚地址,通过loader用函数虚地址初始化thunk。在Orignal First Thunk缺席下,它指向first thunk:Hints和The Function names的thunks。
我们看到:OriginalFirstThunk 和 FirstThunk 他们都是两个类型为IMAGE_THUNK_DATA 的数组,它是一个指针大小的联合(union)类型。每一个IMAGE_THUNK_DATA 结构定义一个导入函数信息(即指向结构为IMAGE_IMPORT_BY_NAME 的家伙,这家伙稍后再议),然后数组最后以一个内容为0 的 IMAGE_THUNK_DATA 结构作为结束标志。
我们得到 IMAGE_THUNK_DATA 结构的定义如下:
IMAGE_THUNK_DATA STRUC
union u1{
ForwarderString DWORD ? ; 指向一个转向者字符串的RVA
Function DWORD ? ; 被输入的函数的内存地址
Ordinal DWORD ? ; 被输入的API 的序数值
AddressOfData DWORD ? ; 指向 IMAGE_IMPORT_BY_NAME
}ends
IMAGE_THUNK_DATA ENDS
好,那接着我们讨论下指向的这个 IMAGE_IMPORT_BY_NAME 结构。IMAGE_IMPORT_BY_NAME 结构仅仅只有一个字型数据的大小,存有一个输入函数的相关信息结构。其结构如下:
IMAGE_IMPORT_BY_NAME STRUCT
Hint WORD ?
Name BYTE ?
IMAGE_IMPORT_BY_NAME ENDS
结构中的 Hint 字段也表示函数的序号,不过这个字段是可选的,有些编译器总是将它设置为 0,Name 字段定义了导入函数的名称字符串,这是一个以 0 为结尾的字符串。
为什么由两个并行的指针数组同时指向 IMAGE_IMPORT_BY_NAME 结构呢?第一个数组(由 OriginalFirstThunk 所指向)是单独的一项,而且不能被改写,我们前边称为 INT。第二个数组(由 FirstThunk 所指向)事实上是由 PE 装载器重写的。
好了,那么 PE 装载器的核心操作时如何的呢?这里就给大家揭秘啦~
PE 装载器首先搜索 OriginalFirstThunk ,找到之后加载程序迭代搜索数组中的每个指针,找到每个 IMAGE_IMPORT_BY_NAME 结构所指向的输入函数的地址,然后加载器用函数真正入口地址来替代由 FirstThunk 数组中的一个入口,因此我们称为输入地址表(IAT)。
导出表
当PE 文件被执行的时候,Windows 加载器将文件装入内存并将导入表(Export Table) 登记的动态链接库(一般是DLL 格式)文件一并装入地址空间,再根据DLL 文件中的函数导出信息对被执行文件的IAT 进行修正。
Windows 在加载一个程序后就在内存中为该程序开辟一个单独的虚拟地址空间,这样的话在各个程序自己看来,自己就拥有几乎任意地址的支配权,所以他自身的函数想放在哪个地址自己说了算。有一些函数很多程序都会用到,为每一个程序写一个相同的函数看起来似乎有点浪费空间,因此Windows就整出了动态链接库的概念,将一些常用的函数封装成动态链接库,等到需要的时候通过直接加载动态链接库,将需要的函数整合到自身中,这样就大大的节约了内存中资源的存放。
动态链接库是被映射到其他应用程序的地址空间中执行的,它和应用程序可以看成是“一体”的,动态链接库可以使用应用程序的资源,它所拥有的资源也可以被应用程序使用,它的任何操作都是代表应用程序进行的,当动态链接库进行打开文件、分配内存和创建窗口等操作后,这些文件、内存和窗口都是为应用程序所拥有的。
导出表就是记载着动态链接库的一些导出信息。通过导出表,DLL 文件可以向系统提供导出函数的名称、序号和入口地址等信息,比便Windows 加载器通过这些信息来完成动态连接的整个过程。
扩展名为.exe 的PE 文件中一般不存在导出表,而大部分的.dll 文件中都包含导出表。但注意,这并不是绝对的。例如纯粹用作资源的.dll 文件就不需要导出函数啦,另外有些特殊功能的.exe 文件也会存在导出函数。
导出表结构
导出表(Export Table)中的主要成分是一个表格,内含函数名称、输出序数等。序数是指定DLL 中某个函数的16位数字,在所指向的DLL 文件中是独一无二的。在此我们不提倡仅仅通过序数来索引函数的方法,这样会给DLL 文件的维护带来问题。例如当DLL 文件一旦升级或修改就可能导致调用改DLL 的程序无法加载到需要的函数。
数据目录表的第一个成员指向导出表,是一个IMAGE_EXPORT_DIRECTORY(以后简称IED)结构,IED 结构的定义如下:
IMAGE_EXPORT_DIRECTORY STRUCT
CharacteristicsDWORD?; 未使用,总是定义为0
TimeDateStamp DWORD? ; 文件生成时间
MajorVersion WORD? ; 未使用,总是定义为0
MinorVersion WORD? ; 未使用,总是定义为0
NameDWORD?; 模块的真实名称
Base DWORD?; 基数,加上序数就是函数地址数组的索引值
NumberOfFunctionsDWORD?; 导出函数的总数
NumberOfNames DWORD? ; 以名称方式导出的函数的总数
AddressOfFunctionsDWORD?; 指向输出函数地址的RVA
AddressOfNamesDWORD?; 指向输出函数名字的RVA
AddressOfNameOrdinalsDWORD?; 指向输出函数序号的RVA
IMAGE_EXPORT_DIRECTORY ENDS
这个结构中的一些字段并没有被使用,有意义的字段说明如下。
- Name:一个RVA 值,指向一个定义了模块名称的字符串。如即使Kernel32.dll 文件被改名为"Ker.dll",仍然可以从这个字符串中的值得知其在编译时的文件名是"Kernel32.dll"。
- NumberOfFunctions:文件中包含的导出函数的总数。
- NumberOfNames:被定义函数名称的导出函数的总数,显然只有这个数量的函数既可以用函数名方式导出。也可以用序号方式导出,剩下 的NumberOfFunctions 减去NumberOfNames 数量的函数只能用序号方式导出。该字段的值只会小于或者等于 NumberOfFunctions 字段的值,如果这个值是0,表示所有的函数都是以序号方式导出的。(ring3 NumberOfNames[索引值] == 0x10000 说明遍历完成 ring0 MmIsAddressValid 判断 模块基地址+NumberOfNames[索引值] 是不是有效地址。不是。说明遍历完成)
- AddressOfFunctions:一个RVA 值,指向包含全部导出函数入口地址的双字数组。数组中的每一项是一个RVA 值,数组的项数等于NumberOfFunctions 字段的值。
- Base:导出函数序号的起始值,将AddressOfFunctions 字段指向的入口地址表的索引号加上这个起始值就是对应函数的导出 序号。假如Base 字段的值为x,那么入口地址表指定的第1个导出函数的序号就是x;第2个导出函数的序号就是x+1。总之,一个导出函数的导出序号等 于Base 字段的值加上其在入口地址表中的位置索引值。(经过我的测试貌似不需要+Base 序号直接旧事AddressOfFunctions指向数组的索引值)
- AddressOfNames 和 AddressOfNameOrdinals:均为RVA 值。前者指向函数名字符串地址表。这个地址表是一个双字数组,数组中的每一项指向一个函数名称字符串的RVA。数组的项数等于NumberOfNames 字段的值,所有有名称的导出函数的名称字符串都定义在这个表中;后者指向另一个word 类型的数组(注意不是双字数组)。数组项目与文件名地址表中的项目一一对应,项目值代表函数入口地址表的索引,这样函 数名称与函数入口地址关联起来。
序号查找函数入口地址
Windows 装载器的工作步骤如下:
- 定位到PE 文件头
- 从PE 文件头中的 IMAGE_OPTIONAL_HEADER32 结构中取出数据目录表,并从第一个数据目录中得到导出表的RVA
- 从导出表的 Base 字段得到起始序号
- 将需要查找的导出序号减去起始序号,得到函数在入口地址表中的索引
- 检测索引值是否大于导出表的 NumberOfFunctions 字段的值,如果大于后者的话,说明输入的序号是无效的
- 用这个索引值在 AddressOfFunctions 字段指向的导出函数入口地址表中取出相应的项目,这就是函数入口地址的RVA 值,当函数被装入内存的时候,这个RVA 值加上模块实际装入的基地址,就得到了函数真正的入口地址
函数名称查找函数入口地址
Windows 装载器的工作步骤如下:
- 最初的步骤是一样的,那就是首先得到导出表的地址
- 从导出表的 NumberOfNames 字段得到已命名函数的总数,并以这个数字作为循环的次数来构造一个循环
- 从 AddressOfNames 字段指向得到的函数名称地址表的第一项开始,在循环中将每一项定义的函数名与要查找的函数名相比较,如果没有任何一个函数名是符合的,表示文件中没有指定名称的函数
- 如果某一项定义的函数名与要查找的函数名符合,那么记下这个函数名在字符串地址表中的索引值,然后在 AddressOfNamesOrdinals 指向的数组中以同样的索引值取出数组项的值,我们这里假设这个值是x
- 最后,以 x 值作为索引值,在 AddressOfFunctions 字段指向的函数入口地址表中获取的 RVA 就是函数的入口地址
基址重定位
本身程序要占有一个地址,但是地址已经被占用,所以需要进行重定位。当动态链接库执行的时候发生,因为动态链接库的函数一般是被别的进程调用,当这个dll被载入程序的时候很有可能地址已经被占用,故而需要进行地址重定位。当然这里是虚拟内存空间的重定位,物理空间的寻址通过内存管理器完成。
但凡涉及到直接寻址的指令都需要进行重定位处理,如
:10001026 FF0500300010 inc dword ptr [10003000]
:1000102C FF3500300010 push dword ptr [10003000]
:1000103D A100300010 mov eax, dword ptr [10003000]
:10001049 FF0D00300010 dec dword ptr [10003000]
:1000104F FF3500300010 push dword ptr [10003000]
:10001060 A100300010 mov eax, dword ptr [10003000]
:1000106A FF2500200010 Jmp dword ptr [10002000]
假设重定位后的基地址由原来的10000000h 变为 20000000h了,那么类似这样的语句:inc dword ptr [10003000] 应该改成 inc dword ptr [20003000] 。
注意,重定位的算法我们可以总结为:将直接寻址指令中的双字地址加上模块的实际装入地址与模块建议装入地址之差。
从上边的信息中我们看到,需要进行重定位需要三个因素:
- 需要修正的地址(10003000h)
- 建议装入的地址(10003000h)
- 实际装入的地址(20003000h)
建议装入的地址在pe头中
实际装入的地址装载器装入的时候才知道
PE 文件的重定位表(Base Relocation Table)中保存的就是文件中所有需要进行重定位修正的代码的地址。
基址重定位表
IMAGE_BASE_RELOCATION STRUC
VirtualAddress DWORD ? ; 重定位数据开始的RVA 地址
SizeOfBlock DWORD ? ; 重定位块得长度
TypeOffset WORD ? ; 重定项位数组
IMAGE_BASE_RELOCATION ENDS
VirtualAddress 是 Base Relocation Table 的位置它是一个 RVA 值;
SizeOfBlock 是 Base Relocation Table 的大小;
TypeOffset 是一个数组,数组每项大小为两个字节(16位),它由高 4位和低 12位组成,高 4位代表重定位类型,低 12位是重定位地址,它与 VirtualAddress 相加即是指向PE 映像中需要修改的那个代码的地址。
IMAGE_REL_BASED_ABSOLUTE (0) 使块按照32位对齐,位置为0。
IMAGE_REL_BASED_HIGH (1) 高16位必须应用于偏移量所指高字16位。
IMAGE_REL_BASED_LOW (2) 低16位必须应用于偏移量所指低字16位。
IMAGE_REL_BASED_HIGHLOW (3) 全部32位应用于所有32位。
IMAGE_REL_BASED_HIGHADJ (4) 需要32位,高16位位于偏移量,低16位位于下一个偏移量数组元素,组合为一个带符号数,加上32位的一个数,然后加上8000然后把高16位保存在偏移量的16位域内。
IMAGE_REL_BASED_MIPS_JMPADDR (5) Unknown
IMAGE_REL_BASED_SECTION (6) Unknown
IMAGE_REL_BASED_REL32 (7) Unknown
资源
可以进行资源的修改和替换。
资源结构
资源目录结构
数据目录表中的 IMAGE_DIRECTORY_ENTRY_RESOURCE 条目(第三项)包含资源的 RVA 和大小。资源目录结构中的每一个节点都是由 IMAGE_RESOURCE_DIRECTORY 结构和紧跟其后的数个IMAGE_RESOURCE_DIRECTORY_ENTRY 结构组成的。
认识了这层关系后,我们来看下 IMAGE_RESOURCE_DIRECTORY 这个结构,该结构长度为 16 字节,共有 6 个字段,定义如下:
IMAGE_RESOURCE_DIRECTORY STRUCT
Characteristics DWORD ? ;理论上为资源的属性,不过事实上总是0
TimeDateStamp DWORD ? ;资源的产生时刻
MajorVersion WORD ? ;理论上为资源的版本,不过事实上总是0
MinorVersion WORD ?
NumberOfNamedEntries WORD ? ;以名称(字符串)命名的入口数量
NumberOfIdEntries WORD ? ;以ID(整型数字)命名的入口数量
IMAGE_RESOURCE_DIRECTORY ENDS
其实在这里边我们唯一要注意的就是 NameberOfNamedEntries 和 NumberOfIdEntries,它们说明了本目录中目录项的数量。两者加起来就是本目录中的目录项总和。也就是后边跟着的IMAGE_RESOURCE_DIRECTORY_ENTRY 数目。
资源目录入口的结构
IMAGE_RESOURCE_DIRECTORY_ENTRY 紧跟在资源目录结构后,此结构长度为 8 个字节,包含 2 个字段。该结构定义如下:
IMAGE_RESOURCE_DIRECTORY_ENTRY STRUCT
Name DWORD ? ;目录项的名称字符串指针或ID
OffsetToData DWORD ? ;目录项指针
IMAGE_RESOURCE_DIRECTORY_ENTRY ENDS
Name 字段完全是个百变精灵,该字段定义的是目录项的名称或ID。当结构用于第一层目录时,定义的是资源类型;当结构定义于第二层目录时,定义的是资源的名称;当结构用于第三层目录时,定义的是代码页编号。
注意:当最高位为 0 的时候,表示字段的值作为 ID 使用;而最高位为 1 的时候,字段的低位作为指针使用(资源名称字符串是使用 UNICODE编码),但是这个指针不是直接指向字符串哦,而是指向一个 IMAGE_RESOURCE_DIR_STRING_U 结构的。
该结构定义如下:
IMAGE_RESOURCE_DIR_STRING_U STRUCT
Length DWORD ? ; 字符串的长度
NameString DWORD ? ; UNICODE字符串,由于字符串是不定长的。由Length 制定长度
IMAGE_RESOURCE_DIR_STRING_U ENDS
OffsetOfData 字段是一个指针,当最高位为 1 时,低位数据指向下一层目录块的其实地址;当最高位为 0 时,指针指向 IMAGE_RESOURCE_DATA_ENTRY 结构。
注意:将 Name 和 OffsetToData 用做指针时需要注意,该指针是从资源区块开始的地方算起的偏移量(即根目录的起始位置的偏移量),不是我们习惯的 RVA 哦。
最后,在上图中我们看到,在第一层的时候,IMAGE_RESOURCE_DIRECTORY_ENTRY 的Name 字段作为资源类型使用。
具体类型匹配见下表:
资源入口地址
经过三层 IAMGE_RESOURCE_DIRECTORY_ENTRY (一般是3层,偶尔更少一些。第一层资源类型,第二层资源名,第三层是资源的 Language),第三层目录结构中的 OffsetToData 指向 IMAGE_RESOURCE_DATA_ENTRY 结构。该结构描述了资源数据的位置和大小,定义如下:
IMAGE_RESOURCE_DATA_ENTRY STRUCT
OffsetToData DWORD ? ; 资源数据的RVA
Size DWORD ? ; 资源数据的长度
CodePage DWORD ? ; 代码页, 一般为0
Reserved DWORD ? ; 保留字段
IMAGE_RESOURCE_DATA_ENTRY ENDS
此处的 IMAGE_RESOURCE_DATA_ENTRY 结构就是真正的资源数据了。结构中的OffsetOfData 指向资源数据的指针,其为 RVA 值。
文章评论