使用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的FileInputStream
和ByteBuffer
来实现:
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;
}
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;
}
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; // 移动到下一个导入描述符
}
}
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);
}
}
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);
}
}
写在最后
如果哪里有问题,可以随时滴滴我~