ELF 文件格式解析:从二进制视角看程序骨架

说实话,搞 Pwn 这么多年,我见过太多人一上来就怼着漏洞代码猛看,结果连 ELF 头里的 entry point 在哪都不知道。这就像你要拆解一台发动机,却连它的外壳结构都不清楚——迟早要翻车。

ELF(Executable and Linkable Format)是 Linux 下可执行文件、共享库、目标文件的标准格式。你想想看,无论是你写的 C 程序编译出来的二进制,还是 glibc 里的 libc.so.6,甚至内核模块,底层都是这套结构。今天我就带你把它扒开看看。

核心认知:ELF 格式是理解漏洞缓解机制绕过的地基。GOT/PLT 怎么被劫持?重定位表怎么被篡改?段权限怎么被绕过?——所有高级技巧都建立在对 ELF 结构的透彻理解上。

4.1 ELF 文件整体布局

一个典型的 ELF 文件,从文件头开始,后面跟着三种主要的数据结构:程序头表(Program Header Table)、节区头表(Section Header Table),以及它们指向的各种节区(Sections)。

我个人习惯把 ELF 想象成三层结构:

  • 第一层:ELF 头——文件的身份证,告诉你这是什么类型、什么架构、入口在哪。
  • 第二层:程序头表——加载器(loader)的施工图,告诉系统「哪些数据要加载到内存的哪个位置,权限是什么」。
  • 第三层:节区头表——链接器和调试器的说明书,告诉编译器「符号在哪、代码段在哪、重定位信息在哪」。

嗯,这里要注意:程序头表对可执行文件是必需的,但对目标文件(.o)来说可有可无;反过来,节区头表对链接过程是必需的,但运行时加载器基本不关心它。

ELF 文件三层结构 ELF 头(ELF Header) e_ident | e_type | e_machine | e_entry | e_phoff | e_shoff | e_flags | e_ehsize | e_phentsize | e_phnum | e_shentsize | e_shnum | e_shstrndx 程序头表(Program Header Table) p_type | p_offset | p_vaddr | p_paddr | p_filesz | p_memsz | p_flags | p_align → 描述 LOAD / DYNAMIC / INTERP / GNU_STACK / GNU_RELRO 等段 节区(Sections) .text | .data | .bss | .rodata | .got | .got.plt | .plt | .plt.got .rela.dyn | .rela.plt | .dynsym | .dynstr | .init | .fini | .ctors | .dtors .init_array | .fini_array | .tdata | .tbss | .comment | .note.* 节区头表(Section Header Table) sh_name | sh_type | sh_flags | sh_addr | sh_offset | sh_size | sh_link | sh_info | sh_addralign | sh_entsize

4.2 ELF 头:一切从这里开始

用 readelf -h 看一个 ELF 文件,你看到的第一个东西就是 ELF 头。它固定 64 字节(64 位系统)或 52 字节(32 位系统)。

ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
  Class:                             ELF64
  Data:                              2's complement, little endian
  Entry point address:               0x401060
  Start of program headers:          64 (bytes into file)
  Start of section headers:          14856 (bytes into file)
  Number of program headers:         13
  Number of section headers:         31
  Section header string table index: 30

几个关键字段我提一下:

  • e_entry:程序入口地址。Pwn 里改这个值可以劫持执行流,不过现在有 IBT 和 Shadow Stack 保护,没那么好用了。
  • e_phoff / e_shoff:程序头表和节区头表在文件中的偏移。我曾经在 CTF 里见过一道题,把 e_shoff 改成 0 让节区头表「消失」,调试器直接懵逼。
  • e_shstrndx:节区名字符串表的索引。没有它,你看到的节区名就是一堆乱码。
调试技巧:用 xxd 配合 readelf 可以手动验证 ELF 头的每个字节。我习惯在分析未知二进制时先 dump 前 64 字节,确认文件类型和架构,避免被「伪装 ELF」的样本骗到。

4.3 程序头表:加载器的施工图

程序头表(Program Header Table)是 ELF 文件里对运行时最重要的结构。每个条目是一个 Elf64_Phdr,描述一个「段」(segment)。

用 readelf -l 可以看到:

Elf file type is DYN (Shared object file)
Entry point 0x1060
There are 13 program headers, starting at offset 64

