认识ELF文件

ELF (Executable and Linkable Format),可执行可链接格式,是Unix、Linux环境下一种十分常见的文件格式,它可用于可执行程序、目标文件、共享库、core文件等。

ELF文件结构

ELF文件结构如下图所示,包括ELF文件头 (ELF Header)、段头表 (Program Header Table)、节头表 (Section Header Table)、Sections。Sections位于段头表和节头表之间,并被段头表和节头表引用。

img

文件头:ELF文件头 (ELF FIle Header),其描述了当前ELF文件的类型(可执行程序、可重定位文件、动态链接文件、core文件等)、32位/64位寻址、ABI、ISA、程序入口地址、Program Header Table起始地址及元素大小、Section Header Table起始地址及元素大小,等等。

段头表:段头表定义了程序的“执行时视图”,描述了如何创建程序的进程映像。每个表项定义了一个“段 (segment)” ,每个段引用了0、1或多个sections。段有类型,如PT_LOAD表示该段引用的sections需要在运行时被加载到内存。段头表主要是为了指导加载器进行加载。

举个例子,.text section隶属于一个Type=PT_LOAD的段,意味着会被加载到内存;并且该段的权限为RE(Read+Execute),意味着指令部分加载到内存后,进程对这部分区域的访问权限为“读+可执行”。加载器 (loader /lib64/ld-linux-x86-64.so) 应按照段定义好的虚拟地址范围、权限,将引用的sections加载到进程地址空间中指定位置,并在GDT、LDT中设置好读、写、执行权限。

节头表:节头表定义了程序的“链接时视图”,描述了二进制可执行文件中包含的每个section的位置、大小、类型、链接顺序,等等,主要目的是为了指导链接器进行链接。

举个例子,项目包含多个源文件,每个源文件是一个编译单元,每个编译单元最终会生成一个目标文件(*.o),每个目标文件都是一个ELF文件,都包含自己的sections。链接器是将依赖的目标文件和库文件的相同section进行合并(如所有*.o文件的.text合并到一起),然后将符号引用解析成正确的偏移量或者地址。

Sections:ELF文件中的sections数据,夹在段头表、节头表之间,由段头表、节头表引用。

不同程序中包含的sections数量是不固定的:

  • 有些编程语言会有特殊的sections来支持对应的语言运行时层面的功能,如go .gopclntab, gosymtab;
  • 程序采用静态链接、动态链接生成的sections也会不同,如动态链接往往会生成.got, .plt, .rel.text。

下面,我们详细来看下每个部分。

文件头(ELF File Header)

类型定义

每个ELF文件,对应着go标准库类型 debug/elf.File,其中包含了文件头 FileHeader、Sections、Progs,FileHeader定义及各字段含义如下:

// A File represents an open ELF file.
type File struct {
    FileHeader
    Sections    []*Section
    Progs       []*Prog
    ...
}

但是上述结果只是elf文件读取成功后经常需要的一个结构,并不是文件系统中实际的elf文件结构,我们来看下 Linux man 手册中ELF文件头的定义:

#define EI_NIDENT 16

