PE文件格式分析
这是PE文件框架结构图从下往上,本文的顺序就是按照这个结构图从前往后一个一个分析的:
PE文件内容被分割为不同的区区块(Section),每一区区块中可能包含代码或数据。各区区块按页边界对齐,区区块没有大小限制,是一个连续结构。每一个区区块在内存中都有它自己的一套属性,比如:这个区区块是否包含代码、是否只读或可读/写等。每一个区区块都有不同的名字,这个名字用来表示区区块的功能。例如:
.text 是在编译或汇编结束时产生的一种块,它的内容全是指令代码;
.rdata 是运行期只读数据;
.data 是初始化的数据块;
.idata 包含其它外来DLL的函数及数据信息,即导入表;
.rsrc 包含模块的全部资源,如图标、菜单、位图、对话框等。
Note:使用区区块名只是方便人们使用,而对操作系统来说是无关紧要的,因此可将上面区区块名任意更改而不会影响PE文件执行。
一、MS-DOS头部
每个PE文件都是以一个 DOS程序开始的,这是为了和旧版本MS-DOS及Windows操作系统的兼容。其中MS-DOS头部又分为DOS MZ头和DOS Stub。
DOS MZ头
DOS MZ头占据了PE文件的前64个字节,在头文件WINNT.H中描述它内容的结构如下:
//WINNT.H
typedef struct _IMAGE_DOS_HEADER { // DOS的.EXE头部
USHORT e_magic; // 魔术数字
USHORT e_cblp; // 文件最后页的字节数
USHORT e_cp; // 文件页数
USHORT e_crlc; // 重定义元素个数
USHORT e_cparhdr; // 头部尺寸,以区块落为单位
USHORT e_minalloc; // 所需的最小附加区块
USHORT e_maxalloc; // 所需的最大附加区块
USHORT e_ss; // 初始的SS值(相对偏移量)
USHORT e_sp; // 初始的SP值
USHORT e_csum; // 校验和
USHORT e_ip; // 初始的IP值
USHORT e_cs; // 初始的CS值(相对偏移量)
USHORT e_lfarlc; // 重分配表文件地址
USHORT e_ovno; // 覆盖号
USHORT e_res[4]; // 保留字
USHORT e_oemid; // OEM标识符(相对e_oeminfo)
USHORT e_oeminfo; // OEM信息
USHORT e_res2[10]; // 保留字
LONG e_lfanew; // 新exe头部的文件地址
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;
第一个域e_magic,被称为魔术数字,它被用于表示一个MS-DOS兼容的文件类型。所有MS-DOS兼容的可执行文件都将这个值设为0x5A4D,表示ASCII字符MZ。MS-DOS头部之所以有的时候被称为MZ头部,就是这个缘故。还有许多其它的域对于MS-DOS操作系统来说都有用,但是对于Windows NT来说,这个结构中只有一个有用的域——最后一个域e_lfnew,一个4字节的文件偏移量,PE文件头部就是由它定位的。对于Windows NT的PE文件来说,PE文件头部是紧跟在MS-DOS头部和实模式程序残余之后的。
看个实例:
因为Intel CPU 属于小端序,字符储存时低位在前,高位在后,所以,将次序恢复后,上图中e_magic的值就是0x5A4D,e_lfnew的值就是0x000000B0。
DOS Stub
DOS Strub 是存放一些DOS指令,当你尝试在Windows 3.1以下或MS-DOS 2.0以上的系统下装载一个文件的时候,操作系统能够读取这个文件并明白它是和当前系统不相兼容的。换句话说,当你在MS-DOS 6.0下运行一个Windows NT可执行文件时,你会得到这样一条消息:“This program cannot be run in DOS mode.”
看个实例:
DOS Strub 在小甲鱼的解密教程中也叫做实模式残余程序,实模式残余程序是一个在装载时能够被MS-DOS运行的实际程序。对于一个MS-DOS的可执行映像文件,应用程序就是从这里执行的。对于Windows、OS/2、Windows NT这些操作系统来说,MS-DOS残余程序就代替了主程序的位置被放在这里。这种残余程序通常什么也不做,而只是输出一行文本,例如:“This program requires Microsoft Windows v3.1 or greater.”当然,用户可以在此放入任何的残余程序,这就意味着你可能经常看到像这样的东西:“You can’‘t run a Windows NT application on OS/2, it’'s simply not possible.”
二、PE文件头
在DOS Strub 后面存放的就是PE文件头,PE文件头是PE相关结构NT映像头(IMAGE_NT_HEADERS)的简称。在运行PE文件时,PE装载器从上面DOS MZ的结构体IMAGE_DOS_HEADER中取出e_lfanew字区块(PE文件的起始偏移量),用它加上基地址,就可以得到PE文件头的地址了:
PNTHeader = ImageBase + e_lfanew
看个实例:
IMAGE_NT_HEADERS(NT映像头)
PE文件头是IMAGE_NT_HEADERS结构,在WINNT.H中定义如下:
typedef struct _IMAGE_NT_HEADERS {
DWORD Signature;
IMAGE_FILE_HEADER FileHeader;
IMAGE_OPTIONAL_HEADER32 OptionalHeader;
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;
Signature
其中Signature字区块被设置为Ox00004550, ASCII码字符是“PE00”,在WINNT.H中定义如下:
#define IMAGE_DOS_SIGNATURE 0x4D5A // MZ
#define IMAGE_OS2_SIGNATURE 0x4E45 // NE
#define IMAGE_OS2_SIGNATURE_LE 0x4C45 // LE
#define IMAGE_NT_SIGNATURE 0x50450000 // PE00
看个实例:
IMAGE_FILE_HEADER
文件头格式IMAGE_FILE_HEADER结构在WINNT.H中定义如下:
//WINNT.H
//
// File header format.
//
typedef struct _IMAGE_FILE_HEADER {
USHORT Machine; //运行平台
USHORT NumberOfSections;//文件的区块数
ULONG TimeDateStamp;//文件创建日期和时间
ULONG PointerToSymbolTable;//指向符号表(用于调试)
ULONG NumberOfSymbols;//符号表中符号的个数(用于调试)
USHORT SizeOfOptionalHeader;//IMAGE_OPTIONAL_HEADER32结构的大小
USHORT Characteristics;//文件属性
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;
#define IMAGE_SIZEOF_FILE_HEADER 20
PE文件头结构中一个有用的入口是NumberOfSections域,它表示如果你要方便地提取文件信息的话,就需要了解多少个区块——更明确一点来说,有多少个区块头部和多少个区块实体。每一个区块头部和区块实体都在文件中连续地排列着,所以要决定区块头部和区块实体在哪里结束的话,区块的数目是必需的。
看个实例:
其中:
Machine的值0x1C4指的是Intel i386平台;
NumberOfSections的值0x3指的是文件有3个区块;
TimeDateStamp的值0x3DB8972C指的是文件创建的时间从1970年1月1日以来的秒数,转换成现在的时间是2002-10-25 08:58:20;
PointerToSymbolTable的值0指的是COFF符号表的文件偏移位置,0表示没有符号表;
NumberOfSymbols的值0指的是COFF符号表中符号的个数,可以用来确定符号表结束的位置,0表示没有符号表;
SizeOfOptionalHeader的值0xE0指的是32位PE文件的IMAGE_FILE_HEADER结构的大小,64位的PE32+文件的IMAGE_FILE_HEADER结构的大小是0x00F0,这两个只是通常出现的最小值,可能会出现较大的值;
Characteristics的值0x010F=0x1+0x2+0x4+0x8+0x0100指的是文件中不存在重定位信息;文件可执行;行号信息被移去;符号信息被移去;目标平台为32位机器。
IMAGE_OPTIONAL_HEADER32(可选映像头)
之后是PE可选头部IMAGE_OPTIONAL_HEADER32,虽然它的名字是“可选头部”,但是请确信:这个头部并非“可选”,而是“必需”的。可选头部包含了很多关于可执行映像的重要信息,例如初始的堆栈大小、程序入口点的位置、首选基地址、操作系统版本、区块对齐的信息等等。IMAGE_OPTIONAL_HEADER32结构在WINNT.H中定义如下:
//
// Optional header format.
//
typedef struct _IMAGE_OPTIONAL_HEADER {
//
// Standard fields.
//
WORD Magic;//标志字
BYTE MajorLinkerVersion;//链接器主版本号
BYTE MinorLinkerVersion;//链接器次版本号
DWORD SizeOfCode;//所有含有代码的区块的大小
DWORD SizeOfInitializedData;//所有初始化数据区块的大小
DWORD SizeOfUninitializedData;//所有未初始化数据区块的大小
DWORD AddressOfEntryPoint;//程序执行人口 RVA
DWORD BaseOfCode;//代码区块起始RVA
DWORD BaseOfData;//数据区块起始RVA
//
// NT additional fields.
//
DWORD ImageBase;//程序默认载人基地址
DWORD SectionAlignment;//内存中区块的对齐值
DWORD FileAlignment;//文件中区块的对齐值
WORD MajorOperatingSystemVersion;//操作系统主版本号
WORD MinorOperatingSystemVersion;//操作系统次版本号
WORD MajorImageVersion;//用户自定义主版本号
WORD MinorImageVersion;//用户自定义次版本号
WORD MajorSubsystemVersion;//所需子系统主版本号
WORD MinorSubsystemVersion;//所需子系统次版本号
DWORD Win32VersionValue;//保留,通常被设置为0
DWORD SizeOfImage;//映像载入内存后的总尺寸
DWORD SizeOfHeaders;//MS-DOS头部、PE文件头、区块表总大小
DWORD CheckSum;//映像校验和
WORD Subsystem;//文件子系统
WORD DllCharacteristics;//显示 DLL特性的旗标
DWORD SizeOfStackReserve;初始化时栈的大小
DWORD SizeOfStackCommit;//初始化时实际提交栈的大小
DWORD SizeOfHeapReserve;//初始化时保留堆的大小
DWORD SizeOfHeapCommit;//初始化时实际保留堆的大小
DWORD LoaderFlags;//与调试相关,默认值为0
DWORD NumberOfRvaAndSizes;//数据目录表的项数
IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];//数据目录表
} IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32;
上面的IMAGE_OPTIONAL_HEADER32结构一共31项,
看个实例
下面蓝色部分就是IMAGE_OPTIONAL_HEADER32结构前30项的值,第31项数据目录表非常重要是接下来16项的值,我们后面会仔细分析:
31项太多了,我们就解释一下比较有用的几项,其他的可以需要的自己去了解。
(1)Magic
Magic:这是一个标记字,说明文件是ROM映像(0107h)还是普通可执行的映像(010Bh ),如果是 PE32+,则是020Bh。
(7)AddressOfEntryPoint
AddressOfEntryPoint:程序执行人口RVA。这个位置也是导入地址表(IAT)的末尾。
(8)BaseOfCode
BaseOfCode:代码区块的起始RVA。
(9)BaseOfData
BaseOfData:数据区块的起始RVA。
(10)ImageBase
ImageBase:文件在内存中的首选载人地址。
看个实例,用LoadPE之类的PE编辑工具打开:
(31)IMAGE_DATA_DIRECTORY(数据目录表)
接着的00开始是最后一项DataDirectory[16]的值,DataDirectory[16]:数据目录表,由16个相同的IMAGE_DATA_DIRECTORY结构组成,指向输出表、输入表、资源块等数据。IMAGE_DATA_DIRECTORY 的结构在WINNT.H中定义如下:
//
// Directory format.
//
typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress;//数据块的起始RVA
DWORD Size;//数据块的长度
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
PE文件在定位输出表、输人表和资源等重要数据时,就是从 DataDirectory[16]中找到对应的IMAGE_DATA_DIRECTORY包含了数据的起始RVA和长度。16个数据目录表的成员如下图所示:
序号 | 成员 | 结构 | 偏移量(PE/PE32+) |
---|---|---|---|
0 | Export Table | IMAGE_DIRECTORY_ ENTRY_EXPORT | 78h/88h |
1 | Import Table | IMAGE_DIRECTORY_ENTRY_IMPORT | 80h / 90h |
2 | Resources Table | IMAGE_DIRECTORY ENTRY_RESOURCE | 88k / 98h |
3 | Exception Table | IMACE_DIRECTORY_ENTRY_EXCEPTION | 90k / A0h |
4 | Security 'Table | IMAGE_DIRECTORY_ENTRY_SECURITY | 98h/ A8h |
5 | Base relocation Table | IMAGE_DIRECTORY_ENTRY_BASERELOC | A0/BOh |
6 | Debug | IMAGE_DIRECTORY_ENTRY_DEBUG | A8h / B8h |
7 | Copyright | IMAGE_DIRECTORY_ ENTRY_COPYRIGHT | BOh / COh |
8 | Global Ptr | IMAGE_DIRECTORY_ENTRY_GLOBALPTR | B8h / C8h |
9 | Threadl local storage ( TLS) | IMAGE_DIRECTORY_ENTRY_TLS | cOh / DOh |
10 | Load configuration | IMACE_ DIRECTORY_ENTRYLOAD_CONFIC | C8h / D8h |
11 | Bound Import | IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT | DOh /EOh |
12 | Import Address Tahle(IAT) | IMAGE_DIRECTORY_ENTRY_IAT | D8h / E8h |
13 | Delay Import | IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT | ECh / FOh |
14 | COM descriptor | IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR | E8h /F8h |
15 | 保留,必须为0 | FOh/ 100h |
看个实例:
上面的数据目录表就位于0x128~0x1A7,每个成员占8个字节,起始地址RVA占4个字节,长度占4个字节。例如:上面的第一个成员Export Table(输出表)0x128~0x12F前4个字节是0,后4个字节也是0,说明不存在输出表;第二个成员Import Table(输入表)0x130~0x137前4个字节是0x00002040,表示输入表的起始地址RVA是0x2040,后4个字节是0x0000003C,表示输入表的长度是0x3C。其他的值如下所示:
三、区块表
PE文件头之后就是区块表,它是一个IMACE_SECTION_HEADER结构数组。每个区块表的长度为40个字节,区块表中包含每个块在映像中的信息,分别指向不同的区块实体。
IMACE_SECTION_HEADER(节表项)
IMACE_SECTION_HEADER结构在WINNT.H中定义如下:
//
// Section header format.
//
#define IMAGE_SIZEOF_SHORT_NAME 8
typedef struct _IMAGE_SECTION_HEADER {
BYTE Name[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;
#define IMAGE_SIZEOF_SECTION_HEADER 40//区块表的长度为40个字节
实例PE.exe的区块表含有3个区块的描述,分别是.text、.rdata和.data的节表项。
看个实例
.text区块的节表项
第一个区块.text的节表项如下:
.rdata区块的节表项
第二个区块.rdata的节表项如下:
.data区块的节表项
第三个区块.data的节表项如下:
用LoadPE查看区块表信息:
其他的区块还有:
数据区块.bss区块表示应用程序的未初始化数据,包括所有函数或源模块中声明为static的变量。
资源区块.rsrc区块包含了模块的资源信息。它起始于一个资源目录结构,具体的定义页可以在WINNT.H中查看。
导出数据区块.edata区块包含了应用程序或DLL的导出数据。在这个区块出现的时候,它会包含一个到达导出信息的导出目录。
导入数据区块.idata区块是导入数据,包括导入库和导入地址名称表。
调试信息区块.debug包含了程序的调试信息,同时PE文件格式也支持单独的调试文件(通常由.DBG扩展名标识)作为一种将调试信息集中的方法。调试区块包含了调试信息,但是调试目录却位于早先提到的.rdata区块之中。这其中每个目录都涉及了.debug区块之中的调试信息。
区块的对齐值
区块的大小是要对齐的。有两种对齐值,一种用于磁盘文件内,另一种用于内存中。PE文件头指出了这两个值,它们的大小是可以更改的,例如有的PE文件为了节省空间。
在 PE 文件头里,FileAlignment定义了磁盘区块的对齐值(默认200h)。每一个区块(包括第一个)从对齐值(200h)的倍数的偏移位置开始,在不足的地方一般以00h来填充,这就是区块的间隙。
在PE文件头里,SectionAlignment定义了内存中区块的对齐值(默认一个内存页的大小)。在x86系列CPU中,内存页是按4KB (1000h)排列的;在x64中,内存页是按8KB (2000h)排列的。当PE 文件被映射到内存中时,每个区块总是至少从一个页边界处开始。
回顾一下上面的区块表信息,.text 区块在磁盘文件中的偏移位置是400h,在内存中将是其载人地址之上的1000h字节处。.rdata区块在磁盘文件偏移的600h处,在内存中将是载入地址之上的 2000h字节处。
区块的具体信息
在区块表之后,就是每个区块的具体信息。
.text区块
.text区块也包含了早先提到过的入口点。IAT亦存在于.text区块之中的模块入口点之前。(IAT在.text区块之中的存在非常有意义,因为这个表事实上是一系列的跳转指令,并且它们的跳转目标位置是已固定的地址。)当Windows NT的可执行映像装载入进程的地址空间时,IAT就和每一个导入函数的物理地址一同确定了。要在.text区块之中查找IAT,装载器只用将模块的入口点定位,而IAT恰恰出现于入口点之前。既然每个入口拥有相同的尺寸,那么向后退查找这个表的起始位置就很容易了。
.text区块400h之前没有有效数据的部分由00填充,.text区块如下:
.rdata区块
.rdata区块600h之前没有有效数据的部分由00填充,.rdata区块表示只读的数据,比如字符串文字量、常量和调试目录信息,.rdata区块如下:
.data区块
.data区块包含所有其它变量(除了出现在栈上的自动变量),基本上,这些是应用程序或模块的全局变量。.data区块如下:
文件偏移与虚拟地址的转换
文件被映射到内存中时,MS-DOS头部、PE文件头和块表的偏移位置与大小均没有变化;而当各区块被映射到内存中后,其偏移位置就发生了变化。
例如,磁盘文件中.text块的起始地址和文件头的起始地址之间的文件偏移地址(File Offset)为400h,映射到内存中后,.text块起始地址与文件头(基地址)的偏移量就是相对虚拟地址RVA为1000h。文件偏移地址(File Offset), add2 的值就是相对虚拟地址(RVA)。假设它们的差值为△k,则文件偏移地址与虚拟地址的关系如下。
File Offset = RVA -△k
File Offset = VA - ImageBase - △k
即400h = 1000h -C00h
400h = 401000h - 400000h - C00h
注意每个区块的△k是不同的,如.text的△k为C00h,.rdata的△k为1A00h和.data的△k为2800h,所以在计算不同区块的偏移地址时要注意。
由很多PE编辑工具提供了RVA-Offset即相对虚拟地址转化为偏移地址的功能,如LoadPE的“FLC”按钮,即"File Location Calculator"(文件位置计算器),输入RVA:1112h,点击执行,就会得到其他信息:
四、输入表
可执行文件使用来自其他 DLL 的代码或数据的动作称为输入。当PE文件被载入时,Windows加载器的工作之一就是定位所有被输人的函数和数据,并让正在载入的文件可以使用这些地址。这个过程是通过PE文件的输入表(Import Table,简称“TT”,也称导人表)完成的,输人表中保存的是函数名和其驻留的DLL名等动态链接所需的信息。
在PE文件头的可选映像头中,数据目录表的第2个成员指向输入表。输入表以一个IMAGE_IMPORT_DESCRIPTOR(导入表描述符)(IID)数组开始。每个被PE文件隐式链接的 DLL 都有一个IID。在这个数组中,没有字区块指出该结构数组的项数,但它的最后一个单元是“NULL",由此可以计算出该数组的项数。例如,某个PE文件从两个 DLL文件中引人函数,因此存在两个IID结构来描述这些DLI文件,并在两个IID结构的最后由一个内容全为0的IID结构作为结束。IID的结构如下:
_IMAGE_IMPORT_DESCRIPTOR
typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics;//特征
DWORD OriginalFirstThunk; //输入名称表(INT)的RVA
};
DWORD TimeDateStamp;//
DWORD ForwarderChain;//
DWORD Name;//DLL名字的指针
DWORD FirstThunk;//输入地址表(IAT)的 RVA
} IMAGE_IMPORT_DESCRIPTOR;
typedef IMAGE_IMPORT_DESCRIPTOR UNALIGNED *PIMAGE_IMPORT_DESCRIPTOR;
关键字“union” 表示Characteristics 和 OriginalFirstThunk 任意一个元素。
OriginalFirstThunk
OriginalFirstThunk ( Characteristics):包含指向输入名称表(Import Address Table,INT)的RVA。INT是一个 IMAGE_THUNK_DATA结构的数组,数组中的每个 IMAGE_THUNK_DATA结构都指向 IMAGE_IMPORT_BY_NAME结构,数组以一个内容为0的IMAGE_THUNK_DATA 结构结束。
Name
Name: DLL名字的指针。它是一个以“00”结尾的ASCII字符的RVA地址,该字符串包含输人的DLL名,例如“KERNEL32.DLL”“USER32.DLL”。
FirstThunk
FirstThunk:包含指向输入地址表(Import Name Table,IAT)的RVA。IAT是一个IMAGE_THUNK_DATA结构的数组。
OriginalFirstThunk与FirstThunk相似,它们分别指向INT和IAT两个本质上相同的数组IMAGE_THUNK_DATA结构。如图所示为一个可执行文件正在从USER32.DLL里输入一些APl:
每个 IMAGE_THUNK_DATA元素对应于一个从可执行文件输入的函数。两个数组的结束都是由一个值为0的IMAGE_THUNK_DATA元素表示的。IMAGE_THUNK_DATA结构在WINNT.H中定义如下:
_IMAGE_THUNK_DATA
typedef struct _IMAGE_THUNK_DATA32 {
union {
PBYTE ForwarderString;//指向一个转向者字符串的RVA
PDWORD Function;//被输人的函数的内存地址
DWORD Ordinal;//被输人的AP1的序数值
PIMAGE_IMPORT_BY_NAME AddressOfData;//指向IMAGE_IMPORT_ BY_NAME
} u1;
} IMAGE_THUNK_DATA32;
IMACE_THUNK_DATA结构实际上是一个双字,该结构在不同时刻有不同的含义,当IMAGE_THUNK_DATA值的最高位为1时,表示函数以序号方式输人,这时低31位(或者一个64位可执行文件的低63位)被看成一个函数序号。当双字的最高位为0时,表示函数以字符串类型的函数名方式输入,这时双字的值是一个RVA,指向一个IMAGE_IMPORT_BY_NAME 结构。IMAGE_IMPORT_BY_NAME 结构仅有1个字大小,存储了一个输人函数的相关信息,在WINNT.H中定义如下:
_IMAGE_IMPORT_BY_NAME
typedef struct _IMAGE_IMPORT_BY_NAME {
WORD Hint;
BYTE Name[1];
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;
Hint:本函数在其所驻留DLL的输出表中的序号。该域被PE装载器用来在DLL的输出表里快速查询函数。该值不是必需的,一些链接器将它设为0。
Name:含有输人函数的函数名。函数名是一个ASCII 字符串,以“NULL”结尾。注意,这里虽然将Name的大小以字节为单位进行定义,但其实它是一个可变尺寸域,由于没有更好的表示方法,只好在上面的定义中写成“BYTE"。
输入表实例分析
下面就来分析实例PE.exe文件的输人表,我们从磁盘和内存两种不同的状态来分析不同时候输入表的变化。
磁盘文件中的输入表
在前面PE文件头的IMAGE_NT_HEADERS(NT映像头)中的IMAGE_OPTIONAL_HEADER32(可选映像头)的第31项IMAGE_DATA_DIRECTORY(数据目录表)的第2个成员指向输入表,该指针在PE文件头中的相对偏移地址是80h,也就是PE文件头的起始位置B0h+80h=130h,我们在前面的IMAGE_DATA_DIRECTORY(数据目录表)的实例图中可以看到这个地址:
在130h处可以发现4字节指针“40 20 00 00",也就是说输入表在内存中的RVA(相对偏移地址)是00002040,注意:我们需要把RVA转换成文件偏移地址才能在16进制编辑器中找到输入表,过程参考文件偏移与虚拟地址的转换:
2040h在内存中属于.rdata区块,.rdata区块在内存的RVA是2000h,在磁盘文件中的偏移量是600h,
所以△k = 2000h-600h = 1A00h
2040h在磁盘文件中的偏移地址为2040h-△k = 640h。
看个输入表实例
在16进制编辑器中查看磁盘文件的640h地址的输入表:
每5个双字就是一个IMAGE_IMPORT_DESCRIPTOR(导入表描述符)(IID),用来描述一个引入的DLL文件,5个双字都是0则表示“NULL”结束。所以上面的输入表中引入了两个IID,第三个是全0的IID表示结束,具体的值如下:
OrignalFirstThunk | TimeDateStamp | ForwardChain | Name | First Thunk |
---|---|---|---|---|
8C20 0000 | 0000 0000 | 0000 0000 | 7421 0000 | 1020 0000 |
7C20 0000 | 0000 0000 | 0000 0000 | B421 0000 | 0020 0000 |
0000 0000 | 0000 0000 | 0000 0000 | 0000 0000 | 0000 0000 |
根据Name字段的值指向的相对虚拟地址(RVA),我们可以找到这两个IID所指向的DLL文件:例如“7421 0000”字段的值是相对虚拟地址2174h,那么它对应的文件偏移地址就是2174h-1A00h=774h,在磁盘文件中774h处的DLL文件是USER32.DLL:
同样的“B421 0000”指向的是磁盘文件中7B4处的KERNEL32.DLL,两个DLL文件的具体参数如下:
DLL名称 | OrignalFirstThunk | TimeDateStamp | ForwardChain | Name | First Thunk |
---|---|---|---|---|---|
USER32.dll | 0000 208C | 0000 0000 | 0000 0000 | 0000 2174 | 0000 2010 |
KERNEL32.dll | 0000 207C | 0000 0000 | 0000 0000 | 0000 21B4 | 0000 2000 |
每个DLL文件中调用的函数就要看OrignalFirstThunk和First Thunk字段,它们都指向不同的IMAGE_THUNK_DATA32结构的数组,数组再指向引入的函数。当的OriginalFirstThunk 值为0,就要看FirstThunk 的值,它在程序运行时被初始化。
看个OrignalFirstThunk实例
例如:USER32.dll所在第一个IID的OrignalFirstThunk字段的值是208Ch,用该值减1A00h得68Ch,在偏移68Ch 处就是INT,而INT由多个IMAGE_THUNK_DATA 数组组成,最后以一串0000 0000结束。如下图所示:
上图中INT包含的各个IMAGE_THUNK_DATA数组具体的值如下:
1021 0000 | 1C21 0000 | F420 0000 | E020 0000 |
---|---|---|---|
5021 0000 | 6421 0000 | 0221 0000 | CE20 0000 |
BC20 0000 | 2E21 0000 | 4221 0000 | 0000 0000 |
在USER32.dll中引用了11个函数,除了最后一个全0,其他11个IMAGE_THUNK_DATA数组的值最高位都是0(2进制形式),所以按照IMAGE_THUNK_DATA结构的定义,最高位为0的剩下的31位存储的是指向IMAGE_IMPORT_BY_NAME结构的RVA。
例如第一个“1021 0000”就是的值RVA是2110h,减去1A00h,得到的磁盘文件偏移地址是710h,查看第一个IMAGE_IMPORT_BY_NAME的值:
根据IMAGE_IMPORT_BY_NAME结构的定义,前两个字节是Hint(函数在DLL中的序号),后面的才是函数名以00结尾,所以OrignalFirstThunk指向的11个api函数信息如下所示:
IMAGE_THUNK_DATA(RVA) | 转换成文件偏移地址(File Offset) | 函数在DLL中的序号(Hint) | Api函数名(ApiName) |
---|---|---|---|
00002110h | 710h | 019Bh | LoadleonA |
0000211Ch | 71Ch | 01DDh | PostQuitMessage |
000020F4h | 6F4h | 0128h | GetMessageA |
000020EOh | 6EOh | 0094h | DispatchMessageA |
00002150h | 750h | 072Dh | TranslateMessage |
00002164h | 764h | 028Bh | UpdlateWindow |
00002102h | 702h | 0197h | LoacICursorA |
000020CEh | 6CEh | 0083h | DefWindowProcA |
000020BCh | 6BCh | 0058h | CreateWindowExA |
0000212Eh | 72Eh | O1EFh | RegisterClassExA |
00002142h | 742h | 0265h | ShowWindow |
同样的KERNEL32.dll所在第二个IID的OrignalFirstThunk字段的值是0000 207C,转换成磁盘文件偏移地址是67C,按照上面的方法可以逐步分析KERNEL32.dll导入的3个函数。
这就是输入表中导入的两个DLL文件的OrignalFirstThunk字段的分析,接下来我们分析一下First Thunk字段如何找到导入的函数。
看个FirstThunk实例
在前面的输入表第一个IID结构指向的USER32.dll中FirstThunk字段的值是0000 2010,转换成转换成磁盘文件偏移地址是610h,查看610h指向的IAT中各个_IMAGE_THUNK_DATA数组的值:
可以看到FirstThunk字段指向的IAT的值和前面OrignalFirstThunk字段指向的INT的值完全相同,这样不管通过哪种方式加载导入的函数都是一样的。
输入表第一个IID在磁盘文件中的结构示意图
输入表第一个IID在磁盘文件中的结构示意图如下所示:
内存中的输入表
接下来我们在查看输入表第一个IID在内存中的值,实例dumped.exe是从内存中抓取的,其结构就是PE文件映射到内存的状态。输入表的RVA地址是2040h,如下图所示:
可以看到内存中加载的输入表和前面磁盘文件中的输入表的值完全相同。其中第一个双字OrignalFirstThunk字段“8C20 0000”指向的是输入名称表(INT),208Ch地址的值依旧和前面磁盘中的OrignalFirstThunk字段“8C20 0000”指向的68Ch地址的值一样;但是第5个双字FirstThunk字段“1020 0000”指向的输入地址表(IAT),2010h值随着内存加载已经变化了,之前的磁盘文件中FirstThunk字段“1020 0000”指向的IAT地址610h的值是:
现在的内存文件中FirstThunk字段“1020 0000”指向的IAT地址2010h的值是:
上面IAT中的值是:
77D2 16DD | 77D1 F277 | 77D1 9118 | 77D1 9105 |
---|---|---|---|
77D1 8ABC | 77D1 ABOS | 77DI CA07 | 77D1 ABEO |
77D1 19F4 | 77D1 EC72 | 77DI CIDF | 0000 0000 |
表中地址都是USER32.dll中的相关输出函数的地址。例如第一个“77D2 16DD”,反汇编USER32.dll,跳转到77D216DDh处,显示代码如下:
Exported fn : LoadIconA - ord : 01BCh
:77D216DD 8BCO mov eax, eax
:77D216DF55 push ebp
:77D216EO 8BEC mov ebp, esp
:77D216E2 66F7450EEFEF test [ebp+0E], FFFF
:77D216E8 OF8529170200 jne 77D42E17
:77D216EE 5D pop ebp
:77D216EFEBB6 jmp 77D216A7
:77D216F190 nop
:77D216F290 nop
所以77D216DDh处指向USER32.dl中LoadIconA函数的代码,其他函数类似。
输入表第1个IID在内存中的结构示意图
PE.exe文件装载到内存里的第1个IID的结构示意图如图所示:
程序装载到内存中后,只与IAT 交换信息,输人表的其他部分就不需要了。例如,程序调用LoadIconA 函数的指针是指向IAT 的,而IAT已指向系统USER32.dll 的LoadlconA 函数代码。调用LoadIconA 函数的相关代码如下:
Call 00401164;
00401164 Jmp dword ptr [00402010];跳转到77D216DDh 处(USER32.d1l指向的LoadIconA 函数)
同样的输入表第二个IID结构指向的KERNEL32.dll也是类似的分析。
附:绑定输入
当PE装载器载入PE文件时,会检查输入表并将相关 DLL 映射到进程地址空间,然后遍历IAT里的IMAGE_THUNK_DATA 数组并用输人函数的真实地址替换它,这一步需要花费很长时间。绑定输入(Bound Import)就是由程序员来预测DLL函数的真实地址,这样PE 装载器就不用在每次载人PE文件时都去修正IMAGE_THUNK_DATA的值了。
绑定输入(Bound Import)只是一个帮助PE 装载器快速定位输入函数的字段,去除绑定输人表不会影响程序的正常运行。去除方法是,将绑定数据清零,然后将目录表中的 Bound import的 RVA与大小清零。
五、输出表
创建一个DLL 时,实际上创建了一组能让EXE或其他DLL调用的函数,此时PE装载器根据DLL文件中输出的信息修正被执行文件的 IAT。当一个DLL函数能被EXE或另一个 DLL文件使用时,它就被“输出了" (Exported )。其中,输出信息被保存在输出表中,DLL 文件通过输出表向系统提供输出函数名、序号和入口地址等信息。
大部分DLL文件中存在输出表。EXE文件中很少存在输出。