购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

第2章
ELF文件格式的内部结构

本章可作为了解基本编译过程和ELF文件格式内部结构的参考。如果你已经熟悉了它的概念,可以跳过这一章,把它作为你在分析过程中可能需要的参考。

2.1 程序结构

在深入研究汇编指令和如何对程序二进制文件进行逆向工程之前,首先有必要了解一下这些程序二进制文件最初来自哪里。

程序最初是由软件开发人员编写的源代码。源代码向计算机描述程序应该如何执行以及在各种输入条件下程序应该进行哪些计算。

程序员使用的编程语言在很大程度上是程序员的偏好选择。有些语言很适合解决数学和机器学习问题。有些语言为网站开发或构建智能手机应用程序进行了优化。而像C和C++这样的语言足够灵活,可用于各种可能的应用类型,包括设备驱动程序、固件等低级系统软件,系统服务以及视频游戏、网络浏览器和操作系统等大型应用程序。因此,我们在二进制分析中遇到的许多程序都是从C/C++代码开始的。

计算机不能直接执行源代码文件。在程序可以运行之前,必须首先被翻译成处理器知道如何执行的机器指令。执行这种翻译的程序被称为编译器。在Linux上,GCC是一个常用的编译器集合,包括一个C编译器,用于将C代码转换为Linux可以直接加载和运行的ELF二进制文件。g++是编译C++代码的编译器。图2.1显示了编译概述。

图2.1 编译概述

从某种意义上说,逆向工程是在执行编译器的逆向任务。在逆向工程中,我们从程序的二进制文件开始逆向处理,尝试以更高级的语言方式让程序员了解程序执行的流程。因此,了解ELF文件格式的组成部分及其作用对逆向工程是很有帮助的。

2.2 高级语言与低级语言

C和C++通常被描述为高级语言,因为它们允许程序员定义程序的结构和行为,而不直接参考机器体系结构本身。程序员可以使用抽象的编程概念来编写C/C++代码,例如使用 if-else 块、 while 循环和程序员命名的局部变量,而不必考虑这些变量最终将如何映射到机器寄存器、内存位置或生成的代码中的具体机器指令。

这种抽象通常对程序员非常有益。这种抽象和高级程序流程概念通常使得用C/C++编程比直接用汇编代码编写同等程序要快得多,错误也少得多。此外,由于C和C++与特定的机器体系结构没有强耦合关系,因此可以将相同的C/C++代码编译到不同的目标处理器上运行。

C++和C的区别是它添加了大量新的语法、编程特性和高级抽象,从而使编写大规模程序更加容易和快速。例如,C++为面向对象的编程增加了直接的语言支持,并使构造函数、析构函数和对象创建功能直接成为语言本身的组成部分。C++还引入了编程抽象,如接口、C++异常和运算符重载,并通过更强大的类型检查系统和模板支持,引入了额外的程序正确性编译时检查,这在原来的C编程语言中是不可能的。

按照惯例,C和C++程序从 main 函数开始其核心程序逻辑。这个函数通常处理程序的命令行参数,为程序的执行做准备,然后开始执行核心程序逻辑本身。对于命令行程序来说,这可能涉及处理文件和输入/输出流。图形化程序也可以处理文件和输入流,但通常还会创建窗口来将图形绘制到屏幕上以供用户交互,并设置事件处理程序以响应用户输入。

与C和C++这样的高级语言不一样,程序员也可以选择使用低级“汇编语言”来编写代码。这些汇编语言与它们所针对的目标处理器紧密耦合,但可以让程序员更加灵活地指定处理器应该运行哪些机器指令以及以哪种顺序运行。

除了个人喜好之外,程序员选择用低级语言编写全部或部分程序的原因有很多。表2.1给出了一些低级语言的使用案例。

表2.1 汇编语言编程的使用案例

(续)

在了解低级语言如何汇编之前,我们先看看编译器如何将用C/C++等高级语言编写的程序转换成低级汇编代码。

2.3 编译过程

编译器的核心工作是将用C/C++这样的高级语言编写的程序转换成等效的低级语言(如作为Armv8-A架构 中的A64指令集)程序。我们来看一个用C语言编写的简单示例程序:

在Linux上,常用的C编译器是GCC(GNU Compiler Collection)。默认情况下,GCC不仅将C程序编译成汇编代码,还管理整个编译过程,将编译结果链接起来,最终得到ELF程序的二进制文件,该文件可由操作系统直接执行。我们可以通过以下命令行调用GCC,从源代码中创建程序二进制文件:

我们还可以使用 -v 指令来指导GCC编译器驱动程序,使其向我们提供幕后的细节,如下所示:

这个命令的输出内容很多,但如果我们查看输出的末尾,便可以看到在编译过程的最后阶段,GCC在一个发送到临时位置的汇编文件上调用了汇编器,如下所示:

这是因为GCC是一个编译器的集合。C语言编译器本身将C语言代码转换为汇编代码清单,然后将其发送到汇编器转换为目标文件,最终链接到目标二进制文件中。

我们可以通过命令行选项 -S 拦截汇编代码清单,查看编译器本身正在生成的内容,例如,调用 gcc main.c -S 。GCC将把 main.c 中的程序编译成一个汇编代码清单,并将其写入 main.s 文件。

由于C++在大多数情况下是C语言的超集,因此我们也可以把这个示例当作C++代码来编译。在这里,我们使用C++编译器g++,通过命令行将代码编译成目标二进制文件:

我们还可以通过命令行选项 -S ,即通过命令 g++main.cpp -S ,指示g++输出其汇编代码清单。

如果我们允许GCC运行完成,它最终会输出一个可执行的ELF文件,该文件可以直接从命令行执行。例如,我们可以用 Arm-devs reverse-engineers 这两个命令行选项来运行该程序,该程序会将其输出内容输出到控制台,如下所示:

2.3.1 不同架构的交叉编译

用C/C++这样的高级语言编写程序的主要益处是,源代码在默认情况下不会与特定的处理器架构强耦合。这使得同一个程序的源代码可以被编译到不同的目标平台上运行。在其默认配置中,GCC和g++将创建目标二进制文件,它们运行于我们正在编译的同一机器架构上。例如,如果我们在64位的Arm Linux机器上运行 gcc main.c -o example. so ,产生的 example.so 二进制文件只能作为在64位Arm机器上运行的ELF二进制文件。如果我们在x86_64的Linux机器上运行同样的命令,得到的二进制文件将只能运行在x86_64机器上。

查看ELF二进制文件所针对的架构的一种方法是通过 file 命令,如下所示:

通常情况下,生成与正在运行的系统相匹配的程序二进制文件是一个有用的功能——我们通常希望编译器生成的二进制文件能够立即在我们的开发机器上运行。但是,如果开发机器与目标机器的架构不一样呢?例如,如果开发机器是基于x86_64的,但我们想创建一个专门在64位Arm处理器上运行的目标二进制文件,该怎么办?对于这些情况,我们需要使用交叉编译器。

表2.2中列出的软件包是最常用的GCC和g++的Arm交叉编译器,用于创建可以在基于Arm的32位和64位Linux机器上运行的二进制文件。

表2.2 GCC和g++的Arm交叉编译器

在使用 apt-get 作为主软件包管理器的系统上,我们可以通过以下命令安装这些Arm交叉编译器:

安装了这些交叉编译器后,我们就可以直接从运行不同架构的开发机器上生成32位和64位Arm二进制文件。为此,我们用特定目标的交叉编译器替换 gcc 。例如,一台x86_64机器可以从C或C++代码创建一个64位Arm二进制文件,如下所示:

