第 4 章:深入理解 ELF 文件格式

本文最后更新于:1 年前

Reference


.oat 文件和 ELF Executable and Linkable Format 文件:

  • ELF 是 Unix 包括类 Unix 平台上最通用的二进制文件格式。
    • .c/.c++ 文件编译后得到的 .o 或 .obj 文件就是 ELF 文件。
    • 动态库 .so 文件是 ELF 文件。
    • .o 文件和 .so 文件链接后得到的二进制可执行文件也是 ELF 文件。
  • .oat 文件是 ART 虚拟机的可执行文件,本质是一种经 Android 定制的 ELF 文件。

ELF 文件格式有两种视图

  • Section Header Table 用于描述该文件中所有 section 的头的信息,是 Linking View 的必须信息。
  • Program Header Table 用于描述该文件中所有 segment 的信息,是 Execution View 的必须信息。
  • section 和 segment 之间的映射关系 ELF 文件物理上并不存在 segment,segment 其实是一个或多个 section 按照映射关系组合而成的:区间落在 [p_offset, p_offset + p_filesz] 范围内的 section 属于同一个 segment。

解析 ELF 文件:

  • 【readelf】基于 Linking View 的方式:根据 Section Header Table 解析 section,按基于文件的偏移量来读取不同 section 的内容,再根据 section 对应的数据结构来解析。
  • 【oatdump】基于 Execution View 的方式:先打开文件,逐个将 Program Header Table 中的 segment 映射到对应的虚拟内存地址,再遍历 segment 的内容因为 segment 可由多个 section 组成,所以还会用到 Section Header Table

ELF Header 32 位和 64 位平台有区别

  • Elf64_Ehdr 64 位平台

    类型名称描述
    unsigned char[16]e_ident【0-3】魔数:0x7F、ELF
    【4】EI_CLASS:1 表示 32 位 ELF 文件;2 表示 64 位 ELF 文件
    【5】EI_DATA:1 表示字节序是小端;2 表示字节序是大端
    【6】EI_VERSION:ELF 文件版本
    【7-15】保留
    Elf64_Halfe_typeELF 文件的类型。0NONE 表示没有类型;1REL 表示 relocatable 如 .obj、.o;2EXEC 表示 executable;3DYN 表示 shared 比如 .so
    Elf64_Halfe_machineELF 文件对应哪种 CPU 结构。40 表示 ARM 32 位平台;62 表示 AMD x86 64 位平台;183 表示 ARM 64 位平台
    Elf64_Worde_version取值和 e_ident[6] 相同
    Elf64_Addre_entry虚拟内存地址,可执行程序的入口
    Elf64_Offe_phoffProgram Header Table 在该 ELF 文件的起始位置从文件开头开始算起的偏移量
    Elf64_Offe_shoffSection Header Table 在该 ELF 文件的起始位置从文件开头开始算起的偏移量
    Elf64_Worde_flags和处理器相关的标识
    Elf64_Halfe_ehsize表示 ELF Header 的长度 64 位 ELF Header 长度是 64
    Elf64_Halfe_phentsizeProgram Header Table 中每个元素的长度
    Elf64_Halfe_phnumProgram Header Table 中包含多少个元素
    Elf64_Halfe_shentsizeSection Header Table 中每个元素的长度
    Elf64_Halfe_shnumSection Header Table 中包含多少个元素
    Elf64_Halfe_shstrndxSection Header Table 中每个 section 都有名字,这个索引大概相当于这个 table 的 toString() 的地址
  • Elf32_Ehdr 32 位平台,只有某些属性的长度不同

    类型名称
    unsigned char[16]e_ident
    Elf32_Halfe_type
    Elf32_Halfe_machine
    Elf32_Worde_version
    Elf32_Addre_entry
    Elf32_Offe_phoff
    Elf32_Offe_shoff
    Elf32_Worde_flags
    Elf32_Halfe_ehsize
    Elf32_Halfe_phentsize
    Elf32_Halfe_phnum
    Elf32_Halfe_shentsize
    Elf32_Halfe_shnum
    Elf32_Halfe_shstrndx
  • 数据类型:

    • Elf64_Half、Elf32_Half:作用为 unsigned medium integer,表示无符号的中等大小的整数,2 字节等同于 short
    • Elf64_Word、Elf32_Word:作用为 unsigned integer,表示无符号的整数,4 字节等同于 int
    • Elf64_Addr:作用为 unsigned program address,表示无符号的程序内的地址,8 字节等同于 64 位平台的指针类型
    • Elf32_Addr:作用为 unsigned program address,表示无符号的程序内的地址,4 字节等同于 32 位平台的指针类型
    • Elf64_Off:作用为 unsigned file offset,表示无符号的文件偏移量,8 字节等同于 64 位平台的 long
    • Elf32_Off:作用为 unsigned file offset,表示无符号的文件偏移量,4 字节等同于 32 位平台的 int

