ELF格式解析

TL;DR

一个 ELF 文件同时被四个角色读写:编译器产出它、链接器改造它、内核加载器消费它、工具与攻击者审视它。这四个读者看的是同一坨字节,但需求完全不同。ELF 的解法是把文件切成两套正交的索引表:

  • Program Header Table(段视图)——给加载器看,描述“运行时往内存里怎么放”
  • Section Header Table(节视图)——给链接器和工具看,描述“逻辑上由什么组成”

把这两套视图拆开理解,GOT/PLT、重定位、.bss 不占磁盘、RELRO 这些零碎知识就都能挂到同一根骨架上。

以 gcc -O0 -no-pie 编译的一个 hello 为样本,从头拆一遍。


起点:一坨字节,四个读者

先问一个容易被跳过的问题:为什么要有可执行文件格式?

CPU 只会拿着 pc 去取指令。真要让一个程序跑起来,至少得有人告诉内核三件事:代码在哪、数据在哪、从哪一条指令开始执行。最朴素的方案是“把整个文件按固定偏移搬进内存”,但马上会遇到矛盾:

  • 编译器只关心一件事:把源码变成机器码和符号,它还不知道内存地址;
  • 链接器才决定地址,但它面对的是多个 .o 和一堆共享库;
  • 加载器只想“按页映射、设好权限、跳过去”,它不关心一个字节属于哪个函数;
  • readelf/gdb/pwntools 想按名字定位 .text、.got、main。

一个 .o 里到处是“待填的地址”,一个可执行文件里地址已经写死,一个共享库又要求“谁把我装进内存就按谁的基址平移”。同一种容器要同时满足这些互相冲突的需求,这才是 ELF 真正在解决的问题。


骨架:ELF Header

任何 ELF 文件都以固定 64 字节(64 位)或 52 字节(32 位)的头部开头:

00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000  .ELF............
00000010: 0200 3e00 0100 0000 4010 4000 0000 0000  ..>.....@.@.....
00000020: 4000 0000 0000 0000 5836 0000 0000 0000  @.......X6......
00000030: 0000 0000 4000 3800 0e00 4000 1e00 1d00  ....@.8...@.....

前四个字节 7f 45 4c 46 就是 \x7f E L F,file 命令、ld.so、gdb 都靠它认亲。头部里真正关键的是最后几项“指针”,它们把整个文件的布局串起来:

字段本例值含义
e_ident[4]2ELFCLASS64(1=32 位)
e_ident[5]1小端序
e_type2ET_EXEC(可执行文件)
e_machine0x3eEM_X86_64(62)
e_entry0x401040第一条执行的指令地址
e_phoff64Program Header Table 的文件偏移
e_shoff13912Section Header Table 的文件偏移
e_phnum / e_shnum14 / 30两张表的条目数
e_shstrndx29节名字符串表的下标

e_type 是最先要分清的一环,它决定这个文件“处于生命周期的哪一站”:

值类型谁产出的
ET_REL (1)可重定位目标文件编译器产出的 .o
ET_EXEC (2)可执行文件链接器产出,地址写死
ET_DYN (3)共享对象 / PIE链接器产出,可被加载到任意基址
ET_CORE (4)core dump崩溃时内核写出

注意 e_phoff 和 e_shoff 这两个指针——整篇文章其实都在这两张表上打转。


一个文件,两套视图

readelf -l 看的是 program header,readelf -S 看的是 section header。同一个 hello,两边给出的东西完全不一样:

