What

  • 是一种文件格式 ABI,一种被广泛接受的 SPEC,
  • 主要由四个部分组成:ELF Header (必须且位置固定)、Program Header Table、Segment (Sections) 和 Section Header Table
    • Segment 段:从运行视角看待 ELF 文件
      • 由一个或多个 section 组成
    • Section 节: 从链接视角看待 ELF 文件
      • .text - 代码段
      • .data - 已初始化数据
      • .bss - 未初始化数据
      • .rodata - 只读数据
      • .symtab - 符号表
      • .strtab - 字符串表

image

  • 提供的能力(储存的信息)
    • 32 bit / 64 bit
    • 大端小端(MSB / LSB)
    • 文件类型
      • executable
      • relocateable library
      • shared library
      • core dump
    • Machine Architecture (eg: x86-64)
    • 程序起始地址
    • 链接,调试等元数据 TODO

ELF Header

using Elf64_Addr = uint64_t;
using Elf64_Off = uint64_t;
using Elf64_Half = uint16_t;
using Elf64_Word = uint32_t;
using Elf64_Sword = int32_t;
using Elf64_Xword = uint64_t;
using Elf64_Sxword = int64_t;
 
# define ELF_NIDENT	16
 
// 64-bit ELF header. Fields are the same as for ELF32, but with different
// types (see above).
struct Elf64_Ehdr {
  unsigned char e_ident[EI_NIDENT];
  Elf64_Half e_type;
  Elf64_Half e_machine;
  Elf64_Word e_version;
  Elf64_Addr e_entry;
  Elf64_Off e_phoff;
  Elf64_Off e_shoff;
  Elf64_Word e_flags;
  Elf64_Half e_ehsize;
  Elf64_Half e_phentsize;
  Elf64_Half e_phnum;
  Elf64_Half e_shentsize;
  Elf64_Half e_shnum;
  Elf64_Half e_shstrndx;
};
# 创建十六进制文件
echo "7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
02 00 3e 00 01 00 00 00 78 00 40 00 00 00 00 00
40 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 40 00 38 00 01 00 00 00 00 00 00 00
01 00 00 00 05 00 00 00 00 00 00 00 00 00 00 00
00 00 40 00 00 00 00 00 00 00 40 00 00 00 00 00
95 00 00 00 00 00 00 00 95 00 00 00 00 00 00 00
00 00 20 00 00 00 00 00 ba 95 00 00 00 b9 00 00
40 00 bb 01 00 00 00 b8 04 00 00 00 cd 80 b8 01
00 00 00 cd 80" | xxd -r -p > quine

# 设置执行权限
chmod +x quine

# 运行程序
./quine > quine2

# 验证输出与原文件相同
md5sum quine quine2

由于 ELF 的部份字段是 optional 的,所以只有一个固定位置的头部 ELF Header,为前 64 字节 这里以一个能自己输出自己的 ELF 文件(quine)举例,其前 64 个字节为

$ hexdump -e '16/1 "%02x " "\n"' -v -n 64 quine  
7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00  
02 00 3e 00 01 00 00 00 78 00 40 00 00 00 00 00  
40 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  
00 00 00 00 40 00 38 00 01 00 00 00 00 00 00 00
  • 第 1 至 4 字节 始终相同,是 elf 的 magic number,用于识别。
7f 45 4c 46
// The second, third, and fourth bytes [45 4c 46] are the string “ELF” encoded in ASCII.
  • 第 5 个字节 (02: 64-bit) 告诉我们使用的是 32 位还是 64 位 ELF 格式

  • 第 6 个字节 (01: LSB) 告诉我们文件其余部分的 encoding 方式 (LSB or MSB)

  • 第 7 个字节 (01) 告诉我们该文件使用的是哪个版本的 ELF 格式,目前所有文件都使用 ELF 版本 1

  • 第 8 到第 16 个字节置零。是没有实际意义的 buffer (部份 OS Specific 的内容视作不重要),用于 对齐

  • 对齐:ELF 的 field 都是对齐的,所有 4 字节字段的起始位置都必须是 4 的倍数,所有 X 字节字段的起始位置都必须是 X 的倍数

  • 第 17 和第 18 个字节是一个两字节字段 (02 00),由于采用 LSB,值为 2

    • 该字段表示 ELF 文件的 type。值为 2 意味着我们的文件是一个 executable
    • 从索引 16 开始,2 对齐
  • 19-20 又是两字节字段 (3e 00) 为 machine 字段,值为 62,表示 x86-64

  • 21-24 是个四字节字段 (01 00 00 00): 又是 ELF 版本 1

  • 25-32 是一个八字节字段,它从索引 24 开始 (8 对齐)78 00 40 00 00 00 00 00

    • 这个字段指明了程序应该从何处开始执行。它是进程启动时开始执行的内存地址。
    • 一般
  • 33-40 是一个八字节字段 40 00 00 00 00 00 00 00 = 64,是 program header table offset,是一个指针,告诉我们 program header table 的位置是 index (offset) 64

  • 41-48 是一个八字节字段为 0,是 section header table offset,也是指针,代表本文件没有 section header table

  • 49-52 是一个四字节字段为 0,会写处理器特定标志,如区分 ARM,MIPS,RISC-V,PowerPC。

    • x86-64 架构不使用 e_flags 字段
  • 53-54 二字节字段,是 ELF 头大小,为 64,取决于是 32 位 ELF 还是 64 位 ELF,是固定值,差在了哪里可以想一想🤔

  • 接下来几个如下

    • 38 00 - e_phentsize:程序头表项大小 56 字节
    • 01 00 - e_phnum:1 个程序头表项
    • 00 00 - e_shentsize:节头表项大小为 0
    • 00 00 - e_shnum:0 个节头表项
  • 最后的 00 00Section Header STRing table iNDeX,为 0 代表没有 Section Header String Table

