【转】pe结构详解

(一)基本概念
PE(Portable Execute)文件是Windows下可执行文件的总称,常见的有DLL,EXE,OCX,SYS等,
事实上,一个文件是否是PE文件与其扩展名无关,PE文件可以是任何扩展名。
那Windows是怎么区分可执行文件和非可执行文件的呢?
我们调用LoadLibrary传递了一个文件名,系统是如何判断这个文件是一个合法的动态库呢?
这就涉及到PE文件结构了。
 
PE文件的结构一般来说如下图所示:从起始位置开始依次是DOS头,NT头,节表以及具体的节。
DOS头是用来兼容MS-DOS操作系统的,目的是当这个文件在MS-DOS上运行时提示一段文字,
大部分情况下是:This program cannot be run in DOS mode.还有一个目的,就是指明NT头在文件中的位置。
 
NT头包含windows PE文件的主要信息,其中包括一个‘PE’字样的签名,PE文件头(IMAGE_FILE_HEADER)和PE可选头(IMAGE_OPTIONAL_HEADER32),
头部的详细结构以及其具体意义在PE文件头文章中详细描述。
 
节表:是PE文件后续节的描述,windows根据节表的描述加载每个节。
 
节:每个节实际上是一个容器,可以包含代码、数据等等,每个节可以有独立的内存权限,
比如代码节默认有读/执行权限,节的名字和数量可以自己定义,未必是上图中的三个。
 
当一个PE文件被加载到内存中以后,我们称之为“映象”(image),
一般来说,PE文件在硬盘上和在内存里是不完全一样的,被加载到内存以后其占用的虚拟地址空间要比在硬盘上占用的空间大一些,
这是因为各个节在硬盘上是连续的,而在内存中是按页对齐的,所以加载到内存以后节之间会出现一些“空洞”。
 
因为存在这种对齐,所以在PE结构内部,表示某个位置的地址采用了两种方式,针对在硬盘上存储文件中的地址,称为原始存储地址或物理地址表示距离文件头的偏移;
另外一种是针对加载到内存以后映象中的地址,称为相对虚拟地址(RVA),表示相对内存映象头的偏移。
 
然而CPU的某些指令是需要使用绝对地址的,
比如取全局变量的地址,传递函数的地址编译以后的汇编指令中肯定需要用到绝对地址而不是相对映象头的偏移,
因此PE文件会建议操作系统将其加载到某个内存地址(这个叫基地址),编译器便根据这个地址求出代码中一些全局变量和函数的地址,并将这些地址用到对应的指令中。
例如在IDA里看上去是这个样子:
这种表示方式叫做虚拟地址(VA)。
 
既然有VA这么简单的表示方式为什么还要有前面的RVA呢?
因为虽然PE文件为自己指定加载的基地址,但是windows有茫茫多的DLL,而且每个软件也有自己的DLL,如果指定的地址已经被别的DLL占了怎么办?
如果PE文件无法加载到预期的地址,那么系统会帮他重新选择一个合适的基地址将他加载到此处,这时原有的VA就全部失效了,NT头保存了PE文件加载所需的信息,在不知道PE会加载到哪个基地址之前,VA是无效的,所以在PE文件头中大部分是使用RVA来表示地址的,而在代码中是用VA表示全局变量和函数地址的。
既然加载基址变了以后VA都失效了,那存在于代码中的那些VA怎么办呢?
答案是:重定位。系统有自己的办法修正这些值,到后续重定位表的文章中会详细描述。
既然有重定位,为什么NT头不能依靠重定位采用VA表示地址呢?
因为不是所有的PE都有重定位,早期的EXE就是没有重定位的。
 
我们都知道PE文件可以导出函数让其他的PE文件使用,也可以从其他PE文件导入函数,这些是如何做到的?
PE文件通过导出表指明自己导出那些函数,通过导入表指明需要从哪些模块导入哪些函数。
 
(二)可执行文件头
了解一个文件的格式,最应该首先了解的就是这个文件的文件头的含义,
因为几乎所有的文件格式,重要的信息都包含在头部,顺着头部的信息,可以引导系统解析整个文件。
所以,先来认识一下PE文件的头部格式
 
DOS头和NT头就是PE文件中两个重要的文件头。
 