typedef struct {
    unsigned char e_ident[EI_NIDENT];
    uint16_t      e_type;
    uint16_t      e_machine;
    uint32_t      e_version;
    ElfN_Addr     e_entry;
    ElfN_Off      e_phoff;
    ElfN_Off      e_shoff;
    uint32_t      e_flags;
    uint16_t      e_ehsize;
    uint16_t      e_phentsize;
    uint16_t      e_phnum;
    uint16_t      e_shentsize;
    uint16_t      e_shnum;
    uint16_t      e_shstrndx;
} ElfN_Ehdr;
  • e_ident[EI_NIDENT]
    • EI_MAG0: 0x7f
    • EI_MAG1: E
    • EI_MAG2: L
    • EI_MAG3: F
    • EI_Class: 寻址类型(32位寻址 or 64位寻址);
    • EI_Data: 处理器特定的数据在文件中的编码方式(小端还是大端);
    • EI_VERSION: ELF规范的版本;
    • EI_OSABI: 该二进制面向的OS以及ABI(sysv,hpux,netbsd,linux,solaris,irix,freebsd,tru64 unix,arm,stand-alone(embeded);
    • EI_ABIVERSION: 该二进制面向的ABI版本(相同OSABI可能有不兼容的多个ABI版本);
    • EI_PAD: 这个位置开始到最后EI_NIDENT填充0,读取时要忽略;
    • EI_NIDENT: e_ident数组长度;
  • e_type: 文件类型(可重定位文件、可执行程序、动态链接文件、core文件等);
  • e_machine: 机器类型(386,spark,ppc,etc);
  • e_version: 文件版本;
  • e_entry: 程序入口地址(如果当前文件没有入口地址,就填0);
  • e_phoff: 段头表相对当前文件开头的偏移量;
  • e_shoff: 节头表相对当前文件开头的偏移量;
  • e_flags: 处理器特定的flags;
  • e_ehsize: ELF文件头部结构体大小;
  • e_phentsize: 段头表中每个条目占用的空间大小;
  • e_phnum: 段头表中的条目数量;
  • e_shentsize: 节头表中每个条目占用的空间大小;
  • e_shnum: 节头表中的条目数量;
  • e_shstrndx: section名在.shstrtab中的索引(.shstrtab[e_shstrndx]就是一个null结束的section名);

ps:ELF文件头其他字段都比较容易懂,关于.shstrtab,它的数据存储与.strtab雷同,只是它用来存section名 (man手册显示.strtab除了可以存储符号名,也可以存储Section名)。

Section Header String Table (.shstrtab section)

Index +0 +1 +2 +3 +4 +5 +6 +7 +8 +9
0 \0 n a m e . \0 V a r
10 i a b l e \0 a b l e
20 \0 \0 x x \0

假定有上述.shstrtab,那么e_shstrndx=0对应的section name为none,e_shstrndx=1的对应着section name为“name.”,e_shstrndx=7的对应的section name为“Variable”。

段头表 (Program Header Table)

段头表 (Program Header Table),可以理解为程序执行的视图(executable point of view),主要用来指导loader如何加载。从可执行程序角度来看,进程运行时需要了解如何将程序中不同部分,加载到进程虚拟内存地址空间中的不同区域。Linux下进程地址空间的内存布局,大家并不陌生,如data段、text段,每个段包含的信息其实是由段头表预先定义好的,包括在虚拟内存空间中的位置,以及段中应该包含哪些sections数据。

类型定义

段头表当然就是一个数组了,我们看看其中每个“段”的定义:

typedef struct {
    uint32_t   p_type;
    Elf32_Off  p_offset;
    Elf32_Addr p_vaddr;
    Elf32_Addr p_paddr;
    uint32_t   p_filesz;
    uint32_t   p_memsz;
    uint32_t   p_flags;
    uint32_t   p_align;
} Elf32_Phdr;

typedef struct {
    uint32_t   p_type;
    uint32_t   p_flags;
    Elf64_Off  p_offset;
    Elf64_Addr p_vaddr;
    Elf64_Addr p_paddr;
    uint64_t   p_filesz;
    uint64_t   p_memsz;
    uint64_t   p_align;
} Elf64_Phdr;

下面详细解释下,上面两个结构分别是面向32位、64位系统下的结构体,其字段含义如下:

  • p_type: 段类型
    • PT_NULL: 改表想描述了一个undefined的段,可以忽略;
    • PT_LOAD: 该表项描述了一个可加载的段;
    • PT_DYNAMIC: 该表项描述了一个动态链接信息;
    • PT_INTERP: 该表项指定了一个interpreter的路径;
    • PT_NOTE: 该表项指定了notes的位置;
    • PT_SHLIB: 该类型被保留,但语义未指定。包含这个类型的段表项的程序不符合ABI规范;
    • PT_PHDR: 该表项指定了段头表本身的位置和size;
    • PT_LOPROC, PT_HIPROC: 该表项指定了一个范围[PT_LOPROC, PTHIPROC],这个范围内数据用来保存处理特定机制信息;
    • PT_GNU_STACK: GNU扩展,Linux内核使用该字段来p_flags中设置的Stack的状态;TODO
  • p_offset: 表示该段相对于文件开头的偏移量;
  • p_vaddr: 表示该段数据加载到内存后的虚拟地址;
  • p_paddr: 表示该段在内存中加载的物理地址;
  • p_filesz: 表示该段在文件中占用的大小;
  • p_memsz: 表示该段在内存中占用的大小;
  • p_flags: 表示该段的属性,以位掩码的形式:
    • PF_X: 可执行;
    • PF_W: 可写;
    • PF_R: 可读;
  • p_align: 表示该段对齐方式;

注意:

  • 内存地址空间中的内存布局,代码所在区域我们常称为代码段(code segment, CS寄存器来寻址)or 文本段(text segment),数据段我们也常称为数据段(data segment,DS寄存器来寻址)。
  • 内存布局中的术语text segment、data segment,不是ELF文件中的.text section和.data section,注意区分。

下面的段头表定义给出了一个这样的示例,text segment其实包含了.text section以及其他sections,data segment其实也包含了.data section以外的其他sections。

// text segment,段索引02,可以看到包含了.text等其他sections
LOAD        0x0000000000000000 0x0000000000400000 0x0000000000400000
            0x0000000000000a70 0x0000000000000a70  R E    0x200000

// data segment,段索引03,可以看到包含了.data等其他sections
LOAD        0x0000000000000df0 0x0000000000600df0 0x0000000000600df0
            0x000000000000025c 0x0000000000000260  RW     0x200000

02     .interp .note.ABI-tag .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt .init .plt .text .fini .rodata
03     .init_array .fini_array .dynamic .got .got.plt .data .bss

工具演示

下面这个示例,则展示了测试程序 golang-debugger-lessons/testdata/loop2 的完整段头表定义,运行 readelf -l查看其段头表,共有7个表项,每个段定义包含了类型、在虚拟内存中的地址、读写执行权限,以及引用的sections。通过 Section to Segment mapping: Segment Sections...部分可以看到,最终组织好的:

  • text segment(编号02的segment其Flags为R+E,表示可读可执行,这就是text segment)包含了如下sections .text .note.go.buildid;
  • rodata segment (编号03的segment其Flags为R,表示只读,就是rodata segment) 包含了 .rodata .typelink .itablink .gosymtab .gopclntab 这些go运行时需要的数据;
  • data segment (编号04的segment其Flags为RW,表示可读可写,就是data segment) 包含了 .data .bss 等这些可读写的数据;
$ readelf -l testdata/loop2

Elf file type is EXEC (Executable file)
Entry point 0x475a80
There are 6 program headers, starting at offset 64

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  PHDR           0x0000000000000040 0x0000000000400040 0x0000000000400040
                 0x0000000000000150 0x0000000000000150  R      0x1000
  NOTE           0x0000000000000f9c 0x0000000000400f9c 0x0000000000400f9c
                 0x0000000000000064 0x0000000000000064  R      0x4
  LOAD           0x0000000000000000 0x0000000000400000 0x0000000000400000
                 0x00000000000af317 0x00000000000af317  R E    0x1000
  LOAD           0x00000000000b0000 0x00000000004b0000 0x00000000004b0000
                 0x00000000000a6e70 0x00000000000a6e70  R      0x1000
  LOAD           0x0000000000157000 0x0000000000557000 0x0000000000557000
                 0x000000000000a520 0x000000000002e0c0  RW     0x1000
  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000000 0x0000000000000000  RW     0x8

 Section to Segment mapping:
  Segment Sections...
   00
   01     .note.go.buildid
   02     .text .note.go.buildid
   03     .rodata .typelink .itablink .gosymtab .gopclntab
   04     .go.buildinfo .noptrdata .data .bss .noptrbss
   05
   06

一个section中数据最终会不会被加载到内存,也是由引用它的段的类型决定的:如果段类型为PT_LOAD类型,则会被加载到内存; 反之不会。

以上面的go程序demo为例:

1).gosymtab、.gopclntab所属的段(段索引值 03)是PT_LOAD类型,表示其数据会被加载到内存,这是因为go runtime依赖这些信息来计算stacktrace。

