Skip to content

使用Java手撕PE

好久没发文了,我不是在写个沙箱么,现在使用java手撕了个PE,感谢大模型帮忙,水一篇文章,手撕PE结构~

1. PE文件结构概述

PE文件的结构相对复杂,主要由以下几个部分组成: - DOS头:PE文件的开头部分,包含一个简单的标识符("MZ"),用于指示这是一个可执行文件。DOS头的存在是为了向后兼容,尽管现代Windows系统几乎不再使用DOS程序。 - PE头:紧随DOS头之后,包含PE文件的基本信息,如文件类型、机器类型、节数等。PE头的标识符为"PE00"。 - 可选头:包含更详细的信息,如入口点地址、代码段大小等。可选头是PE文件的核心部分,提供了执行文件所需的关键信息。 - 节表:描述文件中各个节(如代码段、数据段等)的信息。每个节都有自己的名称、虚拟地址和原始数据大小。 - 数据目录:指向文件中其他重要数据结构的位置,如导入表、导出表和资源表。

2. 解析PE文件的基本步骤

2.1 读取文件

首先,我们需要读取PE文件的字节数据。可以使用Java的FileInputStreamByteBuffer来实现:

FileInputStream fis = new FileInputStream("example.exe");
ByteBuffer buffer = ByteBuffer.allocate((int) fis.getChannel().size());
fis.getChannel().read(buffer);
buffer.flip();
这里我们使用FileInputStream读取文件内容,并将其存储在ByteBuffer中。ByteBuffer提供了方便的字节操作方法,适合处理二进制数据。

2.2 解析DOS头

PE文件的解析从DOS头开始。我们需要检查DOS头的标识符是否为"MZ":

if (buffer.getShort(0) != 0x5A4D) { // 'MZ'标志
    throw new IllegalArgumentException("Not a valid PE file.");
}
0x5A4D是"MZ"的十六进制表示。如果文件不是以"MZ"开头,则说明它不是有效的PE文件。

2.3 定位PE头

通过DOS头中的偏移量,我们可以找到PE头的位置:

int peOffset = buffer.getInt(0x3C);
buffer.position(peOffset);
if (buffer.getInt() != 0x00004550) { // 'PE00'标志
    throw new IllegalArgumentException("Invalid PE signature.");
}
0x3C位置存储了PE头的偏移量。我们将缓冲区的当前位置设置为PE头,并检查其标识符是否为"PE00"(0x00004550),以确认文件格式。

2.4 文件头(FileHeader)

文件头位于PE头之后,通常包含以下字段: - Machine:指示目标机器的类型(例如,x86、x64等)。这是一个16位的值,表示可执行文件的目标架构。 - NumberOfSections:节的数量,表示文件中包含多少个节。也是一个16位的值。 - TimeDateStamp:文件的创建时间戳,通常用于版本控制和文件管理。是一个32位的值。 - PointerToSymbolTable:指向符号表的指针(在PE文件中通常不使用),是一个32位的值。 - NumberOfSymbols:符号的数量(在PE文件中通常不使用),是一个32位的值。 - SizeOfOptionalHeader:可选头的大小,指示可选头的字节数。是一个16位的值。 - Characteristics:文件的特性标志,指示文件的属性(如可执行、可链接等)。是一个16位的值。 解析文件头的代码示例:

private static FileHeader parseFileHeader(ByteBuffer buffer) {
  FileHeader header = new FileHeader();
  header.machine = buffer.getShort(); // 读取机器类型
  header.numberOfSections = buffer.getShort(); // 读取节的数量
  header.timeDateStamp = buffer.getInt(); // 读取时间戳
  // 跳过不需要的字段
  buffer.position(buffer.position() + 8); // 跳过PointerToSymbolTable和NumberOfSymbols
  header.characteristics = buffer.getShort(); // 读取文件特性
  return header;
}
在解析文件头时,我们使用ByteBuffer的getShort()和getInt()方法逐个读取字段。注意,某些字段(如符号表相关字段)在现代PE文件中通常不使用,因此可以跳过。

2. 5可选头(Optional Header)

