PE
- PE(protable executable) 可移植的可执行文件
-
EXE和DLL文件之间的区别是语义上的,他们使用完全相同的PE格式。唯一的区别:用一个字段标识出EXE或DLL。
-
64位Windows,新的PE : PE32+。普通PE:PE32.PE32+没有任何新的结构加进去。
-
PE格式定义的主要地方位于头文件 winnt.h,头文件中几乎能找到关于PE文件的所有定义。
-
文件内容被分割为不同区块。每个块都有自己的属性(是否只读,是否有代码,可读/可写)
-
PE文件不是作为单一内存映射文件被装入内存。
-
PE装载器遍历PE,决定哪一部分被映射。映射方式:文件的较高偏移映射到内存里较高的偏移地址
-
磁盘文件被装入内存,磁盘上的数据结构布局和内存上的一样
-
基地址、相对虚拟地址、文件偏移地址
- 基地址(ImageBase)内存中的PE结构的头地址。模块的句柄(函数:GetModuleHandle(LPCTSTR lpModuleName)获得模块的句柄)
- 文件的偏移地址
- 虚拟地址:文件在内存中的地址
- 相对虚拟地址(RVA)。relative virtual of address。 虚拟地址相对于基地址的偏移
MS-DOS头部
- PE文件的第一个字节起始于一个传统的MS-DOS头部。称为:IMAGE_DOS_HEADER(一个结构体)
- IMAGE_DOS_HEADER里比较重要的:
- +0h e_magic (MZ 4Dh 5Ah)DOS的可执行文件标记
- +3ch e_lfanew 指针,指向PE文件头的偏移地址。
PE头
-
在IMAGE_DOS_HEADER中的e_lfanew能找到PE头的起始偏移量
- PNTHeader = ImageBase + dosHeader->e_lfanew
-
IMAGE_NT_HEADERS
PEHeader是PE相关结构NT映像头(IMAGE_NE_HEADER) 的简称,包含许多PE装载器用到的重要容器。
-
IMAGE_NT_HEADERS STRUCT {+0h DWORD signature +4h IMAGE_FILE_HEADER FileHeader +18h IMAGE_OPTIONAL_HEADER32 OptionalHeader // PE头地址+18h即可选头的起始地址。 }IMAGE_NT_HEADERS ENDS
-
Signature:在有效的PE文件中,Signature字段置为00004550h,ASCII:“PE00",PE文件的开头
-
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;
(6)SizeOfOptionalHeader: 紧跟着IMAGE_FILE_HEADER 后边的数据结构(IMAGE_OPTIONAL_HEADER)的大小。(对于32位PE文件,这个值通常是00E0h;对于64位PE32+文件,这个值是00F0h )。
-
IMAGE_OPTIONAL_HEADER32(可选头
此结构中大部分字段不重要,但是一些病毒会利用里面不重要的部分,在其中做手脚
-
+28h DWORD AddressOfEntryPoint // 程序执行入口RVA
指出文件被执行时的入口地址,(RVA地址)。如果在一个可执行文件上附加了一段代码,并想让这一段代码首先被执行,只需要将这个入口地址指向附加的代码就可以了。(可能会执行恶意代码)
对于一般DLL文件,一般不起作用,这块被填充0
-
+34h DWORD ImageBase // 程序的首选装载地址
程序的首选装载地址,只有指定的地址被其他模块使用时,文件被装入到其他地址。
EXE文件,每个文件总是使用独立的虚拟地址空间,优先装入的地址不可能被其他模块占据,EXE总是能按这个地址装入。
DLL文件,多个DLL文件全部使用宿主EXE文件的地址空间,不能保证优先装入地址,所以包含重定位信息。
-
+38h DWORD SectionAlignment字段 //内存中区块的对齐大小(默认1000hB)
**和+3ch DWORD FileAlignment字段 ** // 文件中的区块的对齐大小(默认200hB)
SectionAlignment字段:每个节装入的地址时本字段指定数值的整数值。
FileAlignment字段:节存储在磁盘文件中时的对其单位
-
+78h IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES] //数据目录表
数组,一直是16个元素。数组里面每一个都是一个结构
IMAGE_DATA_DIRECTORY STRUCT VirtualAddress DWORD ; // 数据的起始RVA isize DWORD ; // 数据块的长度
数组的十六个元素 0、导出表 1、导入表 2、资源 5、重定位表
-
PE文件到内存的映射
-
执行PE文件的时候,Windows不在一开始就将整个文件读入内存。采用与内存映射文件类似的机制
-
当且仅当真正执行到某个内存页中的指令或者访问某一页中的数据时,这个页才会被从磁盘提交到物理内存。文件装入的速度和文件大小没有很大的关系。(don’t call me ,i will call you)
-
再装载可执行文件的时候,有些数据再装入前会被预处理,如重定位等。
-
Windows装载DOS、PE文件头部分和节表部分时不进行任何特殊处理的,而再装载节的时候会自动按节的属性做不同的处理。
-
会处理以下几个方面的内容:
-
内存页的属性
-
节的偏移地址
节在磁盘中的偏移和在内存中的偏移是不同的。
节是相同属性数据的组合。
-
节的尺寸
-
不进行映射的节
-
-
节表(区块表)
索引的作用。所有节的属性都被定义在节表中。节表在文件中的排列顺序与它们描述的节在文件中的排列顺序是一致的。
-
全部有效结构的最后,以一个空的IMAGE_SECTION_HEADER结构作为结束,所以节表中总的IMAGE_SECTION_HEADER结构数量,等于节的数量加一。
-
IMAGE_SECTION_HEADER结构的总数 由IMAGE_NT_HEADER->FileHeader.NumberOfSection 来指定。
-
节表总是被存放在紧接在PE文件头的地方。
-
节表(区块表)
-
一个区块中的数据仅只是由于属性相同而放在一起,并不一定是同一种用途的内容。例如:输入表、输出表有可能和只读常量一起放在同一个区块中,因为他们的属性都是可读不可写的。
每个区块表的大小是 28h。
-
成员:
-
Name:区块名。由8位的ASCII码组成。如果区块名超过8个字节,没有最后的终止标志“NULL”。
定义的区块名是唯一的。病毒感染文件时,要开辟一块区块“.kkkkfj",再次感染时可以判断是不是感染过。
区块名可任意定义。从PE文件中读取需要的区块的时候,不能以区块的名称作为定位的标准和依据。正确方法:按照IMAGE_OPTIONAL_HEADER32结构中的数据目录表(IMAGE_DATA_DIRECTORY )结合进行定位
-
Virtual Size:对应的区块的大小,是区块的数据在没有进行对齐处理的大小
-
Virtual Address:该区块装载到内存中的RVA地址,按照内存页对齐。数值总是SctionAlignment的整数倍。默认1000h
-
SizeOfRawData:该区块在磁盘中所占的大小。
-
PointerToRawData:该区块在磁盘中的偏移(从文件头开始算起的偏移量
-
第一个区块的 PointerToRawData+第一个区块的 SizeOfRawData = 第二个区块的起始位置
-
Characteristics:区块的属性(可查
-
-
PE文件一般至少会有两个区块,代码块、数据块。
-
- .data 默认的读/写数据区块,全局变量、静态变量一般在里面
- .text 默认的区块代码,内容全是指令代码。
- .rdata 代 表是一个只读区块。
- .rarc 资源。这个区块是只读的。
- .bss 未初始化数据。很少用。
- .reloc 基址重定位
-
在c++里命名区块:用
# pragma
来声明,格式:# pragma data_msg("FC_data")
.#
为宏处理符号
对齐值
- PE文件头里的FileAligment定义了磁盘区块的对齐值。
RVA和文件偏移的转换
- Relative Virtual Address,相对虚拟地址。某个数据位置相对于文件头的偏移量。
- DOS文件头、PE文件头和区块表的偏移位置与大小没有变化。而各个区块映射到内存后,其偏移位置就发生了变化
- 利用RVA找到在文件中的偏移地址
输入表(导入表) IMAGE_DIRECTORY_ENTRY_IMPORT
-
输入函数
被程序调用但其执行代码又不在程序中的函数。这些函数的代码位于相关的DLL文件中。
对于磁盘上的PE文件,它无法得知这些输入函数在内存中的地址,只有当PE文件被装入内存后,windows加载器才将相关的DLL装入,并将调用输入函数的指令和函数实际所处的地址联系起来。即”动态链接”。
-
输入表是以一个IMAGE_IMPORT_DESCRIPTOR(IID)的数组开始。
-
每个被PE文件链接进来的DLL文件都分别对应一个IID数组结构。在这个IID数组中,没有指出又多少个项,但它最后是以一个 全为NULL(0) 的IID作为结束的标志。
-
IID结构定义:
typedef struct _IMAGE_IMPORT_DESCRIPTOR
{
union // 联合类型,只取其中一个的值(这里一般取OriginalFirstThunk
{
DWORD Characteristics;
DWORD OriginalFirstThunk; //RVA,指向INT表--导入名字表。(IMAGE_THUNK_DATA结构数组
};
DWORD TimeDateStamp; //时间戳
DWORD ForwarderChain;
DWORD Name; // RVA,指向dll名字,该名字以0结尾
DWORD FirstThunk;// RVA,指向IAT表--导入地址表(IMAGE_THUNK_DATA结构数组
}IMAGE_IMPORT_DESCRIPTOR;
-
IID成员:
补充:
- IMAGE_THUNK_DATA
typedef struct _IMAGE_THUNK_DATA32{ union{ DWORD ForwarderString; // 转发用的(暂时不用考虑 DWORD Function; // 函数地址 DWORD Ordinal; // 若按序号导入,用到Ordinal DWORD AddressOfDta; // 若按名字导入,指向名字信息 }u1; }IMAGE_THUNK_DATA32;
判断按名字导入还是按序号导入:
Ordinal最高位为1:按序号,低16位就是导入序号
最高位为0:AddressOfData是一个RVA,指向一个IMAGE_IMPORT_BY_NAME结构,用来保存名字信息。
-
IMAGE_IMPORT_BY_NAME
typedef struct _IMAGE_IMPORT_BY_NAME{ WORD Hint; CHAR Name[1]; }IMAGE_IMPORT_BY_NAME,*PIMAGE_IMPORT_BY_NAME;
前两个字节:Hint,为函数在地址表的索引(没多大用)
第三个字节:Name[1],为函数名字的开始。
-
OriginalFirstThunk:指向INT表(IMAGE_THUNK_DATA结构)
IMAGE_THUNK_DATA32结构为4字节大小,如果这个值的最高位(32位)为1,那么剩下的31位为所需函数的导出序号(这个函数在dll中是以序号导出的
如果为0,值是IMAGE_IMPORT_BY_NAME结构的RVA
-
Name:DLL文件的名字RVA
-
FirstThunk:指向IAT表(IMAGE_THUNK_DATA结构)
-
一般情况下,在程序加载前IAT表的值与INT表一样(即都为IMAGE_THUNK_DATA结构)。在加载后,IAT表的值为函数的地址。
加载时,通过INT表找到第一个函数的名字,之后通过GetProcAddress得到函数的地址,写入到IAT表同样的索引的位置。然后找第二个函数的地址,遍历完INT表,IAT表的地址也就写入完成了。
-
另一种情况,IAT表在加载前就直接写成了绝对地址。所需DLL的ImageBase设置成不同的值,这个表即课在程序加载前直接写入。
通过_IAMGE_IMPORT_DESCRIPTOR的TimeDtaeStamp(时间戳)可以判断导入表是否被绑定。
时间戳为0时,未绑定
为-1时,被绑定。
-
-
OriginalFirstThunk,指向IMAGE_THUNK_DATA数组,包含导入信息,在这个数组中只有Ordinal和AddressOfData是有用的,所以可以通过OriginalFirstThunk查找到函数的地址。
FirstThunk,在PE文件加载以前,它所指向的数组与OriginalFirstThunk中的数组不是同一个,但是内容是相同的,都包含了导入信息,在加载之后,FirstThunk中的Function开始生效,它指向实际的函数地址,因为FirstThunk实际上指向IAT中的一个位置,IAT就充当了IMAGE_THUNK_DATA数组,加载完成后,这些IAT就变成了实际的函数地址,即Function的意义。
-
程序加载前与程序加载后,输入表所指内容的变化。
- 程序加载前
- 程序加载后
- 程序加载前
-
输入地址表(IAT)
程序加载前,两个并行的指针(OriginalFirstThunk和FirstThunk)同时指向IMAGE_IMPORT_BY_NAME结构。
第一个数组(由OriginalFirstThunk指向)是单独的一项,而且不能被改写,称为INT。
第二个数组(由FirstThunk指向)事实上是由PE装载器重写的。
PE装载器的核心操作:
PE装载器首先搜索OriginalFirstThunk,找到之后加载程序迭代搜索数组中的每个指针,找到每个IMAGE_IMPORT_BY_NAME结构所指向的输入函数的地址,然后加载器用函数真正入口地址来替代由FirstThunk数组中的一个入口,因此我们称为输入地址表(IAT)。
-
脱壳中,IAT修复原理
导入地址表(IAT):Import Address Table。由于导入函数就是被程序调用但其执行代码又不在程序中的函数,这些函数的代码位于一个或者多个DLL中,当PE文件被装入内存的时候,Windows装载器才将DLL装入,并将调用导入函数的指令和函数实际所处的地址联系起来(动态链接),这操作就需要导入表。
修复IAT原理:程序的IAT是连续排列的,只需要找到IAT的起始位置和末位置,就可以确定IAT的地址和大小。在压缩壳中,只要找到一个调用系统的API的call地址,然后在数据窗口中查找,确定IAT起始和结束地址。然后在OD中手动修复。
为什么要手动修复:
IAT主要用于DLL文件的重定位,IAT的引入相较于16位dos程序不再需要包含库文件,而是通过表的形式进行映射。如果IAT不准确,则程序无法执行相关库的函数。
压缩壳对节区进行了压缩,把IAT修改为壳自身的IAT,在解压缩的最后一步会还原IAT使程序可以正常运行,所以脱壳后需要进行IAT的修复。
相较于加壳程序的IAT,运行加壳程序后真正的IAT要多得多。如果只进行脱壳,而不进行IAT的修复,程序的IAT是被损坏的。
脱壳程序无法在WIN7以上平台运行
windows win7系统开始使用ASLR技术防止溢出攻击。使得每次加载程序都加载到一个随机的虚拟地址。ASLR依赖于重定位表进行定位,对于EXE程序来说,重定位是可选的,通过关闭ASLR即可解决。
输出表(导出表)
-
导出表的定义
typedef struct _IMAGE_EXPORT_DIRECTORY{ DWORD Characteristics;//现在没有用到,一般为0 DWORD TimeDateStamp;//导出表生成的时间戳 WORD MajorVersion; // 版本。实际没用。 WORD MinorVersion; // 和上面一样。都是0 DWORD Name; // 模块的真实名称 DWORD Base; // 序号的基数 DWORD NumberOfFunctions;// 所有导出函数的数量 DWORD NumberOfNames;// 按名字导出函数的数量 DWORD AddressOfFunctions; // 这三个看下面 DWORD AddressOfNames; DWORD AddressOfNameOrdinals; }
-
扩展名为**.exe的PE文件中一般不存在**导出表,而大部分的.dll文件都存在导出表。
-
导出表中的主要成分是一个表格,内含函数名称、输出序数等。序数是指定DLL中某个函数的16位数字,在所指向的DLL文件中是独一无二的。
-
数据目录表(IMAGE_DATA_DIRECTORY )的第一个成员指向导出表,是一个IMAGE_EXPORT_DIRECTORY(简称IED)结构
-
IED结构中有意义的字段:
-
Name:模块的真实名称。改不掉。
-
Base:序号的基数,按序号,导出函数的序号值从Base开始递增
-
NumberOfFunctions:导出函数的总数
-
NumberOfNames:以名称方式导出的函数的总数
-
AddressOfFunctions:指向输出函数地址的RVA,指向一个DWORD数组,数组中的每一项是一个导出函数的RVA,顺序与导出序号相同。
-
AddressOfNames:指向输出函数名字的RVA,指向函数名的字符串地址表----一个双字(DWORD)数组,数组中的每一项指向一个函数名称字符串的RVA。
-
AddressOfNameOrdinals:指向输出函数序号的RVA。指向一个word类型的数组(不是双字)。数组项目与文件名地址表(AddressOfNames)中的项目一一对应,表示该名字的函数在AddressOfFunctions中的序号。
-
对(7.)举例:
加入函数名称的字符串地址表(AddressOfNames)的第n项指向一个字符串“myfunction”,可以去查找AddressOfNameOrdinals指向的数组的第n项,假如第n项中存放的值为x,那么,AddressOfFunctions字段描述的地址表中的第 x 项函数入口地址,对应的函数名称就是“myfunction”。
-
看图加强理解:
AddressOfNames 指向一个数组,数组里保存着一组 RVA,每个RVA指向一个字符串,这个字符串即导出的函数名,与这个函数名对应的是AddressOfNameOrdinals中的对应项。获取导出函数地址时,先在AddressOfNames中找到对应的名字。比如Func2,他在AddressOfNames中是第二项,然后从AddressOfNameOrdinals中取出第二项的值,这里是2,表示函数入口保存在AddressOfFunctions这个数组中下标为2的项里,即第三项,取出其中的值,加上模块基地址便是导出函数的地址。如果函数是以序号导出的,那么查找的时候直接用序号减去Base,得到的值就是函数在AddressOfFunctions中的下标。
为啥有三个RVA?
导出表是用来描述模块(dll)中的导出函数的结构,如果一个模块导出了函数,那么这个函数会被记录在导出表中,这样通过GetProcAddress函数就能动态获取到函数的地址。
-
函数导出的方式:
-
按序号导出
-
按名字导出
-
从序号查找函数入口地址
-
-
Windows装载器的工作步骤:
- 定位到PE文件头
- 从PE文件头中的IMAGE_OPTIONAL_HEADER32结构中取出数据目录表,并得到导出表的RVA
- 从导出表的Base字段得到起始序号
- 将需要查找的导出序号减去起始序号,得到函数在入口地址表中的索引**(base+ 索引 = 导出序号**
- 检测索引值是否大于导出表的NumberOfFunctions(导出函数的数量)字段的值,如果大于,说明输入的序号是无效的
- 用索引值在AddressOfFunctions字段指向的导出函数入口地址表中取出相应的项目,这就是函数入口地址的RVA的值。当函数被装入内存的时候,这个RVA加上模块实际装入的基地址,就得到了函数真正的入口地址。
在GetProcAddress()里,如果以序号索引,要减去base才是数组里面的索引值。
- 从函数名称查找入口地址
Windows装载器的工作步骤:
- 得到导出表的地址
- 从导出表的NumberOfNames字段得到已命名函数的总数,循环这些个数字次。
- 从AddressOfNames字段指向得到的函数名称地址表的第一项开始,在循环中将每一项定义的函数名与要查找的函数名相比较。若没有任何函数名是符合的,表示文件中没有指定名称的函数。
- 如果有符合的,就记下这个函数名在字符串地址表中的索引值,然后在AddressOfNamesOrdinals指向的数组中以同样的索引值取出数组项的值,设为x。
- 最后以x作为索引值,在AddressOfFunctions字段指向的函数入口地址表中获取的RVA就是函数的入口地址
一般情况下,病毒通过函数名称查找入口地址,因为病毒程序是作为一段额外的代码被附加到可执行文件中的,如果病毒代码中用到某些API的话,这些API的地址不可能在宿主文件的导出表中为病毒代码准备好。因此只能通过在内存中动态查找的方法来实现获取API的地址。
重定位表
-
重定位表在病毒研究方面起重要作用。
-
基址重定位
重定位就是程序理论上要占据这个地址,但是由于某种原因,这个地址现在不能让这个程序占,必须转移到别的地址,就需要基址重定位。
-
为什么需要基址重定位
-
补充知识:动态链接库它自己是没有占据任何私有空间的,都是寄生在应用程序的私有空间里面。
- 举例:
test.exe可执行程序需要三个动态链接库dll(a.dll,b.dll,c.dll),假设:test.exe的ImageBase为400000H,而三个dll的基址ImageBase均为1000000H。
那么OS的加载程序在将test.exe加载进内存时,直接复制其程序到400000H开始的虚拟内存,接着再加载三个dll:假设按abc的顺序加载,如果test.exe的ImageBase+SizeOdImage+1000H不大于1000000H,则a.dll直接复制到1000000H开始的内存中;
b.dll加载时,虽然基址也为1000000H,但是由于已经被a.dll占用,则b.dll需要重新分配基址。比如加载程序经过计算将其分配到1200000H的地址,c.dll同样经过计算将其加载到1500000H的地址。
但是b.dll和c.dll有些地址时根据ImageBase固定的,被写死了的,而且是绝对地址不是相对偏移地址。
比如b.dll中存在一个call 0x01034560,这是一个绝对地址,相对于ImageBase的地址为0x34560H;但是此时的内存中b.dll存在的地址时1200000H开始的内存,加载器分配的ImageBase和b.dll中原来默认的ImageBase(1000000H)相差了200000H,因此该call值也应该加上这个差值,被修正为0x1234560H,也就是相对偏移地址没有改变。否则call的地址不修正会导致call指令跳转的地址不是实际要跳转的地址,获取不到正确的函数指令,程序则不能正常运行。
由于一个dll中的需要修正的地址不止一两个,可能有很多,所以用一张表记录那些“写死”的地址,将来加载进内存时,可能需要一一修正,这张表称为重定位表。
图:(方便理解
(来源于网路)
-
-
一般每个PE文件都有一个重定位表。当加载器加载程序时,如果加载器为某PE(.exe,.dll)分配的基址与其自身默认记录的ImageBase不相同,那么该程序文件加载完毕后需要修正重定位表中的所有需要修正的地址。
如果相同则不需要修正,重定位表对于该dll也是没有用的。
比如上面例子中的a.dll (由于一般情况.exe运行时被第一个加载,所以exe文件一般没有重定位表,但是不代表所有exe都没有重定位表)。 同理如果先加载b.dll后加载a.dll、c.dll,那么b.dll的重定位表就不起作用了。
但凡涉及到直接寻址的指令都需要进行重定位处理。
-
直接寻址----只要在机器码中看到有地址的,就叫直接寻址。
-
间接寻址----地址被间接的保存起来,例如存放在寄存器中,然后通过方可寄存器来获取地址。
-
补充知识。
“(机器码)10001038 E8CFFFFFFF (汇编指令)call 1000100C”
为什么后边显示的是call+地址,而机器码却不包含地址信息?
有一种**”地址+偏移“**的形式。CFFFFFFFh事实上是一个偏移地址,小端序(little-edition),转换过来就是FFFFFFCFh,也就是等于-31h。1000103Dh-31h == 1000100Ch。
-
系统对一条指令进行重定位需要哪些信息
重定位的算法可以总结为:将直接寻址指令中的双字地址加上模块的实际装入地址与模块建议装入地址之差
重定位需要三个因素:
- 需要修正的地址
- 建议装入的地址
- 实际装入的地址
建议装入的地址在PE文件头中已经定义了
实际装入的地址在没有被装载器装入前我们不能晓得,
所以,PE文件的重定位表(Baese Relocation Table)中保存 的是文件中所有需要进行重定位修正的代码的地址。
-
Windows采用分组的方式,按照重定位项所在的页面分组,每组保存一个页面起始地址的RVA,页内的每项重定位项使用一个WORD保存重定位项在页内的偏移,缩小了重定位表的大小。
-
重定位表的定义:
typedef struct _IMAGE_BASE_RELOCATION{ DWORD VirtualAddress; DWORD SizeOfBlock; // DWORD TypeOffset[1]; }IMAGE_BASE_RELOCATION;
-
VirtualAddress:页的起始地址RVA
-
SizeOfBlock:表示该分组保存了几项重定位项。
一个重定位表由多个大小SizeOfBlock的Block组成,(不同块的SizeOfBlock大小不一)。
-
TypeOffset:一个数组,数组每项大小为两个字节(16位)由高4位和低12位组成。
高4位代表重定位类型,低12位是重定位地址(下面解释
它与VirtalAddress相加即是指向PE映像中需要修改的那个代码的地址。事实上,Windows只用了一种类型IMAGE_REL_BASED_HIGHLOW ,数值是3。
-
重定位表中的Block块记录了 1000H(4KB)大小 的内存中需要重定位信息的地址(一页大小),这些地址以VirtualAddreess为该页的基址,偏移地址占两个字节(1000H最多需要12bit即可:0~FFFH)。所以两个字节的低12位为偏移地址。
而高4为就是一个标记,此标记为0011(3)时低12位才有效,否则该两个字节可能是为了对齐而产生的。而且为对齐而产生的字节其值全为0.
图:
-
-
-
是的网上拿的(还有水印emoji
-
图的最下面全是0,表示重定位表结束。
-
-
总结:哪些项目需要被重定位呢?
- 代码中使用全局变量的指令。全局变量一定是模块内的地址,而且使用全局变量的语句在编译后会产生一条引用全局变量基地址的指令。
- 将模块函数指针赋值给变量或作为参数传递。因为赋值或传递参数是会产生mov和push指令,这些指令需要直接地址。
- c++中的构造函数和析构函数赋值虚函数表指针。虚函数表中的每一项本身就是重定位项。
延迟导入表(Delay Import)
-
这种导入机制导入其他DLL的时机比较“迟”,因为有些导入函数可能使用的频率比较低,或者在某些特定的场合才会用到,而有些函数可能要在程序运行一段时间吼才会用到,这些函数可以等到他实际使用的时候再去加载对应的DLL,而没必要在程序一装载就初始化好。
-
延迟导入表(IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT)
IMAGE_DATA_DIRECTORY.VirtualAddress 指向延迟导入表的起始地址。
延迟导入表的每一项都是一个ImgDelayDescr结构体,和导入表一样,每一项都代表一个导入的DLL
定义:
[cpp] view plaincopy typedef struct ImgDelayDescr { DWORD grAttrs; //区分版本 RVA rvaDLLName; //指向导入DLL名字的RVA RVA rvaHmod; //指向导入DLL的模块基地址的RVA。在DLL被真正导入前是NULL,导入后是实际的基地址。 RVA rvaIAT; // 表示导入函数表,实际上是指向IAT的RVA。 DLL加载前,IAT里存放的是一小段代码的地址,加载后才是真正的导入函数地址。 RVA rvaINT; //指向导入函数的名字表的RVA RVA rvaBoundIAT; RVA rvaUnloadIAT; //延迟导入函数卸载表。 DWORD dwTimeStamp; //时间戳 } ImgDelayDescr, * PImgDelayDescr; typedef const ImgDelayDescr * PCImgDelayDescr;
-
延迟导入表的处理。再延迟导入函数指向的IAT里,默认保存的是一段代码的地址(rvaIAT),当程序第一次调用到这个延迟导入函数时,流程会走到那段代码,这段代码的作用是啥嘞?
延迟导入函数的栗子:
[cpp] view plaincopy .text:75C7A363 __imp_load__InternetConnectA@32: ; InternetConnectA(x,x,x,x,x,x,x,x) .text:75C7A363 mov eax, offset __imp__InternetConnectA@32 .text:75C7A368 jmp __tailMerge_WININET
两行汇编,第一行把导入函数IAT项的地址放在eax中,然后用一个jmp跳转,跳转的地址:
[cpp] view plaincopy __tailMerge_WININET proc near .text:75C6BEF0 push ecx .text:75C6BEF1 push edx .text:75C6BEF2 push eax .text:75C6BEF3 push offset __DELAY_IMPORT_DESCRIPTOR_WININET .text:75C6BEF8 call __delayLoadHelper .text:75C6BEFD pop edx .text:75C6BEFE pop ecx .text:75C6BEFF jmp eax .text:75C6BEFF __tailMerge_WININET endp
其中,push了一个
__DELAY_IMPORT_DESCRIPTOR_WININET
,也就是ImgDelayDescr结构,他的DLL名字时wininet.dll。之后,**CALL了一个__delayLoadHelper
,**在这个函数里,执行了,加载DLL,查找导出函数,填充导入表等一系列操作,函数结束时IAT中已经是真正的导入函数的地址,这个函数同时返回了导入函数的地址,这个函数同时返回了导入函数的地址,所以之后的eax里保存的就是函数地址,最后的jmp eax
就跳转到了真实的导入函数中。-
__delayLoadHelper:延迟加载DLL。它的参数中只有IAT项的偏移和整个模块的延迟导入描述 DELAY_IMPORT_DESCRIPTOR_WININET ,但是参数中并没有要导入函数的名字。
DELAY_IMPORT_DESCRIPTOR_WININET 中含有名字表,但是这个表里存的是所有要从该模块导入的函数名字,不是“当前”这个被调用函数的函数名。所以上面的“名字”不是 名字表中的。
Windows是如何得到名字的?
MS使用了一个巧妙的方法: __DELAY_IMPORT_DESCRIPTOR_WININET 中有一项是rvaIAT,这个实际上就是指向了IAT,而且是该模块第一个导入函数的IAT的偏移。现在有两个偏移:即将导入的函数IAT项的偏移(RVA1)和要导入模块第一个函数IAT项的偏移(RVA0),(RVA1-RVA0)/4 = 导入函数IAT项再rvaIAT中的下表。rvaINT中的名字顺序与rvaIAT中的顺序是相同的,所以下标也相同,这样就能获取到导入函数的名字了。
有了函数名和模块名,用GetProcAddress就能获取到导入函数的地址了。
图:
-
注意:
- 延迟导入的加载只发生在函数第一次被调用的时候,之后IAT就填充为正确函数地址,不会再走 __delayLoadHelper了。
- 延迟导入一次只会导入一个函数,而不是一次导入整个模块的所有函数。
-
资源表
-
Windows将程序的各种界面定义为资源。
包括加速键(Accelerator)、位图(Bitmap)、光标(Cursor)、对话框(Dialog Box)、图标(Icon)、菜单(Menu)、串表(String Table)、工具栏(Toolbar)和版本信息(Version Information)等。
这些内容或界面元素,都以二进制的形式保存在PE文件中。
这些数据保存的位置,就是PE文件的资源段(.rsrc)
这些数据的组织格式,称为资源表
-
资源表的定位
位置:NT头–>扩展头–>资源目录表–>第三个元素–>相对虚拟地址(RVA)
-
资源结构
-
几乎所有的PE文件中都包含着资源,与导入表和导出表相比,资源的组织方式要复杂很多
-
资源表的结构比较复杂,一共有三层,三层从上到下是树状扩展的
三层:资源类型 -> 资源ID -> 资源代码页
图:
-
-
资源目录结构
数据目录表中的IMAGE_DIRECTORY_ENTRY_RESOURCE条目(第三项)包含资源的RVA和大小。资源目录结构中的每一个节点都是由IMAGE_RESOURCE_DIRECTORY结构和紧跟其后的几个IMAGE_RESOURCE_DIRECTORY_ENTRY结构组成。(有点类似套娃)
图:
- IMAGE_RESOURCE_DIRECTORY 与 IMAGE_RESOURCE_DIRECTORY_ENTRY 结构体定义
typedef struct _IMAGE_RESOURCE_DIRECTORY {// 长度为16字节,共六个字段 DWORD Characteristics; //资源属性,0 DWORD TimeDateStamp; //时间戳,0 WORD MajorVersion; //主版本号,0 WORD MinorVersion; //次版本号,0 WORD NumberOfNamedEntries; //以名称(字符串)命名的入口数量 WORD NumberOfIdEntries; //以ID(整型数字)命名的入口数量 } IMAGE_RESOURCE_DIRECTORY, *PIMAGE_RESOURCE_DIRECTORY;
注意: NumberOfNamedEntries和NumberOfIdEntries,说明了本目录中目录项目的数量。两者加起来是本目录中目录项的总和。也就是后面跟着的IMAGE_RESOURCE_DIRECTORY_ENTRY的数目。
typedef struct _IMAGE_RESOURCE_DIRECTORY_ENTRY { union { struct { DWORD NameOffset:31; DWORD NameIsString:1; }; DWORD Name; WORD Id; }DUMMYUNIONNAME; // 资源名称 union { DWORD OffsetToData; struct { DWORD OffsetToDirectory:31; DWORD DataIsDirectory:1; }DUMMYSTRUCTNAME2; }DUMMYSTRUCTNAME2;// 资源位置 } IMAGE_RESOURCE_DIRECTORY_ENTRY, *PIMAGE_RESOURCE_DIRECTORY_ENTRY;
-
IMAGE_RESOURCE_DIRECTORY_ENTRY一共8字节,里面包含两个联合体,每个联合体4字节
第一个联合体:资源的名称
第二个联合体:资源的位置 -
**第一个联合体:**如果最高位是0,也就是NameIsString为0,此时4字节代表资源类型,也就是ID起作用。
值 资源类型 值 资源类型 0x01 鼠标指针 0x08 字体 0x02 位图 0x09 快捷键 0x03 图标 0x0A 非格式化资源 0x04 菜单 0x0B 消息列表 0x05 对话框 0x0C 鼠标指针组 0x06 字符串列表 0x0E 图标组 0x07 字体目录 0x10 版本信息 如果NameIsString为1,NameOffset指向保存字符串的结构体。
typedef struct _IMAGE_RESOURCE_DIR_STRING_U { WORD Length; WCHAR NameString[ 1 ]; }IMAGE_RESOURCE_DIR_STRING_U,*PIMAGE_RESOURCE_DIR_STRING_U;
第二个元素NameString为字符串起始,长度为Length,这个串不是以0结尾
-
**第二个联合体:**如果最高位为1,即DataIsDirectory为1,OffsetToDirectory指向的地方是一个目录。
通常,第一层和第二层,这个值都是1
若DataIsDirectory为0, OffsetToDirectory指向的地方是一个数据
通常,第三层,这个值为0
1.第一层
- 第一层起始于一个 IMAGE_RESOURCE_DIRECTORY 头,后面紧接着是 IMAGE_RESOURCE_DIRECTORY_ENTRY 数组。如上面提到的,数组个数 = NumberOfNamedEntries + NumberOfIdEntries。
- IMAGE_RESOURCE_DIRECTORY_ENTRY使用的是Name与OffsetToDirectory,分别代表资源类型和第二层的数据偏移地址。
- OffsetToDirectory 数据偏移地址是相对整个资源结构来说的,也就是说首个第一层的起始偏移地址加上 OffsetToDirectory 就是第二层的偏移地址。
2.第二层
- 与第一层一样,起始与。。头,紧接着。。数组,数组个数= 。。。
- 使用的 是NameIsString、NameOffset、Id与OffsetToDirectory。其中OffsetToDirectory与第一层一样,代表了第三层的数据偏移地址,同样是相对整个资源结构来说的。
- 如果NameIsString为1,NameOffset指向保存字符串的结构体,是名称相对整个资源结构的偏移地址。
3.第三层
-
与前两层一样,起始于。。头,紧接着。。数组,但是数组个数 = 1.
-
使用的是Name与OffsetToData,分别代表了资源语言类型与资源数据相对地址。Name是指语言内码,比如936代表简体中文。
-
OffsetToData是相对整个资源结构的偏移地址,指向一个IMAGE_RESOURCE_DATA_ENTRY结构体,该结构体定义如下:
typedef struct _IMAGE_RESOURCE_DATA_ENTRY {
DWORD OffsetToData; //资源数据的RVA
DWORD Size; //资源数据的长度
DWORD CodePage; //代码页, 一般为0
DWORD Reserved; //保留字段
} IMAGE_RESOURCE_DATA_ENTRY, *PIMAGE_RESOURCE_DATA_ENTRY;
听小甲鱼的网课记的笔记,也有大部分借鉴于其他师傅博客,很多。
本文中的图全部来源于网络。
主要借鉴的博客链接贴一下:
PE文件结构
延迟导入表
等等。