ELF 文件格式解析:从二进制视角看程序骨架
说实话,搞 Pwn 这么多年,我见过太多人一上来就怼着漏洞代码猛看,结果连 ELF 头里的 entry point 在哪都不知道。这就像你要拆解一台发动机,却连它的外壳结构都不清楚——迟早要翻车。
ELF(Executable and Linkable Format)是 Linux 下可执行文件、共享库、目标文件的标准格式。你想想看,无论是你写的 C 程序编译出来的二进制,还是 glibc 里的 libc.so.6,甚至内核模块,底层都是这套结构。今天我就带你把它扒开看看。
4.1 ELF 文件整体布局
一个典型的 ELF 文件,从文件头开始,后面跟着三种主要的数据结构:程序头表(Program Header Table)、节区头表(Section Header Table),以及它们指向的各种节区(Sections)。
我个人习惯把 ELF 想象成三层结构:
- 第一层:ELF 头——文件的身份证,告诉你这是什么类型、什么架构、入口在哪。
- 第二层:程序头表——加载器(loader)的施工图,告诉系统「哪些数据要加载到内存的哪个位置,权限是什么」。
- 第三层:节区头表——链接器和调试器的说明书,告诉编译器「符号在哪、代码段在哪、重定位信息在哪」。
嗯,这里要注意:程序头表对可执行文件是必需的,但对目标文件(.o)来说可有可无;反过来,节区头表对链接过程是必需的,但运行时加载器基本不关心它。
4.2 ELF 头:一切从这里开始
用 readelf -h 看一个 ELF 文件,你看到的第一个东西就是 ELF 头。它固定 64 字节(64 位系统)或 52 字节(32 位系统)。
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: 0x401060
Start of program headers: 64 (bytes into file)
Start of section headers: 14856 (bytes into file)
Number of program headers: 13
Number of section headers: 31
Section header string table index: 30
几个关键字段我提一下:
- e_entry:程序入口地址。Pwn 里改这个值可以劫持执行流,不过现在有 IBT 和 Shadow Stack 保护,没那么好用了。
- e_phoff / e_shoff:程序头表和节区头表在文件中的偏移。我曾经在 CTF 里见过一道题,把 e_shoff 改成 0 让节区头表「消失」,调试器直接懵逼。
- e_shstrndx:节区名字符串表的索引。没有它,你看到的节区名就是一堆乱码。
xxd 配合 readelf 可以手动验证 ELF 头的每个字节。我习惯在分析未知二进制时先 dump 前 64 字节,确认文件类型和架构,避免被「伪装 ELF」的样本骗到。
4.3 程序头表:加载器的施工图
程序头表(Program Header Table)是 ELF 文件里对运行时最重要的结构。每个条目是一个 Elf64_Phdr,描述一个「段」(segment)。
用 readelf -l 可以看到:
Elf file type is DYN (Shared object file)
Entry point 0x1060
There are 13 program headers, starting at offset 64
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
PHDR 0x000040 0x0000000000000040 0x0000000000000040 0x0002d8 0x0002d8 R 0x8
INTERP 0x000318 0x0000000000000318 0x0000000000000318 0x00001c 0x00001c R 0x1
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
LOAD 0x000000 0x0000000000000000 0x0000000000000000 0x0005d8 0x0005d8 R 0x1000
LOAD 0x001000 0x0000000000001000 0x0000000000001000 0x0001d5 0x0001d5 R E 0x1000
LOAD 0x002000 0x0000000000002000 0x0000000000002000 0x000150 0x000150 R 0x1000
LOAD 0x002db0 0x0000000000003db0 0x0000000000003db0 0x000250 0x000258 RW 0x1000
DYNAMIC 0x002dc0 0x0000000000003dc0 0x0000000000003dc0 0x0001e0 0x0001e0 RW 0x8
GNU_STACK 0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW 0x10
GNU_RELRO 0x002db0 0x0000000000003db0 0x0000000000003db0 0x000250 0x000250 R 0x1
...
这里我重点说几个:
| 程序头类型 | 作用 | Pwn 视角 |
|---|---|---|
| PT_LOAD | 描述需要加载到内存的数据段 | 改 p_flags 可以给 .text 段加写权限,经典绕过 W^X |
| PT_DYNAMIC | 指向 .dynamic 节区,包含动态链接信息 | 篡改里面的 DT_STRTAB 或 DT_SYMTAB 可以劫持符号解析 |
| PT_GNU_STACK | 控制栈的可执行权限 | p_flags 如果包含 X,栈就是可执行的——NX 失效 |
| PT_GNU_RELRO | 标记哪些内存区域需要设为只读 | Full RELRO 下 GOT 只读,Partial RELRO 下 GOT 可写 |
| PT_GNU_EH_FRAME | 异常处理帧信息 | 不太直接用于攻击,但可以用来定位栈展开信息 |
4.4 节区头表:链接器的说明书
节区头表(Section Header Table)对运行时加载器来说不是必需的,但对链接器、调试器、反汇编器来说至关重要。每个条目是一个 Elf64_Shdr。
用 readelf -S 可以看到:
Section Headers:
[Nr] Name Type Address Offset Size EntSize Flags Link Info Align
[13] .text PROGBITS 0000000000001020 00001020 00000101 00000000 AX 0 0 16
[14] .rodata PROGBITS 0000000000002000 00002000 0000002c 00000000 A 0 0 4
[15] .data PROGBITS 0000000000003db0 00002db0 00000010 00000000 WA 0 0 8
[16] .bss NOBITS 0000000000003dc0 00002dc0 00000008 00000000 WA 0 0 8
[23] .got PROGBITS 0000000000003f98 00002f98 00000010 00000008 WA 0 0 8
[24] .got.plt PROGBITS 0000000000003fa8 00002fa8 00000028 00000008 WA 0 0 8
[25] .plt PROGBITS 0000000000001030 00001030 00000030 00000010 AX 0 0 16
几个常见节区:
- .text:代码段,可读可执行,不可写。Pwn 里我们想往这里写 shellcode?不行,除非你改了段权限。
- .data:已初始化的全局变量,可读可写。格式化字符串漏洞经常利用这里存放的指针。
- .bss:未初始化的全局变量,不占文件空间(FileSiz=0),但占内存空间。堆溢出经常覆盖到 .bss 里的关键变量。
- .rodata:只读数据,比如字符串常量。格式化字符串漏洞的「%s」参数经常指向这里。
- .got / .got.plt:全局偏移表,存放外部函数的实际地址。这是 Pwn 里最经典的攻击目标——GOT 覆写。
- .plt:过程链接表,用于延迟绑定。每个外部函数在 PLT 里有一段桩代码。
4.5 动态链接与重定位:GOT/PLT 的核心机制
动态链接是 ELF 最精妙的设计之一。你想想看,你的程序调用了 printf,但编译时根本不知道 printf 在 libc 里的实际地址——因为 ASLR 会让每次加载的基址都不同。那怎么办?
答案就是 GOT 和 PLT 的配合。
4.5.1 延迟绑定(Lazy Binding)
默认情况下,外部函数第一次被调用时才解析地址。流程是这样的:
- 程序调用
printf@plt。 - PLT 里的桩代码跳转到 GOT 中对应的条目。
- 第一次调用时,GOT 条目里存的是 PLT 中下一条指令的地址(回退地址)。
- 控制流回到 PLT,调用动态链接器的
_dl_runtime_resolve函数。 - 解析器找到 printf 在 libc 中的实际地址,写入 GOT 条目。
- 后续调用直接跳转到 GOT 里的真实地址,不再经过解析。
用代码表示就是:
; printf@plt 的典型实现
printf@plt:
jmp *printf@got.plt ; 跳转到 GOT 条目
push $0x0 ; 重定位索引
jmp .plt ; 跳转到公共 PLT 桩
; 第一次调用后,printf@got.plt 被改写为 printf 的真实地址
4.5.2 重定位表
重定位表记录了哪些位置需要在加载时或运行时被修正。用 readelf -r 查看:
Relocation section '.rela.dyn' at offset 0x4a0 contains 8 entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000003f98 000200000006 R_X86_64_GLOB_DAT 0000000000000000 __libc_start_main + 0
000000003fa0 000400000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0
Relocation section '.rela.plt' at offset 0x500 contains 3 entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000003fb8 000100000007 R_X86_64_JUMP_SLOT 0000000000000000 printf + 0
000000003fc0 000300000007 R_X86_64_JUMP_SLOT 0000000000000000 puts + 0
000000003fc8 000500000007 R_X86_64_JUMP_SLOT 0000000000000000 exit + 0
两种主要的重定位类型:
- R_X86_64_GLOB_DAT:用于 .got 节区,在加载时直接解析。
- R_X86_64_JUMP_SLOT:用于 .got.plt 节区,支持延迟绑定。
嗯,这里有个细节:Full RELRO 会把所有 GOT 条目在加载时就解析完,然后把整个 GOT 区域设为只读。这样你就没法通过任意写改 GOT 了。但 Partial RELRO 只保护了 .got,.got.plt 仍然是可写的。
4.6 常见段的内存布局与权限
最后,我用一张表总结一下各段在内存中的典型布局:
| 段/节区 | 典型地址范围(64位) | 权限 | Pwn 关注点 |
|---|---|---|---|
| .text | 0x401000 - 0x402000 | R-X | ROP gadget 来源,不可写 |
| .rodata | 0x402000 - 0x403000 | R-- | 字符串常量,格式化漏洞可读 |
| .data | 0x404000 - 0x405000 | RW- | 全局变量,可写可读 |
| .bss | 0x405000 - 0x406000 | RW- | 未初始化变量,溢出目标 |
| .got.plt | 0x406000 - 0x407000 | RW-(Partial RELRO) R--(Full RELRO) |
GOT 覆写的主要目标 |
| .plt | 0x401020 - 0x401100 | R-X | PLT 桩代码,可执行 |
| 栈 | 0x7ffffffde000 - ... | RW-(NX 开启) RWX(NX 关闭) |
栈溢出、ROP、ret2libc |
| 堆 | 0x555555559000 - ... | RW- | 堆溢出、UAF、Tcache 攻击 |
readelf -l 看 RELRO 状态,再跑 readelf -S 看 GOT 地址范围。这两个信息直接决定了你的攻击路径——是走 GOT 覆写,还是走栈/堆溢出,还是走 ret2dlresolve。
ELF 格式的内容到这里就差不多了。说实话,这些结构看起来枯燥,但你在 Pwn 里遇到的每一个漏洞利用技巧,最终都会落到这些结构的某个字段上。理解了它们,你再看那些花里胡哨的 exploit,会发现底层逻辑其实就那么几招。