程序如何运行:ELF二进制文件

程序如何运行:ELF二进制文件

前一篇系列文章描述了 Linux 内核在用户空间调用 execve() 时用于执行程序的通用机制。然而,那篇文章中描述的特定格式处理程序都将其执行过程推迟到一个内部调用 search_binary_handler()。这种递归几乎总是以调用 ELF 二进制程序为终结,这就是本文的主题。

ELF格式

ELF(可执行和可链接格式)是现代 Linux 系统中使用的的主要二进制格式,对其支持在文件fs/binfmt_elf.c中实现。它也是内核处理起来稍微复杂的格式;主要的 load_elf_binary() 函数跨越了 400多行,ELF 支持代码的大小是支持旧 a.out 格式代码的4倍以上。

一个可执行程序的 ELF 文件(而不是共享库或对象文件)必须始终在文件开始处的 ELF 头之后包含一个程序头表;这个表中的每个项都提供了运行程序所需的信息。

内核真正关心的是三种类型的程序头。

  • 第一种类型是 PT_LOAD 段,它描述了新程序运行内存中的区域。这包括来自可执行文件的代码和数据部分,以及 BSS 部分的长度。BSS 将被填充为零(因此只需要在可执行文件中存储其长度)。
  • 第二个是 PT_INTERP,它标识了汇编完整程序所需的运行时链接器;在讨论静态链接的 ELF 二进制之前,我们将暂时假设一个动态链接的二进制。
  • 最后,如果存在 PT_GNU_STACK,内核也会从该项得到一个比特的信息,指示程序堆栈是否应设置为可执行。

本文仅关注加载 ELF 程序所需的内容,而不是探讨格式的所有细节。感兴趣的读者可以通过 Wikipedia 的 ELF 文章中的参考链接或使用 objdump 工具探索实际二进制文件来获取更多信息。

处理ELF二进制文件

加载 ELF 二进制文件的任务由 load_elf_binary() 函数处理,该函数首先检查 ELF 头部,以确保所涉及的文件确实看起来像是一个受支持的ELF格式。处理程序需要整个 ELF 程序头,无论它是否在 linux_binprm 中读入buf的前128个字节内,因此需要将其读入一些临时空间。

现在代码遍历程序头,检查是否有一个解释器(PT_INTERP)以及程序的堆栈是否应可执行(来自PT_GNU_STACK)。做好这些准备工作后,代码需要初始化新程序的那些不是从旧程序继承的属性;单 UNIX 规范版本3(SUSv3)执行规范描述了大部分所需行为(Linux 编程接口的表28-4给出了涉及属性的出色总结)。

设置新程序的过程从调用 flush_old_exec() 开始,该函数清除内核中引用旧程序的状态。旧程序的任何其他线程都被杀死,使新程序从一个线程开始,并使进程的信号处理信息不再共享,以便以后可以安全地更改。清除旧程序的任何挂起的 POSIX 定时器,并更新程序的可执行文件位置(在 /proc/pid/exe 处可见)。释放旧程序的虚拟内存映射,这也将杀死任何挂起的异步I/O操作并释放任何 uprobes。最后,更新进程的个性,以删除可能影响安全的任何功能,如之前在 linux_binprm 中的 per_clear 字段中记录的那样。主处理程序代码还调用 SET_PERSONALITY() 宏,以便为新的64位程序适当地设置线程标志。

调用setup_new_exec()函数相应地设置新程序的内核内部状态。该函数首先确定新程序是否可以生成核心转储(或被ptrace()附加);默认情况下,setuidsetgid程序禁用此目标。当程序文件在当前凭证下不可读时,也会禁用转储。调用__set_task_comm()设置当前任务的comm字段为原始调用的文件名的基本名称;这个值用作线程名称,并通过PR_GET_NAMEPR_SET_NAME prctl()操作在用户空间中访问。调用flush_signal_handlers()设置新程序的信号处理程序;任何不是SIG_IGN的信号处理程序都设置为默认的SIG_DFL值(所以任何被忽略的信号都将被新程序继承)。最后,调用do_close_on_exec()关闭旧程序的所有设置了O_CLOEXEC标志的文件描述符;其他文件描述符将被新程序继承。