可选头紧随文件头之后,包含了更详细的信息,主要包括: - Magic:标识可选头的类型(32位或64位)。是一个16位的值。 - MajorLinkerVersion:链接器的主版本号。是一个8位的值。 - MinorLinkerVersion:链接器的次版本号。是一个8位的值。 - SizeOfCode:代码段的大小。是一个32位的值。 - AddressOfEntryPoint:程序的入口点地址,指示程序开始执行的位置。是一个32位的值。 - BaseOfCode:代码段的基址。是一个32位的值。 - ImageBase:映像基址,指示程序在内存中加载的起始地址(32位和64位的处理方式不同)。32位为32位值,64位为64位值。 - SectionAlignment:节对齐方式,指示节在内存中的对齐方式。是一个32位的值。 - FileAlignment:文件对齐方式,指示节在文件中的对齐方式。是一个32位的值。 解析可选头的代码示例:

private static OptionalHeader parseOptionalHeader(ByteBuffer buffer) {
  OptionalHeader header = new OptionalHeader();
  header.magic = buffer.getShort(); // 读取魔数
  header.majorLinkerVersion = buffer.get(); // 读取主版本号
  header.minorLinkerVersion = buffer.get(); // 读取次版本号
  header.sizeOfCode = buffer.getInt(); // 读取代码段大小
  header.addressOfEntryPoint = buffer.getInt(); // 读取入口点地址
  header.baseOfCode = buffer.getInt(); // 读取代码基址
  // 区分32位和64位
  if (header.magic == 0x10B) { // 32位
    header.baseOfCode = buffer.getInt();
    header.imageBase = buffer.getInt();
  } else if (header.magic == 0x20B) { // 64位
    header.imageBase = buffer.getLong();
  }
  // 其他字段解析
  header.sectionAlignment = buffer.getInt(); // 读取节对齐
  header.fileAlignment = buffer.getInt(); // 读取文件对齐
  return header;
}
在解析可选头时,我们首先读取魔数以确定文件是32位还是64位。根据魔数的值,我们分别处理不同的字段。可选头提供了关于程序执行环境的重要信息,尤其是入口点地址和基址,这对于程序的加载和执行至关重要。

2.6导入表(Import Table)

导入表列出了可执行文件所依赖的外部DLL(动态链接库)及其函数。通过解析导入表,我们可以了解程序在运行时需要调用哪些外部函数。

导入表的结构

导入表通常包含以下几个部分: Import Descriptor:每个导入描述符包含了DLL名称和指向该DLL中函数的指针。 OriginalFirstThunk:指向导入函数名称的RVA(相对虚拟地址)。 Name:DLL的名称。 FirstThunk:指向导入函数的实际地址。

导入表解析示例

以下是解析导入表的代码示例:

public static void parseImportTable(ByteBuffer buffer, int peOffset, byte[] data) {
  int importTableRVA = buffer.getInt(peOffset + 0x80); // 获取导入表的RVA
  int importDescOffset = Utils.rvaToOffset(buffer, importTableRVA, data); // 转换为文件偏移
  while (true) {
    int originalFirstThunkRVA = buffer.getInt(importDescOffset); // OriginalFirstThunk 
    int nameRVA = buffer.getInt(importDescOffset + 12); // DLL Name RVA
    buffer.getInt(importDescOffset + 16); // FirstThunk (IAT)
    if (nameRVA == 0) break; // 终止条件   
    int nameOffset = Utils.rvaToOffset(buffer, nameRVA, data); // 获取DLL名称的偏移    
    String dllName = Utils.readNullTerminatedString(data, nameOffset); // 读取DLL名称   
    System.out.println("\nDLL: " + dllName);   
    // 解析导入函数  
    importDescOffset += 20; // 移动到下一个导入描述符
  }
}
解释:在解析导入表时,我们首先获取导入表的RVA,并将其转换为文件中的实际偏移量。然后,我们遍历每个导入描述符,读取DLL名称和函数信息。通过Utils.readNullTerminatedString方法,我们可以提取DLL的名称。

2.6 导出表(Export Table)

导出表列出了可执行文件提供的函数,供其他模块调用。通过解析导出表,我们可以了解程序的功能和接口。

