Featured image of post CTF-PWN PE File Structure & PE EntryPoint Feature

CTF-PWN PE File Structure & PE EntryPoint Feature

Windows可执行程序PE文件格式结构及PE文件入口特征

CTF-PWN PE File Structure & PE EntryPoint Feature

引入

可执行程序通常在开发过程中,或许因为需要二进制安全防护,或许因为压缩,通常在程序中植入一段代码,运行时优先取得控制权,处理后的将控制权交还。其目的即为隐藏程序真正的OEP防止破解。通常这种行为可被叫做加壳,加壳不仅可以隐藏OEP,还可以提高部分程序的运行速度。通用加壳程序应阻止外部程序进行附加或对本身进行反汇编分析与动态分析,以保护壳内原始程序不被破坏。此种方法也可应用于病毒绕过杀软查杀。

PE程序结构简述

在Windows操作系统中,PE即为Portable Executable,即为可执行程序。PE程序的结构如图所示: PE文件的框架结构

所有的PE文件均以DOS 'MZ' HEADER开始,程序在DOS下执行时,发现MZHeader,即可认定为是有效执行体,一般会存储文字:This program cannot be run in DOS mode.,若判断执行体不可在DOS模式中运行或为GUI程序,在DOS环境下执行默认输出此字符串。MZHeader后存在一个DOS Stub,DosStub为在不支持PE格式的操作系统中运行的错误提示的有效执行体。DosStub后下一个模块即为WinNT头,NT头含有WinPE文件的主要信息,可分为PE签名、PE文件头(IMAGE_FILE_HEADERS)PE可选头(IMAGE_OPTIONAL_HEADER32)。对于PEHeader,PEHeader为PE结构中IMAGE_NT_HEADERS的简称,内容中含有PE装载器的重要域。执行体在支持PE结构的操作系统中执行时,PE装载器从DOS MZ Header中找到PEHeader的起始偏移量,跳过了DOSStub直接定位到真正的文件头PEHeader。PE文件的内容划分成块可称之为节,每个节可理解为一个容器,即有共同属性的数据,可含有代码和数据等,也有独立的内存权限,代码节默认有读与执行的权限。节的名字和数量可以自定义,节的划分基于各组数据的共同属性,而非逻辑概念。节表Section Table是PE文件后续节的描述,结构中有对应节的属性、文件偏移量、虚拟偏移量等。Windows根据节表描述进行加载。当一个PE文件被加载到内存后,我们可将其称之为映像(Image),节在可执行文件中事连续存储的,当可执行文件加载到内存中则为按页对齐,所以在PE结构内部中,地址表示采取了两种方式:在可执行文件本体中存储的地址称为原始存储地址或物理地址以表示文件头偏移量,当加载到内存后映像的地址称为RVA相对虚拟地址,表示内存映像头部的偏移量。CPU执行程序指令时,若取全局变量地址,传递函数地址编译后汇编指令一定是绝对地址而非映像头部偏移量,故操作系统加载PE文件时加载至基地址,编译器可根据基地址求出代码中全局变量和函数的地址,转移至对应指令中,PE文件指定基地址中地址表示方式为VA虚拟地址。若PE文件中的基地址已被系统占用,无法加载预期基地址,系统重新分配基地址加载,原有VA虚拟地址全部失效。故PE文件头中使用RVA相对虚拟地址来表示地址,代码中用VA虚拟地址表示全局变量和函数地址。代码中的VA虚拟地址在加载基址改变后必须通过重定位修正值才可以正确访问。总结PE文件装载的步骤如下:

  • PE文件被执行,PE装载器检查DOS MZ Header内的PE Header偏移量,存在则执行跳转。
  • PE装载器检查PE Header是否有效,若PE Header有效可以跳转到尾部
  • PE Header后即为节表,PE装载器读取节信息,采用文件映射方法将这些节映射到内存,附上节表中指定的节属性
  • PE文件映射到内存后,PE装载器处理PE文件中引入表的逻辑部分。

对于常用的文件格式,重要信息一般都在头部,根据头部信息,可以引导系统解析整个文件。