1、DOS头
DOS头的作用是兼容MS-DOS操作系统中的可执行文件,
对于32位PE文件来说,DOS所起的作用就是显示一行文字,提示用户:我需要在32位windows上才可以运行。
这是个善意的玩笑,因为他并不像显示的那样不能运行,其实已经运行了,只是在DOS上没有干用户希望看到的工作而已。
DOS头是如何定义的:
typedef struct _IMAGE_DOS_HEADER {      // DOS .EXE header
    WORD   e_magic;                     // Magic number
    WORD   e_cblp;                      // Bytes on last page of file
    WORD   e_cp;                        // Pages in file
    WORD   e_crlc;                      // Relocations
    WORD   e_cparhdr;                   // Size of header in paragraphs
    WORD   e_minalloc;                  // Minimum extra paragraphs needed
    WORD   e_maxalloc;                  // Maximum extra paragraphs needed
    WORD   e_ss;                        // Initial (relative) SS value
    WORD   e_sp;                        // Initial SP value
    WORD   e_csum;                      // Checksum
    WORD   e_ip;                        // Initial IP value
    WORD   e_cs;                        // Initial (relative) CS value
    WORD   e_lfarlc;                    // File address of relocation table
    WORD   e_ovno;                      // Overlay number
    WORD   e_res[4];                    // Reserved words
    WORD   e_oemid;                     // OEM identifier (for e_oeminfo)
    WORD   e_oeminfo;                   // OEM information; e_oemid specific
    WORD   e_res2[10];                  // Reserved words
    LONG   e_lfanew;                    // File address of new exe header
  } IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;
需要关注两个域:
e_magic:一个WORD类型,值是一个常数0x4D5A,用文本编辑器查看该值位‘MZ’,可执行文件必须都是'MZ'开头。
e_lfanew:为32位可执行文件扩展的域,用来表示DOS头之后的NT头相对文件起始地址的偏移。
 
2、NT头
顺着DOS头中的e_lfanew,很容易可以找到NT头,这个才是32位PE文件中最有用的头,定义如下:
typedef struct _IMAGE_NT_HEADERS {
    DWORD Signature;
    IMAGE_FILE_HEADER FileHeader;
    IMAGE_OPTIONAL_HEADER32 OptionalHeader;
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;
 
下图是一张真实的PE文件头结构以及其各个域的取值:
 
1)Signature:类似于DOS头中的e_magic,其高16位是0,低16是0x4550,用字符表示是'PE‘。
 
2)IMAGE_FILE_HEADER是PE文件头,c语言的定义是这样的:
typedef struct _IMAGE_FILE_HEADER {
    WORD    Machine;
    WORD    NumberOfSections;
    DWORD   TimeDateStamp;
    DWORD   PointerToSymbolTable;
    DWORD   NumberOfSymbols;
    WORD    SizeOfOptionalHeader;
    WORD    Characteristics;
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;
每个域的具体含义如下:
Machine:该文件的运行平台,是x86、x64还是I64等等,可以是下面值里的某一个。
#define IMAGE_FILE_MACHINE_UNKNOWN           0
#define IMAGE_FILE_MACHINE_I386              0x014c  // Intel 386.
#define IMAGE_FILE_MACHINE_R3000             0x0162  // MIPS little-endian, 0x160 big-endian
#define IMAGE_FILE_MACHINE_R4000             0x0166  // MIPS little-endian
#define IMAGE_FILE_MACHINE_R10000            0x0168  // MIPS little-endian
#define IMAGE_FILE_MACHINE_WCEMIPSV2         0x0169  // MIPS little-endian WCE v2
#define IMAGE_FILE_MACHINE_ALPHA             0x0184  // Alpha_AXP
#define IMAGE_FILE_MACHINE_SH3               0x01a2  // SH3 little-endian
#define IMAGE_FILE_MACHINE_SH3DSP            0x01a3
#define IMAGE_FILE_MACHINE_SH3E              0x01a4  // SH3E little-endian
#define IMAGE_FILE_MACHINE_SH4               0x01a6  // SH4 little-endian
#define IMAGE_FILE_MACHINE_SH5               0x01a8  // SH5
#define IMAGE_FILE_MACHINE_ARM               0x01c0  // ARM Little-Endian
#define IMAGE_FILE_MACHINE_THUMB             0x01c2
#define IMAGE_FILE_MACHINE_AM33              0x01d3
#define IMAGE_FILE_MACHINE_POWERPC           0x01F0  // IBM PowerPC Little-Endian
#define IMAGE_FILE_MACHINE_POWERPCFP         0x01f1
#define IMAGE_FILE_MACHINE_IA64              0x0200  // Intel 64
#define IMAGE_FILE_MACHINE_MIPS16            0x0266  // MIPS
#define IMAGE_FILE_MACHINE_ALPHA64           0x0284  // ALPHA64
#define IMAGE_FILE_MACHINE_MIPSFPU           0x0366  // MIPS
#define IMAGE_FILE_MACHINE_MIPSFPU16         0x0466  // MIPS
#define IMAGE_FILE_MACHINE_AXP64             IMAGE_FILE_MACHINE_ALPHA64
#define IMAGE_FILE_MACHINE_TRICORE           0x0520  // Infineon
#define IMAGE_FILE_MACHINE_CEF               0x0CEF
#define IMAGE_FILE_MACHINE_EBC               0x0EBC  // EFI Byte Code
#define IMAGE_FILE_MACHINE_AMD64             0x8664  // AMD64 (K8)
#define IMAGE_FILE_MACHINE_M32R              0x9041  // M32R little-endian
#define IMAGE_FILE_MACHINE_CEE               0xC0EE
 
NumberOfSections:该PE文件中有多少个节,也就是节表中的项数。
 
TimeDateStamp:PE文件的创建时间,一般有连接器填写。
 
PointerToSymbolTable:COFF文件符号表在文件中的偏移。
 
NumberOfSymbols:符号表的数量。
 
SizeOfOptionalHeader:紧随其后的可选头的大小。
 
Characteristics:可执行文件的属性,可以是下面这些值按位相或。
#define IMAGE_FILE_RELOCS_STRIPPED           0x0001  // Relocation info stripped from file.
#define IMAGE_FILE_EXECUTABLE_IMAGE          0x0002  // File is executable  (i.e. no unresolved externel references).
#define IMAGE_FILE_LINE_NUMS_STRIPPED        0x0004  // Line nunbers stripped from file.
#define IMAGE_FILE_LOCAL_SYMS_STRIPPED       0x0008  // Local symbols stripped from file.
#define IMAGE_FILE_AGGRESIVE_WS_TRIM         0x0010  // Agressively trim working set
#define IMAGE_FILE_LARGE_ADDRESS_AWARE       0x0020  // App can handle >2gb addresses
#define IMAGE_FILE_BYTES_REVERSED_LO         0x0080  // Bytes of machine word are reversed.
#define IMAGE_FILE_32BIT_MACHINE             0x0100  // 32 bit word machine.
#define IMAGE_FILE_DEBUG_STRIPPED            0x0200  // Debugging info stripped from file in .DBG file
#define IMAGE_FILE_REMOVABLE_RUN_FROM_SWAP   0x0400  // If Image is on removable media, copy and run from the swap file.
#define IMAGE_FILE_NET_RUN_FROM_SWAP         0x0800  // If Image is on Net, copy and run from the swap file.
#define IMAGE_FILE_SYSTEM                    0x1000  // System File.
#define IMAGE_FILE_DLL                       0x2000  // File is a DLL.
#define IMAGE_FILE_UP_SYSTEM_ONLY            0x4000  // File should only be run on a UP machine
#define IMAGE_FILE_BYTES_REVERSED_HI         0x8000  // Bytes of machine word are reversed.
 
可以看出,PE文件头定义了PE文件的一些基本信息和属性,
这些属性会在PE加载器加载时用到,如果加载器发现PE文件头中定义的一些属性不满足当前的运行环境,将会终止加载该PE。
 
3)另一个重要的头就是PE可选头
别看他名字叫可选头,其实一点都不能少,不过,它在不同的平台下是不一样的,
例如32位下是IMAGE_OPTIONAL_HEADER32,而在64位下是IMAGE_OPTIONAL_HEADER64。
 
