ELF文件格式深度解析:ELF头与程序头、节头与符号表、动态链接与重定位、与PE格式对比

ELF,全称Executable and Linkable Format,是Linux和大多数Unix系统上的标准可执行文件格式。我最早接触它是在做嵌入式逆向的时候,那时候调试器一加载,文件头不对,整个分析就全废了。说白了,ELF就是操作系统和二进制文件之间的“契约”——你格式写对了,系统就认你,否则直接拒绝执行。

这一章,我会带你从ELF头开始,一路深入到程序头、节头、符号表、动态链接和重定位,最后再和Windows的PE格式做个对比。嗯,内容不少,但都是硬核干货。

6.1 ELF头:文件的总纲

每个ELF文件的开头都是一个固定大小的头部结构,叫做ELF头。它告诉系统:这是个什么类型的文件、目标架构是什么、入口点在哪里、程序头和节头表在什么位置。

我习惯用readelf -h来查看它。举个例子:

$ 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

注意那个Magic字段——7f 45 4c 46,其实就是0x7f加上"ELF"的ASCII码。这是文件格式的签名,系统加载器看到这个才知道“哦,这是个ELF文件”。

我曾经遇到过一个坑:某个嵌入式设备上的ELF文件,Class字段写的是ELF32,但实际代码是64位的。加载器直接崩溃。后来查出来是编译工具链的配置问题。所以,ELF头里的每个字段都不能信手拈来,必须和实际内容一致

6.2 程序头:告诉系统怎么加载

