第三章 可执行文件格式:PE、ELF、Mach-O的结构解析

可执行文件格式是二进制安全与逆向工程的基石。无论是分析恶意软件、挖掘漏洞还是进行软件破解,第一步都是理解目标文件在磁盘上如何组织、加载器如何将其映射到内存、以及系统如何解析其依赖与符号。Windows的PE(Portable Executable)、Linux/Android的ELF(Executable and Linkable Format)以及macOS/iOS的Mach-O,构成了当今三大主流操作系统下的二进制事实标准。本章将深入解析这三种格式的节区(Section)、段(Segment)、导入导出表与重定位表,帮助读者建立从文件字节到内存映像的完整映射思维。

三大可执行文件格式结构对比与加载映射 PE (Windows) DOS头 + e_lfanew NT头 (Signature+FileHeader+OptionalHeader) 节表 (Section Table) .text / .rdata / .data .idata (导入表) / .edata (导出表) .reloc (重定位表) 节区对齐 (FileAlignment/SectionAlignment) RVA (相对虚拟地址) 映射 IAT (导入地址表) 由加载器填充 重定位表修正基址 ELF (Linux/Android) ELF Header (e_ident + 类型 + 入口) 程序头表 (Program Header Table) 节头表 (Section Header Table) .text / .rodata / .data / .bss .dynsym / .dynstr (动态符号) .rel.dyn / .rel.plt (重定位) 段 (Segment) 包含多个节区 PT_LOAD 段映射到内存 GOT (全局偏移表) + PLT (过程链接表) 延迟绑定 (Lazy Binding) Mach-O (macOS/iOS) Mach-O Header (magic + cputype) Load Commands (加载命令) __TEXT / __DATA / __LINKEDIT __text / __cstring / __const __la_symbol_ptr / __got __symbol_table / __string_table 段 (Segment) 以页大小对齐 虚拟内存 (VM) 偏好 Dyld 绑定 (Bind/Weak Bind) Chained Fixups (链式修复) 加载器 (Loader) → 内存映像 (Memory Image)

上图展示了三种格式在宏观结构上的对应关系。尽管它们的头部长相各异,但核心思想一致:通过头部描述文件布局,通过节区/段划分权限与内容,通过表结构管理符号与重定位。下面我们将逐一拆解。

3.1 PE格式:Windows下的可移植可执行文件

核心原理:从DOS头到节区的层层封装

PE格式起源于Unix的COFF(Common Object File Format),但微软对其进行了扩展。一个完整的PE文件由以下部分组成:DOS头(64字节)、DOS Stub、NT头(包含Signature、FileHeader、OptionalHeader)、节表(Section Table)以及节区数据。DOS头中的e_lfanew字段(偏移0x3C)指向NT头的起始位置,这是解析PE的入口点。

NT头中的OptionalHeader虽然名为“可选”,但在PE文件中是必需的。它定义了入口点地址(AddressOfEntryPoint)、镜像基址(ImageBase,通常为0x00400000)、节区对齐(SectionAlignment)和文件对齐(FileAlignment)。节区对齐决定了节区在内存中的布局粒度(通常为0x1000即4KB),而文件对齐决定了节区在磁盘上的存储粒度(通常为0x200即512字节)。两者不同,意味着磁盘上的文件字节与内存中的映像并非一一对应,加载器需要根据对齐值进行展开。

关键点:PE中的“节区”(Section)是磁盘与内存中数据的基本单元,而“段”(Segment)在PE语境下通常指OptionalHeader中的IMAGE_OPTIONAL_HEADER结构体(如代码段、数据段、栈段等属性描述),这与ELF中的“段”概念不同。在逆向分析时,我们更关注节区,因为节区承载了实际的代码与数据。

判断方法:使用工具解析PE结构

在Windows环境下,我们可以使用dumpbin(Visual Studio自带)、PE-bear、CFF Explorer或010 Editor等工具。以dumpbin /headers命令为例,可以查看文件头、节区表、导入表等关键信息。对于自动化分析,Python的pefile库是最常用的选择。

