想象你买回一大盒乐高积木,说明书上写着“618片,适合6-99岁”。打开盒子,里面是一张总图纸:先确认这块积木该装在哪,然后按页码翻到对应页,照着图纸一块块拼起来。Windows系统加载和运行程序的时候,面对的也是一份“乐高说明书”——PE文件。PE(Portable Executable)是Windows可执行文件的标准格式,它从DOS时代的MZ格式进化而来,又基于Unix的COFF通用对象格式做了扩展。整份说明书有封面(DOS头)、目录(PE头)、页码索引(节表)、零件清单(导入表)和地址簿(重定位表)。今天,我们就用这份乐高说明书当地图,把PE文件从头到尾拆开看个明白。

岗位关联

掌握PE/COFF格式,是AI安全分析师、系统工具开发者的基本功——它能帮你读懂恶意软件的行为、手工修复崩溃的二进制文件,是“反汇编与系统工具”栈上最硬核的一块地基。

目录
  1. 可视化演示
  2. PE文件总览
  3. COFF与可选头
  4. 节表与节区
  5. 数据目录
  6. 导入表机制
  7. 导出表机制
  8. 重定位机制
  9. RVA转文件偏移
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

展示系统处理流水线、实时性能指标曲线及多方案对比分析,包含数据流转、吞吐延迟监控与方案评估。

本节配有交互动效(进入视口后自动加载,约 3KB)。

系统可视化 处理流程:输入→处理→模型→输出 性能监控:实时指标 P=L×T

PE文件总览

翻开乐高说明书的第一页,你首先看到的是产品名称和型号——这是“封面”。PE文件的封面叫DOS头,它的最前面有两个字节的魔数(Magic Number):"MZ",这是DOS时代创始人Mark Zbikowski的缩写。任何一个真正的PE文件,文件开头的这两位都必须是4D 5A。如果打开一个文件发现前两字节不是MZ,那它八成不是Windows可执行文件,就像说明书封面印错了书名,后面内容再精致也没用。

DOS头最重要的字段在文件偏移0x3C处,叫e_lfanew。它是一个4字节的小端整数,存的是“PE头在文件里的位置”。为什么需要它?因为DOS头后面还跟着一段DOS存根(DOS Stub),用于在纯DOS环境下运行程序并输出“此程序无法在DOS模式下运行”的提示。这段存根长度不固定,所以必须靠一个指针精确定位真正的PE头。

拿到e_lfanew指针,就像说明书翻到目录页——接下来,COFF头和可选头这片“总纲”就会逐一展开。有了封面和目录,这份说明书才算真正开始为人所用。

PE头偏移量=read_u32(Offset 0x3C)\text{PE头偏移量} = \text{read\_u32}(\text{Offset } 0x3C)
文件偏移0x3C处存放的4字节小端整数,就是PE头所在位置。
  1. 确定读取位置从文件开头数到第0x3C这个字节,它是DOS头中的e_lfanew字段。
    Offset 0x3C\text{Offset } 0x3C
  2. 读取4字节用小端字节序读取一个无符号32位整数,得到PE头在文件中的偏移。
    read_u32(Offset 0x3C)\text{read\_u32}(\text{Offset } 0x3C)
  3. 定位PE头跳转到该偏移处,验证接下来的4字节是否为PE签名\x50\x45\x00\x00。
    0x000045500x00004550

COFF与可选头

说明书翻过封面页,接下来是“规格总览”:这个套装适合多大年龄、包含多少片零件、有没有特殊部件。PE头由三部分组成:4字节签名(“PE\0\0“)、20字节COFF头、以及一个长度不定的可选头(Optional Header)。名称虽然叫”可选“,但对可执行文件来说它必须有,就像乐高说明书上说”部分套装含专属人仔”,实际每盒都有。

COFF头里最关键的两个字段:Machine(机器类型)和NumberOfSections(节的数量)。Machine字段决定了这个二进制文件是给哪种CPU用的——0x014C代表x86,0x8664代表x64,0xAA64代表ARM64。如果拿错了,Windows加载器会直接拒绝运行,就像给汽车加错了标号的汽油,发动机就是不转。