Program Headers:
  Type           Offset   VirtAddr           FileSiz  MemSiz   Flg Align
  PHDR           0x000040 0x0000000000400040 0x000310 0x000310 R   0x8
  INTERP         0x000394 0x0000000000400394 0x00001c 0x00001c R   0x1
  LOAD           0x000000 0x0000000000400000 0x0004f8 0x0004f8 R   0x1000
  LOAD           0x001000 0x0000000000401000 0x000149 0x000149 R E 0x1000
  LOAD           0x002000 0x0000000000402000 0x0000fc 0x0000fc R   0x1000
  LOAD           0x002df8 0x0000000000403df8 0x000224 0x000230 RW  0x1000
  DYNAMIC        0x002e08 0x0000000000403e08 0x0001d0 0x0001d0 RW  0x8
  GNU_STACK      0x000000 0x0000000000000000 0x000000 0x000000 RW  0x10
  GNU_RELRO      0x002df8 0x0000000000403df8 0x000208 0x000208 R   0x1
Section Headers:
  [Nr] Name              Type            Address          Off    Size   Flg
  [ 1] .interp           PROGBITS        0000000000400394 000394 00001c  A
  [ 5] .dynsym           DYNSYM          00000000004003d0 0003d0 000060  A
  [11] .init             PROGBITS        0000000000401000 001000 000017  AX
  [12] .plt              PROGBITS        0000000000401020 001020 000020  AX
  [13] .text             PROGBITS        0000000000401040 001040 000100  AX
  [15] .rodata           PROGBITS        0000000000402000 002000 000007  A
  [22] .got              PROGBITS        0000000000403fd8 002fd8 000010  WA
  [23] .got.plt          PROGBITS        0000000000403fe8 002fe8 000020  WA
  [24] .data             PROGBITS        0000000000404008 003008 000014  WA
  [25] .bss              NOBITS          000000000040401c 00301c 00000c  WA
  [27] .symtab           SYMTAB          0000000000000000 003040 000360
  [28] .strtab           STRTAB          0000000000000000 0033a0 00019f

两张表最本质的差异:

Program Header(段)Section Header(节)
粒度粗,面向内存页权限细,面向逻辑分类
读者内核 / ld.so链接器 / readelf / gdb
是否必需必需,运行时靠它可选,strip 后可删
.o 里有吗没有(还没到布局阶段)有
权限字段R/W/EAX/WA 等字母标记
例子LOAD、INTERP、RELRO.text、.got、.symtab

为什么非得拆成两套?因为权限是按页给的,而逻辑是按功能分的。看 Section to Segment mapping 那一行:

03     .init .plt .text .fini
04     .rodata .eh_frame_hdr .eh_frame .note.ABI-tag
05     .init_array .fini_array .dynamic .got .got.plt .data .bss

加载器只认识四个 LOAD:只读、只执行、只读、可读写。至于 .text 和 .plt 是两个 section 还是一个,它根本不关心。反过来说,.rodata 和 .eh_frame 在逻辑上风马牛不相及,运行时却共享同一个只读页——把权限模型和逻辑模型强行合并只会制造矛盾。于是分成两张表,各服务一类读者。

这也解释了一个常被问到的现象:strip 掉符号表(连同一堆 section header)后程序照样能跑。因为运行时只读 program header,section header 只是给链接和调试用的旁白。


从 .c 到进程

第一站:.o(地址全 0)

$ readelf -h h.o | grep Type
  Type:  REL (Relocatable file)

这个时候文件里没有任何“最终地址”的概念。看符号表:

Num:    Value          Size Type    Bind   Vis      Ndx Name
    4: 0000000000000000     4 OBJECT  GLOBAL DEFAULT    3 g
    5: 0000000000000000     4 OBJECT  GLOBAL DEFAULT    4 u
    6: 0000000000000000    26 FUNC    GLOBAL DEFAULT    1 main
    7: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND puts

每个符号的 Value 都是 0,Ndx 指向的是“第几个 section”,而不是地址。几个字段值得停顿一下:

  • Bind:LOCAL 还是 GLOBAL(导出/需要外部解析);
  • Type:FUNC、OBJECT、SECTION、FILE;
  • Ndx = UND:这个符号未定义,puts 属于这一类——.o 里不知道它在哪,只能记一笔“欠着”。

欠账怎么记?记在重定位表里:

