第五章:ELF文件格式解析——ELF头、程序头表、节头表、符号表、重定位表详解
说到二进制安全,ELF文件格式是绕不开的坎。我刚开始逆向Linux程序时,面对一堆二进制数据完全摸不着头脑。后来花了整整一周,把ELF规范啃了一遍,才算真正入了门。今天我就带你把这几个核心结构彻底搞明白。
5.1 ELF文件概览
ELF(Executable and Linkable Format)是Linux下可执行文件、目标文件、共享库的标准格式。说白了,它就是操作系统和二进制程序之间的“契约”。
一个ELF文件从逻辑上分为三部分:
- ELF头(ELF Header):文件最开头,描述整个文件的元信息
- 程序头表(Program Header Table):告诉系统如何加载文件到内存
- 节头表(Section Header Table):告诉链接器如何链接各个节
我个人习惯把ELF想象成一本书:ELF头是封面和目录,程序头表是“如何阅读这本书”的指南,节头表是具体的章节索引。
核心要点:程序头表对运行时是必需的,节头表对链接和调试是必需的。可执行文件通常两者都有,但目标文件(.o)只有节头表。
5.2 ELF头详解
ELF头位于文件最开头,固定大小64字节(64位系统)或52字节(32位系统)。我当年第一次用hexdump看ELF头时,看到一堆0x7f 0x45 0x4c 0x46,这就是传说中的魔数“\x7fELF”。
关键字段如下:
| 偏移 | 字段 | 说明 |
|---|---|---|
| 0x00 | e_ident[16] | 魔数、字节序、架构等标识 |
| 0x10 | e_type | 文件类型:ET_REL(1)、ET_EXEC(2)、ET_DYN(3) |
| 0x12 | e_machine | 目标架构,x86-64是0x3e |
| 0x18 | e_entry | 程序入口地址 |
| 0x20 | e_phoff | 程序头表在文件中的偏移 |
| 0x28 | e_shoff | 节头表在文件中的偏移 |
| 0x30 | e_flags | 处理器相关标志 |
| 0x36 | e_ehsize | ELF头自身大小 |
| 0x38 | e_phentsize | 每个程序头表项的大小 |
| 0x3a | e_phnum | 程序头表项数量 |
| 0x3c | e_shentsize | 每个节头表项的大小 |
| 0x3e | e_shnum | 节头表项数量 |
| 0x40 | e_shstrndx | 节名字符串表所在的节索引 |
实战技巧:用readelf -h命令可以快速查看ELF头。我经常在逆向分析时先用这个命令确认文件类型和入口点。
5.3 程序头表——加载视图
程序头表描述的是“运行时视图”。每个程序头表项(Program Header)描述一个段(Segment),告诉内核如何将文件的一部分映射到内存。
常见的段类型:
- PT_LOAD (1):可加载段,需要映射到内存
- PT_DYNAMIC (2):动态链接信息
- PT_INTERP (3):解释器路径,如/lib64/ld-linux-x86-64.so.2
- PT_NOTE (4):辅助信息
- PT_GNU_STACK (0x6474e551):栈执行权限
- PT_GNU_RELRO (0x6474e552):只读重定位
每个程序头表项的结构:
typedef struct {
uint32_t p_type; // 段类型
uint32_t p_flags; // 段权限(R/W/X)
uint64_t p_offset; // 段在文件中的偏移
uint64_t p_vaddr; // 段在内存中的虚拟地址
uint64_t p_paddr; // 段在物理内存中的地址(通常不用)
uint64_t p_filesz; // 段在文件中的大小
uint64_t p_memsz; // 段在内存中的大小
uint64_t p_align; // 对齐要求
} Elf64_Phdr;
注意:p_filesz和p_memsz可能不同。比如.bss段在文件中不占空间(p_filesz=0),但在内存中需要分配空间(p_memsz>0)。我曾经调试一个程序时发现内存占用比文件大很多,就是这个原因。
5.4 节头表——链接视图
节头表描述的是“链接视图”。每个节头表项(Section Header)描述一个节(Section),链接器根据这些信息进行符号解析和重定位。
常见的节:
- .text:代码段,包含可执行指令
- .data:已初始化的全局变量
- .bss:未初始化的全局变量
- .rodata:只读数据,如字符串常量
- .symtab:符号表
- .strtab:字符串表
- .rela.text:.text节的重定位表
- .plt:过程链接表,用于动态链接
- .got:全局偏移表
节头表项结构:
typedef struct {
uint32_t sh_name; // 节名称在字符串表中的索引
uint32_t sh_type; // 节类型
uint64_t sh_flags; // 节标志(可写、可执行等)
uint64_t sh_addr; // 节在内存中的地址
uint64_t sh_offset; // 节在文件中的偏移
uint64_t sh_size; // 节的大小
uint32_t sh_link; // 关联的其他节索引
uint32_t sh_info; // 附加信息
uint64_t sh_addralign; // 对齐要求
uint64_t sh_entsize; // 如果节包含固定大小项,每项的大小
} Elf64_Shdr;
关键理解:程序头表是“如何运行”,节头表是“如何链接”。同一个ELF文件,运行时看程序头表,链接时看节头表。这就是所谓的“两种视图”。
5.5 符号表
符号表记录了程序中所有的符号信息——函数名、变量名、它们的地址和类型。逆向分析时,符号表就是我们的“地图”。
符号表项结构:
typedef struct {
uint32_t st_name; // 符号名在字符串表中的索引
unsigned char st_info; // 符号类型和绑定信息
unsigned char st_other;// 可见性
uint16_t st_shndx; // 符号所在的节索引
uint64_t st_value; // 符号的值(地址或偏移)
uint64_t st_size; // 符号的大小
} Elf64_Sym;
st_info字段包含两部分:
- 符号类型:STT_NOTYPE(0)、STT_OBJECT(1)、STT_FUNC(2)、STT_SECTION(3)、STT_FILE(4)
- 符号绑定:STB_LOCAL(0)、STB_GLOBAL(1)、STB_WEAK(2)
实用命令:nm、objdump -t、readelf -s都可以查看符号表。我一般先用nm快速浏览,再用readelf -s看详细信息。
5.6 重定位表
重定位表是链接器用来“修补”地址的。为什么需要重定位?因为编译时不知道最终的内存地址,需要链接器来填充正确的地址。
重定位表项结构(以RelA为例):
typedef struct {
uint64_t r_offset; // 需要重定位的位置
uint64_t r_info; // 重定位类型和符号索引
int64_t r_addend; // 加数
} Elf64_Rela;
r_info字段包含:
- 低32位:重定位类型
- 高32位:符号表索引
常见的重定位类型:
| 类型 | 值 | 说明 |
|---|---|---|
| R_X86_64_NONE | 0 | 无操作 |
| R_X86_64_64 | 1 | 64位绝对地址 |
| R_X86_64_PC32 | 2 | 32位PC相对地址 |
| R_X86_64_GOT32 | 3 | GOT表项偏移 |
| R_X86_64_PLT32 | 4 | PLT表项偏移 |
| R_X86_64_GLOB_DAT | 6 | 全局数据符号 |
| R_X86_64_JUMP_SLOT | 7 | 跳转槽 |
| R_X86_64_RELATIVE | 8 | 相对地址 |
避坑指南:我曾经在分析一个被strip过的程序时,发现符号表被删除了,但重定位表还在。这意味着函数名没了,但调用关系还能通过重定位表还原。很多恶意软件会strip掉符号表,但重定位表往往会被忽略——这就是突破口。
5.7 整体结构图
下面我用一张SVG图来展示ELF文件的整体结构,以及各个表之间的关系:
5.8 实战:用readelf解析ELF文件
光说不练假把式。我们拿一个实际的ELF文件来解剖一下:
# 查看ELF头
readelf -h /bin/ls
# 查看程序头表
readelf -l /bin/ls
# 查看节头表
readelf -S /bin/ls
# 查看符号表
readelf -s /bin/ls
# 查看重定位表
readelf -r /bin/ls
输出示例(简化):
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: 0x5850
Start of program headers: 64 (bytes into file)
Start of section headers: 132960 (bytes into file)
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
LOAD 0x000000 0x0000000000000000 0x0000000000000000 0x013e68 0x013e68 R E 0x1000
LOAD 0x013e68 0x0000000000014e68 0x0000000000014e68 0x000a50 0x000a50 RW 0x1000
DYNAMIC 0x013e78 0x0000000000014e78 0x0000000000014e78 0x0001e0 0x0001e0 RW 0x8
GNU_STACK 0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW 0x10
个人经验:我每次拿到一个未知的二进制文件,第一件事就是readelf -h和readelf -l。这两个命令能告诉我:这是不是ELF?是32位还是64位?入口在哪?有没有被strip?有没有奇怪的段?这些信息对后续分析至关重要。
5.9 常见问题与避坑
- 节头表被删除:strip命令会删除节头表,但程序头表还在。这时候readelf -S会报错,但程序照样能运行。逆向时需要用其他方法恢复符号信息。
- 符号表被strip:动态符号表(.dynsym)通常保留,因为动态链接需要它。静态符号表(.symtab)可能被删。用readelf -s --dyn-syms可以只看动态符号。
- 重定位表类型混淆:Rel和RelA的区别在于是否有加数(addend)。x86-64用RelA,x86用Rel。搞混了会导致地址计算错误。
- 字节序问题:x86和ARM都是小端,但PowerPC和MIPS可能是大端。解析时一定要检查e_ident[EI_DATA]字段。
我曾经踩过的坑:有一次分析一个MIPS架构的固件,直接用x86的解析工具去读,结果所有地址都是反的。后来才发现是字节序问题。从那以后,我每次解析ELF都会先检查字节序标志。
5.10 总结
ELF文件格式是二进制安全的基础。你想想看,无论是漏洞利用、恶意软件分析、还是固件逆向,都离不开对ELF结构的理解。掌握ELF头、程序头表、节头表、符号表、重定位表这五个核心结构,就等于拿到了打开Linux二进制世界大门的钥匙。
嗯,这一章的内容就到这里。记住,多动手用readelf和objdump去分析实际文件,比死记硬背结构体定义有用得多。