程序头(Program Header)是给操作系统加载器看的。它描述了文件中的哪些段(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
                 0x000000000000d8d8 0x000000000000d8d8  R E    0x200000
  LOAD           0x000000000000de00 0x000000000002de00 0x000000000002de00
                 0x0000000000002e10 0x0000000000002e10  RW     0x200000
  DYNAMIC        0x000000000000de10 0x000000000002de10 0x000000000002de10
                 0x00000000000001e0 0x00000000000001e0  RW     0x8
  ...

这里最关键的是LOAD类型的段。它告诉加载器:把文件中的某一段数据拷贝到内存的某个地址,并设置对应的权限(R=读,W=写,E=执行)。

你想想看,为什么代码段(R E)和数据段(RW)要分开?因为代码段是只读可执行的,数据段是可读可写的。如果混在一起,攻击者就能通过写数据段来修改代码——这太危险了。

核心要点:程序头是“运行时视图”,它决定了进程的内存布局。逆向分析时,看程序头就能知道代码段、数据段、栈、堆的映射关系。

6.3 节头:链接器的地图

如果说程序头是给加载器看的,那节头(Section Header)就是给链接器和调试器看的。它把文件划分成一个个节(section),比如.text(代码)、.data(已初始化数据)、.bss(未初始化数据)、.symtab(符号表)等。

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
  [13] .text             PROGBITS         00000000000026a0  000026a0
       000000000000b238  0000000000000000  AX       0     0     16
  [24] .data             PROGBITS         000000000002de00  0000de00
       00000000000002b0  0000000000000000  WA       0     0     32
  [25] .bss              NOBITS           000000000002e0b0  0000e0b0
       0000000000000a28  0000000000000000  WA       0     0     32
  [26] .symtab           SYMTAB           0000000000000000  0000e0b0
       0000000000003f18  0000000000000018          29   1004     8

注意.bss节的TypeNOBITS——它在文件中不占用空间,但加载到内存时会分配指定大小的零初始化区域。我刚开始逆向时,看到.bss节的大小很大,但文件本身很小,一度以为文件损坏了。后来才明白,这是为了节省磁盘空间。

节头表在链接过程中至关重要。链接器根据节头表把多个目标文件中的同名节合并到一起,然后重新计算地址。如果你在逆向时发现某个节不见了,那很可能是被链接器优化掉了。

6.4 符号表:函数和变量的“通讯录”

符号表(Symbol Table)记录了文件中所有的函数名、全局变量名、以及它们对应的地址。对于动态链接的可执行文件,符号表还记录了需要从外部共享库导入的符号。

readelf -s查看:

$ readelf -s /bin/ls
Symbol table '.symtab' contains 1006 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND
     1: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS ls.c
     2: 00000000000026a0   130 FUNC    LOCAL  DEFAULT   13 usage
    ...
   456: 0000000000005850   104 FUNC    GLOBAL DEFAULT   13 main
    ...
   789: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND printf

这里main函数的地址是0x5850,类型是FUNC,绑定是GLOBAL。而printf的地址是0,索引是UND——表示它是未定义的,需要从外部库(比如libc)中解析。

逆向时,符号表是你的好朋友。如果程序没有被strip(剥离符号),你直接就能看到所有函数名,分析起来事半功倍。但如果被strip了,符号表就没了,你只能靠地址和交叉引用来猜函数功能。嗯,这时候就考验功力了。

小技巧:如果遇到strip过的二进制文件,可以尝试用nm -Cobjdump -t来恢复部分符号信息。有些编译器会保留调试符号,即使strip了也可能残留一些。

6.5 动态链接与重定位:运行时“拼图”

动态链接是现代操作系统的主流方式。程序运行时,动态链接器(ld-linux.so)负责把共享库加载到内存,并修正所有对外部符号的引用。这个过程叫做重定位(relocation)。

重定位表(.rela.dyn.rela.plt)记录了所有需要修正的位置。用readelf -r查看:

$ readelf -r /bin/ls
Relocation section '.rela.dyn' at offset 0xde10 contains 8 entries:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
0000002de18  000000000008 R_X86_64_RELATIVE                    5850
0000002de20  000000000008 R_X86_64_RELATIVE                    4e80
...

Relocation section '.rela.plt' at offset 0xde50 contains 5 entries:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
0000002dfb8  000000000007 R_X86_64_JUMP_SLOT                    00000000 printf + 0
0000002dfc0  000000000007 R_X86_64_JUMP_SLOT                    00000000 puts + 0

这里R_X86_64_RELATIVE是相对重定位,用于修正代码段内部的地址。而R_X86_64_JUMP_SLOT是PLT(过程链接表)重定位,用于延迟绑定——第一次调用printf时,动态链接器才去解析它的真实地址。

我曾在分析一个恶意软件时,发现它用了大量的R_X86_64_GLOB_DAT重定位来动态修改全局变量地址,以此绕过静态检测。说白了,就是利用重定位机制在运行时“偷换”函数指针。这种手法在高级恶意软件中并不少见。

6.6 ELF与PE格式对比

Windows的PE(Portable Executable)格式和ELF有很多相似之处,但设计哲学不同。我整理了一个对比表:

特性 ELF PE
文件头 ELF头(52/64字节) DOS头 + PE头(64字节)
段/节映射 程序头(PHDR)描述段,节头(SHDR)描述节 节表(Section Table)直接描述节,无独立段概念
入口点 e_entry字段 AddressOfEntryPoint字段
导入表 .dynamic段 + .dynsym + .rela.plt 导入表(Import Table)
导出表 .dynsym + .dynstr 导出表(Export Table)
重定位 重定位表(.rela.dyn / .rela.plt) 基址重定位表(Base Relocation Table)
调试信息 .debug_* 节(DWARF格式) .debug$T / .debug$S(CodeView格式)
资源管理 无标准资源节,通常用外部文件 .rsrc节(资源表)

从逆向角度看,ELF和PE最大的区别在于:ELF的节头表在链接后通常会被保留(除非strip),而PE的节表始终存在。另外,ELF的符号表比PE的导出表更灵活——你可以导出任意符号,而PE只能导出函数和变量。

我记得有一次在分析一个跨平台恶意软件时,它同时生成了ELF和PE版本。ELF版本用了DT_NEEDED来动态加载libc,而PE版本用了LoadLibraryGetProcAddress。虽然机制不同,但核心思路是一样的:运行时解析外部符号。

6.7 知识体系总览

下面这张SVG图总结了ELF格式的核心结构,以及各组件之间的关系:

ELF文件格式核心结构 ELF头 程序头表 节头表 指向 指向 程序头条目 - LOAD段(代码/数据) - INTERP(解释器路径) - DYNAMIC(动态链接信息) - GNU_STACK(栈权限) - GNU_RELRO(只读重定位) 节头条目 - .text(代码节) - .data(已初始化数据) - .bss(未初始化数据) - .symtab(符号表) - .strtab(字符串表) 符号表 (.symtab) - 函数名 + 地址 - 全局变量名 + 地址 - UND(未定义外部符号) 重定位表 (.rela.*) - R_X86_64_RELATIVE - R_X86_64_JUMP_SLOT - R_X86_64_GLOB_DAT 动态链接信息 - .dynamic段(DT_NEEDED等) - .got(全局偏移表) - .plt(过程链接表) 引用 引用 映射 ELF头是入口,程序头描述运行时内存布局,节头描述链接时内容组织 符号表、重定位表和动态链接信息共同支撑动态链接机制

从这张图可以看出,ELF头是整个文件的入口,它指向程序头表和节头表。程序头表描述运行时内存布局,节头表描述链接时内容组织。符号表、重定位表和动态链接信息则共同支撑动态链接机制。说白了,ELF格式的设计就是“一个入口,两个视图”——链接视图和运行视图。

注意事项:在逆向分析时,不要只依赖节头表。有些恶意软件会故意破坏节头表,但程序头表是加载器必须使用的,所以程序头表通常更可靠。我遇到过几次这样的情况:节头表被篡改,但程序头表完好无损,最终靠程序头表成功还原了文件结构。

好了,这一章的内容就到这里。ELF格式虽然复杂,但只要抓住“头-段-节-符号-重定位”这条主线,就能逐步拆解。下一章我们会深入动态链接的细节,看看.got.plt到底是怎么工作的。


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