2)而.note.go.buildid所属的段(段索引 01)为NOTE类型,只看这个段的话,section .note.go.buildid不会被加载到内存,但是

3)注意到.note.go.buildid还被下面这个段索引为02、PT_TYPE=LOAD的段引用,那这个section最终就会被加载到内存中。

ps: 一般情况下,.note.* 这种sections就是给一些外部工具读取使用的,一般不会被加载到内存中,除非go设计者希望能从进程内存中直接读取到这部分信息,或者希望core转储时能包含这些信息以供后续提取使用。

本章稍后的章节,会继续介绍ELF段头表信息如何指导loader加载程序数据到内存,以构建进程映像。

节头表 (Section Header Table)

每个编译单元生成的目标文件(ELF格式),将代码和数据划分成不同sections,如指令在.text、只读数据在.rodata、可读写数据在.data、其他vendor自定义sections,等等,实现了对不同数据的合理组织。

在此基础上,节头表 (Section Header Table),定义了程序的链接视图(the linkable point of view),用来指导linker如何对多个编译单元中的sections进行链接(合并相同sections、符号解析、重定位)。

  • 以C语言为例:每个编译单元编译过程中生成的.o目标文件也是一个ELF文件,里面包含了当前文件的section信息,最终链接器将所有.o文件的相同sections合并在一起,所以说它是用来指导链接器连接的一个视图。see:https://stackoverflow.com/a/51165896
  • 再以Go语言为例,在 how "go build" works 小节里,我们也提及了go tool compile会将go源码文件对应的目标文件归档到静态库文件pkg.a,然后go tool pack将go tool asm汇编源文件生成的目标文件 file.o 最终追加到这个pkg.a,最终go tool link将这个pkg.a与其他运行时、标准库代码链接到一起,形成一个可执行程序。这个过程中对不同目标文件中的sections的处理也是大同小异的。