DOS Header

DOS头的作用为兼容MS-DOS操作系统中的可执行文件,DOS MZ Header又命名为IMAGE_DOS_HEADER,其结构体定义为:

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头相对文件起始地址的偏移。 在DosStub部分实质为可执行程序,用于当前系统不支持PE环境时,输出错误提示,当前程序需要Windows才能运行。

PE Header

沿着DOS Header中e_lfanew,可以找到NTHeader,是32位PE文件中重要的结构,定义于WinNT.h中:

typedef struct _IMAGE_NT_HEADERS {
        DWORD Signature;
        IMAGE_FILE_HEADER FileHeader;
        IMAGE_OPTIONAL_HEADER32 OptionalHeader;
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;

Signature是标志变量,高16位为0,低16位为0x4550,若其值等于PE\0\0时即满足PE文件要求。 第二个成员类型IMAGE_FILE_HEADER是PE映像文件头,结构体定义为:

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;

WORD Machine的定义为该文件的运行平台,对IMAGE_FILE_MACHINE的typedef定义如下:

#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为可选头
  • OptionalHeader的大小
  • 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文件。OptionalHeader可选头在不同平台下的定义也不同,如32位环境下名为IMAGE_OPTIONAL_HEADER32,而64位环境下名为IMAGE_OPTIONAL_HEADER64,以32为为例,其结构定义如下:

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表示OptionalHeader可选头类型,其可选值如下:

#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相对虚拟地址,可执行程序为WinMain的RVA,动态资源库程序(DLL)为DLLMain的RVA,驱动程序为DriverEntry的RVA,入口点可根据程序类型的不同而变化。入口前还有初始化工作。
  • BaseOfCode为代码段起始地址的RVA
  • BaseOfData为数据段起始地址的RVA
  • ImageBase为加载到内存中PE文件的映像基地址,若系统中基地址已被占用,则分配其他内存地址
  • SectionAlignment为节对齐,PE中节被加载到内存会按照域指定值进行对齐,若设SectionAlignment为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数组的项数,而DataDirectory是一个数据目录,数组项定义如下:
typedef struct _IMAGE_DATA_DIRECTORY {
    DWORD   VirtualAddress;
    DWORD   Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
  • VirtualAddress是RVA地址
  • Size为DWORD类型大小

数据目录项定义的为DataDirectory数组中每一项对应的特定数据结构,内建导入表,导出表等,项结构定义如下:

#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

PE导出表

若要获取DataDirectory中数组项中的值,可通过调用RtlImageDirectoryEntryToData函数实现,其函数原型为:PVOID NTAPI RtlImageDirectoryEntryToData(PVOID Base, BOOLEAN MappedAsImage, USHORT Directory PULONG Size);,其中,Base为模块基地址,MappedAsImage为映射至映像的布尔值,Directory为数据目录项的索引。Size对应数据目录项的大小,若Directory值为0,表示导出表的大小。返回值表示数据目录项的起始地址。导出表是用来描述模块中的导出函数的结构,若某模块导出函数,函数会被记录再导出表,此时通过GetProcAddress函数就可以动态获取函数地址。函数导出可按函数名导出也可按序号导出,不同导出方式在导出表中的描述也不同。如 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为主版本号,一般为0
  • MinorVersion为次版本号,一般也为0
  • Name为模块的名字
  • Base为序号基数,按序号导出函数的序号值从Base开始递增。当通过序数查询输出函数时,此值从序数里被减去,结果用作进入输出地址表EAT的索引
  • NumberOfFunctions为所有导出函数的数量
  • NumberOfNames为按名字导出函数的数量。这个值总是小于或者等于NumberOfFunctions的值
  • AddressOfFunctions为EAT的RVA,指向一个DWORD数组,数组中的每一项是一个导出函数的RVA,顺序与导出序号相同。
  • AddressOfNames为ENT的RVA,依然指向DWORD数组,数组中的每项仍是RVA,表示函数的名字。
  • AddressOfNameOrdinals为输出序数表的RVA,指向WORD数组,每一项与AddressOfNames中的每一项对应,表示改名字的函数在AddressOfFunctions中的序号。一个经典的导出表结构如图所示:

PE导出表结构图

AddressOfNames指向一个数组,数组里保存着一组RVA,每个RVA指向一个字符串,字符串即导出的函数名,与函数名对应的是AddressOfNameOrdinals中的对应项,获取导出函数地址时,在AddressOfNames中找到对应的名字,在AddressOfNames中是第二项,从AddressOfNameOrdinals中取出第二项的值,表示函数入口保存在AddressOfFunctions这个数组中,取出其中的值,加上模块基地址便是导出函数的地址。若函数是以序号导出的,那么查找的时候直接用序号减去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导入表

上面详述了DataDirectory中第一项 IMAGE_DIRECTORY_ENTRY_EXPORT,也就是PE导出表,下一项是 IMAGE_DIRECTORY_ENTRY_IMPORT,即PE导入表。 IMAGE_DIRECTORY_ENTRY_IMPORT在PE文件加载时,根据此表的内容加载依赖的DLL,填充所需函数的地址。 IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT为绑定导入表,EntryImport导入表导入地址修正在PE加载时完成,若PE文件导入的DLL或者函数数量较多则加载变慢,故设计绑定导入,在加载前修正导入表,可以提升加载速度。 IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT为延迟导入表,PE文件中的功能越多,加载的动态资源库就越多,并非每次加载都使用所有动态资源库提供的功能,当PE文件使用对应的动态资源库时,对应DLL才被加载,甚至只有真正使用某导入函数,函数地址才会被修正。 IMAGE_DIRECTORY_ENTRY_IAT是导入地址表,举个例子:

68 98 6C 1B 30          push        offset aHttpShell ; "HTTP\\shell"
68 00 00 00 80          push        80000000h   ; hKey
C7 85 E4 FD FF FF+    	mov         [ebp+hKey], 0
FF 15 00 00 19 30       call        ds:RegOpenKeyW
85 C0                   test        eax, eax
0F 85 9A 00 00 00      	jnz         loc_300E053F

此处调用了RegOpenKeyW的导入函数,OpCode是FF 15 00 00 19 30,其中,FF 15表示间接调用,即call dowrd ptr [30190000];表示调用的地址存放在30190000中,而改地址在导入表地址范围内,即当模块加载时,PE加载器会根据导入表中描述的信息修正30190000内存块中的内容。导入表的定义如下:

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,则会得到_IMAGE_IMPORT_DESCRIPTOR结构指针,指向其类型结构数组,导入的DLL会成为数组成员,即一个结构对应一个导入的动态资源库。

  • Characteristics与OriginalFirstThunk为一个联合体,若为数组的最后一项,则Characteristics值为0,否则OriginalFirstThunk保存一个RVA,指向IMAGE_THUNK_DATA数组,数组中的每一项表示一个导入函数。
  • TimeDateStamp:映像绑定前,此值为0,绑定后为导入模块的时间戳
  • ForwarderChain:转发链,若不存在转发器,其值为-1;
  • Name:RVA,指向导入模块的名字,一个IMAGE_IMPORT_DESCRIPTOR描述一个导入的动态资源库
  • FirstThunk:RVA,指向一个IMAGE_THUNK_DATA数组。

当OriginalFirstThunk与FirstThunk都指向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的最高位为1,此时按序号导入,低16位为导入序号,最高位是0,则AddressOfData是一个RVA,指向IMAGE_IMPORT_BY_NAME结构,用以保存名字信息,由于Ordinal与AddressOfData为同一个内存空间,所以AddressOfData只有低31位可以表示RVA,单PE文件不可能超过2GB大小,最高位全取0,微软提供了两个宏定义处理序号导入:IMAGE_SNAP_BY_ORDINAL判断是否按序号导入,IMAGE_ORDINAL用来获取导入序号。OriginalFirstThunk指向的IMAGE_THUNK_DATA数组含有导入信息,数组中只有Ordinal与AddressOfData有效,可以通过OriginalFirstThunk查找函数的地址,FirstThunk在PE文件加载之前、导入表未处理之前,所指向的数组与OriginalFirstThunk中的数组不同,内容实际上是一致的,均含有导入信息,在加载之后,FirstThunk中的Function指向实际的函数地址,实际上是IAT中的位置,而IAT充当了IMAGE_THUNK_DATA数组,加载完成后,IAT项就变成了实际的函数地址,即Function。总结,导入表其实是一个IMAGE_IMPORT_DESCRIPTOR的数组,导入的动态资源库对应一个IMAGE_IMPORT_DESCRIPTORIMAGE_IMPORT_DESCRIPTOR包含两个IMAGE_THUNK_DATA数组,数组中的每一项对应一个导入函数,加载前OriginalFirstThunk与FirstThunk的数组都指向名字信息,加载后FirstThunk数组指向实际的函数地址。

延迟导入表

延迟导入表是因为存在某些动态资源库在程序运行一段时间后使用,不必在程序初加载时完成,可以大幅提升程序的启动速度,在Visual Studio中的Configuration Properties选项卡中,Linker选项卡中的Input选项,右侧菜单中的Delay Loaded Dlls,在此处填写需要延迟导入的DLL列表,链接器会自动将指定的DLL设定为延迟导入。在IMAGE_DATA_DIRECTORY中,存在IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT,此项即为延迟导入表,IMAGE_DATA_DIRECTORY.VirtualAddress指向延迟导入表的起始地址,延迟导入表数组中,每一项都是ImgDelayDescr结构体,与导入表类似,每项都代表导入表的动态资源库,其定义为:

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为旧版本,旧版本中后续rva*域使用的均为指针,新版本中使用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 offset __DELAY_IMPORT_DESCRIPTOR_WININET即上文所提的ImgDelayDescr结构,位于WinINET.dll。而后,执行call __delayLoadHelper,在此调用中执行了加载动态资源库,查找导出函数,填充导入表等一系列操作,函数结束时IAT中存放的已为真正的导入函数的地址,因为该函数返回导入函数地址,故EAX寄存器中存放的就是函数地址,所以最后的jmp eax跳转到了真实的导入函数中。_delayLoadHelper的参数中只有IAT项的偏移喝整个模块的延迟导入描述__DELAY_IMPORT_DESCRIPTOR_WININET,参数中并没有导入函数的名称,而__DELAY_IMPORT_DESCRIPTOR_WININET表中存储了所有从该模块导入函数的名字,并非当前被调用函数的函数名字。该数组中也并不存在索引号指定项数。微软实现的过程中,在__DELAY_IMPORT_DESCRIPTOR_WININET中rvaIAT,实际指向了IAT,是该模块的第一个导入函数的IAT偏移量,此时存在的两个偏移量为即将导入的函数IAT项的偏移量与要导入模块的第一个函数IAT项的偏移量,分别记作RVA0与RVA1,(RVA0-RVA1)/4 = 导入函数IAT项在rvaIAT中的下标,rvaINT中名字顺序与rvaIAT中的顺序一致,故下标也相同,这样就可以获得导入函数的名字。有了模块名和函数名,用GetProcAddress就可以获取导入函数的地址。延迟导入的加载只发生在函数第一次被调用时,之后IAT就被填充为正确的函数地址,不会走__delayLoadHelper。延迟导入一次只会导入一个函数,而不是一次导入整个模块的所有函数。参考如下流程图:

PE延迟导入表结构图

重定位

导入表与延迟导入表部分详述了常用的两种导入方式,下面看一个例子:IE浏览器的iexplorer.exe导入了Kernel32.dll中的GetCommandLineA函数,此调用为间接调用,地址为00401004,该地址中保存了目的地址,此地址存在于iexplorer.exe模块中,实际上即为IAT地址。代码中call地址是模块内的VA地址,若基地址变换,该地址是否无效?Windows使用了重定位机制保证该代码模块无论被加载到哪个基址都可以正确调用,编译器编译时可识别出使用了模块内直接VA的项,如push全局变量、函数地址等,指令的操作数在模块加载时即重定位。链接器生成PE文件时将编译器识别的重定位项记录在表中,该表就是重定位表,保存在DataDirectory中,序号为IMAGE_DIRECTORY_ENTRY_BASERELOC。PE文件加载时,PE加载器分析重定位表,将其中每一项按照现在的模块基址进行重定位。若使用这样的设计:每个重定位项是一个DWORD,保存需要重定位的RVA,只需要简单操作即可找到重定位的项。此种方案占用空间太大,若一个文件有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.

代码中使用全局变量的指令需要重定位,全局变量一定是模块内的地址使用全局变量的语句在编译后会产生一条引用全局变量基地址的指令。将模块函数指针赋值给变量或作为参数传递,因为赋值或传递参数是会产生mov和push指令,这些指令需要直接地址。C++中的构造函数和析构函数赋值虚函数表指针,虚函数表中的每一项本身就是重定位项。

常见的PE程序特征

Windows下常见的PE程序通过各种编译器编译而成,通常情况下,有VC6、VB、Delphi、BC++2010、BC++6、AutoIt-v3、.NET、VS2008、VS2010、Qt5、PB、ASM、易语言等格式,可以通过观察程序入口判断其PE程序类型。

VC6 PE入口结构

VC6程序入口点

Microsoft Visual C++ 6.0编译器编译的程序,通常入口点代码固定,一般均为push ebpmov ebp, esppush -0x1开头。下方的DWORD调用中,也可以看到msvcrt的调用。入口调用的API也相同,通过PEiD查看EP区段,通常包含.text.rdata.data.rsrc四个区段。

VC6程序PEiD

VS2008/VS2013/VS2019 PE入口结构

VS2008程序入口点

Microsoft Visual Studio 2008编译器编译的程序,通常以CALL $PROGNAME.$ADDRJMP $PRGNAME.$ADDR开头,CALL进入后调用的API是相同的,通过单步步入功能进行跳转,我们发现此时的汇编块与VC6几乎一致:

VS2008程序单步步入

通过PEiD查看EP区段,相较VC6区段,新增了.reloc区段,如图所示:

VS2008程序PEiD

Microsoft Visual Studio 2013编译器编译的程序,与VS2008基本一致,单步步入仍相似,PEiD查看EP区段则完全一致,

VS2013程序入口点

Microsoft Visual Studio 2019编译器编译的程序,与VS2008和VS2013基本一致,因少许系统库调用升级更新,导致汇编行不同。其入口结构仍以CALL $PROGNAME.$ADDRJMP $PRGNAME.$ADDR开头。

VS2019程序入口点

易语言PE入口结构

易语言编译器编译的程序,分为独立编译与非独立编译。非独立编译需要程序目录中含krnln.fnrkrnln.fne库文件。易语言的独立编译调用了Visual C++编译器,故其区段和入口代码特征与VC相同。独立编译与非独立编译的程序入口点如图所示:

易语言独立编译程序入口点

易语言非独立编译程序入口点

非独立编译程序的特征较为明显,自0x0040100B处至0x00401084处均为外部运行库文件缺失时的ASCII编码文本,通过ConcatString组合字符串与LoadLibraryA库加载函数等对krnln.fnr库文件。若看不到ASCII编码文本,可按下Ctrl+A组合快捷键或选择分析代码功能显示编码文本数据。通过查看非独立编译的模块加载,可以看到其加载了库函数文件krnln.fnr

易语言非独立编译程序模块列表

对于独立编译的易语言程序,在ModuleEntryPoint程序入口点下方偏移至0x0046C6E2的位置处,为改程序的WinMain函数程序入口,如图所示:

易语言独立编译程序函数入口

按下Enter键切换至地址0x0047A4DE处,在偏移至0x0047A4EE处的指令调用,继续切换至地址0x00482B17处,继续偏移至0x00482B5B处的指令调用,在此处按下F4运行至选定位置,此时按F7单步步入,如图所示:

易语言独立编译程序函数指令步入

此时地址切换至0x0041A6B0,在偏移至0x0041A6E7处的指令调用,继续切换至地址0x0040D5CF处,如图所示:

易语言独立编译程序函数指令步入

此处的call $PROGNAME.$ADDR为易语言初始化特征,jmp dword ptr ds:[0x48DC**]为通用调用库接口。通过观察,在下方偏移的call $PROGNAME.$ADDR指令,切换地址操作均会回到jmp dword ptr ds:[0x48DC**]库调用地址处。易语言的常见特征之一是,在执行了多条压栈指令后,通过call $PROGNAME.$ADDR指令跳转到jmp dword ptr ds:[0x48DC**]库调用地址处。

Delphi PE入口结构

Borland Delphi编译器编译的程序,通过PEiD查看EP区段,如图所示:

Delphi程序PEiD

使用Borland C++(BC++)编译器编译的C/C++程序的EP区段与Delphi相似,均含有以上区段。Delphi程序入口点如图所示:

Delphi程序入口点

此时地址切换至0x0044EDFF,即第一个函数调用处,切换至0x00405BC8处的指令调用,如图所示:

Delphi程序偏移

在地址0x00405BD4处,调用系统调用GetModuleHandleA即为Delphi程序的通用入口特征。对于类似的BC++,ModuleEntryPoint即为较大区段的jmp函数,如图所示:

BC++6程序入口点

BC++由于实际编程中使用较少,此处不再详述。

ASM 汇编PE入口结构

MASM/TASM汇编编译器编译的程序,通过PEiD查看EP区段,如图所示:

ASM程序PEiD

ASM汇编程序通常非常小,示例程序仅有7KB,程序入口点如图所示:

ASM程序入口点

ASM程序的OEP起始于0x0040108B处,于0x004010DC后不再存在汇编代码。OEP后第一行即调用GetModuleHandleA系统调用函数。

.NET PE入口结构

.NET编译器编译的程序,通过PEiD查看EP区段,如图所示:

.NET程序PEiD

若使用OllyDbg对.NET程序进行调试,程序直接启动,可以使用专门用于.NET调试的ILLY插件,在OD的插件栏中ILLY项中启用ILLY后,在工具栏中选择重新开始,即可实现程序中断。程序入口点如图所示:

.NET程序入口点

对于.NET可执行程序特征,查看其执行模块时,可发现大量.NET库调用函数加载,如图所示:

.NET程序加载模块

在入口附近处可以发现汇编行与模块加载处大量调用了mscorjit,mscorjit的全称为MICROSOFT .NET RUNTIME JUST-IN-TIME COMPILER,中文可译为微软.NET运行时即时编译器。.NET支持的语言在编译器编译后,会产出对应的程序集,主要内容为中间语言IL和元数据。JIT将IL翻译为机器码。IL使跨平台成为可能,统一框架语言编译后的形式,框架实现的代价极低。JIT即时编译是动态编译的一种形式,可以提高程序运行效率。即时编译将翻译的代码缓存以降低性能损耗,可以处理延迟绑定并增强安全性。关于JIT即时编译的内容此处不再详述。而mscorjit即为.NET程序的入口特征用以识别。

Qt PE入口结构

Qt编译器编译的程序,通过PEiD查看编译器类型,无法识别,通过ExeInfoPEScan查看编译器类型,仍无法识别。此处可使用DetectItEasy查看其EP区段信息,如图所示:

Qt程序DetectItEasy

程序入口点如图所示:

Qt程序入口点

Qt程序的入口处以push ebx起始,程序特征极为明显,Qt程序运行需大量运行库支持,查看其加载模块:

Qt程序加载模块

其中必有Qt5GuiQt5Core,若为Widgets应用,则必有Qt5Widgets外部运行库加载,在地址0x00402BE0处至0x00402D80地址处有大量的Qt函数库调用跳转:

Qt程序调用

其他的程序段也含有大量的Qt5函数库调用。

对于常见的PE文件结构,均已做分析,后续文章将对常见PE程序的加壳与脱壳进行分析。示例及文件来源均源自52破解论坛官方培训教程。