import pefile
pe = pefile.PE('C:\\Windows\\System32\\notepad.exe')
print(pe.FILE_HEADER.Machine)  # 0x8664 表示x64
print(pe.OPTIONAL_HEADER.ImageBase)  # 0x140000000
for section in pe.sections:
    print(section.Name, hex(section.VirtualAddress), hex(section.Misc_VirtualSize), section.Characteristics)
# 遍历导入表
if hasattr(pe, 'DIRECTORY_ENTRY_IMPORT'):
    for entry in pe.DIRECTORY_ENTRY_IMPORT:
        print(entry.dll.decode())
        for imp in entry.imports:
            print('  ', imp.name.decode() if imp.name else hex(imp.ordinal))

通过上述代码,我们可以快速获取节区名称、虚拟地址、虚拟大小以及节区属性(如可读、可写、可执行)。节区属性是逆向分析的重要线索:一个同时具有可写和可执行属性的节区(如旧的UPX壳),往往暗示着代码自修改或加壳行为。

案例拆解:分析一个加壳PE的导入表

假设我们拿到一个疑似加壳的样本sample.exe。初始现象是:使用pefile加载后,发现导入表只有LoadLibraryA和GetProcAddress两个函数,且节区名称被修改为UPX0、UPX1。这强烈提示使用了UPX加壳。分析判断过程如下:

  1. 确认壳类型:检查节区名称和入口点代码。UPX壳的入口点通常是一条pushad指令,随后是call指令跳转到解压代码。
  2. 脱壳操作:使用upx -d sample.exe尝试脱壳。如果成功,再次解析PE,导入表将恢复为完整的系统API列表(如CreateFileW、WriteFile等)。
  3. 结果对比:脱壳前,文件大小为100KB;脱壳后为300KB。脱壳前节区数为2,脱壳后为5(.text、.rdata、.data、.rsrc、.reloc)。

这个案例说明,导入表是判断加壳与否的重要指标。加壳程序为了减小体积或隐藏逻辑,会压缩原始导入表,仅保留最少的加载函数。

常见误区与复盘

  • 误区1:认为ImageBase就是程序加载后的实际基址。实际上,由于ASLR(地址空间布局随机化),系统可能将PE加载到其他地址。此时,重定位表(.reloc)就发挥作用了。如果程序禁用了ASLR(如某些老旧恶意软件),则基址固定。
  • 误区2:忽略SizeOfRawData与VirtualSize的区别。在磁盘上,节区数据按FileAlignment对齐;在内存中,按SectionAlignment对齐。如果VirtualSize大于SizeOfRawData,则多出的部分在内存中被填充为0(通常为.bss节区)。
  • 复盘:在分析PE时,务必先检查Characteristics字段。例如,0xE0000020表示代码节区可读、可执行、不可写;0xC0000040表示数据节区可读、可写、不可执行。若发现代码节区可写,需警惕堆喷射或代码注入。

3.2 ELF格式:Linux与Android的通用格式

核心原理:节区与段的双重视角

ELF格式最独特的设计在于它同时提供了节区视图(Section View)和段视图(Segment View)。节区视图用于链接和调试(由节头表描述),段视图用于加载和执行(由程序头表描述)。一个段(Segment)可以包含多个节区(Section),例如PT_LOAD段通常包含.text、.rodata、.data等节区。这种设计使得加载器无需关心节区的具体划分,只需按段进行内存映射即可。

ELF头部的e_ident数组包含魔数(0x7F 0x45 0x4C 0x46)、位数(32/64)、字节序(小端/大端)等信息。e_type字段区分文件类型:ET_REL(可重定位文件,如.o)、ET_EXEC(可执行文件)、ET_DYN(共享对象,如.so,也用于PIE可执行文件)。

判断方法:readelf与objdump实战