32位PE可选头:
typedef struct _IMAGE_OPTIONAL_HEADER {
    WORD    Magic;
    BYTE    MajorLinkerVersion;
    BYTE    MinorLinkerVersion;
    DWORD   SizeOfCode;
    DWORD   SizeOfInitializedData;
    DWORD   SizeOfUninitializedData;
    DWORD   AddressOfEntryPoint;
    DWORD   BaseOfCode;
    DWORD   BaseOfData;
    DWORD   ImageBase;
    DWORD   SectionAlignment;
    DWORD   FileAlignment;
    WORD    MajorOperatingSystemVersion;
    WORD    MinorOperatingSystemVersion;
    WORD    MajorImageVersion;
    WORD    MinorImageVersion;
    WORD    MajorSubsystemVersion;
    WORD    MinorSubsystemVersion;
    DWORD   Win32VersionValue;
    DWORD   SizeOfImage;
    DWORD   SizeOfHeaders;
    DWORD   CheckSum;
    WORD    Subsystem;
    WORD    DllCharacteristics;
    DWORD   SizeOfStackReserve;
    DWORD   SizeOfStackCommit;
    DWORD   SizeOfHeapReserve;
    DWORD   SizeOfHeapCommit;
    DWORD   LoaderFlags;
    DWORD   NumberOfRvaAndSizes;
    IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];
} IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32;
 
Magic:表示可选头的类型。
#define IMAGE_NT_OPTIONAL_HDR32_MAGIC      0x10b  // 32位PE可选头
#define IMAGE_NT_OPTIONAL_HDR64_MAGIC      0x20b  // 64位PE可选头
#define IMAGE_ROM_OPTIONAL_HDR_MAGIC       0x107
 
MajorLinkerVersion和MinorLinkerVersion:链接器的版本号。
 
SizeOfCode:代码段的长度,如果有多个代码段,则是代码段长度的总和。
 
SizeOfInitializedData:初始化的数据长度。
 
SizeOfUninitializedData:未初始化的数据长度。
 
AddressOfEntryPoint:程序入口的RVA,对于exe这个地址可以理解为WinMain的RVA。对于DLL,这个地址可以理解为DllMain的RVA,如果是驱动程序,可以理解为DriverEntry的RVA。当然,实际上入口点并非是WinMain,DllMain和DriverEntry,在这些函数之前还有一系列初始化要完成,当然,这些不是本文的重点。
 
BaseOfCode:代码段起始地址的RVA。
 