导出表的结构

导出表通常包含以下几个部分: - Export Directory:包含导出表的基本信息,如函数数量、名称RVA、地址RVA等。 - Function Names:指向导出函数名称的RVA。 - Function Addresses:指向导出函数实际地址的RVA。

导出表解析示例

以下是解析导出表的代码示例:

public static void parseExportTable(ByteBuffer buffer, int peOffset, byte[] data) {
  int exportTableRVA = buffer.getInt(peOffset + 0x78); // 获取导出表的RVA
  int exportOffset = Utils.rvaToOffset(buffer, exportTableRVA, data); // 转换为文件偏移
  int numFunctions = buffer.getInt(exportOffset + 24); // 获取函数数量
  int functionNamesRVA = buffer.getInt(exportOffset + 32); // 获取函数名称RVA
  for (int i = 0; i < numFunctions; i++) {
    int nameRVA = buffer.getInt(functionNamesRVA + (i * 4)); // 获取函数名称的RVA
    int nameOffset = Utils.rvaToOffset(buffer, nameRVA, data); // 转换为文件偏移
    String functionName = Utils.readNullTerminatedString(data, nameOffset); // 读取函数名称
    System.out.println(" Exported Function: " + functionName);
  }
}
解释:在解析导出表时,我们首先获取导出表的RVA,并将其转换为文件中的实际偏移量。然后,我们读取函数数量和函数名称的RVA,遍历每个函数,提取其名称并打印。

2.7 解析节表

在PE文件中,节表(Section Table)是描述文件中各个节(如代码段、数据段等)信息的重要部分。每个节都有自己的名称、虚拟地址、原始数据大小等属性。解析节表可以帮助我们了解可执行文件的结构和内容。

节表的结构

节表通常包含以下字段: - Name:节的名称,通常是一个8字节的字符串。 - VirtualSize:节的虚拟大小,表示在内存中占用的字节数。 - VirtualAddress:节的虚拟地址,表示在内存中的起始位置。 - SizeOfRawData:节的原始数据大小,表示在文件中占用的字节数。 - PointerToRawData:指向节在文件中原始数据的偏移量。 - Characteristics:节的属性标志,指示节的特性(如可执行、可写等)。

节表解析示例

以下是解析节表的代码示例:

public static void parseSections(ByteBuffer buffer, int peOffset, byte[] data) {
  int numSections = buffer.getShort(peOffset + 6) & 0xFFFF; // 获取节的数量
  int sectionTableOffset = peOffset + 0xF8; // 节表的起始位置
  for (int i = 0; i < numSections; i++) {
    int sectionOffset = sectionTableOffset + (i * 40); // 每个节的大小为40字节
    byte[] nameBytes = new byte[8];
    buffer.position(sectionOffset);
    buffer.get(nameBytes); // 读取节名称
    String sectionName = new String(nameBytes, StandardCharsets.UTF_8).trim(); // 转换为字符串
    int virtualAddress = buffer.getInt(sectionOffset + 12); // 读取虚拟地址
    int rawSize = buffer.getInt(sectionOffset + 16); // 读取原始数据大小
    int rawOffset = buffer.getInt(sectionOffset + 20); // 读取原始数据指针
    System.out.printf("Section: %s, VA: 0x%X, Size: 0x%X, Offset: 0x%X\n", sectionName, virtualAddress, rawSize, rawOffset);
  }
}
- 获取节的数量:通过buffer.getShort(peOffset + 6)读取节的数量,peOffset + 6是节数量在PE头中的偏移位置。 - 节表的起始位置:节表的起始位置通常在PE头之后,具体位置为peOffset + 0xF8。 - 遍历节表:使用循环遍历每个节,计算每个节的偏移量,并读取节的名称、虚拟地址、原始数据大小和原始数据指针。 - 节名称:节名称是一个8字节的字符串,使用buffer.get(nameBytes)读取后,转换为UTF-8字符串并去除空格。 - 打印节信息:最后,使用System.out.printf打印每个节的详细信息,包括名称、虚拟地址、大小和偏移量。

写在最后

如果哪里有问题,可以随时滴滴我~

Comments