Relocation section '.rela.text':
    Offset             Info             Type               Symbol's Name + Addend
0000000000000007  0000000300000002 R_X86_64_PC32           .rodata - 4
000000000000000f  0000000700000004 R_X86_64_PLT32          puts - 4

反汇编 .o 能看到对应的空位:

call   0            ; 待重定位,目标是 puts

编译器在这里只留了占位,Symbol's Name 告诉链接器“这里要填 puts 的地址”。

第二站:链接与重定位

链接器干两件事,顺序不能反:

  1. 符号解析:把每个 UND 的 puts 对上某个库里定义的同名符号;
  2. 重定位:合并同类 section、给每个 section 分配最终虚拟地址,然后回头把第一站留下的空位填上。

一条 R_X86_64 重定位条目就三个信息:r_offset(在哪个位置填)、r_info(填哪个符号、用哪种算法)、r_addend(加数)。常见类型:

类型场景含义
R_X86_64_PC32同模块内的相对引用S + A - P
R_X86_64_PLT32调用外部函数指向该函数的 PLT 桩
R_X86_64_GLOB_DAT数据符号填 GOT 条目
R_X86_64_JUMP_SLOT函数符号填 .got.plt 条目

于是 .o 里那句 call 0,在可执行文件里变成:

401134:  e8 f7 fe ff ff    call   401030 <puts@plt>

地址 0x401030 不是 puts 在 libc 里的地址,而是 .plt 里一个桩的地址——这是第三站和动态链接的伏笔。

第三站:加载与 execve

程序被执行时,内核的路径大致是:

  1. 读 ELF header,校验 magic、e_type、e_machine;
  2. 遍历 program header,对每个 PT_LOAD 调用 mmap,把文件的 p_offset 起 p_filesz 字节映射到 p_vaddr,权限取自 p_flags(R/W/E 对应 PF_R/PF_W/PF_X);
  3. 见到 PT_INTERP,先把动态链接器 /lib64/ld-linux-x86-64.so.2 也映射进来;
  4. 控制权交给 e_entry(本例 0x401040,即 _start)。

第 2 步里藏着 .bss 的秘密。看那个可写段:

LOAD  0x002df8  0x403df8   FileSiz 0x224   MemSiz 0x230   RW  0x1000

MemSiz - FileSiz = 0xc,而 .bss 大小正好是 0xc:

[25] .bss   NOBITS  000000000040401c  00301c  00000c  WA

.bss 的类型是 NOBITS——它在文件里不占内容,sh_offset 只是一个空壳。内核把 p_filesz 映射完后,把多出来的 p_memsz - p_filesz 用匿名零页补齐(mmap 的 MAP_ANONYMOUS)。所以“未初始化的全局变量不进磁盘镜像”不是编译器的小聪明,而是加载器按两个 size 的差值主动清零的结果。int u; 全局变量,磁盘上确实一个字节都没为它花。

再看 p_align = 0x1000,并且所有 LOAD 都满足 p_offset ≡ p_vaddr (mod 0x1000):

LOAD  0x001000  0x401000   ...
LOAD  0x002000  0x402000   ...
LOAD  0x002df8  0x403df8   ...   # 0xdf8 ≡ 0xdf8 (mod 0x1000)

这不是巧合,而是硬性要求。mmap 只能整页对齐,如果段在文件里和内存里相对页首的偏移对不上,就没法直接映射,得拆成“前后两段单独拷贝”。所以链接器必须保证这个同余关系。可写段 p_vaddr = 0x403df8 并非页对齐,内核仍从 0x403000 这一页开始映射,段内容落在页内 0xdf8 偏移处——这也解释了为什么下面 RELRO 区域会和 .got.plt 挤在同一页里。

最后看入口 _start:

401040:  xor    ebp,ebp
401042:  mov    r9,rdx
401045:  pop    rsi
...
401054:  mov    rdi,401126        ; main
...
40105b:  call   __libc_start_main@plt

