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节的Type是NOBITS——它在文件中不占用空间,但加载到内存时会分配指定大小的零初始化区域。我刚开始逆向时,看到.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 -C或objdump -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版本用了LoadLibrary和GetProcAddress。虽然机制不同,但核心思路是一样的:运行时解析外部符号。
6.7 知识体系总览
下面这张SVG图总结了ELF格式的核心结构,以及各组件之间的关系:
从这张图可以看出,ELF头是整个文件的入口,它指向程序头表和节头表。程序头表描述运行时内存布局,节头表描述链接时内容组织。符号表、重定位表和动态链接信息则共同支撑动态链接机制。说白了,ELF格式的设计就是“一个入口,两个视图”——链接视图和运行视图。
注意事项:在逆向分析时,不要只依赖节头表。有些恶意软件会故意破坏节头表,但程序头表是加载器必须使用的,所以程序头表通常更可靠。我遇到过几次这样的情况:节头表被篡改,但程序头表完好无损,最终靠程序头表成功还原了文件结构。
好了,这一章的内容就到这里。ELF格式虽然复杂,但只要抓住“头-段-节-符号-重定位”这条主线,就能逐步拆解。下一章我们会深入动态链接的细节,看看.got和.plt到底是怎么工作的。
公众号:蓝海资料掘金营,微信deep3321