4、ELF文件格式深度解析:ELF头与程序头、节头与节、动态链接与符号表、GOT与PLT机制

说到ELF,很多搞逆向的朋友第一反应就是“看头、找节、跟跳转”。但说实话,真正把ELF吃透的人并不多。我早年做固件逆向时,就因为没搞懂程序头和节头的区别,硬生生在内存映射上浪费了两天时间。今天咱们就把这块骨头啃干净。

4.1 ELF头(Ehdr)—— 文件的“身份证”

每个ELF文件的开头都是一个固定大小的头部,64位下是64字节。它告诉操作系统和调试器三件事:这是个啥文件、目标架构是啥、程序头和节头表在哪儿。

核心字段速查:

  • e_ident[0..3]:魔数 0x7f 'E' 'L' 'F',我见过有人改这个来反调试。
  • e_type:ET_REL (1) 可重定位、ET_EXEC (2) 可执行、ET_DYN (3) 共享对象。
  • e_machine:EM_X86_64 (62) 或 EM_ARM (40),决定了后续指令集解析方式。
  • e_phoff / e_shoff:程序头和节头表的文件偏移,这是解析的入口。
  • e_entry:虚拟地址入口点,动态链接的ELF通常指向_start。

我个人习惯用 readelf -h 先扫一眼头部,确认文件没被篡改。有一次在分析恶意样本时,发现攻击者把 e_shoff 改成了0,导致节头表无法读取——典型的“节头擦除”反逆向技巧。

4.2 程序头(Phdr)—— 加载器眼中的“地图”

程序头表是给内核加载器看的。它描述了如何将文件映射到内存。每个程序头是一个 Elf64_Phdr 结构,8字节对齐,共56字节。

字段 说明 我踩过的坑
p_type PT_LOAD (1) 可加载段、PT_DYNAMIC (2) 动态信息、PT_INTERP (3) 解释器路径 PT_GNU_STACK 控制栈是否可执行,漏洞利用常改它
p_offset 段在文件中的偏移 偏移不对会导致 mmap 失败,我遇到过对齐问题
p_vaddr 段在内存中的虚拟地址 PIE 下是相对地址,调试时要算基址
p_filesz / p_memsz 文件大小 vs 内存大小 .bss 段 memsz > filesz,多出的部分清零
p_flags PF_R (4) 读、PF_W (2) 写、PF_X (1) 执行 W^X 保护下,同时有写和执行就是异常

嗯,这里要注意:程序头里的 p_vaddr 是虚拟地址,不是文件偏移。我见过新手直接用这个值去文件里读数据,结果读出来全是乱的。正确的做法是用 p_offset 定位文件内容。

实战技巧:用 readelf -l 查看程序头,重点关注 PT_LOAD 段。如果某个 PT_LOAD 段的 p_flags 同时包含 PF_W 和 PF_X,那这个程序很可能有堆栈执行漏洞。

4.3 节头(Shdr)与节(Section)—— 链接器的“零件清单”

节头表是给链接器和调试器用的。每个节头描述一个节(Section),比如 .text 放代码、.data 放数据、.bss 放未初始化数据。

节头的关键字段:

  • sh_name:节名字符串在 .shstrtab 中的索引。
  • sh_type:SHT_PROGBITS (1) 程序数据、SHT_SYMTAB (2) 符号表、SHT_STRTAB (3) 字符串表、SHT_DYNAMIC (6) 动态链接信息。
  • sh_addr:节在内存中的虚拟地址(如果被加载的话)。
  • sh_offset:节在文件中的偏移。
  • sh_size:节的大小。

为什么要有两套视图?你想想看:程序头是给运行时用的,只关心哪些数据要加载到内存;节头是给静态分析用的,需要知道每个节的类型和名字。我早期做固件逆向时,经常需要根据节头重建符号信息,因为固件里通常没有程序头。

避坑指南:strip 命令会删除节头表,但程序头还在。被 strip 过的 ELF 文件,用 readelf -S 看不到节信息,但用 readelf -l 依然能看段信息。我曾经在分析一个被 strip 的样本时,以为文件损坏了,后来才反应过来是节头被删了。

4.4 动态链接与符号表(.dynsym)

动态链接的ELF文件依赖外部共享库(如 libc.so)。链接器在加载时解析这些依赖,把符号地址填进去。符号表 .dynsym 记录了所有导入导出符号。

