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 里找。
动态链接的关键流程:
- 内核加载 ELF 和解释器(通常是
/lib64/ld-linux-x86-64.so.2)。 - 解释器读取
.dynamic节,找到依赖的共享库。 - 加载共享库,解析符号,更新 GOT 表。
- 跳转到
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 的两条解析路径画清楚了。左边是加载视图(程序头),右边是链接视图(节头),底部是动态链接的核心机制。我个人做逆向时,会先看程序头确定内存布局,再看节头定位符号和代码段,最后通过 GOT/PLT 追踪外部函数调用。
嗯,ELF 的知识点就这些。记住一个原则:程序头是给运行时的,节头是给静态分析的,GOT/PLT 是给动态链接的。搞清楚了这三者的关系,ELF 文件在你眼里就是透明的了。