6、ELF文件结构解析:ELF头、程序头表、节头表、符号表与重定位
说到逆向工程,ELF文件格式是绕不开的一道坎。我刚开始接触Linux下的逆向时,面对一堆二进制数据完全摸不着头脑。后来花了整整一周,把ELF的结构啃了下来,才发现——嗯,其实它没那么玄乎。
ELF(Executable and Linkable Format)是Linux和大多数Unix系统下的标准可执行文件格式。你平时用的ls、cat,还有你编译出来的a.out,都是ELF文件。说白了,它就是操作系统和二进制代码之间的一份“说明书”。
核心要点:ELF文件由四个主要部分组成——ELF头、程序头表、节头表,以及各个节(Section)。理解这四者的关系,你就掌握了ELF的骨架。
6.1 ELF头:文件的身份证
ELF头位于文件的最开头,固定大小64位系统下是64字节。它告诉系统:这是个什么文件、目标架构是什么、入口点在哪、程序头表和节头表的位置和大小。
我个人习惯用 readelf -h 来快速查看ELF头。比如:
$ 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
看到那个 7f 45 4c 46 了吗?这就是ELF的魔数,对应ASCII字符 .ELF。我在一次逆向分析中遇到过恶意软件篡改这个魔数来逃避检测——但那是后话了。
ELF头里最重要的几个字段:
- e_entry:程序入口地址。逆向时我经常从这里开始追踪执行流。
- e_phoff:程序头表在文件中的偏移。加载器靠这个找到程序头。
- e_shoff:节头表的偏移。链接器用这个来解析符号和重定位。
- e_phentsize / e_phnum:每个程序头的大小和数量。
小技巧:用 xxd 配合 head 可以直接看ELF头的原始字节。我经常在调试器里直接比对内存中的ELF头和文件中的是否一致——这是检测自修改代码的常用手段。
6.2 程序头表:加载器的地图
程序头表(Program Header Table)是给操作系统加载器看的。它描述了如何将ELF文件映射到内存中。每个程序头条目描述一个段(Segment)。
用 readelf -l 查看:
$ readelf -l /bin/ls
Elf file type is EXEC (Executable file)
Entry point 0x5850
There are 13 program headers, starting at offset 64
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
PHDR 0x0000000000000040 0x0000000000000040 0x0000000000000040
0x00000000000002d8 0x00000000000002d8 R 0x8
INTERP 0x0000000000000318 0x0000000000000318 0x0000000000000318
0x000000000000001c 0x000000000000001c R 0x1
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
LOAD 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x000000000000dbe8 0x000000000000dbe8 R 0x1000
LOAD 0x000000000000e000 0x000000000000e000 0x000000000000e000
0x0000000000006a4d 0x0000000000006a4d R E 0x1000
...
最常见的程序头类型:
- PT_LOAD:可加载段。这是最重要的,告诉加载器要把哪部分文件加载到内存的哪个地址。
- PT_INTERP:指定动态链接器的路径。一般是
/lib64/ld-linux-x86-64.so.2。 - PT_DYNAMIC:动态链接信息。动态链接器靠这个找到符号表和重定位表。
- PT_GNU_STACK:控制栈的可执行权限。现代系统默认栈不可执行,就是靠这个实现的。
注意:程序头表是加载时必需的,但节头表在运行时可以没有。很多加壳后的ELF文件会清掉节头表来增加分析难度。我曾经逆向过一个恶意样本,它把节头表全部置零,但程序头表保留完整——因为系统加载器只认程序头表。
6.3 节头表:链接器的索引
如果说程序头表是给加载器看的,那节头表(Section Header Table)就是给链接器和调试器看的。每个节(Section)包含特定类型的数据:代码、数据、符号表、字符串表等等。
用 readelf -S 查看:
$ readelf -S /bin/ls
There are 30 section headers, starting at offset 0x20740:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .interp PROGBITS 0000000000000318 00000318
000000000000001c 0000000000000000 A 0 0 1
[ 2] .note.gnu.bu[...] NOTE 0000000000000338 00000338
0000000000000024 0000000000000000 A 0 0 4
[ 3] .gnu.hash GNU_HASH 0000000000000360 00000360
0000000000000038 0000000000000000 A 4 0 8
[ 4] .dynsym DYNSYM 0000000000000398 00000398
0000000000000a98 0000000000000018 A 5 1 8
...
常见的节:
| 节名 | 类型 | 用途 |
|---|---|---|
| .text | PROGBITS | 程序代码,可执行 |
| .data | PROGBITS | 已初始化的全局变量 |
| .bss | NOBITS | 未初始化的全局变量(不占文件空间) |
| .rodata | PROGBITS | 只读数据,如字符串常量 |
| .dynsym | DYNSYM | 动态符号表 |
| .strtab | STRTAB | 字符串表 |
| .rela.dyn | RELA | 重定位表(带加数) |
经验之谈:逆向时我第一个看的就是 .rodata 节。里面往往藏着错误信息、调试输出、甚至硬编码的密钥。有一次我分析一个闭源协议,就是在 .rodata 里找到了所有API的URL路径——省了我三天抓包的时间。
6.4 符号表:名字与地址的桥梁
符号表(Symbol Table)记录了函数名、变量名和它们对应的地址。对于动态链接的ELF,有两个符号表:.symtab(完整符号表,strip后会被移除)和 .dynsym(动态符号表,始终保留)。
用 readelf -s 查看:
$ readelf -s /bin/ls | head -20
Symbol table '.dynsym' contains 113 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FUNC GLOBAL DEFAULT UND getenv@GLIBC_2.2.5 (2)
2: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __errno_location@GLIBC_2.2.5 (2)
3: 0000000000000000 0 FUNC GLOBAL DEFAULT UND strcoll@GLIBC_2.2.5 (2)
4: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __fprintf_chk@GLIBC_2.3.4 (3)
...
符号表里每个条目包含:
- Value:符号的地址(对于函数就是入口地址)
- Size:符号的大小(函数体大小或变量大小)
- Type:FUNC(函数)、OBJECT(变量)、NOTYPE(未定义)等
- Bind:GLOBAL(全局可见)、LOCAL(局部)、WEAK(弱符号)
- Ndx:符号所在的节索引,UND表示未定义(需要从共享库导入)
为什么符号表对逆向这么重要?因为它直接告诉你函数名。strip过的二进制虽然移除了 .symtab,但 .dynsym 还在——动态链接器需要它来解析外部函数。所以,你永远可以在动态符号表里看到 printf、malloc 这些库函数的名字。
6.5 重定位:地址修正的艺术
重定位(Relocation)是链接过程中最关键的一步。说白了,就是修正代码中的地址引用。因为编译时不知道最终的内存布局,所以编译器生成的是占位符,链接器再根据实际地址填上正确的值。
用 readelf -r 查看重定位表:
$ readelf -r /bin/ls
Relocation section '.rela.dyn' at offset 0x1f3a8 contains 8 entries:
Offset Info Type Sym. Value Sym. Name + Addend
0000003dbe58 000000000008 R_X86_64_RELATIVE 115a60
0000003dbe60 000000000008 R_X86_64_RELATIVE 115a20
0000003dbe68 000000000008 R_X86_64_RELATIVE 115a40
...
Relocation section '.rela.plt' at offset 0x1f3f8 contains 56 entries:
Offset Info Type Sym. Value Sym. Name + Addend
0000003db038 000100000007 R_X86_64_JUMP_SLOT 0000000000000000 getenv + 0
0000003db040 000200000007 R_X86_64_JUMP_SLOT 0000000000000000 __errno_location + 0
...
常见的重定位类型:
- R_X86_64_RELATIVE:相对寻址修正。计算方式:
地址 = 基址 + 加数。用于PIE(位置无关可执行文件)中的全局变量。 - R_X86_64_JUMP_SLOT:PLT跳转槽修正。动态链接器在第一次调用时填入实际函数地址。
- R_X86_64_GLOB_DAT:全局数据引用修正。用于全局变量在共享库中的地址解析。
实战要点:我在分析一个被混淆的二进制时,发现它用了大量的 R_X86_64_RELATIVE 重定位来动态解密代码段。通过监控重定位表的处理顺序,我成功还原了它的执行流程。重定位表有时候比代码本身更能揭示程序的真实逻辑。
6.6 整体结构图
下面这张图展示了ELF文件的核心结构。我建议你把它记在脑子里——每次分析ELF时,都可以在脑海里过一遍这个布局。
这张图里,左侧是节(Section)视角——链接器眼中的世界;右侧是段(Segment)视角——加载器眼中的世界。两者通过程序头表中的映射关系关联起来。一个段可以包含多个节,比如 PT_LOAD 段通常包含 .text、.rodata 等只读节。
6.7 实战:手动解析ELF头
光说不练假把式。我们写一段Python代码,手动解析ELF头的前几个字段:
import struct
def parse_elf_header(filepath):
with open(filepath, 'rb') as f:
# 读取ELF头前64字节
header = f.read(64)
# 魔数检查
if header[:4] != b'\x7fELF':
print("不是有效的ELF文件")
return
# 解析关键字段
elf_class = header[4] # 1=32位, 2=64位
endian = header[5] # 1=小端, 2=大端
if elf_class == 2: # 64位
# 使用小端序解析
entry = struct.unpack('
建议:你可以用这个脚本配合调试器,在内存中定位ELF头。我曾经用它来检测一个被注入的ELF——它的程序头表数量异常,比正常值多了好几个,结果发现是恶意代码附加的段。
ELF文件结构是逆向工程的基石。你想想看,不理解文件格式,你怎么知道代码从哪开始?数据存在哪?符号怎么解析?我建议你花时间把 readelf -a 的输出从头到尾看一遍,对照着二进制文件逐字节验证。这个过程虽然枯燥,但绝对值得。
嗯,关于ELF结构就讲到这里。记住:ELF头是入口,程序头表是加载地图,节头表是索引目录,符号表是名字簿,重定位表是地址修正器。把这五样东西搞明白,你就能在ELF的世界里来去自如了。
公众号:蓝海资料掘金营,微信deep3321