二进制基础:PE、ELF、Mach-O文件格式解析

逆向工程的核心对象是二进制可执行文件。无论是Windows下的恶意软件分析、Linux下的漏洞挖掘,还是macOS/iOS平台的越狱插件开发,第一步都是理解目标文件的组织方式。PE、ELF、Mach-O是三大主流操作系统(Windows、Linux/Android、macOS/iOS)使用的可执行文件格式。它们虽然语法各异,但设计哲学高度一致:都包含文件头、节区(Section)表、程序头(Segment)表、入口点以及导入导出信息。本章将深入解析这三种格式的共性与差异,重点讲解节区与段的关系、入口点的定位方法、导入导出表的解析思路,并厘清静态链接与动态链接在二进制层面的本质区别。

三大可执行文件格式结构对比与链接模型 PE (Windows) DOS头 + NT头 (e_lfanew) 节区表 (Section Table) .text / .data / .rdata .idata (导入) / .edata (导出) 入口点: AddressOfEntryPoint IAT / INT 表 ELF (Linux/Android) ELF Header (e_entry) 程序头表 (Program Header) .text / .data / .bss .dynsym / .dynstr / .rel.* 入口点: e_entry (通常是_start) GOT / PLT 表 Mach-O (macOS/iOS) Mach Header (magic + cputype) Load Commands (LC_SEGMENT) __TEXT / __DATA / __LINKEDIT __symbol_table / __string_table 入口点: LC_MAIN (entryoff) Dyld Info / Binding 静态链接: 代码复制进文件 | 动态链接: 依赖外部DLL/.so/.dylib + 导入表

上图展示了三种格式在宏观结构上的对应关系。可以看到,尽管文件头名称不同,但都遵循“头部描述元数据 → 节区承载代码数据 → 辅助表记录重定位与符号”的总体框架。理解这个框架,是后续使用IDA Pro和Ghidra进行自动化分析的基础。

3.1 核心原理:文件格式的“地图”作用

可执行文件格式本质上是一张“地图”,它告诉操作系统加载器(Loader)和调试器如何将磁盘上的字节映射到内存地址空间。这张地图包含两个关键层级:节区(Section)和段(Segment)。在PE中,节区是唯一的分割单位;而在ELF和Mach-O中,段是更上层的逻辑分组,一个段可以包含多个节区。例如,ELF的LOAD段通常包含.text、.rodata等多个节区,操作系统只关心段(因为段决定了内存权限),而链接器和反汇编器更关心节区(因为节区决定了内容属性)。

入口点(Entry Point)是程序执行的第一条指令地址。在PE中,它存储在NT头的AddressOfEntryPoint字段,是一个相对于镜像基址(ImageBase)的RVA(相对虚拟地址)。在ELF中,入口点存储在ELF头部的e_entry字段,通常指向_start符号。在Mach-O中,入口点由LC_MAIN加载命令的entryoff字段指定,该值是一个文件偏移量,需要加上__TEXT段的虚拟地址才能得到最终入口地址。逆向工程师在IDA Pro中打开文件时,IDA会自动解析这些字段并将光标定位到入口点,但手动理解这些字段有助于处理加壳或畸形文件。

3.2 判断方法与操作流程:解析导入导出表

导入表(Import Table)和导出表(Export Table)是逆向分析中信息量最大的部分。导入表告诉分析者程序使用了哪些外部函数(如CreateFileW、malloc),导出表则说明程序提供了哪些函数供其他模块调用(常见于DLL或SO文件)。

格式导入表位置导出表位置关键数据结构
PE.idata 节区.edata 节区IMAGE_IMPORT_DESCRIPTOR / IMAGE_EXPORT_DIRECTORY
ELF.dynsym + .dynstr + .rel.plt.dynsym + .dynstrElf32_Sym / Elf64_Sym + 重定位表
Mach-O__LINKEDIT 段的 dyld_info__LINKEDIT 段的 symbol_tabledyld_info_command / nlist_64

在Ghidra中,导入表会被自动识别并生成外部函数引用。但手动分析时,可以遵循以下流程:

  1. 定位节区表:使用readelf -S(ELF)、objdump -x(PE)或otool -l(Mach-O)查看节区名称和偏移。
  2. 解析符号表:对于ELF,.dynsym存储了动态符号,每个符号项包含st_name(名称偏移)、st_value(地址)、st_info(类型和绑定属性)。
  3. 关联重定位表:ELF的.rel.plt和.rela.plt记录了GOT(全局偏移表)中需要填充的地址。每个重定位项包含r_offset(GOT中的位置)和r_info(符号索引)。
  4. 验证导入函数:在IDA Pro中,打开“Imports”窗口,检查函数名和地址是否与节区偏移一致。如果发现导入表被破坏(如IAT加密),则需要动态调试或手动修复。
实战提示:在分析恶意软件时,导入表往往是混淆的重点。攻击者会使用API哈希或动态解析(通过GetProcAddress)来隐藏真实导入。此时,静态导入表只能看到LoadLibrary和GetProcAddress,真正的API调用需要结合动态分析或模拟执行才能还原。

3.3 案例拆解:静态链接与动态链接的二进制差异

静态链接(Static Linking)和动态链接(Dynamic Linking)在二进制层面有显著区别。静态链接的可执行文件体积更大,因为所有依赖库的机器码都被复制到了最终文件中;动态链接的可执行文件体积小,但依赖外部文件(如kernel32.dll、libc.so.6)。

我们以一个简单的C程序hello.c为例:

#include <stdio.h>
int main() { printf("Hello, World!\n"); return 0; }

使用gcc -static hello.c -o hello_static编译得到静态版本,使用gcc hello.c -o hello_dynamic编译得到动态版本。对比两者:

  • 文件大小:静态版本约800KB,动态版本约16KB。
  • 导入表:动态版本的.dynsym中包含printf、puts等符号,而静态版本中这些符号被解析为内部地址,导入表为空。
  • 入口点行为:动态版本的入口点_start会先调用__libc_start_main,该函数负责初始化动态链接器并加载共享库;静态版本的_start直接调用main。

在IDA Pro中打开动态版本,可以看到main函数内部对printf的调用是通过call printf@plt实现的。PLT(过程链接表)是动态链接的关键机制:第一次调用时,PLT跳转到GOT中的地址,如果该地址尚未解析,则触发动态链接器解析并更新GOT;后续调用直接跳转到已解析的地址。静态版本中,printf的代码直接内嵌在.text节区中,调用指令是call sub_401120,无需经过PLT。

分析要点:当你在Ghidra中看到大量__imp_前缀的函数(如__imp_MessageBoxW),说明这是动态链接的导入函数。这些函数在反编译视图中会显示为外部调用,双击可以跳转到导入表定义。而静态链接的程序中,所有函数都在文件内部,反编译视图会显示完整的函数体。

3.4 常见误区与总结复盘

初学者在处理这三种格式时,常犯以下错误:

  • 混淆RVA与文件偏移:PE中的RVA需要减去节区基址再加上文件偏移才能得到磁盘上的位置。ELF和Mach-O中,段映射关系由程序头表定义,不能直接使用文件偏移作为虚拟地址。
  • 忽略节区权限:现代系统强制NX(不可执行)保护,.data节区不可执行,.text节区不可写。在构造ROP链或shellcode时,必须确认目标节区的权限。
  • 误将导入表当作全部外部依赖:动态链接的程序可能通过dlopen/LoadLibrary在运行时加载额外库,这些库不会出现在静态导入表中。