ELF 文件的作用:

  • 参与了源代码的编译和链接比如一个 C 源文件编译成 relocatable 的 .o 文件,然后编译器处理这些 .o 文件(函数、变量、符号表等都可以调整到合适的位置),最终组成 .so 动态库文件或可执行文件
  • 可执行程序的运行依赖 ELF 文件里的信息比如 ELF Header 的 e_entry 指明了可执行程序的入口地址

ABI:

  • ABI Application Binary Interface 是用于二进制模块之间以及模块和操作系统之间交互的标准。
  • ABI 是 ELF 相关规范在不同硬件平台上的进一步拓展和补充比如,ABI 会规定应用程序调用动态库的函数时,栈帧该如何创建,参数该如何传递

ELF 和 ABI 相关的文档:

  • 通用 ELF 和 ABI 文档。
  • 特定处理器相关 ELF 和 ABI 文档。
  • 特定平台/语言相关的 ABI 文档。

Linking View 视图下的 ELF:

示例程序 1:

  1. main.c:

    1
    2
    3
    4
    5
    6
    7
    #include <unistd.h>
    #include <stdio.h>

    int main(int argc, char* argv[]) {
    printf("this is elf test");
    return 0;
    }
  2. Makefile 文件:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    # 使用方法
    all:
    $(error "usage: make [obj|so|exe|clean]")

    # 编译 relocatable 的 obj 文件
    obj:main.c
    gcc -c main.c -o main.o
    # 编译动态库 so 文件
    so:main.c
    gcc -fPIC -shared main.c -o main.so
    # 编译 exe 可执行程序
    exe:main.c
    gcc main.c -o main.out

    clean:
    rm -rf *.o *.so *.out
  3. 编译生成 main.o Obj 文件参与链接,可以用于研究 Linking View

    1
    make obj

执行 readelf --section main.o,获取到 Section Header Table 的信息:

  • 分析信息:

    • 【0】:占位用的,值全为 0。
    • 【10】.shstrtab Section Header String Table:专门存储 String 名字的 section。
      • SHT_STRTAB 类型的 section 用于存储字符串,ELF 文件可能有多个 String Table section 比如 .shstrtab、.strtab、.dynstr,具体使用哪个取决于 Section Header Table 的 sh_link 的值比如上图【11】的 Link 是【12】
      • 本质是一块存储区域,通过 [索引, '\0') 的方式截取字符串:
      • 执行 readelf -p [section 名|索引] main.o,可将指定名字或索引的 section 的内容转换成字符信息打印出来:
    • 【1】.text:用于存储程序的机器指令。
    • 【4】.bss Block Storage Segment:在 ELF 加载到内存的时候会分配一块由 sh_size 指定大小的内存并初始化数据为 0,在 ELF 文件里不占据任何文件的空间。
    • 【3】.data:和 .bss 类似,但是不会初始化数据为 0。
    • 【5】.rodata read-only data:包含只读数据的信息。
    • 【11】.symtab Symbol Table:存储的是符号表,主要用于编译链接,也可以参与动态库的加载。
      .dynsym[1] Dynamic Linking Symbol Table:存储的仅是动态链接需要的符号信息。
  • Section Header Table 中元素的数据结构:

    • Elf64_Shdr 64 位平台

      类型名称描述
      Elf64_Wordsh_name指向 .shstrtab Section Header String Table,专门存储 String 名字的 section 的某个位置存储了本 section 的名字
      Elf64_Wordsh_typesection 的类型
      Elf64_Xwordsh_flagssection 的属性
      Elf64_Addrsh_addr如果该 section 被加载到内存,该值指明应该加载到内存的什么位置进程的虚拟地址空间
      Elf64_Offsh_offsetsection 真正的内容在文件中的位置从文件开头开始算起的偏移量
      Elf64_Xwordsh_sizesection 的大小
      Elf64_Wordsh_linkSection Header Table 的索引链接其解释取决于 section 的类型
      Elf64_Wordsh_info额外信息其解释取决于 section 的类型
      Elf64_Xwordsh_addralign地址对齐 2 的整数幂
      Elf64_Xwordsh_entsize一些 section 的表的条目大小是固定的比如符号表,该值给定了每个条目的大小
    • Elf32_Shdr 32 位平台

      类型名称
      Elf32_Wordsh_name
      Elf32_Wordsh_type
      Elf32_Wordsh_flags
      Elf32_Addrsh_addr
      Elf32_Offsh_offset
      Elf32_Wordsh_size
      Elf32_Wordsh_link
      Elf32_Wordsh_info
      Elf32_Wordsh_addralign
      Elf32_Wordsh_entsize