我们可以用类似的方法针对32位Arm系统创建目标二进制文件,只需使用32位Arm交叉编译器,如下所示:

如果我们使用命令 file 检查这些输出二进制文件,便可以看到它们分别是为64位和32位Arm架构编译的程序二进制文件。

2.3.2 汇编和链接

编译器和手工编写汇编代码的程序员创建的汇编代码清单作为汇编器的输入。汇编器的工作是将人类可读的机器指令描述转换为与其等效的二进制编码指令,并按照程序员或编译器的手动指示将程序的数据和元数据输出到程序二进制文件的其他部分。汇编器的输出是一个目标文件,目标文件被编码为ELF文件,最好将这些目标文件视为部分ELF文件,需要通过最终链接过程将它们组合成一个整体,以创建最终的可执行目标二进制文件。

按照惯例,汇编代码写在.s文件中,可以使用汇编器将这些文件汇编成一个目标文件,比如使用GNU汇编器(GAS),它是GCC/g++工具套件的一部分。

在本书后面的章节中,我们将看到Armv8-A架构上有哪些指令,以及它们如何工作。然而,现在,定义几个模板汇编程序是很有用的,你可以用它来创建基本的汇编程序。

下面的程序是一个简单的汇编程序,它使用 write() 系统调用来输出一个字符串并退出。前三行定义了程序的架构、节和全局入口点。 write() 函数需要三个参数:文件描述符、指向存储数据(如字符串)的缓冲区的指针,以及要从缓冲区写入的字节数。这些参数都在前三个寄存器 x0 x1 x2 中指定。寄存器 x8 应该包含 write() 系统调用的系统调用号, SVC 指令会调用它。 ascii 字符串可以放在 .text 节的末尾(在所谓的字面量池中)或在 .data rodata 节中。

也可以使用库函数来实现同样的结果。下面的程序都执行相同的基本任务:一个用于64位Arm,另一个用于32位Arm。它们都在生成的ELF文件的 .text 节中定义了一个_ start 函数,并将一个以零结尾的字符串 Hello world\n 放置在生成的二进制文件的 .rodata (只读数据)节中。这两种情况下的 main 函数都将这个字符串的地址加载到一个寄存器中,调用 printf 将字符串输出到控制台,然后调用 exit(0) 来退出该程序。

如果开发机器与目标架构相匹配,则可以直接使用 as 命令汇编这些程序,如下所示:

如果开发机器与目标架构不匹配,则可以使用GCC的交叉编译器版本的 as

尝试直接运行目标文件通常不能成功。首先,我们必须链接二进制文件。在GCC套件中,链接器二进制文件被称为 ld (或 aarch64-linux-gnu-ld arm-linux-gnueabihf-ld ,视情况而定)。我们必须向链接器提供所有的目标文件以创建一个完整的程序二进制文件,然后用 -o 选项指定链接器的输出文件。

对于 write64.s 程序,我们只需要一个名为 write64.o 的目标文件,无须指定任何额外的库就可以直接运行。

当汇编程序使用特定的库函数,而不是直接使用系统调用时,它需要包含必要的目标文件。

对于 printf64.s 示例,我们指定 print64.o 为输入目标文件,但在程序运行之前,它还需要包含其他几个目标文件。一个是 libc.so ,所以我们的程序可以访问libc库中的函数 printf exit 。此外,它还需要三个目标文件,它们共同构成了C语言的运行时库,需要在调用 main 函数之前引导进程。表2.3描述了程序所需要的目标文件的依赖关系。

表2.3 所需目标文件及它们的作用

因此,最终的链接器命令行如下所示:

由此产生的目标二进制文件print64.so就可以在64位Arm机器上运行了:

2.4 ELF文件概述

编译和链接过程的最终输出是一个可执行和可链接格式(Executable and Linkable Format,ELF)文件,它包含操作系统和加载器加载并运行程序所需的所有信息。在最抽象的层面上,ELF文件可以被视为描述程序及其运行方式的表集合。在ELF中,存在三种类型的表:ELF文件头(位于文件开头)、程序头和节头(描述如何将ELF程序加载到内存中),以及ELF文件的逻辑节(告诉加载器如何准备执行)。

2.5 ELF文件头

在ELF文件的开头是ELF文件头。ELF文件头描述了程序的全局属性,如运行程序的架构、程序入口点以及文件中其他表的指针和大小。

给定一个ELF文件,例如2.3.2节中的 print32.so print64.so 程序,我们可以用 readelf 这样的程序查看这些属性和节。ELF文件头可以通过 readelf -h 参数来查看,如下所示:

ELF文件头分为四个主要组成部分:ELF文件头信息字段、目标平台字段、程序入口点字段和表位置字段。

2.5.1 ELF文件头信息字段

ELF文件头信息字段告诉加载器这是什么类型的ELF文件,并从 magic 字段开始。 magic 字段是一个常量16字节二进制模式——称为标识模式,表明该文件本身是一个有效的ELF文件。它始终以相同的4字节序列开头,从 0x7f 字节开始,然后是对应于ASCII字符 ELF 的3个字节。

class 字段告诉加载器ELF文件本身是否使用32位或64位ELF文件格式。通常情况下,32位程序使用32位文件格式,而64位程序使用64位文件格式。在我们的例子中,我们可以看到Arm上的程序就是这种情况:32位Arm二进制文件使用32位ELF文件格式,而64位二进制文件使用64位格式。

data 字段告诉加载器应该以大端序(big-endian)或小端序(little-endian)读取ELF文件的字段。Arm上的ELF文件通常对ELF文件格式本身使用小端序编码。我们将在本书后面看到端序是如何工作的,以及处理器如何在小端序和大端序模式之间动态地交换。现在只需要知道这个字段只改变了操作系统和加载器读取ELF文件结构的方式,这个字段并不改变处理器在运行程序时的行为。

最后, version 字段告诉加载器,我们正在使用第一个ELF文件版本格式。这个字段的设计是为了保证ELF文件格式在未来的兼容性。

2.5.2 目标平台字段

目标平台字段告诉加载器ELF文件在哪种类型的机器上运行。

machine 字段告诉加载器该程序在哪种类型的处理器上运行。我们的64位程序将这个字段设置为 AArch64 ,表示ELF文件将只在64位Arm处理器上运行。我们的32位程序指定为 ARM ,这意味着它将只在32位Arm处理器上运行,或者作为一个32位进程在64位Linux机器上使用处理器的32位AArch32执行模式。

flags 字段指定了加载器需要的额外信息,这是一个特定于架构的结构。例如,在我们的64位程序中,没有定义特定架构的标志,这个字段将始终保持值为0。相比之下,对于我们的32位Arm程序,这个字段通知加载器,该程序被编译为使用嵌入式ABI(EABI)配置文件版本5,并且该程序期望对浮点运算的硬件支持。Arm规范定义了4个Arm专用的值,它们可以放在ELF程序头 e_flags 字段中,如表2.4所示。

表2.4 Arm 32位e_flags值

①https://wiki.debian.org/ArmHardFloatPort

最后, type 字段指定了ELF文件的目的。在这种情况下, type 字段指定这些程序是动态链接的二进制文件,系统加载器可以准备并执行它们。

2.5.3 程序入口点字段

程序入口点字段告诉加载器程序的入口点在哪里。当操作系统或加载器在内存中准备好程序并准备开始执行时,这个字段指定程序的启动地址。