类型定义

节头表其实就是一系列section表项的数组,我们来看看其中每个描述表项的定义。注意这里的是section的描述表项,并不是section数据。

typedef struct {
    uint32_t   sh_name;
    uint32_t   sh_type;
    uint32_t   sh_flags;
    Elf32_Addr sh_addr;
    Elf32_Off  sh_offset;
    uint32_t   sh_size;
    uint32_t   sh_link;
    uint32_t   sh_info;
    uint32_t   sh_addralign;
    uint32_t   sh_entsize;
} Elf32_Shdr;

typedef struct {
    uint32_t   sh_name;
    uint32_t   sh_type;
    uint64_t   sh_flags;
    Elf64_Addr sh_addr;
    Elf64_Off  sh_offset;
    uint64_t   sh_size;
    uint32_t   sh_link;
    uint32_t   sh_info;
    uint64_t   sh_addralign;
    uint64_t   sh_entsize;
} Elf64_Shdr;

上面分别是32位、64位的定义,下面详细解释下每个字段的含义:

  • sh_name: section name的偏移量,即section的名字在.strtab中的偏移量;
  • sh_type: section类型
    • SHT_NULL: 空section,不包含任何数据;
    • SHT_PROGBITS: 代码段、数据段;
    • SHT_SYMTAB: 符号表;
    • SHT_STRTAB: 字符串表;
    • SHT_RELAG: 重定位表;
    • SHT_HASH: 符号hash表;
    • SHT_DYNAMIC: 动态链接表;
    • SHT_NOTE: 符号注释;
    • SHT_NOBITS: 空section,不包含任何数据;
    • SHT_REL: 重定位表;
    • SHT_SHLIB: 预留但是缺少明确定义;
    • SHT_DYNSYM: 动态符号表;
    • SHT_LOPROC, SHT_HIPROC: 定义了一个范围[SHT_LOPROC, SHT_HIPROC]用于处理器特定机制;
    • SHT_LOUSER, SHT_HIUSER: 定义了一个范围[SHT_LOUSER, SHT_HIPROC]预留给给应用程序;
  • sh_flags: section标志位
    • SHF_WRITE: 进程执行期间可写;
    • SHF_ALLOC: 进程执行期间需要分配并占据内存;
    • SHF_EXECINSTR: 包含进程执行期间的指令数据;
    • SHF_MASKPROC: 预留给处理器相关的机制;
  • sh_addr: 如果当前section需要被加载到内存中,表示在内存中的虚拟地址;
  • sh_offset: 表示当前section相对文件开头的偏移量;
  • sh_size: section大小;
  • sh_link: 表示要链接的下一个节头表的索引,用于section链接顺序;
  • sh_info: section额外信息,具体解释依赖于sh_type;
  • sh_addralign: 对齐方式;
  • sh_entsize: 表示每个section的大小;