在Linux下,readelf是解析ELF的利器。以下命令展示了如何查看节区与段:

# 查看ELF头
readelf -h /bin/ls
# 查看程序头(段)
readelf -l /bin/ls
# 查看节头(节区)
readelf -S /bin/ls
# 查看动态符号表
readelf --dyn-syms /bin/ls
# 查看重定位表
readelf -r /bin/ls

重点关注readelf -l的输出,其中Type列为LOAD的段是实际映射到内存的段。每个LOAD段都有Offset(文件偏移)、VirtAddr(虚拟地址)、PhysAddr(物理地址,通常与虚拟地址相同)、FileSiz(文件大小)和MemSiz(内存大小)。MemSiz大于FileSiz的部分会被清零,这通常对应.bss节区。

案例拆解:分析一个Linux恶意软件的动态链接与重定位

假设我们分析一个名为evil.so的共享库。初始现象:该库被一个游戏外挂程序加载,但行为异常。我们使用readelf -r evil.so查看其重定位表,发现存在大量R_X86_64_JUMP_SLOT类型的重定位,指向system和execve等危险函数。分析判断过程:

  1. 确认导入函数:通过.dynsym(动态符号表)和.rela.plt(重定位表)确认该库导入了system函数。
  2. 追踪调用路径:在IDA中加载evil.so,定位到引用system@plt的代码地址。发现其通过strcpy拼接用户输入,然后调用system执行命令。
  3. 关键输入数据:重定位表显示system的GOT条目位于0x3fc8,PLT桩代码位于0x1050。通过断点监控GOT地址,可以捕获实际解析后的system函数地址。

这个案例展示了重定位表在逆向工程中的核心价值:它揭示了程序运行时将要绑定哪些外部函数,以及这些函数在GOT中的位置。攻击者常通过修改GOT条目来劫持控制流(GOT覆写攻击),因此分析重定位表也是漏洞挖掘的起点。

常见误区与总结

  • 误区1:混淆ET_EXEC与ET_DYN。现代Linux发行版默认编译PIE(位置无关可执行文件),其类型为ET_DYN。这意味着它的基址不固定,需要依赖重定位表在加载时修正地址。
  • 误区2:忽略.bss节区。该节区不占用文件空间,但占用内存空间。在计算恶意软件的内存占用或进行内存取证时,必须考虑.bss的大小。
  • 总结:ELF的延迟绑定机制(通过PLT/GOT)是性能与安全的折中。在分析时,第一次调用外部函数会触发动态链接器解析,后续调用直接使用GOT中的地址。逆向时,可以通过修改LD_BIND_NOW=1环境变量来强制立即绑定,便于调试。

3.3 Mach-O格式:Apple生态的二进制标准

核心原理:基于段的内存偏好设计

Mach-O格式的设计哲学与ELF类似,但更强调虚拟内存的友好性。其结构分为三部分:Header、Load Commands和Data。Header包含魔数(0xFEEDFACE(32位)或0xFEEDFACF(64位))、CPU类型(如CPU_TYPE_X86_64)、文件类型(MH_EXECUTE、MH_DYLIB等)。Load Commands是一系列变长命令,描述了文件在内存中的布局、依赖库、符号表位置等。Data区域则包含实际的节区数据。

Mach-O的段(Segment)以__开头命名,如__TEXT、__DATA、__LINKEDIT。每个段包含多个节区(Section),节区以__开头,如__text(代码)、__cstring(字符串)、__const(常量)。__LINKEDIT段包含了符号表、字符串表和动态链接信息,是逆向分析的重点。

判断方法:使用otool与MachOView

macOS自带的otool命令可以查看Mach-O结构。以下命令展示了基本用法:

# 查看头部
otool -h /bin/ls
# 查看Load Commands
otool -l /bin/ls
# 查看__TEXT段中的__text节区(反汇编)
otool -tv /bin/ls
# 查看动态依赖库
otool -L /bin/ls

