-
-
[原创]ELF 笔记
-
发表于: 4小时前 137
-
elf 文件格式(分析的 64 位, 32 位有的地方宽度不一样)
我怕自己讲的不清楚, 有的地方难免啰嗦, 请您谅解。笔者也是小白一个, 如有错误, 还请您费些口舌指出, 感谢。
为什么学
笔者也是看了这篇文章才决定重新细致的学一遍 elf: https://bbs.kanxue.com/thread-287182.htm, 当我们在逆向一个 elf 时, 首先脑海里要有整个文件的布局, 随着不断分析, 这个布局越来越清晰, 可能这才是正确的路线?
一些补充概念(遇到不懂的概念, 没准这里会解你燃眉之急)
- 大端序、小端序: 地址从左到右是由低到高, 而如果把我们平时用的阿拉伯数字放进来用地址衡量, 那么正好与地址高低的顺序相反, 高位在低地址, 低位在高地址, 我想有些强迫症看到这个结果已经别扭死了, 遂分为两派: 大端序、小端序, 大端序是保持我们平时用阿拉伯数字的习惯, 高位在低地址, 低位在高地址; 小端序则在低位放低地址, 高位放高地址。值得一提的是操作的单位是字节, 如一个十六进制的阿拉伯数字 0x6E0, 大端序:
06 E0
, 小端序:E0 06
。在 elf 中是小端序, CPU 可直接从低地址读取数据并逐步向高地址处理, 无需调整字节位置。 - 相对虚拟地址(RVA)、进程虚拟地址(VA)、文件偏移地址(FOA): 进程虚拟地址 = 进程的基址(可能会存在随机基址) + 相对虚拟地址; 文件偏移地址 = 进程虚拟地址 - 所在段的起始的进程虚拟地址 + 所在段的起始的文件偏移地址
- 每个程序有自己的独立进程(内存)空间: 每个进程在启动时, 操作系统会利用分页机制为其分配一个独立的虚拟进程(内存)空间。达到只用一份真实的物理内存, 却有无数份进程虚拟内存空间的效果。进程虚拟地址也可以通过分页机制转化为内存中真实的物理地址。
- mmap: 用于将文件或设备直接映射到进程的虚拟地址空间, 参数解析:12
#include <sys/mman.h>
void
*mmap(
void
*addr,
size_t
length,
int
prot,
int
flags,
int
fd, off_t offset);
addr: 建议映射起始地址, 通常设为 NULL(由内核自动分配), 非 NULL 时必须页对齐(通常是 4kb)
length: 映射区大小(字节), 内核会自动按页大小向上取整后在映射(假设页大小为 4kb, length 传入的 5, 那么仍会映射 4kb 的空间, 因为内核是以页为粒度管理内存的)
prot: 内存保护标志, PROT_READ(可读)、PROT_WRITE(可写)、PROT_EXEC(可执行)
flags: 映射类型, MAP_SHARED(共享)、MAP_PRIVATE(私有)、MAP_ANONYMOUS(匿名)、MAP_FIXED(强制要求映射必须从 addr 参数指定的地址开始)
fd: 文件描述符(匿名映射时设为 -1)
offset: 文件偏移地址量, 必须页对齐
注意: 有这样一种情况
mmap
可能超出你的预料: (举例)文件大小为 5 字节, 按页对齐向上取整为 0x1000 字节, 但是设置的length
为 0x2000(一个大于 0x1000 的数) 字节, 会成功分配, 但在这 0x2000 字节大小的内存空间, 前 0x1000 组成为: 原本文件的 5 字节 + 用 0 填充到 0x1000 字节, 之后的部分(这里就是剩下的 0x1000 字节)并不会给予分页, 只会占据这部分进程的虚拟内存, 不在允许再被分配, 而当你访问这段内存时会发生错误。
- PLT: 过程链接表, 位于.plt 节, 包含跳转代码(非数据表), 用于实现延迟绑定(Lazy Binding), 首次调用外部函数时, 触发 _dl_runtime_resolve 解析符号地址并回填 GOT
表项 用途 PLT[0] 公共桩代码, 调用 _dl_runtime_resolve PLT[1..n] 每个函数独立的桩代码(如puts@plt), 首次调用时跳转至解析流程 - GOT: 全局偏移表, 位于.got(变量)和.got.plt(函数), 存储外部符号的实际地址, 分为两部分:
- .got: 存储全局变量地址(启动时填充)
- .got.plt: 存储函数地址(首次调用时填充)
表项 用途 GOT[0] .dynamic 段地址(动态链接信息) GOT[1] link_map结构地址(已加载库的元数据) GOT[2] _dl_runtime_resolve函数地址(符号解析入口) GOT[3..n] 存储函数实际地址(如printf@got.plt)
- program_header_table: 程序头表, 定义了运行时如何将文件映射到内存, 包含多个 program header 条目, 可执行文件必须包含程序头表, 否则无法运行;而可重定位文件(如.o)通常不包含该表
- section_header_table: 节区表, 静态分析工具(如readelf、objdump)的参考依据, 用于描述文件的节(Section)信息(如.text、.data、.rel.dyn等)。这些信息在运行时不被动态链接器使用, 甚至可被剥离以减小文件体积
整体梳理(只写了我觉得有用的, 一些没列出的字段, 可能写什么都不会有影响, 结合 010editor 食用)
elf_header
elf_header 是 elf 文件最开始的结构, 让我们来看看里面包含了哪些信息:
- file_identification 魔数, 在文件的最开始, 固定为
7F 45 4C 46
, 表明这是一个 ELF 文件 - e_type: 文件类型, 可执行文件或 .so 文件都是
03 00
即 ET_DYN(3) - e_machine: 目标机器架构
- e_entry_START_ADDRESS: 程序入口点的相对虚拟地址
- e_phoff_PROGRAM_HEADER_OFFSET_IN_FILE: 下一个结构(program_header_table)的文件偏移地址(当解析下一个结构时, 获取到那个结构的偏移, 不然找不到, 这好像是一句废话......)。
- e_phentsize_PROGRAM_HEADER_ENTRY_SIZE_IN_FILE: 下一个结构(program_header_table)中每一项多大,
program_header_table
是一个数组 - e_phnum_NUMBER_OF_PROGRAM_HEADER_ENTRIES: 下一个结构
program_header_table
数组长度
program_header_table
program_header_table
(程序头表) 是一个数组, 每一项都描述了一段内存和文件的对应关系, 看看 program_header_table
中每一项的属性:
- p_type: 段类型, 只有属性为
PT_LOAD(1)
的段会被直接加载到内存中, 常见类型包括:- PT_NULL(0): 无效条目, 用于标记结束或占位。
- PT_LOAD(1): 可加载段(代码/数据), 需映射到内存。
- PT_DYNAMIC(2): 动态链接信息(如依赖库列表、重定位表)。
- PT_INTERP(3): 解释器路径(如 /lib/ld-linux.so.2), 用于动态加载。
- PT_NOTE(4): 辅助信息(如调试信息)。
- PT_PHDR(6):
program_header_table
自身的位置和大小。 - PT_TLS(7): 线程局部存储(Thread-Local Storage)段。
- PT_GNU_RELRO(0x6474e552): 将动态重定位后的内存区域设为只读, 重定位完成后, PT_GNU_RELRO 标记的区域会被设置为只读权限, 阻止后续写入操作, 防范攻击者篡改函数指针或全局变量。
- p_flags: 定义段在内存中的访问权限, 通过位掩码组合控制内存页的读(Read)、写(Write)、执行(Execute)权限(一般来说, 代码段可读可执行, 数据段可读可写), 除了可加载的段即
p_type
=PT_LOAD(1)
, 其他段p_flags
属性是没有作用的, 可能的值:- PF_None(0): 无权限
- PF_Exec(1): 可执行
- PF_Write(2): 可写
- PF_Write_Exec(3):
PF_Exec(1) | PF_Write(2)
可写可执行 - PF_Read(4): 可读
- PF_Read_Exec(5):
PF_Read(4) | PF_Exec(1)
可读可执行 - PF_Read_Write(6):
PF_Read(4) | PF_Write(2)
可读可写 - PF_Read_Write_Exec(7):
PF_Read(4) | PF_Write(2) | PF_Exec(1)
可读可写可执行
- p_offset_FROM_FILE_BEGIN: 文件偏移地址(从文件开头计算的偏移量)
- p_vaddr_VIRTUAL_ADDRESS: 该段起始的相对虚拟地址
- : 该段在物理内存中的预期加载地址, 在无虚拟内存管理(MMU) 的嵌入式系统或实时操作系统(RTOS)被使用
- p_filesz_SEGMENT_FILE_LENGTH: 该段在 ELF 文件 中的实际数据长度(单位: 字节)
- p_memsz_SEGMENT_RAM_LENGTH: 该段在
进程内存
中需占用的总空间长度(单位: 字节)
在 program_header_table
数组中, 在所有可加载段(p_type
= PT_LOAD(1)
)前, 必须要有 Program Header
(p_type
= PT_PHDR(6)
) 和 Interpreter Path
(p_type
= PT_INTERP(3)
) 段:
Program Header
: 在这个段中p_paddr_PHYSICAL_ADDRESS
与p_vaddr_VIRTUAL_ADDRESS
与elf_header
中的e_phoff_PROGRAM_HEADER_OFFSET_IN_FILE
相同Interpreter Path
: 记录了解析加载这个 elf 文件程序的路径, 根据文件偏移地址p_offset_FROM_FILE_BEGIN
找到字符串, 类似/lib64/ld-linux-x86-64.so.2
, 可以修改成你自己的加载器路径。执行流程为: 操作系统创建新进程后, 会根据Interpreter Path
段中记录的路径找到加载器, 并将其将其加载到内存, 这个加载器会读取 elf 文件的中导入表, 将 elf 用到的模块加载到内存......
在加载 Loadable Segment
(p_type
= PT_LOAD(1)
) 的段时, 将段利用 mmap 映射到内存的流程: 根据文件基址(base)、相对虚拟地址(p_vaddr_VIRTUAL_ADDRESS)算出进程虚拟地址(base + p_vaddr_VIRTUAL_ADDRESS), 进程虚拟地址向下对页大小取整获得要用 mmap 映射的起始地址 pbase
, 而 mmap 的第二个参数 length
赋值为 base + p_vaddr_VIRTUAL_ADDRESS - pbase + p_filesz_SEGMENT_FILE_LENGTH
(之后用 len1
代替) 进行一次映射, 此时段的数据内容是从 base + p_vaddr_VIRTUAL_ADDRESS
开始的, 而 pbase
到 base + p_vaddr_VIRTUAL_ADDRESS
之前文件映射但无有效内容, 访问可能触发错误。
然后再计算 base + p_vaddr_VIRTUAL_ADDRESS - pbase + p_memsz_SEGMENT_RAM_LENGTH
(之后用 len2
代替), 如果 len1
按页大小向上取整的值(称为 len1_
)比 len2
按页大小向上取整的值(称为 len2_
)小, 那么此时在进行一次 mmap, 映射的起始地址为 len1_
, length
为 len2_ - len1_
, 并且 flags
要多设置上一个 MAP_ANONYMOUS
属性, fd 赋值为 -1, 表示没有实际的文件与之对应。(为什么多进行一次 mmap 可以看看上边 mmap 中注意的内容, 如果只进行一次 mmap, 直接按照 length
为 len2_
映射会在访问超出 len1_
时发生错误)。
对照着图看可能会好一点:
也可以对照表格(有些变量对照着上面的文字看, 写全表达式太长了):
地址范围 | 内容状态 | 访问行为 |
---|---|---|
[pbase, base + p_vaddr_VIRTUAL_ADDRESS) |
文件映射但无有效内容 | 访问可能触发 SIGBUS 错误 |
[base + p_vaddr_VIRTUAL_ADDRESS, len1 + pbase) |
文件内容(有效数据) | 正常读取文件数据 |
[len1 + pbase, align_up(pbase + len2, PAGE_SIZE)) |
匿名映射(初始化为 0) | 读取到全 0 |
将 Loadable Segment
段内容映射到内存后, 也同时包含了其他段描述的内容, 自此, 我们不需要在将文件的内容向内存映射。此时我们获得任意一个相对虚拟地址都可以直接用 base + 相对虚拟地址
得到进程虚拟地址
在 Dynamic Segment
(p_type
= PT_DYNAMIC(2)
) 段的内容已经在加载 Loadable Segment
时包含了, 已经加载到了内存, 所以直接获取 Dynamic Segment
的 p_vaddr_VIRTUAL_ADDRESS
, 算出进程虚拟地址就可以访问 Dynamic Segment
的内容(下文称为 tags
)了, 这里的 tags
是一个结构体数组, 结构体大小为 16 字节, 前 8 字节代表类型, 后 8 字节是相对虚拟地址或者整数值(32 位 elf 大小不同, 结构相同):
1 2 3 4 5 6 7 | typedef struct { Elf64_Sxword d_tag; /* 8 字节: 动态条目类型标识 */ union { Elf64_Xword d_val; /* 8 字节: 整数值(如大小、标志等) */ Elf64_Addr d_ptr; /* 8 字节: 相对虚拟地址(指向符号表、字符串表等) */ } d_un; } Elf64_Dyn; |
d_tag
的常见类型有:
d_tag 宏名 | 值 | d_un 解释 | 用途 |
---|---|---|---|
DT_NULL |
0x0 |
忽略 | 动态段结束标记 |
DT_STRTAB |
0x5 |
d_ptr : 相对虚拟地址 |
字符串表(.dynstr )地址 |
DT_NEEDED |
0x1 |
d_val : 字符串表偏移 |
依赖库名(如 libc.so.6 ) |
DT_SYMTAB |
0x6 |
d_ptr : 相对虚拟地址 |
符号表(.dynsym )地址 |
DT_PLTRELSZ |
0x2 |
d_val : 字节大小 |
PLT(Procedure Linkage Table) 重定位表总大小 |
DT_JMPREL |
0x17 |
d_ptr : 相对虚拟地址 |
PLT 重定位表(.rela.plt )地址 |
DT_REL |
0x11 |
d_ptr : 相对虚拟地址 |
重定位表(.rel.dyn )地址 |
DT_GNU_HASH |
0x6ffffef5 |
d_ptr : 相对虚拟地址 |
GNU 扩展哈希表(.gnu.hash)地址 |
- 结束,
d_tag
=DT_NULL
: 标志着tags
结束。 - 字符串表,
d_tag
=DT_STRTAB
: 字符串表存储了一堆以 '\0' 结尾的字符串。 找到字符串表(.dynstr
):- 文件中查看: 计算
d_ptr
相对虚拟地址对应的文件偏移地址:d_ptr
- 所在段的起始的相对虚拟地址 + 所在段的起始的文件偏移地址。 - 内存中查看: 计算
d_ptr
相对虚拟地址对应的进程虚拟地址:base
+d_ptr
(如果忘了的话可以看看上边Loadable Segment
那里) - 在文件尾部的结构
section_header_table
中也保存了.dynstr
等的地址, 但是不作数, 可以随意篡改
- 文件中查看: 计算
- 依赖库名,
d_tag
=DT_NEEDED
: 每个都代表了一个依赖库名。 要结合字符串表(.dynstr
)一起用,d_val
也就是这个项的后 8 字节记录的在字符串表(.dynstr
)中的偏移, 要先找到字符串表(.dynstr
)的地址在加上偏移才是对应的依赖库名字符串的开始, 遇到 '\0' 结束。 - 符号表,
d_tag
=DT_SYMTAB
: 存储了程序在动态链接时需要的符号(如函数名、变量名)及其对应地址等信息。找到符号表(.dynsym
)与找到字符串表(.dynstr
)三种方式相同, 不在赘述。符号表(.dynsym
)也是一个结构体数组, 结构体如下(32 位 elf 大小不同, 字段顺序不同, 组成一样):12345678typedef
struct
{
Elf64_Word st_name;
// 4 字节
unsigned
char
st_info;
// 1 字节
unsigned
char
st_other;
// 1 字节
Elf64_Half st_shndx;
// 2 字节
Elf64_Addr st_value;
// 8 字节
Elf64_Xword st_size;
// 8 字节
} Elf64_Sym;
st_name: 符号名在字符串表中的偏移
st_info: 符号类型和绑定信息
- 高 4 位(bit 7-4): 绑定类型, 常见取值:
- STB_LOCAL(0): 局部符号
- STB_GLOBAL(1): 全局符号
- STB_WEAK(2): 弱引用
- 低 4 位(bit 3-0): 符号类型, 常见取值:
- STT_NOTYPE(0): 未指定类型
- STT_OBJECT(1): 变量
- STT_FUNC(2): 函数
- STT_SECTION(3): 段符号
- STT_FILE(4): 文件名符号
- 高 4 位(bit 7-4): 绑定类型, 常见取值:
st_other: 未定义的含义, 通常为 0
st_shndx: 符号所属段在
section_header_table
(段头表) 中的索引st_value:
- 在可执行文件或共享库(.so)中: 符号对应变量的相对虚拟地址。
- 可重定位文件 (.o): 符号在节内的偏移量
st_size: 符号大小(如函数代码长度或变量占用字节数), 若大小未知或未定义, 值为 0(如未初始化符号)。
导出: 导出的符号需要满足:
- 全局性: st_info 绑定类型为 STB_GLOBAL 或 STB_WEAK
- 类型匹配: st_info 符号类型为 STT_FUNC(函数)或 STT_OBJECT(变量)
- 已定义: st_shndx != SHN_UNDEF
- PLT 重定位表总大小,
d_tag
=DT_PLTRELSZ
:d_val
记录了 PLT 重定位表所占字节数, 在解析 PLT 重定位表要先获取它的大小。 - PLT 重定位表,
d_tag
=DT_JMPREL
: PLT 重定位表(.rela.plt)的作用是支持延迟绑定机制:- 程序启动时: 外部函数调用会先跳转到 PLT(
section_header_table
中的.plt
段) 中对应的入口。 - PLT 入口的初始行为: 首次调用时, PLT 会跳转到 PLT[0], 这是一段公共代码, 负责调用动态链接器的符号解析函数
- 符号解析过程: 动态链接器通过。rela.plt 中的重定位信息(符号索引、类型等), 查找函数的实际地址, 并将其写入 GOT(Global Offset Table)。
- 后续调用优化: 首次解析后, GOT 中已存储实际地址, 后续调用直接通过 GOT 跳转, 无需再次解析。
12345typedef
struct
{
Elf64_Addr r_offset;
// 8字节
Elf64_Xword r_info;
// 8字节
Elf64_Sxword r_addend;
// 8字节
} Elf64_Rela;
- r_offset: 指定需要被修改的内存位置。该地址是待修正值的存储位置。
- r_info: 高 32 位是符号表索引, 在 .dynsym(动态符号表)中的索引, 指向 Elf64_Sym 结构体; 低 32 位是重定位类型, 决定如何计算新地址。常见类型(S = 符号地址, A = r_addend, B = 共享库基址, P = 被修正位置地址):
类型 数值 计算方式 用途 R_X86_64_64 0x1
S + A
绝对地址重定位 R_X86_64_RELATIVE 0x8
B + A
基址重定位(数据段) R_X86_64_JUMP_SLOT 0x7
S
PLT/GOT 函数解析 R_X86_64_PC32 0x2
S + A - P
相对地址重定位 - r_addend: 在计算新地址时作为附加常量参与运算, 通常用于调整偏移(如数组访问、结构体成员), 多数函数重定位(如 R_X86_64_JUMP_SLOT)中为 0
- 程序启动时: 外部函数调用会先跳转到 PLT(
- 重定位表,
d_tag
=DT_REL
: 存储程序启动时需立即修正的全局变量和静态数据的重定位信息。与 PLT 重定位表不同, 重定位表在程序启动时解析此表, 直接修改 GOT 或数据段中的地址(无延迟绑定)。1234typedef
struct
{
Elf64_Addr r_offset;
// 8字节
Elf64_Xword r_info;
// 8字节
} Elf64_Rel;
- r_offset: 需修正的目标地址(如 GOT 表项地址)
- r_info: 高32位: 符号在 .dynsym 中的索引;低32位: 重定位类型, 常见重定位类型:
类型 数值 计算方式 用途 R_X86_64_RELATIVE 0x8
B + A
基址重定位 R_X86_64_GLOB_DAT 0x6
S
全局变量地址修正 R_X86_64_COPY 0x5
- 复制符号到数据段 R_X86_64_32 0x10
S + A
绝对地址重定位
- 哈希表,
d_tag
=DT_GNU_HASH
: 扩展的符号哈希表, 用于加速动态链接时的符号查找。相比老版本的 DT_HASH, 它在结构和算法上进行了优化, 尤其适合大型共享库或程序, 被使用在新版中。
参考资料
b21K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6K6M7r3q4U0k6g2)9J5k6h3u0A6L8r3W2T1K9h3I4A6i4K6u0W2j5$3!0E0i4K6u0r3x3K6M7^5y4K6M7$3y4e0c8Q4x3V1k6D9K9i4y4@1M7#2)9J5c8U0p5@1y4U0M7J5z5o6u0Q4x3@1k6@1P5i4m8W2i4K6y4p5M7$3g2J5K9h3g2K6
一些博客, AI
后续工作
注释一遍源码, 魔改/实现一个 linker
赞赏
- [原创]ELF 笔记 138
- [原创]ida 笔记 822
- [原创]SHA256 流程、特点梳理 238
- [原创]AES 流程、特点梳理 195
- [原创]ARM架构笔记 436