0 概述
长时间不接触容易忘记 ELF 文件的各个字段含义, 本篇做一个 ELF 文件的实践.
- 实验平台: x86_64
1 ELF file headers
1.1 实验文件代码
1 | extern int *host_value_ptr; |
1.2 file header 解析
给出 .o 文件, 和可执行文件分别分析.
相关实验可以直接看 examples/mini-upatch/labs/file_headers/entry_point.md, 里面把 Entry point address 和 _start、main、PT_LOAD 的关系单独拆成了一个最小实验.
可重定位文件.
1 | $ readelf -Wh diff_old.o |
可执行文件.
1 | $ readelf -Wh mini-diff |
C 结构.
1 | typedef struct { |
1.2.1 Magic
对应 e_ident, 16 字节的字段是 ELF 文件的核心标识.
7f 45 4c 46 是 ELF 前 4 字节的固定魔数.
其中 45 4c 46 就是 ASCII 的 ELF. 第 5 个字节是 EI_CLASS, 0x01 表示 32 位, 0x02 表示 64 位. 第 6 个字节是 EI_DATA, 0x01 表示小端序, 0x02 表示大端序. 第 7 个字节是 EI_VERSION, 表示 ELF 的版本号, 第 8 个字节是 EI_OSABI, 表示 ABI 规范, 0x00 表示 System V, 0x03 表示 Linux. 第 9 个字节表示 EI_ABIVERSION, 注明了 ABI 的版本.
1.2.2 Class
对应 e_ident[EI_CLASS].
这个字段说明 ELF 文件采用的是 32 位格式还是 64 位格式. 常见取值如下:
ELFCLASSNONE = 0, 非法或未指定.ELFCLASS32 = 1, 表示 32 位 ELF.ELFCLASS64 = 2, 表示 64 位 ELF.
在我的实验里, diff_old.o 和 mini-diff 都显示为 ELF64, 说明两者都要按 Elf64_Ehdr, Elf64_Shdr, Elf64_Sym 等 64 位结构解释. 这只说明 ELF 格式本身是 64 位, 不直接等价于 CPU 架构, CPU 架构还要看后面的 Machine 字段.
1.2.3 Data
对应 e_ident[EI_DATA].
这个字段说明 ELF 中多字节整数的存储字节序. 常见取值如下:
ELFDATANONE = 0, 非法或未指定.ELFDATA2LSB = 1, 小端序, 低位字节在前.ELFDATA2MSB = 2, 大端序, 高位字节在前.
readelf 中显示的 2's complement, little endian 重点是 little endian, 表示当前文件按小端序解析. 例如一个 32 位整数 0x12345678, 在文件中的字节顺序是 78 56 34 12. 这和 x86-64 平台的默认字节序一致.
在我的实验里, diff_old.o 和 mini-diff 都是小端序, 这和 x86-64 平台的默认字节序一致.
1.2.4 Version
对应 e_ident[EI_VERSION].
这个字段表示 ELF 头标识区里的版本号. 目前几乎总是 1, 也就是 EV_CURRENT. 如果这里不是当前版本, 那么解析器通常会认为这个文件不是自己支持的 ELF 版本.
在我的实验里, .o 文件和可执行文件这里都显示 Version: 1 (current), 表示二者都使用当前 ELF 规范版本.
1.2.5 OS/ABI
对应 e_ident[EI_OSABI].
这个字段说明 ELF 文件声明自己面向哪种 OS/ABI 约定. 它更像一个 ABI 提示, 常见值如下:
ELFOSABI_SYSV = 0, 即UNIX - System V.ELFOSABI_LINUX = 3, Linux ABI.- 其他系统还可能使用
FreeBSD,NetBSD,Solaris等不同取值.
需要注意的是, Linux 上生成的 ELF 文件也经常写成 System V, 这并不表示它不是 Linux 程序, 只是说它遵循通用 SysV 风格的 ELF 规则.
在我的实验里, diff_old.o 和 mini-diff 都显示为 UNIX - System V, 这是非常常见的结果.
1.2.6 ABI Version
对应 e_ident[EI_ABIVERSION].
这个字段是前面 OS/ABI 的子版本号. 对 System V 来说通常就是 0, 很多普通 ELF 文件也不会给出额外含义.
在我的实验里, 两个文件都显示 ABI Version: 0, 基本都可以理解为默认值, 没有更多特殊信息.
1.2.7 Type
对应 e_type.
这个字段决定 ELF 文件的类型, 常见值如下:
ET_REL, 可重定位文件, 也就是.o.ET_EXEC, 可执行文件.ET_DYN, 共享库或 PIE 可执行文件.ET_CORE, core dump 文件.
在我的实验里, diff_old.o 显示为 REL (Relocatable file), 表示它只是编译后的目标文件, 还没有完成最终链接. 这种文件通常包含 .text, .data, .symtab, .rela.* 等节, 供链接器继续处理, 因此它没有最终运行地址, 也通常没有程序头表.
mini-diff 显示为 DYN (Position-Independent Executable file). 这里虽然打印的是 DYN, 但它并不是共享库, 而是 PIE 可执行文件. 现代 Linux 默认会把开启 PIE 的可执行文件标记成 ET_DYN, 这样程序装载时就可以配合地址随机化, 不把代码固定在某个虚拟地址上.
1.2.8 Machine
对应 e_machine.
这个字段说明目标机器架构. 我的两个样例都显示 Advanced Micro Devices X86-64, 对应常量 EM_X86_64.
这里的含义是: 文件中的机器码、重定位类型、调用约定等都针对 x86-64 架构. 名字里虽然写了 AMD, 但它并不只支持 AMD CPU, Intel 的 64 位处理器也兼容这一架构.
1.2.9 Version
对应 e_version.
这是 ELF Header 正式字段里的版本号, 与前面的 e_ident[EI_VERSION] 含义类似, 也通常为 EV_CURRENT, 即十六进制的 0x1.
在我的实验里, .o 文件和可执行文件这里都显示 Version: 0x1, 说明头部主体结构也都使用当前 ELF 版本. 一个正常 ELF 文件通常会同时在 e_ident[EI_VERSION] 和 e_version 里都写成当前版本.
1.2.10 Entry point address
对应 e_entry.
这个字段表示程序入口地址. 对可执行文件来说, 它通常指向 _start 或实际装载后跳转执行的位置.
如果要看一个可以直接复现的最小实验, 可以直接看 examples/mini-upatch/labs/file_headers/entry_point.md. 这个实验里已经把 mini-diff 的 e_entry、_start、main 和可执行 PT_LOAD 段放到同一组命令里核对了.
对 diff_old.o 来说, 当前文件类型是 ET_REL, 即可重定位目标文件, 还不是一个可以直接执行的映像, 所以 Entry point address 为 0x0 是正常现象.
对 mini-diff 来说, 这里是 0x1320, 表示程序装载完成后, 进程最初会从映像基址加上 0x1320 的位置开始执行. 由于 mini-diff 是 PIE, 这个值更适合理解成映像内部偏移, 真正运行时虚拟地址还要加上装载基址.
1.2.11 Start of program headers
对应 e_phoff.
这个字段表示 Program Header Table 在文件中的起始偏移. Program Header 主要服务于加载器, 描述哪些段需要映射到内存、权限是什么、装载地址如何计算.
在我的实验里, diff_old.o 的值为 0, 说明文件里没有 Program Header Table. 这与 ET_REL 类型一致, 因为 .o 文件只是链接输入, 不是直接给内核装载执行的目标.
mini-diff 的值为 64, 说明 ELF Header 之后紧跟着就是 Program Header Table. 这也符合常见可执行文件布局: 前 64 字节是 Elf64_Ehdr, 从偏移 64 开始是若干个程序头项, 供内核和动态加载器装载使用.
1.2.12 Start of section headers
对应 e_shoff.
这个字段表示 Section Header Table 在文件中的起始偏移. Section Header 记录了每个节的元信息, 包括名字偏移、类型、标志、文件偏移、大小、对齐方式等.
在我的实验里, diff_old.o 的值是 3392, 说明从文件偏移 3392 字节开始是节头表. 对 .o 文件来说, Section Header 通常比 Program Header 更重要, 因为链接器主要围绕节、符号和重定位工作.
mini-diff 的值是 55808, 说明节头表被放在文件尾部附近. 这也是常见布局, 因为运行时装载主要依赖 Program Header, Section Header 更多是留给链接器、调试器和分析工具使用.
1.2.13 Flags
对应 e_flags.
这个字段用于存放架构相关的额外标志位. 某些架构会在这里编码 ABI 模式、浮点约定或其他特殊属性, 但在 x86-64 上通常没有额外含义.
在我的实验里, diff_old.o 和 mini-diff 都是 0x0, 这是 x86-64 ELF 中很常见的情况.
1.2.14 Size of this header
对应 e_ehsize.
这个字段表示 ELF Header 自身的大小. 对 64 位 ELF 来说, 正常值通常是 64 字节; 对 32 位 ELF 则通常是 52 字节.
在我的实验里, 两个文件这里都是 64 (bytes), 与 Elf64_Ehdr 的大小一致, 也再次印证当前文件是 ELF64.
1.2.15 Size of program headers
对应 e_phentsize.
这个字段表示每个 Program Header 条目的大小. 如果文件没有 Program Header Table, 这里通常就是 0.
在我的实验里, diff_old.o 的值为 0, 因为它根本没有程序头表.
mini-diff 的值为 56, 说明每个 Program Header 条目大小是 56 字节, 这正好对应 Elf64_Phdr 的标准大小.
1.2.16 Number of program headers
对应 e_phnum.
这个字段表示 Program Header 条目的个数. 可执行文件或共享库通常会有若干条目, 而 .o 文件通常没有.
在我的实验里, diff_old.o 这里是 0, 和前面的 e_phoff = 0, e_phentsize = 0 一起说明该文件没有 Program Header Table.
mini-diff 这里是 13, 表示它一共有 13 个程序头项. 这些条目里通常会包含 PT_PHDR, PT_INTERP, PT_LOAD, PT_DYNAMIC, PT_NOTE, PT_GNU_STACK, PT_GNU_RELRO 等信息, 内核和动态加载器会按这些段描述把程序映射到内存里.
1.2.17 Size of section headers
对应 e_shentsize.
这个字段表示每个 Section Header 条目的大小. 在 ELF64 中, Elf64_Shdr 通常是 64 字节; 在 ELF32 中, Elf32_Shdr 通常是 40 字节.
在我的实验里, 两个文件这里都显示 64 (bytes), 说明节头表中的每一项都是标准的 Elf64_Shdr 结构.
1.2.18 Number of section headers
对应 e_shnum.
这个字段表示 Section Header 条目的总数. 这里统计的是节头项数量, 包括第 0 个空节, 也包括 .symtab, .strtab, .rela.*, .debug_* 之类分析和链接需要的节.
在我的实验里, diff_old.o 的值是 28, 表示它一共含有 28 个节头项. 这并不意味着一定有 28 个功能性代码段, 因为其中还包含各种元数据节.
mini-diff 的值是 38, 比 .o 文件更多. 这通常是因为最终可执行文件除了基本代码和数据节以外, 还会带上 .interp, .dynamic, .dynsym, .dynstr, .got, .plt, .init_array, .fini_array 等与动态链接和运行时装载有关的节.
1.2.19 Section header string table index
对应 e_shstrndx.
这个字段表示“节名字符串表”所在的 Section Header 索引. ELF 中每个节的名字不是直接存放在节头里的, 节头只保存一个名字偏移, 真正的字符串集中保存在 .shstrtab 节中.
在我的实验里, diff_old.o 的值是 27, 表示编号 27 的节是节名字符串表.
mini-diff 的值是 37, 表示它的节名字符串表位于第 37 个节头项. 解析诸如 .text, .data, .dynamic, .plt 这些节名时, 也都要先找到这个字符串表, 再根据各节头里的 sh_name 偏移取出真实名字.
2 ELF section headers
2.1 实验文件代码
可重定位文件.
1 | $ readelf -WS diff_old.o |
可执行文件.
1 | $ readelf -WS mini-diff |
2 ELF sections headers
readelf -WS 对应的是 Section Header Table, 也就是节头表.
如果说 ELF Header 是整个 ELF 文件的总目录, 那么 Section Header Table 就像每一章的分目录, 说明:
- 每个 section 叫什么名字
- 它是什么类型
- 它在文件里的偏移是多少
- 它有多大
- 它是否要装载进内存
- 它和其他 section 是否存在关联
对链接器、调试器和分析工具来说, section 比 segment 更重要. 尤其对 .o 文件来说, 几乎所有工作都是围绕 section, symbol 和 relocation 展开的.
2.1 对应的 C 结构
readelf -WS 打印的每一行, 本质上都来自一个 Elf64_Shdr.
1 | typedef struct { |
这些字段和 readelf -WS 各列的对应关系如下:
Name对应sh_nameType对应sh_typeAddress对应sh_addrOff对应sh_offsetSize对应sh_sizeFlg对应sh_flagsES对应sh_entsizeLk对应sh_linkInf对应sh_infoAl对应sh_addralign
其中 Name 本身不是直接存字符串, 而是一个偏移, 需要到 .shstrtab 里查真正的 section 名字.
如果要单独看一个关于 sh_name 的最小实验, 可以直接看 examples/mini-upatch/labs/section_headers/sh_name.md.
2.2 readelf -WS 各列含义
先看表头:
1 | [Nr] Name Type Address Off Size ES Flg Lk Inf Al |
2.2.1 Name
section 的名字.
例如:
.text.data.bss.rela.text.target_func.dynsym.shstrtab
名字本身不直接存在 Elf64_Shdr 里, 而是通过 sh_name 到 .shstrtab 查到. 其中 sh_name 更准确地说是“相对 .shstrtab 起始位置的字节偏移”, 不是直接的字符串, 也不是抽象编号. 具体可以直接看 examples/mini-upatch/labs/section_headers/sh_name.md.
2.2.2 Type
section 的类型, 决定这个 section 存什么语义的数据.
在这两个样例里最常见的类型有:
NULL- 第 0 项保留空 section.
PROGBITS- 最常见, 表示普通原始数据.
- 代码段、只读数据段、注释段、调试段很多都属于它.
NOBITS- 占内存, 但不占文件内容.
- 典型例子就是
.bss.
RELA- 带显式 addend 的重定位表.
NOTE- note 信息, 用来存 ABI build-id property 等元数据.
SYMTAB: 完整符号表, section 类型是SHT_SYMTAB, 每个表项通常是一个Elf64_Sym, 里面会记录符号名、符号值、大小、绑定属性、符号类型、所属 section 等信息. 它通常比DYNSYM更全, 会包含 local symbol, global symbol, undefined symbol, section symbol 等.- 在我的实验里:
diff_old.o的.symtab是核心元数据之一, 因为.rela.text.target_func的Lk = 25, 指向的就是.symtab.mini-diff也保留了.symtab, 方便调试和分析, 但它不是运行时动态链接所必需的那一套符号表.
- 在我的实验里:
DYNSYM: 动态链接用的符号表, section 类型是SHT_DYNSYM, 每个表项同样通常是Elf64_Sym. 它只保留运行时动态链接真正需要的那一部分符号, 通常比SYMTAB更精简. 主要给动态加载器使用, 例如解析共享库符号、处理.rela.dyn、.rela.plt.- 在我的实验里:
diff_old.o没有.dynsym, 因为它只是ET_REL目标文件.mini-diff有.dynsym, 因为它是最终可执行文件, 需要参与动态链接.
- 在我的实验里:
STRTAB: 字符串表, section 类型是SHT_STRTAB, ELF 里很多结构体并不直接保存字符串, 而是保存一个偏移, 再去某个字符串表里取真正的名字.- 在我的实验里:
.strtab给.symtab提供符号名字符串.dynstr给.dynsym提供动态符号名字符串.shstrtab给 section header 的sh_name提供 section 名字字符串
- 在我的实验里:
GNU_HASH- 动态符号查找用的 GNU 哈希表.
VERSYM- 动态符号版本索引表.
VERNEED- 依赖的版本需求表.
INIT_ARRAY- 构造函数数组.
FINI_ARRAY- 析构函数数组.
DYNAMIC- 动态链接器要读取的元数据表.
2.2.3 Address
section 运行时地址, 对应 sh_addr.
这个字段在 .o 文件和可执行文件里的意义差别很大:
- 对
.o文件来说, 地址通常还没有最终确定, 所以大多是0. - 对最终可执行文件来说, 这是链接后 section 在映像中的虚拟地址.
在我的实验里:
diff_old.o所有 section 基本都是0mini-diff的.text地址是0x1320mini-diff的.rodata地址是0x6000mini-diff的.data地址是0x8000
如果要单独看一个关于 sh_addr 和运行时装载基址关系的最小实验, 可以直接看 examples/mini-upatch/labs/section_headers/address_runtime_layout.md.
在我的实验里:
mini-diff的.text的sh_addr = 0x1320- 运行时装载基址是
0x555555554000 _start的实际地址是0x555555555320
正好满足:
0x555555554000 + 0x1320 = 0x555555555320
这说明对 PIE 可执行文件来说, sh_addr 更适合理解成“相对映像基址的虚拟地址”, 真正的运行时绝对地址还要再加上装载基址.
2.2.4 Off
section 在 ELF 文件中的文件偏移, 对应 sh_offset.
这个值说明“从文件开头数多少字节能找到这个 section 的原始内容”.
在我的实验里:
diff_old.o的.text.target_func偏移是0x66mini-diff的.text偏移是0x1320mini-diff的.dynamic偏移是0x7d18
2.2.5 Size
section 大小, 对应 sh_size.
如果是 NOBITS 类型, 比如 .bss, 这个大小表示运行时要分配多大内存, 但文件里不真正占这段字节内容.
例如:
diff_old.o的.text.target_func大小是0x35mini-diff的.text大小是0x3ee6mini-diff的.bss大小是0x10
这里我们结合 Off 和 Size 看一下 .text.target_func 的内容.
1 | $ hexdump -C -s 0x66 -n 0x35 diff_old.o |
然后我们反汇编内容查看是否正确, 这里依然查看 .text.target_func.
1 | $ objdump -d -j .text.target_func --start-address=0x0 --stop-address=0x35 diff_old.o |
2.2.6 ES
entry size, 对应 sh_entsize.
只有“由固定大小条目组成的 section”这个字段才有明显意义.
常见的:
RELA的ES = 0x18, 因为每个Elf64_Rela是 24 字节SYMTAB/DYNSYM的ES = 0x18, 因为每个Elf64_Sym是 24 字节.plt的ES = 0x10, 表示这里的表项以 16 字节为一个单位组织- 普通代码段和数据段通常
ES = 0
2.2.7 Flg
section 标志位, 对应 sh_flags.
在这两个样例里最常见的是:
W: 可写A: 运行时需要装载进内存X: 可执行M: 可合并S: 是字符串表风格的数据I: 这个 section 的sh_info有特殊索引意义, 常见于 relocation section (实验 TODO)
常见的典型例子:
AX: 可装载且可执行, 典型是代码段WA: 可写且可装载, 典型是.data、.bss、.gotMS: 可合并的字符串型数据, 常见于.comment、.debug_strAI: 可装载, 且sh_info有索引意义, 例如.rela.plt
2.2.8 Lk
link 字段, 对应 sh_link.
这个字段的含义依赖于 section 类型.
最常见的情况:
- 对
SYMTAB/DYNSYM, 它通常指向关联的字符串表 - 对
RELA, 它通常指向该 relocation section 使用的符号表 - 对
GNU_HASH/VERSYM/VERNEED, 它也常指向动态字符串表或动态符号表
例如在我的实验里:
diff_old.o的.rela.text.target_func的Lk = 25, 指向.symtabdiff_old.o的.symtab的Lk = 26, 指向.strtabmini-diff的.dynsym的Lk = 7, 指向.dynstr
2.2.9 Inf
info 字段, 对应 sh_info.
这个字段同样依赖于 section 类型.
最常见的情况:
- 对
RELA, 它通常指向“它要修正哪个目标 section” - 对
SYMTAB, 它通常表示第一个非本地符号的索引 - 对版本表和其他特殊表, 也可能有额外含义
在我的实验里:
diff_old.o的.rela.text.target_func的Inf = 6, 指向.text.target_funcdiff_old.o的.rela.debug_info的Inf = 9, 指向.debug_infomini-diff的.rela.plt的Inf = 24, 指向.gotmini-diff的.symtab的Inf = 57, 表示前 57 个符号属于 local 范围
2.2.10 Al
alignment, 对应 sh_addralign.
它表示这个 section 在文件和内存中通常需要满足的对齐要求.
在我的实验里:
- 代码段经常是
16字节对齐 .dynamic、.got、.eh_frame常见8字节对齐.bss有时会对齐到32
对齐要求越高, 链接器在布局时越要插入 padding.
2.3 先看 .o 和可执行文件的总体差异
在我的实验里, diff_old.o 和 mini-diff 的 section 组织差别非常明显:
diff_old.o- 以“编译单元”为中心
- 代码按函数被拆成
.text.local_offset、.text.target_func这种小 section - 有很多
.rela.*记录链接前尚未确定的引用 - 没有
.interp、.dynamic、.dynsym、.plt、.got
mini-diff- 以“最终运行映像”为中心
- 有统一的
.text、.rodata、.data、.bss - 带上动态链接所需的
.interp、.dynamic、.dynsym、.dynstr - 带上 PLT/GOT 和版本信息
- 仍然保留调试节和普通符号表, 方便分析和调试
也就是说:
.o文件更像链接输入材料- 可执行文件更像运行时最终成品
2.4 可重定位文件 diff_old.o 的 section 解析
先按功能分组来看.
2.4.1 空 section
1 | [ 0] NULL |
第 0 项永远保留为空 section. 这是 ELF 规范规定的.
很多索引字段如果填 0, 往往就表示“没有关联对象”或者“未定义”.
2.4.2 代码与数据 section
1 | [ 1] .text |
这里最值得注意的是, 真正有内容的代码不在总 .text, 而是被拆成了多个函数级 section:
.text.local_offset.text.del_offset.text.target_func.text.unchanged_func
这通常来自 -ffunction-sections 一类编译策略. 这样做的好处是:
- 链接器可以做更细粒度的垃圾回收
- diff/patch 工具可以按函数为单位追踪变更
upatch-diff这类工具更容易把“函数变化”映射到“section 变化”
其中:
.text是空的, 大小为0.data是空的.bss也是空的
这说明当前这个样例里真正关心的是代码和外部引用, 并没有在目标文件内定义静态全局数据.
2.4.3 relocation section
1 | [ 7] .rela.text.target_func |
这些 RELA section 是 .o 文件的核心信息之一.
以:
1 | [ 7] .rela.text.target_func RELA ... Lk=25 Inf=6 |
为例, 可以这样理解:
- 它是一个 relocation 表
Lk = 25, 说明它引用的符号表是.symtabInf = 6, 说明它要修正的目标 section 是第 6 项.text.target_func
这正符合 target_func() 的源码:
1 | return host_add(local_offset(x), *host_value_ptr) + 7; |
这里至少涉及三类尚未最终确定的引用:
- 对本地函数
local_offset的引用 - 对外部变量
host_value_ptr的引用 - 对外部函数
host_add的引用
这些位置在编译成 .o 时还不能完全写死, 所以编译器会先生成占位指令和 relocation 记录, 等链接器后续修正.
此外还有几组 .rela.debug_*, 它们不是修正运行时代码的, 而是修正 DWARF 调试信息中对代码地址、范围、字符串等对象的引用.
2.4.4 调试信息 section
1 | [ 9] .debug_info |
这些 section 都属于 DWARF 调试信息.
可以简单理解为:
.debug_info- 调试主信息, 描述函数、变量、类型、编译单元
.debug_abbrev- 调试信息的“语法模板”
.debug_aranges- 地址范围到编译单元的映射
.debug_rnglists- 更灵活的地址范围列表
.debug_line- 机器地址到源代码行号的映射
.debug_str- 调试信息使用的字符串池
.debug_line_str- 行号信息专用字符串池
这些 section 通常:
Address = 0- 没有
A标志
因为它们不给程序运行时使用, 只给调试器和分析工具使用.
2.4.5 其他辅助 section
1 | [20] .comment |
这几个 section 也值得单独说明.
.comment- 通常存编译器版本字符串,
MS表示可合并的字符串数据
- 通常存编译器版本字符串,
.note.GNU-stack- 用来告诉链接器/装载器“这个对象文件不需要可执行栈”, 它自己一般是空 section
.note.gnu.property- GNU property note, 用来记录一些 ABI/硬件特性属性
.eh_frame- 异常展开和栈回溯信息, 即使不是 C++ 异常, 许多平台上的回溯也会依赖它
.eh_frame 在 .o 里常常也带一个对应的 .rela.eh_frame, 因为里面的地址引用同样需要链接后修正.
2.4.6 字符串表和符号表
1 | [25] .symtab |
这三个 section 是分析 .o 文件时最关键的元数据区.
.symtab- 完整符号表
- 记录本地符号、全局符号、未定义符号、section 符号等
.strtab- 普通符号字符串表
.symtab中的名字偏移要到这里查
.shstrtab- section 名字字符串表
- section header 中的
sh_name要到这里查
在我的实验里:
.symtab的Lk = 26, 表示它关联的字符串表是.strtab.symtab的Inf = 14, 表示前 14 个符号是 local symbol
2.5 可执行文件 mini-diff 的 section 解析
最终可执行文件的 section 类型更丰富, 因为它不仅要保留符号和调试信息, 还要给动态加载器和运行时提供完整支持.
2.5.1 程序解释器与 note section
1 | [ 1] .interp |
.interp- 指定动态加载器路径
- 在我的实验里就是
/lib64/ld-linux-x86-64.so.2
.note.gnu.property- 记录 GNU property
.note.gnu.build-id- 记录 build-id, 方便调试和符号定位
.note.ABI-tag- 描述 ABI 兼容信息
这些 section 都带 A, 因为它们会被装载到进程映像中.
2.5.2 动态链接元数据
1 | [ 5] .gnu.hash |
这组 section 是动态链接的核心元数据.
.gnu.hash- 动态符号查找加速结构
.dynsym- 动态链接需要导出或引用的符号表
.dynstr- 动态符号字符串表
.gnu.version- 动态符号版本索引
.gnu.version_r- 依赖的外部版本信息
.rela.dyn- 普通动态重定位
.rela.plt- PLT 相关的重定位
和 .o 文件相比, 这里的 relocation 已经不是给静态链接器用的“编译中间态”, 而是给动态加载器在程序启动时或首次调用时处理的.
2.5.3 可执行代码 section
1 | [12] .init |
这些都是可执行 section, 都有 AX 标志.
.init- 早期初始化入口
.plt- Procedure Linkage Table, 外部函数调用跳板
.plt.got- 与 GOT 配合使用的一小段跳板代码
.plt.sec- 新版工具链中常见的 PLT 补充分区
.text- 主体代码段
.fini- 结束清理代码
在我的实验里:
.text地址是0x1320- 程序入口
e_entry也是0x1320
所以 _start 就位于 .text 的起始位置.
2.5.4 只读数据和回溯信息
1 | [18] .rodata |
.rodata- 只读常量区, 比如格式化字符串、只读表
.eh_frame_hdr.eh_frame的索引头
.eh_frame- 栈展开和回溯信息
这些 section 都带 A, 但不带 W 和 X, 说明它们会进入内存, 但既不可写也不可执行.
2.5.5 可写运行时数据
1 | [21] .init_array |
这些 section 都带 WA, 表示它们需要装载进内存并且可写.
.init_array- 构造函数指针数组
.fini_array- 析构函数指针数组
.dynamic- 动态加载器读取的动态信息表
.got- Global Offset Table
.data- 已初始化可写数据
.bss- 未初始化可写数据
尤其要注意:
.got- 在动态链接、PIE 和外部符号访问中非常关键
.bssType = NOBITS, 说明文件里不真正存这块内容, 运行时只分配零初始化内存
2.5.6 不参与运行时装载的调试与注释 section
1 | [27] .comment |
这些 section 的 Address 都是 0, 也没有 A 标志.
这说明它们虽然保留在 ELF 文件中, 但不会参与运行时映射, 仅用于:
- 调试
- 反汇编
- 符号恢复
- 源码级分析
2.5.7 普通符号表与字符串表
1 | [35] .symtab |
即使可执行文件已经有 .dynsym, 通常仍然可能保留一份 .symtab.
两者差别是:
.dynsym- 给动态加载器用
- 只保留动态链接必需的符号
.symtab- 给调试器和分析工具用
- 更完整
在我的实验里:
.symtab大小明显比.dynsym大- 这符合“完整符号表比动态符号表更全”的预期
2.6 结合这两个样例理解 section 的关键词
2.6.1 PROGBITS
最常见的 section 类型.
它只是说“这里是普通原始字节内容”, 但并不限定用途. 所以:
- 代码可以是
PROGBITS - 只读数据可以是
PROGBITS - 调试信息也可以是
PROGBITS
真正区分用途, 还要结合:
- section 名字
Flg- 是否在运行时映射
2.6.2 NOBITS
表示“运行时有, 文件里没有”.
最典型的就是 .bss.
它的特点是:
- 有
Size - 有可能带
WA - 文件偏移
Off只是逻辑位置, 不会在文件里真正存这一段数据字节
2.6.3 RELA
表示 relocation with addend.
在 x86-64 上很常见, 每个条目通常对应 Elf64_Rela.
它的职责是:
- 告诉链接器或加载器
- 哪个位置要修正
- 用哪个符号来修正
- 再附加一个显式 addend
.o 文件中的 RELA 主要给静态链接器用, 可执行文件里的 .rela.dyn / .rela.plt 主要给动态加载器用.
2.6.4 SYMTAB 和 DYNSYM
都表示符号表, 但用途不同:
SYMTAB- 完整符号表
- 更适合静态分析、调试、链接阶段
DYNSYM- 动态符号表
- 更适合运行时动态链接
它们的核心区别可以直接概括成:
SYMTAB- 尽量完整
- 面向链接器、调试器、离线分析
DYNSYM- 只保留运行时真正需要的子集
- 面向动态加载器
结合我的两个样例来看最直观:
- 在
diff_old.o里只有.symtab, 没有.dynsym- 因为
.o文件还没有进入“运行时动态链接”这个阶段 - 它的重点是给静态链接器和分析工具提供完整符号信息
- 因为
- 在
mini-diff里同时有.dynsym和.symtab.dynsym给运行时动态链接用.symtab给调试和分析用
可以把它们理解成:
.symtab像“完整通讯录”.dynsym像“运行时真正会用到的联系人子集”
2.6.5 STRTAB
字符串表.
ELF 很少直接在结构体里存字符串, 往往都是:
- 结构体里存偏移
- 再去某个字符串表 section 中查真实字符串
常见字符串表有:
.strtab- 普通符号名字
.dynstr- 动态符号名字
.shstrtab- section 名字
如果把 SYMTAB / DYNSYM 看成“记录表”, 那 STRTAB 就是“名字池”.
举个和当前实验直接相关的例子:
diff_old.o的.symtab里会有target_func、local_offset、host_add这些符号记录- 但这些名字字符串本身不直接塞在每个
Elf64_Sym结构里 Elf64_Sym里只会存一个名字偏移- 真正的字符串内容要去
.strtab查
同理:
mini-diff的.dynsym里记录了运行时动态链接关心的符号- 真正的名字字符串要去
.dynstr查
而 section header 这边的名字又是另一套:
Elf64_Shdr里的sh_name不是直接名字- 它要去
.shstrtab查 section 名
2.6.6 AX / WA / MS
这些组合一眼就能看出 section 的大类:
AX- 可执行代码
WA- 可写运行时数据
MS- 可合并字符串类元数据
3 ELF program headers
3.1 实验文件代码
可重定位文件.
1 | $ readelf -Ws diff_old.o |
可执行文件.
1 | $ readelf -Ws mini-diff |
3.2 对应的 C 结构
1 | typedef struct { |
3.3 readelf -Ws 各表项含义
3.3.1 Num
Num 不直接对应 Elf64_Sym 结构体里的某个字段.
它表示“当前符号在当前符号表中的表项序号”, 也就是数组下标.
可以把符号表理解成:
1 | Elf64_Sym symtab[]; |
那么:
Num = 0就是symtab[0]Num = 1就是symtab[1]Num = 14就是symtab[14]
它的计算方式很直接:
- 从当前符号表起始位置开始
- 按
Elf64_Sym的大小逐项编号 - 第几个表项,
Num就是多少
这一点很重要, 因为 ELF 里很多地方都会通过“符号表索引”引用某个符号.
例如 relocation 条目里的符号引用, 本质上就是在说:
- “我依赖的是符号表第 N 项”
在我的实验里:
readelf -Ws diff_old.o的.symtab有 18 项, 所以Num范围是0 ~ 17readelf -Ws mini-diff的.dynsym有 30 项, 所以它自己的Num范围是0 ~ 29readelf -Ws mini-diff的.symtab有 98 项, 所以它自己的Num范围是0 ~ 97
要注意:
Num是“每张符号表内部单独编号”的.dynsym和.symtab各自从0开始重新编号- 不能把不同符号表里的
Num直接混为同一个全局编号
符号表第 0 项通常还是一个保留空项, 很多工具链都会这样组织. 例如:
1 | 0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND |
这个空项存在的意义和 section header 第 0 项有点类似:
- 作为保留值
- 给“未定义/空引用”留出一个安全索引
3.3.2 Value
对应 st_value, 符号值.
这个字段的含义依赖于符号类型和文件类型.
最常见的理解方式是:
- 对已定义函数/变量
- 它通常表示符号的地址, 或者相对所属 section 的偏移
- 对未定义符号
UND- 它通常是
0
- 它通常是
- 对 section 符号
SECTION- 它通常表示该 section 内的偏移, 常见为
0
- 它通常表示该 section 内的偏移, 常见为
在 .o 文件里:
st_value往往不是最终进程虚拟地址- 而更像“相对 section 起点的偏移”
例如在我的实验里:
diff_old.o里的target_func的Value = 0- 因为它所在的
.text.target_func是单独 section, 函数正好从该 section 的起始位置开始
- 因为它所在的
diff_old.o里的host_add是UND- 所以
Value = 0
- 所以
在最终可执行文件里:
st_value通常就是链接后的映像虚拟地址
例如在我的实验里:
mini-diff的_start是0x1320main是0x4fe4
这和前面 sh_addr, e_entry 的分析是能对上的.
3.3.3 Size
对应 st_size, 符号的大小.
这个字段说明一个符号覆盖多少字节.
最常见的情况:
- 对函数符号
FUNC- 表示函数机器码长度
- 对对象符号
OBJECT- 表示对象大小
- 对
FILE,SECTION、未定义符号- 经常是
0
- 经常是
例如在我的实验里:
diff_old.o中:local_offset的Size = 19target_func的Size = 53unchanged_func的Size = 18
mini-diff中:map_file的Size = 329open_elf_object的Size = 540
这和函数实际反汇编出来的指令长度是一致的.
3.3.4 Type
从 st_info 拆解出来, 用宏 ELF64_ST_TYPE(st_info) 来提取.
常见的数值有:
- STT_NOTYPE
- STT_OBJECT
- STT_FUNC
- STT_SECTION
- STT_FILE
这些类型可以这样理解:
STT_NOTYPE: 没有给出更具体的类型, 常见于未定义外部符号, 尤其是某些目标文件里的外部函数/变量引用.STT_OBJECT: 普通数据对象, 例如全局变量、静态对象、数组、运行时对象槽位.STT_FUNC: 函数符号, 表示这是可执行代码入口.STT_SECTION: section 符号, 它不是普通 C 源码层面的函数或变量, 而是“代表某个 section 本身”的符号, 在 relocation、调试信息、链接内部处理中很常见.STT_FILE: 文件符号, 用来记录编译单元对应的源文件名, 一般Ndx = ABS
除了这些常见类型, ELF 里还可能出现:
STT_COMMON: 历史上用于 common symbolSTT_TLS: 线程局部存储变量
但在当前两个样例里没有重点出现.
3.3.5 Bind
从 st_info 拆解出来, 用宏 ELF64_ST_BIND(st_info) 来提取.
st_info 把两部分塞在一个字节里:
- 高 4 位: Bind
- 低 4 位: Type
常见的数值有:
- STB_LOCAL
- STB_GLOBAL
- STB_WEAK
这些绑定属性可以这样理解:
STB_LOCAL: 只在当前目标文件内部可见, 不会参与跨文件符号导出, 常见于static函数、局部 section 符号、文件符号STB_GLOBAL: 全局可见, 可以被其他目标文件引用, 也可以去解析其他文件里的同名未定义符号STB_WEAK: 弱符号, 如果有强符号同名定义, 强符号优先, 如果没有强符号, 弱符号也可以工作, 常见于某些运行库辅助符号
简单理解:
LOCAL像“仅本文件可见”GLOBAL像“正式对外公开”WEAK像“优先级较低的公开定义/引用”
3.3.6 Vis
从 st_other 拆解出来, 用宏 ELF64_ST_VISIBILITY(st_other) 来提取.
这个字段表示符号可见性, 也就是“即便它是全局绑定, 外部到底能不能正常看到它”.
最常见的值有:
STV_DEFAULT: 默认可见性, 最常见STV_HIDDEN: 符号对本对象内部可见, 但不会正常对外导出STV_PROTECTED: 对外可见, 但本对象内部引用时具有更强的本地绑定语义STV_INTERNAL: 处理器/ABI 特定的内部可见性, 比较少见
在我的实验里大多数符号都显示为 DEFAULT, 这也是最常见的情况.
3.3.7 Ndx
对应 st_shndx, 表示属于的 section.
这个字段表示“该符号归属于哪个 section”.
如果是普通已定义符号:
Ndx通常是一个 section 索引号
例如在我的实验里:
diff_old.o里的target_func的Ndx = 6- 对应
.text.target_func
- 对应
diff_old.o里的unchanged_func的Ndx = 8- 对应
.text.unchanged_func
- 对应
mini-diff里的main的Ndx = 16- 对应
.text
- 对应
还有一些特殊取值:
UND- undefined
- 表示符号在当前文件里没有定义, 需要外部提供
- 例如:
diff_old.o的host_addmini-diff里的printf@GLIBC_2.2.5
ABS- absolute
- 表示这是绝对符号, 不依附于某个普通 section
- 常见于
FILE符号 - 例如:
diff_old.cmini_diff.c
所以 Ndx 一眼就可以先判断:
- 这个符号是否已定义
- 如果已定义, 它属于哪个 section
- 如果未定义, 它是不是等待外部解析
3.3.8 Name
对应 st_name, 相对字符串表的偏移,要去 .strtab 或 .dynstr 查.
和前面 sh_name 的逻辑类似, st_name 也不是直接存字符串.
它存的是:
- 相对字符串表起始位置的字节偏移
但这里使用哪张字符串表, 要看当前是哪张符号表:
- 如果当前是
.symtab- 就去
.strtab查
- 就去
- 如果当前是
.dynsym- 就去
.dynstr查
- 就去
例如在我的实验里:
diff_old.o的.symtabtarget_funclocal_offsethost_add- 这些名字都来自
.strtab
mini-diff的.dynsymprintf@GLIBC_2.2.5malloc@GLIBC_2.2.5__libc_start_main@GLIBC_2.34- 这些名字都来自
.dynstr
可以把它理解成:
Elf64_Sym里存“名字指针的偏移”- 真正字符串统一保存在字符串表里
4 relocations
4.1 实验实例
可重定位文件.
1 | $ readelf -Wr diff_old.o |
可执行文件.
1 | $ readelf -Wr mini-diff |
4.2 C 结构体
1 | typedef struct { |
4.3 readelf -Wr 表项解析
readelf -Wr 打印的每一行 relocation, 本质上都来自一个 Elf64_Rela.
和前面 section header、symbol table 的思路一样, readelf 只是把结构体字段再加上一些辅助解码结果打印出来.
4.3.1 Offset
对应 r_offset.
这个字段表示: “要修正的位置”在目标 section 中的偏移
注意这里不是“符号地址”, 也不是“文件绝对偏移”, 而是: 相对于被重定位 section 起始位置的偏移
在我的实验里:
1 | Relocation section '.rela.text.target_func' ... |
这里的 Offset 分别是:
0x130x1f0x28
它们的含义就是:
- 要修正的地方位于
.text.target_func内部的第0x13、0x1f、0x28字节处
如果只写到这里, 还是会有点抽象.
可以直接看 examples/mini-upatch/labs/relocations/r_offset.md, 里面专门做了一个最小实验, 把:
.text.target_func的sh_offset = 0x66- relocation 里的
r_offset = 0x13 / 0x1f / 0x28 - hexdump 里的真实文件字节位置
- objdump 里的具体指令字段
四者直接对起来了.
在那个实验里可以直接验证:
0x66 + 0x13 = 0x790x66 + 0x1f = 0x850x66 + 0x28 = 0x8e
也就是说:
r_offset本身是“相对 section 的偏移”- 真正落到文件里的位置, 还要再加上目标 section 的
sh_offset
4.3.2 Info
对应 r_info.
这个字段本身不是单一含义, 而是把两部分信息打包在一起:
- 高位部分: 符号表索引
- 低位部分: relocation 类型
在 ELF64 里通常用这两个宏拆:
1 | ELF64_R_SYM(r_info) |
也就是说:
Info本质上是在同时表达:- “这条 relocation 依赖符号表里的哪一个符号”
- “要按哪种 relocation 规则计算”
例如:
1 | 000000000013 000f00000002 ... |
可以拆成:
- 符号索引 =
0x000f= 15 - relocation 类型 =
0x0002=R_X86_64_PC32
再去 diff_old.o 的 .symtab 看第 15 项:
1 | 15: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND host_value_ptr |
这就对上了:
.rela.text.target_func第 1 条 relocation, 引用的是.symtab[15], 也就是host_value_ptr.
4.3.3 Type
这是从 r_info 拆出来的 relocation 类型, 一般通过:
1 | ELF64_R_TYPE(r_info) |
当前样例里最重要的几种类型有:
R_X86_64_PC32- 32 位 PC-relative relocation
- 常用于 RIP 相对寻址, 或某些近距离
call/jmp - 最终写入的是“目标地址相对当前位置的位移”
R_X86_64_PLT32- 也是 32 位 PC-relative 风格
- 但语义偏向函数调用, 链接器可能通过 PLT 处理
- 常见于对外部函数的
call
R_X86_64_32- 写入 32 位绝对值
- 在调试信息里很常见
R_X86_64_64- 写入 64 位绝对值
- 在调试信息和某些数据引用里常见
当前最值得关注的是 .rela.text.target_func 这三条:
R_X86_64_PC32 host_value_ptr - 4R_X86_64_PC32 .text.local_offset - 4R_X86_64_PLT32 host_add - 4
它们分别对应:
- 对外部变量
host_value_ptr的 RIP 相对访问 - 对本地函数
local_offset的call - 对外部函数
host_add的call
更多应该查看对应架构的 ABI 手册的对应部分.
4.3.4 Sym.Value
这是 readelf 为了方便阅读额外打印出来的“关联符号值”.
它不是 Elf64_Rela 里的字段, 而是:
- 先通过
ELF64_R_SYM(r_info)找到关联的符号表项 - 再把那个符号的
st_value打印出来
也就是说, Sym.Value 本质上来自:
- 被引用符号的
Elf64_Sym.st_value
例如在我的实验里:
1 | 000000000013 ... host_value_ptr - 4 |
其中 Sym.Value = 0, 是因为:
host_value_ptr在diff_old.o里是UND- 它本地还没有定义,
st_value自然是0
而对 .text.local_offset 这种 section/local symbol 来说, 它也常常显示为 0, 因为它在自己的 section 里就是从偏移 0 开始.
4.3.5 Sym. Name + Addend
这部分也是 readelf 为了可读性拼出来的结果:
- 关联符号名
- 加号或减号后的 addend
其中:
- 符号名来自
ELF64_R_SYM(r_info)找到的那个符号 - addend 对应
r_addend
例如:
1 | host_value_ptr - 4 |
这里的 - 4 就来自 r_addend = -4.
4.3.6 Addend
对应 r_addend.
这是 RELA 和 REL 的核心区别之一:
REL- addend 不单独存字段, 要从目标位置原始内容里取
RELA- addend 单独存放在结构体里
当前 x86-64 样例主要使用 RELA, 所以 r_addend 是显式可见的.
在 .rela.text.target_func 里三条都是 -4, 原因和 x86-64 的 PC-relative 编码规则有关.
像:
1 | e8 xx xx xx xx |
这样的 call rel32 指令, 位移是相对于“下一条指令地址”计算的, 而 relocation 位置往往指向这 4 字节立即数字段本身.
因此在很多 x86-64 PC-relative relocation 里, 都会看到一个 -4 的 addend, 用来把计算基准调整到正确位置.
可以把它先粗略理解成:
- “类型规则决定怎么算”
- “符号提供目标基准值”
- “addend 再做最后的常数修正”
4.3.7 一条 relocation 是怎么工作的
以这条为例:
1 | 000000000028 001000000004 R_X86_64_PLT32 0000000000000000 host_add - 4 |
它可以拆成:
Offset = 0x28- 要修正
.text.target_func内第0x28字节开始的字段
- 要修正
Info- 符号索引 =
0x10= 16 - 类型 =
0x04=R_X86_64_PLT32
- 符号索引 =
Sym.Name = host_add- 说明目标符号是
host_add
- 说明目标符号是
Addend = -4- 这是额外常数修正
而前面的反汇编里:
1 | 27: e8 00 00 00 00 |
正好对应:
- opcode
e8 - 后面 4 字节立即数暂时全是
0 - 链接器后续会根据
host_add的实际位置, 按R_X86_64_PLT32规则把这 4 字节改成正确位移
4.3.8 为什么 .o 里看到很多 0
这正是 relocation 存在的原因.
在 .o 文件里:
- 编译器已经知道“这里将来要引用谁”
- 但它还不知道“最终链接后那个符号到底在哪里”
所以它先做两件事:
- 在指令或数据里留一个占位值
- 生成 relocation 表项, 记录后续如何修正
因此像下面这种机器码:
1 | 48 8b 05 00 00 00 00 |
看起来像“全是 0”, 实际上不是错误, 而是“等待链接器填空”.
5 ELF program headers
5.1 实验实例
可重定位文件.
1 | $ readelf -Wl diff_old.o |
可执行文件.
1 | $ readelf -Wl mini-diff |
5.2 表项解释
readelf -Wl 打印的是 Program Header Table, 也就是程序头表.
和 section header table 不同, program header 主要是给:
- 内核
- 动态加载器
- 运行时装载器
使用的.
如果说:
- section header 更偏“链接视角”
- 那 program header 更偏“装载视角”
也就是说, 当一个可执行文件真的要进入内存时, 系统更关心的是 program header, 而不是 section header.
5.3 对应的 C 结构
readelf -Wl 的每一行, 本质上都来自一个 Elf64_Phdr.
1 | typedef struct { |
和 readelf -Wl 各列的对应关系如下:
Type对应p_typeOffset对应p_offsetVirtAddr对应p_vaddrPhysAddr对应p_paddrFileSiz对应p_fileszMemSiz对应p_memszFlg对应p_flagsAlign对应p_align
5.4 表示解析
5.4.1 Type
对应 p_type.
这个字段表示 program header 条目的类型, 也就是“这条段描述到底是干什么的”.
在我的实验里最重要的类型有:
PT_PHDR- 描述 program header table 自己
PT_INTERP- 指定动态加载器路径
- 在我的实验里是
/lib64/ld-linux-x86-64.so.2
PT_LOAD- 最重要, 表示一个真正需要映射到内存里的可装载段
PT_DYNAMIC- 动态链接器要读取的动态信息
PT_NOTE- note 信息
PT_GNU_PROPERTY- GNU property 元数据
PT_GNU_EH_FRAME.eh_frame_hdr相关信息
PT_GNU_STACK- 栈权限信息
PT_GNU_RELRO- RELRO 保护范围
其中最关键的是 PT_LOAD, 因为程序真正进入内存, 靠的就是这一类条目.
5.4.2 Offset
对应 p_offset.
它表示:
- 这个 segment 在文件中的起始偏移
在我的实验里:
- 可执行代码段:
LOAD Offset = 0x001000
- 只读数据段:
LOAD Offset = 0x006000
- 可写数据段:
LOAD Offset = 0x007d08
也就是说, 装载器需要从文件的这些偏移位置开始取字节, 再映射到内存中对应的位置.
5.4.3 VirtAddr
对应 p_vaddr.
它表示:
- 这个 segment 在进程虚拟地址空间中的映射地址
对 ET_DYN / PIE 来说, 这里更适合理解成:
- 相对映像基址的虚拟地址
在我的实验里:
- 可执行代码段的
VirtAddr = 0x1000 - 只读数据段的
VirtAddr = 0x6000 - 可写数据段的
VirtAddr = 0x7d08
运行时真正的绝对地址, 通常还要加上这次装载的基址.
5.4.4 PhysAddr
对应 p_paddr.
它表示物理地址.
在现代 Linux 用户态 ELF 里, 这个字段通常没有实际使用价值, 常常只是和 VirtAddr 一样, 或者被简单保留.
所以分析普通 Linux 可执行文件时, 这个字段一般不重点关注.
5.4.5 FileSiz
对应 p_filesz.
它表示:
- 这个 segment 在文件里实际占了多少字节
装载器会从文件里读出这么多字节拷进内存.
例如:
- 代码段
FileSiz = 0x4215 - 只读段
FileSiz = 0x0d18 - 可写段
FileSiz = 0x0308
5.4.6 MemSiz
对应 p_memsz.
它表示:
- 这个 segment 在内存中最终要占多少字节
如果 p_memsz > p_filesz
就说明:
- 文件里只存了前半部分
- 剩下的部分进入内存后要补零
这正是 .bss 一类未初始化数据的来源.
在我的实验里, 最后一个可写 LOAD 段:
FileSiz = 0x0308MemSiz = 0x0328
多出来的 0x20 字节, 就说明运行时还要额外补零.
5.4.7 Flg
对应 p_flags.
这个字段表示内存保护权限.
常见取值:
R: 可读W: 可写E: 可执行
在我的实验里:
R E- 可执行代码段
R- 只读段
RW- 可写数据段
这和 section header 里的 AX / WA 有点像, 但层级不同:
- section header 的 flag 是 section 属性
- program header 的 flag 是最终内存映射权限
5.4.8 Align
对应 p_align.
它表示这个 segment 的文件偏移和虚拟地址应该满足的对齐要求.
例如在我的实验里:
- 主要
LOAD段都是0x1000对齐
这很常见, 因为:
- Linux 页大小通常就是
0x1000 - 按页对齐最方便映射
通常装载器要求:
p_offset % p_align == p_vaddr % p_align
这样文件布局和内存布局才能正确对应.
5.4.9 Section to Segment mapping 是什么
最后这一块:
1 | Section to Segment mapping: |
不是 Elf64_Phdr 的字段, 而是 readelf 额外帮我做的整理结果.
它的作用是:
- 把“逻辑上的 section”
- 映射到“运行时装载的 segment”
这样我就能同时看到:
- section 视角:
.text.rodata.dynamic
- segment 视角:
- 哪些内容一起被映射成
R E - 哪些内容一起被映射成
R - 哪些内容一起被映射成
RW
- 哪些内容一起被映射成
5.5 program headers section 和 relocation 是怎么联动起来完成装载的
前面几节其实已经分别拆开讲了三件事:
- section header 负责描述“文件被组织成哪些逻辑块”
- relocation 负责描述“哪些字节后面还要被修正”
- program header 负责描述“运行时到底把哪些内容映射进内存”
如果把这三部分重新串起来, 一个更接近真实 loader 的心智模型是:
- 链接器先按 section 组织内容.
- 再把需要一起装载的 section 折叠进若干个
PT_LOADsegment. - 程序启动时, 内核和动态加载器先按 program header 把 segment 映射进内存.
- 然后动态加载器再通过
PT_DYNAMIC找到.dynamic. - 再由
.dynamic里的指针找到.rela.dyn、.rela.plt、.dynsym、.dynstr、.got. - 最后把 relocation 真正写进已经映射好的那块内存里.
也就是说, section 决定“构建时怎么分块”, program header 决定“运行时怎么装载”, relocation 决定“装载后哪些位置还要修正”.
下面直接用 mini-diff 做一个串联实验.
5.5.1 先看 section 是怎么被折叠进 PT_LOAD 的
先看 section header:
1 | $ readelf -S mini-diff |
在我的实验里先记几组关键 section:
1 | [10] .rela.dyn RELA Address 0000000000000888 Offset 00000888 |
再看 program header:
1 | $ readelf -Wl mini-diff |
关键结果如下:
1 | LOAD 0x000000 vaddr 0x0000000000000000 filesz 0x000b88 memsz 0x000b88 R |
以及 readelf 自动整理出的映射关系:
1 | 03 .init .plt .plt.got .plt.sec .text .fini |
这一步能直接看到:
.plt、.plt.sec、.text被折叠进同一个可执行PT_LOAD.dynamic、.got、.data、.bss被折叠进同一个可写PT_LOAD.rela.dyn、.rela.plt自己并不是“单独映射一段”, 它们只是落在最前面的只读PT_LOAD里, 作为 loader 后续要读取的元数据
这里最关键的认知是:
- 运行时真正被
mmap的单位是 segment - 不是 section
section 只是帮助链接器和分析工具理解:
- 哪块是代码
- 哪块是符号表
- 哪块是 relocation 表
但真正装载时, 这些东西已经被打包进更粗粒度的 PT_LOAD 里了.
5.5.2 用 mini-loader 直接看 “按 segment 装载” 的动作
mini-upatch 目录里有一个最小 loader:
1 | $ ./mini-loader ./mini-diff |
在我的实验里输出类似:
1 | ELF type: 3 |
这个程序做的事情非常接近最小装载逻辑:
- 扫描所有
PT_LOAD - 先求出整个映像的虚拟地址跨度
- 分配一整块匿名内存
- 对每个
PT_LOAD执行:memcpy(file + p_offset, image + (p_vaddr - min_vaddr), p_filesz)- 如果
p_memsz > p_filesz, 再把多出来的部分清零
这也正好解释了:
- 为什么
.bss的sh_type = NOBITS - 但运行时依然会在内存里存在
因为真正决定 .bss 是否出现的不是 section 自己, 而是:
- 它所在的那个
PT_LOAD - 其
p_memsz比p_filesz更大
所以 loader 不需要从文件里拷 .bss, 只需要在 segment 尾部补零即可.
5.5.3 装载完 segment 以后, relocation 从哪里找
只靠 PT_LOAD 还不够.
因为 loader 现在只知道:
- 哪些字节被搬进内存了
- 每段的权限是什么
但它还不知道:
- 哪些位置要做动态重定位
- 去哪里找符号表
.plt/.got应该怎么补齐
这一步靠的是 PT_DYNAMIC.
先看它:
1 | $ readelf -Wd mini-diff |
在我的实验里关键条目如下:
1 | (SYMTAB) 0x3d8 |
把它和前面的 section 地址对一下:
SYMTAB = 0x3d8对应.dynsymSTRTAB = 0x6a8对应.dynstrRELA = 0x888对应.rela.dynJMPREL = 0x960对应.rela.pltPLTGOT = 0x7f08对应.got
于是 loader 的工作链就变成:
- 先按
PT_LOAD把这些 section 所在的 segment 全部映射进内存. - 再通过
PT_DYNAMIC找到.dynamic. - 再由
.dynamic给出的地址, 找到.dynsym,.dynstr,.rela.dyn,.rela.plt,.got. - 然后逐条执行 relocation.
这说明:
- program header 决定“数据先被搬到哪里”
.dynamic决定“搬过去以后该继续解析哪些运行时元数据”
5.5.4 relocation 实际上是在修正已经映射好的 PT_LOAD 内容
先看 mini-diff 的动态 relocation:
1 | $ readelf -Wr mini-diff |
关键部分如下:
1 | Relocation section '.rela.dyn' at offset 0x888 contains 9 entries: |
这些 Offset 和 .o 文件里的 r_offset 有一个非常重要的区别:
- 对
.o文件里的ET_RELr_offset是“相对目标 section 起始位置的偏移”
- 对已经链接好的
ET_DYN/ET_EXEC- 这里打印出来的 offset 已经是“映像虚拟地址”
所以:
0x7d080x7fd80x7f20
这些值本身就已经直接落在某个已装载的 PT_LOAD 范围里.
例如前面可写段是:
1 | LOAD 0x007d08 vaddr 0x0000000000007d08 filesz 0x000308 memsz 0x000328 RW |
于是:
0x7d08落在这个 RW 段里, 对应.init_array0x7fd8落在这个 RW 段里, 对应.got0x8008落在这个 RW 段里, 对应.data
这就说明动态 relocation 的本质是:
- 先由
PT_LOAD把.got、.data、.init_array这些目标区域映射出来 - 再由
.rela.dyn/.rela.plt去修正这些区域里的具体槽位
换句话说:
- relocation 不是凭空生效
- 它一定是在“已经映射好的 segment 内存”上改字节
5.5.5 .plt、.got 和 .rela.plt 是怎么串起来的
再看一下 .plt.sec 的反汇编:
1 | $ objdump -d -j .plt -j .plt.sec mini-diff |
出现.
1 | 0000000000001220 <printf@plt>: |
这里 printf@plt 这条跳转最终会去读地址 0x7f58.
而前面 .rela.plt 里刚好有:
1 | 0000000000007f58 R_X86_64_JUMP_SLOT printf + 0 |
再结合 .dynamic:
1 | (PLTGOT) 0x7f08 |
这三者的关系就很清楚了:
.plt/.plt.sec- 放的是跳转模板代码
.got- 放的是真正会被改写的函数入口槽位
.rela.plt- 描述“.got 里哪些槽位对应哪个外部符号”
也就是说, 调用链不是:
- 代码直接写死跳到
printf
而是:
- 代码先进
printf@plt printf@plt再去读.got里的槽位- 动态加载器根据
.rela.plt把这个槽位补成真正的printf地址
这里又能看出三层职责:
- section 负责把
.plt/.got/.rela.plt分开存 - program header 负责把它们所属的段装载进内存
- relocation 负责把
.got里的槽位修成正确地址
6 ELF dynamic struct
6.1 对应结构体
1 | typedef struct { |
6.2 readelf -Wd 实例
可重定位文件.
1 | $ readelf -Wd diff_old.o |
可执行文件.
1 | $ readelf -Wd mini-diff |
6.3 表项解释
loader 的典型读取顺序其实是:
- 先读 ELF header.
- 再读 program header table.
- 找到
PT_DYNAMIC. - 把那一段运行时地址解释成
Elf64_Dyn[]. - 一直读到
d_tag = DT_NULL.
所以 .dynamic 是内容本身, PT_DYNAMIC 是“去哪里找这块内容”
伪代码是:
1 | Elf64_Phdr *phdrs = ...; |
6.3.1 Tag 和 Value
readelf -Wd 最左边两列通常可以先这样理解:
Tag: 对应d_tag, 表示这一项到底描述什么.Value: 对应d_un, 具体值可能是:- 一个地址/指针
- 一个大小
- 一个计数
- 一个标志位
- 一个字符串在字符串表中的偏移
也就是说, Elf64_Dyn 是一个:
- “tag + payload”
风格的结构.
不同的 d_tag, 决定 d_un 应该被解释成 d_ptr 或者 d_val.
6.3.2 NEEDED
例如:
1 | 0x0000000000000001 (NEEDED) Shared library: [libc.so.6] |
这个 tag 对应:
DT_NEEDED
它表示:
- 当前 ELF 运行时依赖哪个共享库
这里 readelf 已经帮我把值解码成了:
libc.so.6
更底层一点理解, 原始值其实是:
- 一个在
.dynstr里的字符串偏移
loader 后续就会根据这些 DT_NEEDED 条目去加载依赖库.
6.3.3 INIT / FINI
例如:
1 | (INIT) 0x1000 |
这两个分别对应:
DT_INITDT_FINI
它们表示:
- 初始化函数入口
- 析构函数入口
这些值通常是:
- 映像内虚拟地址
在当前样例里: 0x1000 正好落在 .init, 0x5208 正好落在 .fini.
6.3.4 INIT_ARRAY / FINI_ARRAY
例如:
1 | (INIT_ARRAY) 0x7d08 |
这些对应:
DT_INIT_ARRAYDT_INIT_ARRAYSZDT_FINI_ARRAYDT_FINI_ARRAYSZ
含义是:
- 初始化函数数组在哪里
- 这块数组有多大
- 析构函数数组在哪里
- 这块数组有多大
在当前样例里, 这些地址都落在 RW 的 PT_LOAD 里.
这也能说明:
- dynamic table 里不只是“符号/重定位”信息
- 它还描述运行时初始化流程需要的数据
6.3.5 GNU_HASH / SYMTAB / STRTAB
例如:
1 | (GNU_HASH) 0x3b0 |
这些条目描述的是动态符号解析所需的基础表.
其中:
DT_GNU_HASH- GNU 风格哈希表的位置
- loader 可用它加速动态符号查找
DT_SYMTAB- 动态符号表位置
- 对应
.dynsym
DT_STRTAB- 动态字符串表位置
- 对应
.dynstr
DT_STRSZ.dynstr大小
DT_SYMENT- 每个动态符号项的大小
- 对 ELF64 来说通常就是
sizeof(Elf64_Sym) = 24
结合前面的 section header 可以直接对上:
SYMTAB = 0x3d8对应.dynsymSTRTAB = 0x6a8对应.dynstr
所以 DT_NEEDED、DT_SONAME 一类字符串型条目, 最终都要依赖 DT_STRTAB 去解码.
6.3.6 PLTGOT / JMPREL / PLTRELSZ / PLTREL
例如:
1 | (PLTGOT) 0x7f08 |
这些条目描述的是:
- PLT/GOT 这一条动态调用链需要的元数据
具体来说:
DT_PLTGOT- PLT 相关 GOT 区域的位置
- 当前样例里对上
.got
DT_JMPREL- 专门给 PLT 使用的 relocation 表位置
- 当前样例里对上
.rela.plt
DT_PLTRELSZ- 这张表的总大小
DT_PLTREL- 表项类型是什么
- 当前样例里是
RELA
也就是说, 外部函数调用这一条线通常是:
- 代码先进
.plt/.plt.sec .plt再去读.got里的槽位- loader 根据
DT_JMPREL指向的.rela.plt去修这些槽位
6.3.7 RELA / RELASZ / RELAENT / RELACOUNT
例如:
1 | (RELA) 0x888 |
这些条目描述的是:
- 一般动态 relocation 表在哪里
对应关系通常是:
DT_RELA- relocation 表位置
- 当前样例里对上
.rela.dyn
DT_RELASZ- 整张表大小
DT_RELAENT- 每个表项大小
- ELF64 下通常就是
sizeof(Elf64_Rela) = 24
DT_RELACOUNT- 某些前缀 relocation 的个数
- 常用于
R_X86_64_RELATIVE这一类可快速批处理的项
前面 readelf -Wr mini-diff 里已经看到:
.rela.dyn里有若干R_X86_64_RELATIVE- 也有
R_X86_64_GLOB_DAT .rela.plt里有很多R_X86_64_JUMP_SLOT
这里 DT_RELA 和 DT_JMPREL 的分工就很清楚了:
DT_RELA- 管一般动态重定位
DT_JMPREL- 管 PLT 相关重定位
6.3.8 FLAGS / FLAGS_1
例如:
1 | (FLAGS) BIND_NOW |
这些是动态装载相关标志.
在这个样例里可以先这样理解:
BIND_NOW/NOW- 倾向于在启动时就把需要的符号解析好
- 而不是把所有外部调用都拖到第一次调用时再懒绑定
PIE- 说明这是 position-independent executable
这和前面看到的:
- ELF type =
DYN
是互相呼应的.
6.3.9 VERNEED / VERNEEDNUM / VERSYM
例如:
1 | (VERNEED) 0x828 |
这些条目描述的是:
- 符号版本控制信息
简单理解就是:
- 某个外部符号不只叫
printf - 还可能要求它必须来自某个特定版本的
glibc
这就是为什么在别的输出里经常会看到:
printf@GLIBC_2.2.5__libc_start_main@GLIBC_2.34
这些版本信息并不是凭空来的, 它们就依赖这些 version 相关表.
6.3.10 DEBUG
例如:
1 | (DEBUG) 0x0 |
这个条目在文件静态状态下通常没什么特别内容.
它主要是留给运行时调试器/动态加载器协作使用的.
在 ELF 文件刚落盘时, 常常看到的就是:
0x0
6.3.11 NULL
最后一项:
1 | (NULL) 0x0 |
对应:
DT_NULL
它的作用不是提供业务含义, 而是:
- 表示 dynamic table 到这里结束
loader 一般就是一直遍历 Elf64_Dyn[], 直到遇到这项为止.
6.4 GOT / PLT 最小实验
前面已经分别讲过:
.dynamic里有哪些索引项.rela.dyn/.rela.plt是动态 relocation 表.got/.plt/.plt.sec是动态调用链的一部分
但如果只停在概念层面, 还是很容易混淆:
- 哪些 section 是放“代码”的
- 哪些 section 是放“地址槽位”的
- 哪些 section 是放“如何修这些槽位”的描述
这一节直接用 mini-diff 的真实输出把它们串起来.
实验目录:
1 | cd examples/mini-upatch |
6.4.1 先看这些 section 分别是干什么的
先看相关 section:
1 | readelf -WS mini-diff | grep -E '\.dynsym|\.dynstr|\.rela\.dyn|\.rela\.plt|\.plt|\.got' |
在我的实验里关键输出如下:
1 | [ 6] .dynsym DYNSYM 00000000000003d8 0003d8 ... |
这些 section 可以先这样记:
.dynsym: 动态符号表, 运行时真正需要参与动态解析的符号放在这里..dynstr: 动态字符串表,.dynsym、.dynamic、版本信息里的名字最后都要来这里取字符串..rela.dyn: 一般动态 relocation 表, 常见目标包括.got、.data、.init_array、.fini_array..rela.plt: PLT 专用 relocation 表, 常见类型是R_X86_64_JUMP_SLOT..got: 全局偏移表, 里面放的是“会在装载后被修正的地址槽位”.plt.sec: 外部函数调用常见的跳板代码区, 每个xxx@plt通常都会在这里有一小段 stub..plt.got: 一小段与 GOT 配合的辅助跳板代码, 在现代工具链里常见, 但不是理解主链路时最核心的部分.
所以最重要的分工其实是:
.plt/.plt.sec: 放跳板代码..got: 放可修正的地址槽位..rela.plt/.rela.dyn: 放“这些槽位该怎么修”的描述.
6.4.2 先用 printf@plt 验证: PLT 里放的是跳板代码
1 | objdump -d -j .plt -j .plt.sec mini-diff | sed -n '/<printf@plt>/,+4p' |
在我的实验里输出如下:
1 | 0000000000001220 <printf@plt>: |
这里最关键的是: printf@plt 自己位于 0x1220, 它并没有直接写死真正的 printf 地址, 它执行的是一条 jmp *disp32(%rip). 也就是先根据 RIP-relative 位移算出一个内存地址, 再从那个内存地址里取出真正要跳转的目标地址.
objdump 注释出来的 # 7f58 是它根据当前指令末尾地址 0x122a 位移 0x6d2e 推算出来的目标内存地址: 0x122a + 0x6d2e = 0x7f58.
这里要特别注意被动态加载器修正的不是 1224 这条指令里的 2e 6d 00 00 被修正的是内存地址 0x7f58 那个槽位里的内容.
也就是说 .plt.sec 里的这条指令本身通常已经是完整可执行的它只是把控制流间接转发到 .got 某个槽位.
6.4.3 再看 0x7f58 为什么能说明它落在 GOT 里
前面已经看到 .got 的起始地址是 0x7f08, .got 的大小是 0xf8. 所以 .got 覆盖范围就是 [0x7f08, 0x8000). 而 printf@plt 跳到的那个槽位地址是 0x7f58. 显然满足 0x7f08 <= 0x7f58 < 0x8000. 所以它确实落在 .got 里. 还可以继续算出它相对 .got 起点的偏移 0x7f58 - 0x7f08 = 0x50. 也就是说 printf@plt 实际是在读 .got + 0x50 这个槽位.
6.4.4 再用 .rela.plt 验证: 这个 GOT 槽位确实就是 printf 的槽位
1 | readelf -Wr mini-diff | grep printf |
在我的实验里输出如下:
1 | 0000000000007f58 0000000a00000007 R_X86_64_JUMP_SLOT 0000000000000000 printf@GLIBC_2.2.5 + 0 |
这里最关键的是relocation 类型是 R_X86_64_JUMP_SLOT, r_offset = 0x7f58, 符号名是 printf.
把它和前面的反汇编对起来 printf@plt 的跳板代码读的是 0x7f58. .rela.plt 里 printf 的 relocation 目标偏移也是 0x7f58. 这就能直接证明 .plt.sec 里的printf@plt stub, .got 里的那个槽位, .rela.plt 里的 printf 重定位条目三者不是抽象关联, 而是通过同一个地址 0x7f58 严格对应起来的.
所以 PLT -> GOT -> .rela.plt 最好理解成:
- 代码先跳进
printf@plt printf@plt再去读.got里的0x7f58槽位.rela.plt说明这个槽位对应的外部符号就是printf- 动态加载器把这个槽位修成真正的
printf地址
6.4.5 再拿 free@plt 做一次交叉验证
1 | objdump -d -j .plt -j .plt.sec mini-diff | sed -n '/<free@plt>/,+4p' |
在我的实验里关键输出如下:
1 | 00000000000011b0 <free@plt>: |
这里同样能对上:
free@plt读取的槽位地址是0x7f20.rela.plt里free的r_offset也是0x7f20
这说明前面 printf 的现象不是偶然, 而是 PLT/GOT 的普遍工作方式.