二进制基础:PE、ELF、Mach-O文件格式解析
逆向工程的核心对象是二进制可执行文件。无论是Windows下的恶意软件分析、Linux下的漏洞挖掘,还是macOS/iOS平台的越狱插件开发,第一步都是理解目标文件的组织方式。PE、ELF、Mach-O是三大主流操作系统(Windows、Linux/Android、macOS/iOS)使用的可执行文件格式。它们虽然语法各异,但设计哲学高度一致:都包含文件头、节区(Section)表、程序头(Segment)表、入口点以及导入导出信息。本章将深入解析这三种格式的共性与差异,重点讲解节区与段的关系、入口点的定位方法、导入导出表的解析思路,并厘清静态链接与动态链接在二进制层面的本质区别。
上图展示了三种格式在宏观结构上的对应关系。可以看到,尽管文件头名称不同,但都遵循“头部描述元数据 → 节区承载代码数据 → 辅助表记录重定位与符号”的总体框架。理解这个框架,是后续使用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 + .dynstr | Elf32_Sym / Elf64_Sym + 重定位表 |
| Mach-O | __LINKEDIT 段的 dyld_info | __LINKEDIT 段的 symbol_table | dyld_info_command / nlist_64 |
在Ghidra中,导入表会被自动识别并生成外部函数引用。但手动分析时,可以遵循以下流程:
- 定位节区表:使用
readelf -S(ELF)、objdump -x(PE)或otool -l(Mach-O)查看节区名称和偏移。 - 解析符号表:对于ELF,
.dynsym存储了动态符号,每个符号项包含st_name(名称偏移)、st_value(地址)、st_info(类型和绑定属性)。 - 关联重定位表:ELF的
.rel.plt和.rela.plt记录了GOT(全局偏移表)中需要填充的地址。每个重定位项包含r_offset(GOT中的位置)和r_info(符号索引)。 - 验证导入函数:在IDA Pro中,打开“Imports”窗口,检查函数名和地址是否与节区偏移一致。如果发现导入表被破坏(如IAT加密),则需要动态调试或手动修复。
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。
__imp_前缀的函数(如__imp_MessageBoxW),说明这是动态链接的导入函数。这些函数在反编译视图中会显示为外部调用,双击可以跳转到导入表定义。而静态链接的程序中,所有函数都在文件内部,反编译视图会显示完整的函数体。
3.4 常见误区与总结复盘
初学者在处理这三种格式时,常犯以下错误:
- 混淆RVA与文件偏移:PE中的RVA需要减去节区基址再加上文件偏移才能得到磁盘上的位置。ELF和Mach-O中,段映射关系由程序头表定义,不能直接使用文件偏移作为虚拟地址。
- 忽略节区权限:现代系统强制NX(不可执行)保护,
.data节区不可执行,.text节区不可写。在构造ROP链或shellcode时,必须确认目标节区的权限。 - 误将导入表当作全部外部依赖:动态链接的程序可能通过
dlopen/LoadLibrary在运行时加载额外库,这些库不会出现在静态导入表中。