☰
PE文件结构完全解析:从DOS头到节表的Windows可执行文件指南
2026/10/21 21:55:52 网站建设 项目流程

1. 从零认识PE文件:这是一张让你看懂Windows可执行文件的“地图”

做安全分析、逆向工程或者底层开发的朋友,迟早都要和PE文件正面相遇。PE文件(Portable Executable,可移植可执行文件)就是Windows平台上exe、dll、sys这些可执行文件的统一容器,你可以把它理解成一套“物流包装规范”——里面装的是代码、数据、资源,但如何打包、如何解包、如何定位每一块内容,都有一套固定的规则。搞清楚这套规则,你就拿到了打开Windows可执行世界的钥匙。

这个系列我打算分几篇来讲,第一篇先把最核心的骨架梳理清楚:文件头部到底有哪些关键结构,每个字段在解析时意味着什么,节表和虚拟地址怎么换算。别的先不贪多,基础打牢了,后面看导入表、导出表、重定位、资源段这些内容,你会觉得顺理成章。

很多人一开始面对PE文件会有个误解:直接用十六进制编辑器打开一个exe,看到一堆乱码就放弃了。这很正常,因为PE文件的布局不是给人类直接阅读的,它是给Windows加载器看的。我写这篇博文的目的,就是带你把这张“地图”从头到尾铺开,告诉你哪个字节代表什么、哪个偏移决定了什么。不管你是想手动脱壳、分析恶意样本,还是想搞清楚自己编译出来的程序为什么会加载失败,这篇都会帮你省下大量摸索时间。

适合谁看?如果你已经有基本的十六进制概念,会一点Python或者C,那就够了。没接触过汇编也不影响,解析PE格式本质上是数据结构问题,不是代码逻辑问题。

2. 吃透关键的第一步:DOS头与DOS Stub

2.1 DOS头里的每个字段,哪些真正有用

每个PE文件的第一个字节是0x4D,ASCII码是'M',紧接着是0x5A,ASCII码是'Z',合起来就是“MZ”这两个字符。MZ是早期DOS可执行文件的标志,今天所有PE文件都保留这个头,目的只有一个:兼容老旧的DOS系统。如果有人在DOS下直接运行这个PE文件,系统会把它当作DOS程序来执行,然后显示一行类似“This program cannot be run in DOS mode”的提示,这就是DOS头存在的意义。

DOS头本身是一张结构体表,字段很多,但真正需要我记住的其实没几个。最关键的是文件开头两个字节的e_magic,必须等于0x5A4D,也就是MZ,解析任何PE文件前都要先校验这一点。其次就是偏移0x3C处的e_lfanew字段,它是一个4字节的32位整数,记录的是真正的PE头在文件中的起始偏移。你从文件头跳到这个位置,才会看到“PE\0\0”签名。

我用C语言结构体来描述一下DOS头的基本面貌:

typedef struct _IMAGE_DOS_HEADER { WORD e_magic; // 0x00 'MZ' WORD e_cblp; // 0x02 WORD e_cp; // 0x04 WORD e_crlc; // 0x06 WORD e_cparhdr; // 0x08 WORD e_minalloc; // 0x0A WORD e_maxalloc; // 0x0C WORD e_ss; // 0x0E WORD e_sp; // 0x10 WORD e_csum; // 0x12 WORD e_ip; // 0x14 WORD e_cs; // 0x16 WORD e_lfarlc; // 0x18 WORD e_ovno; // 0x1A WORD e_res[4]; // 0x1E WORD e_oemid; // 0x26 WORD e_oeminfo; // 0x28 WORD e_res2[10]; // 0x2A LONG e_lfanew; // 0x3C 指向PE头 } IMAGE_DOS_HEADER;

平时分析文件,e_lfanew的偏移0x3C一定不要记错。很多加壳程序会故意修改DOS头里的其他字段做混淆,但极少会破坏MZ和e_lfanew,因为Windows加载器自己也需要通过它们找到PE头。所以这两个字段可以看作PE文件的“地标”。

2.2 DOS Stub和那个“MZ”标志

DOS头之后是一段名为DOS Stub的程序代码,长度不固定,通常就是一小段16位程序,负责在DOS环境下打印那行提示信息。大部分编译器和加壳工具生成的DOS Stub内容基本一样,但也有人会在这里塞自定义数据,作为隐写的载体。说实话,做格式解析时DOS Stub本身不需要过多关心,你只需要知道它是一个变长区域,从DOS头结束开始,一直延伸到e_lfanew指向的位置。

值得多说一句的是MZ标志的校验。有些壳会改动DOS头某些字节,但e_magic永远是0x5A4D,e_lfanew也必须指向一个合法偏移。我见过一些损坏的PE文件,e_lfanew指向了文件的末尾甚至越界,这种情况下Windows加载器会直接报错。你在写解析器时也要把这个边界检查做好,别拿到一个畸形文件就崩掉。

在Windows的PE文件里,e_lfanew值通常固定是0x80,也就是文件偏移128字节。这是因为标准的DOS头是64字节,DOS Stub默认情况下也是一小段固定代码,加起来正好128字节。不过这只是常见情况,不要把这个当成硬规则,有些工具生成的PE文件e_lfanew可能是0x40或者更大的值,一切以读到的实际数值为准。

3. NT头:PE文件的真正起点

3.1 PE签名与文件头(File Header)

当你从e_lfanew读取4个字节时,应该看到“PE\0\0”,也就是十六进制0x50 0x45 0x00 0x00。这个签名表示后面的内容按照PE规范解析。紧接着就是IMAGE_FILE_HEADER,长度固定为20字节,它承载了整个可执行文件的基本属性。

IMAGE_FILE_HEADER里值得关注的字段包括Machine、NumberOfSections、SizeOfOptionalHeader、Characteristics。Machine表示目标CPU架构,常见的值有0x014c对应x86,0x8664对应x64,0x01c4对应ARM,0xaa64对应ARM64。解析文件时先看这个值,你才能决定后面可选头的大小和字段布局。NumberOfSections表示节的数量,这个值直接决定后面跟着多少个节表项。SizeOfOptionalHeader告诉你可选头有多大,因为不同架构下可选头的大小不一样,不能硬编码。

Characteristics是一个16位的标志位集合,每一位代表文件的一种属性。比如0x0002表示可执行文件有有效的代码,0x2000表示这是一个DLL而不是EXE,0x0100表示是32位机器。写解析器时,判断“这到底是不是一个DLL”最常用的方法就是检查这个字段的0x2000位。

我用一个简化的Python结构体来表示文件头:

import struct class IMAGE_FILE_HEADER: SIZE = 20 def __init__(self, data): (self.Machine, self.NumberOfSections, self.TimeDateStamp, self.PointerToSymbolTable, self.NumberOfSymbols, self.SizeOfOptionalHeader, self.Characteristics) = struct.unpack('<HHIIIHH', data)

TimeDateStamp是编译时间戳,1970年1月1日以来的秒数。虽然它经常被伪造,但做样本分析时仍然能帮你判断文件的真实编译年份,很多恶意样本会故意把时间戳改成过去的时间来干扰分析,不能全信。

3.2 可选头(Optional Header)里的关键字段解析

名字叫“可选”,实际上对PE文件来说它必须存在,而且承载了大量关键信息。它是一个可变长度的结构,在32位和64位程序里字段布局略有差异。开头是一个16位的Magic字段,32位程序的值是0x10B,64位程序是0x20B,ROM文件则是0x107。你不光要看这个字段,还要根据它来决定后续结构体的解析方式。

可选头里的字段很多,但真正决定程序能否正确加载的,我个人认为就几个:AddressOfEntryPoint、ImageBase、SectionAlignment、FileAlignment、SizeOfImage、SizeOfHeaders、Subsystem。AddressOfEntryPoint是程序入口点相对于基址的偏移,不是绝对地址。在调试器里看到的入口地址是ImageBase加上这个RVA。ImageBase则规定了程序优先加载的虚拟地址,比如32位EXE通常是0x00400000,64位EXE通常是0x0000000140000000,DLL通常对应0x10000000。需要注意的是这只是一个理想值,现代系统开启ASLR后,实际加载地址往往会被随机化。

SectionAlignment和FileAlignment这对参数特别容易让人困惑,而且它们直接关系到虚拟地址和文件偏移的换算。简单来说,SectionAlignment规定了节在内存中的对齐粒度,通常是0x1000(4KB),这与内存页大小一致。FileAlignment规定了节在磁盘文件中的对齐粒度,通常32位默认0x200,64位默认0x200,也可能被设置成0x1000。如果两者相等,文件在磁盘上的布局和内存布局就是一一对应的,换算起来非常省事;如果不相等,你就必须做一次偏移换算,这个我后面专门用一个章节来演示。

SizeOfImage表示的是整个PE映像在内存中占用的总大小,SizeOfHeaders则表示所有头部(DOS头、NT头、节表)在文件中所占的总大小,通常是0x400。Windows加载器会把文件头所在的前几个页映射成只读区域,所以SizeOfHeaders必须小于等于第一个节的文件偏移。

4. 节表与节数据:真正承载代码和资源的区域

4.1 节表结构逐个字段拆解

可选头之后是节表(Section Table),它是由IMAGE_SECTION_HEADER结构体组成的数组,数组项个数就是前面文件头里的NumberOfSections。每个结构体固定40字节,按顺序排列,描述文件中的一个“节”。

节在磁盘上是按FileAlignment对齐的连续数据块,在内存中则是按SectionAlignment对齐的映射区域。每一个节表项包含节名称、虚拟大小、虚拟地址、文件中的大小、文件中的偏移、各种特征标志。

typedef struct _IMAGE_SECTION_HEADER { BYTE Name[8]; // 节名,不是以\0结尾的字符串 union { DWORD PhysicalAddress; DWORD VirtualSize; // 节在内存中的实际大小 } Misc; DWORD VirtualAddress; // 节在内存中的RVA DWORD SizeOfRawData; // 节在文件中对齐后的大小 DWORD PointerToRawData; // 节在文件中的起始偏移 DWORD PointerToRelocations; DWORD PointerToLinenumbers; WORD NumberOfRelocations; WORD NumberOfLinenumbers; DWORD Characteristics; // 节的属性标志 } IMAGE_SECTION_HEADER;

Name字段最多8字节,注意它不一定是正常的C字符串,没有保证以\0结尾。解析时最好限定长度,别用strlen直接遍历,否则一个全满的8字节名字就可能越界。VirtualSize指的是节在内存中实际使用的大小,通常小于SizeOfRawData;SizeOfRawData则是节在文件中对齐后的字节数。这里有个细节:在编译出的PE文件中,VirtualSize不一定等于原始数据大小,甚至可能为0。Windows加载器在映射内存时,会以VirtualSize为准来设置内存区域的边界,而不是SizeOfRawData。很多人写解析器时习惯用SizeOfRawData来读数据,结果在解析最后一节时容易多读,或者读取越界。

Characteristics位标志决定节的权限,常见的有0x40000000表示可读,0x20000000表示可写,0x80000000表示可执行。代码节通常是可读可执行,数据节是可读可写,资源节只读。做防护分析时一定要看这些标志,如果一个原本应该只读的节出现可写属性,往往意味着异常行为。

4.2 常用节的分类与作用

拿一个典型的C/C++编译出的PE文件举例,常见的节有:

  • .text:存放编译后的机器代码,可读可执行,不可写。
  • .rdata:存放只读数据,比如字符串常量、虚函数表、导入表有时也在这里。
  • .data:存放可读写的全局变量和静态变量。
  • .idata:导入表,记录了这个PE文件依赖哪些外部DLL以及导入哪些函数。
  • .edata:导出表,通常只在DLL文件里出现。
  • .rsrc:资源段,包括图标、版本信息、对话框、字符串表等。
  • .reloc:重定位表,记录基址重定位信息,用于DLL被加载到非首选基址时的修正。
  • .pdata:64位程序里的异常处理表。

但要注意,这些节名只是“惯例”而不是“规定”。编译器可以随便起名,加壳工具更是会把所有有效代码集中到一个自定义名字的节里,把原来的.text、.rdata全部清空或合并。所以分析时别看到.text就以为一定是代码,也不要看到一个诡异的名字就直接怀疑是壳,先看节的Characteristics和实际内容才能下结论。

很多UPX加壳程序会生成一个叫做UPX0的节,其VirtualSize很大,但PointerToRawData和SizeOfRawData均为0,意思是内存中开辟一大块空白区域,运行时由解压代码往里面填数据。这种情况下,如果你用常规方式通过文件偏移去读取该节内容,得到的就是空数据。所以分析加壳文件时,要格外注意SizeOfRawData为0的节。

4.3 文件偏移与虚拟地址的换算:新手最容易栽的坑

你现在手里有一个RVA(相对虚拟地址),比如入口点0x10100,需要找到它对应在文件中的哪个位置,这是PE解析中最常见也最容易出错的操作。RVA是相对于ImageBase的偏移,指向内存加载后某个位置;而文件偏移是磁盘上的绝对字节位置。由于节在磁盘和内存中的对齐方式不同,绝大多数情况下两者并不相等。

换算的方法是:先遍历所有节,找到RVA落在哪一个节的范围内。判断条件是RVA大于等于该节的VirtualAddress,且小于VirtualAddress加上VirtualSize(或SizeOfRawData,看场景)。找到目标节后,用下面这个公式计算:

文件偏移 = RVA - 节.VirtualAddress + 节.PointerToRawData

反过来,从文件偏移求RVA也一样:

RVA = 文件偏移 - 节.PointerToRawData + 节.VirtualAddress

我截个典型的例子:假设某节VirtualAddress是0x1000,PointerToRawData是0x400,SizeOfRawData是0x600。有一个RVA是0x1500,落在该节内(0x1000到0x1600之间)。那么文件偏移 = 0x1500 - 0x1000 + 0x400 = 0x900。你去文件偏移0x900处读到的字节,就是运行时刻RVA 0x1500处的数据。

特别要提醒一点,判断RVA是否落在节内时,不要只比对VirtualAddress一个边界,还要考虑节的VirtualSize可能小于SizeOfRawData的情况。如果RVA在VirtualSize之外但仍在SizeOfRawData之内,理论上它并不属于该节的合法内容区域,访问它会导致越界。这种情况下,最好返回空数据,而不是强行按该节换算。

5. 手写一个极简PE解析器:验证你理解的最快方式

5.1 从磁盘读取PE文件的基础框架

理论讲再多,不如动手写个小型解析器来得实在。我用Python写一个简化版本,只需要标准库struct和os,不需要装任何第三方依赖。虽然它不像专业的PE解析工具那样完整,但用来验证上面说的头部和节表解析逻辑已经足够。

先读取整个文件,然后校验DOS头。注意文件可能很大,但对于学习场景,一次性读入内存最方便。如果是分析大型程序,建议改成按偏移读取,但代码会复杂不少。

import os import struct def read_pe_file(path): with open(path, 'rb') as f: data = f.read() # 校验DOS头 if data[:2] != b'MZ': raise ValueError('Invalid file: missing MZ header') # 读取e_lfanew e_lfanew = struct.unpack('<I', data[0x3C:0x40])[0] print(f'e_lfanew = 0x{e_lfanew:08X}') # 校验PE签名 if data[e_lfanew:e_lfanew+4] != b'PE\x00\x00': raise ValueError('Invalid PE signature') return data, e_lfanew

这里有两个细节要养成习惯。一是所有读取都使用小端格式,Windows PE文件在x86和x64上统一采用小端,但如果你要解析来自其他平台的PE文件,也要先确认这一点;二是所有偏移读取都必须做边界检查,确保要读取的区间没有超出文件长度,很多恶意样本专门制造畸形偏移让调试器崩溃。

5.2 解析DOS头和NT头

在拿到e_lfanew之后,我们先解析文件头,然后根据文件头里的SizeOfOptionalHeader去读取可选头。这里的关键是不要直接读死长度,因为不同编译器生成的PE,可选头大小可能不同。

def parse_nt_headers(data, e_lfanew): pe_offset = e_lfanew + 4 # IMAGE_FILE_HEADER 是20字节 machine, num_sections, timestamp, ptr_sym, num_sym, size_opt, characteristics = ( struct.unpack('<HHIIIHH', data[pe_offset:pe_offset+20]) ) print(f'Machine: 0x{machine:04X}') print(f'NumberOfSections: {num_sections}') print(f'SizeOfOptionalHeader: {size_opt}') print(f'Characteristics: 0x{characteristics:04X}') # 判断是否为DLL if characteristics & 0x2000: print('This file is a DLL') else: print('This file is an EXE') # 解析可选头开始处 opt_offset = pe_offset + 20 magic = struct.unpack('<H', data[opt_offset:opt_offset+2])[0] print(f'OptionalHeader Magic: 0x{magic:04X}') if magic == 0x10B: print('32-bit PE') elif magic == 0x20B: print('64-bit PE') else: print('Unknown PE format') return num_sections, opt_offset

这个阶段最常见的坑是size_opt不等于我们默认的数值。标准的32位可选头是224字节(0xE0),64位是240字节(0xF0),但如果有工具往里面填充额外的自定义数据,size_opt会更大。解析时你不要假设从opt_offset开始往后固定读224或240字节,正确做法是只读取你关心的字段,剩下的跳过,这样最安全。

5.3 解析节表并计算RVA转文件偏移

可选头解析完之后才是节表。节表的起始位置是pe_offset + 20 + size_opt。每个节表项40字节,连续排列num_sections个。我们把这部分数据拆出来,放进一个列表,方便后续查询。

def parse_section_table(data, section_table_offset, num_sections): sections = [] for i in range(num_sections): sec_start = section_table_offset + i * 40 sec_data = data[sec_start:sec_start+40] name = sec_data[:8].rstrip(b'\x00').decode('ascii', errors='replace') virtual_size, virtual_address, size_of_raw_data, pointer_to_raw_data = ( struct.unpack('<IIII', sec_data[8:24]) ) characteristics = struct.unpack('<I', sec_data[36:40])[0] sections.append({ 'name': name, 'virtual_size': virtual_size, 'virtual_address': virtual_address, 'size_of_raw_data': size_of_raw_data, 'pointer_to_raw_data': pointer_to_raw_data, 'characteristics': characteristics, }) print(f'Section[{i}] {name:8s} VA=0x{virtual_address:08X} ' f'VSize=0x{virtual_size:08X} RawPtr=0x{pointer_to_raw_data:08X} ' f'RawSize=0x{size_of_raw_data:08X} Chars=0x{characteristics:08X}') return sections

有了节表之后,实现RVA转文件偏移就非常简单了。我建议把这段函数独立出来,后面解析导入表、导出表时反复用到。

def rva_to_offset(data, sections, rva): for sec in sections: virtual_address = sec['virtual_address'] virtual_size = sec['virtual_size'] raw_size = sec['size_of_raw_data'] if virtual_address <= rva < virtual_address + max(virtual_size, raw_size): offset = rva - virtual_address + sec['pointer_to_raw_data'] if offset + 4 > len(data): raise ValueError('Offset out of file boundary') return offset return None

注意,这里我故意用max(virtual_size, raw_size)来判断范围,算是取了一个安全策略。因为有些节在磁盘上的实际数据比VirtualSize大,或者VirtualSize为0但RawSize存在,只用一边判断容易漏掉边界情况。真实场景中还要求RVA小于SizeOfImage,否则即使落在某个节的扩展范围内也可能越界,这点我就不在这一版中展开了。

5.4 手工解析过程中的常见问题与排查

第一次写解析器的人,几乎都会遇到下面几个问题,我逐个说一下排查思路。

第一个问题是读出来的节名带乱码。这个多半是因为Name字段不是以\0结尾,我前面强调过,所以解析时要用rstrip(b'\x00'),而不是decode('utf-8')一类的直接转换。另外,有些工具生成的节名根本不是合法ASCII,你会看到乱码,这时候不要慌,直接按字节输出十六进制再判断。

第二个问题是RVA转文件偏移时算出负数或者很大很离谱的数。出现这种情况基本就是RVA根本不属于任何节,或者节表解析错位。节表错位又往往是SizeOfOptionalHeader读错了。如果你发现解析出来的节表项第一项VirtualAddress居然是0xFFFFFFFF这种明显异常的值,回头检查一下可选头大小是否真的有20字节的IMAGE_FILE_HEADER偏移搞错。

第三个问题是遇到了加壳程序,节表里某个节的SizeOfRawData为0,但VirtualAddress却正常。这时候你用rva_to_offset去转换该节的地址,返回的指针会落在文件尾部甚至越界。对这种节,最好的处理方式就是返回None,然后在做导入表、导出表解析时跳过它,因为这些节在磁盘上不存在,只有运行时才被填充。

第四个问题是32位和64位PE的字段偏移混淆。很多初学者拿一个32位的解析代码去解析64位PE,结果入口点、ImageBase全部对不上。最核心的判断就是可选头的Magic字段,你必须在解析可选头的第一步就判断当前是0x10B还是0x20B,然后依据架构去读取ImageBase、SizeOfImage这些字段。64位程序的可选头里,ImageBase是8字节,而32位是4字节,后续DataDirectory的起始位置也因此不同。

6. 解读PE头时容易忽略的细节与实用经验

6.1 为什么文件对齐和内存对齐如此重要

有些人对SectionAlignment和FileAlignment不敏感,觉得都是对齐数,无所谓。但我在分析一些利用嵌入式脚本生成的PE文件时,发现他们把FileAlignment设成了0x1000,和SectionAlignment一致,这种情况下节的VirtualAddress和PointerToRawData完全相同。乍一看挺美,省去换算的麻烦,但文件体积会变得很大,因为每个节末尾都不得不用0填充到4KB对齐。所以大多数PE文件宁可让FileAlignment小一点,节省磁盘空间。

反过来,如果FileAlignment小于SectionAlignment,你就能清楚看到每个节的PointerToRawData和VirtualAddress之间有明显差异。那种差异不是错误,而是设计选择。你在做文件修复或者手工patch时,千万别直接把数据覆盖到虚拟地址对应的文件位置,要先用公式算清楚一次。

6.2 利用特征值快速判断PE文件是否有壳

虽然壳的检测不是这一篇的主角,但我可以分享一个用头部信息快速判断是否有壳的小技巧。正常的VC++编译出来的文件,入口点通常在.text节内部,地址不会距离ImageBase太远。如果入口点所在的节被标记为可写,或者入口点恰好指向了一个SizeOfRawData为0的节,那么十有八九是加壳了。UPX加壳后,入口点指向UPX1节,而UPX0节没有原始数据,这两个特征组合在一起非常好认。

另外,壳通常会把原来的导入表压缩掉,导致文件头中的导入表RVA指向一个看起来不合理的地址,或者导入表的大小为0。这个在后续解析导入表时会看到,但如果只是快速判断,看一眼节表的名字、属性、VirtualSize是不是远大于文件大小,也能得出初步结论。

6.3 解析PE文件时如何保证健壮性

这部分是给想把这个解析器用在真实样本分析上的朋友看的。真实世界里的PE文件不一定都符合规范。我在处理恶意样本时,见过把DOS头的e_lfanew故意改成一个巨大值、把节表项数量改成几百个、把VirtualSize改成0xFFFFFFFF的文件。如果你写的解析器没有边界检查,这些恶意字段轻则让程序崩溃,重则被利用来绕过检测。

我总结的几条健壮性规范:

  • 读取任何字段前,先确认该字段起始位置加长度不超过整个文件大小。
  • 节的数量不要无条件信任,最多只能解析到文件末尾所能容纳的节表项数量。
  • RVA转文件偏移时,如果得到的文件偏移不在文件大小范围内,直接放弃该次转换并返回错误。
  • 解析字符串时,要限制最大长度,防止恶意样本构造一个没有结尾的字符串导致读取到文件末尾之外。
  • 对可选头Magic,如果既不是0x10B也不是0x20B,建议中断解析而不是强行套用默认长度。

按照这几条来写,你的解析器至少在畸形文件面前不会直接崩溃。后面我们分析导入表、导出表的时候,还会继续沿用这套边界检查的思路。

6.4 解析实战:用一个真实编译的exe走一遍流程

我拿一个自己编译的小工具来做演示,它就是一个标准的x64控制台程序。打开十六进制编辑器,看前两个字节是4D 5A,确认MZ。再看偏移0x3C处,存储的值是00 80 00 00,小端解释为0x80,说明PE头在文件偏移128字节处。跳到128,看到50 45 00 00,确认PE签名。

接下来文件头里Machine字段是4C 01 03 00,小端是0x014C,按说这是x86,但我明明编译的是x64程序,怎么这里不对?我故意换了一个32位的exe来演示。对32位PE,NumberOfSections是06 00,说明有6个节。SizeOfOptionalHeader是E0 00,也就是224字节。继续看可选头Magic,是0B 01,说明32位。

然后我直接跳到节表位置:0x80 + 4 + 20 + 224 = 0x1C0。第一个节名是2E 74 65 78 74,也就是“.text”,它的VirtualAddress是00 10 00 00,说明RVA是0x1000,PointerToRawData是00 04 00 00,文件偏移0x400。这些数据和预期完全一致。入口点的RVA我是0x1105F,用公式算文件偏移:0x1105F - 0x1000 + 0x400 = 0x1045F。在这个偏移处看到的字节就是入口处的机器码,通常是E9开头的跳转指令,或者是编译器生成的启动代码。

整个过程并不神秘,就是把上文提到的每一项数据翻译成十六进制,再用公式做一个减法。等你手动走通一遍,原本看不太明白的结构体定义,自然而然就在脑海里建立了坐标感。

根据我这几年的经验,学习PE格式不是靠死记硬背,而是靠反复解析、反复报错、反复对照工具输出。建议你拿到一个自己编译的简单程序,试着写一遍上面的Python代码,然后故意改坏文件头的某些字段,看解析器的报错是否符合预期。下一次分析真样本的时候,你会发现自己明显比从前从容很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询