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 还在——动态链接器需要它来解析外部函数。所以,你永远可以在动态符号表里看到 printfmalloc 这些库函数的名字。

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时,都可以在脑海里过一遍这个布局。

ELF Header (e_ehsize = 64 bytes) 魔数 | 架构 | 入口点 | 程序头偏移 | 节头偏移 | 程序头数量 | 节头数量 Program Header Table PT_LOAD | PT_INTERP | PT_DYNAMIC 加载器使用 | 描述内存映射 Section Header Table .text | .data | .bss | .rodata 链接器使用 | 描述节信息 Sections (节) .text (代码段 - 可执行) .data / .bss (数据段) .dynsym / .symtab (符号表) .rela.dyn / .rela.plt (重定位表) Segments (段) LOAD段 (映射到内存) INTERP段 (动态链接器路径) DYNAMIC段 (动态链接信息) GNU_STACK段 (栈权限控制) 映射关系

这张图里,左侧是节(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

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