工具演示

OK,以测试程序golang-debugger-lessons/testdata/loop2测试程序为例,我们来看下其链接器角度的视图,可以看到其包含了25个sections,每个section都有类型、偏移量、大小、链接顺序、对齐等信息,用以指导链接器完成链接操作。

$ readelf -S testdata/loop2 
There are 25 section headers, starting at offset 0x1c8:

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .text             PROGBITS         0000000000401000  00001000
       0000000000098294  0000000000000000  AX       0     0     32
  [ 2] .rodata           PROGBITS         000000000049a000  0009a000
       00000000000440c7  0000000000000000   A       0     0     32
       .............................................................
  [ 4] .typelink         PROGBITS         00000000004de2a0  000de2a0
       0000000000000734  0000000000000000   A       0     0     32
  [ 5] .itablink         PROGBITS         00000000004de9d8  000de9d8
       0000000000000050  0000000000000000   A       0     0     8
  [ 6] .gosymtab         PROGBITS         00000000004dea28  000dea28
       0000000000000000  0000000000000000   A       0     0     1
  [ 7] .gopclntab        PROGBITS         00000000004dea40  000dea40
       000000000005fe86  0000000000000000   A       0     0     32
       .............................................................
  [10] .data             PROGBITS         000000000054d4e0  0014d4e0
       0000000000007410  0000000000000000  WA       0     0     32
       .............................................................
  [14] .zdebug_line      PROGBITS         0000000000588119  00155119
       000000000001cc0d  0000000000000000           0     0     1
  [15] .zdebug_frame     PROGBITS         00000000005a4d26  00171d26
       00000000000062e9  0000000000000000           0     0     1
       .............................................................
  [22] .note.go.buildid  NOTE             0000000000400f9c  00000f9c
       0000000000000064  0000000000000000   A       0     0     4
  [23] .symtab           SYMTAB           0000000000000000  001d0000
       0000000000011370  0000000000000018          24   422     8
  [24] .strtab           STRTAB           0000000000000000  001e1370
       00000000000109fb  0000000000000000           0     0     1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
  L (link order), O (extra OS processing required), G (group), T (TLS),
  C (compressed), x (unknown), o (OS specific), E (exclude),
  l (large), p (processor specific)

Sections

类型定义

Section就是一堆bytes数据,它由节头表、段头表来引用。

必知的节

ELF文件会包含很多的sections,前面给出的测试实例中就包含了25个sections。先了解些常见的sections的作用,为后续加深对linker、loader、debgguer工作原理的认识提前做点准备。

  • .text: 编译好的程序指令;
  • .rodata: 只读数据,如程序中的常量字符串;
  • .data:已经初始化的全局变量;
  • .bss:未经初始化的全局变量,在ELF文件中只是个占位符,不占用实际空间;
  • .symtab:符号表,每个可重定位文件都有一个符号表,存放程序中定义的全局函数和全局变量的信息,注意它不包含局部变量信息,局部非静态变量由栈来管理,它们对链接器符号解析、重定位没有帮助。
  • .debug_*: 调试信息,调试器读取该信息以支持符号级调试(如gcc -g生成,go build默认生成);
  • .strtab:字符串表,包括.symtab和.[z]debug_*节引用的字符串值、section名;
  • .rel.text:一个.text section中位置及符号列表,当链接器尝试把这个目标文件和其他文件链接时,需要对符号进行解析、重定位成正确的地址;
  • .rel.data:引用的一些全局变量的位置及符号列表,和.rel.text有些类似,也需要符号解析、重定位成正确的地址;