执行 readelf -s main.o,获取到符号表的信息:

  • 符号表主要用于编译链接,也可以参与动态库的加载。

  • 符号表中元素的数据结构:

    • Elf64_Sym 64 位平台

      类型名称描述
      Elf64_Wordst_name符号的名称,指向 .strtab 的某个索引位置
      unsigned charst_info符号的类型和绑定属性。
      类型:1OBJECT 表示该符号和某个数据有关;2FUNC 表示该符号和某个函数有关;3SECTION 表示该符号和 section 有关;4FILE 表示存储源文件名。
      绑定属性:0LOCAL 表示只在该 ELF 文件里可见的符号;1GLOBAL 表示可在多个 ELF 文件里可见的符号;2WEAK 和 1GLOBAL 一样,但是在链接或加载的时候优先级更低。
      unsigned charst_other符号的可见性。DEFAULT 表示该符号的可见性由绑定属性决定
      Elf64_Halfst_shndx符号表中的每个元素都和其他 section 有关系,这个值就是相关的 section 的索引。0UND 表示该符号的定义在别的 ELF 文件中;0xFFF1ABS 表示该符号的值是固定不变的
      Elf64_Addrst_value符号的值。对于 relocatable 类型的 ELF 文件,表示该符号位于相关 section 的具体位置;对于 shared 和 executable 类型的 ELF 文件,表示该符号的虚拟内存地址
      Elf64_Xwordst_size和这个符号相关联的数据的长度
    • Elf32_Sym 32 位平台

      类型名称
      Elf32_Wordst_name
      Elf32_Addrst_value
      Elf32_Wordst_size
      unsigned charst_info
      unsigned charst_other
      Elf32_Halfst_shndx

示例程序 2:

  1. test.h:

    1
    void test();
  2. test.c:

    1
    2
    3
    4
    5
    #include "test.h"

    void test() {
    return;
    }
  3. main.c:

    1
    2
    3
    4
    5
    6
    7
    #include <unistd.h>
    #include "test.h"

    int main(int argc, char* argv[]) {
    test();
    return 0;
    }
  4. Makefile 文件:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    # 使用方法
    all:
    $(error "usage: make [obj|clean]")

    # 编译 relocatable 的 obj 文件
    obj:test.c main.c
    gcc -c test.c -o test.o
    gcc -c main.c -o main.o

    clean:
    rm -rf *.o
  5. 编译生成 main.o Obj 文件参与链接,可以用于研究 Linking View

    1
    make obj

执行 readelf -r main.o,获取到重定位表的信息:

  • 重定位最主要的作用就是将符号的使用之处和它的定义之处关联起来。
  • 重定位的方法:
    • 编译链接过程中,最终生成可执行文件或动态库文件时,编译链接器将根据 ELF 文件中的重定位表计算最终的符号的位置
    • 加载动态库时,加载器也会根据重定位信息修改对应的符号使用之处,使得动态库能正常工作。
  • 分析信息:
  1. 【确定要修改的偏移量】.rela.text[0].Offset = 000000000015 先放着,后面用
  2. 【确定符号在符号表中的索引】.rela.text[0].Info = 000900000002:右移 32 位为 9 -> 符号在符号表中的索引为 9
  3. 【确定符号表】Section Header 中 .rela.text.Link = 10 符号表在 Section Header 中的索引 -> 符号表是 .symtab
  4. 【确定要修改的 section 根据名字也能猜出来】Section Header 中 .rela.text.Info = 1 要修改的 section 在 Section Header 中的索引 -> 要修改的 section 是 .text
  5. 执行 objdump -r -d main.o,反编译 .text:
    • 解析第 0x14 个字节:
      • e8:intel 汇编指令,表示 call 函数调用
      • 00 00 00 00:偏移量相对于下一条指令。这里的值是 0x19 + 偏移量为 0,相当于跳过了 test 函数,所以全 0 的地址肯定不对。
        【要修改的偏移量】0x15 来自于1. 【确定要修改的偏移量】.rela.text[0].Offset = 000000000015,对应的信息是 15: R_X86_64_PC32 test-0x4 还不是最终的目标函数的地址,需要编译器处理
        • 15:偏移量 0x15。
        • R_X86_64_PC32:重定位的类型和目标机器有关,主要作用是告诉编译器如何计算真正的地址值
        • test-0x4:重定位对应的符号信息和 r_addend 的值。
  • 重定位表中元素的数据结构:
    • Elf64_Rela 64 位平台、section 名字以 .rela 开头

      类型名称描述
      Elf64_Addrr_offset偏移量
      Elf64_Xwordr_info该重定位项针对符号表的哪一项 + 重定位的类型
      Elf64_Sxwordr_addend用于计算最终的重定位信息的位置
    • Elf32_Rela 32 位平台、section 名字以 .rela 开头

      类型名称
      Elf32_Addrr_offset
      Elf32_Wordr_info
      Elf32_Swordr_addend
    • Elf64_Rel 64 位平台、section 名字以 .rel 开头

      类型名称
      Elf64_Addrr_offset
      Elf64_Xwordr_info
    • Elf32_Rel 32 位平台、section 名字以 .rel 开头

      类型名称
      Elf32_Addrr_offset
      Elf32_Wordr_info