新程序的虚拟内存也需要设置。为了提高安全性(通过帮助防止栈溢出攻击),通常将堆栈的最高地址向下移动一个随机偏移量。首先调用setup_arg_pages()设置内核的内存跟踪结构,并调整为新堆栈的位置。代码遍历程序文件中的所有PT_LOAD段并将它们映射到进程的地址空间,设置新程序的内存布局。然后,设置与程序的BSS段对应的零填充页面。此外,还需要映射额外的特殊页面,例如虚拟动态共享对象(vDSO)页面,这通过调用arch_setup_additional_pages()进行处理。还可能因为在程序地址空间中的零地址处映射一个空页面,以实现向后兼容(旧SVr4程序显然假设从空指针读取将返回零而不是SIGSEGV)

接下来,通过调用install_exec_creds()设置新程序的凭证。这个函数让任何活动的 Linux 安全模块(LSM)知道凭证的变化(通过bprm_committing_credsbprm_committed_creds LSM钩子),内部的 commit_creds() 函数执行赋值。

运行新程序的最后准备工作是设置其堆栈的其余部分(位于新的随机位置),通过调用 create_elf_tables() 函数;这将在下面的单独部分中描述。

现在所有准备工作都已完成,可以启动新程序。前面的文章解释了在进入主内核代码之前,内核的系统调用入口点如何将用户空间CPU寄存器推送到内核堆栈,系统调用完成时,相应地恢复这些寄存器。保存寄存器的堆栈区域被转换为 pt_regs 结构,因此可以用适当的值(零)覆盖保存的用户空间 CPU 寄存器,以开始新程序。调用start_thread() 函数还将保存的指令指针设置为程序的入口点(或动态链接器),将保存的堆栈指针设置为当前堆栈的顶部(来自linux_binprm中的p字段)。处理程序返回的零返回码表示成功,execve()系统调用返回用户空间 - 但是返回一个完全不同的用户空间,其中进程的内存已经被重新映射,并且恢复的寄存器具有开始新程序执行的值。

填充堆栈:辅助向量、环境和参数

create_elf_tables() 函数在新程序的堆栈中添加更多信息,位于由通用代码添加的参数和环境信息以下,作为两个不同的块。首先调用arch_align_stack()将现有的堆栈位置对齐到16字节边界,并且还可能进一步稍微向下随机化堆栈位置。

首先收集一组信息,形成 ELF 辅助向量,它包含(id, value)对,描述有关正在运行的程序及其运行环境的有用信息,这些信息从内核传递到用户空间。为了构建此向量,处理程序代码首先需要将堆栈上任何不符合64位值的附加信息;对于 x86_64,这是平台能力描述(字符串”x86_64”)和16字节的随机数据(以帮助播种用户空间随机数生成器)。

接下来,代码在mm_struct中的saved_auxv空间中组装辅助向量的(id, value)对。Michael KerriskLWN 文章描述了此向量的内容,因此我们在这里仅提及一些有趣的条目:

  • 向量中第一个(架构特定)条目是 x86_64 的 AT_SYSINFO_EHDR 值;这表示 vDSO 页面的位置,如前文所引用。
  • AT_PLATFORM值是将之前推入的”x86_64”平台能力描述的位置。
  • AT_RANDOM值是将之前推入的随机数据的 location。
  • AT_EXECFN值保存先前作为堆栈上第一件事推入的程序文件名(其位置存储在 linux_binprmexec 字段中),位于参数和环境值之上。
  • AT_ENTRY值保存文本段的入口点,即程序执行应开始的位置。

创建此辅助向量后,代码现在开始组装新程序的其余堆栈。计算所需空间,然后从低地址插入到高地址:

  • 首先插入 argc 参数计数。
  • 接下来插入一个参数指针数组,以 NULL 指针结束。这是 main()argv 最终指向的位置。
  • 接下来插入一个环境指针数组,以 NULL 指针结束。这是 environ 将指向的位置。
  • 将辅助向量放在最高地址,紧低于其引用的附加值。
    将这些信息组合在一起,新程序的地址空间顶部将具有类似于以下示例的内容(此页面具有类似的示例):

linux-elf-sample

注意:尽管堆栈布局中有两个随机化(内存顶部位置和参数值与辅助向量之间的间隔大小),但新运行的程序仍然可以弄清楚堆栈上所有信息的存放位置。SP寄存器告诉程序堆栈顶部在哪里(即最低地址),命令行参数从那里向上排列在内存中,用NULL指针标记它们的结束位置。接下来是环境值,同样用NULL指针终止,辅助向量在紧邻的地址中,以AT_NULL ID结束。在这些信息中找到的值给出了参数字符串、环境字符串和辅助数据值的地址,因此不需要关于随机间隔大小的显式信息。

