PE与ELF文件格式解析

文件格式这东西,说白了就是操作系统和可执行文件之间的「握手协议」。你在Windows上双击一个exe,系统怎么知道代码在哪、数据在哪、从哪里开始执行?这些信息都藏在文件头里。我刚开始学逆向时,总觉得看这些结构体很枯燥,直到有一次调试一个加壳样本,发现DOS头被篡改导致加载失败……嗯,从那以后我再也不敢跳过文件格式了。

PE文件结构

PE(Portable Executable)是Windows平台的可执行文件格式。它的设计思路其实挺清晰的——从DOS兼容性一路演进到32位/64位。整个结构分三层:

  • DOS头:64字节,以 MZ 签名开头。关键字段是 e_lfanew,它指向真正的PE头偏移。为什么要有这个?为了兼容DOS系统,老程序看到MZ就直接运行,新系统则顺着偏移去找NT头。
  • NT头:包含 Signature(PE\0\0)、FileHeader(机器类型、节数量、时间戳等)、OptionalHeader(入口点、镜像基址、节对齐等)。注意,OptionalHeader在PE32+里是必选的,名字有误导性。
  • 节表:每个节(Section)描述一块内存区域。常见的节有 .text(代码)、.data(已初始化数据)、.rdata(只读数据)、.idata(导入表)。

关键偏移速查:PE文件解析时,你只需要记住三个偏移:0x00(DOS头起始)、0x3C(e_lfanew字段)、e_lfanew指向的值(NT头起始)。其他字段都可以通过结构体偏移计算。

ELF文件结构

ELF(Executable and Linkable Format)是Linux/Unix世界的标准。它比PE更简洁,但概念上类似。我个人习惯把ELF看作三张表:

组件 作用 我踩过的坑
ELF头 文件标识、架构、入口点、程序头/节头偏移 有一次忘了检查 e_ident[EI_CLASS],把32位ELF当64位解析,段错误查了一下午
程序头 描述段(Segment),用于加载器映射到内存 PT_LOAD段的对齐属性没处理好,mmap会失败
节头 描述节(Section),用于链接器和调试器 .symtab和.strtab的关联关系容易搞混

你想想看,为什么ELF需要两套视图?程序头是给运行时加载器看的,节头是给链接器和调试器看的。一个面向执行,一个面向分析,分工明确。

动态链接与静态链接

这是CTF里绕不开的话题。静态链接把所有库代码打包进可执行文件,体积大但独立性强。动态链接依赖外部共享库(.so或.dll),体积小但需要运行时解析符号。

我建议你重点理解动态链接的延迟绑定(Lazy Binding)机制。为什么?因为CTF的ret2libc攻击就是利用GOT(全局偏移表)和PLT(过程链接表)的解析流程。具体来说:

  • GOT:存放外部函数的实际地址。第一次调用时,GOT条目指向PLT中的解析器;解析完成后,GOT更新为真实地址。
  • PLT:一段桩代码,负责调用动态链接器解析符号。每个外部函数对应一个PLT条目。
  • 延迟绑定:只有函数第一次被调用时才解析,节省启动时间。

实战技巧:在GDB中,用 got.plt 查看GOT条目,用 disas <func>@plt 查看PLT代码。我曾经靠这个技巧在比赛中快速定位了一个libc基址泄露漏洞。

PE vs ELF:核心差异对比

维度 PE ELF
签名 MZ + PE\0\0 \x7fELF
头结构 DOS头 + NT头 + 节表 ELF头 + 程序头 + 节头
导入机制 导入表(IDT) .dynsym + .dynstr + .plt.got
重定位 .reloc节 .rel.dyn / .rel.plt
入口点 AddressOfEntryPoint(相对基址) e_entry(虚拟地址)

为什么会这样?因为两个系统的设计哲学不同。Windows强调向后兼容(DOS头),Linux追求简洁统一(ELF头只有52字节)。

SVG:PE与ELF结构对比图

PE与ELF文件结构对比 DOS头 (64字节, MZ签名) NT头 (PE\0\0 + FileHeader + OptionalHeader) 节表 (.text, .data, .rdata, .idata) ELF头 (52/64字节, \x7fELF签名) 程序头 (PT_LOAD, PT_DYNAMIC, PT_INTERP) 节头 (.text, .data, .symtab, .strtab) 💡 关键区别: PE用DOS头兼容老系统,NT头集中管理;ELF用程序头/节头分离运行时与链接时视图。 CTF中:PE关注导入表劫持,ELF关注GOT/PLT覆盖。 解析流程对比: PE: 读DOS头 → 取e_lfanew → 读NT头 → 遍历节表 → 定位代码/数据 ELF: 读ELF头 → 取e_phoff → 读程序头 → 映射段 → 跳转入口点 两者核心都是「头 → 表 → 数据」的三级索引结构。

避坑指南

我曾经犯过的错:

  • 解析PE时直接读 0x00 当作NT头——忘了先读 e_lfanew,结果解析出一堆乱码。
  • 处理ELF动态链接时,以为 .got.plt 和 .got 是同一个节——其实前者是PLT专用的GOT,后者是全局GOT,偏移不同。
  • 静态链接的ELF没有 .plt 节,我写脚本时没做判断,直接段错误。

我的调试习惯:用 readelf -a 看ELF全貌,用 objdump -x 看PE节表。写解析脚本时,先打印头结构的前几个字节验证偏移,再逐步展开。这样能快速定位解析错误。

文件格式是逆向工程的地基。你花一小时把PE和ELF的头结构理清楚,后面调试、漏洞利用、加壳脱壳都会顺畅很多。别急,慢慢来。