第五章: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文件的整体结构,以及各个表之间的关系:

ELF文件结构全景图 ELF头(ELF Header) 魔数、入口地址、程序头表偏移、节头表偏移 e_phoff e_shoff 程序头表(Program Header Table) PT_LOAD、PT_DYNAMIC、PT_INTERP... 运行时视图:如何加载到内存 节头表(Section Header Table) .text、.data、.bss、.symtab... 链接视图:如何链接和重定位 映射到内存 内存中的段(Segments) 代码段 | 数据段 | 动态段 | 栈段 索引到具体节 文件中的节(Sections) .text | .data | .bss | .rodata 符号表(Symbol Table) 函数名、变量名、地址、类型 重定位表(Relocation Table) 地址修补、符号引用解析 引用 关联

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 常见问题与避坑

  1. 节头表被删除:strip命令会删除节头表,但程序头表还在。这时候readelf -S会报错,但程序照样能运行。逆向时需要用其他方法恢复符号信息。
  2. 符号表被strip:动态符号表(.dynsym)通常保留,因为动态链接需要它。静态符号表(.symtab)可能被删。用readelf -s --dyn-syms可以只看动态符号。
  3. 重定位表类型混淆:Rel和RelA的区别在于是否有加数(addend)。x86-64用RelA,x86用Rel。搞混了会导致地址计算错误。
  4. 字节序问题:x86和ARM都是小端,但PowerPC和MIPS可能是大端。解析时一定要检查e_ident[EI_DATA]字段。

我曾经踩过的坑:有一次分析一个MIPS架构的固件,直接用x86的解析工具去读,结果所有地址都是反的。后来才发现是字节序问题。从那以后,我每次解析ELF都会先检查字节序标志。

5.10 总结

ELF文件格式是二进制安全的基础。你想想看,无论是漏洞利用、恶意软件分析、还是固件逆向,都离不开对ELF结构的理解。掌握ELF头、程序头表、节头表、符号表、重定位表这五个核心结构,就等于拿到了打开Linux二进制世界大门的钥匙。

嗯,这一章的内容就到这里。记住,多动手用readelf和objdump去分析实际文件,比死记硬背结构体定义有用得多。


公众号:蓝海资料掘金营,微信deep3321