BaseOfData:数据段起始地址的RVA。
 
ImageBase:映象(加载到内存中的PE文件)的基地址,这个基地址是建议,对于DLL来说,如果无法加载到这个地址,系统会自动为其选择地址。
 
SectionAlignment:节对齐,PE中的节被加载到内存时会按照这个域指定的值来对齐,比如这个值是0x1000,那么每个节的起始地址的低12位都为0。
 
FileAlignment:节在文件中按此值对齐,SectionAlignment必须大于或等于FileAlignment。
 
MajorOperatingSystemVersion、MinorOperatingSystemVersion:所需操作系统的版本号,随着操作系统版本越来越多,这个好像不是那么重要了。
 
MajorImageVersion、MinorImageVersion:映象的版本号,这个是开发者自己指定的,由连接器填写。
 
MajorSubsystemVersion、MinorSubsystemVersion:所需子系统版本号。
 
Win32VersionValue:保留,必须为0。
 
SizeOfImage:映象的大小,PE文件加载到内存中空间是连续的,这个值指定占用虚拟空间的大小。
 
SizeOfHeaders:所有文件头(包括节表)的大小,这个值是以FileAlignment对齐的。
 
CheckSum:映象文件的校验和。
 
Subsystem:运行该PE文件所需的子系统,可以是下面定义中的某一个:
#define IMAGE_SUBSYSTEM_UNKNOWN              0   // Unknown subsystem.
#define IMAGE_SUBSYSTEM_NATIVE               1   // Image doesn't require a subsystem.
#define IMAGE_SUBSYSTEM_WINDOWS_GUI          2   // Image runs in the Windows GUI subsystem.
#define IMAGE_SUBSYSTEM_WINDOWS_CUI          3   // Image runs in the Windows character subsystem.
#define IMAGE_SUBSYSTEM_OS2_CUI              5   // image runs in the OS/2 character subsystem.
#define IMAGE_SUBSYSTEM_POSIX_CUI            7   // image runs in the Posix character subsystem.
#define IMAGE_SUBSYSTEM_NATIVE_WINDOWS       8   // image is a native Win9x driver.
#define IMAGE_SUBSYSTEM_WINDOWS_CE_GUI       9   // Image runs in the Windows CE subsystem.
#define IMAGE_SUBSYSTEM_EFI_APPLICATION      10  //
#define IMAGE_SUBSYSTEM_EFI_BOOT_SERVICE_DRIVER  11   //
#define IMAGE_SUBSYSTEM_EFI_RUNTIME_DRIVER   12  //
#define IMAGE_SUBSYSTEM_EFI_ROM              13
#define IMAGE_SUBSYSTEM_XBOX                 14
#define IMAGE_SUBSYSTEM_WINDOWS_BOOT_APPLICATION 16
 
DllCharacteristics:DLL的文件属性,只对DLL文件有效,可以是下面定义中某些的组合:
#define IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE 0x0040     // DLL can move.
#define IMAGE_DLLCHARACTERISTICS_FORCE_INTEGRITY    0x0080     // Code Integrity Image
#define IMAGE_DLLCHARACTERISTICS_NX_COMPAT    0x0100     // Image is NX compatible
#define IMAGE_DLLCHARACTERISTICS_NO_ISOLATION 0x0200     // Image understands isolation and doesn't want it
#define IMAGE_DLLCHARACTERISTICS_NO_SEH       0x0400     // Image does not use SEH.  No SE handler may reside in this image
#define IMAGE_DLLCHARACTERISTICS_NO_BIND      0x0800     // Do not bind this image.
//                                            0x1000     // Reserved.
#define IMAGE_DLLCHARACTERISTICS_WDM_DRIVER   0x2000     // Driver uses WDM model
//                                            0x4000     // Reserved.
#define IMAGE_DLLCHARACTERISTICS_TERMINAL_SERVER_AWARE     0x8000
 
SizeOfStackReserve:运行时为每个线程栈保留内存的大小。
 
SizeOfStackCommit:运行时每个线程栈初始占用内存大小。
 
SizeOfHeapReserve:运行时为进程堆保留内存大小。
 
SizeOfHeapCommit:运行时进程堆初始占用内存大小。
 
LoaderFlags:保留,必须为0。
 
NumberOfRvaAndSizes:数据目录的项数,即下面这个数组的项数。
 
