4. ELF文件结构解析:从二进制里读懂程序的血与肉
说实话,我当年刚接触逆向工程时,第一个拦路虎就是ELF文件。那时候我对着一个二进制文件发愣——这堆乱码到底怎么组织起来的?后来花了整整一周,把ELF规范啃了一遍,才恍然大悟。嗯,今天我就带你把这个东西彻底搞明白。
4.1 ELF文件格式概述
ELF,全称Executable and Linkable Format,是Linux和大多数Unix系统上的标准可执行文件格式。你平时跑的每一个程序,从ls到nginx,底层都是这个格式。
我个人习惯把ELF文件想象成一栋楼:
- ELF头(Ehdr)——大楼的总平面图,告诉你楼有几层、入口在哪
- 程序头表(Phdr)——楼层的功能分区,哪些区域是办公区、哪些是仓库
- 节头表(Shdr)——每个房间的详细清单,哪个房间放代码、哪个放数据
你看,结构其实很清晰。但要注意,不是所有ELF文件都有节头表。可执行文件运行时只需要程序头表,而链接器(比如ld)才需要节头表。我在项目中遇到过好几次,有人用readelf -S去看一个strip过的二进制,结果什么都看不到——这就是因为节头表被删掉了。
核心要点:ELF文件有两种视图——链接视图(靠节头表)和加载视图(靠程序头表)。搞逆向,两个视图都得懂。
4.2 ELF头(Ehdr)——一切从这里开始
每个ELF文件的开头就是ELF头,固定64字节(64位系统)或52字节(32位系统)。我每次拿到一个陌生二进制,第一件事就是dump它的ELF头。
// 64位ELF头结构(来自 /usr/include/elf.h)
typedef struct {
unsigned char e_ident[16]; // 魔数 + 类别 + 字节序 + 版本 + OS/ABI
uint16_t e_type; // 文件类型:ET_REL(1), ET_EXEC(2), ET_DYN(3)
uint16_t e_machine; // 架构:x86_64(62), ARM(40)
uint32_t e_version; // 版本号
uint64_t e_entry; // 入口点地址
uint64_t e_phoff; // 程序头表偏移
uint64_t e_shoff; // 节头表偏移
uint32_t e_flags; // 处理器特定标志
uint16_t e_ehsize; // ELF头大小
uint16_t e_phentsize; // 每个程序头表项大小
uint16_t e_phnum; // 程序头表项数量
uint16_t e_shentsize; // 每个节头表项大小
uint16_t e_shnum; // 节头表项数量
uint16_t e_shstrndx; // 节名字符串表索引
} Elf64_Ehdr;
这里最值得关注的是e_ident[0..3]——也就是著名的魔数0x7f 'E' 'L' 'F'。我经常用这个来快速判断一个文件是不是ELF。曾经有一次,一个同事把Windows的PE文件传到了Linux服务器上,我一眼就看出不对——魔数不对嘛。
另一个关键字段是e_type:
| 值 | 类型 | 说明 |
|---|---|---|
| 1 | ET_REL(可重定位文件) | .o文件,还没链接 |
| 2 | ET_EXEC(可执行文件) | 静态链接的可执行程序 |
| 3 | ET_DYN(共享目标文件) | .so文件或PIE可执行文件 |
避坑指南:现在大部分Linux发行版默认编译出来的可执行文件都是ET_DYN(PIE),而不是ET_EXEC。如果你用readelf -h看到e_type=3,别惊讶,这是正常的。
4.3 程序头表(Phdr)——运行时怎么加载
程序头表描述的是ELF文件如何被加载到内存中。每个程序头表项(Program Header)定义了一个段(Segment)。
typedef struct {
uint32_t p_type; // 段类型:PT_LOAD(1), PT_DYNAMIC(2), PT_INTERP(3)...
uint32_t p_flags; // 权限:PF_R(4), PF_W(2), PF_X(1)
uint64_t p_offset; // 在文件中的偏移
uint64_t p_vaddr; // 虚拟地址
uint64_t p_paddr; // 物理地址(一般不用)
uint64_t p_filesz; // 文件中的大小
uint64_t p_memsz; // 内存中的大小
uint64_t p_align; // 对齐要求
} Elf64_Phdr;
最常见的段类型是PT_LOAD,它告诉加载器:把文件中的某一段数据拷贝到内存的某个地址。我见过一个很有意思的案例——有人想手动修改一个ELF文件,结果把p_vaddr改错了,程序一运行就段错误。说白了,这个字段就是告诉内核「把我放到哪里」。
还有一个重要的段是PT_INTERP,它指定了动态链接器的路径。通常就是/lib64/ld-linux-x86-64.so.2。你想想看,没有这个段,动态链接的程序根本跑不起来。
4.4 节头表(Shdr)——文件内部的组织结构
如果说程序头表是给操作系统看的,那节头表就是给链接器和调试器看的。每个节(Section)对应文件中的一个逻辑区域。
typedef struct {
uint32_t sh_name; // 节名在字符串表中的索引
uint32_t sh_type; // 节类型:SHT_PROGBITS(1), SHT_NOBITS(8)...
uint64_t sh_flags; // 标志:SHF_WRITE(2), SHF_ALLOC(2), SHF_EXECINSTR(4)
uint64_t sh_addr; // 虚拟地址(如果加载的话)
uint64_t sh_offset; // 文件中的偏移
uint64_t sh_size; // 大小
uint32_t sh_link; // 关联的其他节索引
uint32_t sh_info; // 附加信息
uint64_t sh_addralign; // 对齐
uint64_t sh_entsize; // 如果包含固定大小项,每项大小
} Elf64_Shdr;
我个人习惯用readelf -S来查看节头表。输出会列出所有节的名字、类型、地址、大小等信息。嗯,这里要注意,节名其实只是一个字符串,存在.shstrtab这个节里。sh_name字段只是这个字符串的索引。
4.5 常见节详解
搞逆向,有几个节你必须要认识:
| 节名 | 类型 | 权限 | 内容 |
|---|---|---|---|
| .text | SHT_PROGBITS | 读+执行 | 程序代码(机器指令) |
| .data | SHT_PROGBITS | 读+写 | 已初始化的全局变量 |
| .bss | SHT_NOBITS | 读+写 | 未初始化的全局变量(不占文件空间) |
| .rodata | SHT_PROGBITS | 只读 | 常量字符串、只读数据 |
| .plt | SHT_PROGBITS | 读+执行 | 过程链接表(动态调用跳板) |
| .got | SHT_PROGBITS | 读+写 | 全局偏移表(函数地址缓存) |
.text节——这是逆向分析的主战场。所有你反汇编出来的代码都在这里。我记得有一次分析一个恶意软件,它在.text节里藏了一段加密的shellcode,运行时才解密执行。嗯,这种手法现在很常见。
.data节——存放已经初始化好的全局变量。比如int x = 42;,这个42就存在.data里。我在调试一个网络程序时,发现它的缓冲区地址就在.data段,直接修改内存就能改变程序行为。
.bss节——这个节比较特殊,它在文件里不占空间(sh_type = SHT_NOBITS),但加载到内存后会分配空间并清零。你想想看,如果每个未初始化的全局变量都要在文件里占4字节,那文件得多大?所以设计者聪明地把它单独拎出来了。
.rodata节——只读数据段。字符串常量、switch跳转表、const修饰的全局变量都在这里。我曾经通过修改.rodata里的字符串,让一个程序输出完全不同的信息——当然,这是在合法测试环境下。
4.6 动态链接与PLT/GOT
这是ELF结构里最精妙的部分,也是很多初学者觉得头疼的地方。说白了,动态链接就是程序运行时才去解析外部函数的地址。
假设你的程序调用了printf。编译时,编译器并不知道printf在内存中的哪个位置。那怎么办?它用了一个间接跳转的机制:
- 程序调用
printf@plt(PLT中的一个桩) printf@plt跳转到GOT[printf]中存储的地址- 第一次调用时,GOT里存的是PLT中下一段代码的地址,这段代码会调用动态链接器去解析
printf的真实地址 - 解析完成后,动态链接器把真实地址写回GOT
- 后续调用直接跳转到真实地址,不再需要解析
这个过程叫「延迟绑定」(Lazy Binding)。我刚开始学的时候觉得这设计太绕了,但后来发现它其实很优雅——只解析真正用到的函数,节省了启动时间。
注意:延迟绑定也带来了安全风险。如果攻击者能修改GOT表项,就能劫持函数调用。这就是GOT覆写攻击的原理。我在做CTF比赛时,就经常用这个手法来获取shell。
来看一个实际的PLT/GOT交互流程:
; 调用 printf 的汇编代码
call printf@plt ; 跳转到PLT桩
; PLT桩(第一次调用)
printf@plt:
jmp *GOT[printf] ; 跳转到GOT中存储的地址
push $index ; 压入函数索引
jmp _dl_runtime_resolve ; 调用动态链接器解析
; 解析完成后,GOT[printf] 被更新为 printf 的真实地址
; 后续调用直接跳转到真实 printf
我个人习惯用objdump -d来查看PLT表,用readelf -r来查看重定位表。这两个工具配合使用,基本能把动态链接的流程摸清楚。
4.7 知识体系总览
下面这张图总结了ELF文件的核心结构,我建议你把它保存下来,分析二进制时对照着看:
这张图把ELF的骨架画出来了。你从顶部往下看:ELF头在最前面,它指向程序头表和节头表。程序头表描述运行时加载,节头表描述文件内部结构。而PLT/GOT是动态链接的核心机制,连接了代码和外部函数。
好了,ELF文件结构就讲到这里。记住,分析二进制时,先看ELF头确认文件类型和架构,再看程序头表了解加载方式,最后深入节头表分析具体内容。这套流程我用了十年,从来没出过问题。