_start 并不直接调 main,而是把 main 的地址当参数交给 __libc_start_main,由 libc 完成堆、TLS、atexit 等初始化后再调用它。C 程序的“入口”其实比 main 早得多。


动态链接:最绕的一环

静态链接把库代码整个拷进可执行文件,简单但浪费:每份程序一份 libc。动态链接把它换成“运行时再解析”,代价是引入了 ELF 里最绕的两个节——.got 和 .plt。

问题很直接:调用 puts 时,libc 会被加载到哪个地址? 对 PIE 和共享库来说,编译期根本不知道。那 call 指令该填什么?

答案是:别填真实地址,填一张间接表的地址。

  • .plt(Procedure Linkage Table)在代码段,放可执行的小跳板;
  • .got.plt(Global Offset Table)在数据段,放真实的函数地址。

call puts@plt 跳到 PLT 桩,桩再通过 GOT 里的指针跳走:

<puts@plt>:
  401030:  ff 25 ca 2f 00 00   jmp  QWORD PTR [rip+0x2fca]   # 404000 → GOT 槽
  401036:  68 00 00 00 00      push 0x0                    # 重定位表下标
  40103b:  e9 e0 ff ff ff      jmp  401020                 # 跳 PLT[0]

<PLT[0]>:
  401020:  ff 35 ca 2f 00 00   push QWORD PTR [rip+0x2fca]   # 403ff0 → link_map
  401026:  ff 25 cc 2f 00 00   jmp  QWORD PTR [rip+0x2fcc]   # 403ff8 → _dl_runtime_resolve

首次调用时,0x404000 这个 GOT 槽还指向 PLT 桩内部的下一条指令(push 0),于是流程拐进 PLT[0],把 link_map 和符号下标压栈,跳进 _dl_runtime_resolve。解析器查 .dynsym/.dynstr 找到 puts 的真实地址,回填 GOT 槽。第二次调用,jmp [0x404000] 就直接落到 libc 里了。这就是 lazy binding。

用 readelf -r 能看到两类重定位分别落进哪张表:

.got  (数据符号,启动前解析):
0000000000403fd8  R_X86_64_GLOB_DAT   __libc_start_main@GLIBC_2.34
0000000000403fe0  R_X86_64_GLOB_DAT   __gmon_start__

.got.plt  (函数符号,首次调用解析):
0000000000404000  R_X86_64_JUMP_SLOT  puts@GLIBC_2.2.5

.dynsym(动态符号表)是给运行时解析器用的,和第一站那个 .symtab 是两套东西:.symtab 给链接器和调试用,可 strip;.dynsym 必须保留,否则运行时找不到符号。

RELRO 就是在这里动手脚。 默认的 partial RELRO(GNU_RELRO 段覆盖 .init_array .fini_array .dynamic .got)会在启动后把 .got 等区域改成只读,但为了 lazy binding 必须给 .got.plt 留写权限。加上 -z now(立即绑定)后,所有符号在启动时解析完毕,.got.plt 也能一并只读——这就是 full RELRO,checksec 里 Full RELRO 的来源。


pwn 视角:攻击面