Program Headers:
  Type           Offset   VirtAddr          PhysAddr          FileSiz  MemSiz   Flg  Align
  PHDR           0x000040 0x0000000000000040 0x0000000000000040 0x0002d8 0x0002d8 R    0x8
  INTERP         0x000318 0x0000000000000318 0x0000000000000318 0x00001c 0x00001c R    0x1
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
  LOAD           0x000000 0x0000000000000000 0x0000000000000000 0x0005d8 0x0005d8 R    0x1000
  LOAD           0x001000 0x0000000000001000 0x0000000000001000 0x0001d5 0x0001d5 R E  0x1000
  LOAD           0x002000 0x0000000000002000 0x0000000000002000 0x000150 0x000150 R    0x1000
  LOAD           0x002db0 0x0000000000003db0 0x0000000000003db0 0x000250 0x000258 RW   0x1000
  DYNAMIC        0x002dc0 0x0000000000003dc0 0x0000000000003dc0 0x0001e0 0x0001e0 RW   0x8
  GNU_STACK      0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW   0x10
  GNU_RELRO      0x002db0 0x0000000000003db0 0x0000000000003db0 0x000250 0x000250 R    0x1
  ...

这里我重点说几个:

程序头类型 作用 Pwn 视角
PT_LOAD 描述需要加载到内存的数据段 改 p_flags 可以给 .text 段加写权限,经典绕过 W^X
PT_DYNAMIC 指向 .dynamic 节区,包含动态链接信息 篡改里面的 DT_STRTAB 或 DT_SYMTAB 可以劫持符号解析
PT_GNU_STACK 控制栈的可执行权限 p_flags 如果包含 X,栈就是可执行的——NX 失效
PT_GNU_RELRO 标记哪些内存区域需要设为只读 Full RELRO 下 GOT 只读,Partial RELRO 下 GOT 可写
PT_GNU_EH_FRAME 异常处理帧信息 不太直接用于攻击,但可以用来定位栈展开信息
避坑指南:我曾经在分析一个加了混淆的样本时,发现它的 PT_LOAD 段 p_vaddr 和 p_offset 故意不对齐,导致加载器 mmap 失败。后来才意识到这是反调试技巧——某些加载器实现不严格,但标准 Linux 内核会直接拒绝执行。所以写 exploit 时,如果你要手动构造 ELF,一定要保证段对齐。

4.4 节区头表:链接器的说明书

节区头表(Section Header Table)对运行时加载器来说不是必需的,但对链接器、调试器、反汇编器来说至关重要。每个条目是一个 Elf64_Shdr。

用 readelf -S 可以看到:

Section Headers:
  [Nr] Name              Type             Address           Offset    Size     EntSize  Flags  Link  Info  Align
  [13] .text             PROGBITS         0000000000001020  00001020  00000101  00000000  AX       0     0     16
  [14] .rodata           PROGBITS         0000000000002000  00002000  0000002c  00000000   A       0     0     4
  [15] .data             PROGBITS         0000000000003db0  00002db0  00000010  00000000  WA       0     0     8
  [16] .bss              NOBITS           0000000000003dc0  00002dc0  00000008  00000000  WA       0     0     8
  [23] .got              PROGBITS         0000000000003f98  00002f98  00000010  00000008  WA       0     0     8
  [24] .got.plt          PROGBITS         0000000000003fa8  00002fa8  00000028  00000008  WA       0     0     8
  [25] .plt              PROGBITS         0000000000001030  00001030  00000030  00000010  AX       0     0     16

几个常见节区:

  • .text:代码段,可读可执行,不可写。Pwn 里我们想往这里写 shellcode?不行,除非你改了段权限。
  • .data:已初始化的全局变量,可读可写。格式化字符串漏洞经常利用这里存放的指针。
  • .bss:未初始化的全局变量,不占文件空间(FileSiz=0),但占内存空间。堆溢出经常覆盖到 .bss 里的关键变量。
  • .rodata:只读数据,比如字符串常量。格式化字符串漏洞的「%s」参数经常指向这里。
  • .got / .got.plt:全局偏移表,存放外部函数的实际地址。这是 Pwn 里最经典的攻击目标——GOT 覆写。
  • .plt:过程链接表,用于延迟绑定。每个外部函数在 PLT 里有一段桩代码。

4.5 动态链接与重定位:GOT/PLT 的核心机制