尽管按照惯例,C和C++程序从 main 函数处“开始”,但程序实际上并不从这里开始执行。它们从一个小的汇编代码存根(传统上在名为 _start 的符号处)中开始执行。当链接标准的C运行时库时, _start 函数通常是一个小的代码存根,它将控制权传递给libc辅助函数 __libc_start_main 。然后,这个函数为程序的 main 函数准备参数并调用它, main 函数将会运行程序的核心逻辑,如果 main 函数返回到 __libc_start_main ,则 main 函数的返回值就会被传递给 exit 以正常退出程序。

2.5.4 表位置字段

表位置字段对二进制分析员来说一般是不感兴趣的,除非你想编写代码来手动解析ELF文件。它们向加载器描述了文件中程序头和节头的位置和数量,并为包含字符串表(string table)和符号表(symbol table)的特殊节提供指针,我们将在后面介绍。加载器使用这些字段来准备内存中的ELF文件,以备执行。

2.6 ELF程序头

程序头(program header)表实际上描述了如何有效地将ELF二进制文件加载到内存中,以便加载器进行加载。

程序头与节头的不同之处在于,尽管它们都描述了程序的布局,但程序头是以映射为中心的,而节头则以更细粒度的逻辑单元来描述。程序头定义了一系列的段(segment),每个段都告诉内核如何启动程序。这些段指定了如何以及从哪里将ELF文件的数据加载到内存中、程序是否需要运行时加载器来引导它、主线程的线程本地存储的初始布局,以及其他与内核相关的元数据,如程序是否应该被赋予可执行线程栈。

我们先用 readelf 命令看一下64位 print64.so 程序的程序头:

这个程序有9个程序头,每个程序头都有一个相应的类型,如 PHDR INTERP ,每个类型都描述了如何解释程序头。节到段的列表显示了每个给定段(segment)内包含哪些逻辑节(section)。例如,这里我们可以看到 INTERP 段只包含 .interp 节。

2.6.1 PHDR程序头

PHDR (Program HeadDeR,HeadDeR程序)是包含程序头表和元数据本身的meta段。

2.6.2 INTERP程序头

INTERP 程序头用来告诉操作系统,ELF文件需要另一个程序的帮助来把自己载入内存。在几乎所有的情况下,这个程序将是操作系统的加载器文件,它的路径是 /lib/ld-linux-aarch64.so.1

当一个程序被执行时,操作系统使用这个程序头将支持的加载器加载到内存中,并将加载器而不是程序本身安排为初始执行目标。如果程序使用动态链接的库,则必须使用外部加载器。外部加载器管理程序的全局符号表,处理将二进制文件连接在一起的过程(称为重定位),并在准备就绪时最终调用程序的入口点。

除了加载器之外,几乎所有复杂程序都会使用该字段来指定系统加载器。 INTERP 程序头只与程序文件本身相关,对于在初始程序加载期间或在程序执行期间动态加载的共享库,该值会被忽略。

2.6.3 LOAD程序头

LOAD 程序头告诉操作系统和加载器如何尽可能高效地将程序的数据加载到内存中。每个 LOAD 程序头都指示加载器创建一个具有给定大小、内存权限和对齐标准的内存区域,并告诉加载器文件中的哪些字节要放在该区域中。

如果我们再看一下前面例子中的 LOAD 程序头,便可以看到程序定义了两个内存区域,要用ELF文件中的数据来填充。

第一个内存区域的长度为 0xa3c 字节,具有64 KB的对齐要求,被映射为可读、可执行但不可写。这个区域应该用ELF文件本身的0到 0xa3c 字节填充。

第二个内存区域的长度为 0x290 字节,应该被加载到第一节之后的 0x10db8 字节的位置,应该被标记为可读和可写,并将从文件中的偏移量 0xdb8 开始被填充 0x288 字节。

值得注意的是, LOAD 程序头不一定要用文件中的字节来填充其定义的整个区域。例如,我们的第二个 LOAD 程序头只填充了 0x290 大小的区域的前 0x288 字节。剩下的字节将被填充为零。在这种特殊情况下,最后的8个字节对应于二进制文件的 .bss 节,编译器使用这种加载策略在加载过程中将该节预置为零。

LOAD 段从根本上说是帮助操作系统和加载器将数据从ELF文件中高效地加载到内存中,并且它们与二进制文件的逻辑节进行粗略映射。例如,如果我们再次查看之前的 readelf 输出,则可以看到两个 LOAD 程序头中的第一个将加载与ELF文件的17个逻辑节相对应的数据,包括只读数据和程序代码,而第二个 LOAD 程序头指示加载器加载剩余的7个节,包括负责全局偏移表的节以及 .data .bss 节,如下所示:

2.6.4 DYNAMIC程序头

DYNAMIC 程序头被加载器用于动态链接程序和它们的共享库依赖项,以及在程序被加载到与预期不同的地址时对程序应用重定位功能以修复程序代码和指针。我们将在本章后面讨论 dynamic 节以及链接和重定位过程。

2.6.5 NOTE程序头

NOTE 程序头用来存储关于程序本身的供应商元数据。该节基本上描述了一个键值对表,其中每个条目都有一个字符串名称映射到描述该条目的字节序列上 。ELF手册文件 中给出了一系列众所周知的 NOTE 值及其含义。

我们还可以使用 readelf 来查看给定ELF文件中 NOTE 条目的可读描述。例如,我们可以在我们的 print64.so 文件中这样做,如下所示:

在这里,我们可以看到可执行文件的 NOTE 条目描述了程序期望使用的GNU ABI版本(在本例中为Linux ABI 3.7.0),以及分配给二进制文件的唯一构建ID值,通常用于将崩溃转储与导致它们的二进制文件相关联,以便对崩溃进行诊断和分析

2.6.6 TLS程序头

另一个常见的程序头是 TLS 程序头。 TLS 程序头定义了TLS条目表,该表存储了程序所使用的线程局部变量的信息 。线程本地存储是一个更高级的主题,详见2.9节。

2.6.7 GNU_EH_FRAME程序头

这个程序头定义了程序的栈展开表在内存中的位置。栈展开表既被调试器使用,也被C++异常处理运行时函数使用,这些函数被负责处理C++ throw 关键字的例程在内部使用。这些例程也处理 try...catch...final 语句,在保持C++自动销毁器和异常处理语义的同时展开栈。

2.6.8 GNU_STACK程序头

处理器没有提供可用于阻止程序指令在内存区域内执行的不执行内存保护。这意味着代码可以被写入栈并直接执行。在实践中,很少有程序会合法地这样做。相比之下,黑客通常会利用程序中的内存损坏漏洞,并利用可执行的栈区域直接从栈中执行特别制作的指令。

引入32位和64位Arm处理器以及其他制造商的处理器支持的不执行(No-eXecute,NX)内存权限,意味着有可能将栈明确标记为不执行区域,从而阻止这些类型的攻击。在Arm处理器中,这种缓解措施由XN(eXecute Never,绝不执行)位控制。如果启用(设置为1),则尝试在该不可执行区域中执行指令将导致权限故障

不幸的是,Linux的问题是虽然很少有程序合法地将可执行指令写入栈以供执行,但实际仍存在这种情况,这会导致应用程序兼容性问题。操作系统不能默认强制设置栈为不可执行(NX),否则将破坏需要可执行栈的少数程序。

