为了对 Linux 二进制文件进行反向工程,您必须了解二进制格式本身。 ELF 已经成为 Unix 和 Unix 风格操作系统的标准二进制格式。 在 Linux、BSD 变体和其他操作系统中,ELF 格式用于可执行文件、共享库、目标文件、coredump 文件,甚至内核引导映像。 这使得学习 ELF 对于那些想要更好地理解反向工程、二进制黑客和程序执行的人来说非常重要。 像 ELF 这样的二进制格式通常不是快速学习的对象,要学习 ELF 需要在一定程度上应用所学习的不同组件。 真正的实践经验是达到精通的必要条件。 ELF 格式复杂而枯燥,但是当您在反向工程和编程任务中应用关于它的开发知识时,可以带着一些乐趣来学习它。 ELF 实际上是计算机科学的一个令人难以置信的组合,它包含程序加载、动态链接、符号表查找和许多其他紧密协调的组件。
我相信这一章可能是整本书中最重要的一章,因为它将使读者更深入地了解有关程序如何在磁盘上映射并加载到内存的主题。 程序执行的内部工作是复杂的,理解它对于有抱负的二进制黑客、反向工程师或低级程序员来说是很有价值的知识。 在 Linux 中,程序执行意味着 ELF 二进制格式。
我学习 ELF 的方法是通过研究 ELF 规范,就像任何 Linux 逆向工程师应该做的那样,然后创造性地应用我们所学到的各个方面。 在本书中,您将访问 ELF 的许多方面,并了解它是如何与病毒、进程内存取证、二进制保护、rootkit 等相关的知识。
在本章中,您将涵盖以下 ELF 主题:
- 精灵文件类型
- 程序标题
- 节标题
- 符号
- 搬迁
- 动态链接
- 编写 ELF 解析器
ELF 文件可以被标记为以下类型之一:
ET_NONE
:此为未知类型。 它指示文件类型未知,或尚未定义。ET_REL
:是一个可重定位文件。 ELF 类型的可重定位文件意味着该文件被标记为可重定位的代码片段,有时也被称为目标文件。 可重定位的目标文件通常是尚未链接到可执行文件中的位置无关代码(PIC)的片段。 您经常会在编译后的代码库中看到.o
文件。 这些文件包含适合创建可执行文件的代码和数据。ET_EXEC
:是一个可执行文件。 ELF 类型可执行文件意味着该文件被标记为可执行文件。 这些类型的文件也被称为程序,是进程如何开始运行的入口点。ET_DYN
:这个是一个共享对象。 ELF 动态类型意味着该文件被标记为动态链接的目标文件,也称为共享库。 这些共享库在运行时被加载并链接到程序的进程映像中。ET_CORE
:这个是一个 ELF 类型的核心,它标记一个核心文件。 核心文件是在程序崩溃或进程传递 SIGSEGV 信号(分割违反)时转储的整个进程映像。 GDB 可以读取这些文件并帮助调试以确定导致程序崩溃的原因。
如果我们使用readelf -h
命令查看 ELF 文件,我们可以查看初始的 ELF 文件头。 ELF 文件头从 ELF 文件的 0 偏移量开始,充当到文件其余部分的映射。 首先,这个头标记 ELF 类型、体系结构和开始执行的入口点地址,并为其他类型的 ELF 头(节头和程序头)提供偏移量,稍后将对此进行深入解释。 一旦我们解释了节头和程序头的含义,就会对文件头有更多的了解。 查看 Linux 中的 ELF(5) 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;
在本章的后面,我们将看到如何利用这个结构中的字段用一个简单的 C 程序映射一个 ELF 文件。 首先,我们将继续研究现有的其他类型的 ELF 头。
ELF 程序头描述二进制文件中的段,是程序加载所必需的。 段被内核在加载时理解,描述可执行文件在磁盘上的内存布局,以及它应该如何转换为内存。 可以通过引用初始 ELF 头成员e_phoff
中的偏移量(程序头表偏移量)来访问程序头表,如显示1.7
中的ElfN_Ehdr
结构所示。
这里我们将讨论五种常见的程序头文件类型。 程序头描述一个可执行文件的段(包括共享库)和它是什么类型的段(也就是说,它为什么类型的数据或代码保留)。 首先,让我们看一下组成 32 位 ELF 可执行程序头表中的程序头条目的Elf32_Phdr
结构。
在本书的其余部分,我们有时将程序头文件称为 Phdrs。
下面是Elf32_Phdr
结构:
typedef struct {
uint32_t p_type; (segment type)
Elf32_Off p_offset; (segment offset)
Elf32_Addr p_vaddr; (segment virtual address)
Elf32_Addr p_paddr; (segment physical address)
uint32_t p_filesz; (size of segment in the file)
uint32_t p_memsz; (size of segment in memory)
uint32_t p_flags; (segment flags, I.E execute|read|read)
uint32_t p_align; (segment alignment in memory)
} Elf32_Phdr;
一个可执行文件总是至少有一个PT_LOAD
类型段。 这种类型的程序头描述的是一个可加载的段,这意味着该段将被加载或映射到内存中。
例如,一个带有动态链接的 ELF 可执行文件通常包含以下两个可加载段(类型为PT_LOAD
):
- 用于程序代码的文本段
- 数据段为全局变量和动态链接信息
前面的两个段将被映射到内存中,并通过存储在p_align
中的值在内存中对齐。 我建议阅读 Linux 中的 ELF 手册页,以理解 Phdr 结构中的所有成员,因为它们描述了文件和内存中段的布局。
程序头文件主要用于描述程序在执行时和在内存中的布局。 我们将在本章的后面使用博士来演示他们是什么以及如何在逆向工程软件中使用他们。
文本段(也称为代码段)通常将段权限设置为PF_X
|PF_R
(READ+EXECUTE
)。
数据段通常具有被设置为PF_W
|PF_R
(READ+WRITE
)的段权限。
感染了多态病毒的文件可能以某种方式改变了这些权限,例如通过在程序头的段标志(p_flags
)中添加PF_W
标志来修改文本段以使其可写。
动态段是特定于动态链接的可执行文件的,并且包含动态链接器所必需的信息。 该段包含带标记的值和指针,包括但不限于以下内容:
- 要在运行时链接的共享库的列表
- ELF 动态连接部分讨论了全局偏移表(GOT)的地址/位置
- 关于重定位表项的信息
以下是标签名称的完整列表:
|标签名
|
描述
|
| --- | --- |
| DT_HASH
| 符号哈希表地址 |
| DT_STRTAB
| 字符串表地址 |
| DT_SYMTAB
| 符号表的地址 |
| DT_RELA
| Rela relocs 表地址 |
| DT_RELASZ
| Rela 表的字节大小 |
| DT_RELAENT
| Rela 表项的字节大小 |
| DT_STRSZ
| 字符串表的字节大小 |
| DT_STRSZ
| 字符串表的字节大小 |
| DT_STRSZ
| 字符串表的字节大小 |
| DT_SYMENT
| 符号表项的字节大小 |
| DT_INIT
| 初始化函数的地址 |
| DT_FINI
| 终止函数的地址 |
| DT_SONAME
| 字符串表到共享对象名称的偏移量 |
| DT_RPATH
| String 表到库搜索路径的偏移量 |
| DT_SYMBOLIC
| 警告链接器在可执行文件之前搜索此共享对象以查找符号 |
| DT_REL
| Rel relocs 表的地址 |
| DT_RELSZ
| Rel 表的字节大小 |
| DT_RELENT
| Rel 表项的字节大小 |
| DT_PLTREL
| PLT 引用的重定位类型(Rela 或 Rel) |
| DT_DEBUG
| 未定义的调试使用 |
| DT_TEXTREL
| 如果没有此参数,则表示不应该对不可写段应用 relocs |
| DT_JMPREL
| 仅供 PLT 使用的 reloc 条目地址 |
| DT_BIND_NOW
| 指示动态链接器在将控制权传递给可执行文件之前处理所有重 locs |
| DT_RUNPATH
| String 表到库搜索路径的偏移量 |
动态段包含一系列包含相关动态链接信息的结构。 d_tag
成员控制d_un
的解释。
32 位 ELF 动态结构:
typedef struct {
Elf32_Sword d_tag;
union {
Elf32_Word d_val;
Elf32_Addr d_ptr;
} d_un;
} Elf32_Dyn;
extern Elf32_Dyn _DYNAMIC[];
我们将在本章后面进一步探讨动态链接。
类型为PT_NOTE
的段可能包含与特定供应商或系统相关的辅助信息。 以下是正式 ELF 规范中对PT_NOTE
的定义:
有时供应商或系统构建者需要用特殊信息标记一个目标文件,其他程序将检查其一致性、兼容性等等。 类型为SHT_NOTE
的节和类型为PT_NOTE
的程序头元素可以用于此目的。 section 和程序头元素中的注释信息包含任意数量的条目,每个条目都是目标处理器格式的 4 字节单词数组。 下面的标签有助于解释注释信息的组织,但它们不是规范的一部分。
有趣的一点: 因为这个段只用于操作系统规范信息,而且实际上不是一个可执行文件运行所必需的(因为系统将只是假设可执行文件是本地的任何一种方式),这个段成为病毒感染的一个有趣的地方, 虽然这不是最实际的方法,因为大小的限制。 关于 NOTE 段感染的一些信息可以在http://vxheavens.com/lib/vhe06.html中找到。
这个小段只包含一个空终止字符串的位置和大小,该字符串描述了程序解释器所在的位置; 例如,/lib/linux-ld.so.2
通常是动态连接器的位置,它也是程序解释器。
这个段包含程序头表本身的位置和大小。 Phdr 表包含描述文件片段(以及内存映像)的所有 Phdr。
请参阅 ELF(5)手册页或 ELF 规范文件以了解所有可能的 dr 类型。 我们已经介绍了对程序执行至关重要的最常见的方法,或者我们在反向工程中最常见的方法。
我们可以使用readelf -l <filename>
命令来查看文件的 Phdr 表:
Elf file type is EXEC (Executable file)
Entry point 0x8049a30
There are 9 program headers, starting at offset 52
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
PHDR 0x000034 0x08048034 0x08048034 0x00120 0x00120 R E 0x4
INTERP 0x000154 0x08048154 0x08048154 0x00013 0x00013 R 0x1
[Requesting program interpreter: /lib/ld-linux.so.2]
LOAD 0x000000 0x08048000 0x08048000 0x1622c 0x1622c R E 0x1000
LOAD 0x016ef8 0x0805fef8 0x0805fef8 0x003c8 0x00fe8 RW 0x1000
DYNAMIC 0x016f0c 0x0805ff0c 0x0805ff0c 0x000e0 0x000e0 RW 0x4
NOTE 0x000168 0x08048168 0x08048168 0x00044 0x00044 R 0x4
GNU_EH_FRAME 0x016104 0x0805e104 0x0805e104 0x0002c 0x0002c R 0x4
GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RW 0x4
GNU_RELRO 0x016ef8 0x0805fef8 0x0805fef8 0x00108 0x00108 R 0x1
我们可以看到可执行文件的入口点,以及我们刚刚讨论过的一些不同的段类型。 注意前两个PT_LOAD
段的权限标志和对齐标志右边的偏移量。
文本段为READ+EXECUTE
,数据段为READ+WRITE
,两个段的对齐方式为0x1000
或 4,096,这是 32 位可执行文件的页面大小,这是在程序加载期间的对齐方式。
既然我们已经了解了程序头文件是什么,现在是时候看看节头文件了。 我想指出两者之间的区别; 我经常听到人们叫分部,分部,反之亦然。 分段不是分段。 段是程序执行所必需的,在每个段中,有代码或数据被划分成段。 section 头表的存在是为了引用这些 section 的位置和大小,主要用于链接和调试。 Section 头文件对于程序执行来说不是必需的,一个程序在没有 Section 头文件表的情况下也可以很好地执行。 这是因为 section 头表没有描述程序的内存布局。 这是程序头表的职责。 节头实际上只是程序头的补充。 readelf –l
命令将显示哪些部分映射到哪些段,这有助于可视化段和段之间的关系。
如果 section 头被剥离(从二进制文件中丢失),这并不意味着 section 不存在; 这只是意味着它们不能被节头引用,调试器和反汇编程序可用的信息更少。
每个部分都包含某种类型的代码或数据。 数据的范围可以从程序数据(如全局变量)或链接器所必需的动态链接信息。 现在,正如前面提到的,每个 ELF 对象都有段,但并不是所有 ELF 对象都有段头,主要是当有人故意删除了段头表时,这不是默认的。
通常,这是因为可执行文件被篡改了(例如,节头被剥离了,因此调试更加困难)。 所有 GNU 的 binutils(如objcopy
、objdump
和其他工具(如gdb
)都依赖于节头来定位存储在特定于包含符号数据的节中的符号信息。 如果没有段头,像gdb
和objdump
这样的工具几乎毫无用处。
节头可以方便地检查我们正在查看的 ELF 对象的哪些部分或部分。 事实上,节头使反向工程变得容易得多,因为它们为我们提供了使用某些需要它们的工具的能力。 例如,如果 section 头表被剥离,那么我们就不能访问像.dynsym
这样的 section,它包含了导入/导出的符号,用来描述函数名和偏移量/地址。
即使 section 头表已经从可执行文件中剥离,一个适度的反向工程师也可以通过从某些程序头表中获取信息来重建 section 头表(甚至是符号表的一部分),因为这些文件头表总是存在于程序或共享库中。 我们在前面讨论了动态段和包含符号表和重定位表项信息的不同的DT_TAG
。 我们可以使用它来重构可执行文件的其他部分,如第八章,ECFS -扩展核心文件快照技术所示。
以下是 32 位的 ELF section header 的样子:
typedef struct {
uint32_t sh_name; // offset into shdr string table for shdr name
uint32_t sh_type; // shdr type I.E SHT_PROGBITS
uint32_t sh_flags; // shdr flags I.E SHT_WRITE|SHT_ALLOC
Elf32_Addr sh_addr; // address of where section begins
Elf32_Off sh_offset; // offset of shdr from beginning of file
uint32_t sh_size; // size that section takes up on disk
uint32_t sh_link; // points to another section
uint32_t sh_info; // interpretation depends on section type
uint32_t sh_addralign; // alignment for address of section
uint32_t sh_entsize; // size of each certain entries that may be in section
} Elf32_Shdr;
让我们看一看一些最重要的节和节类型,再次为研究 ELF(5)手册页和官方 ELF 规范提供空间,以获得关于节的更详细信息。
.text
节是一个包含程序代码指令的代码段。 在一个也有博士的可执行程序中,这个部分将在文本段的范围内。 因为它包含程序代码,所以它是 section typeSHT_PROGBITS
。
sectionrodata
section 包含只读数据,例如一行 C 代码中的字符串,例如下面的命令存储在本节中:
printf("Hello World!\n");
该节是只读的,因此必须存在于可执行文件的只读段中。 因此,您将在文本段(而不是数据段)的范围内找到.rodata
。 因为该节是只读的,所以它的类型是SHT_PROGBITS
。
程序链接表**(PLT)将在本章后面讨论的深度,但它包含动态链接器所必需的代码调用函数从共享库进口。 它驻留在文本段中并包含代码,因此它被标记为类型SHT_PROGBITS
。**
data
段(而不是)将与数据段混淆,它将存在于数据段中,并包含初始化的全局变量等数据。 它包含程序变量数据,因此标记为SHT_PROGBITS
。
bss
段包含未初始化的全局数据作为数据段的一部分,因此除了 4 个字节(表示该段本身)外,不占用磁盘空间。 数据在程序加载时被初始化为零,数据可以在程序执行时被赋值。 bss
节被标记为SHT_NOBITS
,因为它不包含实际数据。
全局偏移表(GOT)部分包含全局偏移表。 它与 PLT 一起工作,提供对导入的共享库函数的访问,并在运行时由动态链接器进行修改。 特别是这个部分经常被攻击者滥用,他们在堆中获得指针大小的写原语或利用.bss
。 我们将在本章的ELF 动态链接部分讨论这个问题。 这一节与程序执行有关,因此标记为SHT_PROGBITS
。
dynsym
节包含从共享库导入的动态符号信息。 它包含在文本段中,并被标记为类型SHT_DYNSYM
。
dynstr
部分包含动态符号的字符串表,其中包含一系列以空结尾的字符串中每个符号的名称。
重定位部分包含关于 ELF 对象或进程映像的部分在链接或运行时需要如何修复或修改的信息。 我们将在本章的ELF 重置部分讨论更多关于重置的内容。 重定位部分被标记为类型SHT_REL
,因为它们包含重定位数据。
hash
部分,有时又称为.gnu.hash
,包含一个用于符号查找的哈希表。 以下是 Linux ELF 中用于符号名称查找的哈希算法:
uint32_t
dl_new_hash (const char *s)
{
uint32_t h = 5381;
for (unsigned char c = *s; c != '\0'; c = *++s)
h = h * 33 + c;
return h;
}
h = h * 33 + c
常被视为的代号h = ((h << 5) + h) + c
symtab
部分包含ElfN_Sym
类型的符号信息,我们将在本章的 ELF 符号和重定位部分进行更深入的分析。 symtab
节被标记为SHT_SYMTAB
类型,因为它包含符号信息。
.strtab
节包含符号字符串表,该表由.symtab
结构中的ElfN_Sym
项引用,并被标记为类型SHT_STRTAB
,因为它包含一个字符串表。
shstrtab
节包含节头字符串表,该表是一组以空结尾的字符串,其中包含每个节的名称,如.text
、.data
,等等。 这个部分是由名为e_shstrndx
的 ELF 文件头条目指向的,该条目保存着.shstrtab
的偏移量。 这个部分被标记为SHT_STRTAB
,因为它包含一个字符串表。
.ctors
(构造函数)和.dtors
(析构函数)部分包含函数指针初始化和【显示】这是终结代码被执行之前和之后的实际身体main()
程序代码。
**### 注意事项
黑客和病毒编写人员有时使用__constructor__
属性来实现一个函数,该函数执行反调试技巧,例如调用PTRACE_TRACEME
,以便进程跟踪自身,而没有调试器可以附加到它。 通过这种方式,反调试代码在程序进入main()
之前被执行。
还有许多其他的节名称和类型,但是我们已经介绍了节中动态链接的可执行文件中的大部分主要部分。 现在可以看到一个可执行文件是如何同时使用phdrs
和shdrs
进行布局的。
文本部分将如下:
- :这是程序代码
[.rodata]
:只读数据[.hash]
:符号哈希表[.dynsym ]
:共享对象符号数据[.dynstr ]
:共享对象符号名[.plt]
:过程链接表[.rel.got]
:这是 G.O.T 搬迁数据
数据段如下:
[.data]
:这些是全局初始化的变量[.dynamic]
:这些是动态连接的结构和对象[.got.plt]
:全局偏移表[.bss]
:这些是全局未初始化的变量
让我们用readelf –S
命令来看看ET_REL
文件(目标文件)的节头:
ryan@alchemy:~$ gcc -c test.c
ryan@alchemy:~$ readelf -S test.o
下面是 12 个 section 头,从偏移量 0 x 124 开始:
[Nr] Name Type Addr Off
Size ES Flg Lk Inf Al
[ 0] NULL 00000000 000000
000000 00 0 0 0
[ 1] .text PROGBITS 00000000 000034
000034 00 AX 0 0 4
[ 2] .rel.text REL 00000000 0003d0
000010 08 10 1 4
[ 3] .data PROGBITS 00000000 000068
000000 00 WA 0 0 4
[ 4] .bss NOBITS 00000000 000068
000000 00 WA 0 0 4
[ 5] .comment PROGBITS 00000000 000068
00002b 01 MS 0 0 1
[ 6] .note.GNU-stack PROGBITS 00000000 000093
000000 00 0 0 1
[ 7] .eh_frame PROGBITS 00000000 000094
000038 00 A 0 0 4
[ 8] .rel.eh_frame REL 00000000 0003e0
000008 08 10 7 4
[ 9] .shstrtab STRTAB 00000000 0000cc
000057 00 0 0 1
[10] .symtab SYMTAB 00000000 000304
0000b0 10 11 8 4
[11] .strtab STRTAB 00000000 0003b4
00001a 00 0 0 1
在可重定位对象(类型为ET_REL
的 ELF 文件)中不存在程序头文件,因为.o
文件意味着要链接到可执行文件中,但不意味着要直接加载到内存中; 因此,readelf -l 在test.o
上不会产生任何结果。 Linux 可加载内核模块实际上是ET_REL
对象,是该规则的一个例外,因为它们会直接加载到内核内存中并动态地重新定位。
我们可以看到,我们谈到的许多部分都存在,但也有一些没有。 如果我们将test.o
编译成一个可执行文件,我们会看到添加了许多新的部分,包括.got.plt
,.plt
,.dynsym
,以及其他与动态链接和运行时重定位相关的部分:
ryan@alchemy:~$ gcc evil.o -o evil
ryan@alchemy:~$ readelf -S evil
下面是 30 个 section 头,从偏移量 0 x 1140 开始:
[Nr] Name Type Addr Off
Size ES Flg Lk Inf Al
[ 0] NULL 00000000 000000
000000 00 0 0 0
[ 1] .interp PROGBITS 08048154 000154
000013 00 A 0 0 1
[ 2] .note.ABI-tag NOTE 08048168 000168
000020 00 A 0 0 4
[ 3] .note.gnu.build-i NOTE 08048188 000188
000024 00 A 0 0 4
[ 4] .gnu.hash GNU_HASH 080481ac 0001ac
000020 04 A 5 0 4
[ 5] .dynsym DYNSYM 080481cc 0001cc
000060 10 A 6 1 4
[ 6] .dynstr STRTAB 0804822c 00022c
000052 00 A 0 0 1
[ 7] .gnu.version VERSYM 0804827e 00027e
00000c 02 A 5 0 2
[ 8] .gnu.version_r VERNEED 0804828c 00028c
000020 00 A 6 1 4
[ 9] .rel.dyn REL 080482ac 0002ac
000008 08 A 5 0 4
[10] .rel.plt REL 080482b4 0002b4
000020 08 A 5 12 4
[11] .init PROGBITS 080482d4 0002d4
00002e 00 AX 0 0 4
[12] .plt PROGBITS 08048310 000310
000050 04 AX 0 0 16
[13] .text PROGBITS 08048360 000360
00019c 00 AX 0 0 16
[14] .fini PROGBITS 080484fc 0004fc
00001a 00 AX 0 0 4
[15] .rodata PROGBITS 08048518 000518
000008 00 A 0 0 4
[16] .eh_frame_hdr PROGBITS 08048520 000520
000034 00 A 0 0 4
[17] .eh_frame PROGBITS 08048554 000554
0000c4 00 A 0 0 4
[18] .ctors PROGBITS 08049f14 000f14
000008 00 WA 0 0 4
[19] .dtors PROGBITS 08049f1c 000f1c
000008 00 WA 0 0 4
[20] .jcr PROGBITS 08049f24 000f24
000004 00 WA 0 0 4
[21] .dynamic DYNAMIC 08049f28 000f28
0000c8 08 WA 6 0 4
[22] .got PROGBITS 08049ff0 000ff0
000004 04 WA 0 0 4
[23] .got.plt PROGBITS 08049ff4 000ff4
00001c 04 WA 0 0 4
[24] .data PROGBITS 0804a010 001010
000008 00 WA 0 0 4
[25] .bss NOBITS 0804a018 001018
000008 00 WA 0 0 4
[26] .comment PROGBITS 00000000 001018
00002a 01 MS 0 0 1
[27] .shstrtab STRTAB 00000000 001042
0000fc 00 0 0 1
[28] .symtab SYMTAB 00000000 0015f0
000420 10 29 45 4
[29] .strtab STRTAB 00000000 001a10
00020d 00 0 0
正如所观察到的,已经添加了许多节,最显著的是与动态链接和构造器相关的节。 我强烈建议读者遵循这样的练习:推断哪些部分被修改或添加了,以及添加的部分的目的是什么。 请参阅 ELF(5)手册页或 ELF 规范。
符号是对某些类型的数据或代码(如全局变量或函数)的符号引用。 例如,printf()
函数将在动态符号表.dynsym
中有一个指向它的符号条目。 在大多数共享库和动态链接的可执行文件中,存在两个符号表。 在前面显示的readelf -S
输出中,您可以看到两个部分:.dynsym
和.symtab
。
.dynsym
包含全球从外部源符号引用符号,比如像printf``libc
功能,而.symtab
中包含的符号将包含所有的符号在.dynsym
,以及当地的符号的可执行文件,如全局变量,或地方在代码中定义的函数。 所以.symtab
包含所有的符号,而.dynsym
只包含动态/全局符号。
所以问题是:如果.symtab
已经包含了.dynsym
中的所有内容,为什么还要有两个符号表? readelf -S
如果你查看输出的可执行文件,你会发现有些部分是标志着**(【显示】ALLOC)或WA**(写/ ALLOC【病人】或AX**(ALLOC / EXEC【t16.1】)。 如果您查看.dynsym
,您将看到它被标记为 ALLOC,而.symtab
没有标志。******
ALLOC 意味着该节将在运行时分配并加载到内存中,而.symtab
不加载到内存中,因为它不是运行时所必需的。 .dynsym
包含只能在运行时解析的符号,因此它们是动态连接器在运行时唯一需要的符号。 因此,虽然.dynsym
符号表对于动态链接的可执行文件的执行是必要的,但.symtab
符号表的存在只是为了调试和链接的目的,通常会从生产二进制文件中删除(删除)以节省空间。
让我们来看看 64 位 ELF 文件的 ELF 符号条目是什么样子的:
typedef struct {
uint32_t st_name;
unsigned char st_info;
unsigned char st_other;
uint16_t st_shndx;
Elf64_Addr st_value;
Uint64_t st_size;
} Elf64_Sym;
符号项包含在.symtab
和.dynsym
节中,这就是为什么这些节的sh_entsize
(节头条目大小)等同于sizeof(ElfN_Sym)
。
st_name
包含符号表的字符串表(位于.dynstr
或.strtab
中)的偏移量,符号的名称位于其中,例如printf
。
st_value
保存符号的值(地址或其位置的偏移量)。
st_size
包含符号的大小,例如全局函数ptr
的大小,在 32 位系统上是 4 个字节。
该成员定义了符号的可见性。
每个符号表条目都被定义为与某个 section 相关。 该成员保存相关的 section 头表索引。
st_info
指定符号类型和绑定属性。 要获得这些类型和属性的完整列表,请参阅ELF(5)手册页。 符号类型以 STT 开始,而符号绑定以 STB 开始。 作为一个例子,一些常见的例子将在下一节中解释。
我们有以下的符号类型:
STT_NOTYPE
:符号类型未定义- :该符号与一个函数或其他可执行代码相关联
STT_OBJECT
:该符号与数据对象相关联
我们得到了以下的符号绑定:
STB_LOCAL
:局部符号在包含其定义的 object 文件之外不可见,例如声明为 static 的函数。STB_GLOBAL
:全局符号对所有被合并的目标文件都可见。 一个文件对全局符号的定义将满足另一个文件对该符号的未定义引用。STB_WEAK
:类似于全局绑定,但优先级较低,这意味着绑定是弱的,可能会被另一个没有标记为STB_WEAK
的符号(具有相同名称)覆盖。
有一些宏用于打包和解包 binding 和 type 字段:
ELF32_ST_BIND(info)
或ELF64_ST_BIND(info)
从st_info
值中提取绑定ELF32_ST_TYPE(info)
或ELF64_ST_TYPE(info)
从st_info
值提取类型ELF32_ST_INFO(bind, type)
或ELF64_ST_INFO(bind, type)
将绑定和类型转换为st_info
值
让我们看看以下源代码的符号表:
static inline void foochu()
{ /* Do nothing */ }
void func1()
{ /* Do nothing */ }
_start()
{
func1();
foochu();
}
下面是查看函数foochu
和func1
符号表项的命令:
ryan@alchemy:~$ readelf -s test | egrep 'foochu|func1'
7: 080480d8 5 FUNC LOCAL DEFAULT 2 foochu
8: 080480dd 5 FUNC GLOBAL DEFAULT 2 func1
我们可以看到,foochu
函数是一个值0x80480da
,并且是一个具有局部符号绑定(STB_LOCAL
)的函数(STT_FUNC
)。 如果你还记得,我们谈论了一些关于LOCAL
绑定,这意味着对象文件外的符号不能看到它定义它,这就是为什么foochu
是本地的,因为我们宣布它与【T6 static 关键字】【显示】在我们的源代码。
符号让每个人的生活更轻松; 它们是 ELF 对象的一部分,用于链接、重定位、可读的反汇编和调试。 这就引出了我在 2013 年编写的一个名为ftrace
的有用工具的主题。 与ltrace
和strace
类似,ftrace
将跟踪二进制文件中所有的函数调用,并且还可以显示跳转等其他分支指令。 我最初设计ftrace
是为了帮助反转我在工作时没有源代码的二进制文件。 ftrace
被认为是一种动态分析工具。 让我们来看看它的一些功能。 我们用下面的源代码编译一个二进制文件:
#include <stdio.h>
int func1(int a, int b, int c)
{
printf("%d %d %d\n", a, b ,c);
}
int main(void)
{
func1(1, 2, 3);
}
现在,假设我们没有前面的源代码,并且我们想知道它所编译的二进制文件的内部工作原理,我们可以在它上运行ftrace
。 首先让我们看一下大纲:
ftrace [-p <pid>] [-Sstve] <prog>
用法如下:
[-p]
:根据 PID 进行跟踪[-t]
:用于函数参数的类型检测[-s]
:打印字符串值[-v]
:这将提供详细的输出[-e]
:这提供了各种 ELF 信息(符号、依赖)[-S]
:这显示了去掉符号的函数调用[-C]
:这就完成了控制流分析
让我们试试:
ryan@alchemy:~$ ftrace -s test
[+] Function tracing begins here:
PLT_call@0x400420:__libc_start_main()
LOCAL_call@0x4003e0:_init()
(RETURN VALUE) LOCAL_call@0x4003e0: _init() = 0
LOCAL_call@0x40052c:func1(0x1,0x2,0x3) // notice values passed
PLT_call@0x400410:printf("%d %d %d\n") // notice we see string value
1 2 3
(RETURN VALUE) PLT_call@0x400410: printf("%d %d %d\n") = 6
(RETURN VALUE) LOCAL_call@0x40052c: func1(0x1,0x2,0x3) = 6
LOCAL_call@0x400470:deregister_tm_clones()
(RETURN VALUE) LOCAL_call@0x400470: deregister_tm_clones() = 7
一个聪明的人现在可能会问:如果一个二进制的符号表被剥离,会发生什么? 这是正确的; 你可以去掉二进制的符号表; 然而,动态链接的可执行文件将始终保留.dynsym
,但如果删除了.symtab
,则将丢弃.symtab
,因此只显示导入的库符号。
如果编译静态二进制libc
(gcc-static
)或没有连接(gcc-nostdlib
),然后它是剥夺了strip
命令,一个二进制将没有符号表,因为动态符号表不再是必要的。 ftrace
与–S
标志的行为不同,该标志告诉ftrace
即使没有附加符号,也要显示每个函数调用。 当使用–S
标志时,ftrace
将把函数名显示为SUB_<address_of_function>
,类似于 IDA pro 显示没有符号表引用的函数。
让我们看看以下非常简单的源代码:
int foo(void) {
}
_start()
{
foo();
__asm__("leave");
}
前面的源代码只是调用foo()
函数并退出。 我们使用_start()
而不是main()
的原因是我们用以下代码编译它:
gcc -nostdlib test2.c -o test2
gcc
标志-nostdlib
指示链接器忽略标准libc
链接约定,而仅仅编译我们拥有的代码,仅此而已。 默认的入口点是一个名为_start()
的符号:
ryan@alchemy:~$ ftrace ./test2
[+] Function tracing begins here:
LOCAL_call@0x400144:foo()
(RETURN VALUE) LOCAL_call@0x400144: foo() = 0
Now let's strip the symbol table and run ftrace on it again:
ryan@alchemy:~$ strip test2
ryan@alchemy:~$ ftrace -S test2
[+] Function tracing begins here:
LOCAL_call@0x400144:sub_400144()
(RETURN VALUE) LOCAL_call@0x400144: sub_400144() = 0
现在我们注意到,foo()
函数已经被sub_400144()
替换,这表明函数调用发生在地址0x400144
。 现在,如果我们在去除符号之前查看二进制test2
,我们可以看到0x400144
确实是foo()
所在的位置:
ryan@alchemy:~$ objdump -d test2
test2: file format elf64-x86-64
Disassembly of section .text:
0000000000400144<foo>:
400144: 55 push %rbp
400145: 48 89 e5 mov %rsp,%rbp
400148: 5d pop %rbp
400149: c3 retq
000000000040014a <_start>:
40014a: 55 push %rbp
40014b: 48 89 e5 mov %rsp,%rbp
40014e: e8 f1 ff ff ff callq 400144 <foo>
400153: c9 leaveq
400154: 5d pop %rbp
400155: c3 retq
事实上,为了让您真正了解符号对逆向工程师的帮助有多大(当我们有符号时),让我们看一下test2
二进制文件,这次没有符号来演示它是如何变得不那么容易阅读的。 这主要是因为分支指令不再有附加的符号名,因此分析控制流变得更加乏味,需要更多的注释,而一些反汇编器,如 IDA-pro,允许我们这样做:
$ objdump -d test2
test2: file format elf64-x86-64
Disassembly of section .text:
0000000000400144 <.text>:
400144: 55 push %rbp
400145: 48 89 e5 mov %rsp,%rbp
400148: 5d pop %rbp
400149: c3 retq
40014a: 55 push %rbp
40014b: 48 89 e5 mov %rsp,%rbp
40014e: e8 f1 ff ff ff callq 0x400144
400153: c9 leaveq
400154: 5d pop %rbp
400155: c3 retq
唯一给我们一个想法,一个新的函数开始是通过检查过程开场白,每个函数的开头,除非(gcc -fomit-frame-pointer
)已经被使用,在这种情况下,变得不太明显的识别。
本书假设读者已经有一些汇编语言的知识,因为教授 x86 asm 不是本书的目标,但请注意前面大胆的过程序言,它帮助表示每个函数的开始。 procedure prologue 只是通过备份堆栈上的基指针并在调整堆栈指针以为局部变量腾出空间之前将其值设置为堆栈指针来为每个已调用的新函数设置堆栈框架。 这样,变量就可以作为从存储在基指针寄存器ebp/rbp
中的固定地址的正偏移量来引用。
既然我们已经掌握了符号,下一步就是理解重定位。 在下一节中,我们将看到符号、重定位和节如何紧密地联系在一起,并在 ELF 格式中处于同一抽象级别。
从 ELF(5) man 页面:
重定位是将符号引用与符号定义连接起来的过程。 可重定位文件必须包含描述如何修改其部分内容的信息,从而允许可执行文件和共享对象文件保存进程的程序映像的正确信息。 重定位表项就是这些数据。
重定位的过程依赖于符号和节,所以我们先讲了符号和节。 在重定位中,有重定位记录,其本质上包含关于如何给与给定符号相关的代码打补丁的信息。 重定位实际上是一种用于二进制补丁的机制,当涉及到动态连接器时,甚至是内存中的热补丁。 用于创建可执行文件的链接程序:/bin/ld
,并且共享库必须具有某种类型的元数据来描述如何给某些指令打补丁。 这种元数据存储为我们所称的重定位记录。 我将通过一个例子进一步解释重定位。
假设将两个目标文件链接在一起以创建一个可执行文件。 我们有obj1.o
,它包含调用位于obj2.o
中的名为foo()
的函数的代码。 其中 obj1。 o 和obj2.o
由链接器程序分析,并包含重定位记录,以便它们可以被链接以创建一个完全工作的可执行程序。 符号引用将被解析为符号定义,但这到底意味着什么呢? 目标文件是可重定位的代码,这意味着它是可以被重新定位到可执行段中给定地址的一个位置的代码。 在重定位过程发生之前,代码中的符号和代码在不知道它们在内存中的位置之前不能正常工作或不能被正确引用。 在链接器知道指令或符号在可执行段中的位置之后,必须对这些补丁进行修补。
让我们快速看一下 64 位重定位条目:
typedef struct {
Elf64_Addr r_offset;
Uint64_t r_info;
} Elf64_Rel;
一些重定位表项需要加数:
typedef struct {
Elf64_Addr r_offset;
uint64_t r_info;
int64_t r_addend;
} Elf64_Rela;
r_offset
为需要搬迁行动的位置。 重定位操作描述如何给r_offset
中包含的代码或数据打补丁的详细信息。
r_info
给出了必须对其进行重定位的符号表索引,以及要应用的重定位类型。
r_addend
指定一个常量加数,用于计算存储在可重定位字段中的值。
32 位 ELF 文件的重定位记录与 64 位文件相同,但使用 32 位整数。 下面的示例是目标文件代码将被编译为 32 位,以便我们可以演示隐式的加数,这在 64 位中并不常用。 当重定位记录存储在 ElfN_Rel 类型结构中且不包含r_addend
字段时,就会出现一个隐式加数,因此加数存储在重定位目标本身中。 64 位可执行文件倾向于使用包含显式加的ElfN_Rela
结构体。 我认为这两种情况都值得理解,但隐含的增加有点令人困惑,所以有必要为这个领域带来光明。
让我们来看看源代码:
_start()
{
foo();
}
我们看到它调用了foo()
函数。 然而,foo()
函数并不直接位于该源代码文件中; 因此,在编译时,将创建一个重定位条目,这是以后满足符号引用所必需的:
$ objdump -d obj1.o
obj1.o: file format elf32-i386
Disassembly of section .text:
00000000 <func>:
0: 55 push %ebp
1: 89 e5 mov %esp,%ebp
3: 83 ec 08 sub $0x8,%esp
6: e8 fc ff ff ff call 7 <func+0x7>
b: c9 leave
c: c3 ret
如我们所见,对foo()
的调用被高亮显示,它包含值0xfffffffc
,即的隐式加数。 还有注意call 7
。 编号7
为需要打补丁的重定位目标偏移量。 所以当obj1.o
(调用foo()
位于obj2.o
)与obj2.o
可执行,搬迁条目指向抵消【显示】是由链接器处理,告诉它的位置(偏移量 7)需要修改。 然后链接器在偏移量为 7 的 4 个字节上打补丁,以便在foo()
被定位到可执行文件中的某个位置之后,它将包含到foo()
函数的真实偏移量。
调用指令e8 fc ff ff ff
包含了隐式加数,在这节课中记住它很重要; 值0xfffffffc
为-(4)
或-(sizeof(uint32_t))
。 一个双字在 32 位系统上是 4 个字节,这就是重定位目标的大小。
$ readelf -r obj1.o
Relocation section '.rel.text' at offset 0x394 contains 1 entries:
Offset Info Type Sym.Value Sym. Name
00000007 00000902 R_386_PC32 00000000 foo
正如我们所看到的,偏移量为 7 的重定位字段由重定位表项的r_offset
字段指定。
R_386_PC32
为搬迁类型。 要理解所有这些类型,请阅读 ELF 规范。 每种重定位类型都需要对被修改的重定位目标进行不同的计算。R_386_PC32
用S + A – P
修改目标。S
是其索引位于重定位表项中的符号的值。A
为重新定位项中的加数。P
是存储单元被重新定位的位置(section offset 或 address)(使用r_offset
计算)。
让我们看看在 32 位系统上编译obj1.o
和obj2.o
后,可执行文件的最终输出:
$ gcc -nostdlib obj1.o obj2.o -o relocated
$ objdump -d relocated
test: file format elf32-i386
Disassembly of section .text:
080480d8 <func>:
80480d8: 55 push %ebp
80480d9: 89 e5 mov %esp,%ebp
80480db: 83 ec 08 sub $0x8,%esp
80480de: e8 05 00 00 00 call 80480e8 <foo>
80480e3: c9 leave
80480e4: c3 ret
80480e5: 90 nop
80480e6: 90 nop
80480e7: 90 nop
080480e8 <foo>:
80480e8: 55 push %ebp
80480e9: 89 e5 mov %esp,%ebp
80480eb: 5d pop %ebp
80480ec: c3 ret
我们可以看到位于 0x80480de 的调用指令**(重定位目标)已经被修改为 32 位偏移值5
,它指向foo()
。 值5
是R386_PC_32
搬迁行动的结果:**
S + A – P: 0x80480e8 + 0xfffffffc – 0x80480df = 5
如果是有符号整数,则0xfffffffc
与–4
相同,因此计算也可以看成:
0x80480e8 + (0x80480df + sizeof(uint32_t))
要计算进入虚拟地址的偏移量,使用以下计算方法:
address_of_call + offset + 5 (Where 5 is the length of the call instruction)
在本例中是0x80480de + 5 + 5 = 0x80480e8
。
请注意这个计算,因为记住它很重要,并且可以在经常计算地址的偏移量时使用。
一个地址也可以用以下计算方法计算到一个偏移量中:
address – address_of_call – 4 (Where 4 is the length of the immediate operand to the call instruction, which is 32bits).
如前所述,ELF 规范深入地介绍了 ELF 重定位,我们将在下一节中访问动态链接中使用的一些类型,例如R386_JMP_SLOT
重定位条目。
可重定位代码注入是一种技术,黑客、病毒编者或任何想修改二进制代码的人都可以利用它来重新链接已经编译并链接到可执行文件中的二进制代码。 也就是说,你可以将一个目标文件注入到一个可执行文件中,更新可执行文件的符号表以反映新插入的功能,并对注入的目标代码执行必要的重定位,以便它成为可执行文件的一部分。
复杂的病毒可能会使用这种技术,而不仅仅是附加位置无关的代码。 这种技术需要在目标可执行文件中留出空间来注入代码,然后应用重定位。 我们将在第 4 章、ELF 病毒技术—Linux/Unix 病毒中更全面地讨论二进制感染和代码注入。
所第一章,Linux 环境和工具,有一个神奇的工具叫Eresi*【显示】(http://www.eresi-project.org),这是可以浮动的代码注入(又名`ET_REL`注入)。 我还为 ELF 设计了一个定制的逆向工程工具,即昆雅。 它非常古老,但可以在http://www.bitlackeys.org/projects/quenya_32bit.tgz找到。 Quenya 有许多特性和功能,其中之一就是将目标代码注入到可执行文件中。 这对于通过劫持给定函数来修补二进制文件非常有用。 昆雅只是一个原型,从未开发到Eresi项目的程度。 我只是把它作为一个例子,因为我更熟悉它; 然而,我要说的是,为了获得更可靠的结果,可能需要使用Eresi或编写自己的工具。*
*让我们假设我们是一个攻击者,并且我们想要感染一个 32 位程序,该程序调用puts()
来打印Hello World
。 我们的目标是劫持puts()
,使其调用evil_puts()
:
#include <sys/syscall.h>
int _write (int fd, void *buf, int count)
{
long ret;
__asm__ __volatile__ ("pushl %%ebx\n\t"
"movl %%esi,%%ebx\n\t"
"int $0x80\n\t""popl %%ebx":"=a" (ret)
:"0" (SYS_write), "S" ((long) fd),
"c" ((long) buf), "d" ((long) count));
if (ret >= 0) {
return (int) ret;
}
return -1;
}
int evil_puts(void)
{
_write(1, "HAHA puts() has been hijacked!\n", 31);
}
现在我们将evil_puts.c
编译为evil_puts.o
,并将其注入我们的程序./hello_world
:
$ ./hello_world
Hello World
这个程序调用以下函数:
puts("Hello World\n");
我们现在使用Quenya
将evil_puts.o
文件注入hello_world
:
[Quenya v0.1@alchemy] reloc evil_puts.o hello_world
0x08048624 addr: 0x8048612
0x080485c4 _write addr: 0x804861e
0x080485c4 addr: 0x804868f
0x080485c4 addr: 0x80486b7
Injection/Relocation succeeded
如我们所见,我们的evil_puts.o
目标文件中的write()
函数已经被重新定位,并在可执行文件hello_world
中为0x804861e
分配了一个地址。 下一个命令 hijack 用evil_puts()
的地址覆盖puts()
的全局偏移表项:
[Quenya v0.1@alchemy] hijack binary hello_world evil_puts puts
Attempting to hijack function: puts
Modifying GOT entry for puts
Successfully hijacked function: puts
Committing changes into executable file
[Quenya v0.1@alchemy] quit
And Whammi!
ryan@alchemy:~/quenya$ ./hello_world
HAHA puts() has been hijacked!
我们已经成功地将一个目标文件重新定位到一个可执行文件中,并修改了可执行文件的控制流,以便它执行我们注入的代码。 如果我们对hello_world
使用readelf -s
,我们现在实际上可以看到evil_puts()
的符号。
为了您的兴趣,我已经包含了一小段代码,其中包含了在 quanya 中的 ELF 重新定位机制; 在没有看到其余的代码基础的情况下,它可能有点模糊,但如果你保留了我们学到的关于重定位的内容,它也有点直截了当:
switch(obj.shdr[i].sh_type)
{
case SHT_REL: /* Section contains ElfN_Rel records */
rel = (Elf32_Rel *)(obj.mem + obj.shdr[i].sh_offset);
for (j = 0; j < obj.shdr[i].sh_size / sizeof(Elf32_Rel); j++, rel++)
{
/* symbol table */
symtab = (Elf32_Sym *)obj.section[obj.shdr[i].sh_link];
/* symbol we are applying relocation to */
symbol = &symtab[ELF32_R_SYM(rel->r_info)];
/* section to modify */
TargetSection = &obj.shdr[obj.shdr[i].sh_info];
TargetIndex = obj.shdr[i].sh_info;
/* target location */
TargetAddr = TargetSection->sh_addr + rel->r_offset;
/* pointer to relocation target */
RelocPtr = (Elf32_Addr *)(obj.section[TargetIndex] + rel->r_offset);
/* relocation value */
RelVal = symbol->st_value;
RelVal += obj.shdr[symbol->st_shndx].sh_addr;
printf("0x%08x %s addr: 0x%x\n",RelVal, &SymStringTable[symbol->st_name], TargetAddr);
switch (ELF32_R_TYPE(rel->r_info))
{
/* R_386_PC32 2 word32 S + A - P */
case R_386_PC32:
*RelocPtr += RelVal;
*RelocPtr -= TargetAddr;
break;
/* R_386_32 1 word32 S + A */
case R_386_32:
*RelocPtr += RelVal;
break;
}
}
如上代码所示,RelocPtr
指向的重定位目标根据重定位类型(如R_386_32
)所请求的重定位动作进行修改。
虽然可重定位代码二进制注入是重定位背后思想的一个很好的例子,但它并不是一个链接器如何在多个目标文件中执行重定位的完美例子。 然而,它仍然保留了搬迁诉讼的一般思想和适用。 稍后我们将讨论共享库(ET_DYN
)注入,这将我们带到动态链接的主题。
在过去,一切都是静态链接的。 如果程序使用外部库函数,则整个库将直接编译到可执行文件中。 ELF 支持动态链接,这是处理共享库的一种更有效的方法。
当程序被加载到内存中时,动态链接器也会加载并绑定该进程地址空间所需要的共享库。 动态链接的主题很少被深入了解,因为它是一个相对复杂的过程,似乎在幕后像魔术一样工作。 在本节中,我们将揭开它的一些复杂性,并揭示它是如何工作的,以及它是如何被攻击者滥用的。
共享库被编译为与位置无关的,因此可以很容易地重新定位到进程地址空间中。 共享库是动态 ELF 对象。 如果查看readelf -h lib.so
,您将看到e_type
(ELF 文件类型)被称为ET_DYN
。 动态对象与可执行文件非常相似。 它们通常没有PT_INTERP
段,因为它们是由程序解释器加载的,因此不会调用程序解释器。
当一个共享库被加载到进程地址空间时,它必须有任何满足引用其他共享库的重定位。 动态连接器必须修改可执行文件(位于.got.plt
节)的 GOT(全局偏移表),它是位于数据段中的地址表。 它在数据段中是因为它必须是可写的(至少在初始阶段; 请参阅作为安全特性的只读重定位)。 动态链接器用解析过的共享库地址给 GOT 打补丁。 稍后我们将解释延迟链接的过程。
当一个程序被sys_execve()
系统调用加载到内存中时,可执行文件被映射到一个堆栈中(在其他事情中)。 该进程地址空间的堆栈以一种非常特定的方式设置,以便将信息传递给动态连接器。 这种特殊的信息设置和排列被称为辅助载体或auxv。 堆栈的底部(这是它的最高内存地址,因为堆栈在 x86 架构下增长)被加载如下信息:
[argc][argv][envp][辅助的][。 argv/envp 的 ascii 数据
辅助向量(或 auxv)是一系列 ElfN_auxv_t 结构体。
typedef struct
{
uint64_t a_type; /* Entry type */
union
{
uint64_t a_val; /* Integer value */
} a_un;
} Elf64_auxv_t;
a_type
描述了 auxv 条目类型,a_val 提供了它的值。 以下是动态链接器需要的一些最重要的条目类型:
#define AT_EXECFD 2 /* File descriptor of program */
#define AT_PHDR 3 /* Program headers for program */
#define AT_PHENT 4 /* Size of program header entry */
#define AT_PHNUM 5 /* Number of program headers */
#define AT_PAGESZ 6 /* System page size */
#define AT_ENTRY 9 /* Entry point of program */
#define AT_UID 11 /* Real uid */
动态连接器从堆栈中检索有关正在执行的程序的信息。 链接器必须知道程序头在哪里,程序的入口点,等等。 我之前只列出了几个 auxv 条目类型,取自/usr/include/elf.h
。
辅助向量由驻留在 Linux 源代码/usr/src/linux/fs/binfmt_elf.c
中的名为create_elf_tables()
的内核函数设置。
实际上,内核的执行进程看起来如下所示:
sys_execve()
→。- 调用
do_execve_common()
→。 - 调用
search_binary_handler()
→。 - 调用
load_elf_binary()
→。 - 调用
create_elf_tables()
→。
下面是/usr/src/linux/fs/binfmt_elf.c
中create_elf_tables()
中添加 auxv 条目的一些代码:
NEW_AUX_ENT(AT_PAGESZ, ELF_EXEC_PAGESIZE);
NEW_AUX_ENT(AT_PHDR, load_addr + exec->e_phoff);
NEW_AUX_ENT(AT_PHENT, sizeof(struct elf_phdr));
NEW_AUX_ENT(AT_PHNUM, exec->e_phnum);
NEW_AUX_ENT(AT_BASE, interp_load_addr);
NEW_AUX_ENT(AT_ENTRY, exec->e_entry);
如您所见,ELF 入口点和程序头的地址,以及其他值,都与内核中的NEW_AUX_ENT()
宏一起放在堆栈上。
一旦一个程序被装入内存并且辅助向量已经被填充,控制就被传递给动态连接器。 动态链接器解析链接到进程地址空间的共享库的符号和重定位。 默认情况下,一个可执行文件与 GNU C 库libc.so
动态链接。 ldd
命令将显示给定可执行文件的共享库依赖项。
PLT(过程链接表)和 GOT(全局偏移表)可以在可执行文件和共享的库中找到。 我们将特别关注可执行程序的 PLT/GOT。 当程序调用共享库函数(如strcpy()
或printf()
,这些函数直到运行时才解析)时,必须存在动态链接共享库并将地址解析到共享函数的机制。 当动态链接的程序被编译时,它以一种特定的方式处理共享库函数调用,这与简单的call
指令到本地函数的方式大不相同。
让我们看一下给 libc 的电话。 so 函数fgets()
在 32 位编译的 ELF 可执行文件中。 我们将在我们的示例中使用 32 位可执行文件,因为与 GOT 的关系更容易可视化,因为没有使用 IP 相对寻址,而 64 位可执行文件中是这样的:
objdump -d test
...
8048481: e8 da fe ff ff call 8048360<fgets@plt>
...
地址0x8048360
对应于fgets()
的 PLT 项。 让我们看看这个地址在我们的可执行文件:
objdump -d test (grep for 8048360)
...
08048360<fgets@plt>: /* A jmp into the GOT */
8048360: ff 25 00 a0 04 08 jmp *0x804a000
8048366: 68 00 00 00 00 push $0x0
804836b: e9 e0 ff ff ff jmp 8048350 <_init+0x34>
...
因此,对fgets()
的调用导致 8048360,这是fgets()
的 PLT 跳转表条目。 正如我们所看到的,有一个间接跳转到前面反汇编代码输出中存储在0x804a000
的地址。 这个地址是一个 GOT(全局偏移表)条目,它保存着 libc 共享库中实际的fgets()
函数的地址。
然而,第一次调用函数时,动态链接器还没有解析它的地址,这时使用了默认的行为延迟链接。 延迟链接意味着动态链接器不应该在程序加载时解析每个函数。 相反,它将在调用函数时解析它们,这是通过.plt
和.got.plt
节实现的(分别对应 Procedure 链接表和 Global 偏移量表)。 可以将此行为更改为与LD_BIND_NOW
环境变量的严格链接,以便所有动态链接都在程序加载时发生。 延迟链接在加载时提高了性能,这就是为什么它是默认行为的原因,但它也可能是不可预测的,因为链接错误可能直到程序运行一段时间后才发生。 多年来,我自己只经历过一次。 还值得注意的是,一些安全特性,即只读重定位不能应用,除非启用了严格链接,因为.plt.got
节(以及其他部分)被标记为只读; 这只能发生在动态链接器完成补丁后,因此必须使用严格链接。
让我们来看看fgets()
的搬迁条目:
$ readelf -r test
Offset Info Type SymValue SymName
...
0804a000 00000107 R_386_JUMP_SLOT 00000000 fgets
...
R_386_JUMP_SLOT
是 PLT/GOT 表项的重定位类型。 在x86_64
上,称为R_X86_64_JUMP_SLOT
。
请注意,重定位偏移量是地址 0x804a000,与fgets()
PLT 跳转到的地址相同。 假设fgets()
是第一次被调用,动态连接器必须解析fgets()
的地址,并将其值放入fgets()
的 GOT 条目中。
让我们来看看我们的测试程序中的 GOT:
08049ff4 <_GLOBAL_OFFSET_TABLE_>:
8049ff4: 28 9f 04 08 00 00 sub %bl,0x804(%edi)
8049ffa: 00 00 add %al,(%eax)
8049ffc: 00 00 add %al,(%eax)
8049ffe: 00 00 add %al,(%eax)
804a000: 66 83 04 08 76 addw $0x76,(%eax,%ecx,1)
804a005: 83 04 08 86 addl $0xffffff86,(%eax,%ecx,1)
804a009: 83 04 08 96 addl $0xffffff96,(%eax,%ecx,1)
804a00d: 83 .byte 0x83
804a00e: 04 08 add $0x8,%al
地址0x08048366
是前面强调的,在 GOT 的0x804a000
中找到。 记住,小尾数反转了字节顺序,所以它显示为66 83 04 08
。 这个地址不是fgets()
函数的地址,因为它还没有被链接器解析,而是指向fgets()
的 PLT 条目。 让我们再来看看fgets()
的 PLT 条目:
08048360 <fgets@plt>:
8048360: ff 25 00 a0 04 08 jmp *0x804a000
8048366: 68 00 00 00 00 push $0x0
804836b: e9 e0 ff ff ff jmp 8048350 <_init+0x34>
因此,jmp *0x804a000
跳转到0x8048366
中包含的地址,即push $0x0
指令。 该推入指令有一个目的,就是将fgets()
的 GOT 条目推入堆栈。 fgets()
的 GOT 条目偏移量是 0x0,它对应于为共享库符号值保留的第一个 GOT 条目,它实际上是第四个 GOT 条目,GOT[3]。 换句话说,共享库地址并不从 GOT[0]开始插入,而是从 GOT3开始插入,因为前三个是为其他目的保留的。
请注意以下 GOT 补偿:
- GOT[0]包含一个指向可执行文件的动态段的地址,该地址由动态链接器用于提取与动态链接相关的信息
- GOT[1]包含动态链接器用来解析符号的
link_map
结构的地址 - GOT[2]包含动态链接器
_dl_runtime_resolve()
函数的地址,该函数解析共享库函数的实际符号地址
在fgets()
PLT 存根中的最后一条指令是 jmp 8048350。 这个地址指向每个可执行文件中的第一个 PLT 条目,称为 PLT-0。
PLT-0from 我们的可执行文件包含以下代码:
8048350: ff 35 f8 9f 04 08 pushl 0x8049ff8
8048356: ff 25 fc 9f 04 08 jmp *0x8049ffc
804835c: 00 00 add %al,(%eax)
第一条pushl
指令将第二个 GOT 条目(GOT[1])的地址推入堆栈,如前所述,栈中包含link_map
结构的地址。
jmp *0x8049ffc
对第三个 GOT 表项(GOT[2])执行一个间接的 jmp,该表项包含给动态连接器_dl_runtime_resolve()
函数的地址,因此将控制权转移给动态连接器并解析fgets()
的地址。 一旦fgets()
被解决,所有对 PLT 条目forfgets()
的未来调用都将导致跳转到fgets()
代码本身,而不是指向 PLT 并再次经历惰性链接过程。
以下是上文所述内容的摘要:
- 调用
fgets@PLT
(调用fgets
函数)。 - PLT 代码对 GOT 中的地址执行一个间接的
jmp
操作。 - GOT 条目包含指向
push
指令的 PLT 的地址。 push $0x0
指令将fgets()
GOT 条目的偏移量推入堆栈。- 最后一条
fgets()
PLT 指令是一个到 PLT-0 代码的 jmp。 - PLT-0 的第一条指令将 GOT[1]的地址推入栈中,栈中包含了
fgets()
的link_map
结构体的偏移量。 - PLT-0 第二指令是一个无条件转移指令的地址有[2]指向动态链接器的
_dl_runtime_resolve()
,然后处理R_386_JUMP_SLOT
搬迁通过添加符号价值(内存地址)fgets()
其相应的进入了.got.plt
部分。
下次调用fgets()
时,PLT 条目将直接跳转到函数本身,而不必再次执行重定位过程。
我在前面引用了动态段作为一个名为.dynamic
的节。 动态段有一个引用它的 section 头,但它也有一个引用它的程序头,因为它必须在运行时被动态链接器找到; 因为 section 头文件不会被加载到内存中,所以它必须有一个相关联的程序头文件。
动态段包含一个类型为ElfN_Dyn
的结构体数组:
typedef struct {
Elf32_Sword d_tag;
union {
Elf32_Word d_val;
Elf32_Addr d_ptr;
} d_un;
} Elf32_Dyn;
d_tag
字段包含一个标记,该标记与可以在 ELF(5)手册页中找到的众多定义之一相匹配。 我已经列出了一些最重要的动态链接器使用。
它保存到所需共享库的名称的字符串表偏移量。
它包含了动态符号表的地址,也就是它的 section 名.dynsym
。
它保存着符号哈希表的地址,也可以通过它的 section 名.hash
(或有时命名为.gnu.hash
)来知道。
它保存着符号字符串表的地址,也被它的 section 名.dynstr
所知。
它保存了全局偏移表的地址。
前面的动态标记演示了如何通过动态段找到某些部分的位置,该动态段可以帮助重新构建 section 头表的法医重建任务。 如果 section 头表已经被剥离,一个聪明的个体可以通过从动态段(也就是.dynstr、.dynsym 和.hash 等)获取信息来重新构建部分 section 头表。
其他段(如文本和数据)也可以生成您需要的信息(例如.text
和.data
节)。
ElfN_Dyn
中的d_val
成员持有一个整数值,该整数值具有多种解释,例如作为一个重定位表项的大小来给出一个实例。
d_ptr
成员保存一个虚拟内存地址,它可以指向连接器需要的各种位置; 一个很好的例子是d_tag``DT_SYMTAB
符号表的地址。
动态链接器利用ElfN_Dyn``d_tags
定位的不同部分动态段包含可执行的一部分通过引用d_tag``DT_SYMTAB
、d_ptr
的虚拟地址的符号表。
当动态连接器被映射到内存中,它首先处理任何自己的重定位,如果必要的话; 请记住,链接器本身就是一个共享库。 然后,它查看可执行程序的动态段,并搜索包含指向必要的共享库的字符串或路径名的指针的DT_NEEDED
标记。 当它将一个需要的共享库映射到内存中时,它访问库的动态段(是的,它们也有动态段),并将库的符号表添加到一个符号表链中,这个符号表链保存着每个映射库的符号表。
链接器为每个共享库创建一个结构体link_map
条目,并将其存储在一个链表中:
struct link_map
{
ElfW(Addr) l_addr; /* Base address shared object is loaded at. */
char *l_name; /* Absolute file name object was found in. */
ElfW(Dyn) *l_ld; /* Dynamic section of the shared object. */
struct link_map *l_next, *l_prev; /* Chain of loaded objects. */
};
一旦链接器完成了它的依赖列表的构建,它就会处理每个库上的重定位,类似于我们在本章前面讨论的重定位,并修复每个共享库的 GOT。 Lazy linking仍然适用于共享库的 PLT/GOT,所以 GOT 重定位(类型为R_386_JMP_SLOT
)在函数被实际调用之前不会发生。
要了解关于 ELF 和动态链接的更多详细信息,请在线阅读 ELF 规范或查看一些有趣的 glibc 源代码。 希望动态链接在这一点上不再那么神秘,而更具有诱惑力。 在第七章,进程内存取证中,我们将介绍重定向共享库函数调用的 PLT/GOT 中毒技术。 一个非常有趣的技巧是颠覆动态链接。
为了帮助总结我们所学到的一些内容,我包含了一些简单的代码,这些代码将打印 32 位 ELF 可执行文件的程序头和节名。 更多与 elf 相关的代码示例(以及更有趣的代码)将贯穿全书:
/* elfparse.c – gcc elfparse.c -o elfparse */
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <elf.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <stdint.h>
#include <sys/stat.h>
#include <fcntl.h>
int main(int argc, char **argv)
{
int fd, i;
uint8_t *mem;
struct stat st;
char *StringTable, *interp;
Elf32_Ehdr *ehdr;
Elf32_Phdr *phdr;
Elf32_Shdr *shdr;
if (argc < 2) {
printf("Usage: %s <executable>\n", argv[0]);
exit(0);
}
if ((fd = open(argv[1], O_RDONLY)) < 0) {
perror("open");
exit(-1);
}
if (fstat(fd, &st) < 0) {
perror("fstat");
exit(-1);
}
/* Map the executable into memory */
mem = mmap(NULL, st.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
if (mem == MAP_FAILED) {
perror("mmap");
exit(-1);
}
/*
* The initial ELF Header starts at offset 0
* of our mapped memory.
*/
ehdr = (Elf32_Ehdr *)mem;
/*
* The shdr table and phdr table offsets are
* given by e_shoff and e_phoff members of the
* Elf32_Ehdr.
*/
phdr = (Elf32_Phdr *)&mem[ehdr->e_phoff];
shdr = (Elf32_Shdr *)&mem[ehdr->e_shoff];
/*
* Check to see if the ELF magic (The first 4 bytes)
* match up as 0x7f E L F
*/
if (mem[0] != 0x7f && strcmp(&mem[1], "ELF")) {
fprintf(stderr, "%s is not an ELF file\n", argv[1]);
exit(-1);
}
/* We are only parsing executables with this code.
* so ET_EXEC marks an executable.
*/
if (ehdr->e_type != ET_EXEC) {
fprintf(stderr, "%s is not an executable\n", argv[1]);
exit(-1);
}
printf("Program Entry point: 0x%x\n", ehdr->e_entry);
/*
* We find the string table for the section header
* names with e_shstrndx which gives the index of
* which section holds the string table.
*/
StringTable = &mem[shdr[ehdr->e_shstrndx].sh_offset];
/*
* Print each section header name and address.
* Notice we get the index into the string table
* that contains each section header name with
* the shdr.sh_name member.
*/
printf("Section header list:\n\n");
for (i = 1; i < ehdr->e_shnum; i++)
printf("%s: 0x%x\n", &StringTable[shdr[i].sh_name], shdr[i].sh_addr);
/*
* Print out each segment name, and address.
* Except for PT_INTERP we print the path to
* the dynamic linker (Interpreter).
*/
printf("\nProgram header list\n\n");
for (i = 0; i < ehdr->e_phnum; i++) {
switch(phdr[i].p_type) {
case PT_LOAD:
/*
* We know that text segment starts
* at offset 0\. And only one other
* possible loadable segment exists
* which is the data segment.
*/
if (phdr[i].p_offset == 0)
printf("Text segment: 0x%x\n", phdr[i].p_vaddr);
else
printf("Data segment: 0x%x\n", phdr[i].p_vaddr);
break;
case PT_INTERP:
interp = strdup((char *)&mem[phdr[i].p_offset]);
printf("Interpreter: %s\n", interp);
break;
case PT_NOTE:
printf("Note segment: 0x%x\n", phdr[i].p_vaddr);
break;
case PT_DYNAMIC:
printf("Dynamic segment: 0x%x\n", phdr[i].p_vaddr);
break;
case PT_PHDR:
printf("Phdr segment: 0x%x\n", phdr[i].p_vaddr);
break;
}
}
exit(0);
}
下载示例代码
您可以从您的帐户http://www.packtpub.com下载您购买的所有 Packt Publishing 图书的示例代码文件。 如果您在其他地方购买这本书,您可以访问http://www.packtpub.com/support并注册,将文件直接通过电子邮件发送给您。
既然我们已经探索了 ELF,我强烈建议读者继续探索这种格式。 在本书中,你会遇到许多项目,希望它们能激励你这样做。 我花了多年的热情和探索才了解我所拥有的。 我很感激能够分享我所学到的东西,并以一种有助于读者以一种有趣和创造性的方式学习这一困难材料的方式呈现出来。***