动态链接是 ELF 最精妙的设计之一。你想想看,你的程序调用了 printf,但编译时根本不知道 printf 在 libc 里的实际地址——因为 ASLR 会让每次加载的基址都不同。那怎么办?

答案就是 GOT 和 PLT 的配合。

4.5.1 延迟绑定(Lazy Binding)

默认情况下,外部函数第一次被调用时才解析地址。流程是这样的:

  1. 程序调用 printf@plt。
  2. PLT 里的桩代码跳转到 GOT 中对应的条目。
  3. 第一次调用时,GOT 条目里存的是 PLT 中下一条指令的地址(回退地址)。
  4. 控制流回到 PLT,调用动态链接器的 _dl_runtime_resolve 函数。
  5. 解析器找到 printf 在 libc 中的实际地址,写入 GOT 条目。
  6. 后续调用直接跳转到 GOT 里的真实地址,不再经过解析。

用代码表示就是:

; printf@plt 的典型实现
printf@plt:
  jmp    *printf@got.plt      ; 跳转到 GOT 条目
  push   $0x0                 ; 重定位索引
  jmp    .plt                 ; 跳转到公共 PLT 桩

; 第一次调用后,printf@got.plt 被改写为 printf 的真实地址
攻击视角:如果 GOT 可写(Partial RELRO),我们可以通过任意写漏洞把 GOT 条目改成 system 的地址。这样程序再调用 printf("/bin/sh") 时,实际上执行的是 system("/bin/sh")。这就是经典的 GOT 覆写攻击。

4.5.2 重定位表

重定位表记录了哪些位置需要在加载时或运行时被修正。用 readelf -r 查看:

Relocation section '.rela.dyn' at offset 0x4a0 contains 8 entries:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
000000003f98  000200000006 R_X86_64_GLOB_DAT 0000000000000000 __libc_start_main + 0
000000003fa0  000400000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0

Relocation section '.rela.plt' at offset 0x500 contains 3 entries:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
000000003fb8  000100000007 R_X86_64_JUMP_SLOT 0000000000000000 printf + 0
000000003fc0  000300000007 R_X86_64_JUMP_SLOT 0000000000000000 puts + 0
000000003fc8  000500000007 R_X86_64_JUMP_SLOT 0000000000000000 exit + 0

两种主要的重定位类型:

  • R_X86_64_GLOB_DAT:用于 .got 节区,在加载时直接解析。
  • R_X86_64_JUMP_SLOT:用于 .got.plt 节区,支持延迟绑定。

嗯,这里有个细节:Full RELRO 会把所有 GOT 条目在加载时就解析完,然后把整个 GOT 区域设为只读。这样你就没法通过任意写改 GOT 了。但 Partial RELRO 只保护了 .got,.got.plt 仍然是可写的。

4.6 常见段的内存布局与权限

最后,我用一张表总结一下各段在内存中的典型布局:

段/节区 典型地址范围(64位) 权限 Pwn 关注点
.text 0x401000 - 0x402000 R-X ROP gadget 来源,不可写
.rodata 0x402000 - 0x403000 R-- 字符串常量,格式化漏洞可读
.data 0x404000 - 0x405000 RW- 全局变量,可写可读
.bss 0x405000 - 0x406000 RW- 未初始化变量,溢出目标
.got.plt 0x406000 - 0x407000 RW-(Partial RELRO)
R--(Full RELRO)
GOT 覆写的主要目标
.plt 0x401020 - 0x401100 R-X PLT 桩代码,可执行
栈 0x7ffffffde000 - ... RW-(NX 开启)
RWX(NX 关闭)
栈溢出、ROP、ret2libc
堆 0x555555559000 - ... RW- 堆溢出、UAF、Tcache 攻击
实战建议:拿到一个二进制后,我习惯先跑 readelf -l 看 RELRO 状态,再跑 readelf -S 看 GOT 地址范围。这两个信息直接决定了你的攻击路径——是走 GOT 覆写,还是走栈/堆溢出,还是走 ret2dlresolve。

ELF 格式的内容到这里就差不多了。说实话,这些结构看起来枯燥,但你在 Pwn 里遇到的每一个漏洞利用技巧,最终都会落到这些结构的某个字段上。理解了它们,你再看那些花里胡哨的 exploit,会发现底层逻辑其实就那么几招。