前面每一节都能换成攻击语言重讲一遍:

  • GOT 可写 = 任意函数指针。partial RELRO 下 .got.plt 可写,一旦拿到任意写(%n、堆溢出),把 puts 的 GOT 槽改成 system,再让程序 puts("/bin/sh"),执行流就接管了。
  • PLT 是已知地址的跳板。PLT 桩在主程序里,即使 libc 因 ASLR 地址随机,puts@plt 的地址在非 PIE 下也是固定的。ret2plt 先调用 PLT 里的 puts 泄露某个 libc 地址,再算出 system 的真实地址——这是绕过 ASLR 的标准第一步。
  • .init_array / .fini_array 是函数指针数组。它们位于 GNU_RELRO 覆盖的区间,partial/full RELRO 都会在启动后把它改成只读,只有完全没有 RELRO 时才可写、才谈得上劫持。
  • 保护开关都是 ELF 字段决定的:
    保护由 ELF 里什么决定
    NXGNU_STACK 的 p_flags 是否含 E(本例 RW = 不可执行)
    PIEe_type 是 ET_EXEC 还是 ET_DYN
    RELRO是否存在 GNU_RELRO 段,以及绑定时机
    Canary靠 .symtab 里能否找到 __stack_chk_fail 判断
  • ROP gadget 从可执行段里挖。R E 那个 LOAD(.text .plt .init .fini)就是 gadget 的矿场;.rodata 提供 /bin/sh 这类字符串。
  • pwntools 的 ELF() 干的事,就是解析上面两张表,把 symbols、got、plt、relro、nx、pie 都读出来。

一句话概括:链接器和加载器为了性能与灵活性写进 ELF 的每一个“间接层”,对攻击者都是一次可控的跳转机会。


手工验证速查

file ./a.out                  # 一眼看类型、位数、是否 PIE、是否 stripping
readelf -h ./a.out            # ELF header:e_type / e_entry / 两张表偏移
readelf -lW ./a.out           # program header:段视图、权限、RELRO
readelf -SW ./a.out           # section header:节视图、地址、标志
readelf -rW ./a.out           # 重定位:GLOB_DAT / JUMP_SLOT 各在哪
readelf -sW ./a.out           # 符号表:UND / GLOBAL / 地址
readelf -dW ./a.out           # .dynamic:NEEDED / PLTGOT / INIT_ARRAY
objdump -d -M intel ./a.out   # 反汇编,配 -j .plt 单看桩
ldd ./a.out                   # 运行时依赖哪些共享库

一个常被忽略的对照实验:把 -no-pie 换成默认的 PIE,e_type 从 ET_EXEC 变 ET_DYN,e_entry 从 0x401040 变成 0x1050——所有虚拟地址的基准都挪到了接近 0 的位置,真正的基址要等加载时才由内核随机决定。


总结

问题答案
为什么需要 ELF编译器、链接器、加载器、工具四类读者需求冲突,需要统一容器
核心设计一个文件两套索引:段(运行时/权限)与节(链接时/逻辑)正交
.o 有什么特别ET_REL,符号地址全 0,靠 .rela.* 记下待填位置
链接器做什么符号解析 + 重定位,把 .o 的占位填成最终地址
加载器看什么只看 PT_LOAD,按页 mmap,用 p_memsz - p_filesz 补齐 .bss
动态链接怎么调函数call → .plt 桩 → .got.plt →(首次经 _dl_runtime_resolve 回填)libc
RELRO 管什么启动后把 .got(full 时含 .got.plt)改成只读,掐断 GOT 劫持
pwn 怎么利用GOT 可写做函数指针覆盖、PLT 做地址泄露、RELRO/NX/PIE 全由 ELF 字段决定

一句话:读懂 readelf -l 和 readelf -S 两张表,ELF 就不再是一堆散落的节名,而是一条从编译器到内核、再一路通向利用链的完整脉络。


参考资料

recent-work

ELF格式解析

TL;DR 一个 ELF 文件同时被四个角色读写:编译器产出它、链接器改造它、内核加载器消费它、工具与攻击者审视它。这四个读者看的是同一坨字节,但需求完全不同。ELF 的解法是把文件切成两套正交的索引表: Program Header Table(段视图)——给加载器看,描述“运 …

Read more →

NEMU RISC-V32 指令模式匹配与汇编级优化

TL;DR 本文沿着一条「先问为什么,再看怎么做」的线索,拆开 NEMU 的 RISC-V32 指令解码:从「怎么知道当前是哪条指令」这个问题出发,引出模式字符串,再手推 pattern_decode 如何把它变成 key/mask/shift,最后贴出宿主机 Intel 汇编, …

Read more →