对于图形化分析,MachOView是社区常用的工具。它能够直观地展示段、节区、符号表、重定位项等。在iOS逆向中,class-dump工具可以导出Objective-C的类信息,其原理就是解析__objc_classlist等节区。

案例拆解:分析一个iOS应用的Dyld绑定信息

假设我们分析一个越狱环境下的iOS应用App.ipa。初始现象:应用启动时崩溃,提示“Symbol not found: _OBJC_CLASS_$_SomeClass”。分析判断过程:

  1. 检查依赖库:使用otool -L App查看其依赖的动态库列表,确认是否链接了包含SomeClass的私有框架。
  2. 查看绑定信息:使用dyldinfo -bind App查看所有绑定符号。发现_OBJC_CLASS_$_SomeClass被标记为从PrivateFramework导入。
  3. 关键操作:在越狱设备上,使用nm -g PrivateFramework检查该框架是否真的导出了这个符号。结果发现该符号在框架的新版本中已被移除。
  4. 结果对比:应用是针对旧版本SDK编译的,新系统移除了该私有API,导致dyld无法完成绑定。

这个案例说明了Mach-O的绑定(Bind)信息在排查启动崩溃中的关键作用。与ELF的PLT/GOT不同,Mach-O在较新的系统中使用Chained Fixups机制,将重定位信息以链式结构存储在__LINKEDIT段中,减少了启动时的计算开销。

常见误区与复盘

  • 误区1:认为Mach-O的__TEXT段只包含代码。实际上,__TEXT段还包含__cstring(字符串常量)、__objc_methname(Objective-C方法名)等。这些节区是提取字符串和类信息的重要来源。
  • 误区2:忽略__LINKEDIT段。该段在文件末尾,但包含了符号表(__symbol_table)、字符串表(__string_table)和代码签名。在iOS逆向中,去除代码签名或修改二进制后,需要重新计算__LINKEDIT中的哈希值,否则应用无法启动。
  • 复盘:在分析Mach-O时,务必注意其通用二进制(Universal Binary)格式。一个文件可能包含多个架构(如arm64和x86_64),每个架构有独立的Header和Load Commands。使用lipo -info可以查看包含的架构,使用lipo -thin arm64可以提取指定架构。

3.4 三种格式的对比与实战要点

维度PEELFMach-O
头部魔数MZ (0x4D5A)0x7F 45 4C 460xFEEDFACE / 0xFEEDFACF
核心结构DOS头 + NT头 + 节表ELF头 + 程序头 + 节头Header + Load Commands + Data
段/节区关系节区是基本单元,段是属性描述段包含节区,段用于加载段包含节区,段以页对齐
导入机制IAT(导入地址表)PLT/GOT(延迟绑定)Dyld Bind / Chained Fixups
重定位表.reloc(基址重定位).rel.dyn / .rel.plt__LINKEDIT中的Fixups
常见工具dumpbin, PE-bear, CFFreadelf, objdump, IDAotool, MachOView, class-dump

在实战中,无论面对哪种格式,建议遵循以下分析流程:第一步,解析头部,确认架构、位数、入口点;第二步,查看节区/段,重点关注权限属性(可写可执行需警惕);第三步,分析导入导出表,了解程序的外部依赖;第四步,检查重定位表,理解地址修正逻辑;第五步,结合反汇编工具,定位关键代码逻辑。

风险提示:在分析恶意软件时,重定位表可能被恶意利用。例如,某些壳会修改重定位表指向恶意代码,或者利用.reloc节区进行数据混淆。务必在沙箱环境中进行动态分析,并校验文件的数字签名(PE)或代码签名(Mach-O)。
经验总结:对于初学者,建议从PE格式入手,因为Windows工具链成熟,且pefile库文档丰富。掌握PE后,再对比学习ELF和Mach-O,会发现三者概念高度相似,只是名称和布局不同。核心能力在于理解“文件字节”与“内存映像”之间的映射关系,以及“符号解析”与“重定位”的机制。