Execution View 视图下的 ELF:

示例程序 3:

  1. main.c:

    1
    2
    3
    4
    5
    6
    7
    #include <unistd.h>
    #include <stdio.h>

    int main(int argc, char* argv[]) {
    printf("this is elf test");
    return 0;
    }
  2. Makefile 文件:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    # 使用方法
    all:
    $(error "usage: make [obj|so|exe|clean]")

    # 编译 relocatable 的 obj 文件
    obj:main.c
    gcc -c main.c -o main.o
    # 编译动态库 so 文件
    so:main.c
    gcc -fPIC -shared main.c -o main.so
    # 编译 exe 可执行程序
    exe:main.c
    gcc main.c -o main.out

    clean:
    rm -rf *.o *.so *.out
  3. 编译生成 main.out:

    1
    make exe

执行 readelf -l main.out,获取到 Program Header Table 的信息:

  • Program Header Table 中元素的数据结构:
    • Elf64_Phdr 64 位平台

      类型名称描述
      Elf64_Wordp_typesegment 的类型。1LOAD 表示可加载到内存的 segment;2DYNAMIC 表示和动态链接有关;3INTERP 表示该 segment 用于说明此程序使用的链接器可执行程序使用动态库的时候,动态库的加载工作是由程序解释器来完成的,程序解释器就是链接器在系统中的绝对路径;6PHDR 表示 Program Header Table 本身的信息,当文件加载到内存的时候使用
      Elf64_Wordp_flagssegment 的标记符,和 segment 在内存中的访问权限有关
      Elf64_Offp_offset该 segment 位于文件的起始位置
      Elf64_Addrp_vaddr该 segment 加载到进程虚拟内存空间时指定的内存地址
      Elf64_Addrp_paddr该 segment 对应的物理地址对于可执行文件和动态库文件而言,这个值没有意义,因为系统用的是虚拟地址
      Elf64_Xwordp_filesz该 segment 在文件中占据的大小
      Elf64_Xwordp_memsz该 segment 在内存中占据的大小
      Elf64_Xwordp_alignsegment 加载到内存后其首地址需要按 p_align 的要求进行对齐
    • Elf32_Phdr 32 位平台

      类型名称
      Elf32_Wordp_type
      Elf32_Offp_offset
      Elf32_Addrp_vaddr
      Elf32_Addrp_paddr
      Elf32_Wordp_filesz
      Elf32_Wordp_memsz
      Elf32_Wordp_flags
      Elf32_Wordp_align