解决这个问题的方法是使用 GNU_STACK 程序头。 GNU_STACK 程序头的内容本身可被忽略,但程序头的内存保护字段被用来定义程序的线程栈将被授予的内存保护。这使得大多数从不运行线程栈代码的程序可以告诉操作系统,将程序的线程栈标记为不可执行 是安全的。

链接器 LD 负责创建 GNU_STACK 程序头,因此当通过GCC编译程序时,我们可以通过GCC命令行选项来设置栈是否可执行。使用选项 -z noexecstack 可以禁用可执行栈,使用 -z execstack 可以手动将栈强制分配为可执行栈。

为了看到这是如何工作的,我们故意使用可执行栈重新编译程序,然后使用 readelf 查看 GNU_STACK 程序头,如下所示:

我们可以通过查看进程的内存映射来查看当前正在运行的程序的这种行为的效果。使用之前的示例程序实现这种行为有点困难,因为它们在启动后很快就退出了,但我们可以使用以下两行代码的程序,它只是永久休眠,以便我们可以在运行时检查其内存,而不必使用调试器:

如果我们使用 -z execstack 选项编译此程序,运行此程序时应将栈标记为可执行。首先,我们编译该程序:

接下来在另一个终端窗口中使用 ./execstack.so 运行该程序,并使用另一个终端窗口来查找该程序的进程ID。一个简单的命令是 pidof 命令:

现在我们知道了正在运行的程序的进程ID,我们可以通过伪文件 /proc/pid/maps 查看其内存映射,在本例中该伪文件是 /proc/7784/maps 。这里给出了这个文件的输出结果(考虑到可读性,行已略微缩短):

我们可以看到这里栈的权限被标记为 rwx ,这意味着栈是可执行的。如果我们省略 -z execstack 编译器选项,重复之前的步骤,我们将看到栈被标记为 rw -(即不可执行),如下所示:

检查短暂程序的内存比较困难。对于这类情况,我们需要使用调试器(例如GDB),并使用其 info proc mappings 命令来查看进程运行时的内存。

2.6.9 GNU_RELRO程序头

GNU_STACK 一样, GNU_RELRO 程序头用作编译器的漏洞利用缓解措施。RELRO(Relocation Read-Only)的主要目的是指示加载器在程序加载后但开始运行前将程序二进制文件的某些关键区域标记为只读,以阻止漏洞利用者轻而易举地改写它们所包含的关键数据。RELRO用于保护全局偏移表(Global Offset Table,GOT),以及包含函数指针的 init fini 表,程序将在程序的 main 函数运行之前以及在最后调用 exit 期间(或在 main 返回后)运行这些表。

RELRO程序头的具体机制很简单。它定义了一个内存区域和一个最终应用的内存保护机制,该保护机制应该在程序做好运行准备后通过 mprotect 调用来实现。我们再次使用 readelf 查看程序头,看看它们如何应用于RELRO程序头。

如果我们看一下节到段的映射,便可以看到这里RELRO要求加载器在程序启动前将二进制文件的 .init_array .fini_array .dynamic .got 节标记为只读,分别保护程序初始化器、非初始化器、整个 .dynamic 节和全局偏移表。如果程序还定义了TLS数据,那么 .tdata 节的TLS模板数据通常也会被RELRO区域所保护。

RELRO缓解措施有两种:部分RELRO和完整RELRO 。可以通过表2.5所示的命令行选项指示链接器启用部分RELRO、启用完整RELRO,甚至禁用RELRO。

表2.5 RELRO选项

部分RELRO和完整RELRO的主要区别在于,部分RELRO不保护全局偏移表中负责管理程序链接表的部分(通常称为 .plt.got ),该部分用于惰性地绑定导入的函数符号。完整RELRO强制对所有库函数调用进行加载时绑定,因此可以将 .got .got.plt 节都标记为只读。这可以防止一种常见的控制流漏洞利用技术,该技术通过覆盖 .got.plt 节的函数指针来重定向程序的执行流,但同时也会稍微降低大型程序的启动性能。

我们可以使用命令行工具,如开源的 checksec.sh 工具(包含在Fedora中) ,通过以下语法来检查是否在给定的程序二进制文件上启用了完整RELRO、部分RELRO或完全禁用RELRO:

2.7 ELF节头

程序头是ELF文件的一个以数据为中心的视图,它告诉操作系统如何有效地将程序直接放入内存,与此相反,节头将ELF二进制文件分解为逻辑单元。ELF程序头指定了ELF文件中节头表的数量和位置。

我们可以使用 readelf 工具查看给定二进制文件节头的信息,如下所示:

另一种以更易读的格式查看这些带标志的节头的方法是使用 objdump 实用程序(考虑到可读性,此处输出已被截断,只显示基本部分)。

与程序头类似,我们可以看到每个节头都描述了加载的二进制文件中的一个内存区域,它由地址和区域大小定义。每个节头还有一个名称、一个类型,以及可选的一系列辅助标志字段,它们描述如何解释节头。例如, .text 节被标记为只读代码,而 .data 节被标记为数据,既不是代码也不是只读数据,因此被标记为读/写。

其中一些节与程序头等效项一一对应,这里不再赘述。例如, .interp 节只包含程序头 INTERP 使用的数据,而 NOTE 节是 NOTE 程序头的两个条目。

其他节(如 .text .data .init_array )描述程序的逻辑结构,并由加载器在执行前用于初始化程序。接下来,我们将介绍在逆向工程中遇到的最重要的ELF节以及它们的工作原理。

2.7.1 ELF meta节

二进制文件有两个节是meta节,它们对ELF文件有特殊的意义,并被用于其他节表的查询。它们是字符串表和符号表,前者定义了ELF文件使用的字符串,后者定义了其他ELF节引用的符号。

2.7.1.1 字符串表

首先要介绍的是字符串表(string table)。字符串表定义了ELF文件所需的所有字符串,但通常不包含程序所使用的字符串字面量。字符串表是ELF文件所使用的所有字符串的直接串联,每个字符串以终止零字节结尾。

字符串表被ELF文件中具有字符串字段的结构所使用。这些结构通过字符串表的偏移来指定字符串的值,节表就是这样的结构。每个节都有一个名称,比如 .text .data 或者 .strtab 。例如,如果字符串 .strtab 在字符串表中的偏移量为67,那么 .strtab 节的节头将在其 name 字段中使用数字67。

在某种程度上,这给加载器创建了一个“鸡生蛋”问题。如果加载器在知道字符串表的位置之前不能检查各节的名称,它怎么能知道哪一节是字符串表?为了解决这个问题,ELF程序头提供了一个直接指向字符串表的指针。这允许加载器在解析ELF文件的其他节之前追踪字符串表。

2.7.1.2 符号表

接下来要介绍的是符号表(symbol table)。符号表定义了程序二进制文件所使用或定义的符号。表中的每个符号都定义了以下内容:

●一个唯一的名称(指定为字符串表的偏移量)。

●符号的地址(或值)。

●符号的大小。

●关于符号的辅助元数据,如符号类型。

符号表在ELF文件格式中被广泛使用。其他引用符号的表会将其作为符号表的查找。

2.7.2 主要的ELF节

ELF文件中许多常见的节仅仅定义了代码或数据被加载到内存的区域。从加载器的角度来看,加载器根本不解释这些节的内容——它们被标记为 PROGBITS (或 NOBITS )。然而,对于逆向工程来说,识别这些节是很重要的。

2.7.2.1 .text节

按照惯例,由编译器生成的机器码指令将全部放在程序二进制文件的 .text 节中。 .text 节被标记为可读、可执行但不可写。这意味着如果程序试图意外地修改自己的程序代码,该程序将触发分段故障。