可选头里住着几个“大人物”:ImageBase(内存基址,比如exe通常是0x400000,DLL是0x10000000)、AddressOfEntryPoint(程序入口点RVA)、SectionAlignment(内存中的节对齐粒度)和FileAlignment(文件中的对齐粒度)。它们联手决定了程序加载到内存后的“家”在哪里、地方多大。

请记住NumberOfSections这个数——它就像是说明书目录的行数,告诉我们接下来要翻几页。每多一节,就是多一章零件说明。

架构={x86if Machine=0x014Cx64if Machine=0x8664\text{架构} =\begin{cases}\text{x86} & \text{if Machine} = 0x014C \\\text{x64} & \text{if Machine} = 0x8664\end{cases}
Machine字段用不同十六进制值标识CPU架构,加载器据此决定能否运行。
  1. 读取Machine字段在COFF头起始处读取2字节,这个值就是CPU架构编号。
    Machine=read_u16(COFF头偏移+0)\text{Machine} = \text{read\_u16}(\text{COFF头偏移} + 0)
  2. 比较架构值0x014C对应x86平台,0x8664对应x64平台。
    Machine=0x014C或0x8664\text{Machine} = 0x014C \quad \text{或} \quad 0x8664
  3. 读取节数量紧跟Machine之后的2字节就是NumberOfSections,它告诉加载器节表有多少行。
    NumberOfSections=read_u16(COFF头偏移+2)\text{NumberOfSections} = \text{read\_u16}(\text{COFF头偏移} + 2)

节表与节区

说明书翻开目录页,你会看到每一章的名字和起始页码。PE文件的“目录页”叫节表,它紧跟在可选头后面,是一个固定大小为40字节的数组。数组的每一项对应一个节(Section),最常见的节有:.text(代码)、.data(已初始化数据)、.rsrc(资源,比如图标和对话框)、.reloc(重定位)。

节表项的结构像一个“地址翻译卡”:VirtualSize(节在内存中真正占用的字节数)、VirtualAddress(节在内存中的RVA起始地址)、SizeOfRawData(节在文件中占用的字节数)、PointerToRawData(节在文件中的起始偏移)。一套数据,两种视野——文件中的一个节区域,加载到内存后会被映射到一个不同的位置。翻译的桥梁就是RVA。

节和节之间的空隙可不是浪费空间,而是为了对齐。文件中的节按FileAlignment对齐(通常0x200=512字节),内存中的节按SectionAlignment对齐(通常0x1000=4096字节)。就像乐高积木收纳箱里,每个小抽屉的尺寸必须统一,才能整整齐齐地放进大箱子。

节表告诉加载器“这一章从第几页开始、占几页”。但页和页之间可能留白,这就是对齐带来的“呼吸感”——为了性能,多花一点空间也值得。

对齐后大小=⌈原始大小FileAlignment⌉×FileAlignment\text{对齐后大小} = \Big\lceil \frac{\text{原始大小}}{\text{FileAlignment}} \Big\rceil \times \text{FileAlignment}
文件中的每个节都会向上取整到FileAlignment的整数倍,保证磁盘上节与节之间不重叠。
  1. 计算对齐需几块原始大小除以FileAlignment,向上取整。
    ⌈原始大小FileAlignment⌉\Big\lceil \frac{\text{原始大小}}{\text{FileAlignment}} \Big\rceil
  2. 乘回对齐粒度把块数乘FileAlignment,得到对齐后的总大小。
    ⌈原始大小FileAlignment⌉×FileAlignment\Big\lceil \frac{\text{原始大小}}{\text{FileAlignment}} \Big\rceil \times \text{FileAlignment}

数据目录

说明书里除了正文,往往还有一张“索引表”:哪个章节讲了特殊玩法、哪个页面有全尺寸图纸。PE文件的可选头尾部,就藏着这样一张索引——数据目录(Data Directory)。它由16个条目组成,每个条目8字节(4字节RVA + 4字节大小),有点像书架上的16个格子,每个格子贴了标签:0号是导出表、1号是导入表、5号是重定位表、14号是延迟导入表,等等。

数据目录存在的意义在于:让加载器和分析工具不需要扫描整个文件,就能快速“按图索骥”找到关键组件。比如安全分析工具想查看程序导入了哪些函数,直接跳到数据目录第1项,拿到导入表的RVA,然后翻译成文件偏移去读取。这个过程就像你在乐高说明书索引页找到“城堡塔楼拼法在第42页”——不用一页页翻。

内存中的真实地址就是“基址+偏移”的组合。ImageBase是进程地址空间中模块的起始地址,RVA是相对这个基址的偏移,两者一加,就是CPU看得见的绝对虚拟地址(VA)。

有了这张索引表,所有“特殊零件”的位置一目了然。但索引只给了位置,真正的内容还得去对应章节里细读——接下来我们翻开最厚的两章:导入表和导出表。

VA=ImageBase+RVA\text{VA} = \text{ImageBase} + \text{RVA}
绝对虚拟地址等于模块基址加上相对虚拟地址,是RVA在运行时落地的最终形态。
  1. 基址加偏移把ImageBase这个“街道号”和RVA这个“门牌号”相加。
    ImageBase+RVA\text{ImageBase} + \text{RVA}
  2. 得到绝对地址结果VA就是进程虚拟地址空间中,该数据真正所在的地址。
    VA\text{VA}

导入表机制

一套乐高城堡套装,说明书可能会写上“需另购小骑士人仔”。程序也一样,一个exe往往需要调用其他DLL里的函数,比如MessageBoxW来自user32.dll。这个“需另购”清单就在导入表(Import Table)里。PE加载器启动程序前,会先读导入表,把需要用到的DLL加载进内存,然后把函数地址填进一个叫IAT(Import Address Table)的表格里。

导入表的结构是一组IMAGE_IMPORT_DESCRIPTOR的数组,每个描述符对应一个被导入的DLL。描述符里有三个关键字段:OriginalFirstThunk(指向INT数组的RVA)、FirstThunk(指向IAT数组的RVA)和Name(DLL名字符串的RVA)。INT(Import Name Table)和IAT在文件里是一一对应的“镜像双胞胎”——INT里存的是函数名字符串的RVA(或序号),IAT一开始也存同样的值,但加载完成后,IAT里的值会被替换成函数的真实地址。

为什么要有两份?因为编译器可以用INT做校验、用IAT做跳板。程序运行时调用导入函数,实际是call [IAT + 偏移]——直接从IAT里取出早已填好的真实地址,一次内存访问,毫无延迟。这就像你提前把游戏中需要的武器都放进快捷栏,战斗时一键切换,不用再去仓库翻箱倒柜。

导入表是“采购清单”,IAT是“收货仓库”——程序运行前,仓库已满,货物齐全,拿起就能用。

IAT真实地址=ImageBase+FirstThunk中已填充的地址值\text{IAT真实地址} = \text{ImageBase} + \text{FirstThunk中已填充的地址值}
加载完成后IAT中存放的是函数的真正入口地址,程序通过它实现间接调用。
  1. 加载器读导入表在数据目录第1项拿到导入表的RVA,翻译成文件偏移。
    导入表RVA→文件偏移\text{导入表RVA} \rightarrow \text{文件偏移}
  2. 解析每个DLL描述符拿到Name获得DLL名,拿到FirstThunk定位IAT。
    Name,FirstThunk\text{Name}, \text{FirstThunk}
  3. 填充IAT用GetProcAddress算出每个函数的真实地址并写入IAT。
    IAT[i]=真实函数地址\text{IAT}[i] = \text{真实函数地址}

导出表机制

如果乐高公司允许你把自己的独家零件分享给别的套装用,那他们需要一个“公共零件目录”——这就是导出表(Export Table)。DLL把自己愿意公开的函数列在导出表里,别的程序就能通过这个目录找到函数并使用。导出表的数据结构其实是三张平行表:AddressOfFunctions(RVA地址表)、AddressOfNames(函数名字表)和AddressOfNameOrdinals(序号表)。

查找一个导出函数的流程像查字典:先在名字表里找到"MessageBoxW"这个名字在数组中的序号(比如3),然后从序号表第3项读出真正的导出序号(比如7),最后去地址表第7项拿到函数入口RVA。名字是钥匙,序号是抽屉号,地址是抽屉里的宝物。

还有一种更快的查找方式——按序号直接导出。调用方不提供名字,直接用序号去寻址。少了名字查表这一步,速度更快但可读性变差。恶意软件常这么做来增加分析难度,就像藏在工具箱暗格里的特殊零件,不按编号根本找不到。

导出表像一份“共享零件租借中心”的货架:名字、序号、地址三栏齐备,你按着编号取货,DLL就能把函数“借”给你用。

函数地址=导出表基址+地址表[序号]×4\text{函数地址} = \text{导出表基址} + \text{地址表}[ \text{序号} ] \times 4
导出函数地址等于导出表地址表中,以序号为索引的那一项的RVA。
  1. 拿到序号在序号表中查找名字对应的导出序号。
    Ordinal=NameOrdinals[NameIndex]\text{Ordinal} = \text{NameOrdinals}[\text{NameIndex}]
  2. 查地址表用序号定位地址表数组的元素。
    AddressOfFunctions+Ordinal×4\text{AddressOfFunctions} + \text{Ordinal} \times 4
  3. 读出函数RVA该位置的4字节就是函数入口的RVA。
    函数入口RVA\text{函数入口RVA}

重定位机制

假设乐高说明书里有一处写着“把红色砖块放在第10排第3列”,但如果地基尺寸变动,这个坐标就得重算。PE文件也有类似问题:PE头里的ImageBase只是“理想基址”,但当多个DLL同时加载,或者开了ASLR(地址空间随机化),实际的加载基址往往会偏离理想值。程序里的绝对地址就全部“失联”了。重定位表(Base Relocation Table)就是这本“地址簿”,专门记录哪些地址需要在加载时被重新修正。

重定位表以“页”为单位组织:每个块描述一页(4KB)内的所有需要修正的位置。块的头部有两个字段:Page RVA(这一页的起始RVA)和Block Size(块总大小)。后面跟着一堆2字节的条目,每个条目的低12位表示页内偏移,高4位表示重定位类型(取值为3表示HIGHLOW,即需按32位修正)。

加载器计算实际修正时,先用实际加载地址减去理想基址,得到偏移量delta,然后对每个条目指向的内存位置执行“原值加delta”的操作。这样,所有绝对地址都跟着新家重新“落户”。

重定位表这张“搬家地址簿”,保证了程序无论被安排在哪个地址都能正常运行——就像搬家后所有快递自动改了收件人地址,一件不落。

修正后值=原值+(实际基址−ImageBase)\text{修正后值} = \text{原值} + (\text{实际基址} - \text{ImageBase})
实际加载地址与理想基址的差值delta,会被加到所有需要重定位的绝对地址上。
  1. 计算delta实际基址减去ImageBase,得到搬迁偏移量。
    Δ=实际基址−ImageBase\Delta = \text{实际基址} - \text{ImageBase}
  2. 定位修正位置每个条目的高4位是类型,低12位是页内偏移。
    偏移=条目值 & 0x0FFF,类型=条目值≫12\text{偏移} = \text{条目值} \,\&\, 0x0FFF, \quad \text{类型} = \text{条目值} \gg 12
  3. 修正绝对值把该位置的原值加上delta。
    修正后值=原值+Δ\text{修正后值} = \text{原值} + \Delta

RVA转文件偏移

乐高说明书目录告诉你在第42页找图纸,但“第42页”指的是书页上的印刷编号,不是PDF文件的物理字节位置。PE文件里同样存在两种坐标系统:RVA(相对虚拟地址,内存视角)和文件偏移(磁盘视角)。程序在磁盘上是一堆字节,加载到内存后又是另一番布局。节在磁盘上的位置和内存中的位置并不同步,所以转换是刚需。

转换算法其实非常简单:拿到一个RVA,遍历节表,找到它落在哪个节的范围里——条件是RVA >= VirtualAddress且RVA < VirtualAddress + VirtualSize。确认归属后,用RVA减去节的VirtualAddress得到节内偏移,再加上节在文件中的PointerToRawData,就得到了文件偏移。这就像先确定“第42页属于城堡章节的第5页”,然后“城堡章节从第100页印刷开始”,所以差值是42-37=5,文件页=100+5=105。

这个转换在PE分析工具、调试器、杀毒引擎里被高频调用。无论你是想读取导入表、解析资源,还是修补代码,第一步永远是RVA转文件偏移。搞不懂这个,就像拿着地图却不知道比例尺——坐标全对,但永远找不到地方。

RVA转文件偏移是连接两个世界的翻译官。有了它,你既能在内存视角理解程序,也能在磁盘视角动手修改文件——就像同一本书,既能看章节页码,也能翻实体书页。

文件偏移=PointerToRawData+(RVA−VirtualAddress)\text{文件偏移} = \text{PointerToRawData} + (\text{RVA} - \text{VirtualAddress})
目标RVA落在某节范围内时,文件偏移等于该节文件起始位置加上节内偏移量。
  1. 匹配节区间遍历节表,找到满足条件的节。
    VirtualAddress≤RVA<VirtualAddress+VirtualSize\text{VirtualAddress} \le \text{RVA} < \text{VirtualAddress} + \text{VirtualSize}
  2. 算节内偏移RVA减去该节起始RVA,得到目标在节内的偏移。
    节内偏移=RVA−VirtualAddress\text{节内偏移} = \text{RVA} - \text{VirtualAddress}
  3. 加上文件起点把节内偏移加到PointerToRawData上,得到文件偏移。
    文件偏移=PointerToRawData+节内偏移\text{文件偏移} = \text{PointerToRawData} + \text{节内偏移}

AI 应用场景

恶意软件样本分析

阶段:静态特征提取

解决的问题:安全分析师拿到一个未知的exe文件,传统方式是人工用十六进制编辑器慢慢翻查,效率极低且容易遗漏关键特征。AI系统接入PE解析器后,可在毫秒级提取出机器类型、节表、导入导出表等结构化特征,作为恶意分类模型的输入。

具体案例:在某次钓鱼邮件攻击调查中,安全团队收到一个伪装成PDF的exe。AI解析器提取该文件的节表后发现.text节大小异常、隐藏了第三个无名字节区,并且导入表里只有LoadLibrary和GetProcAddress这两个典型的“动态解析”函数——三分钟内判定为高度可疑的加载器样本。

DLL劫持漏洞挖掘

阶段:动态行为建模

解决的问题:传统DLL劫持检测靠人工审计导出表和加载路径,工作量大且容易遗漏。AI系统通过解析PE导出表,批量识别目标程序调用的DLL列表,再结合进程监控日志判断是否存在“不在系统目录、可被替换”的可劫持DLL。

具体案例:某安全团队利用PE导出表解析工具,对一款办公软件的全部模块构建了DLL依赖图谱,发现其插件的加载逻辑缺少绝对路径校验。攻击者只需在同目录放置同名恶意DLL,就能实现劫持。AI系统将此线索自动生成预警,人工复核后确认并修复。

固件与补丁逆向分析

阶段:代码重构

解决的问题:在漏洞修复验证中,需要对比补丁前后两个版本的二进制差异。AI系统利用RVA与文件偏移的自动转换,将补丁前后的函数映射到统一坐标系,自动定位被修改的代码区域,大幅提升逆向分析效率。

具体案例:某CVE漏洞的补丁分析中,AI系统对比了补丁前后的PE节表与函数位置,自动锁定一个位于.text节中的4字节改动,并反向推导该改动重写了某数组的长度检查逻辑。分析人员据此快速写出漏洞利用检测规则。

Python 演示

python69 行
import structimport sys class PEParser:    def __init__(self, filepath):        with open(filepath, 'rb') as f:            self.data = f.read()        self.sections = []        self.parse_dos_and_pe()     def read(self, offset, fmt):        size = struct.calcsize(fmt)        return struct.unpack_from(fmt, self.data, offset)     def parse_dos_and_pe(self):        if self.data[:2] != b'MZ':            raise ValueError('Not a valid PE file: missing MZ')        self.e_lfanew = self.read(0x3C, '<I')[0]        pe_sig = self.read(self.e_lfanew, '<I')[0]        if pe_sig != 0x00004550:            raise ValueError('Not a valid PE file: bad PE signature')        self.machine, self.num_sections = self.read(self.e_lfanew + 4, '<HH')        self.opt_offset = self.e_lfanew + 4 + 20        self.image_base = self.read(self.opt_offset + 28, '<I')[0]        self.section_align = self.read(self.opt_offset + 32, '<I')[0]        self.file_align = self.read(self.opt_offset + 36, '<I')[0]        self.entry_point = self.read(self.opt_offset + 16, '<I')[0]        size_of_opt = self.read(self.e_lfanew + 20, '<H')[0]        self.section_offset = self.opt_offset + size_of_opt        self._parse_sections()     def _parse_sections(self):        for i in range(self.num_sections):            off = self.section_offset + i * 40            name = self.data[off:off + 8].rstrip(b'\x00')            vsize = self.read(off + 8, '<I')[0]            vaddr = self.read(off + 12, '<I')[0]            rawsize = self.read(off + 16, '<I')[0]            rawptr = self.read(off + 20, '<I')[0]            self.sections.append({'name': name, 'virtual_size': vsize,                                  'virtual_addr': vaddr, 'raw_size': rawsize,                                  'raw_ptr': rawptr})     def rva_to_offset(self, rva):        for sec in self.sections:            start = sec['virtual_addr']            end = start + sec['virtual_size']            if start <= rva < end:                return sec['raw_ptr'] + (rva - start)        return None     def summary(self):        print(f'Machine:   0x{self.machine:04X}')        print(f'Sections:  {self.num_sections}')        print(f'ImageBase: 0x{self.image_base:X}')        print(f'FileAlign: {self.file_align}')        print(f'EntryRVA:  0x{self.entry_point:X}')        for sec in self.sections:            print(f'  {sec["name"].decode("ascii", "ignore"):8s} '                  f'VA={sec["virtual_addr"]:06X} '                  f'FileOff={sec["raw_ptr"]:06X}') if __name__ == '__main__':    path = sys.argv[1] if len(sys.argv) > 1 else 'C:/Windows/System32/notepad.exe'    pe = PEParser(path)    pe.summary()    test_rva = pe.sections[0]['virtual_addr'] + 1    print(f'Test RVA 0x{test_rva:X} -> file offset '          f'0x{pe.rva_to_offset(test_rva):X}')
目的:解析任意Windows PE文件的头部结构与节表,验证RVA到文件偏移的关键转换逻辑。
  1. if self.data[:2] != b'MZ'检查文件头两字节是否为"MZ",这是PE格式的入场券。
  2. self.e_lfanew = self.read(0x3C, '<I')[0]从偏移0x3C处读取4字节小端整数,拿到PE头的文件位置。
  3. pe_sig != 0x00004550验证PE签名的四个字节"\x50\x45\x00\x00",确保这是真正的PE文件。
  4. self.read(self.e_lfanew + 4, '<HH')在PE头偏移4处读取Machine(2字节)和节数量(2字节)。
  5. self.image_base = self.read(self.opt_offset + 28, '<I')[0]从可选头第28字节处读取ImageBase,这是模块加载进内存的理想基址。
  6. self.section_offset = self.opt_offset + size_of_opt节表的起始位置,等于可选头偏移加上可选头大小。
  7. for i in range(self.num_sections)用节数量循环遍历整个节表数组。
  8. if start <= rva < end核心匹配逻辑:判断目标RVA是否落在当前节的虚拟地址区间内。
  9. return sec['raw_ptr'] + (rva - start)RVA转文件偏移的关键一步:节的文件起始偏移加上节内偏移量。

常见误区

误区一个RVA等于0x1000,又看到文件偏移0x1000处有数据,就以为两者直接相等。有时候我直接拿RVA去文件里找数据,结果读出来的内容完全对不上。

正解RVA是内存视角的地址,文件偏移是磁盘视角的位置。两者之间隔着“节对齐”的差异:磁盘上节按FileAlignment对齐,内存里按SectionAlignment对齐。同一个数据,在磁盘上的位置和内存中的位置往往完全不同,必须通过节表转换。

为何常见:分析工具(如x64dbg)默认显示内存地址,而十六进制编辑器显示文件偏移。初学者在两边来回切换时,容易忽略转换环节,把两个坐标混为一谈。

误区我看磁盘上`.text`节从文件偏移0x400开始,那么它在内存中的RVA也应该从0x400开始吧?

正解节在内存中的起始地址由节表里的VirtualAddress字段决定,与文件偏移无关。一个节的虚拟地址可能是0x1000,而文件偏移只有0x400。加载器按节表信息把文件内容映射到对应的内存地址,而不是简单“一对一”拷贝。

为何常见:人的直觉天然倾向于“原样复制”的映射方式,但实际上PE加载器是“按条目搬砖”,每个节按自己的目标和尺寸搬运。

误区文件开头不是有"PE"两个字母吗?DOS头的所有字段都是PE格式的一部分,我要解析PE头就从文件开头读。

正解真正的PE头从e_lfanew指向的位置才开始,且以"PE\0\0"4字节签名开头。DOS头里的内容(如MZ签名、DOS存根)是历史遗留的兼容层。如果直接从头解析,你会把faterlapp、endjob这些无意义字段当成PE结构,必然解析失败。

为何常见:很多入门工具教程直接展示“文件头”的十六进制视图,MZ和PE的"Ascii"表示挨得近,初学者容易把它们当成同一块区域。

误区FileAlignment和SectionAlignment不都是对齐吗?那它们应该相等,或者随便取一个就行。

正解两者作用域完全不同:FileAlignment是文件中的对齐粒度(常为0x200),保证磁盘上的节紧凑节省空间;SectionAlignment是内存中的对齐粒度(常为0x1000),匹配内存分页机制提升加载效率。内存页是4KB,所以节在内存中也要按4KB对齐,这样每节恰好占整数个页。

为何常见:从名字上看两者很像,且在很多小文件中两个值巧合地都是0x200(当SectionAlignment=0x1000时),初学者没见过差异案例就默认相同。

误区IAT就是导入表,导入表就是IAT,反正都记录导入函数的名字和地址。

正解导入表是描述符的集合,每个描述符指向INT和IAT两个数组。INT(Import Name Table)在文件和内存中都保存函数名/序号信息;IAT(Import Address Table)在文件中和INT内容一致,但加载完成后会被替换为函数真实地址。程序运行时的call [IAT]使用的是IAT,而INT更多被调试器和安全工具用于还原导入信息。

为何常见:在未加载的文件视图里,INT和IAT的值一模一样,初学者拿到的工具(如DIE、CFF Explorer)默认展示的是整合视图,看不到双数组的存在。

面试题

Q1. PE文件开头的DOS签名是以下哪个?

单选题 · easy

  • A."PE\0\0"
  • B."MZ"← 正确答案
  • C."\x7FELF"
  • D."COFF"

答案:B。PE文件开头固定是“MZ“两个字节,这是DOS头签名,致敬DOS作者Mark Zbikowski。”PE\0\0”是PE头自己的签名,位于e_lfanew指向的位置。\x7FELF是Linux ELF文件的魔数。

Q2. e_lfanew字段的作用是什么?

单选题 · easy

  • A.存储CPU架构信息
  • B.指向导入表的地址
  • C.记录PE头在文件中的偏移← 正确答案
  • D.标识节的数量

答案:C。e_lfanew位于文件偏移0x3C处,是一个4字节的小端整数,记录PE头在文件中的确切偏移。因为DOS存根长度不固定,只有通过该指针才能正确定位PE头。

Q3. 哪种Machine值表示64位x86架构?

单选题 · easy

  • A.0x014C
  • B.0x8664← 正确答案
  • C.0xAA64
  • D.0x1C0

答案:B。0x8664对应x64架构,0x014C对应x86(32位),0xAA64对应ARM64。Machine字段是COFF头第一个字段,加载器据此判断能否运行该二进制文件。

Q4. 一个节在内存中的起始地址字段是?

单选题 · mid

  • A.PointerToRawData
  • B.SizeOfRawData
  • C.VirtualAddress← 正确答案
  • D.VirtualSize

答案:C。VirtualAddress记录节在内存中的RVA起始地址;PointerToRawData记录节在文件中的偏移,两者是“内存视角”和“磁盘视角”的对应关系。SizeOfRawData是文件中的节大小。

Q5. RVA转文件偏移的正确步骤是?

单选题 · mid

  • A.直接用RVA当作文件偏移
  • B.找到所在节,RVA减VirtualAddress再加PointerToRawData← 正确答案
  • C.将RVA与ImageBase相加
  • D.找到所在节,RVA减PointerToRawData

答案:B。先遍历节表找到RVA落在哪个节区间,然后用RVA减去该节VirtualAddress得到节内偏移,再加上PointerToRawData。直接拿RVA当文件偏移是初学者最常见的错误。

Q6. 程序运行时调用导入函数,实际通过哪个表跳转?

单选题 · mid

  • A.INT(Import Name Table)
  • B.导出表
  • C.IAT(Import Address Table)← 正确答案
  • D.重定位表

答案:C。加载器会把导入函数的真实地址填入IAT,程序运行时执行call [IAT]完成调用。INT在文件中和IAT内容相同,但加载后不会被替换真实地址,主要用于名称解析和工具分析。

Q7. 程序入口点的RVA存放在哪?

单选题 · easy

  • A.COFF头的Machine字段
  • B.可选头的AddressOfEntryPoint字段← 正确答案
  • C.数据目录的第0项
  • D.节表的VirtualSize字段

答案:B。可选头(Optional Header)中的AddressOfEntryPoint字段记录程序入口函数的RVA。操作系统加载PE后,会跳转到ImageBase + AddressOfEntryPoint处开始执行程序。

Q8. 数据目录中可能包含以下哪些表?

多选题 · mid

  • A.导出表← 正确答案
  • B.导入表← 正确答案
  • C.重定位表← 正确答案
  • D.节表

答案:ABC。数据目录是可选头尾部的16条目索引,其中0号条目是导出表、1号是导入表、5号是重定位表。节表不属于数据目录,它位于可选头之后,是独立存在的数组,记录各节的位置和大小。

Q9. 关于重定位表的描述,哪些是正确的?

多选题 · mid

  • A.它以页为单位组织块← 正确答案
  • B.记录ImageBase的值
  • C.每个条目高4位是类型← 正确答案
  • D.条目低12位表示页内偏移← 正确答案

答案:ACD。重定位表按4KB页分组,块头部有PageRVA和BlockSize,每个2字节条目高4位表示重定位类型(3表示32位修正),低12位是页内偏移。它修正的是绝对地址,与ImageBase字段无关。

Q10. 若DLL实际加载基址与ImageBase不同,以下说法正确的是?

多选题 · hard

  • A.程序中所有绝对地址都会失效← 正确答案
  • B.需要重定位表修正绝对地址← 正确答案
  • C.delta等于实际基址减ImageBase← 正确答案
  • D.修正时直接用delta乘以4

答案:ABC。当ASLR或DLL冲突导致实际基址偏离ImageBase时,所有绝对地址都需要修正。加载器计算delta=实际基址-ImageBase,然后将每个需重定位位置的原值加上delta。修正直接加delta,不需要乘以4。