地址的概念

在解析PE文件时,必须熟练掌握以下三种地址的概念及其转换关系,这是编写PE解析器的基础:

VA (Virtual Address, 虚拟地址): PE 文件被操作系统加载到内存后的真实内存地址。

RVA (Relative Virtual Address, 相对虚拟地址): PE 文件被加载到内存后,相对于基地址(ImageBase)的偏移量。 公式:VA = ImageBase + RVA

FOA (File Offset Address, 文件偏移地址 / RAW): PE 文件在磁盘上的物理位置(用十六进制编辑器打开时看到的地址)。

RVA 与 FOA 的转换(地址换算): 由于文件在磁盘上的对齐值(FileAlignment)和在内存中的对齐值(SectionAlignment)通常不同,导致 RVA 和 FOA 并不总是相等的。

换算公式:

遍历节表,判断目标 RVA 落在哪一个节(Section)内:VirtualAddress RVA < VirtualAddress + VirtualSize

计算偏移量:Offset = RVA - 节的 VirtualAddress 计算文件偏移:FOA = 节的 PointerToRawData + Offset

主要结构体

可执行文件指的是可以由操作系统进行加载执行的文件

  • 格式
    • Windows平台:PE文件结构
    • Linux平台:ELF文件结构

如何识别

特征

分别打开.exe.dll.sys等文件,观察特征前2个字节

不要仅仅通过文件的后缀名来认定PE文件

image.png

DOS部分

DOS部分主要是为了兼容以前的DOS系统,DOS部分可以分为DOS MZ文件头(IMAGE_DOS_HEADER,64字节)和DOS块(DOS Stub)组成,PE文件的第一个字节位于一个传统的MS-DOS头部,称作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,指向PE头
  } IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;

DOS部分我们需要熟悉的是e_magic成员和e_lfanew成员,前者是标识PE指纹的一部分,后者则是寻找PE文件头的部分,除了这两个成员,其他成员全部用0填充都不会影响程序正常运行,所以我们不需要过多的对其他部分深究,DOS部分在16进制编辑器中看就是下图的部分: image.png

我们可以看到e_lfanew指向PE文件头,我们可以通过它来寻找PE文件头,而DOS块的部分自然就是PE文件头和DOS MZ文件头中间的部分,这部分是由链接器所写入的,可以随意进行修改,并不影响程序的运行: image.png

PE文件头

PE文件头由PE文件头标志,标准PE头,扩展PE头三部分组成。PE文件头标志自然是50 40 00 00,也就是’PE’,我们从结构体的角度看一下PE文件头的详细信息