Program Header Table

// Program header for ELF64.
struct Elf64_Phdr {
  Elf64_Word p_type;    // Type of segment
  Elf64_Word p_flags;   // Segment flags
  Elf64_Off p_offset;   // File offset where segment is located, in bytes
  Elf64_Addr p_vaddr;   // Virtual address of beginning of segment
  Elf64_Addr p_paddr;   // Physical addr of beginning of segment (OS-specific)
  Elf64_Xword p_filesz; // Num. of bytes in file image of segment (may be zero)
  Elf64_Xword p_memsz;  // Num. of bytes in mem image of segment (may be zero)
  Elf64_Xword p_align;  // Segment alignment constraint
};
$ hexdump -e '16/1 "%02x " "\n"' -v -n 56 -s 64 quine  
01 00 00 00 05 00 00 00 00 00 00 00 00 00 00 00  
00 00 40 00 00 00 00 00 00 00 40 00 00 00 00 00  
95 00 00 00 00 00 00 00 95 00 00 00 00 00 00 00  
00 00 20 00 00 00 00 00

前两个字段为四字节,其余字段均为八字节,注意对齐

  • 第一个字段 program header type 被设置为 1 (01 00 00 00)
  • 第二个字段 flags,和 Linux Permission 一样是 bitfield05 = 4 + 1
    • 其中数字 4 代表名为“read(读取)”的标志位,数字 1 代表名为“execute(执行)”的标志位。
      • “read”标志表示进程可以读取该内存(例如,将其加载到寄存器中)。“execute”标志表示该内存可被解码并作为指令执行。
    • 唯一另一个可能的标志是名为 write(写入)的标志,其编码为数字 2,而 2 不在 4 + 1 之中,因此被视为 off。
      • 如果该段具有 write 标志,则它也能向该内存写入数据。如果它不具备该权限,任何尝试向这些内存地址写入的操作都将导致 segmentation fault(段错误)。这里的 segment 实际上指的是我们的 ELF 文件所定义的那些 segment
  • 下一个字段是一个名为 offset 的八字节字段,为 0,指向 ELF 头部,是实现 quine 的关键
    • offset 字段指定了要加载的段在 ELF 文件中的起始位置,意味着这个段从文件的第 0 个字节开始,整个文件都是段的一部分
    • 导致整个文件全部被加载到内存地址 0x400000
    • 非常规但合法
  • 接下来两个字段是称为“虚拟地址”和“物理地址”的八字节字段
    • 对于 Linux 应用程序,物理地址字段会被忽略,只有虚拟地址才起作用。
    • 00 00 40 00 00 00 00 00: p_vaddr = 0x0000000000400000
      • 告诉加载器应将该段的起始位置放置在该内存空间的何处。
    • 00 00 40 00 00 00 00 00: p_paddr = 0x0000000000400000
  • 接下来是另外两个八字节字段,称为“文件大小”(file size)和“内存大小”(memory size)。“文件大小”字段告诉我们该段在文件中占用多少字节。“内存大小”字段通常与之相同,但也可以更大(在这种情况下,多余的内存部分会被初始化为零)。
  • 最后一项是一个八字节字段,称为“对齐”(align)。

Program Segment

$ hexdump -e '16/1 "%02x " "\n"' -v -n 29 -s 120 quine  
ba 95 00 00 00 b9 00 00 40 00 bb 01 00 00 00 b8  
04 00 00 00 cd 80 b8 01 00 00 00 cd 80

这一数据块的含义并不属于 ELF 规范的一部分。对 ELF 而言,这仅仅是一块无意义的字节序列。

这些字节的意义是由 x86-64 架构规范所赋予的,并由 x86-64 处理器实现

使用 ndisasm 之后可得

$ dd skip=120 bs=1 count=29 if=quine 2> /dev/null | ndisasm -b 64 -  
00000000 BA95000000 mov edx,0x95  
00000005 B900004000 mov ecx,0x400000  
0000000A BB01000000 mov ebx,0x1  
0000000F B804000000 mov eax,0x4  
00000014 CD80 int 0x80  
00000016 B801000000 mov eax,0x1  
0000001B CD80 int 0x80
mov edx,0x95       ; Set byte count to 149 (0x95)
mov ecx,0x400000   ; Set buffer address to 0x400000  
mov ebx,0x1        ; Set file descriptor to 1 (stdout)
mov eax,0x4        ; Set system call number to 4 (sys_write)
int 0x80           ; Execute system call - write 149 bytes to stdout
 
mov eax,0x1        ; Set system call number to 1 (sys_exit)  implicitly used ebx (1) as args
int 0x80           ; Execute system call - exit program
$ ./quine > quine2

$ echo $? # implicitly used ebx (1) as args
1

Loader

ELF 会告诉 Linux 如何创建一个新进程。当我们 执行 (execute) 一个文件时,Linux 会读取该文件,利用其中的信息来设置一个新进程,然后启动该进程。这个过程被称为 加载 (load),而 Linux 中负责将 ELF 文件转换为进程的部分称为 加载器 (loader)。

Linker

Why

提供了一种标准化的方式来存储和组织可执行代码、数据和元信息,让操作系统知道如何处理二进制文件。

Alternatives:

Microsoft*nixApple
PEELFMach-O