符号表条目 Elf64_Sym 结构:

  • st_name:符号名字符串在 .dynstr 中的索引。
  • st_value:符号的地址或偏移。
  • st_size:符号大小(函数或变量)。
  • st_info:符号类型和绑定属性(STB_GLOBAL、STB_WEAK、STT_FUNC、STT_OBJECT)。
  • st_shndx:符号所在的节索引。

我个人习惯用 readelf -s 查看符号表。如果看到 UND(未定义)的符号,那就是需要动态链接器解析的导入函数。比如 printf 在编译时不知道地址,运行时由链接器从 libc 里找。

动态链接的关键流程:

  1. 内核加载 ELF 和解释器(通常是 /lib64/ld-linux-x86-64.so.2)。
  2. 解释器读取 .dynamic 节,找到依赖的共享库。
  3. 加载共享库,解析符号,更新 GOT 表。
  4. 跳转到 e_entry 执行程序。

4.5 GOT与PLT机制——延迟绑定的艺术

GOT(全局偏移表)和 PLT(过程链接表)是动态链接的核心机制。它们实现了“延迟绑定”:函数第一次调用时才解析地址,后续调用直接跳转。

PLT 的结构很简单:每个外部函数对应一个 PLT 条目,里面是一小段跳转代码。第一次调用时,PLT 跳转到 GOT 表项,GOT 表项初始指向 PLT 的下一条指令(即回退到解析器)。解析器找到函数地址后,更新 GOT 表项,后续调用直接跳转到目标函数。

GOT 表项的类型:

  • GOT[0]:指向 .dynamic 节地址。
  • GOT[1]:指向 link_map 结构,用于符号解析。
  • GOT[2]:指向 _dl_runtime_resolve 函数。
  • GOT[3+]:外部函数的实际地址(初始为 PLT 回退地址)。

为什么会这样设计?说白了就是为了性能。如果程序有100个外部函数,但只用了10个,延迟绑定能省下90次符号解析的开销。我做过一个实验:在大型 C++ 程序里,延迟绑定能减少 30% 以上的启动时间。

逆向技巧:在 GDB 中,用 x/3gx &got.plt 查看 GOT 表的前三项。如果 GOT[3+] 的值指向 PLT 内部(即未解析状态),说明该函数还没被调用过。这在分析恶意软件时很有用——可以判断哪些功能被触发了。

PLT 的典型代码(x86-64):

; printf@plt
printf@plt:
  jmp    *printf@got.plt      ; 跳转到 GOT 表项
  push   $0x0                 ; 函数索引(用于解析器)
  jmp    .plt                  ; 跳转到公共 PLT 解析入口

第一次执行时,GOT 表项指向 push $0x0 这条指令,所以会执行 push 和 jmp,进入解析器。解析器根据索引找到 printf 的地址,写入 GOT 表项。第二次调用时,jmp 直接跳转到 printf 的真实地址。

漏洞利用点:GOT 表是可写的(在 .got.plt 节中)。如果程序有任意写漏洞,攻击者可以覆盖 GOT 表项,将函数调用劫持到恶意代码。这就是经典的 GOT 覆写攻击。我曾在 CTF 比赛中用这个技巧,把 free 的 GOT 表项改成 system 的地址,实现了任意命令执行。

4.6 知识体系总览

下面这张图把 ELF 的核心结构串起来了。你可以把它当作逆向分析的“地图”:从 ELF 头出发,通过程序头找到加载段,通过节头找到符号表和 GOT/PLT,最终定位到具体的函数和数据。

ELF文件结构总览图 ELF 文件结构总览 ELF 头 (Ehdr) 程序头表 (Phdr) 加载视图:段映射到内存 节头表 (Shdr) 链接视图:节类型与属性 PT_LOAD 段(代码/数据) .text / .data / .bss 等 .dynamic / .dynsym / .dynstr .got.plt / .plt(延迟绑定) 核心思路:ELF头 → 程序头(加载)或节头(链接)→ 动态符号 → GOT/PLT 逆向分析时,先看程序头确定内存布局,再看节头定位符号和代码段

这张图把 ELF 的两条解析路径画清楚了。左边是加载视图(程序头),右边是链接视图(节头),底部是动态链接的核心机制。我个人做逆向时,会先看程序头确定内存布局,再看节头定位符号和代码段,最后通过 GOT/PLT 追踪外部函数调用。

嗯,ELF 的知识点就这些。记住一个原则:程序头是给运行时的,节头是给静态分析的,GOT/PLT 是给动态链接的。搞清楚了这三者的关系,ELF 文件在你眼里就是透明的了。