typedef struct _IMAGE_NT_HEADERS {  
    DWORD Signature; 						//PE文件头标志 => 4字节  
    IMAGE_FILE_HEADER FileHeader; 			//标准PE头 => 20字节  
    IMAGE_OPTIONAL_HEADER32 OptionalHeader; //扩展PE头 => 32位下224字节(0xE0) 64位下240字节(0xF0),可改变大小  
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;

标准PE头

标准PE头结构如下,有20个字节,我们可以从PE文件头标志后20个字节找到它

typedef struct _IMAGE_FILE_HEADER {  
    WORD    Machine; 				//可以运行在什么平台上 任意:0 ,Intel 386以及后续:14C x64:8664  
    WORD    NumberOfSections; 		//节的数量  
    DWORD   TimeDateStamp; 			//编译器填写的时间戳,1970年开始
    DWORD   PointerToSymbolTable;   //调试相关  
    DWORD   NumberOfSymbols; 		//调试相关  
    WORD    SizeOfOptionalHeader;   //标识扩展PE头大小  
    WORD    Characteristics;        //文件属性 => 16进制转换为2进制根据哪些位有1,可以查看相关属性  
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;

文件属性

数据位常量符号为 1 时的含义
0IMAGE_FILE_RELOCS_STRIPPED文件中不存在重定位信息
1IMAGE_FILE_EXECUTABLE_IMAGE文件是可执行的
2IMAGE_FILE_LINE_NUMS_STRIPPED不存在行信息
3IMAGE_FILE_LOCAL_SYMS_STRIPPED不存在符号信息
4IMAGE_FILE_AGGRESSIVE_WS_TRIM调整工作集
5IMAGE_FILE_LARGE_ADDRESS_AWARE应用程序可处理大于 2GB 的地址
6此标志保留
7IMAGE_FILE_BYTES_REVERSED_LO小尾方式
8IMAGE_FILE_32BIT_MACHINE只在 32 位平台上运行
9IMAGE_FILE_DEBUG_STRIPPED不包含调试信息
10IMAGE_FILE_REMOVABLE_RUN_FROM_SWAP不能从可移动盘运行
11IMAGE_FILE_NET_RUN_FROM_SWAP不能从网络运行
12IMAGE_FILE_SYSTEM系统文件(如驱动程序),不能直接运行
13IMAGE_FILE_DLL这是一个 DLL 文件
14IMAGE_FILE_UP_SYSTEM_ONLY文件不能在多处理器计算机上运行
15IMAGE_FILE_BYTES_REVERSED_HI大尾方式

扩展PE头

扩展PE头在32位和64位系统上大小是不同的,在32位系统上有224个字节,16进制就是0xE0,结构如下,重要的属性我都有标注

typedef struct _IMAGE_OPTIONAL_HEADER {  
    //  
    // Standard fields.  
    //  
  
    WORD    Magic;						//PE32: 10B PE64: 20B  
    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;              //PE文件自身的版本号
    WORD    MinorImageVersion;              //PE文件自身的次版本号
    WORD    MajorSubsystemVersion;          //运行所需子系统版本号
    WORD    MinorSubsystemVersion;          //运行所需子系统次版本号
    DWORD   Win32VersionValue;              //子系统版本的值,必须为0
    DWORD   SizeOfImage;					//内存中整个PE文件的映射的尺寸,可比实际值大,必须是SectionAlignment的整数倍  
    DWORD   SizeOfHeaders; 					//所有的头加上节表文件对齐之后的值  
    DWORD   CheckSum;						//映像校验和,一些系统.dll文件有要求,判断是否被修改  
    WORD    Subsystem;						//子系统  驱动程序(1) 图形界面(2) 控制台、DLL(3)
    WORD    DllCharacteristics;				//文件特性,不是针对DLL文件的,16进制转换2进制可以根据属性对应的表格得到相应的属性  
    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;

程序中的扩展PE头大小在标准PE头中的显示如下图 image.png

扩展PE头在程序中显示如下,每一个属性可以通过偏移找到

image.png

还需要知道的是,程序的真正入口点 = ImageBase + AddressOfEntryPoint

数据目录表 (Data Directory) 详解

数据目录表 (Data Directory)

IMAGE_OPTIONAL_HEADER 的最后一个成员是 DataDirectory 数组,它由 16 个 IMAGE_DATA_DIRECTORY 结构体组成。它就像是 PE 文件数据结构的“目录”,指引我们找到导入表、导出表等关键信息。 最常用的几个目录索引如下:

索引 (Index)宏定义含义
0IMAGE_DIRECTORY_ENTRY_EXPORT导出表 (Export Table)
1IMAGE_DIRECTORY_ENTRY_IMPORT导入表 (Import Table)
2IMAGE_DIRECTORY_ENTRY_RESOURCE资源表 (Resource Table)
5IMAGE_DIRECTORY_ENTRY_BASERELOC重定位表 (Relocation Table)
9IMAGE_DIRECTORY_ENTRY_TLSTLS 表 (Thread Local Storage)
12IMAGE_DIRECTORY_ENTRY_IAT导入地址表 (IAT, Import Address Table)

节表

typedef struct _IMAGE_SECTION_HEADER {  
    BYTE    Name[IMAGE_SIZEOF_SHORT_NAME]; //ASCII字符串 可自定义 只截取8个字节  
    union {								   //该节在没有对齐之前的真实尺寸,该值可以不准确  
            DWORD   PhysicalAddress;  
            DWORD   VirtualSize;  
    } Misc;  
    DWORD   VirtualAddress; 			   //内存中的偏移地址  
    DWORD   SizeOfRawData;				   //节在文件中对齐的尺寸  
    DWORD   PointerToRawData;			   //节区在文件中的偏移  
    DWORD   PointerToRelocations;  
    DWORD   PointerToLinenumbers;  
    WORD    NumberOfRelocations;  
    WORD    NumberOfLinenumbers;  
    DWORD   Characteristics;			   //节的属性  
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;

程序中显示如下 image.png

值得注意的是扩展PE头中的 FileAlignment 以及 SizeOfHeaders 这两个成员,SizeOfHeaders 表示所有的头加上节表文件对齐之后的值,对齐的大小参考的就是 FileAlignment 成员,如果所有的头加上节表的大小为320,FileAlignment 为 200,那么 SizeOfHeaders 大小就为 400,因为是根据FileAlignment 对齐的,这种对齐虽然牺牲了空间,但是可以提高程序运行效率,下图中的前面部分0x00100000就是程序在内存中对齐的大小,也就是程序运行起来时对齐的大小,0x00000400是程序在文件中的对齐大小,也就是没有运行时对齐的大小,需要清楚的是,PE程序在运行时内存中的对齐值和没有运行时的对齐值可能是截然不同的,了解这一点这对我们后面写PE解析器有帮助。

image.png

节的说明

常见的区块 (Sections) 虽然节的名字(Name 字段)是可以被修改甚至抹除的,但在标准的编译器(如 MSVC)生成的 PE 文件中,通常包含以下几个具有特定用途的区块:

区块名 作用说明 .text / .code 默认的代码区块,存放程序的汇编指令。通常具有“可读”、“可执行”属性。 .data 初始化的数据块,包含程序中已初始化的全局变量和静态变量。具有“可读”、“可写”属性。 .rdata 只读数据块,通常存放字符串常量、C++ 的虚函数表、以及导入表/导出表等。 .bss 未初始化的数据块,存放未初始化的全局变量。在文件中不占用空间(SizeOfRawData 为 0),在内存中分配空间并清零。 .rsrc 资源区块,存放图标、位图、对话框、字符串表、甚至内嵌的其他 PE 文件(Drop 行为常见)。 .reloc 重定位区块,存放重定位表数据。

节数据

导入表

导出表(Import Table)和导入表是靠 IMAGE_DATA_DIRECTORY 这个结构体数组来寻找的,IMAGE_DATA_DIRECTORY 的结构如下

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

在程序中查找导出表如下图所示,因为结构体数组中每一个结构体大小为 16 位,又是扩展PE头中的最后一个成员,所以我们从节表段向上推 8 行即为我们的结构体数组开头,前 8 位是导出表的内容,因为是一个exe文件,这里刚好就没有导出表只有导入表,可以看到导入表RVA地址是0x00003700的位置

image.png 导入表的结构如下

typedef struct _IMAGE_IMPORT_DESCRIPTOR {  
    union {  
        DWORD   Characteristics;            // 0 for terminating null import descriptor  
        DWORD   OriginalFirstThunk;         // RVA 指向 INT (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;							//RVA指向dll名字,以0结尾  
    DWORD   FirstThunk;                     // RVA 指向 IAT (PIMAGE_THUNK_DATA结构数组)  
} IMAGE_IMPORT_DESCRIPTOR;  
typedef IMAGE_IMPORT_DESCRIPTOR UNALIGNED *PIMAGE_IMPORT_DESCRIPTOR;

可以看到,OriginalFirstThunk 和 FirstThunk 指向的内容分别是 INT 和 IAT ,但实际上 INT 和 IAT 的内容是一样的,所以他们指向的内容是一样的,只是方式不同而已,下图可以完美的解释

image.png

但是上图只是PE文件加载前的情况,PE文件一旦运行起来,就会变成下图的情况

image.png

我们还需要了解的结构体是 IMAGE_THUNK_DATA 和 IMAGE_IMPORT_BY_NAME 结构如下

typedef struct _IMAGE_IMPORT_BY_NAME {  
    WORD    Hint; //可能为空,编译器决定,如果不为空,是函数在导出表的索引  
    BYTE    Name[1]; //函数名称,以0结尾  
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;  
  
#include "pshpack8.h"                       // Use align 8 for the 64-bit IAT.  
  
typedef struct _IMAGE_THUNK_DATA64 {  
    union {  
        ULONGLONG ForwarderString;  // 指向一个转向者字符串的RVA  
        ULONGLONG Function;         // 被输入的函数的内存地址  
        ULONGLONG Ordinal;			// 被输入API的序数值  
        ULONGLONG AddressOfData;    // 指针指向 IMAGE_IMPORT_BY_NAME  
    } u1;  
} IMAGE_THUNK_DATA64;  
typedef IMAGE_THUNK_DATA64 * PIMAGE_THUNK_DATA64;  
  
#include "poppack.h"                        // Back to 4 byte packing  
  
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;

其实他们的作用很明显,就是用来寻找当前的模块依赖哪些函数,可以用这几个结构体求到依赖函数的名字

导出表

导出表(Export Table)一般是DLL文件用的比较多,exe文件很少有导出表,导出表的数据结构如下

typedef struct _IMAGE_EXPORT_DIRECTORY {  
    DWORD   Characteristics;  
    DWORD   TimeDateStamp;  
    WORD    MajorVersion;  
    WORD    MinorVersion;  
    DWORD   Name;					// 指针指向该导出表文件名字符串  
    DWORD   Base;					// 导出函数起始序号  
    DWORD   NumberOfFunctions;		// 所有导出函数的个数  
    DWORD   NumberOfNames;			// 以函数名字导出的函数个数  
    DWORD   AddressOfFunctions;     // 指针指向导出函数地址表RVA  
    DWORD   AddressOfNames;         // 指针指向导出函数名称表RVA  
    DWORD   AddressOfNameOrdinals;  // 指针指向导出函数序号表RVA  
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;

可以看到导出表里面最后还有三个表,这三个表可以让我们找到函数真正的地址,在编写PE格式解析器的时候可以用到,AddressOfFunctions 是函数地址表,指向每个函数真正的地址,AddressOfNames 和 AddressOfNameOrdinals 分别是函数名称表和函数序号表,我们知道DLL文件有两种调用方式,一种是用名字,一种是用序号,通过这两个表可以用来寻找函数在 AddressOfFunctions 表中真正的地址。

重定位表

当PE文件被装载到虚拟内存的另一个地址中的时候,也就是载入时不将默认的值作为基地址载入,链接器登记的哪个地址是错误的,需要我们用重定位表来调整,重定位表在数据目录项的第 6 个结构,结构如下

typedef struct _IMAGE_BASE_RELOCATION {  
    DWORD   VirtualAddress; // 重定位数据的开始 RVA 地址  
    DWORD   SizeOfBlock;	// 重定位块的长度  
//  WORD    TypeOffset[1];	// 重定位项数组  
} IMAGE_BASE_RELOCATION;  
typedef IMAGE_BASE_RELOCATION UNALIGNED * PIMAGE_BASE_RELOCATION;

重定位表的数据块 IMAGE_BASE_RELOCATION 后面紧跟着的是一个 WORD 类型的数组(常常被称为 TypeOffset 数组)。 每一个 WORD(2 字节 = 16 位)由两部分组成:

高 4 位:代表重定位的类型。在 32 位系统中,通常是 3 (IMAGE_REL_BASED_HIGHLOW);在 64 位系统中,通常是 10 (IMAGE_REL_BASED_DIR64)。如果为 0,则用于数据对齐,无实际意义。

低 12 位:代表相对于该数据块 VirtualAddress 的偏移量(Offset)。

真正的重定位地址 RVA计算公式: 目标RVA = 块的VirtualAddress + (TypeOffset & 0x0FFF)

当程序被加载到非默认基址时,操作系统会将该 目标RVA 指向的内存处的数据加上 实际ImageBase - 默认ImageBase 的差值。

TLS表

TLS(线程局部存储)主要用于多线程程序中为每一个线程分配独立的全局变量。但在安全和逆向工程领域,TLS 表非常重要,因为 TLS 回调函数(TLS Callbacks)的执行时机比程序的真正入口点(OEP, AddressOfEntryPoint)还要早

很多恶意软件或加壳程序会将反调试代码或解密代码放在 TLS 回调函数中。如果调试器直接断在 OEP,恶意代码可能早就已经执行完毕了。

typedef struct _IMAGE_TLS_DIRECTORY32 {
    DWORD   StartAddressOfRawData;
    DWORD   EndAddressOfRawData;
    DWORD   AddressOfIndex;             // PDWORD
    DWORD   AddressOfCallBacks;         // PIMAGE_TLS_CALLBACK * (指向回调函数数组的指针)
    DWORD   SizeOfZeroFill;
    DWORD   Characteristics;
} IMAGE_TLS_DIRECTORY32;

其中 AddressOfCallBacks 是一个指向 VA(注意:不是 RVA)的数组指针,数组中的每一个元素都是一个函数指针,以 NULL 结尾。

从你的两张截图来看,你正在写的是 “空白区添加代码” 这个主题,已经有了基本的框架标题:

  • 构造写入的代码
  • 在PE的空白区构造一段代码
  • 修改入口地址为新增代码
  • 跳回入口地址

这是 PE 文件修改(代码注入)的经典步骤,我来帮你把这个章节完整补充好:


空白区添加代码

PE 文件中每个节(Section)在文件中都按照 FileAlignment 进行对齐,这意味着节的实际数据结束后,到下一个对齐边界之间往往存在大量以 0x00 填充的空白字节,我们可以利用这些空白区域写入我们自己的代码。

整体流程分为以下四步:

  1. 构造要写入的代码(ShellCode)
  2. 在 PE 文件的空白区中写入构造好的代码
  3. 修改程序入口点(AddressOfEntryPoint)指向新增的代码
  4. 在新增代码末尾跳回原始入口点,保证程序正常执行

构造写入的代码

我们要写入的是一段机器码(ShellCode),以调用 MessageBox 弹窗为例,构造如下:

6A 00 6A 00 6A 00 6A 00 E8 [相对地址1] E9 [相对地址2]

各字节含义对照:

机器码汇编指令说明
6A 00push 0MessageBox 第4个参数 uType = 0
6A 00push 0MessageBox 第3个参数 lpCaption = NULL
6A 00push 0MessageBox 第2个参数 lpText = NULL
6A 00push 0MessageBox 第1个参数 hWnd = NULL
E8 XX XX XX XXcall MessageBoxA调用 MessageBoxA 函数

E8 指令的相对地址计算公式:

相对地址1 = 目标函数的 VA - E8 指令当前所在的 VA - 5

解释:E8 是近调用指令,其后跟随的 4 字节是相对偏移量(有符号数),CPU 执行 call 时会从下一条指令的地址(即 E8所在地址 + 5)加上这个偏移量,跳转到目标函数。 因此:

偏移量 = 目标函数VA - (E8所在VA + 5) 即:偏移量 = 目标函数VA - E8所在VA - 5


在PE的空白区构造一段代码

如何找到空白区:

  1. 打开 PE 文件(用十六进制编辑器,如 010 Editor / HxD)。
  2. 遍历节表,找到各节结束位置之后、下一个对齐边界之前的 0x00 填充区域。
  3. 确认空白区大小足够容纳我们的 ShellCode。
  4. 记录该空白区的文件偏移 (FOA),同时换算出它对应的 RVA(用于后续计算跳转地址):

    RVA = 节的 VirtualAddress + (FOA - 节的 PointerToRawData)

将构造好的机器码写入该文件偏移处:

  • 将 ShellCode 逐字节填入空白区。
  • 注意此时要先用占位符(如 00 00 00 00)填入 E8 后面的相对地址,等写入位置确定后再回填计算好的值。

修改入口地址为新增代码

IMAGE_OPTIONAL_HEADERAddressOfEntryPoint 字段中,存放的是原始程序入口点的 RVA

我们需要将其修改为第二步中空白区的 RVA,让程序启动后先跳转到我们写入的代码执行。

定位 AddressOfEntryPoint 的方法:

  1. 通过 e_lfanew 找到 IMAGE_NT_HEADERS 起始位置。
  2. 跳过 4 字节的 SignaturePE\0\0)和 20 字节的 IMAGE_FILE_HEADER(标准PE头)。
  3. 此后即为 IMAGE_OPTIONAL_HEADER,其中第 4 个 DWORD 字段(偏移 +0x10 处)即为 AddressOfEntryPoint
  4. 将该字段的值修改为新代码所在的 RVA。

跳回原始入口地址

我们的新增代码执行完毕后,必须跳回到程序原始的入口点(OEP),否则程序无法正常运行。

E9 指令的相对地址计算公式与 E8 相同:

相对地址2 = OEP的VA - E9指令所在的VA - 5

其中:

  • OEP 的 VA = ImageBase + 原始的 AddressOfEntryPoint(修改之前的值,需提前记录)
  • E9 指令所在的 VA = ImageBase + E9 所在位置的 RVA

完整的新增代码结构(以 MessageBox 为例):

6A 00               ; push 0       (uType)
6A 00               ; push 0       (lpCaption)
6A 00               ; push 0       (lpText)
6A 00               ; push 0       (hWnd)
E8 XX XX XX XX      ; call MessageBoxA  (相对地址 = MessageBoxA_VA - 当前VA - 5)
E9 XX XX XX XX      ; jmp OEP           (相对地址 = OEP_VA - 当前VA - 5)

⚠️ 注意事项

注意点说明
空白区大小确保空白区字节数 ≥ ShellCode 总长度,否则会覆盖到下一节的数据造成程序崩溃
地址类型AddressOfEntryPoint 填写的是 RVA,不是 VA,也不是 FOA
相对地址符号E8/E9 后面的相对偏移是有符号 32 位整数(小端序),向前跳时结果为负数,写入时需注意字节序
DEP/ASLR现代系统开启了 DEP(数据执行保护)和 ASLR(地址随机化),直接修改 PE 文件的此类操作在实际运行时可能受到保护机制拦截,通常用于学习和CTF环境