DataDirectory:数据目录,这是一个数组,数组的项定义如下:
typedef struct _IMAGE_DATA_DIRECTORY {
    DWORD   VirtualAddress;
    DWORD   Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
VirtualAddress:是一个RVA。
Size:是一个大小。
这两个数有什么用?
一个是地址,一个是大小,可以看出这个数据目录项定义的是一个区域。
那他定义的是什么东西的区域呢?
前面说了,DataDirectory是个数组,数组中的每一项对应一个特定的数据结构,包括导入表,导出表等等,根据不同的索引取出来的是不同的结构,头文件里定义各个项表示哪个结构,
 
(三)PE导出表
可执行文件头 的结尾出现了一个大数组,这个数组中的每一项都是一个特定的结构,
通过函数获取数组中的项可以用RtlImageDirectoryEntryToData函数,
DataDirectory中的每一项都可以用这个函数获取,函数原型如下:
PVOID NTAPI RtlImageDirectoryEntryToData(PVOID Base, BOOLEAN MappedAsImage, USHORT Directory, PULONG Size);
 
Base:模块基地址。
 
MappedAsImage:是否映射为映象。
 
Directory:数据目录项的索引。
#define IMAGE_DIRECTORY_ENTRY_EXPORT          0   // Export Directory
#define IMAGE_DIRECTORY_ENTRY_IMPORT          1   // Import Directory
#define IMAGE_DIRECTORY_ENTRY_RESOURCE        2   // Resource Directory
#define IMAGE_DIRECTORY_ENTRY_EXCEPTION       3   // Exception Directory
#define IMAGE_DIRECTORY_ENTRY_SECURITY        4   // Security Directory
#define IMAGE_DIRECTORY_ENTRY_BASERELOC       5   // Base Relocation Table
#define IMAGE_DIRECTORY_ENTRY_DEBUG           6   // Debug Directory
//      IMAGE_DIRECTORY_ENTRY_COPYRIGHT       7   // (X86 usage)
#define IMAGE_DIRECTORY_ENTRY_ARCHITECTURE    7   // Architecture Specific Data
#define IMAGE_DIRECTORY_ENTRY_GLOBALPTR       8   // RVA of GP
#define IMAGE_DIRECTORY_ENTRY_TLS             9   // TLS Directory
#define IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG    10   // Load Configuration Directory
#define IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT   11   // Bound Import Directory in headers
#define IMAGE_DIRECTORY_ENTRY_IAT            12   // Import Address Table
#define IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT   13   // Delay Load Import Descriptors
#define IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR 14   // COM Runtime descriptor
 
Size:对应数据目录项的大小,比如Directory为0,则表示导出表的大小。
 
返回值表示数据目录项的起始地址。
 
这次来看看第一项:导出表。
导出表是用来描述模块中的导出函数的结构,
如果一个模块导出了函数,那么这个函数会被记录在导出表中,这样通过GetProcAddress函数就能动态获取到函数的地址。
函数导出的方式有两种,一种是按名字导出,一种是按序号导出。这两种导出方式在导出表中的描述方式也不相同。
模块的导出函数可以通过Dependency walker工具来查看:
上图中红框位置显示的就是模块的导出函数,有时候显示的导出函数名字中有一些符号,像 ??0CP2PDownloadUIInterface@@QAE@ABV0@@Z,这种是导出了C++的函数名,编译器将名字进行了修饰。
 
导出表的定义:
typedef struct _IMAGE_EXPORT_DIRECTORY {
    DWORD   Characteristics;
    DWORD   TimeDateStamp;
    WORD    MajorVersion;
    WORD    MinorVersion;
    DWORD   Name;
    DWORD   Base;
    DWORD   NumberOfFunctions;
    DWORD   NumberOfNames;
    DWORD   AddressOfFunctions;     // RVA from base of image
    DWORD   AddressOfNames;         // RVA from base of image
    DWORD   AddressOfNameOrdinals;  // RVA from base of image
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;
Characteristics:现在没有用到,一般为0。
TimeDateStamp:导出表生成的时间戳,由连接器生成。
MajorVersion,MinorVersion:看名字是版本,实际貌似没有用,都是0。
Name:模块的名字。
Base:序号的基数,按序号导出函数的序号值从Base开始递增。
NumberOfFunctions:所有导出函数的数量。
NumberOfNames:按名字导出函数的数量。
AddressOfFunctions:一个RVA,指向一个DWORD数组,数组中的每一项是一个导出函数的RVA,顺序与导出序号相同。
AddressOfNames:一个RVA,依然指向一个DWORD数组,数组中的每一项仍然是一个RVA,指向一个表示函数名字。
AddressOfNameOrdinals:一个RVA,还是指向一个WORD数组,数组中的每一项与AddressOfNames中的每一项对应,表示该名字的函数在AddressOfFunctions中的序号。
在上图中,AddressOfNames指向一个数组,数组里保存着一组RVA,每个RVA指向一个字符串,这个字符串即导出的函数名,与这个函数名对应的是AddressOfNameOrdinals中的对应项。获取导出函数地址时,先在AddressOfNames中找到对应的名字,比如Func2,他在AddressOfNames中是第二项,然后从AddressOfNameOrdinals中取出第二项的值,这里是2,表示函数入口保存在AddressOfFunctions这个数组中下标为2的项里,即第三项,取出其中的值,加上模块基地址便是导出函数的地址。如果函数是以序号导出的,那么查找的时候直接用序号减去Base,得到的值就是函数在AddressOfFunctions中的下标。
 
用代码实现如下:
DWORD* CEAT::SearchEAT( const char* szName)
{
    if (IS_VALID_PTR(m_pTable))
    {
        bool bByOrdinal = HIWORD(szName) == 0;
        DWORD* pProcs = (DWORD*)((char*)RVA2VA(m_pTable->AddressOfFunctions));
        if (bByOrdinal)
        {
            DWORD dwOrdinal = (DWORD)szName;
            if (dwOrdinal < m_pTable->NumberOfFunctions && dwOrdinal >= m_pTable->Base)
            {
                return &pProcs[dwOrdinal-m_pTable->Base];
            }
        }
        else
        {
            WORD* pOrdinals = (WORD*)((char*)RVA2VA(m_pTable->AddressOfNameOrdinals));
            DWORD* pNames = (DWORD*)((char*)RVA2VA(m_pTable->AddressOfNames));
            for (unsigned int i=0; i<m_pTable->NumberOfNames; ++i)
            {
                char* pNameVA = (char*)RVA2VA(pNames[i]);
                if (strcmp(szName, pNameVA) != 0)
                {
                    continue;
                }
                return &pProcs[pOrdinals[i]];
            }
        }
    }
    return NULL;
}
 
(四)PE导入表
可执行文件头的最后展示了一个数组;
这次来看看这个数组的第二项:导出表 IMAGE_DIRECTORY_ENTRY_IMPORT。
 
在IMAGE_DATA_DIRECTORY中,有几项的名字都和导入表有关系,
其中包括:IMAGE_DIRECTORY_ENTRY_IMPORT,IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT,IMAGE_DIRECTORY_ENTRY_IAT和IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT
 
这几个导入都是用来干什么的,他们之间又是什么关系呢?
IMAGE_DIRECTORY_ENTRY_IMPORT就是我们通常所知道的导入表,在PE文件加载时,会根据这个表里的内容加载依赖的DLL,并填充所需函数的地址。
IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT叫做绑定导入表,在第一种导入表导入地址的修正是在PE加载时完成,如果一个PE文件导入的DLL或者函数多那么加载起来就会略显的慢一些,所以出现了绑定导入,在加载以前就修正了导入表,这样就会快一些。
IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT叫做延迟导入表,一个PE文件也许提供了很多功能,也导入了很多其他DLL,但是并非每次加载都会用到它提供的所有功能,也不一定会用到它需要导入的所有DLL,因此延迟导入就出现了,只有在一个PE文件真正用到需要的DLL,这个DLL才会被加载,甚至于只有真正使用某个导入函数,这个函数地址才会被修正。
IMAGE_DIRECTORY_ENTRY_IAT是导入地址表,前面的三个表其实是导入函数的描述,真正的函数地址是被填充在导入地址表中的。
 
例如:
这个代码调用了一个RegOpenKeyW的导入函数,
我们看到其opcode是FF 15 00 00 19 30气质FF 15表示这是一个间接调用,即call dword ptr [30190000] ;
这表示要调用的地址存放在30190000这个地址中,而30190000这个地址在导入地址表的范围内,
当模块加载时,PE 加载器会根据导入表中描述的信息修正30190000这个内存中的内容。
 
那么导入表里到底记录了那些信息,如何根据这些信息修正IAT呢?
导入表的定义:
typedef struct _IMAGE_IMPORT_DESCRIPTOR {
    union {
        DWORD   Characteristics;            // 0 for terminating null import descriptor
        DWORD   OriginalFirstThunk;         // RVA to original unbound IAT (PIMAGE_THUNK_DATA)
    } DUMMYUNIONNAME;
    DWORD   TimeDateStamp;                  // 0 if not bound,
                                            // -1 if bound, and real date\time stamp
                                            //     in IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT (new BIND)
                                            // O.W. date/time stamp of DLL bound to (Old BIND)
    DWORD   ForwarderChain;                 // -1 if no forwarders
    DWORD   Name;
    DWORD   FirstThunk;                     // RVA to IAT (if bound this IAT has actual addresses)
} IMAGE_IMPORT_DESCRIPTOR;
typedef IMAGE_IMPORT_DESCRIPTOR UNALIGNED *PIMAGE_IMPORT_DESCRIPTOR;
使用RtlImageDirectoryEntryToData并将索引号传1,会得到一个如上结构的指针,实际上指向一个上述结构的数组,每个导入的DLL都会成为数组中的一项,也就是说,一个这样的结构对应一个导入的DLL。
Characteristics和OriginalFirstThunk:一个联合体,如果是数组的最后一项Characteristics为0,否则OriginalFirstThunk保存一个RVA,指向一个IMAGE_THUNK_DATA的数组,这个数组中的每一项表示一个导入函数。
TimeDateStamp:映象绑定前,这个值是0,绑定后是导入模块的时间戳。
ForwarderChain:转发链,如果没有转发器,这个值是-1。
Name:一个RVA,指向导入模块的名字,所以一个IMAGE_IMPORT_DESCRIPTOR描述一个导入的DLL。
FirstThunk:也是一个RVA,也指向一个IMAGE_THUNK_DATA数组。
 
既然OriginalFirstThunk与FirstThunk都指向一个IMAGE_THUNK_DATA数组,而且这两个域的名字都长得很像,他俩有什么区别呢?
为了解答这个问题,先来认识一下IMAGE_THUNK_DATA结构:
typedef struct _IMAGE_THUNK_DATA32 {
    union {
        DWORD ForwarderString;      // PBYTE
        DWORD Function;             // PDWORD
        DWORD Ordinal;
        DWORD AddressOfData;        // PIMAGE_IMPORT_BY_NAME
    } u1;
} IMAGE_THUNK_DATA32;
 
typedef IMAGE_THUNK_DATA32 * PIMAGE_THUNK_DATA32;
ForwarderString是转发用的,暂时不用考虑,
Function表示函数地址,如果是按序号导入Ordinal就有用了,若是按名字导入AddressOfData便指向名字信息。
可以看出这个结构体就是一个大的union,union虽包含多个域但是在不同时刻代表不同的意义那到底应该是名字还是序号,该如何区分呢?
可以通过Ordinal判断,如果Ordinal的最高位是1,就是按序号导入的,这时候,低16位就是导入序号,如果最高位是0,则AddressOfData是一个RVA,指向一个IMAGE_IMPORT_BY_NAME结构,用来保存名字信息,由于Ordinal和AddressOfData实际上是同一个内存空间,所以AddressOfData其实只有低31位可以表示RVA,但是一个PE文件不可能超过2G,所以最高位永远为0,这样设计很合理的利用了空间。实际编写代码的时候微软提供两个宏定义处理序号导入:IMAGE_SNAP_BY_ORDINAL判断是否按序号导入,IMAGE_ORDINAL用来获取导入序号。
这时可以回头看看OriginalFirstThunk与FirstThunk,OriginalFirstThunk指向的IMAGE_THUNK_DATA数组包含导入信息,在这个数组中只有Ordinal和AddressOfData是有用的,因此可以通过OriginalFirstThunk查找到函数的地址。FirstThunk则略有不同,在PE文件加载以前或者说在导入表未处理以前,他所指向的数组与OriginalFirstThunk中的数组虽不是同一个,但是内容却是相同的,都包含了导入信息,而在加载之后,FirstThunk中的Function开始生效,他指向实际的函数地址,因为FirstThunk实际上指向IAT中的一个位置,IAT就充当了IMAGE_THUNK_DATA数组,加载完成后,这些IAT项就变成了实际的函数地址,即Function的意义。还是上个图对比一下:
 
加载前:
 
加载后:
 
最后总结一下:
导入表其实是一个IMAGE_IMPORT_DESCRIPTOR的数组,每个导入的DLL对应一个IMAGE_IMPORT_DESCRIPTOR。
IMAGE_IMPORT_DESCRIPTOR包含两个IMAGE_THUNK_DATA数组,数组中的每一项对应一个导入函数。
加载前OriginalFirstThunk与FirstThunk的数组都指向名字信息,加载后FirstThunk数组指向实际的函数地址。
 
(五)延迟导入表
延迟导入(Delay Import)。看名字就知道,这种导入机制导入其他DLL的时机比较“迟”,为什么要迟呢?
因为有些导入函数可能使用的频率比较低,或者在某些特定的场合才会用到,而有些函数可能要在程序运行一段时间后才会用到,这些函数可以等到他实际使用的时候再去加载对应的DLL,而没必要再程序一装载就初始化好。
 
这个机制听起来很诱人,因为他可以加快启动速度,我们应该如何利用这项机制呢?
VC有一个选项,可以让我们很方便的使用到这项特性,如下图所示:
在这一项后面填写需要延迟导入的DLL名称,连接器就会自动帮我们将这些DLL的导入变为延迟导入。
 
现在知道如何使用延迟导入了,那这个看上去很厉害的机制是如何实现的呢?
在IMAGE_DATA_DIRECTORY中,有一项为IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT,这一项便是延迟导入表,
IMAGE_DATA_DIRECTORY.VirtualAddress就指向延迟导入表的起始地址。
既然是表,肯定又是一个数组,每一项都是一个ImgDelayDescr结构体,和导入表一样,每一项都代表一个导入的DLL,
来看看定义:
typedef struct ImgDelayDescr {  
    DWORD           grAttrs;        // attributes  
    RVA             rvaDLLName;     // RVA to dll name  
    RVA             rvaHmod;        // RVA of module handle  
    RVA             rvaIAT;         // RVA of the IAT  
    RVA             rvaINT;         // RVA of the INT  
    RVA             rvaBoundIAT;    // RVA of the optional bound IAT  
    RVA             rvaUnloadIAT;   // RVA of optional copy of original IAT  
    DWORD           dwTimeStamp;    // 0 if not bound,  
                                    // O.W. date/time stamp of DLL bound to (Old BIND)  
} ImgDelayDescr, * PImgDelayDescr;  
typedef const ImgDelayDescr *   PCImgDelayDescr;  
grAttrs:用来区分版本,1是新版本,0是旧版本,旧版本中后续的rvaxxxxxx域使用的都是指针,而新版本中都用RVA,我们只讨论新版本。
rvaDLLName:一个RVA,指向导入DLL的名字。
rvaHmod:一个RVA,指向导入DLL的模块基地址,这个基地址在DLL真正被导入前是NULL,导入后才是实际的基地址。
rvaIAT:一个RVA,表示导入函数表,实际上指向IAT,在DLL加载前,IAT里存放的是一小段代码的地址,加载后才是真正的导入函数地址。
rvaINT:一个RVA,指向导入函数的名字表。
rvaUnloadIAT:延迟导入函数卸载表。
dwTimeStamp:延迟导入DLL的时间戳。
 
定义知道了,那他是怎么被处理的呢?
前面提到了,在延迟导入函数指向的IAT里,默认保存的是一段代码的地址,
当程序第一次调用到这个延迟导入函数时,流程会走到那段代码,这段代码用来干什么呢?
看一个真实的延迟导入函数的例子:
.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跳转走,那么他跳转到哪里了呢?继续跟踪:
__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的参数中只有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了。
延迟导入一次只会导入一个函数,而不是一次导入整个模块的所有函数。
 
(六)重定位
前面介绍了PE文件中比较常用的两种导入方式:PE导入表和延时导入表,
我们可以注意到在调用导入函数时系统生成的代码是像下面这样的:
在这里,IE的iexplorer.exe导入了Kernel32.dll的GetCommandLineA函数,可以看到这是个间接call,
00401004这个地址的内存里保存了目的地址, 
根据图中显示的符号信息可知,00401004这个地址是存在于iexplorer.exe模块中的,实际上也就是一项IAT的地址。
这个是IE6的 exe中的例子,当然在dll中如果导入其他dll中的函数,结果也是一样的。
 
这样就有一个问题,代码里call的地址是一个模块内的地址,而且是一个 VA,那么如果模块基地址发生了变化,这个地址岂不是就无效了?这个问题如何解决?
答案是:Windows使用重定位机制保证以上代码无论模块加载到哪个基址都能正确被调用。
 
听起来很神奇,是怎么做到的呢?
其实原理并不很复杂,这个过程分三步:
1.编译的时候由编译器识别出哪些项使用了模块内的直接VA,比如push一个全局变量、函数地址,这些指令的操作数在模块加载的时候就需要被重定位。
2.链接器生成PE文件的时候将编译器识别的重定位的项纪录在一张表里,这张表就是重定位表,保存在DataDirectory中,序号是 IMAGE_DIRECTORY_ENTRY_BASERELOC。
3.PE文件加载时,PE 加载器分析重定位表,将其中每一项按照现在的模块基址进行重定位。
 
以上三步,前两部涉及到了编译和链接的知识,跟本文的关系不大,我们直接看第三步,这一步符合本系列的特征。
 
在查看重定位表的定义前,我们先了解一下他的存储方式,有助于后面的理解。
按照常规思路,每个重定位项应该是一个DWORD,里面保存需要重定位的RVA,这样只需要简单操作便能找到需要重定位的项。
然而,Windows并没有这样设计, 原因是这样存放太占用空间了,
试想一下,加入一个文件有n个重定位项,那么就需要占用4*n个字节。所以Windows采用了分组的方式,
按照重定位项所在的页面分组,每组保存一个页面起始地址的RVA,页内的每项重定位项使用一个WORD保存重定位项在页内的偏移,
这样就大大缩小了重定位表的大小。
 
有了上面的概念,我们现在可以来看一下基址重定位表的定义了:
typedef struct _IMAGE_BASE_RELOCATION {  
     DWORD   VirtualAddress;  
     DWORD   SizeOfBlock;  
 //  WORD    TypeOffset[1];  
} IMAGE_BASE_RELOCATION; 
 
typedef IMAGE_BASE_RELOCATION UNALIGNED * PIMAGE_BASE_RELOCATION;  
VirtualAddress:页起始地址RVA。
SizeOfBlock:表示该分组保存了几项重定位项。
TypeOffset:这个域有两个含义,大家都知道,页内偏移用12位就可以表示,剩下的高4位用来表示重定位的类型。而事实上,Windows只用了一种类型IMAGE_REL_BASED_HIGHLOW,数值是3。
 
最后,还是总结一下,哪些项目需要被重定位呢?
1.代码中使用全局变量的指令,因为全局变量一定是模块内的地址,而且使用全局变量的语句在编译后会产生一条引用全局变量基地址的指令。
2.将模块函数指针赋值给变量或作为参数传递,因为赋值或传递参数是会产生mov和push指令,这些指令需要直接地址。
3.C++中的构造函数和析构函数赋值虚函数表指针,虚函数表中的每一项本身就是重定位项,为什么呢?
 
 
 
posted @ 2019-10-14 18:53  L丶银甲闪闪  阅读(507)  评论(0编辑  收藏  举报