1-PE的相关数据结构

这一小节理论性很强,如果看不懂也不要担心,通过后面的实际技术的讲解和实操之后再回过头来看这一小节的内容会豁然开朗。在本小节只要求你了解每个概念的定义即可,有个印象就行了。

推荐读者去学习一波操作系统的知识,这些知识会帮助你快速理解我将在下文提到的概念与数据结构。

一、基本概念

名称
描述

逻辑地址(虚拟地址)

虚拟地址是程序运行时使用的地址,他不是在内存的物理地址。它的主要优势是①虚拟地址使得每个进程都可以拥有自己的独立地址空间,从而提高了安全性和稳定性;②提高了灵活性,方便操作系统管理;③可以扩大地址空间;④程序员可以在不考虑物理内存布局的情况下编写代码。我们在各种逆向工具中看到的都是虚拟地址

程序镜像文件(Program Image File)

程序镜像文件(Program Image File)是指包含一个程序的可执行代码及其相关资源的文件。程序在运行时他们常常被直接“复制”到内存。常见的可以映像文件的有EXE 文件为代表的 “可执行文件”、以DLL文件为代表的“动态链接库”

RVA

英文全称 Relatively Virtual Address。偏移(又称“相对虚拟地址”),是相对于镜像基址的偏移量

映像基址

映像基址(ImageBase)就是一个文件被映射到内存中的基址。

VA

虚拟内存地址(Virtual Address)PE 文件被操作系统加载进内存后的地址。VA = ImageBase + RVA

注:镜像和映像都是一个意思,只是对“image”这个英文的不同翻译

二、PE文件结构

PE( Portable Execute)文件是Windows下可执行文件的总称,常见的有 DLL,EXE,OCX,SYS 等。其文件的结构一般来说:从起始位置开始依次是 DOS头NT头节表 以及 具体的节。如下图所示。

PE文件结构里面有很多有用的信息,木马、免杀、病毒都需要了解 PE 结构。有一些比较高端的玩法,比如 进程镂空注入(Process Hollowing) 中有所涉及。

2.1 PE文件的执行顺序

  1. 检查DOS头

    • 当一个PE文件被执行时,Windows的PE装载器首先会检查DOS头(DOS Header)。DOS头的结构中包含一个指向PE头(PE Header)的偏移量。PE头是PE文件格式的核心,包含了关于文件的基本信息。

  2. 跳转到PE头

    • 如果找到有效的PE头偏移量,装载器会跳转到该位置,开始解析PE头。

  3. 验证PE头

    • 在PE头中,装载器会检查PE文件的有效性,包括检查“PE\0\0”标识符、机器类型、时间戳等信息。如果PE头有效,装载器将继续处理。

  4. 跳转到PE头尾部

    • 装载器会跳过PE头的内容,直接跳转到PE头的尾部,接下来会读取节表(Section Table)。

  5. 读取节表

    • 节表包含了所有节段的信息,如节名、虚拟地址、大小、读写权限等。装载器会遍历节表,获取每个节段的相关信息。

  6. 文件映射

    • Windows使用文件映射机制将PE文件的节段映射到进程的虚拟地址空间。

  7. 设置节段属性

    • 在映射节段到内存时,装载器会根据节表中指定的属性(如可读、可写、可执行)来设置每个节段的内存保护属性。

  8. 处理导入表

    • 映射完成后,装载器会继续处理PE文件中的导入表(Import Table)。导入表包含了程序依赖的其他模块(DLL)的信息,以及所需的导出函数。装载器会根据导入表加载所需的DLL,并解析其中的函数地址。

  9. 执行入口点

    • 最后,装载器会找到程序的入口点(Entry Point),并开始执行程序的代码。

了解PE结构和PE文件的执行顺序是很用的。比如说,在 DLL反射注入 中,由于我们是自实现的PELoder,所以要自己在代码中实现上述提到的步骤。

2.2 PE文件存储与加载的差异

  • 硬盘上的PE文件

    • PE文件在硬盘上以文件的形式存储,包含多个节(Section),如代码节(.text)、数据节(.data)、资源节(.rsrc)等。这些节在文件中是顺序排列的,且可以是连续的。

  • 内存中的PE文件

    • 当PE文件被加载到内存中时,操作系统会根据内存管理的要求对这些节进行处理。内存是以页(通常为4KB或更大的块)对齐的,即分配给一个节的内存大小是页的整数倍。因此在加载时,若一个节的大小不足一页时,可能会出现“空洞”或填充区域,这是由于内存页的对齐要求和节的大小不一定完全匹配所导致的。