ELF也支持vendor自定义sections来进行扩展,如go语言就添加了.gosymtab、.gopclntab、.note.build.id来支持go运行时、go工具链的一些操作。如果您想了解更多支持的sections及其作用,可以查看man手册:man 5 elf,这里我们就不一一罗列了。

ps: .symtab、DWARF都提供了“符号”一类的信息,但它们是独立的。使用.symtab可以更快速查询符号信息,但是DWARF描述更详细。调试器(比如gdb)可同时使用二者,兼顾效率的同时还能提高兼容性。

工具演示

这里我们来简单介绍下如何查看sections中的内容:

  • 以字符串形式打印:readelf --string-dump=<section> <prog>
  • 以十六进制数打印:readelf --hex-dump=<section> <prog>
  • 打印前先完成重定位,再以十六进制打印:readelf --relocated-dump=<section> <prog>
  • 打印DWARF调试信息:readelf --debug-dump=<section> <prog>

以go语言为例,首先 go tool buildid <prog>提取buildid信息,这个其实就是存储在.note.go.buildid section中的。来验证下,首先通过 go tool buildid来提取buildid信息:

$ go tool buildid testdata/loop
_Iq-Pc8WKArkKz99o-e6/6mQTe-5rece47rT9tQco/8IOigl4fPBb3ZSKYst1T/QZmo-_A8O3Ec6NVYEn_1

接下来通过 readelf --string-dump=.note.go.buildid <prog>直接读取ELF文件中的数据:

$ readelf --string-dump=.note.go.buildid testdata/loop
String dump of section '.note.go.buildid':
  [     4]  S
  [     c]  Go
  [    10]  _Iq-Pc8WKArkKz99o-e6/6mQTe-5rece47rT9tQco/8IOigl4fPBb3ZSKYst1T/QZmo-_A8O3Ec6NVYEn_1

结果发现buildid数据是一致的,证实了我们上述判断。

本节ELF内容就先介绍到这里,在此基础上,接下来的几个小节,我们将循序渐进地介绍linker、loader、debugger的工作原理。

参考文献

  1. Executable and Linkable Format, https://en.wikipedia.org/wiki/Executable_and_Linkable_Format
  2. How to Fool Analysis Tools, https://tuanlinh.gitbook.io/ctf/golang-function-name-obfuscation-how-to-fool-analysis-tools
  3. Go 1.2 Runtime Symbol Information, Russ Cox, https://docs.google.com/document/d/1lyPIbmsYbXnpNj57a261hgOYVpNRcgydurVQIyZOz_o/pub
  4. Some notes on the structure of Go Binaries, https://utcc.utoronto.ca/~cks/space/blog/programming/GoBinaryStructureNotes
  5. Buiding a better Go Linker, Austin Clements, https://docs.google.com/document/d/1D13QhciikbdLtaI67U6Ble5d_1nsI4befEd6_k1z91U/view
  6. Time for Some Function Recovery, https://www.mdeditor.tw/pl/2DRS/zh-hk
  7. Computer System: A Programmer's Perspective, Randal E.Bryant, David R. O'Hallaron, p450-p479
  8. 深入理解计算机系统, 龚奕利 雷迎春 译, p450-p479
  9. Learning Linux Binary Analysis, Ryan O'Neill, p14-15, p18-19
  10. Linux二进制分析, 棣琦 译, p14-15, p18-19
  11. 字符串表示例, https://refspecs.linuxbase.org/elf/gabi4+/ch4.strtab.html

results matching ""

    No results matching ""