2.7.2.2 .data节

在程序中定义的普通全局变量,无论是显示定义为全局变量还是定义为静态函数局部变量,都需要被赋予一个在程序生命周期内静态的唯一地址。默认情况下,将在ELF文件的 .data 节中为这些全局变量分配地址,并为其设置初始值。

例如,如果我们在程序中定义了全局变量 int myVar=3 myVar 的符号将存在于 .data 节,长度为4字节,初始值为3,这个初始值将被写入 .data 节。

.data 节通常被保护为可读/可写。尽管全局变量的初始值是在 .data 节定义的,但在程序执行过程中,程序可以自由地读取和覆盖这些全局变量。

2.7.2.3 .bss节

对于那些未被程序员初始化或被初始化为零的全局变量,ELF文件提供了一种优化:块起始符号( .bss )节。该节的操作与 .data 节相同,只是其中的变量在程序开始前自动初始化为零。这避免了在ELF文件中存储多个全局变量“模板”(这些模板仅包含零),从而使ELF文件更小,并避免了在程序启动期间进行一些不必要的文件访问(为了将零从磁盘加载到内存中)。

2.7.2.4 .rodata节

只读数据节 .rodata 用于存储程序中不应在程序执行期间修改的全局数据。该节存储被标记为 const 的全局变量,同时存储在给定程序中使用的常量C字符串字面量。

举例来说,我们可以使用 objdump 工具来转储示例程序的只读数据节的内容,显示字符串字面量 Hello and %s ! 都被输出到最终二进制文件的 .rodata 节中。

2.7.2.5 .tdata和.tbss节

编译器在程序员使用线程局部变量时使用 .tdata .tbss 节。线程局部变量是使用C++中的 __thread_local 关键字或者GCC或clang特定关键字 __thread 注释的全局变量。

2.7.3 ELF符号

在查看 .dynamic 节之前,我们首先需要了解ELF符号。

在ELF文件格式中,符号是程序或外部定义符号中的命名(可选版本化)位置。在程序或共享二进制文件中定义的符号在ELF文件的主符号表中指定。函数和全局数据对象都可以有与之相关的符号名称,但符号也可以分配给线程局部变量、运行时内部对象(如全局偏移表),甚至是位于特定函数内部的标签。

查看特定程序二进制文件的符号表的一种方法是通过 readelf -r 命令行。例如,查看 ld-linux-aarch64.so.1 二进制文件可以发现以下符号:

查看ELF文件符号表的另一个工具是命令行工具 nm ,它有一些额外的功能,对查看编译过的C++程序的符号很有用。例如,我们可以使用这个工具利用选项 -g 将符号限制在只导出的符号,还可以要求 nm 使用 -C 选项自动取消C++符号的装饰,如下面来自 libstdc++ 的符号列表(输出已截断):

符号表中的每个符号条目都定义了以下属性:

●符号名称。

●符号绑定属性,例如符号是弱的、本地的,还是全局的。

●符号类型,通常是表2.6中所示的值之一。

●符号所处的节索引。

●符号的值,通常是它在内存中的地址。

●符号的大小。对于数据对象来说,这通常是数据对象的大小,单位是字节;对于函数来说,是函数的长度,单位是字节。

表2.6 符号类型

2.7.3.1 全局与本地符号

符号的绑定属性定义了符号在链接过程中是否应该对其他程序可见。符号可以是本地的( STB_LOCAL )、全局的( STB_GLOBAL ),也可以两者都不是。

本地符号是不应该对当前ELF文件以外的程序可见的符号。加载器会忽略这些符号以进行动态链接。相比之下,全局符号则在程序或共享库之外被显式共享。整个程序中只允许有一个这样的符号。

2.7.3.2 弱符号

符号也可以被定义为弱符号。弱符号对于创建函数的默认实现非常有用,可以被其他库所重写。使用GCC编译的C程序和C++程序可以使用 __attribute__((weak)) 属性语法或通过C/C++代码中的 #pragma weak 符号指令将函数和数据标记为弱。

例如, malloc 和其他内存分配例程经常使用弱符号定义。

这使得希望使用程序特定替代方案覆盖这些默认实现的程序可以这样做,而无须进行函数挂钩。例如,程序可以链接到一个库,该库提供了针对与内存分配相关的错误的附加检查。由于该库为这些内存分配例程定义了一个强符号,因此该库将覆盖GLIBC提供的默认实现。

2.7.3.3 符号版本

符号版本管理是一个高级主题,通常在编写程序或对程序进行逆向工程时不需要使用,但在对系统库(如glibc)进行逆向工程时,偶尔会看到它。在之前的例子中,以 @GLIBC_PRIVATE 结尾的符号被“版本化”为 GLIBC_PRIVATE 版本,而以 @GLIBC_2.17 结尾的符号被“版本化”为GLIBC_2.17版本。

在抽象层面上,符号版本的工作原理如下 ,程序需要以一种打破现有应用程序二进制接口(Application Binary Interface,ABI)的方式进行更新,例如,更新一个函数以包括一个额外的参数并要求使用相同的名称。

如果程序是一个核心系统库,这些类型的更改就会带来问题,因为打破ABI的更改需要重新编译依赖库的每个程序。这个问题的解决方案是进行符号版本控制。这里,程序同时定义了新符号和旧符号,但显式地用不同版本标记两个符号。使用新版本编译的程序将无缝地使用新符号,而使用旧版本编译的程序将使用旧符号,从而保持ABI兼容性。

符号版本控制的另一个用途是从共享库中导出一个符号,该符号不应被某些特定其他库之外的程序意外使用。在这种情况下, GLIBC_PRIVATE 符号被用来“隐藏”内部的glibc符号,因此只有内部的GLIBC系统库可以调用这些函数,其他程序无法意外导入该符号。符号版本表的定义和分配是通过ELF文件的 .gnu.version_d .gnu.version 节进行管理的。

2.7.3.4 映射符号

映射符号是Arm架构专用的特殊符号。它们的存在是因为Arm二进制文件中的 .text 节有时包含多种不同类型的内容。例如,32位Arm二进制文件可能包含32位Arm指令集编码的指令、Thumb指令集编码的指令,以及常量。映射符号用来帮助调试器和反汇编器确定如何解释文本节中的字节。这些符号仅用于提供信息,不会改变处理器解释节中数据的方式。

表2.7显示了32位和64位Arm的映射符号

表2.7 映射符号

映射符号也可以选择在后面加一个句号,然后后面接字符序列,这不会改变其含义。例如,符号 $d.realdata 表示后面的序列是数据。

2.8 .dynamic节和动态加载

在ELF文件格式中, .dynamic 节用于指示加载器如何链接和准备二进制文件以供执行。

我们可以使用 readelf -d 命令详细查看ELF文件的 .dynamic 节。

这些节由加载器处理,最终形成一个可以运行的程序。与我们看到的其他表一样,每个条目都有相应的类型,详细说明了它的解释方式,以及其数据相对于 .dynamic 节开头的位置。

令人困惑的是, DYNAMIC 程序头还维护着自己的符号表和字符串表,这些表与ELF文件的主字符串表和符号表无关。它们的位置由 STRTAB SYMTAB 指定,它们的大小由 STRSZ 字段和 SYMENT 字段决定,前者是以字节为单位的字符串表大小,后者是动态符号表中的符号项数量。

2.8.1 依赖项加载

