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在内存中的哪个位置。那怎么办?它用了一个间接跳转的机制:

  1. 程序调用printf@plt(PLT中的一个桩)
  2. printf@plt跳转到GOT[printf]中存储的地址
  3. 第一次调用时,GOT里存的是PLT中下一段代码的地址,这段代码会调用动态链接器去解析printf的真实地址
  4. 解析完成后,动态链接器把真实地址写回GOT
  5. 后续调用直接跳转到真实地址,不再需要解析

这个过程叫「延迟绑定」(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头(Ehdr) 魔数 | 文件类型 | 架构 | 入口点 | 程序头偏移 | 节头偏移 程序头表(Phdr) 加载视图:段(Segment) PT_LOAD | PT_DYNAMIC | PT_INTERP 运行时加载到内存 节头表(Shdr) 链接视图:节(Section) .text | .data | .bss | .rodata 链接器和调试器使用 常见节详解 .text 代码段(读+执行) .data 已初始化数据(读+写) .bss 未初始化数据(不占文件) .rodata 只读数据(常量字符串) .plt(过程链接表)→ .got(全局偏移表) 动态链接:延迟绑定 | 函数地址解析 动态链接器(ld-linux.so) 运行时解析外部函数地址 ELF文件 = ELF头 + 程序头表(加载视图) + 节头表(链接视图) + 节数据

这张图把ELF的骨架画出来了。你从顶部往下看:ELF头在最前面,它指向程序头表和节头表。程序头表描述运行时加载,节头表描述文件内部结构。而PLT/GOT是动态链接的核心机制,连接了代码和外部函数。

好了,ELF文件结构就讲到这里。记住,分析二进制时,先看ELF头确认文件类型和架构,再看程序头表了解加载方式,最后深入节头表分析具体内容。这套流程我用了十年,从来没出过问题。


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