实例分析:调用动态库中的函数

  • 本质:源码编译成 ELF 文件后,代码就被翻译成了机器指令。而函数调用对应的指令就是指示 CPU 先跳到该函数所在的内存地址,然后执行后面的指令

  • 【存在的问题】编译时无法确定函数的入口在内存中的地址。原因是:

    • 如果一个程序使用多个动态库,编译器很难为所有函数都确定一个绝对地址。
    • 出于安全考虑,操作系统加载动态库到内存的时候会基于一个随机数来计算最终的加载位置。

    【解决问题】GOT Global Offset Table

    • GOT 对应的 section 是 .got 存储的是该 ELF 文件用到的符号(函数或变量)地址
    • GOT[1] 和 GOT[2] 这两项内存存储的是解释器其实就是链接器,Program Header Table 中类型为 INTERP 的元素,用于加载执行时需要的动态库的信息和符号解析处理函数的入口地址。其余项中的值将由 Resolver 动态填写。
    • 当可执行程序被操作系统加载和准备执行时,如果操作系统发现该程序有 INTERP 类型的 segment,就会先跳转到链接器的 entry point 执行。链接器会设置好 GOT 等对应项,然后将系统的控制权交还给可执行程序。
      • 【引入问题】符号地址什么时候计算?
        【解决问题】有两种方法:1、链接器将控制权交给可执行程序之前链接器加载了依赖的动态库后就知道了动态库加载到内存的虚拟地址,就可以计算出符号地址。缺点是加载时间变长;2、用的时候再计算。优点是提升了程序启动速度。
        • 【引入问题】程序不知道链接器会使用哪种方法计算符号地址。
          【解决问题】编译器生成的二进制文件必须同时支持这两种方法。PLT Procedure Linkage Table
          • PLT 中元素存储的是一段代码辅助触发符号地址的计算以及跳转到正确的符号地址上
          • 图中 PLT[0] 存储的是跳转到 GOT 表 Resolver 的指令。
          • 图中 PLT[1] 中 *name1_in_GOT 表示 GOT[name1] 的值:
            • 如果 name1 的地址还没有计算,GOT[name1] 存储的就是后一条指令 push $offset,把 Resolver 计算这个符号地址所需的参数压栈,下一条指令是跳转到 PLT[0]
            • 如果 name1 的地址已经计算过,jmp *name1_in_GOT 就直接跳转到目标地址。

示例程序 4:

  1. test.h:

    1
    2
    3
    void test();

    void test2();
  2. test.c:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    #include <unistd.h>
    #include <stdio.h>
    #include "test.h"

    void test2() {
    printf("I am in test2");
    }

    void test() {
    printf("I am in test");
    test2()
    return;
    }
  3. main.c:

    1
    2
    3
    4
    5
    6
    7
    #include <unistd.h>
    #include "test.h"

    int main(int argc, char* argv[]) {
    test();
    return 0;
    }
  4. Makefile 文件:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    # 使用方法
    all:
    $(error "usage: make [so|exe|clean]")

    # 编译动态库 so 文件
    so:test.c
    gcc -fPIC -m32 -g -shared test.c -o libtest.so
    # 编译 exe 可执行程序
    exe:main.c
    gcc -g -m32 main.c -o main.out -L`pwd` -ltest

    clean:
    rm -rf *.exe *.so
  5. 编译生成 libtest.so

    1
    make so
  6. 编译生成 main.out:

    1
    make exe

反编译,获取到的信息:

“符号地址的计算依赖于重定位信息,除非有特殊需要,否则无须死抠细节。”

分析信息:

  • main.out 的 call 8048430 <test@plt>
    • -> .plt 的 0x8048430 之后的是 PLT[1] 3 行代码:第一行的 GOT[0x804a014] 还未计算 test 函数的地址地址 0x804a014,在【图 2】.rel.plt 可以查到 0x804a014 的符号名是 test;等计算后会在 .got.plt 的 0x804a014 存储函数符号的地址,所以后一行地址 0x8048436,最开始时 .got.plt 的 0x804a014 存储的就是这个地址把参数【图 3】0x10 是 .rel.plt 中 test 的偏移量压栈,最后一行 jmp 8048400
      • -> .plt 的 0x8048400 是 PLT[0] 2 行代码
        • -> GOT[1] 的地址是 0x804a004 GOT[0] 的地址是 0x804a000,GOT[2] 的地址是 0x804a008,第二行 jmp *0x804a008
          • -> Resolver 计算 test 的最终地址,并填写到 GOT[0x804a014],以后就可以直接跳转到 test 函数了。

Absolute PLT 和 PIC Position Independent Code PLT:

  • Absolute PLT:PLT 里的参数都是绝对地址 GOT 地址在编译时可以确定
    • main 函数中调用 libtest.so 的 test() 函数。
  • PIC PLT:PLT 里的参数都是基于 GOT 的偏移量 GOT 的值在运行过程中计算出来的
    • libtest.so 的 test() 函数中调用 test2() 函数。
    • x86 平台的 GOT 的地址放在 ebx 寄存器里。

TODO:自己编写一个简单的 ELF 文件解析器

参考

  1. Special Sections(https://docs.oracle.com/cd/E19120-01/open.solaris/819-0690/6n33n7fcb/index.html) 里只有 .dynsym,没有 .dynsymtab,书中这里应该是写错了。

第 4 章:深入理解 ELF 文件格式
https://weichao.io/5dcfb5b9f13c/
作者
魏超
发布于
2023年2月17日
更新于
2023年2月23日
许可协议