加载器处理的第一个主要动态表项是 NEEDED 项。大多数现代程序都不是完全孤立的单元,都依赖于从系统和其他库中导入的函数。例如,一个需要在堆上分配内存的程序可能会使用 malloc ,但程序员不太可能自己编写 malloc 实现,相反会使用操作系统提供的默认实现。

在程序加载期间,加载器还会递归地加载程序的所有共享库依赖项以及它们的依赖项。程序通过动态节中的 NEEDED 指令告诉加载器它依赖于哪些库。程序使用的每个依赖项都有自己的 NEEDED 指令,加载器会依次加载每个依赖项。一旦共享库完全可运行并准备好被使用, NEEDED 指令就完成了。

2.8.2 程序重定位

加载器的第二项任务是在加载程序的依赖项后执行重定位和链接步骤。重定位表可以是两种格式之一: REL RELA 。它们的编码略有不同。重定位数分别在动态节的 RELSZ RELASZ 字段中给出。

我们可以使用 readelf -r 命令查看程序的重定位表。

在给定程序二进制文件中发现的重定位类型因指令集架构而有很大不同。例如,我们可以在这个程序中看到,所有的重定位都是64位Arm专用的。

重定位大致分为三类:

●静态重定位通常是指在程序二进制文件中更新指针并动态重写指令,以便在程序需要被加载到非默认地址时使用。

●动态重定位通常是指引用共享库依赖项中的外部符号。

●线程本地重定位通常是指为每个线程存储一个偏移量,该偏移量指向线程本地存储区域,以便给定的线程局部变量可以使用它。本章稍后将会讨论线程本地存储。

2.8.2.1 静态重定位

我们已经看到,ELF文件定义了一系列的程序头,这些程序头指定了ELF文件应该被操作系统和加载器加载到内存的方式和位置。传统上,ELF程序文件将使用这种机制来准确指定它们应该被加载到内存中的哪些地址,该地址称为程序的首选地址。例如,程序文件通常会要求在内存地址 0x400000 处加载,而共享库会选择一些远高于地址空间的其他固定地址。

由于各种原因,加载器和操作系统可能会选择将程序或共享库加载到首选地址以外的地址。一个原因可能是首选地址的区域不可用,因为该区域中已有其他东西,比如映射文件或其他共享库。另一个常见原因是程序和操作系统支持地址空间布局随机化(Address Space Layout Randomization,ASLR)。ASLR是一种漏洞利用缓解措施,它随机化了程序地址空间中代码和数据的地址,使远程攻击者在针对程序中的内存损坏漏洞(如缓冲区溢出)启动攻击时无法轻易预测程序中关键数据和代码的位置。

在这两种情况下,程序都无法在其首选地址加载。相反,操作系统或加载器会选择内存中的另一个适当位置来加载二进制文件。首选地址和实际加载地址之间的差称为二进制文件的重定位偏移量。

简单地将程序加载到错误的地址会带来问题。程序通常会在其各个代码和数据部分中编码指向其自身代码和数据的指针。例如,C++虚拟方法使用vtable定义,这是指向C++类定义的虚拟函数的具体实现的指针。如果ELF文件被映射到其首选地址,这些指针将正确指向这些函数,但如果出于某些原因ELF文件被映射到其他地址,这些指针将不再有效。

为了解决这个问题,我们可以采用两种方法。第一种方法是将程序编译为位置无关的代码。这会指示编译器通过发出动态确定自身位置的代码来避免静态重定位,并且在加载到不同地址时完全避免了重定位的需要。

第二种方法是必须应用重定位“修正”(fixup),如果程序被加载到不同的地址的话。实际上,每个重定位都会稍微“调整”程序,以更新指针或指令,以便在重定位步骤之后程序仍然像以前一样工作。

在我们之前看到的 readelf -r 的输出中,我们可以看到每个重定位都可以有不同的类型,例如 R_AARCH64_RELATIV 。这种重定位类型引用程序二进制文件中必须在重定位期间更新的地址。对于这种重定位类型,重定位地址是重定位偏移量加上重定位的加数参数,然后将此结果写入重定位条目指示的地址。

每种架构都定义了自己的静态重定位类型集,类型可能很多 ,甚至包括动态重写指令或插入跳板“存根”(如果要跳转的地址太远无法直接编码到指令中的话)。

2.8.2.2 动态重定位

当加载器最初处理程序以及稍后处理每个共享库依赖项和动态加载的共享库时,它会跟踪每个程序中定义的(非本地)符号,以构建当前程序中所有符号的数据库。

在程序重定位阶段,动态链接器可能会遇到重定位,表明重定位不是对需要更新的某个内部指针的引用,而是对程序二进制文件或共享库之外定义的符号的引用。对于这些动态重定位,加载器会检查重定位的符号条目以发现导入的符号名称,并将其与当前程序中所有符号的数据库进行比对。

如果加载器能在数据库中找到匹配的符号,加载器将该符号的绝对地址写到重定位条目中指定的位置,这通常是ELF二进制文件中全局偏移表节的插槽位置。

举个具体的例子,假设 program.so 编写时使用了 libc.so 中定义的 malloc 函数。在程序初始化期间,加载器看到 program.so 通过 NEEDED 指令引用了 libc.so ,并开始加载 libc.so 。此时,加载器将来自 libc.so 的所有外部可见符号添加到全局符号数据库中。例如,假设 libc.so 被加载到地址 0x1000000 malloc 位于该文件的偏移量 0x3000 处,这意味着 malloc 符号的地址将在数据库中被存储为 0x1003000 。稍后,当加载器处理 program.so 的重定位时,它将遇到一个引用 malloc 符号的动态重定位条目。加载器将检查数据库,看到 malloc 符号的地址为 0x1003000 ,并将此值写入 program.so 的全局偏移表中重定位条目指示的地址。

之后,当 program.so 试图调用 malloc 函数时,将通过 program.so 的全局偏移表发生间接调用。这意味着从 program.so 调用 malloc 将在 libc.so 内部的 malloc 函数定义处继续。

2.8.2.3 全局偏移表

正如我们在前面看到的,动态重定位在程序中指定导入符号的地址,例如libc内部 malloc 的地址。然而,在实践中,程序可能会多次导入给定的符号,例如 malloc 。原则上,为每个调用发出符号查找是被允许的,然而,由于符号查找是一项耗时的操作,需要在全局符号表中进行基于字符串的查找,因此这个过程并不理想。

解决这个问题的办法是设置ELF二进制文件的全局偏移表( .got )节。全局偏移表(GOT)整合了外部符号的解析,因此每个符号只需要被查找一次。因此,一个在256个不同地方使用 malloc 的程序将只发出一个重定位,要求加载器查找 malloc 并将地址放在相应的GOT插槽位置。然后,在运行时对 malloc 的调用可以通过加载这个槽内的地址并跳转到其地址来进行。

2.8.2.4 程序链接表

对这一过程的进一步优化利用了另一个节,该节称为程序链接表(Procedure Linkage Table,PLT),其目的是促进惰性符号绑定。

惰性绑定基于这样的观察:给定的程序可能会导入大量的符号,但程序实际上可能不会在运行中使用它所导入的所有符号。如果我们将符号解析推迟到第一次使用符号之前,我们就可以“节省”与解析所有未使用的符号有关的性能成本。对于函数,我们可以通过PLT进行这种惰性解析优化。

