第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头就是大楼的铭牌,程序头表是施工图纸(告诉工人怎么建),节头表是楼层索引(告诉访客每层有什么)。
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 | 作用 |
|---|---|---|
| .text | SHT_PROGBITS | 代码段,可执行 |
| .data | SHT_PROGBITS | 已初始化的全局变量 |
| .bss | SHT_NOBITS | 未初始化的全局变量(文件里不占空间) |
| .rodata | SHT_PROGBITS | 只读数据,比如字符串常量 |
| .symtab | SHT_SYMTAB | 符号表 |
| .strtab | SHT_STRTAB | 字符串表 |
| .rel.text | SHT_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):弱符号,可以被同名的全局符号覆盖。
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_NONE | 0 | 无操作 |
| R_X86_64_64 | 1 | 绝对地址,64位 |
| R_X86_64_PC32 | 2 | 相对地址,32位 |
| R_X86_64_GOT32 | 3 | GOT表项偏移 |
| R_X86_64_PLT32 | 4 | PLT表项偏移 |
| R_X86_64_GLOB_DAT | 6 | 全局数据引用 |
| R_X86_64_RELATIVE | 8 | 相对地址(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后可能丢失,但程序头表还在。
- 符号表:逆向的藏宝图,有符号表事半功倍。
- 重定位表:链接器的填空指南,也是理解动态链接的关键。
公众号:蓝海资料掘金营,微信deep3321