动态链接程序

到目前为止,我们假设正在执行的程序是静态链接的,并跳过了由 ELF 程序头中的PT_INTERP条目触发的步骤。然而,大多数程序都是动态链接的,这意味着在运行时需要找到并链接所需的共享库。这是由运行时链接器(通常类似于/lib64/ld-linux-x86-64.so.2)完成的,并且此链接器的标识由PT_INTERP程序头条目指定。

为了处理运行时链接器,ELF 处理程序首先将 ELF 解释器文件名读入临时空间,然后使用open_exec()打开可执行文件。文件的前128字节被读入bprm->buf临时区域,替换原始程序文件的内容,从而允许访问解释器程序的ELF头 - 因此,解释器程序本身必须是一个ELF二进制文件,而不是其他任何格式。

在将程序代码加载到内存中如前所述后,ELF 处理程序还使用 load_elf_interp() 将 ELF 解释器程序加载到内存中。这个过程与加载原始程序的过程类似:代码检查 ELF 头中的格式信息,读取 ELF 程序头,将文件中的所有PT_LOAD段映射到新程序的内存中,并为解释器的BSS段留出空间。

程序的执行起始地址也设置为解释器的入口点,而不是程序本身的入口点。当execve()系统调用完成时,执行将从 ELF 解释器开始,它负责从用户空间满足程序的链接要求 - 找到并加载程序依赖的共享库,并将程序的不定义符号解析为这些库中的正确定义。一旦完成此链接过程(这依赖于比内核更深入的 ELF 格式理解),解释器可以开始执行新程序本身,从之前在AT_ENTRY辅助值中记录的地址开始。

与其他架构的兼容性

如前所述,现代64位(x86_64)Linux系统还可以支持运行两种类型的32位二进制文件:正常32位二进制文件(x86_32)和x32 ABI程序(可以利用额外的x86_64寄存器)。那么内核如何支持这些二进制文件呢?

提供这些格式支持的关键文件是compat_binfmt_elf.c,当CONFIG_COMPAT_BINFMT_ELF配置选项设置时,此文件包含在内核中。这个文件没有出现在我们之前列出的注册二进制处理程序的地方,因为文件中几乎没有自己的代码。相反,它包括主要的binfmt_elf.c EL处理程序代码(使用#include),并使用预处理器将各种内部函数和值重定向到32位兼容版本。除了这些更改之外,格式处理程序的行为与上述正常的EL处理程序相同。

一组更改使用描述ELF文件布局的32位结构版本;类似地,使用32位二进制文件的适当常量值,确保兼容处理程序只声称支持相关的ELF二进制类型。特别是,elf_check_arch()调用被替换为compat_elf_check_arch()版本,该版本检查 x86_32 或(如果配置)x32。

预处理器更改还重定向了ELF处理程序代码的一些内部功能。SET_PERSONALITY()宏的调用重定向到set_personality_ia32(),以便为32位架构设置相关的线程标志,同样,arch_setup_additional_pages()函数被替换为设置32位vDSO的版本。更值得注意的是,start_thread()函数被替换为compat_start_thread(),映射到start_thread_ia32()。这改变了传递给内部start_thread_common()函数的参数,以便与x86_64二进制文件(以及调整ELF_PLAT_INIT()宏以匹配)不同地初始化保存的段寄存器。

尾声

每个在Linux系统上运行的程序都通过 execve() 的门户;因此,它是一个值得深入了解的关键内核功能。尽管内核原生支持脚本和其他机器码格式程序,但在现代 Linux 系统上,程序执行最终涉及运行 ELF 二进制文件。ELF 是一个复杂的格式,但幸运的是,内核可以忽略 ELF 的复杂性 - 它只需要理解足够的 ELF 来将段加载到内存中,并调用用户空间运行时链接器程序来完成组装完整运行程序的工作。

未完待续

以上内容就是对《程序如何运行:ELF二进制文件》全部内容,希望以上内容对同学们能有所启发和帮助。

如果您喜欢这篇文章,欢迎关注微信公众号《猿禹宙》、点赞、转发和赞赏。每一位读者的认可都是我持续创作的动力。

公众号