PLT存根是微型函数,旨在调用导入的函数。导入的函数被链接器重写为调用PLT,因此对 malloc 的程序调用被重写为调用相应的 malloc PLT存根(通常称为 malloc@plt )。第一次调用 malloc@plt 存根时,PLT调用一个惰性加载例程,该例程将 malloc 符号解析为其真实地址,然后跳转到该地址以调用 malloc 。后续对PLT存根的调用将直接使用先前解析的地址。总体结果是每个函数符号在每次程序运行中只加载一次,就在第一次调用该函数之前。

2.8.3 ELF程序的初始化和终止节

一旦程序被加载到内存中,其依赖项便已得到满足,并且程序已正确重定位和链接到其共享库依赖项,加载器已准备好启动程序的核心程序代码。但是,在这样做之前,它首先需要运行程序的初始化例程。

从语义上讲,C和C++程序都从包含核心程序逻辑的 main 函数开始执行,并在 main 函数返回后立即退出。然而,实际情况要复杂得多。

在C编程语言中,类型系统相对有限。当定义全局变量时,它们可以被静态地初始化为某个常量值或者保持未初始化状态。在2.7.2节中,我们看到,如果变量被初始化,变量的初始值将被放在 .data 节,而未初始化的变量将被放在 .bss 节中。这个过程被称为全局变量静态初始化。

C++编程语言更加复杂。C++变量可以使用复杂的程序员定义的类型,例如类,这些类型可以定义构造函数(在变量进入作用域时自动运行),并定义析构函数(在变量离开作用域时自动运行)。全局变量在 main 函数被调用之前进入作用域,并在程序退出或共享库卸载时离开作用域。这个过程被称为动态初始化。

举个具体的例子,请看下面的程序:

这个程序定义了一个全局变量,类型为 AutoInit AutoInit 是一个C++类,它定义了一个构造函数和一个析构函数,这两个函数都在控制台输出一个字符串。该程序还定义了一个 main 函数,该函数向控制台输出一个字符串,然后退出。

如果我们编译并运行这个程序,会得到以下输出:

这个程序的工作原理是,C++像以前一样在 .data .bss 节中定义全局变量的存储空间,但是会跟踪每个全局变量的构造函数和析构函数,这些函数在程序的 main 函数被调用之前被调用。它分别保存在两个列表 __CTOR_LIST__ __DTOR_LIST__ 中。相应的析构函数在程序安全退出时(以相反的顺序)被调用。

虽然这些构造函数和析构函数列表主要用于诸如C++之类的语言,但使用C语言编写的程序也可以利用它们。编写C代码的程序员可以使用GNU扩展 __attribute__((constructor)) 将对该函数的引用添加到构造函数列表中,反之,可以将函数标记为 __attribute__((destructor)) 以将其添加到析构函数列表中

ELF文件定义了编译器可以采用的两种不同策略,以确保在程序入口点被调用之前发生这个过程 。较旧的策略是编译器生成两个函数: init 函数(在 main 函数之前调用),以及 fini 函数(在程序安全退出或共享库被卸载时调用)。如果编译器选择此策略,则在 .dynamic 节中分别引用 init 函数和 fini 函数,并按照惯例将这两个函数分别放置在ELF二进制文件的 init fini 节中。为了使程序正常运行,这两个节都必须标记为可执行。

较新的策略是编译器直接在ELF文件中引用整个 __CTOR_LIST__ __DTOR_LIST__ 列表。这是通过 .dynamic 节中的 INIT_ARRAY FINI_ARRAY 条目完成的,这些数组的长度分别由 INIT_ARRAYSZ FINI_ARRAYSZ 给出。数组中的每个条目都是一个不带参数且不返回值的函数指针。作为程序启动的一部分,加载器依次调用列表中的每个条目。加载器还确保当程序优雅地退出或共享库被卸载时,它将使用析构函数数组中的条目列表调用程序的所有静态析构函数。

该设计的最后一个复杂性是ELF文件还可以定义 PREINIT_ARRAY 列表。该列表与 INIT_ARRAY 列表相同,只是 PREINIT_ARRAY 中的所有函数都在 INIT_ARRAY 中的任何条目之前被调用。

初始化和终止顺序

程序也可以自由地混合和匹配之前定义的初始化策略。如果程序选择使用多个策略,则初始化顺序如下

●首先使用程序头将程序加载到内存中。这个过程预先初始化了所有的全局变量。包括静态初始化的C++全局变量在内的全局变量都在这个阶段被初始化,而 .bss 中未初始化的变量在这里被清零。

●加载器确保在启动动态链接序列之前,已经完全加载和初始化了程序或共享库的所有依赖项。

●加载器通过 atexit 函数为程序中的每个非零条目以及 FINI 函数本身(如果已定义)注册 FINI_ARRAY 中的函数。对于共享库,加载器会在 dlclose 期间或在 exit 期间(如果共享库在那时仍然加载)注册函数以运行 FINI_ARRAY 中的函数。

●如果程序定义了 PREINIT_ARRAY 条目,则该数组中的每个非零条目将按顺序被调用。

●如果程序定义了 INIT_ARRAY 条目,则该数组中的每个非零条目将被依次调用。

●如果程序定义了 INIT 条目,则加载器将直接调用该节中的第一条指令来运行 init 存根。

●现在模块已经被初始化。如果模块是共享库,那么 dlopen 现在可以返回。如果模块是一个程序,在启动时,加载器将调用程序的入口点来启动C运行时并引导程序调用 main 函数。

2.9 线程本地存储

除全局数据变量外,C和C++程序还可以定义线程局部数据变量。对于程序员来说,线程局部全局变量的外观和行为大多与普通全局变量相同,除了它们使用C++中的 __thread_local 关键字或GNU扩展关键字 __thread 进行注释。

对于传统的全局变量,整个程序中只存在一个全局变量,每个线程都可以对其进行读写,每个线程都为自己的线程局部变量维护着唯一存储位置。因此,对线程局部变量的读写对程序中的其他线程不可见。

图2.2给出了程序访问线程局部变量与全局变量的区别。在这里,两个线程都将全局变量视为引用相同的内存地址。因此,某线程对变量的写操作对另一个线程可见,反之亦然。相比之下,两个线程看到线程局部变量由不同的内存地址支持。对线程局部变量的写操作不会更改程序中其他线程所看到的变量的值。

与普通全局变量一样,线程局部变量可以从共享库依赖项导入。例如, errno 变量被广泛用于跟踪各种标准库函数的错误,它是一个线程局部变量 。线程局部变量可以是零初始化或静态初始化的。

图2.2 线程局部变量与全局变量

为了了解这是如何工作的,请考虑以下程序 tls.c ,它定义了两个TLS局部变量 myThreadLocal myUninitializedLocal

让我们编译这个程序,并使用 readelf 查看一下。

在这里,我们可以看到程序现在定义了一个 TLS 程序头,它包含两个逻辑节: .tdata .tbss

程序中定义的每个线程局部变量都在ELF文件的TLS表中有一个对应的条目,该表由 TLS 程序头引用。该条目指定每个线程局部变量的大小(以字节为单位),并为每个线程局部变量分配一个“TLS偏移量”,该偏移量是变量在线程本地数据区域中使用的偏移量。

我们可以通过符号表查看这些变量的确切TLS偏移量。 _TLS_MODULE_BASE 是一个符号,用于引用给定模块的线程本地存储(TLS)数据的基址。此符号用作给定模块的TLS数据的基指针,并指向包含给定模块的所有线程本地数据的内存区域的开头。 $d 是一个映射符号。除了这两种特殊情况,我们可以看到我们的程序只包含两个线程局部变量, myThreadLocal 具有TLS偏移量0, myUninitializedLocal 具有TLS偏移量4。

