第4章:ELF文件结构解析——ELF头、程序头表、节头表、符号表、重定位表详解

说实话,搞逆向这么多年,ELF文件就像我的老朋友。每次拿到一个陌生的二进制文件,第一件事就是扒开它的ELF头看看。嗯,今天我就带你把这个老朋友彻底看透。

ELF(Executable and Linkable Format)是Linux下最主流的可执行文件格式。你想想看,无论是你写的C程序编译出来的a.out,还是系统里的/lib/libc.so.6,甚至内核模块.ko文件,统统都是ELF格式。搞懂它,就等于拿到了Linux逆向的钥匙。

4.1 ELF文件整体布局

一个典型的ELF文件,从结构上看分这么几块:

  • ELF头(ELF Header):文件最开头,描述文件的基本属性
  • 程序头表(Program Header Table):告诉系统怎么加载这个文件到内存
  • 节头表(Section Header Table):描述文件中各个节(Section)的信息
  • 各个节(Sections):实际的数据内容,比如代码、数据、符号表等

我习惯把ELF想象成一栋楼:ELF头就是大楼的铭牌,程序头表是施工图纸(告诉工人怎么建),节头表是楼层索引(告诉访客每层有什么)。

ELF文件结构总览 ELF Header(ELF头) Program Header Table(程序头表) Sections(节区) .text .data .bss .rodata .symtab .strtab .rel.text .debug Section Header Table(节头表)

4.2 ELF头(ELF Header)深度解析

ELF头位于文件最开头,固定大小。32位系统是52字节,64位系统是64字节。我每次拿到一个陌生样本,第一件事就是读它的ELF头——这就像看一个人的身份证。

用readelf命令可以轻松查看:

$ readelf -h /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:          132928 (bytes into file)
  Number of program headers:         13
  Number of section headers:         30
  Section header string table index: 29

关键字段我挑几个重点说:

字段含义我的经验
e_ident[0..3]魔数:0x7f 'E' 'L' 'F'文件损坏时第一个检查这里
e_type文件类型:ET_REL(1)、ET_EXEC(2)、ET_DYN(3)ET_DYN是PIE或共享库,逆向时要注意基址随机化
e_machine架构:EM_X86_64(62)、EM_ARM(40)判断是x86还是ARM,别搞混了指令集
e_entry程序入口地址调试时直接跳到这里开始分析
e_phoff程序头表偏移加载器从这里开始读程序头
e_shoff节头表偏移链接器或调试器用这个定位节信息
小技巧: 我经常用 xxd 配合 ELF 头结构手动解析。比如魔数 7f 45 4c 46 一眼就能认出是 ELF 文件。有一次遇到一个伪装成 .txt 的恶意样本,就是靠魔数识破的。

4.3 程序头表(Program Header Table)

程序头表是给加载器(loader)看的。它告诉操作系统:哪些数据要加载到内存,加载到什么地址,有什么权限。说白了,就是内存映射的说明书。

每个程序头条目(Program Header)的结构如下:

typedef struct {
    uint32_t   p_type;   // 段类型
    uint32_t   p_flags;  // 权限标志
    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;

最常见的段类型:

  • PT_LOAD (1):可加载段。代码段和数据段都属于这个类型。
  • PT_DYNAMIC (2):动态链接信息。共享库的依赖关系、符号重定位等。
  • PT_INTERP (3):解释器路径。比如 /lib64/ld-linux-x86-64.so.2。
  • PT_NOTE (4):辅助信息。比如ABI版本、构建ID等。

避坑指南: 我曾经遇到一个加壳样本,它的PT_LOAD段把p_filesz设成0,但p_memsz很大。这意味着文件里没有实际数据,但内存中要分配一大块空间——典型的壳代码自解密手法。如果你看到p_filesz和p_memsz不一致,就要警惕了。

4.4 节头表(Section Header Table)

节头表是给链接器和调试器看的。它描述了文件中每个节(Section)的详细信息。注意,程序运行时节头表不是必需的,strip掉节头表的程序照样能跑。

每个节头条目结构:

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;

常见的节类型:

节名sh_type作用
.textSHT_PROGBITS代码段,可执行
.dataSHT_PROGBITS已初始化的全局变量
.bssSHT_NOBITS未初始化的全局变量(文件里不占空间)
.rodataSHT_PROGBITS只读数据,比如字符串常量
.symtabSHT_SYMTAB符号表
.strtabSHT_STRTAB字符串表
.rel.textSHT_REL重定位表
注意: 节头表不是运行必需的。很多恶意软件会故意破坏或删除节头表来干扰静态分析。这时候你就得靠程序头表和手动解析来还原信息了。

4.5 符号表(Symbol Table)

符号表记录了程序中所有的函数名、变量名、文件名等信息。对于逆向分析来说,符号表简直就是藏宝图——有了它,你就不用对着地址猜函数功能了。

符号表条目结构:

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字段包含两部分:低4位是符号类型,高4位是绑定属性。常见的符号类型:

  • STT_FUNC (2):函数。我最关心的类型。
  • STT_OBJECT (1):变量或数据对象。
  • STT_FILE (3):源文件名。调试时很有用。

绑定属性:

  • STB_LOCAL (0):局部符号,只在当前目标文件可见。
  • STB_GLOBAL (1):全局符号,整个程序可见。
  • STB_WEAK (2):弱符号,可以被同名的全局符号覆盖。
实战经验: 我分析过一款商业软件,它把所有函数名都strip掉了,但符号表里还留着STT_FILE类型的源文件名。从文件名我推断出了项目结构,甚至找到了开发者的命名习惯——这给后续分析省了不少事。

4.6 重定位表(Relocation Table)

重定位表是链接器用来修正地址的。为什么需要重定位?因为编译时不知道最终的内存布局,所以先用占位符,等链接时再填上真实地址。

重定位条目结构(64位):

typedef struct {
    uint64_t   r_offset; // 需要重定位的位置
    uint64_t   r_info;   // 符号索引和重定位类型
    int64_t    r_addend; // 加数(用于计算最终地址)
} Elf64_Rela;

r_info字段同样包含两部分:低32位是重定位类型,高32位是符号表索引。

常见的x86_64重定位类型:

类型说明
R_X86_64_NONE0无操作
R_X86_64_641绝对地址,64位
R_X86_64_PC322相对地址,32位
R_X86_64_GOT323GOT表项偏移
R_X86_64_PLT324PLT表项偏移
R_X86_64_GLOB_DAT6全局数据引用
R_X86_64_RELATIVE8相对地址(PIE常用)

核心理解: 重定位说白了就是「填空」。编译器挖了个坑,链接器往里填地址。我见过一个混淆手法,就是故意把重定位表搞乱,让反汇编器解析出错误的地址。这时候你得手动计算每个重定位项,还原真实的调用关系。

4.7 实战:手动解析一个ELF文件

光说不练假把式。我们来手动解析一个最小的ELF文件。假设我们有一个简单的C程序:

// hello.c
#include <stdio.h>
int main() {
    printf("Hello, ELF!\n");
    return 0;
}

编译并查看:

$ gcc -o hello hello.c
$ readelf -h hello    # 查看ELF头
$ readelf -l hello    # 查看程序头表
$ readelf -S hello    # 查看节头表
$ readelf -s hello    # 查看符号表
$ readelf -r hello    # 查看重定位表

我个人习惯用readelf配合objdump一起看:

$ objdump -d hello    # 反汇编
$ objdump -t hello    # 显示符号表
$ objdump -R hello    # 显示动态重定位

当你看到printf的地址在反汇编里是0x0或者一个占位值,然后在重定位表里找到对应的R_X86_64_PLT32条目——嗯,这时候你就真正理解了「重定位」这三个字的意思。

4.8 总结与避坑

ELF文件结构是逆向工程的基石。我总结几个关键点:

  • ELF头:文件身份证,先看魔数和架构。
  • 程序头表:运行时视图,关注PT_LOAD段和PT_DYNAMIC段。
  • 节头表:静态视图,strip后可能丢失,但程序头表还在。
  • 符号表:逆向的藏宝图,有符号表事半功倍。
  • 重定位表:链接器的填空指南,也是理解动态链接的关键。
最后提醒: 我曾经在分析一个Linux内核模块(.ko文件)时,发现它的节头表被故意破坏了。但程序头表是完整的。我通过程序头表里的PT_LOAD段信息,手动重建了节头表,最终成功提取出了关键函数。所以记住:程序头表是运行必需的,节头表不是。遇到恶意样本,优先分析程序头表。

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