例子:假设有一个PE文件,其中一个代码节的大小为3KB,另一个数据节的大小为2KB。在加载到内存时,操作系统可能会为代码节分配一个完整的4KB页,随后为数据节分配另一个完整的4KB页。这将导致在内存中产生3KB的空洞。下面给一个图解释

2.3 文件映射

刚刚提到了PE文件的加载,其实它其实就是文件映射。

文件映射(File Mapping)

  1. 是一种在操作系统中将文件内容映射到进程的虚拟地址空间的技术。它是一种 直接将进程的用户私有地址空间(虚拟地址空间)中的一部分区域与文件对象建立起映射关系,就好像直接从内存中读、写文件一样,速度比传统的I/O快了不少,但是最终还是要读写硬盘的。

  2. 实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必再调用 read、write 等系统调用函数。

  3. 当且仅当真正执行到某个内存页中的指令或者访问某一页中的数据时,若这个页面不在内存中,就是发生缺页异常(也称缺页故障)这个页面才会被从磁盘提交到物理内存,这种机制使文件装入的速度和文件大小没有太大的关系。如果想详细了解这个页面管理机制,可以去看操作系统中的存储管理章节中的请求分页管理方式,这里不过多展开。

应用:后面会专门有几节教程介绍它的应用,就是 映射注入以文件映射远程进程注入,当然它的应用远不止这些,更高级的玩法等待你自己去探索。

其映射过程可以参考下面的图片

2.4 PE文件字段详解

注:学习字段的含义是很枯燥的过程,建议在用的时候去官网或者其他教程去了解个字段的含义和使用。

(一)DOS头

组成:DOS头由MZ文件头和Dos Stub两部分组成。无论是32位或64位可执行文件,其文件的头部必定是IMAGE_DOS_HEADER

MZ头IMAGE_DOS_HEADER 结构体,其大小占64个字节。

MZ头字段如下

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;
//一个word是对应2个字节,该结构体大小为64个字节,因为里面定义了两个word型数组。

其中最重要的是

  1. e_magic:值是一个常数 0x4D5A(小端序),用文本编辑器查看该值位对应的ASCII字符串是‘MZ’,可执行文件必须都是'MZ'开头。

  2. e_lfanew:用来表示 DOS头之后的 NT头相对文件起始地址的偏移量。这可太重要了,有了这个我们就可以根据偏移计算出NT头的信息,然后读取节表的信息。

DOS stub:dos存根,在IMAGE_DOS_HEADER和IMAGE_NT_HEADERS之间存在一DOS存根。PE文件是运行在32位或64位操作系统下的。其功能是当该EXE运行在16位环境下,输出一段文字:“This program cannot be run in DOS mode”,然后并退出该进程。

(二)NT头

PE Header:是PE相关结构NT映像头(IMAGE_NT_HEADER)的简称,其中包含许多PE装载器用到的重要字段。执行体在支持PE文件结构的操作系统中执行时,PE装载器将从IMAGE_DOS_HEADER结构中的e_lfanew字段里找到PE Header的起始偏移量,加上基址得到PE文件头的指针。 ★★★ NTHeader = ImageBase + dosHeader->e_lfanew

PE头的数据结构被定义为IMAGE_NT_HEADERS。包含三部分,其结构如下:

typedef struct IMAGE_NT_HEADERS{  
     DWORD Signature;  
     IMAGE_FILE_HEADER FileHeader;  
     IMAGE_OPTIONAL_HEADER32 OptionalHeader;  
}IMAGE_NT_HEADERS,*PIMAGE_NT_HEADERS;   
  1. Signature:将文件标识为 PE 映像的 4 字节签名。字节为“PE\0\0”。这个字段是PE文件的标志字段,通常设置成00004550h,其ASCII码为PE00,这个字段是PE文件头的开始,前面的DOS_HEADER结构中的字段e_lfanew字段就是指向这里

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: 可执行文件的属性,可以是下面这些值按位相或。

  1. IMAGE_FILE_HEADER:共20字节的数据,其结构如下:

typedef struct _IMAGE_FILE_HEADER {  
   WORD    Machine;                    //运行平台  
   WORD    NumberOfSections;           //文件的区块数目  
   DWORD   TimeDateStamp;              //文件创建日期和时间  
   DWORD   PointerToSymbolTable;       //指向符号表(用于调试)  
   DWORD   NumberOfSymbols;            //符号表中符号个数(用于调试)  
   WORD    SizeOfOptionalHeader;       //IMAGE_OPTIONAL_HEADER32结构大小  
   WORD    Characteristics;            //文件属性  
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;  
  1. IMAGE_OPTIONAL_HEADER结构:是一个可选的机构,实际上IMAGE_FILE_HEADER结构不足以定义PE文件属性,因此可选映像头中定义了更多的数据。 总共224个字节,最后128个字节为数据目录(Data Directory) ,其结构如下

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  
  • MajorLinkerVersionMinorLinkerVersion:链接器的版本号。

  • 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:所需操作系统的版本号,随着操作系统版本越来越多,这个好像不是那么重要了。

  • MajorImageVersionMinorImageVersion:映象的版本号,这个是开发者自己指定的,由连接器填写。

  • MajorSubsystemVersionMinorSubsystemVersion:所需子系统版本号。

  • Win32VersionValue:保留,必须为0。

  • SizeOfImage:映象的大小,PE文件加载到内存中空间是连续的,这个值指定占用虚拟空间的大小。

  • SizeOfHeaders:所有文件头(包括节表)的大小,这个值是以FileAlignment对齐的。

  • CheckSum:映象文件的校验和。

  • Subsystem:运行该PE文件所需的子系统,可以是下面定义中的某一个:

  • SizeOfStackReserve:运行时为每个线程栈保留内存的大小。

  • SizeOfStackCommit:运行时每个线程栈初始占用内存大小。

  • SizeOfHeapReserve:运行时为进程堆保留内存大小。

  • SizeOfHeapCommit:运行时进程堆初始占用内存大小。

  • LoaderFlags:保留,必须为0。

  • NumberOfRvaAndSizes:数据目录的项数,即下面这个数组的项数。

下面介绍比较重要的几项

  • DataDirectory:数据目录,这是一个数组。

  • VirtualAddress:是一个RVA。

  • Size:是一个大小。

typedef struct _IMAGE_DATA_DIRECTORY {
    DWORD   VirtualAddress;
    DWORD   Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;

DataDirectory数组的每一项的内容如下,都是C语言的宏定义。

#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

(三)导出表

导出表(Export Table):是 Windows PE(Portable Executable)文件格式中的一个重要数据结构,主要用于描述一个动态链接库(DLL)所提供的可供外部调用的函数和数据

(四)导入表

导入表(Import Table):是Windows可执行文件中的一部分,导入表的地址是由DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress提供, 它记录了一个程序或DLL所需调用的外部函数(或API)的名称,以及这些函数在哪些动态链接库(DLL)中可以找到。在Win32编程中我们会经常用到导入函数,导入函数就是程序调用其执行代码又不在程序中的函数,这些函数通常是系统提供给我们的API,在调用者程序中只保留一些函数信息,包括函数名机器所在DLL路径。

当程序需要调用某个函数时,它必须知道该函数的名称和所在的DLL文件名,并将DLL文件加载到进程的内存中。导入表就是告诉程序这些信息的重要数据结构。一般来说导入表的数据结构如下:

  1. Import Lookup Table:通常被称为ILT记录了程序需要调用的外部函数的名称,每个名称以0结尾。如果使用了API重命名技术,这里的名称就是修改过的名称。

  2. Import Address Table:通常被称为IAT记录了如何定位到程序需要调用的外部函数,即每个函数在DLL文件中的虚拟地址。在程序加载DLL文件时,IAT中的每一个条目都会被填充为实际函数在DLL中的地址。如果DLL中的函数地址发生变化,程序会重新填充IAT中的条目。

  3. Import Directory Table:通常被称为IDT记录了DLL文件的名称、ILT和IAT在可执行文件中的位置等信息

总结:ILT记录API名称,IAT记录API在DLL的虚拟地址,IDT记录了DLL文件的名称、ILT和IAT的位置。

②导入表的DESCRIPTOR的定义 那么导入表里到底记录了那些信息,如何根据这些信息修正 IAT ( 导入地址表 ) 呢?我们一起来看一下导入表的一个 DESCRIPTOR(导入描述符)的定义:

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;
  1. DUMMYUNIONNAME

    • Characteristics: 如果该值为 0,表示这是导入描述符的终止标志(即没有更多的导入描述符)

    • OriginalFirstThunk: 如果该值不为 0,表示原始未绑定的 ILT 的 RVA(相对虚拟地址),指向 IMAGE_THUNK_DATA 结构,包含导入的函数名称或序号,这个数组中的每一项表示一个导入函数。

  2. TimeDateStamp:映象绑定前,这个值是0,绑定后是导入模块的时间戳。

  3. ForwarderChain:转发链,如果没有转发器,这个值是 -1 。

  4. Name:一个 RVA,指向导入模块的名字,所以一个 IMAGE_IMPORT_DESCRIPTOR 描述一个导入的DLL。

  5. FirstThunk:该字段是一个 RVA,指向 IAT。如果绑定,则 IAT 包含实际的函数地址。操作系统将使用这个地址来调用导入的函数。也指向一个 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;

注:

  • OriginalFirstThunk 主要用于在 程序加载时 查找导入的函数。它提供了函数的原始信息(名称或序号),允许操作系统在运行时解析这些函数的地址。

  • FirstThunk程序调用导入的函数时,它使用 FirstThunk 中的地址进行调用。这个字段在程序运行时被填充为实际的函数地址,是由PELoader负责的。

  • OriginalFirstThunkFirstThunk 他们指向的不是同一个 IMAGE_THUNK_DATA 数组。OriginalFirstThunk 指向的 IMAGE_THUNK_DATA 数组包含导入信息,在这个数组中只有 OrdinalAddressOfData 是有用的,因此可以通过 OriginalFirstThunk 查找到函数的地址。FirstThunk则略有不同,在PE文件加载以前或者说在导入表未处理以前,他所指向的数组与 OriginalFirstThunk 中的数组虽不是同一个,但是内容却是相同的,都包含了导入信息,而在加载之后,FirstThunk 中的 Function 开始生效,他指向实际的函数地址,因为FirstThunk 实际上指向 IAT 中的一个位置。

下图是加载前

下图是加载后

小结:

  1. 导入表的地址是由DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress提供

  2. 每一个被导入的DLL对应一个 IMAGE_IMPORT_DESCRIPTOR,而 IMAGE_IMPORT_DESCRIPTOR 是导入表的表项

  3. IMAGE_IMPORT_DESCRIPTOR 包含两个 IMAGE_THUNK_DATA 数组,数组中的每一项对应一个导入函数

  4. 加载前 OriginalFirstThunkFirstThunk 的数组都指向名字信息,加载后 FirstThunk 数组指函数在DLL的虚拟地址(VA)。

  5. IMAGE_IMPORT_BY_NAME 是一个结构体。相应函数的 IMAGE_IMPORT_BY_NAME 组合成一个数组,用于存放函数名称,支持名称导入函数地址。AddressOfData 表示了相应函数的 IMAGE_IMPORT_BY_NAME 在整个PE文件的偏移量是多少。即 pImgImportByName = (PIMAGE_IMPORT_BY_NAME)(pebase + pOriginalFirstThunk->u1.AddressOfData);

应用:在 反射DLL注入 中,有一步操作就是修复导入表。一旦各个节被加载到正确的虚拟地址中,所有的相对虚拟地址(RVA)就开始有意义了。因此,在这里我们可以开始修复导入目录(Import Directory):遍历我们反射 DLL 需要操作的所有 DLL 列表,导入它们,并根据我们在内存中获得的位置调整每个函数的 RVA。基本上将所有的 RVA 转换为 VA(虚拟地址),即 VA = ImageBase + RVA。

(五)重定位

定义:程序在加载之前,按照规定应该要占据这个地址,但是出于某种原因,现在这个地址不能给程序用了,程序必须转移到别的地址,这使得所有这些嵌入的地址无效。为了解决这个加载问题,一个包含所有这些需要调整的嵌入地址的列表被存储在PE文件的一个专门表中,称为重定位表(Relocation Table)。这个表位于.reloc节的一个数据目录中。

步骤

  1. 获取重定位表:通过 IMAGE_NT_HEADERS 结构中的 DataDirectory 字段获取重定位表的地址和大小。

  2. 计算偏移量:计算实际加载地址与原始基址之间的差异(delta)。

  3. 遍历重定位表:检查每个重定位块,获取需要重定位的地址,并根据重定位类型进行调整。

  4. 处理不同的重定位类型:根据需要处理不同类型的重定位(如 IMAGE_REL_BASED_HIGHLOWIMAGE_REL_BASED_ABSOLUTE 等)。

这里不展开说明,在 反射DLL注入 中,有一步操作就是修复重定位表,到那时我们以代码的方式详细讲解。

三、线程上下文

定义:在现代操作系统中,线程(thread)作为CPU调度的基本单位,每次调度就是线程上下文的切换。线程上下文就是表示线程信息的一系列东西,包括各种变量寄存器以及进程的运行的环境。这样,当进程被切换后,下次再切换回来继续执行,能够知道原来的状态

进程上下文切换大致步骤

  1. 保存当前进程的上下文:将当前进程的寄存器值、程序计数器、内存状态等信息保存到其 PCB 中。

  2. 选择下一个进程:根据调度算法选择下一个要执行的进程。

  3. 加载下一个进程的上下文:从下一个进程的 PCB 中加载寄存器值、程序计数器等信息。

  4. 更新状态:更新当前进程和下一个进程的状态,确保它们在正确的状态下执行。

③PCB和PEB

特征
PCB
PEB

定义

进程控制块,用于管理进程的状态和信息。

进程环境块,存储进程运行时环境信息。

作用

主要用于进程调度和状态管理。

提供进程执行所需的环境和模块信息。

存在形式

每个进程都有一个 PCB,存在于内存中。

每个进程都有一个 PEB,存在于内存中。

组成部分

包含 PID、状态、程序计数器、寄存器等。

包含进程参数、图像基址、已加载模块等。

使用场景

操作系统在调度进程时使用。

操作系统和进程在运行时访问环境信息。

应用:在设置 上下文劫持注入 中我们通过进程上下文获取 EIP/RIP 的值,这样可以直接劫持cpu的 EIP/RIP 寄存器,使其直接指向我们的恶意代码;或者在 进程镂空注入 中,我们可以获取 EBX/RDX 寄存器的值,根据 Windows 的调用约定,PEB 的地址通常存储在 EBX/RDX 寄存器中,进一步根据PEB获取特定进程的映像基址(ImageBase)

在注入中常用寄存器的调用约定

// x64 注入
ctx.Rcx = 入口点地址  
ctx.Rdx = PEB地址
ctx.Rip = 入口点

// x86 注入 
ctx.Eax = 入口点地址
ctx.Ebx = PEB地址
ctx.Eip = 入口点

四、PEB

参考文章

1、PEB及其武器化 - 先知社区

2、Vergilius Project | x86

一说到进程,有一个极其重要的数据结构不得不提起,那就是PEB(Process Environment Block,进程环境控制块)

PEB:是Windows操作系统中用于管理进程的一个数据结构。它包含了与进程相关的所有信息,比如进程的环境变量、进程的状态、内存管理信息、句柄信息等。其中非常关键的信息就是进程的映像基址

作用:我们利用 PEB 可以完成很多事情,比如说 动态获取 api进程伪装反调试 等等。后面也会详细讲解。

如何查看PEB的结构呢,这就需要用到Windows的调式工具windbg。windbg是非常的强大windows调式工具,也是windows开发的必备工具。

官方教程:开始使用 Windows 调试 - Windows drivers | Microsoft Learn

工具下载:安装 WinDbg - Windows drivers | Microsoft Learn

使用

  1. 配置符号服务器

//微软官方符号服务器
srv*D:\Symbols*http://msdl.microsoft.com/download/symbols

//但是由于网络等原因,经常导致符号库下载失败等,可以使用下面的镜像
srv*D:\Symbols*http://msdl.blackint3.com:88/download/symbols
  1. 打开一个notepad程序

  1. 可以进行调式了。 官方文档:使用调试器命令 - Windows drivers | Microsoft Learn

我们就看一下进程的peb,输入命令 dt _peb

可以看到从PEB起始地址偏移0x10的位置存放着进程的映像基址的地址。

五、总结

PE文件结构是关于一个程序在运行前和加载时的重要数据结构,定义程序的执行方式和资源管理。

线程上下文是用于管理进程的状态和信息。

PEB是 Windows 操作系统中用于存储进程环境信息的数据结构。每个进程都有一个 PEB,包含了与该进程相关的各种信息。

如果只是想当脚本小子,这三个数据结构你可以只了解或者说不了解,都可以。但是想要玩好免杀,这三个数据结构是必须要熟悉的。

Last updated