如果局部变量是静态初始化的,则该变量的TLS条目也将指向存储在磁盘上的ELF文件的 .tdata 节中的“初始模板”局部变量。未初始化的TLS条目指向 .tbss 数据节,避免在ELF文件中存储多余的零。将这两个区域连接将形成程序或共享库的TLS初始化映像。在我们的示例中,这意味着程序的TLS初始化映像将是八字节序列 03 00 00 00 00 00 00 00

线程本地存储的运行时机制可能有点复杂,但基本原理如图2.3所示。具体如下:

●每个线程都可以访问线程指针寄存器。在64位Arm上,该寄存器是系统的 TPIDR_EL0 寄存器;在32位Arm上,它是系统的 TPIDRURW 寄存器

●线程指针寄存器指向为该线程分配的线程控制块(Thread-Control Block,TCB)。在64位Arm上;TCB为16字节;在32位Arm上,为8字节。

●紧接着TCB的是主程序二进制文件的线程局部变量,即从线程指针中保存的地址开始的字节偏移量16(在32位Arm上为8)处。

●主程序二进制文件的共享库依赖项的TLS区域随后存储。

●TCB还在TCB的偏移量0处维护着一个指向动态线程向量(Dynamic Thread Vector,DTV)数组的指针。DTV数组从代表“生成”的字段(gen)开始,其余的则是指向每个库的线程本地存储的指针数组。

●使用 dlopen 在运行时加载的库的线程局部变量被分配在单独的存储空间中,但仍由DTV数组指向。

这种TLS实现方案不仅允许程序访问在自己的程序模块中定义的线程局部变量,还允许访问在共享库中定义的线程局部变量。在编译时,当遇到对线程局部变量的加载或存储操作时,编译器将使用四种TLS访问模型之一发出TLS访问。编译器通常会根据表2.8中的信息选择此模型,但也可以使用命令行选项 -ftls-model 或在C和C++中使用 __attribute__((tls_model("name"))) 属性 来单独针对每个变量手动覆盖此模型。表2.8描述了这些模型及其约束条件,表格中模型越靠上,运行时效率越高。

图2.3 线程本地存储的运行时机制

表2.8 TLS访问模型

2.9.1 local-exec TLS访问模型

local-exec模型是线程局部变量最快、最严格的TLS访问模型,只能在主程序二进制文件访问在自己的程序二进制文件中定义的线程局部变量时使用。

local-exec模型基于这样一个观察:对于给定的线程,线程指针直接指向线程的TCB,而在TCB元数据之后是当前线程的主程序线程本地数据。对于64位程序,TCB元数据是16字节;对于32位程序,它是8字节。这意味着访问TLS偏移量4处的变量,在64位程序中将执行以下操作:

●访问当前线程的指针。

●将这个值加16或8以跳过TCB,再加4,即变量的TLS偏移量。

●读取或写入这个地址以访问该变量。

这种模型仅适用于程序二进制文件。共享库无法使用此方法,主程序二进制文件也无法使用此模型来访问在共享库中定义的线程局部变量。对于这种访问,必须使用其他访问模型。

2.9.2 initial-exec TLS访问模型

当访问的线程局部变量定义在程序初始化期间加载(即不通过 dlopen 在运行时加载)的共享库中时,使用initial-exec TLS访问模型。这是此模型的一个严格要求,因此以此方式编译的程序在其动态节中设置 DF_STATIC_TLS 标志,以阻止通过 dlopen 加载库。

在这种情况下,程序无法确定在编译时访问的变量的TLS偏移量。程序使用TLS重定位来解决这种模糊性问题。加载器使用此重定位来通知程序跨边界访问的变量的TLS偏移量。因此,在运行时,访问此变量的过程如下所示:

●访问线程指针。

●加载由TLS重定位放置在全局偏移表中的TLS偏移量值,它对应于我们要访问的变量。

●两者相加。

●要访问该变量,可以从该指针读取或写入该指针。

2.9.3 general-dynamic TLS访问模型

general-dynamic TLS访问模型是访问TLS变量最通用但也是最慢的方式。该模型可被任何程序模块用来访问在任何模块中定义的TLS变量,包括自己或其他地方定义的变量。

为此,程序使用名为 __tls_get_addr 的辅助函数。该函数接受一个参数,该参数是指向一对整数的指针,这两个整数分别为包含线程局部变量的模块的模块ID和正在访问的变量的TLS偏移量,函数返回由该整数对结构引用的确切线程局部变量的地址。这些结构本身存储在程序二进制文件的全局偏移表(GOT)节中。此结构中的模块ID是对应于我们正在运行的模块的DTV结构中的唯一索引。此结构的定义(在32位和64位Arm上相同) 如下:

当然,自然的问题是程序如何在编译时知道TLS模块ID或变量的TLS偏移量。对于我们自己程序二进制文件中的线程局部变量,TLS偏移量可能是已知的,但对于外部符号,直到运行时才能确定。

为了解决这个问题,ELF文件重新利用了重定位。有大量可能的重定位,表2.9中显示的重定位给出了基本类型。

表2.9 Arm ELF文件的基本TLS重定位类型

(续)

__tls_get_addr 函数执行以下操作,以下是伪代码 描述:

DTV版本检查的目的是处理以下情况:一个共享库通过 dlopen 在某个线程上被动态打开,然后另一个线程尝试访问该共享库中的线程局部变量。这避免了在 dlopen 期间挂起所有线程并动态调整它们各自的DTV数组的需要。在 dlopen dlclose 期间,全局DTV版本将被更新。然后,线程将在下一次调用 __tls_get_addr 时更新自己的DTV数组,释放与现在关闭的共享库相关联的线程本地存储,并确保DTV数组本身足够长,以容纳每个打开的共享库的条目。

延迟TLS节分配的目的是轻微地优化性能。这确保线程仅在该线程实际使用这些变量时,才为动态打开的共享库的线程局部变量分配内存。

这个过程的总体结果是编译器通过调用 __get_tls_addr 访问线程局部变量。加载器使用重定位来传递正在访问的变量的模块ID和TLS偏移量。最后,运行时系统使用 __get_tls_addr 函数按需分配线程本地存储并将线程局部变量的地址返回给程序。

2.9.4 local-dynamic TLS访问模型

local-dynamic TLS访问模型被需要访问自己的线程局部变量的共享库使用,无论这些共享库是静态加载的还是动态加载的,它实际上是global-dynamic TLS访问模型的简化形式。它基于这样一个观察结果,即当访问自己的线程局部变量时,程序已经知道该偏移量在它自己的TLS区域内的偏移量,唯一不知道的是该TLS区域的确切位置。

对于这种情况,编译器有时可以发出稍微更快的序列。假设程序尝试按顺序访问两个线程局部变量,一个的偏移量为16,另一个的偏移量为256。编译器不再发出两个对 __get_tls_addr 的调用,而是针对当前线程发出一个对 __get_tls_addr 的调用,传递当前模块ID和偏移量0,以获取自己的线程的TLS地址,供当前模块使用。将16与这个地址相加可以得到第一个变量的地址,将256与这个地址相加可以得到第二个变量的地址。 Bn7Qr14dGbp9fjjto0czshfTApewXzfu8iWNU2fMgldcJmzDZLle8s08ec0QFYZm

点击中间区域
呼出菜单
上一章
目录
下一章
×