二进制分析实践
二进制分析 是分析二进制计算机程序(称为 二进制文件)及其包含的机器代码和数据属性。反汇编是许多二进制分析形式中的重要第一步,而逆向工程是二进制分析的常见应用,通常是记录专有软件或恶意软件行为的唯一方法。然而,二进制分析的领域远不止这些。
许多二进制分析任务本质上是不可判定的,这意味着不可能为这些问题构建一个始终返回正确结果的分析引擎!比如:
没有符号信息 C 或 C++ 这样的高级语言编写源代码时,我们为变量、函数和类等构造命名。这些命名我们称之为符号。但它们在二进制级别没有实际意义。因此,二进制文件通常会去除符号信息,这使得理解代码变得更加困难。
没有类型信息 另一个高级程序的特点是它们围绕具有明确定义类型的变量展开,例如int、float 或 string,以及更复杂的数据结构,如 struct 类型。相比之下,在二进制层面,类型从不显式声明,这使得数据的用途和结构很难推断。
没有高级抽象 现代程序被划分为类和函数,但编译器会丢弃这些高级构造。这意味着,二进制文件呈现为大量的代码和数据块,而不是结构良好的程序,恢复高级结构既复杂又容易出错。
混合的代码和数据 二进制文件可以(并且确实会)包含与可执行代码混合的数据片段。这使得意外地将数据当作代码,或将代码当作数据,变得容易,从而导致错误的结果。
依赖位置的代码和数据 由于二进制文件并非设计用于修改,即使是添加一条机器指令,也可能引发问题,因为它会导致其他代码位置发生变化,从而使内存地址和代码中的其他引用失效。因此,任何类型的代码或数据修改都非常具有挑战性,并且容易破坏二进制文件。
在学习之前,先需要学会:
• C 和 C++ 编程语言。
• 操作系统基本原理。
• 了解如何使用 Linux shell(最好是bash)。
• 熟悉 x86/x86-64 汇编语言。可以先阅读附录 A!
让我们开始深入探讨计算机系统的底层细节。
学习环境
实验代码在 Ubuntu Linux 上完成,主要关注 ELF 二进制文件。
每一章该书都包含了若干代码示例,并且有一个预配置的虚拟机。该虚拟机运行的是流行的 Linux 发行版 Ubuntu 16.04,并安装了所有工具。虚拟机可以在本书的官方网站上找到,网址是*practicalbinaryanalysis.com或nostarch.com/binaryanalysis/*。
在书籍的官方网站上,还会找到一个包含所有示例和练习源代码的存档。如果不想下载整个虚拟机,可以下载此存档。
虚拟机启动完成后,使用“binary”作为用户名和密码进行登录。然后,使用键盘快捷键 CTRL-ALT-T 打开终端,您就可以开始跟随书中的内容操作了。
在目录 ~/code 中会找到每个章节的一个子目录,其中包含该章节的所有代码示例和其他相关文件。例如,在 ~/code/chapter1 目录中找到 第一章的所有代码。还有一个名为 ~/code/inc 的目录,包含多个章节中使用的公共代码。我为 C++ 源文件使用 .cc 扩展名,为 C 源文件使用 .c 扩展名,为头文件使用 .h 扩展名,为 Python 脚本使用 .py 扩展名。
要构建给定章节的所有示例程序,只需打开终端,导航到该章节的目录,然后执行 make 命令来构建目录中的所有内容。
大多数重要的代码示例在其对应的章节中都有详细讨论。如果书中讨论的代码清单在虚拟机上有对应的源文件,其文件名会显示在清单之前,如下所示。
filename.c
1 | int |
该清单标题表明,可以在文件 filename.c 中找到清单所示的代码。显示 shell 命令及其输出的列表使用 $ 符号来表示命令提示符,并且使用粗体字体来标识包含用户输入的行。这些行是可以在虚拟机上尝试的命令,而后续未带提示符或未加粗的行则表示命令输出。例如,下面是虚拟机上*~/code*目录的概览:
1 | cd ~/code && ls |
第一部分
二进制格式
第一章:二进制文件的构成
本章将介绍二进制格式的基本构成以及二进制文件的生命周期。
现代计算机使用二进制数字系统进行计算,该系统将所有数字表示为一串一和零。计算机执行的机器代码被称为二进制代码。每个程序都由一组二进制代码(机器指令)和数据(变量、常量等)组成。为了跟踪系统中所有不同的程序,需要一种方法来将每个程序的所有代码和数据存储在一个自包含的文件中。因为这些文件包含可执行的二进制程序,所以它们被称为二进制可执行文件,简称二进制文件。
1.1 C 语言编译过程
二进制文件是通过编译生成的,编译是将人类可读的源代码(如 C 或 C++)转换为处理器可以执行的机器代码的过程。编译 C 语言代码涉及四个阶段,其中一个也叫做编译,与完整的编译过程相同。这些阶段是预处理、编译、汇编和链接。
1.1.1 预处理阶段
编译过程从源文件开始。虽然可以只有一个源文件,但大型程序通常由多个文件组成。这不仅使项目更容易管理,还加速了编译过程,因为如果某个文件发生更改,你只需要重新编译该文件,而不是所有的代码。
C 源文件包含宏(通过#define表示)和#include指令。使用#include指令来包含源文件所依赖的头文件(扩展名为*.h*)。预处理阶段展开源文件中的所有#define和#include指令,结果就是纯粹的 C 代码,准备好被编译。
gcc编译器是许多 Linux 发行版(包括安装在虚拟机上的 Ubuntu 操作系统)的默认编译器。其他编译器,如clang或 Visual Studio,的结果也会类似。编译所有代码为 x86-64 代码。
假设你想编译一个 C 源文件,如列表 1-1 所示。
列表 1-1: compilation_example.c
1 |
|
稍后,你将看到这个文件在编译过程中接下来的变化,但现在我们先来看预处理阶段的输出。默认情况下,gcc会自动执行所有编译阶段,所以你需要明确告诉它在预处理之后停止,并显示中间输出。对于gcc,这可以通过命令gcc -E -P来实现,其中-E告诉gcc在预处理后停止,-P使编译器省略调试信息,以便输出更加简洁。列表 1-2 展示了预处理阶段的输出,已为简洁起见进行编辑。启动虚拟机并跟着操作,查看预处理器的完整输出。
列表 1-2:C 预处理器输出的“Hello, world!”程序
1 | $ gcc -E -P compilation_example.c |
stdio.h头文件被完整地包含进来,其中的所有类型定义、全局变量和函数原型都被“复制”到源文件中。由于每个#include指令都会发生这种情况,预处理器的输出可能会相当冗长。预处理器还会完全展开通过#define定义的所有宏。在这个例子中,这意味着printf的两个参数(FORMAT_STRING ➊ 和 MESSAGE ➋)都会被评估并替换为它们所代表的常量字符串。
1.1.2 编译阶段
预处理阶段完成后,源代码就可以进入编译阶段。编译阶段将预处理后的代码翻译成汇编语言。(大多数编译器在此阶段还会进行大量优化,通常可以通过命令行选项如-O0至-O3在gcc中配置为优化级别。
为什么编译阶段会生成汇编语言而不是机器代码?这个设计在单一语言中似乎没有意义,但流行的编译语言包括 C、C++、Objective-C、Common Lisp、Delphi、Go 和 Haskell,为每种语言编写一个直接生成机器代码的编译器将是一个极为繁重且耗时的任务。与其这样,不如生成汇编代码(这已经是一个足够具挑战性的任务),然后有一个专门的汇编器来处理每种语言的汇编到机器代码的最终转换。
所以,编译阶段的输出是汇编语言,形式相对人类可读,符号信息保持完整。gcc 通常会自动调用所有编译阶段,因此,要查看编译阶段生成的汇编代码,需要告诉gcc在此阶段停止并将汇编文件存储到磁盘。可以通过使用-S标志来实现(.s 是汇编文件的常规扩展名)。你还需要传递选项-masm=intel给gcc,这样它会以 Intel 语法而不是默认的 AT&T 语法生成汇编代码。列表 1-3 展示了编译阶段为示例程序生成的输出。
列表 1-3:编译阶段为“Hello, world!”程序生成的汇编代码
1 | $ gcc -S -masm=intel compilation_example.c |
在列表 1-3 中,汇编代码相对容易阅读,因为符号和函数被保留了。例如,常量和变量有符号名称,而不仅仅是地址(即使它只是一个自动生成的名称,如“Hello, world!”字符串的LC0 ➊),并且有一个明确的标签标记main函数 ➋(在这个例子中是唯一的函数)。任何对代码和数据的引用也是符号化的,比如对“Hello, world!”字符串的引用 ➌。
1.1.3 汇编阶段
在汇编阶段,终于可以生成机器代码了!汇编阶段的输入是编译阶段生成的一组汇编语言文件,输出是一组目标文件,有时也称为模块。目标文件包含的机器指令原则上是可以由处理器执行的。但还需要做一些工作才能得到一个可以运行的二进制可执行文件。通常,每个源文件对应一个汇编文件,每个汇编文件对应一个目标文件。要生成目标文件,你需要给gcc传递-c标志,如列表 1-4 所示。
列表 1-4:使用 *gcc* 生成目标文件
1 | $ gcc -c compilation_example.c |
可以使用file工具来确认生成的文件,compilation_example.o是一个目标文件,为ELF 64-bit LSB 可重定位文件。
file输出的第一部分显示该文件符合二进制可执行文件的 ELF 规范。具体地说,它是一个 64 位 ELF 文件(x86-64 进行编译),并且它是LSB,意味着数字在内存中的顺序是以最不重要的字节为先。但最重要的是,该文件是可重定位的。
可重定位文件不依赖于被放置在内存中的特定地址;相反,它们可以随意移动。当在file输出中看到可重定位这个术语时,就知道正在处理的是目标文件,而不是可执行文件。
目标文件是相互独立编译的,因此在汇编目标文件时,汇编器无法知道其他目标文件的内存地址。所以目标文件需要是可重定位的;这样,可以将它们以任何顺序链接在一起,形成一个完整的二进制可执行文件。如果目标文件不可重定位,这将无法实现。
1.1.4 链接阶段
链接阶段是编译过程的最后阶段。顾名思义,这一阶段将所有目标文件链接成一个单一的二进制可执行文件。在现代系统中,链接阶段有时会加入一个额外的优化过程,称为链接时优化(LTO)。
执行链接阶段的程序被称为链接器,或链接编辑器。它通常与编译器分开。
目标文件是可重定位独立编译的,防止了编译器假设某个目标会位于特定的基地址。此外,目标文件可能引用其他目标文件或程序外部库中的函数或变量。在链接阶段之前,引用的代码和数据将被放置的地址尚未确定,因此目标文件只包含重定位符号,这些符号指定了函数和变量引用应如何最终解析。在链接的上下文中,依赖于重定位符号的引用称为符号引用。当目标文件通过绝对地址引用其自身的函数或变量时,该引用也将是符号引用。
链接器的工作是将属于一个程序的所有目标文件合并为一个单一的连贯可执行文件,通常是打算加载到特定内存地址的。现在,所有模块在可执行文件中的安排已知,链接器还可以解决大多数符号引用。对于库的引用,可能完全解决,也可能没有完全解决,这取决于库的类型。
静态库(在 Linux 上通常扩展名为 .a)被合并到二进制可执行文件中。还有动态(共享)库,它们在内存中由所有在系统上运行的程序共享。动态库不会被复制到每个使用它的二进制文件中,而是只加载一次到内存中,任何想要使用该库的二进制文件都需要使用这个共享副本。在链接阶段,动态库将驻留的地址尚未确定,因此无法解决对它们的引用。相反,链接器会在最终可执行文件中保留这些库的符号引用,这些引用直到二进制文件实际加载到内存并执行时才会被解决。
大多数编译器,包括 gcc,在编译过程结束时会自动调用链接器。因此,要生成一个完整的二进制可执行文件,你可以直接调用 gcc 而无需任何特殊选项,如示例 1-5 所示。
示例 1-5: 使用 gcc 生成二进制可执行文件
1 | $ gcc compilation_example.c |
默认情况下,可执行文件名为 a.out,但是可以通过向 gcc 传递 -o 参数并指定输出文件的名称来覆盖该名称。file 工具告诉你正在处理ELF 64-bit LSB 可执行文件 ➊,而不是在汇编阶段末尾看到的可重定位文件。文件是动态链接的 ➋,意味着它使用一些未合并到可执行文件中的库,而是与所有在同一系统上运行的程序共享。最后,file 输出中的 interpreter /lib64/ld-linux-x86-64.so.2 ➌ 告诉将使用哪个 动态链接器 来解析对动态库的最终依赖关系。当运行这个二进制文件(使用命令 ./a.out)时,可以看到它产生了预期的输出,这确认已经生成了一个有效的二进制文件。
1.2 符号与去除符号的二进制文件
高级源代码,如 C 代码,围绕着具有有意义、可读名称的函数和变量展开。当编译一个程序时,编译器会生成符号,这些符号用于追踪这些符号名称,并记录每个符号对应的二进制代码和数据。例如,函数符号提供了从符号化的高级函数名称到每个函数的起始地址和大小的映射。这些信息通常由链接器在合并目标文件时使用(例如,解决模块间的函数和变量引用),并且对调试也有帮助。
1.2.1 查看符号信息
列表 1-6 展示了二进制文件中的一些符号。
列表 1-6:a.out 二进制文件中的符号,如通过readelf所示
1 | $ ➊readelf --syms a.out |
列表 1-6 使用readelf来显示符号➊。在许多不熟悉的符号中,存在一个main函数的符号➋。可以看到它指定了main在二进制加载到内存时所驻留的地址(0x400526)。输出还显示了main的代码大小(32 字节),并且表明它是一个函数符号(类型为FUNC)。
符号信息可以作为二进制文件的一部分或以单独的符号文件形式存在,并且有多种不同的格式。链接器只需要基本符号,但为了调试的目的,可能会生成更多的扩展信息。调试符号提供了源代码行和二进制指令之间的完整映射,甚至描述了函数参数、栈帧信息等。对于 ELF 二进制文件,调试符号通常以 DWARF 格式生成,而 PE 二进制文件通常使用专有的 Microsoft Portable Debugging (PDB) 格式。DWARF 信息通常嵌入在二进制文件中,而 PDB 以单独的符号文件形式存在。
符号信息对于二进制分析非常有用,拥有一套明确定义的函数符号可以大大简化反汇编过程,因为你可以将每个函数符号作为反汇编的起点,这使得不太可能错误地将数据当作代码反汇编。
可以使用readelf来解析符号,或者像libbfd这样的库进行编程解析,将在 Chapter 4 中解释。此外,还有专门用于解析 DWARF 调试符号的库,如libdwarf。
生产环境中的二进制文件通常不包含大量的调试信息,甚至基本的符号信息也经常会被去除,以减少文件大小并防止逆向工程,特别是在恶意软件或专有软件的情况下。这意味着,作为二进制分析师,通常需要处理没有任何符号信息的被去除符号的二进制文件的情况。
1.2.2 去除二进制文件的符号信息
gcc的默认行为是不会自动去除新编译二进制文件的符号。如果想去除带符号的二进制文件,只需使用strip的命令,如 Listing 1-7 所示。
Listing 1-7: 去除可执行文件的符号信息
1 | $ ➊strip --strip-all a.out |
示例二进制文件已经被去除符号信息 ➊,如file输出 ➋所确认。只有少数符号保留在.dynsym符号表中 ➌。这些符号用于在二进制文件加载到内存时解析动态依赖(例如动态库的引用),但在反汇编时作用不大。所有其他符号,包括在 Listing 1-6 中看到的main函数符号,已经消失。
1.3 反汇编二进制文件
我们来看一下在编译阶段汇编生成的目标文件内容。之后,将反汇编主二进制可执行文件,向你展示它的内容与目标文件的不同之处。通过这种方式,将更清楚地了解目标文件中包含了什么,以及在链接阶段添加了什么内容。
1.3.1 查看目标文件内部
目前,我将使用objdump工具来展示如何进行反汇编(我将在第六章讨论其他反汇编工具)。这是一个简单且易于使用的反汇编工具,通常包含在大多数 Linux 发行版中,非常适合快速了解二进制文件中包含的代码和数据。清单 1-8 展示了示例目标文件compilation_example.o的反汇编版本。
清单 1-8:反汇编目标文件
1 | $ ➊objdump -sj .rodata compilation_example.o |
如果仔细查看清单 1-8,你会看到我调用了两次objdump。第一次,在➊处,我让objdump显示.rodata段的内容。.rodata表示“只读数据”,它是二进制文件中存储所有常量的部分,包括“Hello, world!”字符串。我将在第二章中对.rodata和其他 ELF 二进制段进行更详细的讨论,该章介绍了 ELF 二进制格式。现在请注意,.rodata的内容由字符串的 ASCII 编码组成,显示在输出的左侧。右侧则是这些字节的可读表示。
在➋处对objdump的第二次调用反汇编了目标文件中的所有代码,使用了 Intel 语法。正如你所看到的,它仅包含main函数的代码➌,因为这是源文件中唯一定义的函数。在大多数情况下,输出与之前编译阶段生成的汇编代码非常接近(略有一些汇编级别的宏)。有趣的是,指向“Hello, world!”字符串的指针(在➍处)被设置为零。随后,应该使用puts打印该字符串的调用➎也指向了一个无意义的位置(偏移量 19,在main中间)。
为什么应该引用puts的调用反而指向了main的中间?我之前提到过,目标文件中的数据和代码引用尚未完全解析,因为编译器还不知道文件最终将被加载到哪个基地址。这就是为什么在目标文件中puts的调用尚未正确解析的原因。目标文件正在等待链接器填入此引用的正确值。你可以通过请求readelf显示目标文件中所有的重定位符号来确认这一点,如清单 1-9 所示。
清单 1-9: *readelf*显示的重定位符号
1 | $ readelf --relocs compilation_example.o |
➊处的重定位符号告诉链接器,它应该解析字符串的引用,指向它最终在.rodata段中的地址。类似地,标记为➋的行告诉链接器如何解析对puts的调用。
你可能注意到从puts符号中减去了值 4。你现在可以忽略这一点;链接器计算重定位的方式有些复杂,而readelf的输出可能令人困惑,所以我这里就不详细讲解重定位的细节,而是集中讲解反汇编二进制文件的整体过程。我将在第二章提供更多关于重定位符号的信息。
在清单 1-9 中的readelf输出中,每行最左侧的列(阴影部分)是目标文件中需要填充解析引用的偏移地址。如果你仔细观察,你可能已经注意到,在两种情况下,它等于需要修复的指令的偏移量加 1。例如,在objdump的输出中,调用puts的代码偏移量是0x14,但重定位符号指向的偏移量却是0x15。这是因为你只想覆盖指令的操作数,而不是操作码。恰巧的是,对于需要修复的两条指令,操作码是 1 字节长的,因此,为了指向指令的操作数,重定位符号需要跳过操作码字节。
1.3.2 检查完整的二进制可执行文件
既然你已经看过目标文件的内部结构,现在是时候反汇编一个完整的二进制文件了。我们先从一个带符号的二进制文件开始,然后再处理去符号化的版本,看看反汇编输出的差异。反汇编目标文件和二进制可执行文件之间有很大的区别,你可以在清单 1-10 中的objdump输出中看到这一点。
清单 1-10:使用 *objdump* 反汇编可执行文件*
1 | $ objdump -M intel -d a.out |
你可以看到,二进制文件的代码比目标文件多得多。它不再仅仅是main函数,甚至不仅仅是一个代码段。现在有多个段,名称包括.init ➊、.plt ➋ 和 .text ➌。这些段包含了执行不同功能的代码,如程序初始化或调用共享库的存根。
.text段是主要的代码段,包含了main函数 ➍。它还包含了其他一些函数,如_start,这些函数负责设置命令行参数和为main准备运行时环境,并在main执行完后进行清理。这些额外的函数是标准函数,在任何由gcc生成的 ELF 二进制文件中都存在。
你还可以看到,之前未完成的代码和数据引用现在已经被链接器解析了。例如,调用 puts ➎ 现在指向了包含 puts 的共享库的正确存根(位于 .plt 部分)。(我将在第二章中解释 PLT 存根的工作原理。)
所以,完整的二进制可执行文件包含了比相应的目标文件显著更多的代码(和数据,尽管我没有展示)。但到目前为止,输出的解释并没有更加困难。当二进制文件被去除符号后,情况就不同了,正如清单 1-11 所示,它使用 objdump 来反汇编去除了符号的示例二进制文件。
清单 1-11:使用 *objdump* 反汇编一个去除符号的可执行文件
1 | $ objdump -M intel -d ./a.out.stripped |
清单 1-11 的主要结论是,尽管不同的部分仍然可以清晰地区分(标记为 ➊、➋ 和 ➌),但是函数却不再是这样。相反,所有函数都被合并成了一大块代码。_start 函数从 ➍ 开始,deregister_tm_clones 从 ➏ 开始。main 函数从 ➐ 开始,到 ➑ 结束,但在这些情况下,并没有任何特别的标记来表明这些标记位置的指令代表函数的开始。唯一的例外是 .plt 部分的函数,它们仍然保留了原来的名称(如你在 ➎ 处调用 __libc_start_main 时看到的那样)。除此之外,其他部分的输出你需要自己去理解反汇编结果。
即使在这个简单的例子中,情况已经很混乱了;试想一下,如果要理解一个包含数百个不同函数且所有函数都融合在一起的大型二进制文件该有多困难!这正是为什么在许多二进制分析领域中,准确的自动化函数检测如此重要的原因,我将在第六章中详细讨论这一点。
1.4 加载和执行二进制文件
现在你已经了解了编译过程以及二进制文件的内部结构。你也学会了如何使用 objdump 静态反汇编二进制文件。如果你一直跟着做,你应该已经有了一个全新的二进制文件保存在你的硬盘上。接下来,你将学习当你加载和执行一个二进制文件时会发生什么,这对我在后续章节中讨论动态分析概念非常有帮助。
尽管具体细节因平台和二进制格式不同而有所变化,但加载和执行二进制文件的过程通常涉及一些基本步骤。图 1-2 展示了在基于 Linux 的平台上如何将加载的 ELF 二进制文件(如刚才编译的文件)在内存中表示出来。从高层次来看,在 Windows 上加载 PE 二进制文件也非常相似。

图 1-2:在基于 Linux 的系统上加载 ELF 二进制文件
加载二进制文件是一个复杂的过程,涉及操作系统的大量工作。还需要注意的是,二进制文件在内存中的表示不一定与它在磁盘上的表示一一对应。例如,大量的零初始化数据可能会在磁盘上的二进制文件中被压缩(以节省磁盘空间),但这些零在内存中会被展开。磁盘上的二进制文件某些部分可能在内存中排列的顺序不同,或者根本不加载到内存中。由于这些细节取决于二进制格式,因此我将在第二章(ELF 格式)和第三章(PE 格式)中讨论磁盘上与内存中的二进制表示。现在,我们暂时只做一个关于加载过程的高层概述。
当你决定运行一个二进制文件时,操作系统首先会为程序设置一个新的进程环境,包括一个虚拟地址空间。^(7) 随后,操作系统会将一个解释器映射到进程的虚拟内存中。这个解释器是一个用户空间程序,知道如何加载二进制文件并执行必要的重定位操作。在 Linux 中,解释器通常是一个名为 ld-linux.so 的共享库。在 Windows 中,解释器功能实现为 ntdll.dll 的一部分。加载解释器后,内核将控制权转交给它,解释器开始在用户空间工作。
Linux ELF 二进制文件包含一个名为 .interp 的特殊部分,该部分指定了用于加载二进制文件的解释器路径,如你在readelf中看到的那样,参见清单 1-12。
清单 1-12:\*.interp\* 部分的内容
1 | $ readelf -p .interp a.out |
如前所述,解释器将二进制文件加载到其虚拟地址空间中(即解释器本身被加载的空间)。然后,它解析二进制文件,找出(其中包括)该二进制文件所使用的动态库。解释器将这些动态库映射到虚拟地址空间中(使用mmap或等效函数),并在二进制文件的代码段中执行必要的最后时刻重定位操作,以填充动态库引用的正确地址。实际上,解决动态库函数引用的过程通常会被延迟到稍后。换句话说,解释器并不会在加载时立即解析这些引用,而是在首次调用时才会解析它们。这种方法被称为懒加载绑定,我将在第二章中详细解释。重定位完成后,解释器查找二进制文件的入口点并将控制权转交给它,开始正常执行二进制文件。
1.5 总结
现在你已经熟悉了二进制文件的一般结构和生命周期,是时候深入了解特定的二进制格式了。我们从广泛使用的 ELF 格式开始,它是下一章的主题。
练习
- 定位函数
编写一个包含多个函数的 C 程序,并分别将其编译成汇编文件、目标文件和可执行二进制文件。尝试在汇编文件、反汇编的目标文件和可执行文件中定位你写的函数。你能看到 C 代码和汇编代码之间的对应关系吗?最后,剥离可执行文件并再次尝试识别函数。
- 节
如你所见,ELF 二进制文件(以及其他类型的二进制文件)被划分为多个节。有些节包含代码,有些节包含数据。你认为为什么会有代码节和数据节的区别?你认为代码节和数据节的加载过程有何不同?当加载一个二进制文件执行时,是否有必要将所有节都复制到内存中?
第二章:ELF 格式
现在你对二进制文件的外观和工作原理有了一个大致的了解,你可以开始深入研究真正的二进制格式了。在本章中,你将探讨可执行与可链接格式(ELF),这是基于 Linux 的系统上的默认二进制格式,也是你在本书中将要处理的格式。
ELF 用于可执行文件、目标文件、共享库和核心转储。在这里我将专注于 ELF 可执行文件,但相同的概念也适用于其他 ELF 文件类型。由于你在本书中主要处理的是 64 位二进制文件,所以我将围绕 64 位 ELF 文件进行讨论。然而,32 位格式相似,主要的区别在于某些头字段和其他数据结构的大小和顺序。你不应该在将这里讨论的概念推广到 32 位 ELF 二进制文件时遇到任何问题。
图 2-1 展示了一个典型的 64 位 ELF 可执行文件的格式和内容。当你第一次开始详细分析 ELF 二进制文件时,所有涉及的复杂性可能会让人感到不知所措。但从本质上讲,ELF 二进制文件实际上只由四种类型的组件组成:可执行文件头、一系列(可选的)程序头、若干个节,以及一系列(可选的)节头,每个节一个头。接下来我会逐一讨论这些组件。

图 2-1:一眼看出 64 位 ELF 二进制文件
正如你在 图 2-1 中看到的,标准 ELF 二进制文件首先是可执行文件头,其次是程序头,最后是节和节头。为了使接下来的讨论更容易理解,我将使用稍微不同的顺序,在讨论程序头之前先讨论节和节头。让我们从可执行文件头开始。
2.1 可执行文件头
每个 ELF 文件都以一个 可执行文件头 开始,它只是一个结构化的字节序列,告诉你它是一个 ELF 文件,是什么类型的 ELF 文件,并且指示在文件中在哪里可以找到其他所有内容。要了解可执行文件头的格式,你可以查找其类型定义(以及其他与 ELF 相关的类型和常量的定义)在 /usr/include/elf.h 或 ELF 规范中。^(1) 列表 2-1 显示了 64 位 ELF 可执行文件头的类型定义。
列表 2-1:在 /usr/include/elf.h 中的 ELF64_Ehdr 定义
1 | typedef struct { |
可执行文件头在这里表示为一个 C struct,叫做 Elf64_Ehdr。如果你在 /usr/include/elf.h 中查找它,你可能会注意到,那里给出的 struct 定义包含了像 Elf64_Half 和 Elf64_Word 这样的类型。这些只是整数类型的 typedef,例如 uint16_t 和 uint32_t。为了简便起见,我已经在 图 2-1 和 列表 2-1 中展开了所有的 typedef。
2.1.1 e_ident 数组
可执行文件头(以及 ELF 文件)从一个 16 字节的数组e_ident开始。e_ident数组总是以一个 4 字节的“魔术值”开头,用于标识该文件为 ELF 二进制文件。魔术值由十六进制数0x7f组成,后跟字母E、L和F的 ASCII 字符代码。将这些字节放在文件的开始位置非常方便,因为它允许诸如file工具以及二进制加载器等专用工具迅速识别出这是一个 ELF 文件。
紧跟在魔术值之后的是一些字节,它们提供了关于 ELF 文件类型的更多详细信息。在elf.h中,这些字节的索引(e_ident数组中的第 4 至第 15 个索引)被符号化地称为EI_CLASS、EI_DATA、EI_VERSION、EI_OSABI、EI_ABIVERSION和EI_PAD,分别对应。图 2-1(Figure 2-1)展示了它们的视觉表示。
EI_PAD字段实际上包含多个字节,即e_ident中的第 9 至第 15 个索引位置。所有这些字节目前都被指定为填充字节;它们保留供将来可能使用,但目前都设置为零。
EI_CLASS字节表示 ELF 规范所称的二进制文件的“类别”。这个词其实是个误称,因为“类别”这个词太过泛化,几乎可以表示任何东西。这个字节实际表示的是二进制文件是针对 32 位架构还是 64 位架构的。在前一种情况下,EI_CLASS字节设置为常量ELFCLASS32(值为 1),而在后一种情况下,设置为ELFCLASS64(值为 2)。
与架构的位宽相关的是架构的字节序。换句话说,多字节值(如整数)在内存中的存储顺序是先存储最低有效字节(小端字节序)还是先存储最高有效字节(大端字节序)?EI_DATA字节指示二进制文件的字节序。ELFDATA2LSB(值为 1)表示小端字节序,而ELFDATA2MSB(值为 2)表示大端字节序。
下一个字节叫做EI_VERSION,它表示在创建二进制文件时使用的 ELF 规范的版本。目前,唯一有效的值是EV_CURRENT,其定义等于 1。
最后,EI_OSABI和EI_ABIVERSION字节表示与应用程序二进制接口(ABI)和操作系统(OS)相关的信息,这些信息用于标识二进制文件的编译环境。如果EI_OSABI字节被设置为非零值,表示 ELF 文件中使用了某些特定于 ABI 或操作系统的扩展;这可能会改变二进制文件中其他字段的含义,或指示存在非标准部分。零值表示二进制文件是针对 UNIX 系统 V ABI 编译的。EI_ABIVERSION字节表示二进制文件目标所使用的EI_OSABI字节所指示的 ABI 的具体版本。通常你会看到它被设置为零,因为当使用默认的EI_OSABI时,不需要指定版本信息。
你可以通过使用readelf查看二进制文件的头部,检查任何 ELF 二进制文件的e_ident数组。例如,列表 2-2 显示了第一章中的compilation_example二进制文件的输出(在讨论可执行头部的其他字段时,我还会引用此输出)。
列表 2-2:由 readelf 显示的可执行头部
1 | $ readelf -h a.out |
在列表 2-2 中,e_ident数组显示在标记为Magic的行上 ➊。它以熟悉的四个魔术字节开始,接着是一个值 2(表示ELFCLASS64),然后是 1(ELFDATA2LSB),最后是另一个 1(EV_CURRENT)。其余字节均为零,因为EI_OSABI和EI_ABIVERSION字节保持其默认值;填充字节也都设置为零。某些字节中包含的信息在专门的行中被显式地重复,分别标记为Class、Data、Version、OS/ABI和ABI Version ➋。
2.1.2 e_type, e_machine 和 e_version 字段
在e_ident数组之后,紧跟着一系列多字节整数字段。其中第一个字段是e_type,它指定了二进制文件的类型。你最常见的值包括ET_REL(表示可重定位目标文件)、ET_EXEC(可执行二进制文件)和ET_DYN(动态库,也称为共享目标文件)。在示例二进制文件的readelf输出中,你可以看到这是一个可执行文件(在列表 2-2 中的Type: EXEC ➌)。
接下来是e_machine字段,它表示二进制文件的目标架构 ➍。在本书中,通常会将其设置为EM_X86_64(正如readelf输出中所示),因为你主要将处理 64 位 x86 二进制文件。你可能遇到的其他值包括EM_386(32 位 x86)和EM_ARM(用于 ARM 二进制文件)。
e_version字段的作用与e_ident数组中的EI_VERSION字节相同;具体来说,它指示创建二进制文件时使用的 ELF 规范版本。由于该字段是 32 位宽的,你可能会认为有许多可能的值,但实际上,唯一的可能值是 1(EV_CURRENT),表示该规范的版本为 1 ➎。
2.1.3 e_entry 字段
e_entry字段表示二进制文件的入口点;这是执行开始的虚拟地址(详见第 1.4 节)。对于示例二进制文件,执行从地址0x400430开始(在列表 2-2 中的readelf输出中标记为 ➏)。这是解释器(通常是ld-linux.so)在加载二进制文件到虚拟内存后将控制权转交的地方。入口点也是递归反汇编的有用起点,正如我在第六章中将要讨论的。
2.1.4 e_phoff 和 e_shoff 字段
如 图 2-1 所示,ELF 二进制文件包含程序头表和节头表等数据结构。等我完成对可执行文件头的讨论后,我会重新讲解这些头部类型的含义,不过我现在可以透露的一点是,程序头表和节头表不需要位于二进制文件中的特定偏移量位置。唯一可以假定始终位于 ELF 二进制文件中固定位置的数据结构是可执行文件头,它始终位于文件的开头。
如何知道程序头和节头的位置?为此,可执行文件头包含两个专门的字段,分别为 e_phoff 和 e_shoff,它们指示程序头表和节头表的文件偏移量。对于示例二进制文件,偏移量分别为 64 字节和 6632 字节(见 清单 2-2 中的 ➐ 两行)。这些偏移量也可以设置为零,表示文件中不包含程序头或节头表。需要特别注意的是,这些字段是 文件偏移量,即表示需要读取多少字节才能到达头部。换句话说,与之前讨论的 e_entry 字段不同,e_phoff 和 e_shoff 不是 虚拟地址。
2.1.5 e_flags 字段
e_flags 字段为特定架构的标志提供空间,这些标志与二进制文件所编译的架构相关。例如,旨在嵌入式平台上运行的 ARM 二进制文件可以在 e_flags 字段中设置 ARM 特定的标志,以指示它们期望嵌入式操作系统提供的接口的额外细节(如文件格式约定、栈组织等)。对于 x86 二进制文件,e_flags 通常设置为零,因此不予关注。
2.1.6 e_ehsize 字段
e_ehsize 字段指定可执行文件头的大小(以字节为单位)。对于 64 位 x86 二进制文件,可执行文件头的大小始终为 64 字节,正如你在 readelf 输出中看到的那样,而对于 32 位 x86 二进制文件,其大小为 52 字节(见 清单 2-2 中的 ➑)。
*2.1.7 e_*entsize 和 e_num 字段
如你所知,e_phoff 和 e_shoff 字段指向程序头和节头表开始的文件偏移量。但是,为了让链接器、加载器(或其他处理 ELF 二进制文件的程序)能够实际遍历这些表格,仍然需要额外的信息。具体来说,它们需要知道每个程序头或节头在表格中的大小,以及每个表格中的头部数量。这些信息由 e_phentsize 和 e_phnum 字段提供,用于程序头表;由 e_shentsize 和 e_shnum 字段提供,用于节头表。在 清单 2-2 中的示例二进制文件中,共有 9 个程序头,每个头大小为 56 字节,且有 31 个节头,每个节头大小为 64 字节 ➒。
2.1.8 e_shstrndx 字段
e_shstrndx 字段包含与一个特殊 字符串表 节(名为 .shstrtab)相关联的头部在节头表中的索引。这个节是一个专用节,包含一个以空字符结尾的 ASCII 字符串表,存储着二进制文件中所有节的名称。ELF 处理工具(如 readelf)会使用这个节来正确显示节的名称。我将在本章稍后介绍 .shstrtab(以及其他节)。
在 Listing 2-2 中的示例二进制文件中,.shstrtab 的节头索引为 28 ➓。你可以使用 readelf 查看 .shstrtab 节的内容(以十六进制转储的形式),如 Listing 2-3 所示。
Listing 2-3: readelf 显示的 .shstrtab 节
1 | $ readelf -x .shstrtab a.out |
你可以在 Listing 2-3 ➊ 的右侧看到字符串表中包含的节名称(如 .symtab、.strtab 等)。现在你已经熟悉了 ELF 可执行文件头部的格式和内容,接下来让我们继续讨论节头。
2.2 节头
ELF 二进制文件中的代码和数据逻辑上被划分为连续的、不重叠的块,称为 节。节没有预定的结构;每个节的结构根据其内容不同而不同。实际上,一个节甚至可能没有任何特定的结构;许多时候,节不过是一个没有结构的代码或数据块。每个节都有一个 节头,它描述了节的属性并允许你定位属于该节的字节。二进制文件中所有节的节头都包含在 节头表 中。
严格来说,节的划分旨在为链接器提供方便的组织方式(当然,节也可以被其他工具解析,比如静态二进制分析工具)。这意味着,并非每个节在设置进程和虚拟内存以执行二进制文件时都是必须的。有些节包含的数据根本不需要执行,例如符号信息或重定位信息。
由于节的目的仅仅是为链接器提供视图,因此节头表是 ELF 格式的一个可选部分。那些不需要链接的 ELF 文件不必包含节头表。如果没有节头表,执行文件头中的 e_shoff 字段将被设置为零。
为了加载和执行二进制文件到一个进程中,二进制文件的代码和数据需要以不同的方式组织。因此,ELF 可执行文件指定了另一种逻辑组织方式,称为段,它们在执行时使用(与在链接时使用的节不同)。稍后我会在本章中讨论程序头时覆盖段的内容。现在,让我们聚焦于节,但请记住,我在这里讨论的逻辑组织仅在链接时(或当静态分析工具使用时)存在,而不是在运行时。
让我们从讨论节头的格式开始。之后,我们将查看节的内容。清单 2-4 展示了按*/usr/include/elf.h*中规定的格式定义的 ELF 节头。
清单 2-4:在/usr/include/elf.h中定义的 Elf64_Shdr
1 | typedef struct { |
2.2.1 sh_name 字段
如清单 2-4 所示,节头的第一个字段被称为sh_name。如果设置了,它包含指向字符串表的索引。如果索引为零,则表示该节没有名称。
在第 2.1 节中,我讨论了一个名为.shstrtab的特殊节,它包含一个以NULL终止的字符串数组,每个节名称都有一个字符串。描述字符串表的节头索引存储在可执行文件头的e_shstrndx字段中。这使得像readelf这样的工具能够轻松找到.shstrtab节,并通过每个节头的sh_name字段(包括.shstrtab的头)索引它,以找到描述该节名称的字符串。这使得人工分析人员能够轻松识别每个节的用途。^(2)
2.2.2 sh_type 字段
每个节都有一个类型,通过一个名为sh_type的整数字段来表示,该字段告诉链接器有关节内容结构的信息。图 2-1 展示了我们目的下最重要的节类型。我将逐一讨论每种重要的节类型。
类型为SHT_PROGBITS的节包含程序数据,例如机器指令或常量。这些节没有特定的结构供链接器解析。
还有一些特殊的节类型用于符号表(SHT_SYMTAB表示静态符号表,SHT_DYNSYM表示动态链接器使用的符号表)和字符串表(SHT_STRTAB)。符号表以一种定义明确的格式(如果你有兴趣的话,可以查看elf.h中的struct Elf64_Sym)存储符号,其中描述了特定文件偏移量或地址的符号名称和类型等信息。如果二进制文件被剥离,静态符号表可能不存在。字符串表,如前所述,仅包含一个以NULL终止的字符串数组,字符串表的第一个字节按照约定设置为NULL。
类型为 SHT_REL 或 SHT_RELA 的节对于链接器特别重要,因为它们包含了按照明确格式(在 elf.h 中的 struct Elf64_Rel 和 struct Elf64_Rela)定义的重定位条目,链接器可以解析这些条目来执行其他节中的必要重定位。每个重定位条目都告诉链接器在二进制文件中某个位置需要进行重定位,以及应该解析到哪个符号。实际的重定位过程相当复杂,我现在不打算深入讨论。重要的结论是,SHT_REL 和 SHT_RELA 节用于静态链接。
类型为 SHT_DYNAMIC 的节包含了动态链接所需的信息。该信息的格式使用 struct Elf64_Dyn,如 elf.h 中所指定。
2.2.3 sh_flags 字段
节标志(在 sh_flags 字段中指定)描述了节的附加信息。这里最重要的标志是 SHF_WRITE、SHF_ALLOC 和 SHF_EXECINSTR。
SHF_WRITE 表示该节在运行时是可写的。这使得我们可以很容易地区分包含静态数据(如常量)和包含变量的节。SHF_ALLOC 标志表示该节的内容在执行二进制文件时会被加载到虚拟内存中(尽管实际加载是通过二进制文件的段视图进行的,而不是节视图)。最后,SHF_EXECINSTR 告诉你该节包含可执行指令,这在反汇编二进制文件时非常有用。
2.2.4 sh_addr、sh_offset 和 sh_size 字段
sh_addr、sh_offset 和 sh_size 字段分别描述了节的虚拟地址、文件偏移量(从文件开始算起的字节数)和节的大小(以字节为单位)。乍一看,像 sh_addr 这样描述节虚拟地址的字段可能显得不合适;毕竟,我曾说过节只用于链接,而不是用于创建和执行进程。尽管这仍然成立,但链接器有时需要知道特定的代码和数据在运行时会位于哪些地址,以便进行重定位。sh_addr 字段提供了这些信息。那些在进程设置时不打算加载到虚拟内存中的节,其 sh_addr 值为零。
2.2.5 sh_link 字段
有时候,节与节之间存在一些链接器需要知道的关系。例如,SHT_SYMTAB、SHT_DYNSYM 或 SHT_DYNAMIC 都有一个关联的字符串表节,里面包含了相关符号的符号名称。类似地,重定位节(类型为 SHT_REL 或 SHT_RELA)与一个符号表相关联,该符号表描述了重定位中涉及的符号。sh_link 字段通过表示相关节在节头表中的索引,明确了这些关系。
2.2.6 sh_info 字段
sh_info 字段包含有关段的附加信息。附加信息的含义取决于段的类型。例如,对于重定位段,sh_info 表示将要应用重定位的段的索引。
2.2.7 sh_addralign 字段
某些段可能需要以特定方式在内存中对齐,以提高内存访问效率。例如,一个段可能需要加载到某个地址,这个地址是 8 字节或 16 字节的倍数。这些对齐要求在 sh_addralign 字段中指定。例如,如果该字段设置为 16,则表示该段的基地址(由链接器选择)必须是 16 的倍数。值 0 和 1 被保留,表示没有特殊的对齐需求。
2.2.8 sh_entsize 字段
一些段,例如符号表或重定位表,包含一组定义明确的数据结构(例如 Elf64_Sym 或 Elf64_Rela)。对于这些段,sh_entsize 字段表示表中每个条目的字节大小。当该字段未使用时,它的值为零。
2.3 段
现在你已经熟悉了段头的结构,接下来让我们看看 ELF 二进制文件中一些具体的段。你在 GNU/Linux 系统上找到的典型 ELF 文件是按一系列标准(或事实上的标准)段组织的。列表 2-5 显示了使用 readelf 命令查看示例二进制文件的输出,其中列出了段。
列表 2-5:示例二进制文件中的段列表
1 | $ readelf --sections --wide a.out |
对于每个段,readelf 显示相关的基本信息,包括索引(在段头表中)、段的名称和类型。此外,你还可以看到段的虚拟地址、文件偏移量和字节大小。对于包含表的段(例如符号表和重定位表),还有一列显示每个表条目的大小。最后,readelf 还显示每个段的相关标志,以及链接的段的索引(如果有的话)、附加信息(特定于段类型)和对齐要求。
如你所见,输出内容与段头的结构非常接近。每个 ELF 文件的段头表中的第一个条目是由 ELF 标准定义的 NULL 条目。该条目的类型为 SHT_NULL ➊,且段头的所有字段都被清零。这意味着该段没有名称且没有关联的字节(换句话说,它是一个没有实际段的段头)。接下来,让我们深入了解你在二进制分析过程中可能会遇到的其他一些最有趣的段的内容和目的。^(3)
2.3.1 .init 和 .fini 段
.init 段(在 清单 2-5 中的索引 11)包含执行初始化任务的可执行代码,且需要在二进制文件中的其他代码执行之前运行。你可以通过 readelf 中的 SHF_EXECINSTR 标志(在 Flg 列中以 X 表示) ➋ 知道它包含可执行代码。系统在将控制权转交给二进制文件的主入口点之前,会先执行 .init 段中的代码。因此,如果你熟悉面向对象编程,可以将此段视为构造函数。.fini 段(索引 15)与 .init 段类似,只是它在主程序完成后执行,基本上充当了一种析构函数的角色。
2.3.2 .text 段
.text 段(索引 14)是程序主代码所在的地方,因此它将经常成为你进行二进制分析或逆向工程时的主要关注点。如你在 清单 2-5 中的 readelf 输出所见,.text 段的类型是 SHT_PROGBITS ➌,因为它包含用户定义的代码。还要注意该段的标志,表示该段是可执行的但不可写的 ➍。一般来说,可执行的段几乎不应是可写的(反之亦然),因为这将使得攻击者通过利用漏洞直接覆盖代码来修改程序行为变得容易。
除了从程序源代码编译而来的特定应用程序代码之外,使用 gcc 编译的典型二进制文件的 .text 段包含了许多执行初始化和清理任务的标准函数,如 _start、register_tm_clones 和 frame_dummy。目前,_start 函数是这些标准函数中对你最为重要的一个。清单 2-6 展示了原因(不必担心理解清单中的所有汇编代码;接下来我会指出重要部分)。
清单 2-6:标准 _start 函数的反汇编
1 | $ objdump -M intel -d a.out |
当你编写 C 程序时,总会有一个 main 函数,这是程序开始的地方。但如果你检查二进制文件的入口点,你会发现它并不是指向地址 0x400526 处的 main ➍。相反,它指向地址 0x400430,即 _start 的起始位置 ➊。
那么,程序执行是如何最终到达 main 的呢?仔细观察,你会发现 _start 在地址 0x40044d 处有一条指令,将 main 的地址移动到 rdi 寄存器 ➋,这是 x64 平台上用于传递函数调用参数的寄存器之一。接着,_start 调用一个名为 __libc_start_main 的函数 ➌。这个函数位于 .plt 段,意味着它是共享库的一部分(我将在 2.3.4 节中详细介绍这个内容)。
正如其名称所示,__libc_start_main 最终会调用 main 的地址,以开始执行用户定义的代码。
2.3.3 .bss、.data 和 .rodata 段
由于代码节通常是不可写的,变量通常被保存在一个或多个专用的可写节中。常量数据通常也会保存在单独的节中,以便保持二进制文件的整洁,尽管编译器确实有时会将常量数据输出到代码节中。(现代版本的gcc和clang通常不会混合代码和数据,但 Visual Studio 有时会这样做。)正如你在第六章中将看到的,这会使得反汇编变得更加困难,因为并不总是能清楚区分哪些字节是指令,哪些是数据。
.rodata节(即“只读数据”)用于存储常量值。由于它存储的是常量值,.rodata是不可写的。已初始化变量的默认值存储在.data节中,.data节是可写的,因为变量的值可能会在运行时改变。最后,.bss节为未初始化变量保留空间。该名称历史上代表“由符号启动的块”,指的是为(符号)变量保留内存块。
与.rodata和.data(类型为SHT_PROGBITS)不同,.bss节的类型是SHT_NOBITS。这是因为.bss在二进制文件中不占用任何字节,它仅仅是一个指令,用于在为二进制文件设置执行环境时为未初始化的变量分配合适大小的内存块。通常,位于.bss中的变量会被初始化为零,并且该节被标记为可写。
2.3.4 延迟绑定与 .plt、.got 和 .got.plt 节
在第一章中,我们讨论了当二进制文件被加载到进程中执行时,动态链接器会进行最后的重定位。例如,它会解析对位于共享库中的函数的引用,而共享库的加载地址在编译时尚未知道。我还简要提到,实际上,许多重定位通常不会在二进制文件加载时立即执行,而是会推迟到首次引用未解析位置时才执行。这被称为延迟绑定。
延迟绑定与 PLT
延迟绑定确保动态链接器不会在不必要的时候浪费时间进行重定位,它只会在运行时真正需要时才执行这些重定位。在 Linux 中,延迟绑定是动态链接器的默认行为。你可以通过导出名为LD_BIND_NOW的环境变量强制链接器立即执行所有重定位^(4),但通常只有在应用程序要求实时性能保证时才会这样做。
Linux ELF 二进制文件中的懒绑定是通过两个特殊段来实现的,分别是过程连接表(.plt)和全局偏移表(.got)。尽管以下讨论主要集中在懒绑定上,GOT 实际上用于的不仅仅是懒绑定。ELF 二进制文件通常包含一个单独的 GOT 段,称为.got.plt,用于与.plt一起在懒绑定过程中使用。.got.plt段与常规的.got段类似,你可以认为它们是一样的(实际上,它们在历史上是一样的)。^(5) 图 2-2 展示了懒绑定过程及 PLT 和 GOT 的作用。

图 2-2:通过 PLT 调用共享库函数
正如图示和列表 2-5 中readelf的输出所示,.plt是一个包含可执行代码的代码段,就像.text一样,而.got.plt是一个数据段。^(6) PLT 完全由格式明确的存根构成,专门用于将来自.text段的调用指令导向适当的库位置。为了探讨 PLT 的格式,我们来看一下示例二进制文件中.plt段的反汇编,如列表 2-7 所示。(为简洁起见,指令操作码已省略。)
列表 2-7: .plt 节的反汇编
1 | $ objdump -M intel -- .plt -d a.out |
PLT 的格式如下:首先是一个默认的存根 ➊,稍后我会讲解。接下来是一系列函数存根 ➋➍,每个库函数一个,所有存根遵循相同的模式。还要注意,对于每个连续的函数存根,压入栈中的值会递增 ➌➎。这个值是一个标识符,稍后我会解释它的作用。现在让我们探讨一下如列表 2-7 所示的 PLT 存根是如何让你调用共享库函数的,如图 2-2 所示,并且这如何有助于懒绑定过程。
使用 PLT 动态解析库函数
假设你想要调用puts函数,它是著名的libc库的一部分。你不能直接调用它(由于上述原因,这是不可能的),但是你可以调用对应的 PLT 存根puts@plt(如图 2-2 中的步骤 ➊)。
PLT 存根以一条间接跳转指令开始,该指令跳转到存储在.got.plt段中的地址(如图 2-2 中的步骤 ➋)。最初,在懒绑定发生之前,这个地址只是函数存根中下一条指令的地址,这是一条push指令。因此,间接跳转只是将控制权转移到它后面的指令(如图 2-2 中的步骤 ➌)!这是一种相当间接的方式来跳转到下一条指令,但这样做是有充分理由的,接下来你会看到为什么。
push指令将一个整数(在此情况下为0x0)压入栈中。如前所述,这个整数作为对应 PLT 存根的标识符。接下来,下一条指令跳转到所有 PLT 函数存根共享的公共默认存根(图 2-2 中的步骤➍)。默认存根推送另一个标识符(来自 GOT),标识可执行文件本身,然后跳转(再次通过 GOT 间接跳转)到动态链接器(图 2-2 中的步骤➎)。
利用 PLT 存根推送的标识符,动态链接器可以确定它应该解析puts的地址,并且应该代表加载到进程中的主可执行文件进行此操作。最后这一点很重要,因为同一个进程中可能还加载了多个库,每个库都有自己的 PLT 和 GOT。动态链接器接着查找puts函数所在的地址,并将该函数的地址插入到与puts@plt相关联的 GOT 条目中。于是,GOT 条目不再像最初那样指向 PLT 存根,而是现在指向puts的实际地址。此时,延迟绑定过程完成。
最终,动态链接器通过将控制转移到puts来满足调用puts的最初目的。对于任何后续调用puts@plt,GOT 条目已经包含了puts的适当(修补过的)地址,使得 PLT 存根开始时的跳转直接到puts,而无需涉及动态链接器(图中的步骤➏)。
为什么使用 GOT?
在这一点上,你可能会想,为什么需要 GOT 呢?例如,直接将解析后的库地址直接修补到 PLT 存根的代码中不是更简单吗?事情不能那样做的主要原因归结为安全性。如果二进制文件的某个地方存在漏洞(对于任何非平凡的二进制文件,肯定会有),攻击者就可以轻松修改二进制文件的代码,如果像.text和.plt这样的可执行部分是可写的。但由于 GOT 是数据段,并且它是允许被写入的,因此通过 GOT 进行额外的间接访问是合理的。换句话说,这一额外的间接层使得你能够避免创建可写的代码段。虽然攻击者仍可能成功修改 GOT 中的地址,但这种攻击模式远不如能够注入任意代码那样强大。
另一个原因与共享库中的代码共享性有关。如前所述,现代操作系统通过在所有使用共享库的进程之间共享库的代码来节省(物理)内存。这样,操作系统只需加载每个库的单一副本,而不是每个使用该库的进程都加载一份独立副本。然而,尽管每个库只有一个物理副本,但同一库可能会为每个进程映射到完全不同的虚拟地址。这意味着,不能将为库解析的地址直接打补丁到代码中,因为该地址只在某一个进程的上下文中有效,其他进程则无法使用。相反,将它们打补丁到 GOT 中是可行的,因为每个进程都有自己的私有 GOT 副本。
正如你可能已经猜到的,代码对可重定位数据符号(如从共享库导出的变量和常量)的引用也需要通过 GOT 进行重定向,以避免将数据地址直接打补丁到代码中。不同之处在于,数据引用直接通过 GOT,而没有经过 PLT 的中间步骤。这也澄清了 .got 和 .got.plt 段之间的区别:.got 用于数据项的引用,而 .got.plt 专门用于存储通过 PLT 访问的库函数的解析地址。
2.3.5 .rel. 和 .rela.* 段*
如你在示例二进制文件的 readelf 转储中看到的节头所示,有几个段的名称为 rela.*。这些段的类型是 SHT_RELA,意味着它们包含了链接器用于执行重定位的信息。本质上,所有 SHT_RELA 类型的段都是一个重定位项表,每个项都详细描述了一个需要应用重定位的特定地址,以及如何解析该地址需要插入的特定值。清单 2-8 显示了示例二进制文件中重定位段的内容。如你所见,只有动态重定位(由动态链接器执行)仍然存在,因为所有在目标文件中存在的静态重定位已经在静态链接时被解决了。在任何实际的二进制文件中(与这个简单的示例不同),当然会有更多的动态重定位。
清单 2-8:示例二进制文件中的重定位段
1 | $ readelf --relocs a.out |
这里有两种类型的重定位,分别叫做R_X86_64_GLOB_DAT和R_X86_64_JUMP_SLO。虽然在实际使用中你可能会遇到更多的类型,但这些是最常见和最重要的几种。所有重定位类型的共同点是它们指定了一个偏移量,用于应用重定位。如何计算在该偏移量插入的值在不同的重定位类型中有所不同,有时甚至相当复杂。你可以在 ELF 规范中找到所有这些细节,然而对于普通的二进制分析任务,你不需要了解它们。
在 Listing 2-8 中显示的第一个重定位,类型为R_X86_64_GLOB_DAT,其偏移量位于.got节中➊,你可以通过将偏移量与readelf输出中显示的.got基地址进行比较来判断。通常,这种类型的重定位用于计算数据符号的地址,并将其插入.got中的正确偏移位置。
R_X86_64_JUMP_SLO条目被称为跳转槽➋➌;它们的偏移量位于.got.plt节中,表示可以插入库函数地址的槽。如果你回顾一下在 Listing 2-7 中示例二进制的 PLT 转储,你会发现每个跳转槽都被 PLT 存根用于获取其间接跳转目标。跳转槽的地址(通过相对偏移计算得到rip寄存器的地址)出现在 Listing 2-7 输出的右侧,紧跟在#符号之后。
2.3.6 .dynamic 节
.dynamic节充当操作系统和动态链接器在加载和设置 ELF 二进制文件执行时的“路线图”。如果你忘记了加载过程是如何工作的,可能需要参考 Section 1.4。
.dynamic节包含一个Elf64_Dyn结构体的表(如*/usr/include/elf.h中所指定),也称为标签*。这些标签有不同的类型,每个类型都有一个相关的值。举个例子,来看一下示例二进制中.dynamic的内容,如 Listing 2-9 所示。
Listing 2-9:.dynamic 节的内容
1 | $ readelf --dynamic a.out |
如你所见,.dynamic节中每个标签的类型显示在第二列输出中。DT_NEEDED类型的标签告知动态链接器可执行文件的依赖关系。例如,二进制文件使用来自libc.so.6共享库的puts函数➊,因此在执行二进制文件时需要加载它。DT_VERNEED➋和DT_VERNEEDNUM➌标签指定了版本依赖表的起始地址和条目数,该表表示可执行文件各个依赖项的预期版本。
除了列出依赖关系之外,.dynamic 节还包含指向动态链接器所需的其他重要信息的指针(例如,动态字符串表、动态符号表、.got.plt 节,以及通过 DT_STRTAB、DT_SYMTAB、DT_PLTGOT 和 DT_RELA 类型标签指向的动态重定位节)。
2.3.7 .init_array 和 .fini_array 节
.init_array 节包含一个指向函数的指针数组,用作构造函数。当二进制文件被初始化时,这些函数会依次被调用,在调用 main 之前。前面提到的 .init 节包含一个启动函数,该函数执行一些启动可执行文件所需的关键初始化,而 .init_array 是一个数据节,可以包含任意数量的函数指针,包括指向你自定义构造函数的指针。在 gcc 中,你可以通过 __attribute__((constructor)) 修饰符将 C 源文件中的函数标记为构造函数。
在示例二进制文件中,.init_array 只包含一个条目。它是指向另一个默认初始化函数的指针,名为 frame_dummy,如 Listing 2-10 中的 objdump 输出所示。
Listing 2-10: .init_array 节的内容
1 | ➊ $ objdump -d --section .init_array a.out |
第一次调用 objdump 显示了 .init_array 的内容 ➊。正如你所见,输出中有一个单一的函数指针(以阴影显示),其中包含字节 00 05 40 00 00 00 00 00 ➋。这实际上是小端表示的地址 0x400500(通过反转字节顺序并去掉前导零得到)。第二次调用 objdump 显示,这确实是 frame_dummy 函数的起始地址 ➌。
正如你现在可能已经猜到的,.fini_array 类似于 .init_array,只是 .fini_array 包含的是析构函数的指针,而不是构造函数的指针。.init_array 和 .fini_array 中包含的指针是容易修改的,这使得它们成为插入钩子(例如添加初始化或清理代码来修改二进制行为)的方便位置。需要注意的是,旧版本的 gcc 生成的二进制文件可能包含 .ctors 和 .dtors 节,而不是 .init_array 和 .fini_array。
2.3.8 .shstrtab, .symtab, .strtab, .dynsym 和 .dynstr 节
正如在讨论节头时提到的,.shstrtab 节只是一个包含二进制文件中所有节名称的以 NULL 结尾的字符串数组。它通过节头进行索引,使得像 readelf 这样的工具能够找出节的名称。
.symtab 节包含一个符号表,这是一个 Elf64_Sym 结构的表,每个结构将一个符号名称与二进制文件中其他地方的代码或数据(如函数或变量)关联起来。实际包含符号名称的字符串位于 .strtab 节。这些字符串由 Elf64_Sym 结构指向。在实际操作中,您在二进制分析过程中遇到的二进制文件通常已经被剥离,这意味着 .symtab 和 .strtab 表会被移除。
.dynsym 和 .dynstr 部分类似于 .symtab 和 .strtab,不同之处在于它们包含的是动态链接所需的符号和字符串,而不是静态链接所需的。由于这些信息在动态链接过程中是必需的,因此它们不能被剥离。
请注意,静态符号表的节类型是 SHT_SYMTAB,而动态符号表的节类型是 SHT_DYNSYM。这使得像 strip 这样的工具能够轻松识别哪些符号表可以在剥离二进制文件时安全移除,哪些不能。
2.4 程序头
程序头表 提供了二进制文件的 段视图,与节头表提供的 节视图 相对。ELF 二进制文件的节视图,我之前已经讨论过,仅用于静态链接。而接下来我将讨论的段视图,则在操作系统和动态链接器加载 ELF 文件到进程中执行时使用,用于定位相关的代码和数据并决定将哪些内容加载到虚拟内存中。
一个 ELF 段包含零个或多个节,本质上将这些节捆绑成一个单独的块。由于段提供了执行视图,因此它们仅在可执行 ELF 文件中需要,而对于不可执行文件(如可重定位对象文件)则不需要。程序头表使用 struct Elf64_Phdr 类型的程序头来编码段视图。每个程序头包含列表 2-11 中所示的字段。
列表 2-11: /usr/include/elf.h 中 Elf64_Phdr 的定义
1 | typedef struct { |
我将在接下来的几个部分中描述这些字段。列表 2-12 显示了通过 readelf 展示的示例二进制文件的程序头表。
列表 2-12:通过 readelf 显示的典型程序头
1 | $ readelf --wide --segments a.out |
请注意 readelf 输出底部的节到段映射,它清楚地说明了段实际上只是将多个节捆绑在一起➊。这种特定的节到段映射是大多数 ELF 二进制文件的典型特征。在本节的其余部分,我将讨论列表 2-11 中所示的程序头字段。
2.4.1 p_type 字段
p_type 字段标识段的类型。该字段的重要值包括 PT_LOAD、PT_DYNAMIC 和 PT_INTERP。
PT_LOAD类型的段,顾名思义,在设置进程时应该加载到内存中。可加载块的大小和加载地址在其余的程序头中描述。正如你在readelf输出中看到的,通常至少有两个PT_LOAD段——一个包含不可写的部分,另一个包含可写的数据部分。
PT_INTERP段包含.interp部分,该部分提供了用于加载二进制文件的解释器名称。相应地,PT_DYNAMIC段包含.dynamic部分,告诉解释器如何解析并准备二进制文件以供执行。还值得一提的是PT_PHDR段,它包含程序头表。
2.4.2 p_flags 字段
标志指定段的运行时访问权限。共有三种重要的标志:PF_X、PF_W和PF_R。PF_X标志表示该段是可执行的,通常设置在代码段上(readelf在 Listing 2-12 中的Flg列会将其显示为E,而非X)。PF_W标志意味着该段是可写的,通常仅在可写数据段上设置,代码段不会设置此标志。最后,PF_R意味着该段是可读的,这通常适用于代码段和数据段。
2.4.3 p_offset、p_vaddr、p_paddr、p_filesz 和 p_memsz 字段
在 Listing 2-11 中,p_offset、p_vaddr和p_filesz字段类似于节头中的sh_offset、sh_addr和sh_size字段。它们分别指定段开始的文件偏移量、加载到的虚拟地址以及段的文件大小。对于可加载段,p_vaddr必须等于p_offset,其值模页面大小(通常为 4,096 字节)。
在某些系统中,可以使用p_paddr字段指定段在物理内存中的加载地址。在现代操作系统(如 Linux)中,此字段未使用,且值为零,因为它们将所有二进制文件加载到虚拟内存中执行。
初看起来,为什么段的文件大小(p_filesz)和内存中的大小(p_memsz)需要分别定义,可能不太明显。为了理解这一点,回想一下,有些段只表示需要在内存中分配一些字节,但实际上并不占用二进制文件中的这些字节。例如,.bss段包含零初始化的数据。由于该段中的所有数据已知本身就是零,因此不需要在二进制文件中实际包含这些零。然而,当将包含.bss的段加载到虚拟内存时,所有.bss中的字节应该被分配。因此,p_memsz有可能大于p_filesz。发生这种情况时,加载器在加载二进制文件时会在段的末尾添加额外的字节,并将它们初始化为零。
2.4.4 p_align 字段
p_align字段类似于节头中的sh_addralign字段。它表示段所需的内存对齐(以字节为单位)。就像sh_addralign一样,值为 0 或 1 表示不需要特定的对齐。如果p_align没有设置为 0 或 1,则其值必须是 2 的幂,且p_vaddr必须等于p_offset,模p_align。
2.5 总结
在本章中,你了解了 ELF 格式的所有细节。我讲解了可执行文件头部格式、节头和程序头表格的格式,以及节的内容。这是一次相当大的挑战!但这是值得的,因为现在你已经熟悉了 ELF 二进制文件的内部结构,你有了一个很好的基础,能够进一步学习二进制分析。在下一章中,你将详细了解 PE 格式,这是 Windows 系统中使用的二进制格式。如果你只对分析 ELF 二进制文件感兴趣,可以跳过下一章,直接进入第四章。
练习
- 手动检查头部
使用类似xxd的十六进制查看器以十六进制格式查看 ELF 二进制文件中的字节。例如,你可以使用命令xxd /bin/ls | head -n 30来查看*/bin/ls*程序的前 30 行字节。你能识别出表示 ELF 头部的字节吗?试着在xxd的输出中找到所有 ELF 头部字段,并看看这些字段的内容是否对你有意义。
- 节与段
使用readelf查看 ELF 二进制文件中的节和段。节是如何映射到段中的?请制作二进制文件的磁盘表示与内存表示的插图。它们之间有哪些主要差异?
- C 和 C++二进制文件
使用readelf反汇编两个二进制文件,一个是从 C 源代码编译而成,另一个是从 C++源代码编译而成。它们之间有什么区别?
- 延迟绑定
使用objdump反汇编 ELF 二进制文件的 PLT 段。PLT 存根使用了哪些 GOT 条目?现在查看这些 GOT 条目的内容(再次使用objdump),并分析它们与 PLT 的关系。
第三章:PE 格式:简要介绍
既然你已经了解了 ELF 格式,让我们简要看看另一个流行的二进制格式:可移植执行格式(PE 格式)。因为 PE 是 Windows 上主要使用的二进制格式,所以熟悉 PE 对于分析常见的 Windows 二进制文件,尤其是在恶意软件分析中,十分有用。
PE 是通用对象文件格式(COFF)的一个修改版本,COFF 在被 ELF 替代之前也曾在基于 Unix 的系统中使用。由于这个历史原因,PE 有时也被称为 PE/COFF。令人困惑的是,64 位版本的 PE 被称为 PE32+。由于 PE32+ 与原始 PE 格式只有很小的差异,我将简单地称其为“PE”。
在接下来的 PE 格式概述中,我将重点介绍它与 ELF 的主要区别,以防你需要在 Windows 平台上工作。与我在 ELF 中所做的详细介绍相比,我不会对 PE 进行过多的细节说明,因为 PE 不是本书的主要焦点。话虽如此,PE(以及大多数其他二进制格式)与 ELF 共享许多相似之处。既然你已经了解了 ELF,你会发现学习新的二进制格式变得更容易了!
我将围绕图 3-1 展开讨论。图中显示的数据结构定义在 WinNT.h 中,该文件包含在微软 Windows 软件开发工具包中。
3.1 MS-DOS 头部和 MS-DOS 存根
看一下图 3-1,你会看到它与 ELF 格式有很多相似之处,也有一些关键的不同之处。其中一个主要的区别是存在 MS-DOS 头部。没错,就是 MS-DOS,那个 1981 年的老微软操作系统!微软为何要在一个 supposedly 现代的二进制格式中包含这个东西呢?正如你可能猜到的,原因是为了向后兼容。
当 PE 被引入时,曾有一个过渡期,用户同时使用旧式的 MS-DOS 二进制文件和较新的 PE 二进制文件。为了让过渡不那么混乱,每个 PE 文件都以 MS-DOS 头部开始,这样它也可以被当作 MS-DOS 二进制文件解释,至少在某种程度上是如此。MS-DOS 头部的主要功能是描述如何加载和执行紧跟其后的 MS-DOS 存根。这个存根通常只是一个小型的 MS-DOS 程序,当用户在 MSDOS 中执行 PE 二进制文件时,它会替代主程序运行。MS-DOS 存根程序通常会打印出类似“该程序无法在 DOS 模式下运行”的字符串,然后退出。然而,原则上,它也可以是该程序的完整 MS-DOS 版本!
MS-DOS 头部以一个魔数值开始,由 ASCII 字符“MZ”组成。^(1) 因此,它有时也被称为 MZ 头部。对于本章的目的,MS-DOS 头部中唯一其他重要的字段是最后一个字段,叫做 e_lfanew。该字段包含了 PE 二进制文件开始的文件偏移量。因此,当一个支持 PE 的程序加载器打开二进制文件时,它可以读取 MS-DOS 头部,然后跳过它和 MS-DOS 存根,直接跳到 PE 头部的开始位置。
3.2 PE 签名、文件头和可选头
你可以将 PE 头部类比为 ELF 的可执行文件头,只是 PE 中的“可执行文件头”被拆分为三个部分:一个 32 位签名,一个 PE 文件头,和一个 PE 可选头。如果你查看 WinNT.h,你会看到有一个名为 IMAGE_NT_HEADERS64 的 struct,它包含了这三个部分。可以说,struct IMAGE_NT_HEADERS64 整体上就是 PE 版本的可执行文件头。然而,在实际使用中,签名、文件头和可选头被视为独立的实体。

图 3-1:PE32+ 二进制文件一览
在接下来的几个章节中,我将讨论这些头部组件的每个部分。为了查看所有头部元素的实际应用,我们来看一下 hello.exe,这是第一章 中 compilation_example 程序的 PE 版本。清单 3-1 显示了 hello.exe 中最重要的头部元素和 DataDirectory 的转储。我稍后会解释 DataDirectory 是什么。
清单 3-1:PE 头部和 DataDirectory 的示例转储
1 | $ objdump -x hello.exe |
3.2.1 PE 签名
PE 签名只是一个包含 ASCII 字符“PE”的字符串,后面跟着两个 NULL 字符。它类似于 ELF 可执行文件头中的 e_ident 字段中的魔法字节。
3.2.2 PE 文件头
文件头描述了文件的基本属性。最重要的字段有 Machine、NumberOfSections、SizeOfOptionalHeader 和 Characteristics。描述符号表的两个字段已经废弃,PE 文件不再使用嵌入的符号和调试信息。相反,这些符号会作为单独的调试文件的一部分进行选择性地输出。
与 ELF 的e_machine类似,Machine字段描述了 PE 文件所针对的机器架构。在这种情况下,它是 x86-64(定义为常量0x8664)➊。NumberOfSections字段仅表示区段头表中的条目数量,SizeOfOptionalHeader表示可选头的字节大小,该可选头位于文件头之后。Characteristics字段包含描述诸如二进制文件字节序、是否为 DLL、以及是否被剥离等内容的标志。如objdump的输出所示,示例二进制文件包含Characteristics标志,标识它为一个大地址感知的可执行文件➋。
3.2.3 PE 可选头
尽管名称上看起来是可选的,PE 可选头对于可执行文件而言实际上并非完全可选(尽管它可能在目标文件中缺失)。事实上,你可能会在任何遇到的 PE 可执行文件中发现 PE 可选头。它包含许多字段,下面我将讲解其中最重要的几个。
首先,有一个 16 位的魔法值,对于 64 位 PE 文件,它被设置为0x020b➌。还有几个字段描述了用来创建二进制文件的链接器的主版本号和次版本号,以及运行该二进制文件所需的最小操作系统版本。ImageBase字段➏描述了加载二进制文件时的地址(PE 二进制文件设计为加载到特定的虚拟地址)。其他指针字段包含相对虚拟地址(RVA),这些地址旨在与基址相加以推导出虚拟地址。例如,BaseOfCode字段➎指定了代码区段的基地址作为 RVA。因此,你可以通过计算ImageBase+BaseOfCode来找到代码区段的基虚拟地址。如你所猜测的那样,AddressOfEntryPoint字段➍包含了二进制文件的入口点地址,也以 RVA 形式指定。
在可选头中,可能最不直观的字段是DataDirectory数组➐。DataDirectory包含类型为IMAGE_DATA_DIRECTORY的struct条目,该结构包含一个 RVA 和一个大小。数组中的每个条目描述了二进制文件中某个重要部分的起始 RVA 和大小;该条目的具体解释取决于它在数组中的索引。最重要的条目是索引为 0 的,它描述了导出目录的基 RVA 和大小(基本上是一个导出函数的表);索引为 1 的条目描述了导入目录(一个导入函数的表);索引为 5 的条目描述了重定位表。当我讨论 PE 区段时,我会进一步讲解导出和导入表。DataDirectory基本上为加载器提供了一种快捷方式,使它能够快速查找特定的数据部分,而无需遍历区段头表。
3.3 区段头表
在大多数方面,PE 节头表与 ELF 的节头表类似。它是一个 IMAGE_SECTION_HEADER 结构体的数组,每个结构体描述一个节,标明其在文件和内存中的大小(SizeOfRawData 和 VirtualSize)、文件偏移和虚拟地址(PointerToRawData 和 VirtualAddress)、重定位信息以及任何标志(Characteristics)。其中一些标志描述节是否可执行、可读、可写,或这些特性的组合。与 ELF 节头表引用字符串表不同,PE 节头表使用一个简单的字符数组字段(恰当地命名为 Name)来指定节的名称。由于该数组只有 8 字节长,PE 节名称的长度限制为 8 个字符。
与 ELF 不同,PE 格式没有明确区分节和段。PE 文件最接近 ELF 执行视图的部分是 DataDirectory,它为加载程序提供了快速访问二进制文件中设置执行所需的某些部分的捷径。除此之外,没有单独的程序头表;节头表既用于链接也用于加载。
3.4 节
PE 文件中的许多部分可以直接与 ELF 部分进行比较,通常甚至有(几乎)相同的名称。列表 3-2 展示了 hello.exe 中各部分的概述。
列表 3-2:示例 PE 二进制文件中各部分的概述
1 | $ objdump -x hello.exe |
如 列表 3-2 中所示,.text 部分包含代码,.rdata 部分包含只读数据(大致相当于 ELF 中的 .rodata),而 .data 部分包含可读/可写数据。通常还会有一个 .bss 部分用于零初始化数据,尽管在这个简单的示例二进制文件中它缺失了。还有一个 .reloc 部分,包含重定位信息。一个需要注意的重要点是,像 Visual Studio 这样的 PE 编译器有时会将只读数据放在 .text 部分(与代码混合在一起),而不是放在 .rdata 中。这在反汇编时可能会导致问题,因为它可能会误将常量数据解释为指令。
3.4.1 .edata 和 .idata 部分
在 PE 文件中,最重要的部分是 .edata 和 .idata,它们在 ELF 中没有直接对应的部分,分别包含导出和导入函数的表格。DataDirectory 数组中的导出目录和导入目录条目指向这些部分。.idata 部分指定了二进制文件从共享库或 Windows 中的 DLL 导入的符号(函数和数据)。.edata 部分列出了二进制文件导出的符号及其地址。因此,为了解析外部符号的引用,加载程序需要将所需的导入与提供所需符号的 DLL 的导出表进行匹配。
实际上,你可能会发现没有单独的.idata和.edata 部分。事实上,它们在清单 3-2 中的示例二进制文件中也不存在!当这些部分不存在时,通常会将它们合并到.rdata中,但它们的内容和作用仍然保持不变。
当加载器解析依赖关系时,它会将解析后的地址写入导入地址表(IAT)中。类似于 ELF 中的全局偏移表,IAT 只是一个已解析指针的表格,每个指针占一个槽位。IAT 也是.idata部分的一部分,最初包含指向要导入的符号名称或标识号的指针。动态加载器随后将这些指针替换为指向实际导入函数或变量的指针。对库函数的调用实际上是对该函数的thunk的调用,thunk 不过是通过 IAT 槽位进行的间接跳转。清单 3-3 展示了 thunk 在实践中的样子。
清单 3-3:PE thunk 示例
1 | $ objdump -M intel -d hello.exe |
你会经常看到 thunks 被分组在一起,如清单 3-3 所示。请注意,跳转的目标地址从➊到➎都存储在导入目录中,位于.rdata部分,该部分从地址0x140002000开始。这些是 IAT 中的跳转槽位。
3.4.2 PE 代码段中的填充
顺便提一下,在反汇编 PE 文件时,你可能会注意到有很多int3指令。Visual Studio 将这些指令作为填充指令(而不是gcc使用的nop指令)以对齐内存中的函数和代码块,使其能够高效访问。^(2) int3指令通常由调试器用于设置断点;它会导致程序陷入调试器,或者如果没有调试器的话,则导致程序崩溃。由于填充指令并不打算被执行,所以这对于填充代码来说是没问题的。
3.5 小结
如果你已经完成了第二章和本章的内容,我为你的坚持点赞。阅读完本章后,你应该已经了解了 ELF 和 PE 之间的主要相似点和不同点。如果你对在 Windows 平台上分析二进制文件感兴趣,这将对你有所帮助。在下一章,你将动手开始构建第一个真正的二进制分析工具:一个可以加载 ELF 和 PE 二进制文件进行分析的二进制加载库。
习题
- 手动头部检查
就像在第二章中分析 ELF 二进制文件时一样,使用像xxd这样的十六进制查看器查看 PE 二进制文件中的字节。你可以使用之前相同的命令,xxd program.exe | head -n 30,其中program.exe是你的 PE 二进制文件。你能识别表示 PE 头部的字节并理解所有头部字段的含义吗?
2. 磁盘表示与内存表示
使用readelf查看 PE 二进制文件的内容。然后绘制该二进制文件在磁盘上的表示与其在内存中的表示之间的对比图。它们之间有什么主要区别?
3. PE 与 ELF
使用objdump反汇编一个 ELF 和一个 PE 二进制文件。二进制文件使用不同类型的代码和数据结构吗?你能分别识别出适用于 ELF 编译器和 PE 编译器的一些典型代码或数据模式吗?
第四章:使用 LIBBFD 构建二进制加载器
现在,你已经通过前几章对二进制文件有了扎实的理解,准备开始构建自己的分析工具了。在本书中,你将经常构建自己的工具来操作二进制文件。由于几乎所有这些工具都需要解析并(静态地)加载二进制文件,因此拥有一个提供此功能的通用框架是非常有意义的。在这一章中,我们将使用libbfd来设计和实现这样的框架,以加深你对二进制格式的理解。
在本书的第三部分中,你将再次看到二进制加载框架,该部分涵盖了构建你自己二进制分析工具的高级技术。在设计框架之前,我将简要介绍libbfd。
4.1 什么是 libbfd?
二进制文件描述符库^(1)(libbfd)提供了一个通用接口,用于读取和解析所有流行的二进制格式,并为各种架构编译。这包括针对 x86 和 x86-64 机器的 ELF 和 PE 文件。通过将二进制加载器基于libbfd,你可以自动支持所有这些格式,而无需实现任何格式特定的支持。
BFD 库是 GNU 项目的一部分,并被binutils套件中的许多应用程序使用,包括objdump、readelf和gdb。它提供了对所有常见二进制格式组件的通用抽象,例如描述二进制目标和属性的头文件、节列表、重定位集合、符号表等。在 Ubuntu 中,libbfd是binutils-dev包的一部分。
你可以在*/usr/include/bfd.h*中找到核心的libbfd API。^(2) 不幸的是,libbfd的使用可能有些笨重,因此我们不打算在这里解释它的 API,而是直接深入探索 API,同时实现二进制加载框架。
4.2 一个简单的二进制加载接口
在实现二进制加载器之前,让我们先设计一个易于使用的接口。毕竟,二进制加载器的整个目的是使加载二进制文件的过程尽可能简单,以便后续所有你将在本书中实现的二进制分析工具都能使用。它主要用于静态分析工具。请注意,这与操作系统提供的动态加载器完全不同,后者的工作是将二进制文件加载到内存中以执行,如第一章中讨论的那样。
让我们使二进制加载接口与底层实现无关,这意味着它不会暴露任何libbfd函数或数据结构。为了简化,我们还将保持接口尽可能基础,仅暴露你在后续章节中经常使用的二进制部分。例如,接口将省略如重定位之类的组件,这些通常与二进制分析工具无关。
清单 4-1 显示了描述二进制加载器将公开的基本 API 的 C++ 头文件。请注意,它位于 VM 上的 inc 目录中,而不是包含本章其他代码的 chapter4 目录中。原因是加载器在本书的所有章节中是共享的。
清单 4-1: inc/loader.h
1 |
|
如你所见,API 暴露了表示二进制不同组件的多个类。Binary 类是“根”类,表示整个二进制的抽象 ➌。除此之外,它还包含一个 Section 对象的 vector 和一个 Symbol 对象的 vector。Section 类 ➋ 和 Symbol 类 ➊ 分别表示二进制文件中包含的节和符号。
从核心来看,整个 API 仅围绕两个函数展开。第一个是 load_binary 函数 ➍,它接受一个二进制文件的名称(fname)、一个指向 Binary 对象的指针用于存储加载的二进制文件(bin),以及一个二进制类型的描述符(type)。它将请求的二进制文件加载到 bin 参数中,并在加载成功时返回 0,若加载失败则返回小于 0 的值。第二个函数是 unload_binary ➎,它只是接受一个指向先前加载的 Binary 对象的指针并将其卸载。
现在你已经熟悉了二进制加载器的 API,接下来我们来看看它是如何实现的。我将从讨论 Binary 类的实现开始。
4.2.1 Binary 类
正如其名称所示,Binary 类是一个完整二进制文件的抽象。它包含二进制文件的文件名、类型、架构、位宽、入口点地址,以及节和符号。二进制类型具有双重表示:type 成员包含一个数字类型标识符,而 type_str 包含二进制类型的字符串表示。同样的双重表示也用于架构。
有效的二进制类型在 enum BinaryType 中列举,包括 ELF(BIN_TYPE_ELF)和 PE(BIN_TYPE_PE)。还有一个 BIN_TYPE_AUTO,你可以将其传递给 load_binary 函数,要求它自动判断二进制文件是 ELF 还是 PE 文件。类似地,有效的架构在 enum BinaryArch 中列举。对于这些目的,唯一有效的架构是 ARCH_X86。这包括 x86 和 x86-64;两者之间的区别由 Binary 类的 bits 成员表示,x86 设置为 32 位,x86-64 设置为 64 位。
通常,你可以通过分别迭代 Binary 类中的 sections 和 symbols 向量来访问节和符号。由于二进制分析通常关注 .text 节中的代码,因此还有一个名为 get_text_section 的便捷函数,顾名思义,它会自动查找并返回该节。
4.2.2 Section 类
段由Section类型的对象表示。Section类是一个简单的包装器,用于表示段的主要属性,包括段的名称、类型、起始地址(vma成员)、大小(以字节为单位)以及该段包含的原始字节。为了方便,还提供了一个指向包含Section对象的Binary的指针。段类型由enum SectionType值表示,指示该段是包含代码(SEC_TYPE_CODE)还是数据(SEC_TYPE_DATA)。
在分析过程中,你通常需要检查特定的指令或数据片段属于哪个段。因此,Section类有一个名为contains的函数,它接受一个代码或数据地址,并返回一个bool值,指示该地址是否属于该段。
4.2.3 符号类
如你所知,二进制文件包含许多类型的符号,包括本地和全局变量、函数、重定位表达式、对象等。为了简化,加载器接口只暴露了一种符号类型:函数符号。它们特别有用,因为当函数符号可用时,它们使得你可以轻松地实现函数级别的二进制分析工具。
加载器使用Symbol类来表示符号。该类包含一个符号类型,表示为enum SymbolType,其唯一有效值为SYM_TYPE_FUNC。此外,类还包含符号描述的函数的符号名称和起始地址。
4.3 实现二进制加载器
现在二进制加载器有了明确的接口,我们开始实现它吧!这就是libbfd发挥作用的地方。由于完整的加载器代码较长,我会将其分成几个部分,一一讨论。在以下代码中,你可以通过bfd_前缀识别libbfd的 API 函数(也有一些以_bfd结尾的函数,但它们是加载器定义的函数)。
首先,你当然需要包含所有需要的头文件。我不会提及加载器使用的所有标准 C/C++ 头文件,因为这些内容在这里不重要(如果你真的需要,可以在虚拟机上查看加载器的源码)。需要特别提到的是,所有使用libbfd的程序都必须包含bfd.h,如 Listing 4-2 所示,并通过指定链接器标志-lbfd来链接libbfd。除了bfd.h之外,加载器还包含了前一部分中创建的接口所在的头文件。
Listing 4-2: inc/loader.cc
1 |
说到这,接下来要看的代码部分是load_binary和unload_binary,这是加载器接口暴露的两个入口函数。Listing 4-3 展示了这两个函数的实现。
Listing 4-3: inc/loader.cc (续)
1 | int |
load_binary ➊ 的工作是解析由文件名指定的二进制文件,并将其加载到传入的 Binary 对象中。这是一个有点繁琐的过程,因此 load_binary 明智地将这项工作推迟给另一个函数,叫做 load_binary_bfd ➋。稍后我会讨论这个函数。
首先,让我们看一下 unload_binary ➌。和许多事情一样,销毁一个 Binary 对象要比创建一个容易得多。为了卸载 Binary 对象,加载器必须释放(使用 free)所有 Binary 的动态分配组件。幸运的是,这些组件并不多:只有每个 Section 的 bytes 成员是动态分配的(使用 malloc)。因此,unload_binary 只需遍历所有 Section 对象 ➍,并为它们逐个释放 bytes 数组 ➎。现在你已经了解了卸载二进制文件的工作原理,让我们更详细地看看如何使用 libbfd 实现加载过程。
4.3.1 初始化 libbfd 并打开二进制文件
在上一节中,我承诺会向你展示 load_binary_bfd,这个函数使用 libbfd 来处理加载二进制文件的所有工作。在此之前,我得先处理一个先决条件。也就是说,要解析并加载二进制文件,你首先必须打开它。打开二进制文件的代码实现于一个名为 open_bfd 的函数中,具体代码见 Listing 4-4。
Listing 4-4: inc/loader.cc (续)
1 | static bfd* |
open_bfd 函数使用 libbfd 来确定由文件名(fname 参数)指定的二进制文件的属性,打开它,然后返回一个指向该二进制文件的句柄。在使用 libbfd 之前,你必须调用 bfd_init ➊ 来初始化 libbfd 的内部状态(或者像文档中所说的那样,初始化“神奇的内部数据结构”)。由于这只需要做一次,open_bfd 使用静态变量来跟踪初始化是否已经完成。
在初始化 libbfd 后,你调用 bfd_openr 函数,通过文件名打开二进制文件 ➋。bfd_openr 的第二个参数允许你指定目标(二进制文件的类型),但在本例中,我将其设置为 NULL,这样 libbfd 会自动确定二进制文件的类型。bfd_openr 的返回值是一个指向类型为 bfd 的文件句柄的指针;这是 libbfd 的根数据结构,你可以将其传递给所有其他 libbfd 函数来对二进制文件执行操作。如果发生错误,bfd_openr 会返回 NULL。
一般来说,每当发生错误时,你可以通过调用bfd_get_error来找到最近的错误类型。该函数返回一个bfd_error_type类型的对象,你可以将其与预定义的错误标识符进行比较,比如bfd_error_no_memory或bfd_error_invalid_target,从而判断如何处理该错误。通常,你可能只想退出并显示错误信息。为此,bfd_errmsg函数可以将bfd_error_type转换为描述错误的字符串,供你打印到屏幕上➌。
在获得二进制文件的句柄后,你应该使用bfd_check_format函数检查二进制文件的格式 ➍。该函数接受一个bfd句柄和一个bfd_format值,后者可以设置为bfd_object、bfd_archive或bfd_core。在这种情况下,加载器将其设置为bfd_object,以验证打开的文件是否确实是一个对象,在libbfd术语中,这意味着可执行文件、可重定位对象或共享库。
在确认处理的是bfd_object之后,加载器手动将libbfd的错误状态设置为bfd_error_no_error➎。这是对一些版本的libbfd中的一个问题的变通方法,这些版本在检测格式之前就设置了bfd_error_wrong_format错误,并且即使格式检测没有问题,也会保留该错误状态。
最后,加载器通过使用bfd_get_flavour函数检查二进制文件是否具有已知的“风味”➏。该函数返回一个bfd_flavour对象,表示二进制文件的类型(如 ELF、PE 等)。有效的bfd_flavour值包括bfd_target_msdos_flavour、bfd_target_coff_flavour和bfd_target_elf_flavour。如果二进制格式未知或发生错误,get_bfd_flavour将返回bfd_target_unknown_flavour,在这种情况下,open_bfd会打印错误并返回NULL。
如果所有检查都通过,说明你已成功打开一个有效的二进制文件,并准备开始加载其内容!open_bfd函数返回它所打开的bfd句柄,供你在后续的libbfd API 调用中使用,如下几个清单所示。
4.3.2 解析基本二进制属性
现在你已经看过了打开二进制文件所需的代码,是时候看一下load_binary_bfd函数了,见清单 4-5。回想一下,这是处理所有实际解析和加载工作的函数,代表load_binary函数。在本节中,目的是将有关二进制文件的所有有趣细节加载到由bin参数指向的Binary对象中。
清单 4-5: inc/loader.cc (续)
1 | static int |
load_binary_bfd函数首先使用刚刚实现的open_bfd函数打开fname参数指定的二进制文件,并获取一个指向该二进制文件的bfd句柄➊。然后,load_binary_bfd设置一些bin的基本属性。它首先复制二进制文件的名称,并使用libbfd查找并复制入口点地址➋。
要获取二进制文件的入口点地址,可以使用bfd_get_start_address,它简单地返回bfd对象中start_address字段的值。起始地址是一个bfd_vma,本质上就是一个 64 位无符号整数。
接下来,加载器收集有关二进制类型的信息:它是 ELF、PE 格式,还是其他不受支持的类型?你可以在libbfd维护的bfd_target结构中找到这些信息。要获取指向这个数据结构的指针,只需要访问bfd句柄中的xvec字段。换句话说,bfd_h->xvec给你一个指向bfd_target结构的指针。
除其他外,这个结构提供了一个包含目标类型名称的字符串。加载器将这个字符串复制到Binary对象中 ➌。接下来,它通过switch语句检查bfd_h->xvec->flavour字段,并根据该字段设置Binary的类型 ➍。加载器仅支持 ELF 和 PE 格式,因此如果bfd_h->xvec->flavour表示任何其他类型的二进制文件,它将产生错误。
现在你已经知道二进制文件是 ELF 还是 PE 格式,但还不知道它的架构。要找出这一点,可以使用libbfd的bfd_get_arch_info函数 ➎。顾名思义,这个函数返回一个指向数据结构的指针,该结构提供有关二进制架构的信息。这个数据结构被称为bfd_arch_info_type。它提供了一个方便的可打印字符串,描述了架构,加载器将这个字符串复制到Binary对象中 ➏。
bfd_arch_info_type数据结构还包含一个名为mach的字段 ➐,它只是一个表示架构的整数标识符(在libbfd术语中称为machine)。这种架构的整数表示允许使用方便的switch语句来实现特定架构的处理。如果mach等于bfd_mach_i386_i386,则表示它是一个 32 位 x86 二进制文件,加载器将相应地设置Binary中的字段。如果mach为bfd_mach_x86_64,则它是一个 x86-64 二进制文件,加载器再次设置相应的字段。任何其他类型都不受支持,并会导致错误。
现在你已经了解了如何解析有关二进制类型和架构的基本信息,是时候进行实际的工作了:加载二进制文件中包含的符号和段。正如你想象的那样,这并不像你到目前为止看到的那么简单,因此加载器将必要的工作推迟到专门的函数中,这些函数将在接下来的章节中描述。加载器用来加载符号的两个函数分别称为load_symbols_bfd和load_dynsym_bfd ➑。正如接下来章节所述,它们分别从静态和动态符号表中加载符号。加载器还实现了load_sections_bfd,这是一个专门用于加载二进制文件段的函数 ➒。我将在第 4.3.4 节中详细讨论它。
在加载完符号和段之后,你将把所有感兴趣的信息复制到你自己的Binary对象中,这意味着你已经完成了对libbfd的使用。因为bfd句柄不再需要,所以加载器使用bfd_close ➓关闭它。如果在完全加载二进制之前发生任何错误,它也会关闭句柄。
4.3.3 加载符号
清单 4-6 显示了load_symbols_bfd函数的代码,用于加载静态符号表。
清单 4-6: inc/loader.cc (续)
1 | static int |
在libbfd中,符号通过asymbol结构表示,实际上它是struct bfd_symbol的简称。反过来,符号表只是一个asymbol**,意味着一个指向符号的指针数组。因此,load_symbols_bfd的工作是填充在➊声明的asymbol指针数组,然后将感兴趣的信息复制到Binary对象中。
load_symbols_bfd的输入参数是一个bfd句柄和一个用于存储符号信息的Binary对象。在加载任何符号指针之前,你需要分配足够的空间来存储它们。bfd_get_symtab_upper_bound函数 ➋会告诉你为此分配多少字节。如果出现错误,字节数为负;如果为零,则表示没有符号表。如果没有符号表,load_symbols_bfd就会完成并直接返回。
如果一切正常,且符号表包含正字节数,你会分配足够的空间来存储所有的asymbol指针 ➌。如果malloc成功,你就可以准备好让libbfd来填充你的符号表!你可以通过bfd_canonicalize_symtab函数 ➍来实现,这个函数接受你的bfd句柄和你要填充的符号表(即你的asymbol**)作为输入。按照要求,libbfd将正确填充你的符号表,并返回它在表中放置的符号数量(如果该数字为负,则说明出现了问题)。
现在你已经有了填充的符号表,你可以遍历它包含的所有符号 ➎。回想一下,对于二进制加载器,你只对函数符号感兴趣。因此,对于每个符号,你检查是否设置了BSF_FUNCTION标志,这表示它是一个函数符号 ➏。若是这样,你就为Binary对象中的Symbol(回想一下,这是加载器自己用来存储符号的类)预留空间,通过向包含所有已加载符号的vector中添加条目来实现。你将新创建的Symbol标记为函数符号 ➐,复制符号名称 ➑,并设置Symbol的地址 ➒。要获取函数符号的值,即函数的起始地址,你可以使用libbfd提供的bfd_asymbol_value函数。
现在,所有有趣的符号都已被复制到Symbol对象中,加载器不再需要libbfd的表示。因此,当load_symbols_bfd完成时,它会释放为存储libbfd符号所保留的空间➓。之后,它返回,符号加载过程完成。
这就是如何通过libbfd从静态符号表加载符号的过程。那么,动态符号表是如何完成的呢?幸运的是,过程几乎完全相同,正如你在 Listing 4-7 中看到的那样。
Listing 4-7: inc/loader.cc (续)
1 | static int |
在 Listing 4-7 中展示的从动态符号表加载符号的函数被恰当地命名为load_dynsym_bfd。如你所见,libbfd使用相同的数据结构(asymbol)来表示静态和动态符号➊。与之前展示的load_symbols_bfd函数的唯一区别如下。首先,为了找到你需要为符号指针保留的字节数,你调用bfd_get_dynamic_symtab_upper_bound ➋,而不是bfd_get_symtab_upper_bound。其次,为了填充符号表,你使用bfd_canonicalize_dynamic_symtab ➌,而不是bfd_canonicalize_symtab。就这些!其余的动态符号加载过程与静态符号的加载过程相同。
4.3.4 加载节
加载符号后,剩下的事情只有一件,尽管这可能是最重要的一步:加载二进制文件的节。Listing 4-8 展示了load_sections_bfd是如何实现这一功能的。
Listing 4-8: inc/loader.cc (续)
1 | static int |
为了存储节,libbfd使用一种叫做asection的数据结构,也称为struct bfd_section。在内部,libbfd保持一个asection结构的链表来表示所有节。加载器保留一个asection*来遍历这个列表➊。
要遍历所有的节,你需要从第一个节开始(由bfd_h->sections指向,这是libbfd的节列表头),然后跟随每个asection对象中包含的next指针➋。当next指针为NULL时,你就到达了列表的末尾。
对于每个节,加载器首先检查是否应该加载它。由于加载器只加载代码和数据节,它首先获取节的标志来检查节的类型。为了获取标志,它使用bfd_get_section_flags ➌。然后,它检查是否设置了SEC_CODE或SEC_DATA标志 ➍。如果没有,它就跳过该节,继续处理下一个。如果设置了其中任一标志,则加载器为相应的Section对象设置节类型,并继续加载该节。
除了节类型,加载器还会复制每个代码或数据节的虚拟地址、大小(以字节为单位)、名称和原始字节。要找到libbfd节的虚拟基地址,可以使用bfd_section_vma ➎。类似地,可以使用bfd_section_size ➏和bfd_section_name ➐分别获取节的大小和名称。如果节没有名称,bfd_section_name将返回NULL。
现在,加载器将节的实际内容复制到Section对象中。为此,它在Binary ➑中保留一个Section,并复制它刚刚读取的所有字段。然后,它在Section的bytes成员中分配足够的空间来容纳节中的所有字节 ➒。如果malloc成功,它会使用bfd_get_section_contents函数 ➓将所有节字节从libbfd节对象复制到Section中。它所接受的参数包括bfd句柄、指向相关asection对象的指针、用于存储节内容的目标数组、复制的起始偏移量以及要复制的字节数。为了复制所有字节,起始偏移量为 0,复制字节的数量等于节的大小。如果复制成功,bfd_get_section_contents返回true;否则返回false。如果一切顺利,加载过程就完成了!
4.4 测试二进制加载器
让我们创建一个简单的程序来测试新的二进制加载器。该程序将接受一个二进制文件名作为输入,使用加载器加载该二进制文件,然后显示关于加载内容的一些诊断信息。清单 4-9 展示了测试程序的代码。
清单 4-9: loader_demo.cc
1 |
|
这个测试程序加载作为第一个参数传递给它的二进制文件 ➊,然后显示一些关于该二进制文件的基本信息,如文件名、类型、架构和入口点 ➋。接着,它会打印每个节的基地址、大小、名称和类型 ➌,最后显示所有找到的符号 ➍。然后,它会卸载二进制文件并返回 ➎。尝试在虚拟机中运行loader_demo程序!你应该看到类似于清单 4-10 的输出。
清单 4-10: 加载器测试程序的示例输出
1 | $ loader_demo /bin/ls |
4.5 总结
在第一章到第三章中,你学习了有关二进制格式的所有内容。在本章中,你学习了如何加载这些二进制文件,为后续的二进制分析做准备。在这个过程中,你还了解了libbfd,这是一个常用的二进制加载库。现在你已经拥有了一个功能齐全的二进制加载器,准备继续学习二进制分析技术。在本书的第二部分中,你将学习一些基本的二进制分析技术,在第三部分中,你将使用加载器来实现自己的二进制分析工具。
习题
- 转储节内容
为了简洁,当前版本的loader_demo程序没有显示段内容。扩展程序,使其能够接受一个二进制文件和一个段名作为输入,然后以十六进制格式将该段的内容转储到屏幕上。
- 覆盖弱符号
有些符号是弱的,这意味着它们的值可能会被另一个非弱符号覆盖。目前,二进制加载器没有考虑这一点,而是简单地存储所有符号。扩展二进制加载器,使其在弱符号被其他符号覆盖时,仅保留最新版本。查看*/usr/include/bfd.h*以找出需要检查的标志。
- 打印数据符号
扩展二进制加载器和loader_demo程序,使它们能够处理本地和全局数据符号以及函数符号。你需要在加载器中添加数据符号的处理,向Symbol类中添加一个新的SymbolType,并在loader_demo程序中添加代码,以将数据符号打印到屏幕上。务必在一个未剥离的二进制文件上测试你的修改,以确保数据符号的存在。请注意,数据项在符号术语中被称为对象。如果你对输出的正确性有疑问,可以使用readelf来验证。
第二部分
第五章:在 Linux 中进行基础二进制分析
即使在最复杂的二进制分析中,你也可以通过以正确的方式结合一组基本工具来完成令人惊讶的高级任务。这可以节省你自己实现等效功能的数小时工作。在本章中,你将学习在 Linux 上进行二进制分析所需的基本工具。
我不会仅仅列出工具并解释它们的作用,而是通过一个 Capture the Flag (CTF) 挑战来演示它们是如何工作的。在计算机安全和黑客攻击中,CTF 挑战通常作为竞赛进行,目标通常是分析或利用给定的二进制文件(或正在运行的进程或服务器),直到你成功捕获隐藏在二进制中的旗标。旗标通常是一个十六进制字符串,你可以用它来证明你完成了挑战,并解锁新的挑战。
在这个 CTF 中,你从一个神秘的文件 payload 开始,它位于本章的虚拟机目录中。目标是找出如何从 payload 中提取隐藏的旗标。在分析 payload 并寻找旗标的过程中,你将学习使用一系列可以在几乎所有基于 Linux 的系统上找到的基础二进制分析工具(大多数工具是 GNU coreutils 或 binutils 的一部分)。我鼓励你跟随并实践。
你将看到的大多数工具都有许多有用的选项,但在本章中无法全面覆盖所有选项。因此,建议你在虚拟机上使用命令 man tool 查看每个工具的手册页。本章结束时,你将使用恢复的旗标来解锁新的挑战,之后你可以独立完成它!
5.1 使用 file 解决身份危机
因为你没有任何关于 payload 内容的提示,所以你完全不知道该如何处理这个文件。当发生这种情况时(例如,在逆向工程或取证场景中),一个好的第一步是弄清楚关于文件类型和内容的所有信息。file 工具就是为此设计的;它接受多个文件作为输入,然后告诉你每个文件的类型。你可能还记得在第二章中,我使用 file 来确定 ELF 文件的类型。
file 的优点在于它不会被文件扩展名所迷惑。相反,它会在文件中搜索其他特征模式,例如 ELF 文件开头的 0x7f ELF 魔法字节序列,从而判断文件类型。这在这里非常适用,因为 payload 文件没有扩展名。以下是 file 告诉你关于 payload 的信息:
1 | $ file payload |
正如你所看到的,payload 包含 ASCII 文本。要详细检查文本,你可以使用 head 工具,它会将文本文件的前几行(默认 10 行)输出到 stdout。还有一个类似的工具叫做 tail,它显示文件的最后几行。以下是 head 工具输出的内容:
1 | $ head payload |
这显然不像是人类可读的内容。仔细观察文件中使用的字母表,你会发现它只由字母数字字符和字符 + 与 / 组成,并且按整齐的行排列。当你看到这样的文件时,通常可以安全地假设它是一个Base64文件。
Base64 是一种广泛使用的将二进制数据编码为 ASCII 文本的方法。除其他外,它常用于电子邮件和网络上,以确保通过网络传输的二进制数据不会因只能处理文本的服务而被意外损坏。方便的是,Linux 系统自带了一个名为base64的工具(通常是 GNU coreutils的一部分),可以进行 Base64 编码和解码。默认情况下,base64会编码任何传递给它的文件或stdin输入。但你可以使用-d标志告诉base64进行解码。让我们解码payload看看会得到什么!
1 | $ base64 -d payload > decoded_payload |
这个命令解码payload,然后将解码后的内容存储在一个名为decoded_payload的新文件中。现在你已经解码了payload,让我们再次使用file来检查解码后的文件类型。
1 | $ file decoded_payload |
现在你有了进展!事实证明,在 Base64 编码层背后,神秘的文件实际上只是一个压缩归档文件,使用gzip作为外部压缩层。这是介绍file的另一个实用功能的好机会:能够窥视压缩文件内部。你可以通过为file传递-z选项,查看归档中的内容而无需解压。你应该会看到如下内容:
1 | $ file -z decoded_payload |
你可以看到你正在处理多个需要提取的层,因为最外层是一个gzip压缩层,而里面是一个tar归档文件,通常包含一组文件。为了查看存储在其中的文件,你可以使用tar解压并提取decoded_payload,像这样:
1 | $ tar xvzf decoded_payload |
如tar日志所示,从归档中提取了两个文件:ctf和67b8601。让我们再次使用file,看看你正在处理哪些类型的文件。
1 | $ file ctf |
第一个文件,ctf,是一个动态链接的 64 位精简 ELF 可执行文件。第二个文件,名为67b8601,是一个 512 × 512 像素的位图(BMP)文件。你可以通过如下命令使用file看到这一点:
1 | $ file 67b8601 |
这个 BMP 文件展示了一个黑色方块,正如你在图 5-1a 中看到的那样。如果你仔细观察,你应该能看到图底部有一些颜色不规则的像素。图 5-1b 显示了这些像素的放大片段。
在探索这些含义之前,让我们先仔细看一下你刚刚提取的ctf ELF 文件。

图 5-1:提取的 BMP 文件,67b8601
5.2 使用 ldd 探索依赖关系
尽管运行未知的二进制文件并不明智,但由于你在虚拟机中工作,我们还是尝试运行提取的ctf二进制文件。当你尝试运行该文件时,你并没有走得太远。
1 | $ ./ctf |
在任何应用程序代码执行之前,动态链接器就抱怨缺少一个名为 lib5ae9b7f.so 的库。这听起来不像是你在任何系统上通常会找到的库。在搜索这个库之前,先检查一下 ctf 是否还有其他未解决的依赖项是有意义的。
Linux 系统带有一个名为 ldd 的程序,你可以用它来查找一个二进制文件依赖的共享对象,以及这些依赖项在你的系统上的位置(如果有的话)。你甚至可以使用 ldd 配合 -v 参数来查看二进制文件期望的库版本,这在调试时非常有用。正如 ldd man 页面中提到的那样,ldd 可能会运行该二进制文件来确定其依赖项,因此在运行不信任的二进制文件时不安全,除非你在虚拟机或其他隔离环境中运行它。以下是 ctf 二进制文件的 ldd 输出:
1 | $ ldd ctf |
幸运的是,除了之前识别出的缺失库 lib5ae9b7f.so 之外,没有其他未解决的依赖项。现在你可以专注于弄清楚这个神秘的库是什么,以及如何获取它来捕获旗帜!
因为从库名来看,很明显你不会在任何标准仓库中找到它,所以它一定存在于你目前为止得到的文件中。回想一下第二章,所有 ELF 二进制文件和库都以魔术序列 0x7f ELF 开头。这个字符串对于寻找丢失的库非常有用;只要库没有加密,你应该能够通过这种方式找到 ELF 头。我们来尝试一下简单的 grep 查找字符串 'ELF'。
1 | $ grep 'ELF' * |
正如预期的那样,字符串 'ELF' 出现在 ctf 中,这并不奇怪,因为你已经知道它是一个 ELF 二进制文件。但你可以看到这个字符串也出现在 67b8601 中,乍一看,这似乎是一个无害的位图文件。难道位图的像素数据中隐藏了一个共享库?这倒可以解释你在图 5-1b 中看到的那些奇怪颜色的像素!让我们更详细地检查 67b8601 的内容,看看能否找到答案。
快速查找 ASCII 代码
在将原始字节解释为 ASCII 时,你通常需要一个表格,将不同表示形式的字节值映射到 ASCII 符号。你可以使用一个名为 man ascii 的特殊手册页来快速访问此类表格。以下是从 man ascii 提取的表格片段:
Oct |
Dec |
Hex |
字符 |
Oct |
Dec |
Hex |
字符 |
|---|---|---|---|---|---|---|---|
000 |
0 |
00 |
NUL '\0' (空字符) |
100 |
64 |
40 |
@ |
001 |
1 |
01 |
SOH (标题开始) |
101 |
65 |
41 |
A |
002 |
2 |
02 |
STX (文本开始) |
102 |
66 |
42 |
B |
003 |
3 |
03 |
ETX (文本结束) |
103 |
67 |
43 |
C |
004 |
4 |
04 |
EOT (传输结束) |
104 |
68 |
44 |
D |
005 |
5 |
05 |
ENQ (查询) |
105 |
69 |
45 |
E |
006 |
6 |
06 |
ACK (acknowledge) |
106 |
70 |
46 |
F |
007 |
7 |
07 |
BEL '\a' (bell) |
107 |
71 |
47 |
G |
... |
如你所见,这是一种快速查找从八进制、十进制和十六进制编码到 ASCII 字符映射的方法。比起在 Google 上查找 ASCII 表,这要快得多!
5.3 使用 xxd 查看文件内容
要发现文件中究竟包含什么内容,而又不能依赖于关于文件内容的任何标准假设,你必须在字节级别进行分析。为此,你可以使用任何数字系统来显示屏幕上的位和字节。例如,你可以使用二进制系统,逐个显示所有的 1 和 0。但由于这种方法分析起来非常繁琐,最好使用十六进制系统。在十六进制系统中(也称为基数 16,简称hex),数字从 0 到 9(含普通意义)开始,接着是 a 到 f(其中 a 表示值 10,f 表示值 15)。此外,由于一个字节有 256 = 16 × 16 种可能的值,它正好可以用两位十六进制数字表示,这使得它成为一个方便的编码方式,用于紧凑地显示字节。
要以十六进制表示文件的字节,你可以使用十六进制转储程序。十六进制编辑器是一个也可以编辑文件字节的程序。我将在第七章中详细讲解十六进制编辑,但现在我们先使用一个简单的十六进制转储程序叫做xxd,它默认安装在大多数 Linux 系统中。
这是你正在分析的位图文件通过xxd命令输出的前 15 行内容:
1 | $ xxd 67b8601 | head -n 15 |
如你所见,第一列输出显示了文件的偏移量,以十六进制格式表示。接下来的八列显示文件中字节的十六进制表示,在输出的最右侧,你可以看到相同字节的 ASCII 表示。
你可以使用 xxd 程序的 -c 选项来更改每行显示的字节数。例如,xxd -c 32 会每行显示 32 个字节。你还可以使用 -b 选项显示二进制而不是十六进制,并且可以使用 -i 选项输出一个包含字节的 C 风格数组,你可以直接将其包含在 C 或 C++ 源代码中。要仅输出文件中的部分字节,你可以使用 -s(寻址)选项指定开始的位置,并可以使用 -l(长度)选项指定要转储的字节数。
在位图文件的 xxd 输出中,ELF 魔术字节出现在偏移 0x34 ➊ 处,对应十进制的 52。这告诉你文件中可能的 ELF 库开始的位置。不幸的是,确定它结束的位置并不那么简单,因为 ELF 文件的末尾没有魔术字节作为分界。因此,在尝试提取完整的 ELF 文件之前,先提取 ELF 头部会更容易,因为你知道 64 位 ELF 头部正好包含 64 个字节。然后,你可以检查 ELF 头部,以确定完整文件的大小。
要提取头部,你可以使用 dd 从位图文件的偏移 52 处开始,复制 64 字节到一个名为 elf_header 的新输出文件中。
1 | $ dd skip=52 count=64 if=67b8601 of=elf_header bs=1 |
使用 dd 在这里只是偶然的,因此我不会详细解释。不过,dd 是一个非常多功能的^(1) 工具,如果你不熟悉它,值得阅读它的手册页。
让我们再次使用 xxd 来查看它是否有效。
1 | $ xxd elf_header |
看起来像是 ELF 头部!你可以清楚地看到起始处的魔术字节 ➊,并且还可以看到 e_ident 数组和其他字段看起来合理(有关这些字段的描述,请参考第二章)。
5.4 使用 readelf 解析提取的 ELF
要查看你刚提取的 ELF 头部的详细信息,最好使用 readelf,就像你在第二章中做的那样。但如果 ELF 文件损坏,仅包含一个头部,readelf 还能工作吗?让我们在清单 5-1 中找出答案!
清单 5-1:提取的 ELF 头部的 readelf 输出
1 | ➊ $ readelf -h elf_header |
-h 选项 ➊ 告诉 readelf 仅打印可执行头部。它仍然抱怨节区头表和程序头表的偏移量指向文件之外,但这没关系。关键是,你现在可以方便地查看提取的 ELF 头部。
那么,如何仅凭可执行头部来计算完整 ELF 的大小呢?在第二章的图 2-1 中,你已经学到 ELF 文件的最后部分通常是节区头表,而节区头表的偏移量是在可执行头部中给出的 ➋。可执行头部还告诉你每个节区头的大小 ➌ 和节区头表中的节区头数量 ➍。这意味着你可以通过以下方式计算出隐藏在位图文件中的完整 ELF 库的大小:

在这个方程式中,size 是完整库的大小,eshoff* 是节区头表的偏移量,*eshnum 是节区头表中的节区头数量,e_shentsize 是每个节区头的大小。
现在你已经知道库的大小应该是 10,296 字节,你可以使用 dd 完整提取它,方法如下:
1 | $ dd skip=52 count=10296 if=67b8601 ➊of=lib5ae9b7f.so bs=1 |
dd命令调用提取的文件lib5ae9b7f.so ➊,因为这是ctf二进制文件期望的缺失库的名称。运行此命令后,你现在应该拥有一个完全功能的 ELF 共享对象。让我们使用readelf来查看是否一切顺利,如清单 5-2 所示。为了简洁起见,我们只打印可执行文件头(-h)和符号表(-s)。后者应能帮助你了解库所提供的功能。
清单 5-2:提取的库的readelf*输出,lib5ae9b7f.so
1 | $ readelf -hs lib5ae9b7f.so |
如期望的那样,完整的库似乎已经被正确提取。尽管它被剥离了,但动态符号表确实显示了一些有趣的导出函数(➊到➎)。然而,函数名周围似乎有一些乱码,导致它们难以阅读。让我们看看是否可以解决这个问题。
5.5 使用 nm 解析符号
C++允许函数重载,这意味着可能有多个同名函数,只要它们具有不同的签名。对于链接器来说,这却是个问题,因为它对 C++一无所知。例如,如果有多个名为foo的函数,链接器不知道如何解决对foo的引用;它根本不知道使用哪个版本的foo。为了消除重复的名称,C++编译器会生成破坏的函数名。破坏的函数名本质上是原始函数名和函数参数的编码组合。这样,每个版本的函数都会有一个唯一的名称,链接器就能够轻松区分重载的函数。
对于二进制分析师来说,名称被“破坏”(mangled)的函数名是一种复杂的祝福。一方面,破坏后的函数名更难以阅读,正如你在readelf输出中看到的lib5ae9b7f.so(见清单 5-2)所示,它是用 C++编写的。另一方面,破坏后的函数名实际上通过揭示函数的预期参数提供了免费的类型信息,这在逆向工程二进制文件时非常有用。
幸运的是,破坏后的函数名带来的好处大于缺点,因为它们相对容易被还原。有几个标准工具可以用来还原破坏的函数名。其中最著名的工具之一是nm,它可以列出给定二进制文件、目标文件或共享对象的符号。当给定一个二进制文件时,nm默认尝试解析静态符号表。
1 | $ nm lib5ae9b7f.so |
不幸的是,正如这个例子所示,你不能在lib5ae9b7f.so上使用nm的默认配置,因为它已经被剥离。你必须显式地要求nm解析动态符号表,使用-D开关,如清单 5-3 所示。在这个清单中,"..."表示我已经截断了一行并将其继续到下一行(破坏的函数名可能非常长)。
*清单 5-3:nm输出,lib5ae9b7f.so
1 | $ nm -D lib5ae9b7f.so |
这样看起来好多了,这次你看到了一些符号。但符号名称仍然是混淆的。要去混淆它们,你需要将 --demangle 选项传递给 nm,如 清单 5-4 所示。
清单 5-4: lib5ae9b7f.so 的 nm 输出(已去除混淆)
1 | $ nm -D --demangle lib5ae9b7f.so |
最终,函数名称变得易于阅读。你可以看到五个有趣的函数,它们似乎是实现了著名的 RC4 加密算法的加密函数。^(2) 有一个名为 rc4_init 的函数,它接受一个类型为 rc4_state_t 的数据结构作为输入,以及一个无符号字符字符串和一个整数 ➎。第一个参数可能是一个存储加密状态的数据结构,而接下来的两个参数分别可能是表示密钥的字符串和指定密钥长度的整数。你还可以看到几个加密和解密函数,每个函数都接受指向加密状态的指针,并且有参数指定要加密或解密的字符串(包括 C 和 C++ 字符串)(➊ 到 ➍)。
作为去混淆函数名称的另一种方法,你可以使用名为 c++filt 的专用工具,它接受混淆过的名称作为输入并输出去混淆后的等效名称。c++filt 的优势在于它支持多种混淆格式,并自动检测给定输入的正确混淆格式。以下是使用 c++filt 去混淆函数名称 _Z8rc4_initP11rc4_state_tPhi 的示例:
1 | $ c++filt _Z8rc4_initP11rc4_state_tPhi |
现在,让我们简要回顾一下迄今为止的进展。你提取了神秘的有效负载,并找到了一个名为 ctf 的二进制文件,它依赖于一个名为 lib5ae9b7f.so 的文件。你找到了隐藏在位图文件中的 lib5ae9b7f.so 并成功提取出来。你也大致了解了它的功能:它是一个加密库。现在,让我们再次尝试运行 ctf,这次不再缺少任何依赖项。
当你运行一个二进制文件时,链接器通过搜索多个标准目录中的共享库来解析二进制文件的依赖项,例如 /lib。由于你将 lib5ae9b7f.so 提取到了一个非标准目录,你需要告诉链接器也去该目录搜索,通过设置一个名为 LD_LIBRARY_PATH 的环境变量。让我们将该变量设置为当前工作目录,然后再次尝试启动 ctf。
1 | export LD_LIBRARY_PATH=`pwd` |
成功了!ctf 二进制文件看起来仍然没有做任何有用的事情,但它能够运行,并且没有抱怨缺少任何库文件。ctf 的退出状态(保存在 $? 变量中)是 1,表示发生了错误。现在你已经拥有了所有必需的依赖项,可以继续调查并看看你是否能够让 ctf 克服错误,从而达到你要捕捉的标志。
5.6 使用 strings 寻找线索
为了弄清楚一个二进制文件的功能以及它期望的输入类型,你可以检查该二进制文件是否包含任何有助于揭示其目的的字符串。例如,如果你看到包含 HTTP 请求或 URL 的字符串,你可以安全地猜测该二进制文件正在执行与 Web 相关的操作。当你处理恶意软件(如 bot)时,如果这些字符串没有被混淆,你可能会找到包含 bot 接受的命令的字符串。你甚至可能会发现一些调试时留下的字符串,程序员忘记删除这些字符串,这种情况在实际的恶意软件中也曾发生过!
你可以使用一个名为strings的工具来检查 Linux 上二进制文件(或其他任何文件)中的字符串。strings工具接受一个或多个文件作为输入,然后打印出这些文件中找到的所有可打印字符字符串。请注意,strings并不会检查所找到的字符串是否真的被设计为可读的,所以当它用于二进制文件时,strings的输出可能会包含一些虚假的字符串,这些字符串可能是二进制序列偶然变得可打印的结果。
你可以使用选项来调整strings的行为。例如,你可以使用-d选项与strings一起使用,以仅打印出在二进制文件的数据部分中找到的字符串,而不是打印所有部分。默认情况下,strings只打印四个字符或更多的字符串,但你可以使用-n选项指定其他最小字符串长度。就我们的目的而言,默认选项就足够了;让我们看看你能在ctf二进制文件中使用strings找到什么,如列表 5-5 所示。
列表 5-5:在 ctf 二进制文件中找到的字符字符串
1 | $ strings ctf |
在这里,你可以看到一些在大多数 ELF 文件中都会遇到的字符串。例如,程序解释器的名称➊,可以在.interp部分找到,以及一些在.dynstr部分找到的符号名称➋。在strings的输出末尾,你可以看到所有在.shstrtab部分找到的节名称➐。但这些字符串在此并没有什么特别有趣的地方。
幸运的是,还有一些更有用的字符串。例如,似乎有一条调试信息,暗示程序期望一个命令行选项➌。还有一些检查,可能是针对输入字符串执行的检查➍。你现在还不知道命令行选项的值应该是什么,但你可以尝试一些其他看起来有趣的字符串,例如show_me_the_flag➎,它可能有效。还有一个神秘的字符串➏,它包含一条含义不明的消息。你现在不知道这条消息的意思,但你从对lib5ae9b7f.so的调查中知道,二进制文件使用了 RC4 加密。也许这条消息是用作加密密钥?
现在你知道了二进制文件期望一个命令行选项,让我们看看添加一个任意选项是否能让你更接近揭示旗标。为了没有更好的猜测,我们就简单地使用字符串foobar,如下所示:
1 | $ ./ctf foobar |
该二进制文件现在做了一些新事情。它告诉你它正在检查你给定的输入字符串。但检查并没有成功,因为检查后,二进制文件仍然以错误代码退出。我们来冒险尝试一下你找到的其他一些看起来有趣的字符串,比如 show_me_the_flag,它看起来很有潜力。
1 | $ ./ctf show_me_the_flag |
成功了!检查现在似乎已经成功。不幸的是,退出状态仍然是 1,所以肯定还有其他东西缺失。更糟糕的是,strings 的结果没有提供更多的线索。我们来更详细地查看 ctf 的行为,确定接下来该做什么,从 ctf 发出的系统和库调用开始。
5.7 使用 strace 和 ltrace 跟踪系统调用和库调用
为了取得进展,我们来调查一下 ctf 为什么会退出并返回错误代码,看看 ctf 在退出前的行为。你可以通过很多方式来做这件事,其中一种方法是使用两个工具,分别是 strace 和 ltrace。这些工具分别显示了二进制文件执行的系统调用和库调用。知道一个二进制文件所做的系统和库调用通常可以给你一个关于程序在做什么的高层次理解。
让我们首先使用 strace 来调查 ctf 的系统调用行为。在某些情况下,你可能希望将 strace 附加到一个正在运行的进程。为此,你需要使用 -p pid 选项,其中 pid 是你想附加的进程的进程 ID。然而,在这种情况下,从一开始就用 strace 运行 ctf 就足够了。列 5-6 显示了 ctf 二进制文件的 strace 输出(有些部分被“...”截断)。
列 5-6: ctf 二进制文件执行的系统调用
1 | $ strace ./ctf show_me_the_flag |
当从程序开始追踪时,strace 包含了程序解释器用来设置进程的所有系统调用,这使得输出非常冗长。输出中的第一个系统调用是 execve,它是由你的 shell 调用来启动程序 ➊。之后,程序解释器接管并开始设置执行环境。这涉及到设置内存区域并使用 mprotect 设置正确的内存访问权限。此外,你还可以看到用于查找和加载所需动态库的系统调用。
回想一下,在第 5.5 节中,你设置了 LD_LIBRARY_PATH 环境变量,以告诉动态链接器将当前工作目录添加到其搜索路径中。这就是为什么你可以看到动态链接器在当前工作目录中的多个标准子文件夹中搜索 lib5ae9b7f.so 库,直到它最终在工作目录的根目录中找到该库 ➋。当找到库时,动态链接器读取它并将其映射到内存中 ➌。对于其他所需的库,如 libstdc++.so.6 ➍,会重复此设置过程,这也占据了 strace 输出的绝大多数内容。
直到最后三个系统调用,你才看到特定应用程序的行为。ctf 使用的第一个系统调用是 write,它用于打印 checking 'show_me_the_flag' 到屏幕 ➎。接着,你看到另一个 write 调用,打印字符串 ok ➏,最后是调用 exit_group,导致程序以状态码 1 退出 ➐。
这些都很有趣,但它们怎么帮助你找出如何从 ctf 中提取标志呢?答案是:它们没有帮助!在这个案例中,strace 并没有揭示任何有用的信息,但我仍然想给你展示它是如何工作的,因为它可以帮助理解程序的行为。例如,观察程序执行的系统调用,不仅对二进制分析有帮助,也对调试有用。
查看 ctf 的系统调用行为没有太大帮助,因此我们来尝试一下库调用。要查看 ctf 执行的库调用,可以使用 ltrace。因为 ltrace 与 strace 很相似,所以它支持许多相同的命令行选项,包括 -p 用于附加到现有进程。这里,我们使用 -i 选项,在每个库调用时打印指令指针(稍后会用到)。我们还将使用 -C 自动解混淆 C++ 函数名。让我们从头开始运行 ctf,并使用 ltrace,如 Listing 5-7 所示。
Listing 5-7: ctf 二进制文件的库调用
1 | $ ltrace -i -C ./ctf show_me_the_flag |
如你所见,ltrace 的输出比 strace 更加易读,因为它没有被所有的进程设置代码污染。第一个库调用是 __libc_start_main ➊,它从 _start 函数中调用,用于将控制权转移到程序的 main 函数。一旦 main 开始执行,它的第一个库调用打印出现在熟悉的 checking ... 字符串到屏幕 ➋。实际的检查是一个字符串比较,使用 strcmp 实现,验证传给 ctf 的参数是否等于 show_me_the_flag ➌。如果是这样,ok 会被打印到屏幕上 ➍。
到目前为止,这些大多是你之前见过的行为。但现在你看到了一些新内容:RC4 加密算法通过调用 rc4_init 初始化,该函数位于你之前提取的库中 ➎。之后,你看到一个 assign 操作给一个 C++ 字符串赋值,假设它用加密消息进行了初始化 ➏。然后,使用 rc4_decrypt 调用解密该消息 ➐,并将解密后的消息赋值给一个新的 C++ 字符串 ➑。
最后,调用了 getenv,这是一个标准库函数,用于查找环境变量 ➒。你可以看到 ctf 期望有一个名为 GUESSME 的环境变量!这个变量的名字很可能就是之前解密出来的字符串。让我们看看当你为 GUESSME 环境变量设置一个虚拟值时,ctf 的行为是否会发生变化,如下所示:
1 | $ GUESSME='foobar' ./ctf show_me_the_flag |
设置GUESSME会导致输出一行额外的信息,显示guess again!。看起来ctf期望GUESSME被设置为另一个特定值。也许再执行一次ltrace,如列表 5-8 所示,将揭示出期望的值是什么。
列表 5-8: ctf 二进制文件在设置 GUESSME 环境变量后的库函数调用
1 | $ GUESSME='foobar' ltrace -i -C ./ctf show_me_the_flag |
在调用getenv之后,ctf继续执行分配 ➊ 并解密 ➋ 另一个 C++字符串。不幸的是,在解密和guess again被打印到屏幕 ➌ 之间,你并没有看到任何关于GUESSME期望值的线索。这告诉你,GUESSME与其期望值的比较是没有使用任何库函数来实现的。你需要采取另一种方法。
5.8 使用 objdump 检查指令级行为
由于你知道GUESSME环境变量的值是在没有使用任何知名库函数的情况下进行检查的,接下来的合乎逻辑的步骤是使用objdump检查ctf的指令级别,看看发生了什么。^(3)
从列表 5-8 中的ltrace输出,你知道guess again字符串是通过在地址0x400dd7调用puts打印到屏幕上的。让我们集中在这个地址周围进行objdump调查。知道字符串的地址也会有所帮助,这样可以找到加载它的第一条指令。要找到这个地址,你可以使用objdump -s查看ctf二进制文件的.rodata部分,正如列表 5-9 所示。
列表 5-9: ctf 的 .rodata 部分内容,使用 objdump 显示
1 | $ objdump -s --section .rodata ctf |
使用objdump检查ctf的.rodata部分时,你可以看到guess again字符串位于地址0x4011af ➊。现在让我们来看一下[列表 5-10,它展示了puts调用附近的指令,以找出ctf期望的GUESSME环境变量输入是什么。
列表 5-10:检查 GUESSME 值的指令
1 | $ objdump -d ctf |
guess again字符串是通过地址0x400dcd ➍的指令加载的,然后使用puts ➎打印出来。这是失败的情况;让我们从这里开始倒推。
失败案例是从一个起始地址为0x400dc0的循环中达到的。在每次循环迭代中,它从一个数组(可能是字符串)中加载一个字节到edx寄存器 ➊。rbx寄存器指向该数组的起始位置,而rax则用于索引数组。如果加载的字节是NULL,那么位于0x400dc6的je指令将跳转到失败案例 ➋。这个与NULL的比较是为了检查字符串的结尾。如果这里到达了字符串的结尾,那么它就太短,无法匹配。如果字节不是NULL,则je指令将跳过,进入下一条指令,位于地址0x400dc8,该指令将edx中的字节与另一个字符串中的字节进行比较,这个字符串基于rcx并由rax进行索引 ➌。
如果这两个比较的字节匹配,程序将跳转到地址0x400de0,在这里它增加字符串索引➏,并检查字符串索引是否等于0x15,即字符串的长度➐。如果相等,字符串比较完成;如果不相等,程序将跳转到循环的另一次迭代➑。
从这次分析中,你现在知道基于rcx寄存器的字符串被用作基准真值。程序将从GUESSME变量中获取的环境字符串与这个基准真值进行比较。这意味着,如果你能够转储这个基准真值字符串,就能找到GUESSME的预期值!因为字符串是在运行时解密的,静态时不可用,你需要使用动态分析来恢复它,而不是使用objdump。
5.9 使用 gdb 转储动态字符串缓冲区
在 GNU/Linux 上,最常用的动态分析工具可能是gdb,即 GNU 调试器。顾名思义,gdb主要用于调试,但它也可以用于各种动态分析目的。实际上,它是一个功能非常强大的工具,在这一章中无法覆盖它的所有功能。不过,我将介绍一些最常用的gdb功能,帮助你恢复GUESSME的预期值。查找gdb信息的最佳地点不是手册页,而是*www.gnu.org/software/gdb/documentation/*,在那里你可以找到一份详尽的手册,涵盖了所有支持的gdb命令。
像strace和ltrace一样,gdb也具有附加到正在运行的进程的能力。然而,由于ctf不是一个长期运行的进程,你可以直接从一开始就用gdb运行它。因为gdb是一个交互式工具,当你在gdb下启动一个二进制文件时,它不会立即执行。在打印启动信息和一些使用说明后,gdb会暂停并等待命令。你可以通过命令提示符(gdb)知道gdb正在等待命令。
列表 5-11 展示了查找GUESSME环境变量预期值所需的gdb命令序列。我将在讨论该列表时逐一解释这些命令。
列表 5-11:使用 gdb 查找 GUESSME 的预期值
1 | $ gdb ./ctf |
调试器最基本的功能之一是设置断点,它就是一个地址或函数名,调试器将在该位置“中断”执行。每当调试器达到断点时,它会暂停执行并将控制权交还给用户,等待命令。为了转储与GUESSME环境变量进行比较的“魔法”字符串,你需要在地址0x400dc8 ➊(比较发生的地方)设置断点。在gdb中,设置断点的命令是b address(b是命令break的简写)。如果符号可用(在此情况下不可用),你可以使用函数名在函数入口处设置断点。例如,要在main的起始位置设置断点,可以使用命令b main。
设置完断点后,在开始执行ctf之前,你还需要做一件事。你仍然需要为GUESSME环境变量设置一个值,以防止ctf提前退出。在gdb中,你可以使用命令set env GUESSME=foobar ➋来设置GUESSME环境变量。现在,你可以通过发出命令run show_me_the_flag ➌来开始执行ctf。如你所见,你可以将参数传递给run命令,它会自动将这些参数传递给你正在分析的二进制文件(在此情况下是ctf)。现在,ctf开始正常执行,应该会一直执行直到遇到你的断点。
当ctf遇到断点时,gdb会暂停ctf的执行并将控制权交还给你,通知你断点已被触发 ➍。此时,你可以使用命令display/i $pc来显示当前程序计数器($pc)处的指令,以确保你在预期的指令处 ➎。正如预期的那样,gdb通知你接下来要执行的指令是cmp (%rcx,%rax,1),%dl,这确实是你感兴趣的比较指令(以 AT&T 格式显示)。
现在你已经到达了ctf执行过程中的那个时刻,GUESSME与预期字符串进行比较,你需要找到该字符串的基地址,以便将其转储。要查看rcx寄存器中包含的基地址,可以使用命令info registers rcx➏。你还可以查看rax的内容,确保循环计数器为零,符合预期 ➐。也可以使用命令info registers而不指定任何寄存器名称,在这种情况下,gdb会显示所有通用寄存器的内容。
你现在知道了你想要转储的字符串的基址;它从地址 0x615050 开始。接下来要做的就是在该地址处转储字符串。在 gdb 中转储内存的命令是 x,它能够以多种粒度和编码方式转储内存。例如,x/d 以十进制表示转储一个字节,x/x 以十六进制表示转储一个字节,x/4xw 转储四个十六进制字(即 4 字节整数)。在这种情况下,最有用的命令是 x/s,它会转储一个 C 风格的字符串,直到遇到 NULL 字节为止。当你执行命令 x/s 0x615050 来转储你感兴趣的字符串时 ➑,你可以看到预期的值 GUESSME 是 Crackers Don't Matter。接下来,让我们使用 quit 命令 ➒ 退出 gdb 来尝试它!
1 | $ GUESSME="Crackers Don't Matter" ./ctf show_me_the_flag |
如此列表所示,你终于完成了所有必要的步骤,成功地让 ctf 给你提供了秘密旗帜!在本章的虚拟机目录中,你会找到一个名为 oracle 的程序。现在,按照下面的方式将旗帜传递给 oracle:./oracle 84b34c124b2ba5ca224af8e33b077e9e。你现在已经解锁了下一个挑战,接下来可以凭借你新学到的技能自己完成它。
5.10 小结
在本章中,我向你介绍了所有成为有效二进制分析师所需的基本 Linux 二进制分析工具。尽管这些工具大多数都很简单,但你可以将它们组合起来,迅速实施强大的二进制分析!在下一章中,你将探索一些主要的反汇编工具以及其他更高级的分析技巧。
练习
- 新的 CTF 挑战
完成由 oracle 程序解锁的新的 CTF 挑战!你可以仅使用本章讨论的工具和在第二章中学到的内容来完成整个挑战。完成挑战后,别忘了将你找到的旗帜交给 oracle 以解锁下一个挑战。
第六章:拆解与二进制分析基础
现在你已经了解了二进制文件的结构,并且熟悉了基本的二进制分析工具,接下来是时候开始拆解一些二进制文件了!在本章中,你将学习一些主要的拆解方法和工具的优缺点。我还将讨论一些更高级的分析技巧,用于分析拆解代码的控制流和数据流特性。
请注意,本章并不是反向工程的指南;如果你需要反向工程的指导,我推荐 Chris Eagle 的 《IDA Pro 书籍》(No Starch Press,2011)。本章的目标是帮助你熟悉拆解背后的主要算法,了解拆解器能够和不能做什么。这些知识将帮助你更好地理解后续章节中讨论的更高级的技术,因为这些技术本质上依赖于拆解作为核心。整个章节中,我将使用 objdump 和 IDA Pro 来进行大部分示例。在一些示例中,我将使用伪代码来简化讨论。附录 C 包含了你可以尝试的其他知名拆解器,如果你想使用除了 IDA Pro 或 objdump 之外的拆解工具。
6.1 静态拆解
你可以将所有二进制分析分为静态分析、动态分析,或者两者的结合。当人们提到“拆解”时,他们通常指的是 静态拆解,它涉及从二进制文件中提取指令,而不需要执行它。与此相对,动态拆解,更常见的称呼是 执行跟踪,它在二进制文件运行时记录每个已执行的指令。
每个静态拆解器的目标是将二进制文件中的 所有 代码转换成一个人类可以阅读或机器可以处理(以便进一步分析)的形式。为了实现这一目标,静态拆解器需要执行以下步骤:
- 使用二进制加载器(如第四章中实现的加载器)加载二进制文件进行处理。
- 找到二进制文件中的所有机器指令。
- 将这些指令拆解成人类或机器可读的形式。
不幸的是,步骤 2 在实际操作中常常非常困难,导致拆解错误。静态拆解有两种主要方法,每种方法都以不同的方式尝试避免拆解错误:线性拆解和递归拆解。不幸的是,在每种情况下,这两种方法都不是完美的。我们来讨论一下这两种静态拆解技术的权衡。我将在本章稍后部分回到动态拆解的讨论。
图 6-1 展示了线性和递归拆解的基本原理。它还突出了每种方法可能出现的一些拆解错误类型。

图 6-1:线性拆解与递归拆解。箭头表示拆解流程,灰色块表示丢失或损坏的代码。
6.1.1 线性拆解
让我们从线性反汇编开始,这种方法在概念上是最简单的。它遍历二进制文件中的所有代码段,按顺序解码所有字节,并将它们解析为指令列表。许多简单的反汇编器,包括第一章中的objdump,都采用这种方法。
使用线性反汇编的风险在于,并非所有字节都是指令。例如,一些编译器,如 Visual Studio,会将跳转表等数据与代码交织在一起,而没有留下任何关于数据所在位置的提示。如果反汇编器错误地将这些内联数据解析为代码,它们可能会遇到无效的操作码。更糟糕的是,这些数据字节可能巧合地对应于有效的操作码,导致反汇编器输出虚假的指令。在像 x86 这样密集的 ISA 上,这种情况尤为可能,因为大多数字节值都代表有效的操作码。
此外,对于具有可变长度操作码的指令集架构(ISA),例如 x86,内联数据甚至可能导致反汇编器与真实指令流不同步。尽管反汇编器通常会自我重新同步,但不同步可能导致内联数据后的前几条真实指令被遗漏,如图 6-2 所示。

图 6-2:由于将内联数据误解为代码,导致的反汇编不同步。反汇编重新同步的指令用灰色标示。
该图示例展示了二进制代码段中的反汇编不同步问题。你可以看到一些内联数据字节(0x8e 0x20 0x5c 0x00),后面跟着一些指令(push rbp、mov rbp,rsp等)。正确解码所有字节的结果,假设是通过一个完全同步的反汇编器进行解码,显示在图的左侧,标注为“synchronized”。但是,一个简单的线性反汇编器会将内联数据错误地解释为代码,从而解码出图中显示的“−4 bytes off”字节。正如你所看到的,内联数据被解码为mov fs,[rax]指令,接着是pop rsp和add [rbp+0x48],dl指令。最后这一条指令尤其恶劣,因为它超出了内联数据区域,进入了实际的指令区!这样,add指令“吃掉”了一些真正的指令字节,导致反汇编器完全错过了前两条实际指令。如果反汇编器提早 3 个字节开始(“−3 bytes off”),它也会遇到类似的问题,这可能发生在反汇编器尝试跳过内联数据却没能识别出所有内联数据时。
幸运的是,在 x86 架构上,反汇编后的指令流通常会在几条指令后自动重新同步。但是,如果你进行任何自动化分析,或者基于反汇编的代码修改二进制文件,哪怕遗漏了几条指令也可能是个坏消息。正如你在第八章中看到的,恶意程序有时故意包含一些字节,旨在使反汇编器不同步,从而隐藏程序的真实行为。
在实际操作中,像objdump这样的线性反汇编器在反汇编使用最近版本编译器(如gcc或 LLVM 的clang)编译的 ELF 二进制文件时是安全的。这些编译器的 x86 版本通常不会生成内联数据。另一方面,Visual Studio 会生成内联数据,因此在使用objdump查看 PE 二进制文件时,最好留意反汇编错误。在分析其他架构(如 ARM)上的 ELF 二进制文件时也是如此。如果你使用线性反汇编器分析恶意代码,那就完全无法预料了,因为它可能包含比内联数据更复杂的混淆技术!
6.1.2 递归反汇编
与线性反汇编不同,递归反汇编对控制流非常敏感。它从已知的二进制入口点(如主入口点和导出函数符号)开始,然后递归地跟踪控制流(如跳转和调用)以发现代码。这使得递归反汇编能够绕过几乎所有数据字节,除了极少数的特殊情况。^(1) 这种方法的缺点是,并非所有的控制流都容易跟踪。例如,静态地判断间接跳转或调用的目标往往是困难的,甚至是不可能的。因此,反汇编器可能会遗漏代码块(甚至整个函数,例如图 6-1 中的f[1]和f[2]),这些代码块可能是间接跳转或调用的目标,除非它使用特殊的(特定于编译器且容易出错的)启发式方法来解析控制流。
递归反汇编是许多逆向工程应用中的事实标准,例如恶意软件分析。IDA Pro(如图 6-3 所示)是最先进且广泛使用的递归反汇编工具之一。IDA Pro 是 Interactive DisAssembler(交互式反汇编器)的缩写,旨在交互使用,并提供许多用于代码可视化、代码探索、脚本编写(使用 Python)甚至反编译^(2)的功能,这些功能在简单的工具如objdump中是无法实现的。当然,它的价格也不便宜:在撰写时,IDA Starter(IDA Pro 的简化版)的许可证起价为739,而完整的IDAProfessional许可证则从1,409 起。但不用担心——你不需要购买 IDA Pro 来使用本书。本书关注的不是交互式逆向工程,而是基于免费的框架创建你自己的自动化二进制分析工具。

图 6-3:IDA Pro 的图形视图
图 6-4 展示了像 IDA Pro 这样的递归反汇编工具在实际应用中面临的一些挑战。具体来说,图中显示了如何将 opensshd v7.1p2 版本的一个简单函数通过 gcc v5.1.1 从 C 代码编译成 x64 代码。

图 6-4:反汇编后的 switch 语句示例(来自 opensshd v7.1p2,使用 gcc 5.1.1 为 x64 编译,源代码经过编辑以简化)。有趣的行被阴影标出。
如图左侧所示,展示了该函数的 C 语言表示,函数本身没有做什么特别的事情。它使用一个 for 循环遍历数组,在每次迭代中应用一个 switch 语句来确定如何处理当前的数组元素:跳过不感兴趣的元素,返回满足某些条件的元素的索引,或者如果发生了意外错误则打印错误并退出。尽管 C 语言代码很简单,但该函数的编译版本(图右侧所示)要正确反汇编并不简单。
如图 6-4 所示,switch 语句的 x64 实现基于一个 跳转表,这是现代编译器常见的构造。该跳转表实现避免了复杂的条件跳转链。相反,位于地址 0x4438f9 的指令利用 switch 输入值计算(存储在 rax 寄存器中)一个表的索引,表中存储着对应的 case 块的地址。通过这种方式,只有位于地址 0x443901 的单一间接跳转指令,才能将控制流传递到跳转表定义的任何 case 地址。
尽管跳转表高效,但它们使得递归反汇编变得更加困难,因为它们使用了 间接控制流。间接跳转指令中缺乏明确的目标地址,这使得反汇编器很难追踪到指令流的走向。因此,间接跳转可能会指向的任何指令都不会被发现,除非反汇编器实现了特定的(依赖于编译器的)启发式方法来发现和解析跳转表。^(3) 对于这个例子来说,这意味着一个没有实现 switch 检测启发式方法的递归反汇编工具根本无法发现地址 0x443903–0x443925 之间的指令。
情况变得更加复杂,因为 switch 中有多个 ret 指令,并且还调用了 fatal 函数,该函数抛出错误并且永远不返回。一般来说,不能假设 ret 指令或非返回的 call 后面一定有指令;实际上,这些指令后面可能跟着的是数据或填充字节,而这些内容并不打算被当作代码解析。然而,相反的假设(即这些指令后面没有更多的代码)可能会导致反汇编器遗漏指令,导致反汇编结果不完整。
这些只是递归反汇编器面临的一些挑战;更复杂的情况还很多,特别是在比示例中更复杂的函数中。正如你所看到的,线性反汇编和递归反汇编都不是完美的。对于良性 x86 ELF 二进制文件,线性反汇编是一个不错的选择,因为它能够提供既完整又准确的反汇编:这类二进制文件通常不包含会让反汇编器出错的内联数据,并且线性方法不会因为无法解析的间接控制流而漏掉代码。另一方面,如果涉及到内联数据或恶意代码,使用递归反汇编器可能是更好的选择,因为它不容易像线性反汇编器那样产生虚假的输出。
在需要确保反汇编正确性的情况下,即使以牺牲完整性为代价,也可以使用动态反汇编。让我们来看一下这种方法与刚才讨论的静态反汇编方法有何不同。
6.2 动态反汇编
在前面的章节中,你看到了静态反汇编器所面临的挑战,如区分数据和代码、解析间接调用等。动态分析解决了许多这些问题,因为它拥有丰富的运行时信息,例如具体的寄存器和内存内容。当执行到达特定地址时,你可以完全确信那里有一条指令,因此动态反汇编不会遇到静态反汇编中常见的不准确问题。这使得动态反汇编器,也叫做执行追踪器或指令追踪器,可以在程序执行时直接输出指令(以及可能的内存/寄存器内容)。这种方法的主要缺点是代码覆盖问题:即动态反汇编器只能看到它们执行的指令,而不是所有指令。我将在本节后面再讨论代码覆盖问题。首先,让我们来看一个具体的执行追踪示例。
6.2.1 示例:使用 gdb 追踪二进制执行
令人惊讶的是,Linux 上没有广泛接受的标准工具用于“即刻执行并忘记”追踪(与 Windows 不同,Windows 上有像 OllyDbg 这样优秀的工具^(4))。使用仅标准工具的最简单方法是通过一些gdb命令,如清单 6-1 所示。
清单 6-1:使用 gdb 进行动态反汇编
1 | $ gdb /bin/ls |
本例将 /bin/ls 加载到 gdb 中,并生成一个跟踪,记录在列出当前目录内容时执行的所有指令。启动 gdb 后,你可以列出加载到 gdb 中的文件信息(在本例中,只有可执行文件 /bin/ls) ➊。这会告诉你该二进制文件的入口点地址 ➋,以便你可以在程序开始运行时设置一个断点来暂停执行 ➌。接着,你禁用分页 ➍ 并配置 gdb 将日志记录到文件中,而不是标准输出 ➎。默认情况下,日志文件名为 gdb.txt。分页意味着 gdb 在输出一定行数后会暂停,允许用户在继续之前阅读屏幕上的所有输出,默认情况下启用。由于你正在将日志记录到文件,因此不希望出现这些暂停,否则你会不得不不断按键才能继续,快速变得很烦人。
设置好一切后,你运行二进制文件 ➏。它会立即暂停,一旦入口点被触及。此时你可以告诉 gdb 将这条第一条指令记录到文件中 ➐,然后进入一个 while 循环 ➑,不断执行单条指令 ➒(这称为 单步执行),直到没有更多的指令可以执行为止。每一条单步执行的指令都会自动以与之前相同的格式打印到日志文件中。执行完成后,你将得到一个包含所有执行指令的日志文件。正如你所料,输出相当冗长;即使是简单运行一个小程序,也会遍历数十万甚至更多的指令,如 清单 6-2 所示。
清单 6-2:使用 gdb 进行动态反汇编后的输出
1 | ➊ $ wc -l gdb.txt |
使用 wc 来计算日志文件中的行数,你会发现该文件包含 614,390 行,远远超过这里能列出的数量 ➊。为了给你一个输出的概念,你可以使用 head 查看日志文件的前 20 行 ➋。实际的执行跟踪从 ➌ 开始。对于每条执行的指令,gdb 会打印用于记录该指令的命令,然后是指令本身,最后是指令位置的相关信息(由于二进制文件已被剥离,因此位置未知)。使用 grep,你可以过滤掉除显示已执行指令的行外的所有内容,因为它们才是你关心的,从而得到如下所示的输出,详见 清单 6-3。
清单 6-3:使用 gdb 进行动态反汇编后的过滤输出
1 | $ egrep '^=> 0x[0-9a-f]+:' gdb.txt | head -n 20 |
如你所见,这比未经过滤的 gdb 日志要更易读。
6.2.2 代码覆盖策略
所有动态分析的主要缺点(不仅仅是动态反汇编)是代码覆盖率问题:分析只会看到分析过程中实际执行的指令。因此,如果任何关键的信息隐藏在其他指令中,分析将永远无法得知。例如,如果你正在动态分析一个包含逻辑炸弹的程序(例如,在未来某个时间触发恶意行为),你永远不会发现,直到为时已晚。相反,通过静态分析的仔细检查可能会揭示这一点。再举一个例子,在动态测试软件时,如果有一个代码路径很少执行,你无法保证自己是否遗漏了在测试中未覆盖的 bug。
许多恶意软件样本甚至会主动躲避动态分析工具或调试器,如 gdb。几乎所有这类工具都会在环境中产生某种可检测的痕迹;即使没有其他表现,分析过程通常会导致执行速度变慢,通常慢到足以被检测到。恶意软件会检测到这些痕迹,并在知道自己正在被分析时隐藏其真实行为。为了在这些样本上启用动态分析,你必须对恶意软件进行逆向工程,然后禁用其反分析检查(例如,通过用修补后的值覆盖那些代码字节)。这些反分析技巧就是为什么,如果可能的话,通常建议至少用静态分析方法来增强你的动态恶意软件分析的原因。
由于找到正确的输入以覆盖每一个可能的程序路径是困难且耗时的,动态反汇编几乎永远无法揭示所有可能的程序行为。你可以使用几种方法来提高动态分析工具的覆盖率,尽管通常这些方法都无法达到静态分析所提供的完整性。让我们来看看一些最常用的方法。
测试套件
提高代码覆盖率最简单且最常见的方法之一是使用已知的测试输入运行被分析的二进制文件。软件开发人员通常会手动为他们的程序开发测试套件,设计输入来覆盖尽可能多的程序功能。这类测试套件非常适合动态分析。为了实现良好的代码覆盖率,只需使用每个测试输入对程序进行分析。当然,这种方法的缺点是,并非总能获得现成的测试套件,例如专有软件或恶意软件就可能没有现成的测试套件。
使用测试套件来实现代码覆盖率的具体方式因应用程序而异,这取决于应用程序的测试套件结构。通常,有一个特殊的 Makefile test 目标,你可以通过在命令行输入 make test 来运行测试套件。在 Makefile 内,test 目标通常是像清单 6-4 那样结构化的。
清单 6-4:Makefile 测试 目标 结构
1 | PROGRAM := foo |
PROGRAM变量包含正在测试的应用程序的名称,在本例中为foo。test目标依赖于多个测试用例(test1、test2等),每个测试用例在你运行make test时都会被调用。每个测试用例包括在某些输入上运行PROGRAM、记录输出,然后使用diff与正确输出进行比较。
实现这种类型的测试框架有许多不同(且更简洁)的方法,但关键点是你可以通过简单地覆盖PROGRAM变量,在每个测试用例上运行动态分析工具。例如,假设你想用gdb运行每个foo的测试用例。(实际上,你可能不会用gdb,而是使用完全自动化的动态分析工具,如何构建这种工具你将在第九章中学习。)你可以按照如下方式进行:
1 | make test PROGRAM="gdb foo" |
本质上,这重新定义了PROGRAM,使得你不再只是对每个测试运行foo,而是将foo在 gdb中运行。这样,gdb或你正在使用的任何动态分析工具会在每个测试用例上运行foo,允许动态分析覆盖所有测试用例所涵盖的foo代码。在没有PROGRAM变量可供覆盖的情况下,你需要进行搜索和替换,但思想保持不变。
模糊测试
还有一些被称为模糊测试器的工具,它们试图自动生成输入,以覆盖给定二进制文件中的新代码路径。著名的模糊测试器包括 AFL、微软的 Project Springfield 和谷歌的 OSS-Fuzz。广义上讲,模糊测试器根据生成输入的方式可分为两类。
- 基于生成的模糊测试器:这些模糊测试器从头开始生成输入(可能了解预期的输入格式)。
- 基于变异的模糊测试器:这些模糊测试器通过某种方式变异已知的有效输入来生成新的输入,例如,从现有的测试套件开始。
模糊测试器的成功与性能在很大程度上依赖于可用的信息。例如,如果有源代码信息可用,或者已知程序的预期输入格式,那会非常有帮助。如果这些都不知道(即使知道了),模糊测试可能需要大量的计算时间,且可能无法覆盖被复杂if/else条件所隐藏的代码路径,而这些条件是模糊测试器无法“猜测”的。模糊测试器通常用于搜索程序中的漏洞,改变输入直到检测到崩溃。
虽然我在本书中不会详细讲解模糊测试,但我鼓励你尝试使用一些免费的工具。每个模糊测试器都有其独特的使用方法。一个很好的实验选择是 AFL,它是免费的,并且有很好的在线文档。^(5) 此外,在第十章中,我将讨论如何使用动态污点分析来增强模糊测试。
符号执行
符号执行是一种高级技术,我将在第十二章和第十三章中详细讨论。这是一项广泛的技术,具有多种应用,而不仅仅是代码覆盖。在这里,我只是大致介绍符号执行如何应用于代码覆盖,省略了许多细节,所以如果你暂时跟不上,也不用担心。
通常,当你执行一个应用程序时,你会使用所有变量的具体值。在执行的每个时刻,每个 CPU 寄存器和内存区域都包含某个特定值,并且这些值会随着应用程序的计算过程而不断变化。而符号执行则不同。
简而言之,符号执行允许你用符号值而不是具体值来执行一个应用程序。你可以将符号值视为数学符号。符号执行本质上是对程序的模拟,其中所有或部分变量(或寄存器和内存状态)都通过这些符号来表示。^(6)为了更清楚地理解这意味着什么,请考虑 Listing 6-5 中显示的伪代码程序。
Listing 6-5:伪代码示例,用于说明符号执行
1 | ➊ x = int(argv[0]) |
程序从接受两个命令行参数开始,将它们转换为数字,并存储在两个变量x和y中 ➊。在符号执行的开始,你可能会将x变量定义为包含符号值α[1],而y可能初始化为α[2]。α[1]和α[2]都是可以表示任何可能数值的符号。然后,随着模拟的进行,程序实际上会计算这些符号的公式。例如,操作z = x + y使得z的符号表达式变为α*[1] + α[2] ➋。
与此同时,符号执行还计算了路径约束,这只是对符号可能取值的限制,考虑到到目前为止已遍历的分支。例如,如果分支if(x < 5)被执行,则符号执行会添加一个路径约束,表示α[1] < 5 ➌。这个约束表示,如果执行了if分支,那么α[1](x中的符号值)必须始终小于 5,否则该分支就不会被执行。对于每个分支,符号执行会相应地扩展路径约束列表。
这一切如何与代码覆盖率相关?关键点是,*给定路径约束列表,你可以检查是否存在任何具体输入能够满足所有这些约束。*有一些特殊的程序,叫做 约束求解器,它们可以在给定约束列表的情况下检查是否有办法满足这些约束。例如,如果唯一的约束是 α[1] < 5,求解器可能会给出解 α[1] = 4 ^ α[2] = 0。请注意,路径约束并未提及 α[2],因此它可以取任何值。这意味着,在程序的具体执行开始时,你可以(通过用户输入)将 x 的值设置为 4,将 y 的值设置为 0,然后执行将走在符号执行中走过的相同分支。如果没有解,求解器会通知你。
现在,为了增加代码覆盖率,你可以更改路径约束,并询问求解器是否有任何方法满足更改后的约束。例如,你可以将约束 α[1] < 5 改为 α[1] ≥ α[5],并询问求解器是否有解。求解器会告知你一个可能的解,如 α[1] = 5 ^ α[2] = 0,你可以将这个解作为输入用于程序的具体执行,从而强制该执行走 else 分支,进而增加代码覆盖率 ➍。如果求解器告知你没有可能的解,那就意味着无法“翻转”该分支,你应继续通过更改其他路径约束来寻找新路径。
正如你从前面的讨论中可能已经了解到的,符号执行(甚至仅仅是其在代码覆盖率中的应用)是一个复杂的主题。即便具备了“翻转”路径约束的能力,仍然无法覆盖所有程序路径,因为可能的路径数量随着程序中的分支指令数量的增加而呈指数级增长。此外,求解路径约束集合在计算上是非常密集的;如果不小心,符号执行方法很容易变得不可扩展。实际上,应用符号执行时需要非常小心,以确保其可扩展性和有效性。到目前为止,我仅概述了符号执行背后的核心思想,但理想情况下,它已经让你对第十二章和第十三章有所了解。
6.3 结构化反汇编代码和数据
到目前为止,我已经向你展示了静态和动态反汇编器如何在二进制文件中找到指令,但反汇编并不止于此。大量没有结构的反汇编指令几乎无法进行分析,因此大多数反汇编器会以某种方式将反汇编的代码结构化,使其更容易分析。在本节中,我将讨论反汇编器恢复的常见代码和数据结构,以及它们如何帮助二进制分析。
6.3.1 结构化代码
首先,让我们来看看反汇编代码的各种结构方式。广义上讲,我将展示的代码结构可以通过两种方式让代码分析变得更加容易。
- 划分功能区:通过将代码划分为逻辑上连接的块,分析每个块的功能以及代码块之间的关系变得更加容易。
- 显示控制流:我接下来要讨论的一些代码结构不仅显式地表示代码本身,还表示代码块之间的控制转移。这些结构可以以可视化的方式呈现,使得更容易快速看出控制如何在代码中流动,并快速了解代码的功能。
以下代码结构在自动化和手动分析中都非常有用。
函数
在大多数高级编程语言(包括 C、C++、Java、Python 等)中,函数是将逻辑上相关的代码块组织在一起的基本构建块。正如任何程序员都知道的那样,结构良好并正确划分为函数的程序,比那些结构不良、充满“意大利面条代码”的程序更容易理解。因此,大多数反汇编工具会尽力恢复原始程序的函数结构,并利用它将反汇编指令按函数分组。这被称为函数检测。函数检测不仅使得代码对人工逆向工程师更易于理解,而且对自动化分析也很有帮助。例如,在自动化二进制分析中,你可能希望按函数级别搜索漏洞,或修改代码,使得每个函数的开始和结束处进行特定的安全检查。
对于包含符号信息的二进制文件,函数检测非常简单;符号表指定了函数集合,并列出了它们的名称、起始地址和大小。不幸的是,正如你在第一章中可能记得的那样,许多二进制文件会去除这些信息,这使得函数检测变得更加具有挑战性。源代码级别的函数在二进制级别没有实际意义,因此它们的边界在编译过程中可能会变得模糊。属于某个特定函数的代码甚至可能在二进制文件中不按顺序排列。函数的各个部分可能分散在代码区段中,甚至有些代码块可能会在多个函数之间共享(这称为重叠代码块)。实际上,大多数反汇编工具假设函数是连续的,并且代码不会共享,这在许多情况下是成立的,但并非所有情况都如此。如果你分析的是固件或嵌入式系统代码,这种假设尤其不成立。
反汇编器用于函数检测的主要策略是基于函数签名,即在函数的开始或结束时常用的指令模式。这一策略在所有知名的递归反汇编器中都有使用,包括 IDA Pro。像objdump这样的线性反汇编器通常不进行函数检测,除非有符号可用。
通常,基于签名的函数检测算法从通过反汇编的二进制文件开始,定位由call指令直接调用的函数。这些情况对于反汇编器来说比较容易找到;而仅通过间接调用或尾调用的函数则更具挑战性。^(7) 为了找到这些具有挑战性的情况,基于签名的函数检测器会查询已知函数签名的数据库。
函数签名模式包括众所周知的函数序言(用于设置函数堆栈帧的指令)和函数尾声(用于拆除堆栈帧的指令)。例如,许多 x86 编译器生成的未优化函数的典型模式以序言push ebp; mov ebp,esp开始,并以尾声leave; ret结束。许多函数检测器扫描二进制文件,寻找这样的签名,并用它们来识别函数的起始和结束位置。
尽管函数是构建反汇编代码的一个重要且有用的方式,但你应该始终警惕错误。在实践中,函数模式会根据平台、编译器和用来创建二进制文件的优化级别而有所不同。经过优化的函数可能完全没有众所周知的函数序言或尾声,因此无法通过基于签名的方法进行识别。因此,函数检测错误相当常见。例如,反汇编器将函数起始地址错误标记 20%或更多,甚至报告一个根本不存在的函数也并不罕见。
最近的研究探索了不同的函数检测方法,这些方法不依赖于签名,而是基于代码的结构。^(8) 尽管这种方法可能比基于签名的方法更准确,但检测错误依然是不可避免的。这一方法已被集成到 Binary Ninja 中,研究原型工具也可以与 IDA Pro 互操作,如果你有兴趣,可以尝试一下。
使用.eh_frame 节进行函数检测
一种有趣的替代方法是基于.eh_frame部分进行函数检测,这可以完全绕过函数检测问题。.eh_frame部分包含与基于 DWARF 的调试功能(如栈展开)相关的信息。这包括标识二进制文件中所有函数的函数边界信息。即使是剥离的二进制文件也会包含这些信息,除非该二进制文件是使用gcc的-fno-asynchronous-unwind-tables标志编译的。它主要用于 C++异常处理,但也用于其他各种应用,如backtrace()以及gcc的内建函数,如__attribute__((__cleanup__(f)))和__builtin_return_address(n)。由于它的多种用途,.eh_frame默认存在于所有由gcc生成的二进制文件中,不仅仅是使用异常处理的 C++二进制文件,还包括普通的 C 二进制文件。
据我所知,这种方法最早是由 Ryan O’Neill(别名 ElfMaster)描述的。在他的网站上,他提供了将.eh_frame部分解析为一组函数地址和大小的代码。^(a)
a. www.bitlackeys.org/projects/eh_frame.tgz
控制流图(CFG)
将反汇编的代码拆分为函数是一回事,但有些函数相当庞大,这意味着分析一个函数可能是一个复杂的任务。为了组织每个函数的内部结构,反汇编器和二进制分析框架使用另一种代码结构,称为控制流图(CFG)。控制流图对于自动化分析以及手动分析都非常有用。它们还提供了一种便捷的图形化表示代码结构的方式,可以让你一眼就能了解函数的结构。图 6-5 展示了一个通过 IDA Pro 反汇编的函数的 CFG 示例。

图 6-5:在 IDA Pro 中看到的 CFG
如图所示,控制流图(CFG)将函数内的代码表示为一组代码块,称为基本块,通过分支边连接,这里用箭头表示。基本块是一系列指令,其中第一条指令是唯一的入口点(即任何跳转指令所指向的指令),而最后一条指令是唯一的出口点(即该序列中唯一可能跳转到另一个基本块的指令)。换句话说,你永远不会看到一个基本块有箭头连接到第一条或最后一条以外的指令。
在 CFG 中,从基本块B到另一个基本块C的边,表示B中的最后一条指令可能跳转到C的起始位置。如果B只有一条出边,那么这意味着它一定会将控制转移到该边的目标。例如,这就是间接跳转或调用指令的情况。另一方面,如果B以条件跳转结束,那么它会有两条出边,运行时选择哪条边取决于跳转条件的结果。
调用边不属于 CFG 的一部分,因为它们指向函数外的代码。相反,CFG 仅显示指向函数调用完成后控制将返回的指令的“顺序执行”边。还有一种代码结构,称为调用图,它专门用于表示调用指令和函数之间的边。我将在接下来的内容中讨论调用图。
实际上,反汇编工具通常会省略 CFG 中的间接边,因为静态分析时很难解析这些边的潜在目标。反汇编工具有时还会定义一个全局的 CFG,而不是每个函数的 CFG。这样的全局 CFG 被称为过程间 CFG(ICFG),因为它本质上是所有每个函数的 CFG 的并集(过程是函数的另一种说法)。ICFG 避免了易出错的函数检测,但没有每个函数 CFG 的封装性优势。
调用图
调用图与控制流图(CFG)类似,区别在于它显示的是调用位置和函数之间的关系,而不是基本块之间的关系。换句话说,CFG 展示的是函数内部控制流的走向,而调用图则展示哪些函数可能相互调用。与 CFG 一样,调用图通常会省略间接调用边,因为准确判断某个间接调用位置可能会调用哪些函数是不可行的。
图 6-6 的左侧展示了一组函数(标记为f[1]到f[4])及它们之间的调用关系。每个函数由若干个基本块(灰色圆圈)和分支边(箭头)组成。对应的调用图位于图的右侧。如图所示,调用图包含了每个函数的节点,并且有边显示函数f[1]可以调用f[2]和f*[3],还有一条表示从f*[3]到f[1]的调用边。尾调用实际上是作为跳转指令实现的,在调用图中显示为常规调用。然而,请注意,从f*[2]到f[4]的间接调用在调用图中没有*显示。

图 6-6:控制流图(左)和函数间连接(右)以及相应的调用图
IDA Pro 还可以显示部分调用图,显示你选择的特定函数的潜在调用者。对于手动分析而言,这些通常比完整的调用图更有用,因为完整的调用图通常包含过多的信息。图 6-7 显示了 IDA Pro 中一个部分调用图的示例,揭示了对函数 sub_404610 的引用。正如你所看到的,图中显示了函数的调用位置;例如,sub_404610 被 sub_4e1bd0 调用,而 sub_4e1bd0 又被 sub_4e2fa0 调用。
此外,IDA Pro 生成的调用图还显示了存储函数地址的指令。例如,在 .text 段的地址 0x4e072c 处,有一条指令将函数 sub_4e2fa0 的地址存储到内存中。这称为“获取函数” sub_4e2fa0 的地址。任何在代码中被引用地址的函数都称为 地址引用函数。
了解哪些函数的地址被引用是很有用的,因为这表明它们可能会被间接调用,即使你不确切知道是通过哪个调用位置。如果一个函数的地址从未被引用,也没有出现在任何数据段中,你就知道它永远不会被间接调用。^(9) 这对于某些类型的二进制分析或安全应用很有帮助,例如,当你试图通过限制间接调用只允许合法目标来保护二进制文件时。

图 6-7:一个调用图,显示了指向函数 sub_404610* 的调用,来自 IDA Pro*
面向对象代码
你会发现许多二进制分析工具,包括像 IDA Pro 这样的全功能反汇编器,主要面向用 过程语言(如 C)编写的程序。因为这些语言中的代码主要通过使用函数来结构化,二进制分析工具和反汇编器提供了如函数检测等功能,用于恢复程序的函数结构,并通过调用图来检查函数之间的关系。
面向对象语言,如 C++,通过使用 类 来构造代码,这些类将逻辑上相关的函数和数据组织在一起。它们通常还提供复杂的异常处理功能,允许任何指令抛出异常,之后会被一个特殊的代码块捕获并处理。不幸的是,当前的二进制分析工具缺乏恢复类层次结构和异常处理结构的能力。
更糟糕的是,C++ 程序通常包含大量的函数指针,因为虚拟方法的实现方式。虚拟方法 是允许在派生类中重写的类方法(函数)。在一个经典示例中,你可能会定义一个名为 Shape 的类,它有一个名为 Circle 的派生类。Shape 定义了一个虚拟方法 area,用于计算形状的面积,而 Circle 则重写了这个方法,提供适用于圆形的实现。
在编译 C++ 程序时,编译器可能不知道指针在运行时会指向一个基类Shape对象还是一个派生类Circle对象,因此无法静态地确定运行时应该使用哪个area方法的实现。为了解决这个问题,编译器会生成一个包含函数指针的表,称为vtables,其中包含指向特定类的所有虚函数的指针。Vtables 通常保存在只读内存中,每个多态对象都有一个指向其类型 vtable 的指针(称为vptr)。要调用虚方法,编译器会生成代码,在运行时跟踪对象的 vptr,并间接调用 vtable 中的正确条目。不幸的是,所有这些间接调用使得程序的控制流更加难以追踪。
二进制分析工具和反汇编工具不支持面向对象程序意味着,如果你想围绕类层次结构来组织分析,你就只能依靠自己了。在手动反向工程 C**++** 程序时,你通常可以将属于不同类的函数和数据结构拼凑在一起,但这需要大量的工作。为了保持我们对(半)自动化二进制分析技术的关注,我在这里不会详细讨论这个主题。如果你有兴趣学习如何手动反向工程 C++ 代码,我推荐 Eldad Eilam 的书《Reversing: Secrets of Reverse Engineering》(Wiley,2005 年)。
在自动化分析的情况下,你可以(就像大多数二进制分析工具一样)简单地假装类不存在,将面向对象程序与过程化程序一样对待。事实上,这种“解决方案”对于许多分析工作来说足够有效,并且可以让你避免实现特殊的 C++ 支持,除非真的需要。
6.3.2 数据结构化
正如你所看到的,反汇编工具可以自动识别各种代码结构,以帮助你进行二进制分析。不幸的是,数据结构就不能这么简单了。在精简的二进制文件中自动检测数据结构是一个公认的难题,除了某些研究工作^(10),反汇编工具通常甚至不尝试处理。
但也有一些例外。例如,如果将数据对象的引用传递给一个著名的函数,如库函数,像 IDA Pro 这样的反汇编工具可以根据库函数的规范自动推断数据类型。图 6-8 展示了一个例子。
在基本块的底部,调用了著名的send函数,用于通过网络发送消息。由于 IDA Pro 知道send函数的参数,它可以标记参数名称(flags、len、buf、s),并推断出用于加载参数的寄存器和内存对象的数据类型。
此外,原始类型有时可以通过它们存储的寄存器或用于操作数据的指令来推断。例如,如果你看到使用浮点寄存器或浮点指令,你就知道相关数据是浮点数。如果你看到lodsb(加载字符串字节)或stosb(存储字符串字节)指令,很可能是在操作字符串。
对于复合类型,如struct类型或数组,所有的推测都不再适用,你必须依赖自己的分析。为了说明为什么自动识别复合类型困难,看看以下 C 代码如何编译成机器码:
1 | ccf->user = pwd->pw_uid; |

图 6-8:IDA Pro 根据使用的send函数自动推断数据类型。
这是nginx v1.8.0 源代码中的一行,其中一个struct中的整数字段被赋值到另一个struct中的字段。当使用gcc v5.1 并在优化级别-O2下编译时,生成以下机器码:
1 | mov eax,DWORD PTR [rax+0x10] |
现在让我们看看以下 C 代码,它将一个整数从一个名为b的堆分配数组复制到另一个数组a中:
1 | a[24] = b[4]; |
这是使用gcc v5.1 并在优化级别-O2下编译的结果:
1 | mov eax,DWORD PTR [rsi+0x10] |
如你所见,代码模式与struct赋值完全相同!这表明,没有任何自动化分析方法能够从这样的指令序列中判断它们是表示数组查找、struct访问,还是完全不同的操作。像这样的问题使得准确检测复合数据类型变得困难,在一般情况下甚至是不可能的。请记住,这个例子非常简单;想象一下,反向工程一个包含struct类型数组或嵌套struct的程序,并试图弄清楚哪些指令是对哪个数据结构进行索引!显然,这是一个复杂的任务,需要对代码进行深入分析。鉴于准确识别复杂数据类型的复杂性,你可以理解为什么反汇编工具不会尝试自动检测数据结构。
为了方便手动构造数据,IDA Pro 允许你定义自己的复合类型(你必须通过反向工程代码来推断这些类型),并将它们分配给数据项。Chris Eagle 的*《IDA Pro 书》*(No Starch Press, 2011)是一本非常好的手动反向工程数据结构的资源。
6.3.3 反编译
正如名称所示,反编译器是尝试“逆向编译过程”的工具。它们通常从反汇编代码开始,并将其翻译成更高层次的语言,通常是一种类似 C 的伪代码形式。在逆向大型程序时,反编译器非常有用,因为反编译的代码比大量的汇编指令更易于阅读。但由于反编译过程容易出错,反编译器只能用于手动逆向,无法作为任何自动化分析的可靠基础。尽管在本书中你不会使用反编译,但我们还是来看看清单 6-6,让你对反编译的代码有个大致的了解。
最广泛使用的反编译器是 Hex-Rays,它是 IDA Pro 的一个插件。^(11) 清单 6-6 显示了 Hex-Rays 输出的函数,展示了前面图 6-5 中显示的内容。
清单 6-6:使用 Hex-Rays 反编译的函数
1 | ➊ void **__usercall sub_4047D4<eax>(int a1<ebp>) |
正如你在清单中看到的,反编译的代码比原始汇编代码更易于阅读。反编译器推测了函数的签名 ➊ 和局部变量 ➋。此外,算术和逻辑运算使用 C 的常规运算符 ➌ 表达,而不是汇编助记符。反编译器还尝试重建控制流结构,例如 if/else 分支 ➍,循环 ➎ 和函数调用 ➏。还有一个 C 风格的返回语句,使得更容易看到函数的最终结果 ➐。
尽管这些工具非常有用,但请记住,反编译不过是帮助你理解程序正在做什么的工具。反编译的代码与原始的 C 源代码差距很大,可能会显式地失败,并且会受到底层反汇编和反编译过程本身不准确的影响。这就是为什么通常不建议在反编译的基础上进行更高级的分析。
6.3.4 中间表示
像 x86 和 ARM 这样的指令集包含了许多具有复杂语义的不同指令。例如,在 x86 上,即使是看似简单的指令,如 add,也会产生副作用,例如设置 eflags 寄存器中的状态标志。指令和副作用的数量庞大,使得自动推理二进制程序变得困难。例如,正如你将在第十章到第十三章中看到的那样,动态污点分析和符号执行引擎必须实现显式的处理程序,以捕捉它们分析的所有指令的数据流语义。准确实现这些处理程序是一个艰巨的任务。
中间表示(IR),也称为中间语言,旨在消除这一负担。IR 是一种简单的语言,作为 x86 和 ARM 等低级机器语言的抽象。常见的 IR 包括逆向工程中间语言(REIL)和VEX IR(用于valgrind插桩框架的 IR^(12))。甚至有一个叫做McSema的工具,它将二进制文件转换为LLVM 位代码(也称为LLVM IR)。^(13)
IR 语言的概念是自动将实际的机器代码(如 x86 代码)转换为 IR,这个 IR 捕获了所有机器代码的语义,但更易于分析。作为对比,REIL 只有 17 条不同的指令,而 x86 有数百条指令。此外,像 REIL、VEX 和 LLVM IR 这样的语言明确表达所有操作,没有模糊的指令副作用。
从低级机器代码到 IR 代码的转换步骤仍然是一个繁重的工作,但一旦完成这项工作,就更容易在转换后的代码上实现新的二进制分析。与其为每个二进制分析编写特定的指令处理程序,使用 IR 时,你只需进行一次翻译步骤的实现即可。此外,你还可以为多个 ISA(如 x86、ARM 和 MIPS)编写翻译器,并将它们全部映射到相同的 IR。这样,任何支持该 IR 的二进制分析工具将自动继承 IR 支持的所有 ISA。
将像 x86 这样复杂的指令集转换为像 REIL、VEX 或 LLVM IR 这样简单语言的权衡是,IR 语言远不如原始指令集简洁。这是因为在用有限数量的简单指令表达复杂操作(包括所有副作用)时,必然的结果。这通常对于自动化分析没有问题,但却往往使得中间表示对于人类来说难以阅读。为了让你了解 IR 是什么样子的,可以看看 Listing 6-7,它展示了 x86-64 指令add rax,rdx如何转换为 VEX IR。^(14)
Listing 6-7: 将 x86-64 指令 add rax,rdx 转换为 VEX IR
1 | ➊ IRSB { |
如你所见,单个add指令会生成 10 个 VEX 指令,以及一些元数据。首先,有一些元数据说明这是一个IR 超级块(IRSB) ➊,对应于一个机器指令。IRSB 包含四个临时值,分别标记为t0–t3,类型为Ity_I64(64 位整数) ➋。接下来是一个IMark ➌,它是元数据,指出了机器指令的地址和长度等信息。
接下来是实际的 IR 指令,用于建模add。首先,有两条GET指令,它们分别将 64 位值从rax和rdx取出并存储到临时寄存器t2和t1中 ➍。请注意,rax和rdx只是 VEX 状态中用于建模这些寄存器的符号名称——VEX 指令并不会从真实的rax或rdx寄存器中获取数据,而是从 VEX 的镜像状态中获取这些寄存器的数据。为了执行实际的加法,IR 使用 VEX 的Add64指令,将两个 64 位整数t2和t1相加,并将结果存储到t0中 ➎。
在加法操作之后,有一些PUT指令,用来建模add指令的副作用,例如更新 x86 状态标志 ➏。然后,另一条PUT指令将加法结果存储到 VEX 的状态中,表示rax ➐。最后,VEX IR 建模了将程序计数器更新到下一个指令 ➑。Ijk_Boring(Jump Kind Boring) ➒ 是一个控制流提示,表示add指令不会以任何有趣的方式影响控制流;由于add不是任何形式的跳转指令,控制只是“自然”地流向内存中的下一条指令。相反,分支指令可以使用像Ijk_Call或Ijk_Ret这样的提示来通知分析发生了调用或返回。
在现有的二进制分析框架上实现工具时,通常不需要处理中间表示(IR)。框架会在内部处理所有与 IR 相关的事务。然而,如果你计划实现自己的二进制分析框架或修改现有框架,了解 IR 还是很有用的。
6.4 基本分析方法
你在本章中学习的反汇编技术是二进制分析的基础。许多后续章节中讨论的高级技术,如二进制插桩和符号执行,都基于这些基本的反汇编方法。但在继续讨论这些技术之前,还有一些“标准”分析方法我想要介绍,因为它们具有广泛的应用性。请注意,这些方法并不是独立的二进制分析技术,但你可以将它们作为更高级二进制分析的组成部分来使用。除非另有说明,这些通常作为静态分析来实现,尽管你也可以修改它们以适应动态执行轨迹。
6.4.1 二进制分析属性
首先,让我们回顾一下任何二进制分析方法可能具备的不同属性。这将有助于分类我将在这里以及后续章节中介绍的不同技术,并帮助你理解它们的权衡。
跨过程和过程内分析
回想一下,函数是反汇编器尝试恢复的基本代码结构之一,因为在函数级别分析代码更加直观。使用函数的另一个原因是可扩展性:当应用于完整程序时,某些分析是不可行的。
程序中可能的路径数会随着控制转移(如跳转和调用)的数量呈指数增长。在一个仅有 10 个if/else分支的程序中,最多有 2¹⁰ = 1,024 条可能的路径。如果程序有一百个这样的分支,最多有 1.27 × 10³⁰条可能路径,而一千个分支则最多有 1.07 × 10³⁰¹条路径!许多程序的分支数远超过这个数量,因此在非平凡的程序中分析每一条可能的路径在计算上是不可行的。
这就是为什么计算量大的二进制分析通常是内程序的原因:它们只考虑每次一个函数内部的代码。通常,内程序分析会依次分析每个函数的控制流图(CFG)。这与跨程序分析形成对比,后者会将整个程序作为一个整体来考虑,通常通过调用图将所有函数的控制流图连接在一起。
因为大多数函数只包含几十条控制转移指令,所以在函数级别进行复杂分析是计算上可行的。如果你单独分析 10 个函数,每个函数有 1,024 条可能的路径,你将分析总共 10 × 1,024 = 10,240 条路径;这比考虑整个程序时必须分析的 1,024¹⁰ ≈ 1.27 × 10³⁰条路径要好得多。
内程序分析的缺点是它并不完整。例如,如果你的程序包含一个只有在非常特定的函数调用组合下才会触发的 bug,内程序 bug 检测工具就无法找到该 bug。它只会独立地考虑每个函数,并得出没有问题的结论。相比之下,跨程序工具能够找到这个 bug,但可能需要花费太长时间,导致结果已不再有意义。
另一个例子是,考虑编译器如何决定优化清单 6-8 中显示的代码,具体取决于它是使用内程序优化还是跨程序优化。
清单 6-8:包含死代码的程序
1 |
|
在这个例子中,有一个名为dead的函数,它接受一个整数参数x并不返回任何值➊。在函数内部,有一个分支,只有在x等于 5 时才会打印一条信息➋。实际上,dead只在一个位置被调用,并且其参数是常量值 4➌。因此,➋处的分支永远不会被执行,也不会打印任何信息。
编译器使用一种优化技术,叫做死代码消除,来找出在实际运行中永远无法到达的代码实例,以便它们可以在编译后的二进制文件中省略这些无用的代码。然而,在这种情况下,纯粹的过程内死代码消除会失败,无法消除➋处的无用分支。这是因为当进行dead的优化时,它并不知道其他函数中的任何代码,因此不知道dead是如何以及在何处被调用的。同样,在优化main时,它也无法深入dead函数,注意到在➌处传递给dead的特定参数导致dead什么也不做。
需要进行跨过程分析,才能得出结论:dead仅在main中被调用,且传入的值为 4,这意味着➋处的分支永远不会被执行。因此,过程内死代码消除将会在编译后的二进制文件中输出整个dead函数(及其调用),尽管它没有任何用途,而跨过程分析则会省略整个无用的函数。
流敏感性
二进制分析可以是流敏感或流不敏感的。^(15) 流敏感性意味着分析会考虑指令的执行顺序。为了更清楚地说明这一点,看看下面这个伪代码的示例。
1 | x = unsigned_int(argv[0]) # ➊x ∊ [0,∞] |
这段代码从用户输入中获取一个无符号整数,然后对其进行一些计算。假设你对进行一种分析感兴趣,旨在确定每个变量可能的值,这被称为值集分析。该分析的无流分析版本会简单地确定x可能包含任何值,因为它的值来自用户输入。虽然从程序的角度来看,x在某些时刻可能取任何值,但并不是程序中的所有点都如此。因此,无流分析提供的信息并不是非常精确,但从计算复杂度的角度来看,该分析相对便宜。
流敏感版本的分析会提供更精确的结果。与无流版本相比,它提供了在程序中每个点的x可能值集的估计,同时考虑到之前的指令。在➊处,分析得出结论,x可以是任何无符号值,因为它是从用户输入中获取的,而且此时还没有任何指令来限制x的值。然而,在➋处,你可以细化这个估计:由于x增加了 5,你知道从此时开始,x的值至少是 5。同样,在➌处的指令之后,你知道x的值至少是 15。
当然,现实生活中情况并不像那么简单,你必须处理更复杂的结构,例如分支、循环和(递归)函数调用,而不是简单的直线代码。因此,流敏感分析往往比流不敏感分析更加复杂,并且计算开销更大。
上下文敏感性
流敏感分析考虑的是指令的顺序,上下文敏感性则考虑函数调用的顺序。上下文敏感性仅对跨过程分析有意义。上下文不敏感的跨过程分析会计算一个全局的结果。另一方面,上下文敏感的分析会针对通过调用图的每一条可能路径(换句话说,针对函数可能出现在调用栈中的每一种顺序)计算一个单独的结果。请注意,这意味着上下文敏感分析的准确性受限于调用图的准确性。分析的上下文是遍历调用图时积累的状态。我将把这个状态表示为一个之前遍历过的函数列表,记作 < f[1], f[2], . . . , f[n] >。
实际上,分析的上下文通常是有限制的,因为非常大的上下文会使得流敏感分析变得计算量过大。例如,分析可能只计算连续五个(或任何任意数量的)函数的上下文结果,而不是计算任意长度路径的完整结果。作为上下文敏感分析优势的一个例子,请看图 6-9。

图 6-9:opensshd中上下文敏感与上下文不敏感的间接调用分析
该图展示了上下文敏感性如何影响opensshd v3.5 中间接调用分析的结果。分析的目标是找出channel_handler函数中间接调用位置的可能目标(即执行(*ftab[c->type])(c, readset, writeset);的那一行)。间接调用位置从一个函数指针表中获取其目标,这个表作为参数ftab传递给channel_handler。channel_handler函数由两个其他函数调用:channel_prepare_select和channel_after_select。这两个函数各自将自己的函数指针表作为ftab参数传递。
在没有上下文敏感分析的情况下,间接调用分析得出的结论是channel_handler中的间接调用可能指向channel_pre表中的任何函数指针(从channel_prepare_select传入)或channel_post表中的任何函数指针(从channel_after_select传入)。实际上,它得出结论,所有可能的目标集合是程序中任何路径上所有可能集合的并集 ➊。
相比之下,上下文敏感分析为每个可能的前置调用上下文确定一个不同的目标集合。如果channel_handler是由channel_prepare_select调用的,那么只有在它传递给channel_handler的channel_pre表中的目标才是有效的➋。另一方面,如果channel_handler是从channel_after_select调用的,那么只有channel_post中的目标是可能的➌。在这个例子中,我只讨论了长度为 1 的上下文,但一般来说,上下文可以是任意长的(只要是通过调用图的最长路径)。
与流敏感性类似,上下文敏感性的优点是提高了精度,而缺点则是更高的计算复杂性。此外,上下文敏感分析必须处理大量的状态信息,用以追踪所有不同的上下文。而且,如果存在递归函数,可能的上下文数量是无限的,因此需要采取特别措施来处理这些情况^(16)。通常,若不通过诸如限制上下文大小等成本与收益的权衡,创建一个可扩展的上下文敏感分析版本可能是不可行的。
6.4.2 控制流分析
任何二进制分析的目的是找出程序的控制流属性、数据流属性或两者。专注于控制流属性的二进制分析被称为控制流分析,而专注于数据流的分析被称为数据流分析。这种区分仅仅是基于分析是否专注于控制流或数据流;它并没有说明分析是过程内分析还是跨过程分析,是流敏感还是流不敏感,或者是上下文敏感还是上下文不敏感。让我们先来看一种常见的控制流分析类型,叫做循环检测。在下一节中,你将看到一些常见的数据流分析。
循环检测
顾名思义,循环检测的目的是在代码中查找循环。在源代码级别,像while或for这样的关键字可以轻松地帮助你找到循环。在二进制级别,这就更难一些,因为循环通常使用与实现if/else分支和开关语句相同的(有条件或无条件的)跳转指令来实现。
查找循环的能力有很多用途。例如,从编译器的角度来看,循环很重要,因为程序的大部分执行时间都花费在循环中(一个常被引用的数字是 90%)。这意味着循环是优化的一个重要目标。从安全角度来看,分析循环也很有用,因为像缓冲区溢出这样的漏洞往往发生在循环中。
编译器中使用的循环检测算法采用了不同于直觉的循环定义。这些算法寻找自然循环,这些循环具有某些良好的结构属性,使得它们更易于分析和优化。也有一些算法可以检测 CFG 中的任何循环,即使这些循环不符合自然循环的严格定义。图 6-10 展示了一个包含自然循环的 CFG 示例,以及一个不是自然循环的循环。
首先,我将向您展示用于检测自然循环的典型算法。之后,您会更清楚为什么并非每个循环都符合该定义。要理解什么是自然循环,您需要了解什么是支配树。图 6-10 的右侧展示了一个支配树的示例,它对应于图左侧展示的 CFG。

图 6-10:一个 CFG 及其对应的支配树
一个基本块A被认为是支配另一个基本块B,如果从控制流图(CFG)的入口点到达B的唯一方式是先经过A。例如,在图 6-10 中,BB[3]支配BB[5],但不支配BB[6],因为BB[6]也可以通过BB[4]到达。相反,BB[6]由BB[1]支配,BB[1]是从入口点到BB*[6]*的所有路径必须经过的最后一个节点。支配树编码了 CFG 中的所有支配关系。
现在,一个自然循环是由一个从基本块B到A的回边诱发的,其中A支配B。由这个回边产生的循环包含所有由A支配的、从中有路径通向B的基本块。通常,B本身被排除在这个集合之外。直观地说,这一定义意味着自然循环不能在中途被进入,只能在一个明确的头节点处进入。这简化了自然循环的分析。
例如,在图 6-10 中,存在一个自然循环,横跨基本块BB[3]和BB[5],因为从BB[5]到BB[3]有回边,且BB[3]支配BB[5]。在这种情况下,BB[3]是循环的头节点,BB[5]是“回环”节点,而循环的“主体”(根据定义不包括头节点和回环节点)不包含任何节点。
循环检测
您可能已经注意到图中有另一个回边,从BB[7]到BB[4]。这个回边诱发了一个循环,但不是自然循环,因为循环可以在BB[6]或BB[7]“中途”进入。由于这个原因,BB[4]没有支配BB[7],因此该循环不符合自然循环的定义。
要找到像这样的循环,包括任何自然循环,你只需要控制流图(CFG),而不需要支配树。只需从 CFG 的入口节点开始深度优先搜索(DFS),然后保持一个栈,每当 DFS 遍历一个基本块时,就将其推入栈中,并在 DFS 回溯时将其弹出。如果 DFS 遇到一个已经在栈中的基本块,那么你就找到了一个循环。
例如,假设你正在对 图 6-10 中显示的控制流图(CFG)进行 DFS。DFS 从入口点 BB[1] 开始。列表 6-9 显示了 DFS 状态的演变以及 DFS 如何在 CFG 中检测到两个循环(为了简洁起见,我没有展示 DFS 在找到两个循环之后的继续过程)。
列表 6-9:使用 DFS 检测循环
1 | 0: [BB1] |
首先,DFS 探索 BB[1] 的最左分支,但在遇到死胡同时迅速回溯。然后进入中间分支,从 BB[1] 到 BB[3],继续沿着 BB[5] 搜索,在此之后再次遇到 BB[3],从而找到包含 BB[3] 和 BB[5] 的循环 ➊。接着回溯到 BB[5],继续沿着通往 BB[7] 的路径搜索,然后是 BB[4]、BB[6],直到最终再次遇到 BB[7],找到第二个循环 ➋。
6.4.3 数据流分析
现在让我们来看看一些常见的数据流分析技术:到达定义分析、使用-定义链和程序切片。
到达定义分析
到达定义分析 解答了“哪些数据定义可以到达程序中的这一点?”当我说一个数据定义可以“到达”程序中的某个点时,我的意思是,分配给一个变量(或者在更低级别上,分配给一个寄存器或内存位置)的值可以到达该点,而不会在此过程中被其他赋值覆盖。到达定义分析通常应用于控制流图(CFG)级别,尽管它也可以在过程间使用。
分析首先通过考虑每个基本块生成哪些定义并杀死哪些定义来开始。通常通过计算每个基本块的 gen 和 kill 集合来表达这一点。图 6-11 显示了基本块的 gen 和 kill 集合示例。
BB[3] 的 gen 集合包含语句 6 和 8,因为这些是 BB[3] 中的定义,直到基本块结束时仍然有效。语句 7 不再有效,因为 z 被语句 8 覆盖。kill 集合包含来自 BB[1] 和 BB[2] 的语句 1、3 和 4,因为这些赋值被 BB[3] 中的其他赋值覆盖。

图 6-11:基本块的 gen 和 kill 集合示例
计算每个基本块的gen和kill集合之后,你就得到了一个局部解,告诉你每个基本块生成和消除的数据定义。从这些信息中,你可以计算出一个全局解,告诉你哪些定义(来自控制流图中的任何地方)可以到达一个基本块的开始,哪些定义在基本块执行完后仍然存活。可以到达基本块B的全局定义集合表示为一个集合out[B],定义如下:

直观地说,这意味着到达B的定义集合是所有离开其他前驱基本块的定义集合的并集。离开基本块B的定义集合表示为out[B],定义如下:

换句话说,离开B的定义是B自己生成的或从其前驱接收的(作为其in集合的一部分)且没有被杀死的定义。注意,in集合和out集合之间存在相互依赖关系:in是通过out定义的,反之亦然。这意味着实际上,进行到达定义分析时,仅仅计算每个基本块的in和out集合一次是不够的。相反,分析必须是迭代的:每次迭代时,它都会计算每个基本块的集合,并继续迭代,直到集合没有再发生变化为止。一旦所有的in和out集合都达到稳定状态,分析就完成了。
到达定义分析构成了许多数据流分析的基础。这包括使用-定义分析,我接下来将讨论这一点。
使用-定义链
使用-定义链告诉你,在程序中的每个变量使用点,那个变量可能被定义的位置。例如,在图 6-12 中,B[2]中y的使用-定义链包含语句 2 和语句 7。这是因为在该控制流图(CFG)中的这一点,y可能是通过语句 2 的原始赋值或(经过一次循环迭代后)语句 7 获得的。注意,B[2]中没有z的使用-定义链,因为z仅在该基本块中被赋值,而未被使用。

图 6-12:使用-定义链的示例
使用-定义链的一个应用场景是反编译:它们使反编译器能够追踪在条件跳转中使用的值被比较的位置。通过这种方式,反编译器可以将cmp x,5和je(相等时跳转)指令合并为一个更高层次的表达式,如if(x == 5)。使用-定义链也用于编译器优化,例如常量传播,当某个变量在程序中的某个点唯一的可能值为常量时,替换该变量为常量。它们在许多其他二进制分析场景中也很有用。
乍一看,计算使用-定义链(use-def chain)可能会显得复杂。但在有了控制流图(CFG)的达成定义分析之后,利用in集来查找可能到达基本块的该变量的定义,计算基本块中变量的使用-定义链就变得相当简单。除了使用-定义链,还可以计算定义-使用链。与使用-定义链相反,定义-使用链告诉你程序中某个数据定义可能在哪些地方被使用。
程序切片
切片是一种数据流分析方法,旨在提取在程序某一特定点(称为切片标准)对一组选定变量的值有贡献的所有指令(或者,对于基于源代码的分析,是指源代码的所有行)。这在调试时非常有用,尤其是当你想找出哪些代码部分可能是导致 bug 的原因,也适用于逆向工程。计算切片可能非常复杂,它仍然是一个活跃的研究课题,而不是生产就绪的技术。尽管如此,它仍然是一个有趣的技术,值得了解。在这里,我将简单介绍它的基本思想,如果你想深入体验切片,我建议你查看 angr 逆向工程框架,^(17),它提供了内置的切片功能。你还可以在第十三章中看到如何通过符号执行实现一个实用的切片工具。
切片是通过跟踪控制流和数据流来计算的,以找出哪些代码部分与切片无关,然后删除这些部分。最终的切片是删除所有无关代码后剩下的部分。例如,假设你想知道示例 6-10 中哪些行对第 14 行的y值有贡献。
示例 6-10:使用切片来查找对 y 在第 14 行的贡献行
1 | 1: x = int(argv[0]) |
该切片包含代码中阴影灰色的行。请注意,所有对z的赋值与切片完全无关,因为它们对y的最终值没有影响。x的变化是相关的,因为它决定了第 5 行的循环迭代次数,这反过来又影响了y的值。如果你只编译切片中包含的行,print(y)语句的输出将与完整程序的输出完全相同。
最初,切片是作为静态分析提出的,但现在它通常应用于动态执行跟踪。动态切片的优势在于,它通常比静态切片产生更小(因此更易读)的切片。
你刚才看到的被称为 反向切片,因为它是从后向前搜索影响所选切片标准的行。但也有 正向切片,它从程序中的某个点开始,向前搜索以确定其他哪些代码部分会受到所选切片标准中的指令和变量的影响。除此之外,它还可以预测代码中的哪些部分会受到所选点上代码更改的影响。
6.5 编译器设置对反汇编的影响
编译器优化代码以最小化其大小或执行时间。不幸的是,优化后的代码通常比未优化的代码更难以精确反汇编(因此也更难分析)。
优化后的代码与原始源代码的对应关系较少,这使得它对人类的直观性降低。例如,在优化算术代码时,编译器会尽量避免非常慢的 mul 和 div 指令,而是通过一系列位移和加法操作来实现乘法和除法。逆向工程时,这些操作可能会很难解读。
此外,编译器经常将小函数合并到调用它们的较大函数中,以避免 call 指令的开销;这种合并被称为 内联。因此,你在源代码中看到的并不一定都是二进制文件中存在的函数,至少它们不会作为单独的函数存在。此外,常见的函数优化,例如尾调用和优化的调用约定,会使得函数检测的准确性大大降低。
在较高的优化级别下,编译器通常会在函数和基本块之间插入填充字节,以便将它们对齐到可以最有效访问的内存地址。将这些填充字节误解释为代码可能会导致反汇编错误,尤其是当这些填充字节不是有效指令时。此外,编译器可能会“展开”循环,以避免跳转到下一次迭代的开销。这会妨碍循环检测算法和反编译器,后者试图在代码中找到类似 while 和 for 循环的高级结构。
优化还可能妨碍数据结构检测,而不仅仅是代码发现。例如,优化后的代码可能同时使用相同的基址寄存器来索引不同的数组,这使得很难将它们识别为独立的数据结构。
如今,链接时优化 (LTO) 越来越受到欢迎,这意味着传统上在每个模块基础上应用的优化现在可以用于整个程序。这增加了许多优化的优化面,使得效果更加深远。
在编写和测试自己的二进制分析工具时,务必记住,优化后的二进制文件可能会影响工具的准确性。
除了之前提到的优化方法之外,二进制文件越来越多地被编译为位置无关代码(PIC),以适应像地址空间布局随机化(ASLR)这样的安全功能,这些功能需要能够在不破坏二进制文件的情况下移动代码和数据。^(18) 使用 PIC 编译的二进制文件称为位置无关可执行文件(PIE)。与位置依赖的二进制文件相比,PIE 二进制文件不会使用绝对地址来引用代码和数据。相反,它们使用相对于程序计数器的引用。这也意味着一些常见的结构,比如 ELF 二进制文件中的 PLT,在 PIE 二进制文件中与非 PIE 二进制文件中的表现不同。因此,那些没有考虑到 PIC 的二进制分析工具,可能无法正确处理这种二进制文件。
6.6 总结
你现在已经了解了反汇编器的内部工作原理,以及理解本书其余部分所需的基本二进制分析技术。现在你已经准备好继续学习一些技术,不仅能够反汇编二进制文件,还能修改它们。让我们从第七章开始,学习基本的二进制修改技术!
练习
- 欺骗 objdump
编写一个程序,欺骗objdump,使其将数据解读为代码,或者将代码解读为数据。你可能需要使用一些内联反汇编来实现这一点(例如,使用gcc的asm关键字)。
- 欺骗递归反汇编器
编写另一个程序,这次让它欺骗你最喜欢的递归反汇编器的函数检测算法。实现这一点有多种方法。例如,你可以创建一个尾调用函数,或者一个带有多个返回情况的switch函数。看看你能让反汇编器困惑到什么程度!
- 改进函数检测
为你选择的递归反汇编器编写一个插件,使其能够更好地检测诸如在之前练习中未能检测到的函数。你需要一个可以为其编写插件的递归反汇编器,例如 IDA Pro、Hopper 或 Medusa。
第七章:ELF 的简单代码注入技术
在本章中,你将学习几种将代码注入现有 ELF 二进制文件的技术,这些技术可以让你修改或增强二进制文件的行为。尽管本章讨论的技术对于进行小规模修改非常方便,但它们的灵活性较差。本章将展示这些技术的局限性,以便你理解更全面的代码修改技术的必要性,这些技术你将在第九章中学习到。
7.1 使用十六进制编辑进行裸金属二进制修改
修改现有二进制文件最直接的方法是使用十六进制编辑器,这是一种以十六进制格式表示二进制文件字节并允许你编辑这些字节的程序。通常,你会先使用反汇编工具识别你想要更改的代码或数据字节,然后再使用十六进制编辑器进行更改。
这种方法的优点在于它简单,只需要基本的工具。缺点是它仅支持就地编辑:你可以更改代码或数据字节,但不能添加任何新的内容。插入新的字节会导致后面的所有字节移到另一个地址,从而破坏对这些字节的引用。由于在链接阶段之后通常会丢弃所需的重定位信息,因此很难(甚至不可能)正确识别和修复所有损坏的引用。如果二进制文件中包含任何填充字节、死代码(如未使用的函数)或未使用的数据,你可以用新内容覆盖这些部分。然而,由于大多数二进制文件中没有很多可以安全覆盖的死字节,这种方法是有限制的。
然而,在某些情况下,十六进制编辑可能是你所需要的一切。例如,恶意软件使用反调试技术来检查它运行的环境是否存在分析软件的痕迹。如果恶意软件怀疑自己正在被分析,它会拒绝运行或攻击分析环境。当你分析一个恶意软件样本并怀疑它包含反调试检查时,你可以使用十六进制编辑禁用这些检查,将检查部分覆盖为nop(无操作)指令。有时,你甚至可以通过十六进制编辑器修复程序中的简单错误。为了向你展示一个例子,我将使用名为hexedit的十六进制编辑器,它是一个开源编辑器,已在虚拟机上预安装,用于修复一个简单程序中的越界错误。
寻找正确的操作码
当你在二进制文件中编辑代码时,你需要知道要插入哪些值,为此,你需要了解机器指令的格式和十六进制编码。网上有很多关于 x86 指令的操作码和操作数格式的有用概览,例如*ref.x86asm.net。如果你需要更详细的信息来了解某个 x86 指令如何工作,可以查阅官方的英特尔手册。^a*
a. software.intel.com/sites/default/files/managed/39/c5/325462-sdm-vol-1-2abcd-3abcd.pdf
7.1.1 观察越界错误的实际表现
越界错误 通常发生在循环中,当程序员使用了错误的循环条件,导致循环读取或写入少了一个字节或多了一个字节。列表 7-1 中的示例程序加密一个文件,但由于越界错误,不小心将最后一个字节未加密。为了解决这个问题,我将首先使用 objdump 反汇编二进制文件并定位到出错的代码。然后我会使用 hexedit 编辑该代码并去除越界错误。
列表 7-1: xor_encrypt.c
1 |
|
在解析命令行参数后,程序打开要加密的输入文件 ➊,确定文件大小并将其存储在名为 n 的变量中 ➋,分配一个缓冲区 ➌ 用来存储文件,读取整个文件到缓冲区 ➍,然后关闭文件 ➎。如果在过程中出现任何问题,程序会调用 die 函数打印适当的错误信息并退出。
错误发生在程序的下一个部分,该部分使用简单的 xor 算法加密文件字节。程序进入一个 for 循环,遍历包含所有文件字节的缓冲区,并通过与提供的密钥 ➏ 做 xor 运算来加密每个字节。注意 for 循环的循环条件:循环从 i = 0 开始,但仅当 i < n-1 时才会继续。这意味着最后一个加密的字节位于缓冲区的索引 n-2 处,因此最后一个字节(索引为 n-1)未被加密!这就是越界错误,我们将使用十六进制编辑器来修复它。
在加密文件缓冲区后,程序打开一个输出文件 ➐,将加密后的字节写入文件 ➑,最后关闭输出文件 ➒。列表 7-2 显示了程序的示例运行(使用虚拟机中提供的 Makefile 编译),可以看到程序中存在越界错误的实际情况。
列表 7-2:观察 *xor_encrypt* 程序中的越界错误
1 | ➊ $ ./xor_encrypt xor_encrypt.c encrypted foobar |
在这个示例中,我使用 xor_encrypt 程序用密钥 foobar 加密了它自己的源文件,并将输出写入名为 encrypted 的文件 ➊。使用 xxd 查看原始源文件的内容 ➋,你会看到它以字节 0x0a 结尾 ➌。在加密后的文件中,所有字节都被破坏了 ➍,除了最后一个字节,它与原文件中的字节相同 ➎。这是因为越界错误导致最后一个字节没有被加密。
7.1.2 修复越界错误
现在让我们看看如何修复二进制文件中的越界错误。在本章的所有示例中,你可以假装没有二进制文件的源代码,尽管实际上你是有的。这是为了模拟现实中你被迫使用二进制修改技术的情况,比如你正在处理专有或恶意程序,或者源代码丢失的程序。
查找导致错误的字节
要修复越界错误,你需要更改循环条件,使其多循环一次以加密最后一个字节。因此,你首先需要反汇编二进制文件,找到负责强制执行循环条件的指令。清单 7-3 包含了相关的指令,正如 objdump 所显示的那样。
清单 7-3:显示越界错误的反汇编代码
1 | $ objdump -M intel -d xor_encrypt |
循环从地址 0x4007d8 ➊ 开始,循环计数器(i)存储在 rbx 寄存器中。你可以看到循环计数器在每次循环迭代时都会递增 ➋。你还可以看到一个 cmp 指令 ➌,它检查是否需要进行另一轮循环。cmp 将 i(存储在 rbx)与值 n-1(存储在 r12)进行比较。如果需要进行另一轮循环,jne 指令 ➍ 会跳转回循环开始处。如果不需要,它会跳过,执行下一条指令,结束循环。
jne 指令表示“如果不相等则跳转”^(1):当 i 不等于 n-1(由 cmp 指令确定)时,它会跳转回循环的开始处。换句话说,由于 i 在每次循环迭代时都会递增,循环将会在 i < n-1 时运行。但为了修复越界错误,你希望循环在 i <= n-1 时运行,这样就能多循环一次。
替换有问题的字节
为了实现这个修复,你可以使用十六进制编辑器替换 jne 指令的操作码,将其改为另一种跳转指令。cmp 的第一个操作数是 r12(包含 n-1),后面是 rbx(包含 i)。因此,你应该使用 jae(“如果大于或等于则跳转”)指令,使得循环在 n-1 >= i 时继续运行,也就是相当于说 i <= n-1。现在你可以使用 hexedit 实现这个修复。
要跟着操作,请转到本章的代码文件夹,运行 Makefile,然后在命令行中输入 hexedit xor_encrypt 并按 ENTER 以在十六进制编辑器中打开 xor_encrypt 二进制文件(这是一个交互式程序)。要查找需要修改的特定字节,你可以搜索来自反汇编器(如 objdump)的字节模式。在 Listing 7-3 中,你可以看到需要修改的 jne 指令被编码为十六进制字节串 75d9,所以你将搜索这个模式。在更大的二进制文件中,你可能需要使用更长的模式,可能包括其他指令的字节,以确保唯一性。要在 hexedit 中搜索模式,按 / 键。这将打开一个提示框,如 Figure 7-1 所示,你可以在其中输入搜索模式 75d9,然后按 ENTER 开始搜索。

Figure 7-1:使用 hexedit 搜索字节串
搜索会找到模式并将光标移到模式的第一个字节。参考 x86 操作码参考或英特尔 x86 手册,你可以看到 jne 指令被编码为一个操作码字节(0x75),后跟一个编码跳转位置偏移量的字节(0xd9)。为了这些目的,你只需要将 jne 操作码 0x75 替换为 jae 指令的操作码 0x73,而跳转偏移量保持不变。由于光标已经位于你想修改的字节上,编辑所需的只是输入新的字节值 73。在你输入时,hexedit 会用粗体突出显示修改过的字节值。现在,剩下的就是按 CTRL-X 退出并按 Y 确认更改来保存修改过的二进制文件。你现在已经修复了二进制文件中的越界错误!让我们通过再次使用 objdump 来确认这个更改,如 Listing 7-4 所示。
Listing 7-4:显示修复越界错误补丁的反汇编
1 | $ objdump -M intel -d xor_encrypt.fixed |
如你所见,原来的 jne 指令现在已被 jae ➊ 替换。为了检查修复是否有效,让我们再次运行程序,看它是否加密了最后一个字节。Listing 7-5 显示了结果。
Listing 7-5:修复后的 xor_encrypt 程序输出
1 | ➊ $ ./xor_encrypt xor_encrypt.c encrypted foobar |
和之前一样,你运行 xor_encrypt 程序来加密它自己的源代码 ➊。回想一下,在原始源文件中,最后一个字节的值是 0x0a(见 Listing 7-2)。使用 xxd 检查加密文件 ➋,你可以看到即使是最后一个字节现在也已正确加密 ➌:它现在是 0x65 而不是 0x0a。
现在你知道如何使用十六进制编辑器编辑二进制文件了!虽然这个例子很简单,但程序对于更复杂的二进制文件和编辑是相同的。
7.2 使用 LD_PRELOAD 修改共享库行为
十六进制编辑是一种修改二进制文件的好方法,因为它只需要基础工具,而且由于修改较小,编辑后的二进制文件通常与原始文件相比几乎没有性能或代码/数据大小的开销。然而,正如你在前一节的示例中看到的,十六进制编辑也很繁琐、容易出错并且有局限性,因为你不能添加新的代码或数据。如果你的目标是修改共享库函数的行为,使用 LD_PRELOAD 可以更轻松地实现。
LD_PRELOAD 是一个环境变量的名称,它会影响动态链接器的行为。它允许你指定一个或多个库,在任何其他库加载之前,包括像 libc.so 这样的标准系统库。如果一个预加载的库中包含与稍后加载的库中的某个函数同名的函数,那么运行时将使用第一个函数。这使得你可以用自己实现的版本 覆盖 库函数(即使是像 malloc 或 printf 这样的标准库函数)。这不仅对二进制修改有用,对于那些源代码可用的程序也很有帮助,因为修改库函数的行为可以避免你费力修改源代码中所有调用该库函数的地方。我们来看一个例子,说明 LD_PRELOAD 如何有助于修改二进制程序的行为。
7.2.1 堆溢出漏洞
我将在这个示例中修改的程序是 heapoverflow,它包含一个堆溢出漏洞,可以通过 LD_PRELOAD 来修复。示例 7-6 显示了程序的源代码。
示例 7-6: heapoverflow.c
1 |
|
heapoverflow 程序接受两个命令行参数:一个数字和一个字符串。它将给定的数字作为缓冲区的大小 ➊,然后使用 malloc ➋ 分配该大小的缓冲区。接下来,它使用 strcpy ➌ 将给定的字符串复制到缓冲区中,并将缓冲区的内容打印到屏幕上。最后,它使用 free ➍ 释放该缓冲区。
溢出漏洞出现在 strcpy 操作中:因为字符串的长度从未检查,所以它可能太大,无法放入缓冲区。如果是这种情况,复制操作将导致堆溢出,可能会破坏堆上的其他数据,并导致崩溃甚至利用程序漏洞。但如果给定的字符串可以适应缓冲区,一切都能正常工作,就像你在 示例 7-7 中看到的那样。
示例 7-7: heapoverflow 程序在输入正常时的行为
1 | ./heapoverflow 13 'Hello world!' |
在这里,我告诉 heapoverflow 分配一个 13 字节的缓冲区,然后将消息“Hello world!”复制进去 ➊。程序分配了请求的缓冲区,将消息复制进去,并按预期将其打印到屏幕上,因为该缓冲区刚好足够大,能够容纳字符串,包括其终止的 NULL 字符。让我们检查 Listing 7-8,看看如果提供一个无法适应缓冲区的消息会发生什么。
Listing 7-8: 输入过长时 heapoverflow 程序崩溃
1 | ➊ $ ./heapoverflow 13 `perl -e 'print "A"x100'` |
再次,我告诉程序分配 13 字节,但现在消息太大,无法适应缓冲区:它是一个包含 100 个 A 字符的字符串 ➊。程序如前所述分配了 13 字节的缓冲区 ➋,然后将消息复制进去并将其打印到屏幕上 ➌。然而,当调用 free 来释放缓冲区时出现问题 ➍:溢出的消息覆盖了堆上的元数据,这些元数据被 malloc 和 free 用来跟踪堆缓冲区。损坏的堆元数据最终导致程序崩溃 ➎。最坏的情况是,这种溢出可能使攻击者通过精心构造的溢出字符串接管易受攻击的程序。现在,让我们看看如何使用 LD_PRELOAD 来检测和防止溢出。
7.2.2 检测堆溢出
关键思路是实现一个共享库,重写 malloc 和 free 函数,使其在内部跟踪所有分配的缓冲区的大小,并且重写 strcpy,使其在复制任何内容之前自动检查缓冲区是否足够大以容纳字符串。请注意,为了示例的简单性,这个思路过于简化,不应在生产环境中使用。例如,它没有考虑到缓冲区大小可能通过 realloc 改变,并且使用了简单的记账方法,最多只能追踪最近 1,024 个分配的缓冲区。然而,它应该足以展示如何使用 LD_PRELOAD 来解决现实世界中的问题。Listing 7-9 显示了包含替代 malloc/free/strcpy 实现的库代码 (heapcheck.c)。
Listing 7-9: heapcheck.c
1 |
|
首先,注意到 dlfcn.h 头文件 ➊,当你编写供 LD_PRELOAD 使用的库时,通常会包含这个头文件,因为它提供了 dlsym 函数。你可以使用 dlsym 来获取共享库函数的指针。在这种情况下,我将使用它来访问原始的 malloc、free 和 strcpy 函数,以避免完全重新实现它们。有一组全局函数指针,用来跟踪通过 dlsym 找到的这些原始函数 ➋。
为了跟踪分配的缓冲区大小,我定义了一个名为 alloc_t 的 struct 类型,它可以存储缓冲区的地址和大小 ➌。我使用一个全局的圆形数组来存储这些结构,称为 allocs,用于跟踪最近的 1,024 次分配 ➍。
现在,让我们来看看修改后的malloc函数 ➎。它做的第一件事是检查指向原始(libc)版本的malloc的指针(我称之为orig_malloc)是否已经初始化。如果没有,它会调用dlsym来查找这个指针 ➏。
请注意,我在dlsym中使用了RTLD_NEXT标志,这会导致dlsym返回链中下一个版本的malloc的指针。当你预加载一个库时,它将位于链的开始。因此,dlsym返回指针的下一个版本的malloc将是原始的libc版本,因为libc会比你的预加载库晚加载。
接下来,修改后的malloc调用orig_malloc来执行实际的分配 ➐,然后将分配的缓冲区的地址和大小存储在全局allocs数组中。现在这些信息已经存储,strcpy以后可以检查是否可以安全地将字符串复制到给定的缓冲区中。
新版本的free与新的malloc类似。它简单地解析并调用原始的free(orig_free),然后在allocs数组中使已释放缓冲区的元数据无效 ➑。
最后,让我们看一下新的strcpy ➒。它首先解析原始的strcpy(orig_strcpy)。然而,在调用之前,它会检查通过在全局allocs数组中搜索一个条目来确认复制是否安全,该条目会告诉你目标缓冲区的大小。如果找到元数据,strcpy会检查缓冲区是否足够大以容纳字符串 ➓。如果是,它就允许复制。如果不是,它会打印错误消息并终止程序,以防止攻击者利用这个漏洞。请注意,如果没有找到元数据,因为目标缓冲区不是最近 1,024 次分配之一,strcpy会允许复制。实际上,你可能希望通过使用更复杂的数据结构来跟踪元数据,避免这种情况,这种结构不限于 1,024 个(或任何硬限制的)分配。
清单 7-10 展示了如何在实践中使用heapcheck.so库。
清单 7-10:使用 heapcheck.so 库来防止堆溢出
1 | ➊LD_PRELOAD=`pwd`/heapcheck.so ./heapoverflow 13 `perl -e 'print "A"x100'` |
这里需要注意的关键点是在启动heapoverflow程序时定义LD_PRELOAD环境变量 ➊。这会导致链接器预加载指定的库,heapcheck.so,该库包含修改过的malloc、free和strcpy函数。请注意,LD_PRELOAD中给出的路径需要是绝对路径。如果使用相对路径,动态链接器将无法找到该库,预加载也将无法进行。
heapoverflow程序的参数与清单 7-8 中的相同:一个 13 字节的缓冲区和一个 100 字节的字符串。如你所见,现在堆溢出不再导致崩溃。修改后的strcpy成功检测到了不安全的拷贝,打印了错误信息,并安全地中止了程序 ➋,使得攻击者无法利用这个漏洞。
如果仔细查看heapoverflow程序的 Makefile,你会注意到我使用了gcc的-fno-builtin标志来构建程序。对于像malloc这样的基本函数,gcc有时会使用内建版本,将其静态链接到编译后的程序中。在这种情况下,我使用-fno-builtin确保不会发生这种情况,因为静态链接的函数不能通过LD_PRELOAD进行覆盖。
7.3 注入代码段
到目前为止,你学到的二进制修改技术在适用性上相对有限。十六进制编辑对于小范围的修改很有用,但你无法添加太多(如果有的话)新代码或数据。LD_PRELOAD允许你轻松添加新代码,但它只能用于修改库函数调用。在深入探讨第九章中更灵活的二进制修改技术之前,让我们先来了解如何将一个全新的代码段注入到 ELF 二进制文件中;这个相对简单的技巧比前面讨论的那些方法更灵活。
在虚拟机上,有一个完整的工具叫做elfinject,它实现了这种代码注入技术。由于elfinject的源代码比较长,我在这里不打算详细讲解,但如果你感兴趣,可以在附录 B 中找到关于elfinject实现的说明。附录还介绍了libelf,这是一个流行的开源库,用于解析 ELF 二进制文件。虽然理解本书剩余部分时不需要了解libelf,但在实现你自己的二进制分析工具时,它会非常有用,所以我鼓励你阅读附录 B。
在本节中,我将为你提供一个高层次的概述,解释代码段注入技术的主要步骤。接下来,我将向你展示如何使用虚拟机上提供的elfinject工具,将代码段注入到 ELF 二进制文件中。
7.3.1 注入 ELF 段:高层次概述
图 7-2 展示了将新代码段注入 ELF 所需的主要步骤。图的左侧展示了原始(未修改)ELF 文件,而右侧则展示了添加了新段后的修改文件,新的代码段被称为.injected。
要向 ELF 二进制文件添加新段,首先需要注入该段所包含的字节(在图 7-2 中的步骤➊),将其附加到二进制文件的末尾。接着,你需要为注入的段创建一个段头 ➋ 和一个程序头 ➌。
正如你可能记得的第二章,程序头表通常位于可执行文件头之后➍。因此,添加一个额外的程序头会使后面的所有段和头部发生偏移。为了避免复杂的偏移操作,你可以简单地覆盖一个现有的程序头,而不是添加一个新的程序头,如图 7-2 所示。这正是elfinject所实现的,你也可以应用相同的头部覆盖技巧,以避免向二进制文件中添加新的段头。^(2)

图 7-2:将 .note.ABI-tag 替换为注入的代码段
覆盖 PT_NOTE 段
如你刚才所见,覆盖现有的段头和程序头比添加全新的更为容易。但你如何知道哪些头部可以安全地覆盖,而不会破坏二进制文件呢?一个你可以始终安全覆盖的程序头是PT_NOTE头,它描述了PT_NOTE段。
PT_NOTE段包含有关二进制文件的辅助信息。例如,它可能会告诉你这是一个 GNU/Linux 二进制文件、该二进制文件期望的内核版本等等。特别是在虚拟机中的/bin/ls可执行文件中,PT_NOTE段包含了两个部分的信息,分别是.note.ABI-tag和.note.gnu.build-id。如果这些信息缺失,加载器会默认认为这是一个本地二进制文件,因此可以安全地覆盖PT_NOTE头,而不必担心破坏二进制文件。这个技巧通常被恶意软件用来感染二进制文件,但它也可以用于无害的修改。
现在,让我们考虑图 7-2 中步骤➋所需的更改,你需要覆盖其中一个.note.*段头,将其转变为新代码段(.injected)的头。我将(任意地)选择覆盖.note.ABI-tag段的头部。正如你在图 7-2 中看到的,我将sh_type从SHT_NOTE更改为SHT_PROGBITS,以表示该头部现在描述的是代码段。此外,我将sh_addr、sh_offset和sh_size字段更改为描述新.injected段的位置和大小,而不是已经过时的.note.ABI-tag段。最后,我将段对齐(sh_addralign)更改为 16 字节,以确保代码在加载到内存时能够正确对齐,并且我将SHF_EXECINSTR标志添加到sh_flags字段中,将该段标记为可执行的。
步骤➌的更改类似,不过在这里,我更改的是PT_NOTE程序头,而不是段头。同样,我通过将p_type设置为PT_LOAD来更改头类型,以指示该头现在描述的是一个可加载的段,而不是PT_NOTE段。这使得加载器在程序启动时将该段(包括新的.injected段)加载到内存中。我还更改了所需的地址、偏移量和大小字段:p_offset、p_vaddr(以及p_paddr,未显示)、p_filesz和p_memsz。我将p_flags设置为标记该段为可读且可执行,而不仅仅是可读,并且修正了对齐(p_align)。
虽然图 7-2 中没有显示,但最好也更新字符串表,将旧的.note.ABI-tag段的名称更改为像.injected这样的名称,以反映新代码段的添加。我在附录 B 中详细讨论了这个步骤。
重定向 ELF 入口点
图 7-2 中的步骤➍是可选的。在这个步骤中,我更改了 ELF 可执行文件头中的e_entry字段,使其指向新的.injected段中的一个地址,而不是指向通常位于.text中的原始入口点。只有当你希望.injected段中的某些代码在程序开始时运行时,你才需要这样做。否则,你可以保持入口点不变,不过在这种情况下,新的注入代码永远不会执行,除非你通过重定向原始.text段中的某些调用到注入代码、将一些注入代码用作构造函数,或者使用其他方法来调用注入的代码。我将在第 7.4 节中讨论更多调用注入代码的方法。
7.3.2 使用 elfinject 注入 ELF 段
为了更具体地了解PT_NOTE注入技术,让我们看看如何使用虚拟机中提供的elfinject工具。清单 7-11 展示了如何使用elfinject将代码段注入到二进制文件中。
清单 7-11: elfinject 使用方法
1 | ➊ $ ls hello.bin |
在本章关于虚拟机的代码文件夹中,你会看到一个名为hello.bin ➊的文件,其中包含了你将以原始二进制形式注入的新代码(没有任何 ELF 头)。正如你稍后将看到的,这段代码会打印一个hello world!消息,然后将控制权转交给主机二进制文件的原始入口点,继续正常执行二进制文件。如果你有兴趣,你可以在名为hello.s的文件中找到注入代码的汇编指令,或者在第 7.4 节中找到。
现在让我们来看一下elfinject的用法➋。如你所见,elfinject需要五个参数:一个主机二进制文件的路径,一个注入文件的路径,注入部分的名称和地址,以及注入代码的入口点偏移(如果没有入口点,则为−1)。注入文件hello.bin被注入到主机二进制文件中,使用给定的名称、地址和入口点。
我在这个示例中使用了/bin/ls的副本作为主机二进制文件➌。如你所见,ls在注入前正常工作,打印当前目录的文件列表➍。你可以使用readelf看到该二进制文件包含一个.note.ABI-tag部分➎和一个PT_NOTE段➏,这些将在注入时被覆盖。
现在,是时候注入一些代码了。在这个示例中,我使用elfinject将hello.bin文件注入到ls二进制文件中,使用.injected作为注入部分的名称,0x800000作为加载地址(elfinject会将其附加到二进制文件的末尾)➐。我使用0作为入口点,因为hello.bin的入口点就在其开头。
在elfinject成功完成后,readelf显示ls二进制文件现在包含一个名为.injected的代码部分➑,以及一个类型为PT_LOAD的新的可执行段➒,该段包含了这个代码部分。此外,.note.ABI-tag部分和PT_NOTE段已经消失,因为它们被覆盖了。看起来注入成功了!
现在,让我们检查一下注入的代码是否按预期运行。执行修改后的ls二进制文件➓,你可以看到该二进制文件现在在启动时运行注入的代码,打印出hello world!消息。然后,注入的代码将执行权交给二进制文件的原始入口点,以便恢复正常的行为,即打印目录列表。
7.4 调用注入的代码
在上一节中,你学习了如何使用elfinject将一个新的代码部分注入到现有的二进制文件中。为了让新的代码执行,你修改了 ELF 入口点,使得加载器将控制权交给二进制文件时,新的代码能够立即运行。但有时,你可能并不希望在二进制文件启动时立即使用注入的代码。有时,你希望出于不同的原因使用注入的代码,比如替换现有函数。
在本节中,我将讨论一些将控制权转交给注入代码的替代技术,而不仅仅是修改 ELF 入口点。我还将回顾一下 ELF 入口点修改技术,这次仅使用十六进制编辑器来更改入口点。这将使你能够将入口点重定向到不仅是通过elfinject注入的代码,还包括通过其他方式插入的代码,例如通过覆盖死代码(如填充指令)。请注意,本节讨论的所有技术都适用于任何代码注入方法,而不仅仅是PT_NOTE覆盖。
7.4.1 入口点修改
首先,让我们简要回顾一下 ELF 入口点修改技术。在下面的示例中,我将通过elfinject转移控制权到注入的代码部分,但我不会使用elfinject更新入口点本身,而是使用十六进制编辑器。这将向你展示如何将此技术泛化到各种方式注入的代码。
清单 7-12 展示了我将注入的代码的汇编指令。它是上一节中使用的“hello world”示例。
清单 7-12: hello.s
1 | ➊ 64 |
该代码采用 Intel 语法,旨在使用nasm汇编器在 64 位模式下进行汇编 ➊。前几条汇编指令通过将rax、rcx、rdx、rsi和rdi寄存器推入栈中来保存它们 ➋。这些寄存器可能会被内核覆盖,你会希望在注入代码完成后恢复它们的原始值,以免干扰其他代码。
接下来的指令为sys_write系统调用设置参数 ➌,该调用将把hello world!打印到屏幕上。(你可以在syscall man页面找到所有标准 Linux 系统调用号和参数的更多信息。)对于sys_write,系统调用号(放在rax中)是 1,且有三个参数:要写入的文件描述符(stdout为 1)、指向要打印的字符串的指针和字符串的长度。现在,所有参数都已准备好,syscall指令 ➍执行实际的系统调用,打印字符串。
在调用sys_write系统调用后,代码恢复寄存器到先前保存的状态 ➎。然后,它将原始入口点的地址0x4049a0(我通过readelf找到的,如你将看到的那样)推送到栈上,并返回到该地址,开始执行原始程序 ➏。
“hello world”字符串 ➐ 在汇编指令后声明,并附带一个包含字符串长度的整数 ➑,它们都用于sys_write系统调用。
为了使代码适合注入,你需要将它汇编成一个原始二进制文件,该文件只包含汇编指令和数据的二进制编码。这是因为你不想创建一个包含头部和其他不需要的开销的完整 ELF 二进制文件。要将hello.s汇编成原始二进制文件,可以使用nasm汇编器的-f bin选项,如清单 7-13 所示。本章的Makefile包含一个hello.bin目标,自动运行此命令。
清单 7-13:使用 nasm 将hello.s汇编成hello.bin*
1 | $ nasm -f bin -o hello.bin hello.s |
这会创建文件hello.bin,其中包含适合注入的原始二进制指令和数据。现在,让我们使用elfinject注入此文件,并使用十六进制编辑器重定向 ELF 入口点,使得注入的代码在二进制启动时运行。清单 7-14 展示了如何操作。
清单 7-14:通过覆盖 ELF 入口点调用注入的代码
1 | ➊ $ cp /bin/ls ls.entry |
首先,将/bin/ls二进制文件复制到ls.entry中 ➊。这将作为注入程序的宿主二进制文件。然后,你可以使用elfinject将刚刚准备好的代码注入二进制文件,加载地址为0x800000 ➋,正如在第 7.3.2 节中讨论的那样,唯一的关键区别是:将最后一个elfinject参数设置为-1,这样elfinject就不会修改入口点(因为你将手动覆盖它)。
使用readelf,你可以看到二进制文件的原始入口点:0x4049a0 ➌。注意,这是注入的代码在打印hello world信息后跳转到的地址,如清单 7-12 所示。你还可以使用readelf看到注入的部分实际上是从地址0x800e78 ➍开始的,而不是地址0x800000。这是因为elfinject略微更改了地址,以满足 ELF 格式的对齐要求,正如我在附录 B 中更详细地讨论的那样。这里需要注意的是,0x800e78是你要用来覆盖入口点地址的新地址。
因为入口点仍然未被修改,如果现在运行ls.entry,它的行为就像正常的ls命令,只是没有添加开头的“hello world”信息 ➎。要修改入口点,打开ls.entry二进制文件,使用hexedit ➏并搜索原始入口点地址。记住,你可以在hexedit中使用/键打开搜索对话框,然后输入要搜索的地址。地址是以小端格式存储的,因此你需要搜索字节a04940而不是4049a0。找到入口点后,用新的入口点地址覆盖它,同样需要反转字节顺序:780e80。然后,按 CTRL-X 退出,并按 Y 保存更改。
现在,你可以使用readelf看到入口点已更新为0x800e78 ➐,指向注入代码的起始位置。现在,当你运行ls.entry时,它会在显示目录列表之前先打印hello world ➑。你已经成功地覆盖了入口点!
7.4.2 劫持构造函数和析构函数
现在,让我们看一下另一种确保注入的代码在二进制程序生命周期内运行一次的方法,无论是在执行开始时还是结束时。回顾第二章,使用gcc编译的 x86 ELF 二进制文件包含名为.init_array和.fini_array的部分,它们分别包含构造函数和析构函数的指针。通过覆盖其中一个指针,你可以使注入的代码在二进制文件的main函数之前或之后被调用,具体取决于你是覆盖构造函数指针还是析构函数指针。
当然,在注入的代码完成后,你需要将控制权转回你劫持的构造函数或析构函数。这需要对注入的代码进行一些小的修改,如清单 7-15 所示。在这个清单中,我假设你将控制权传回一个特定的构造函数,其地址可以通过objdump查找。
清单 7-15: hello-ctor.s
1 | 64 |
清单 7-15 中显示的代码与清单 7-12 中的代码相同,唯一不同的是我插入了劫持的构造函数的地址,以便返回到➊,而不是入口点地址。将代码组装成原始二进制文件的命令与上一节中讨论的相同。清单 7-16 展示了如何将代码注入到二进制文件并劫持构造函数。
清单 7-16: 通过劫持构造函数调用注入的代码
1 | ➊ $ cp /bin/ls ls.ctor |
如之前一样,你首先复制/bin/ls ➊,并将新代码注入到副本中 ➋,而不更改入口点。使用readelf可以看到.init_array段的存在 ➌。^(3) .fini_array段也存在,但在这个例子中,我劫持的是构造函数,而不是析构函数。
你可以使用objdump查看.init_array的内容,里面显示了一个构造函数指针,值为0x404a70(以小端格式存储)➍。现在,你可以使用hexedit查找这个地址并将其更改为注入代码的入口地址0x800e78➎。
完成后,.init_array中的唯一指针将指向注入的代码,而不是原始构造函数 ➏。请记住,完成此操作后,注入的代码会将控制权返回到原始构造函数。覆盖构造函数指针后,更新后的ls二进制文件首先会显示“hello world”消息,然后像正常一样打印目录列表 ➐。通过这种技术,你可以在不修改入口点的情况下,让代码在二进制文件的启动或终止时运行一次。
7.4.3 劫持 GOT 条目
到目前为止讨论的两种技术——入口点修改和构造函数/析构函数劫持——都仅允许注入的代码在二进制文件启动时或终止时运行一次。那么,如果你想多次调用注入的函数,比如替换现有的库函数,该怎么办呢?接下来,我将展示如何劫持一个 GOT 条目,将库调用替换为注入的函数。回顾第二章,全局偏移表(GOT)是一个包含指向共享库函数的指针的表,用于动态链接。覆盖这些条目中的一个或多个,基本上可以让你获得与LD_PRELOAD技术相同的控制级别,但不需要包含新函数的外部库,从而使得二进制文件保持自包含。此外,GOT 劫持不仅适用于持久的二进制修改,而且在运行时利用二进制文件也非常合适。
GOT 劫持技术需要对注入代码进行轻微修改,如列表 7-17 所示。
列表 7-17: hello-got.s
1 | 64 |
通过 GOT 劫持,你完全替换了一个库函数,因此注入代码完成后无需将控制权转回原始实现。因此,列表 7-17 中没有包含任何硬编码的地址来转移控制。相反,它只是以正常的返回结束 ➊。
让我们来看一下如何在实践中实现 GOT 劫持技术。列表 7-18 展示了一个示例,该示例将ls二进制文件中fwrite_unlocked库函数的 GOT 条目替换为指向“hello world”函数的指针,如列表 7-17 所示。fwrite_unlocked是ls用来将所有消息打印到屏幕上的函数。
列表 7-18: 通过劫持 GOT 条目调用注入代码
1 | ➊ $ cp /bin/ls ls.got |
在创建ls的全新副本 ➊ 并将代码注入其中 ➋ 后,你可以使用objdump查看二进制文件的 PLT 条目(GOT 条目在此处使用),并找到fwrite_unlocked的条目 ➌。它从地址0x402800开始,使用的 GOT 条目位于地址0x61e2a0 ➍,该地址在.got.plt段中。
使用objdump查看.got.plt段,你可以看到存储在 GOT 条目中的原始地址 ➎:402806(以小端格式编码)。
如第二章所述,这是fwrite_unlocked在 PLT 条目中下一条指令的地址,你想用注入代码的地址来覆盖它。因此,下一步是启动hexedit,搜索字符串062840,并将其替换为注入代码地址0x800e78 ➏,像往常一样确认更改。你可以通过再次使用objdump查看修改后的 GOT 条目 ➐。
在将 GOT 条目修改为指向你的hello world函数后,每次ls程序调用fwrite_unlocked时,都会打印hello world➑,并将所有常规的ls输出替换为hello world字符串的副本。当然,实际情况下,你可能希望将fwrite_unlocked替换为一个更有用的函数。
GOT 劫持的一个好处是,它不仅简单直观,而且可以在运行时轻松完成。这是因为与代码段不同,.got.plt在运行时是可写的。因此,GOT 劫持不仅是静态二进制修改中的一种流行技术,如我在这里演示的,还广泛应用于旨在改变正在运行的进程行为的漏洞利用中。
7.4.4 劫持 PLT 条目
下一种调用注入代码的技术是 PLT 劫持,它与 GOT 劫持类似。与 GOT 劫持一样,PLT 劫持允许你插入一个替代已有库函数的代码。唯一的区别在于,你不是修改 PLT 存根使用的 GOT 条目中的函数地址,而是直接修改 PLT 存根本身。由于该技术涉及修改 PLT(它是一个代码段),因此不适用于在运行时修改二进制的行为。Listing 7-19 展示了如何使用 PLT 劫持技术。
Listing 7-19:通过劫持 PLT 条目调用注入代码
1 | ➊ $ cp /bin/ls ls.plt |
如之前所述,首先创建ls二进制文件的副本➊,并将新代码注入其中➋。请注意,此示例使用与 GOT 劫持技术相同的代码载荷。如同 GOT 劫持示例一样,你将用“hello world”函数替换fwrite_unlocked库调用。
使用objdump查看fwrite_unlocked的 PLT 条目➌。但这次,你不是关注 PLT 存根使用的 GOT 条目的地址,而是查看 PLT 存根第一条指令的二进制编码。如objdump所示,编码为ff259aba2100 ➍,对应一个相对于rip寄存器的间接jmp指令。你可以通过用另一个指令覆盖此指令,从而直接跳转到注入的代码,来劫持 PLT 条目。
接下来,使用hexedit,搜索与 PLT 存根第一条指令ff259aba2100对应的字节序列➎。一旦找到它,将其替换为e973e63f00,该编码表示一个直接跳转(jmp)到地址0x800e78,即注入代码所在的位置。替换字符串的第一个字节e9是直接jmp的操作码,接下来的 4 个字节是相对于jmp指令本身的偏移量,指向注入的代码。
完成修改后,再次使用objdump反汇编 PLT,验证修改结果➏。正如你所看到的,fwrite_unlocked的 PLT 条目的第一条反汇编指令现在是jmp 800e78:直接跳转到注入的代码。之后,反汇编器显示一些伪指令,它们是原始 PLT 条目中没有被覆盖的剩余字节产生的伪指令。由于第一条指令是唯一会被执行的指令,因此这些伪指令并不成问题。
现在,让我们来看一下修改是否生效。当你运行修改后的ls二进制文件时,你会看到每次调用fwrite_unlocked函数时都会打印“hello world”消息➐,正如预期的那样,产生与 GOT 劫持技术相同的结果。
7.4.5 重定向直接和间接调用
到目前为止,你已经学会了如何在二进制文件的开始或结束时,或者在调用库函数时运行注入的代码。但当你想使用注入的函数替换非库函数时,劫持 GOT 或 PLT 条目就不起作用。在这种情况下,你可以使用反汇编工具定位你想修改的调用,然后覆盖它们,使用十六进制编辑器将其替换为调用注入函数,而不是原始函数。十六进制编辑过程与修改 PLT 条目相同,因此我不会在这里重复步骤。
当重定向间接调用(与直接调用相对)时,最简单的方法是将间接调用替换为直接调用。然而,这并不总是可行的,因为直接调用的编码可能比间接调用的编码长。在这种情况下,你首先需要找到你想替换的间接调用函数的地址,例如,通过使用gdb在间接调用指令上设置断点并检查目标地址。
一旦你知道了要替换的函数的地址,你可以使用objdump或十六进制编辑器在二进制文件的.rodata段中搜索该地址。如果运气好的话,这可能会显示包含目标地址的函数指针。然后你可以使用十六进制编辑器覆盖这个函数指针,将其设置为注入代码的地址。如果运气不好,函数指针可能会在运行时以某种方式计算出来,这需要更复杂的十六进制编辑来将计算出的目标替换为注入函数的地址。
7.5 小结
在本章中,你学习了如何使用几种简单的技术修改 ELF 二进制文件:十六进制编辑、LD_PRELOAD和 ELF 段注入。由于这些技术的灵活性较差,它们仅适用于对二进制文件进行小规模修改。本章应该让你意识到,实际上有需求需要更通用、更强大的二进制修改技术。幸运的是,这些技术确实存在,我将在第九章中讨论它们!
练习
- 更改日期格式
创建一份 /bin/date 程序的副本,并使用 hexedit 更改默认日期格式字符串。你可能需要使用 strings 查找默认的格式字符串。
- 限制 ls 的作用范围
使用 LD_PRELOAD 技术修改一份 /bin/ls 的副本,使其仅显示你主目录路径下的目录列表。
- 一个 ELF 寄生虫
编写你自己的 ELF 寄生虫,并使用 elfinject 将其注入到你选择的程序中。看看你能否让寄生虫分叉出一个子进程并打开后门。如果你能创建一个修改版的 ps,使其不在进程列表中显示寄生虫进程,则可以获得额外积分。
附录:A
X86 汇编速成课程
因为汇编语言是你在二进制文件中找到的机器指令的标准表示方式,许多二进制分析都是基于反汇编的。因此,熟悉 x86 汇编语言的基础知识对最大化地利用本书非常重要。本附录将介绍你需要了解的基础知识,以便跟上内容。
本附录的目的不是教你如何编写汇编程序(有专门的书籍讲解这个主题),而是展示你理解反汇编程序所需了解的基本内容。你将了解汇编程序和 x86 指令的结构以及它们在运行时的行为。此外,你还将看到 C/C++程序中常见的代码结构如何在汇编级别表现。我只会涵盖基本的 64 位用户模式 x86 指令,不包括浮点指令或扩展指令集,如 SSE 或 MMX。为了简洁起见,我将把 x86 的 64 位变种(x86-64 或 x64)简称为 x86,因为这是本书的重点。
A.1 汇编程序的布局
清单 A-1 显示了一个简单的 C 程序,而 清单 A-2 显示了由gcc 5.4.0 生成的相应汇编程序。(第一章 解释了编译器如何将 C 程序转换为汇编列表,并最终转化为二进制文件。)
当你反汇编一个二进制文件时,反汇编器本质上会尝试将其翻译回一个准确的汇编列表,尽可能接近编译器生成的汇编代码。现在,让我们先看看汇编程序的布局,暂时不深入讨论汇编指令。
清单 A-1:C 语言中的“Hello, world!”
1 |
|
清单 A-2:由 gcc 生成的汇编
1 | "hello.c" |
清单 A-1 由一个 main 函数 ➊ 组成,该函数调用 printf ➋ 打印常量 "Hello, world!" 字符串 ➌。从高层次来看,相应的汇编程序包含四种类型的组件:指令、指令、标签和注释。
A.1.1 汇编指令、指令、标签和注释
表 A-1 显示了每种组件类型的示例。请注意,每种组件的确切语法因汇编器或反汇编器而异。对于本书而言,你无需对任何汇编器的语法特性非常熟悉;你只需要学会阅读和分析反汇编代码,而不是编写自己的汇编代码。在这里,我将使用由gcc和-masm=intel选项生成的汇编语法。
表 A-1: 汇编程序的组成部分
| 类型 | 示例 | 含义 |
|---|---|---|
| 指令 | mov eax, 0 |
将零存入 eax |
| 指令 | .section .text |
将以下内容放入 .text 区段 |
| 指令 | .string "foobar" |
定义一个包含 "foobar" 的 ASCII 字符串 |
| 指令 | .long 0x12345678 |
定义一个值为0x12345678的双字 |
| 标签 | foo: .string "foobar" |
定义一个符号名为foo的"foobar"字符串 |
| 注释 | # this is a comment |
一条可读的注释 |
指令是 CPU 执行的实际操作。指令是告诉汇编器生成特定数据、将指令或数据放入特定区域等命令。最后,标签是可以用来引用汇编程序中指令或数据的符号名称,注释是供文档使用的可读字符串。在程序汇编并链接成二进制文件后,所有符号名称都会被地址替代。
示例 A-2 中的汇编程序指示汇编器将"Hello, world!"字符串放入.rodata段 ➍➎,该段专门用于存储常量数据。指令.section告诉汇编器将以下内容放入哪个段,而.string是一个指令,用于定义 ASCII 字符串。还有一些用于定义其他类型数据的指令,例如.byte(定义一个字节),.word(一个 2 字节字),.long(一个 4 字节双字),以及.quad(一个 8 字节四字)。
main函数被放置在.text段 ➏➐,该段专门用于存储代码。.text指令是.section .text的简写,而main:为main函数引入了一个符号标签。
标签后面跟着的是main包含的实际指令。这些指令可以通过符号引用之前声明的数据,例如.LC0 ➑(gcc为"Hello, world!"字符串选择的符号名称)。因为程序打印一个常量字符串(没有可变参数),gcc将printf调用替换为puts ➒调用,这是一个更简单的函数,用来将指定的字符串输出到屏幕上。
A.1.2 代码与数据的分离
在示例 A-2 中,你可以观察到一个关键点,即编译器通常将代码和数据分开到不同的段中。这在你反汇编或分析二进制文件时很方便,因为你知道程序中的哪些字节是代码,哪些是数据。然而,x86 架构本身并没有限制你将代码和数据混合在同一段中,实际上,有些编译器或手写汇编程序就是这样做的。
A.1.3 AT&T 与 Intel 语法
如前所述,不同的汇编器使用不同的语法来表示汇编程序。除此之外,x86 机器指令有两种不同的语法格式:Intel 语法和AT&T 语法。
AT&T 语法在每个寄存器名称前面显式添加 % 符号,在每个常量前面添加 $ 符号,而 Intel 语法则省略这些符号。在本书中,我使用 Intel 语法,因为它较为简洁。AT&T 和 Intel 语法的最重要区别在于它们的操作数顺序完全相反。在 AT&T 语法中,源操作数在目标操作数之前,因此将常量移动到 edi 寄存器的写法如下:
1 | mov $0x6,%edi |
相比之下,Intel 语法将相同的指令表示如下,目标操作数在前:
1 | mov edi,0x6 |
牢记操作数的顺序非常重要,因为在深入进行二进制分析时,你可能会遇到两种语法风格。
A.2 x86 指令的结构
现在你对汇编程序的结构有了一定的了解,让我们来看看汇编指令的格式。你还将看到汇编所表示的机器级指令的结构。
A.2.1 x86 指令的汇编级表示
在汇编级别,x86 指令通常采用 助记符 目标操作数, 源操作数 的形式。助记符是机器指令的可读表示,源操作数和目标操作数是指令的操作数。例如,汇编指令 mov rbx, rax 将 rax 寄存器中的值复制到 rbx 中。请注意,并非所有指令都有恰好两个操作数;有些指令甚至没有操作数,如你接下来将看到的那样。
如前所述,助记符是 CPU 理解的机器指令的高级表示。让我们简要了解一下 x86 指令在机器级别的结构。这在某些二进制分析场景中非常有用,比如当你修改现有的二进制文件时。
A.2.2 x86 指令的机器级结构
x86 ISA 使用可变长度的指令;有些 x86 指令只有 1 个字节,但也有多字节指令,最长可达 15 字节。此外,指令可以从任何内存地址开始。这意味着 CPU 不强制要求特定的代码对齐,尽管编译器通常会对代码进行对齐,以优化从内存中获取指令的性能。图 A-1 显示了 x86 指令的机器级结构。

图 A-1:x86 指令的结构
一条 x86 指令由可选的前缀、一个操作码和零个或多个操作数组成。请注意,除了操作码外,其他部分都是可选的。
操作码是指令类型的主要标识符。例如,操作码 0x90 编码的是 nop 指令,它什么都不做,而操作码 0x00–0x05 编码的是各种类型的 add 指令。前缀可以修改指令的行为,例如,导致指令重复执行多次或访问不同的内存段。最后,操作数是指令所操作的数据。
寻址模式字节,也称为MOD-R/M或MOD-REGR/M字节,包含关于指令操作数类型的元数据。SIB(比例/索引/基址)字节和位移用于编码内存操作数,立即数字段可以包含立即数操作数(常量数值)。稍后你将更详细地了解这些字段的含义。
除了图 A-1 中显示的显式操作数外,一些指令还具有隐式操作数。这些操作数并没有在指令中明确编码,但它们是操作码固有的。例如,操作码0x05(add指令)的目标操作数总是rax,只有源操作数是可变的,需要明确编码。另一个例子是,push指令隐式地更新rsp(栈指针寄存器)。
在 x86 中,指令可以有三种不同类型的操作数:寄存器操作数、内存操作数和立即数。我们来看一下每种有效的操作数类型。
A.2.3 寄存器操作数
寄存器是位于 CPU 本身的小型、快速访问的存储单元。有些寄存器具有特殊功能,例如跟踪当前执行地址的指令指针,或跟踪栈顶的栈指针。其他寄存器则是用于存储 CPU 执行的程序中变量的通用存储单元。
通用寄存器
在 x86 架构所基于的原始 8086 指令集上,寄存器是 16 位宽的。32 位的 x86 指令集扩展了这些寄存器至 32 位,x86-64 进一步扩展至 64 位。为了保持向后兼容性,较新指令集中的寄存器是较旧寄存器的超集。
要在汇编中指定一个寄存器操作数,你需要使用寄存器的名称。例如,mov rax,64 将值 64 移动到rax寄存器中。图 A-2 展示了 64 位的rax寄存器如何细分成传统的 32 位和 16 位寄存器。rax的低 32 位组成一个名为eax的寄存器,而其低 16 位则组成原始的 8086 寄存器ax。你可以通过寄存器名al访问ax的低字节,通过ah访问高字节。

图 A-2:x86-64 rax 寄存器的细分
其他寄存器有类似的命名规则。表 A-2 展示了 x86-64 上可用的通用寄存器名称,以及可用的传统“子寄存器”。r8–r15寄存器是 x86-64 中新增的,在早期的 x86 变种中不可用。请注意,如果你设置了一个 32 位的子寄存器,如eax,这会自动将父寄存器(在这种情况下是rax)中的其他位清零;而设置较小的子寄存器,如ax、al和ah,则保留其他位。
表 A-2: x86 通用寄存器
| 描述 | 64 位 | 低 32 位 | 低 16 位 | 低字节 | 第二字节 |
|---|---|---|---|---|---|
| 累加器 | rax |
eax |
ax |
al |
ah |
| 基址 | rbx |
ebx |
bx |
bl |
bh |
| 计数器 | rcx |
ecx |
cx |
cl |
ch |
| 数据 | rdx |
edx |
dx |
dl |
dh |
| 堆栈 | pointer |
rsp |
esp |
sp |
spl |
| 基址 | pointer |
rbp |
ebp |
bp |
bpl |
| 源索引 | rsi |
esi |
si |
sil |
|
| 目标索引 | rdi |
edi |
di |
dil |
|
| x86-64 通用寄存器 | r8–r15 |
r8d–r15d |
r8w–r15w |
r8l–r15l |
不要过分关注大多数寄存器的描述列。这些描述源自 8086 指令集,但如今,大多数在表 A-2 中显示的寄存器是可以互换使用的。正如你在第 A.4.1 节中看到的那样,栈指针(rsp)和基指针(rbp)被认为是特殊的,因为它们用于跟踪栈的布局,尽管原则上你可以将它们用作通用寄存器。
其他寄存器
除了表 A-2 中显示的寄存器,x86 CPU 还包含一些非通用寄存器。最重要的两个是 rip(在 32 位 x86 上称为 eip,在 8086 上称为 ip)和 rflags(在较旧的指令集架构中称为 eflags 或 flags)。指令指针总是指向下一条指令的地址,并由 CPU 自动设置;你不能手动写入它。在 x86-64 上,你可以读取指令指针的值,但在 32 位 x86 上,甚至连这一点都做不到。状态标志寄存器用于比较和条件跳转,跟踪诸如上次操作是否结果为零、是否溢出等信息。
x86 指令集架构还有段寄存器,如 cs、ds、ss、es、fs 和 gs,你可以使用它们将内存分割成不同的段。段式管理大多已经不再使用,x86-64 也大部分放弃了对其的支持,所以我在这里不会详细介绍段式管理。如果你有兴趣了解更多,可以参考一本专门讲解 x86 汇编的书籍。
还有一些控制寄存器,如 cr0–cr10,内核用它们来控制 CPU 的行为,例如切换保护模式和实模式。此外,寄存器 dr0–dr7 是调试寄存器,提供硬件支持调试功能,如断点。在 x86 上,控制和调试寄存器无法从用户模式访问;只有内核可以访问它们。因此,我在本附录中不会进一步讲解这些寄存器。
还有各种*特定模型寄存器(MSRs)*和在扩展指令集(如 SSE 和 MMX)中使用的寄存器,这些寄存器并非所有 x86 CPU 都有。你可以使用cpuid指令来查找 CPU 支持哪些特性,并使用rdmsr和wrmsr指令来读取或写入特定模型寄存器。由于许多这些特殊寄存器仅在内核中可用,因此你在本书中不需要处理它们。
A.2.4 内存操作数
内存操作数指定 CPU 应从中获取一个或多个字节的内存地址。x86 ISA 每条指令仅支持一个显式内存操作数。也就是说,你不能在一条指令中直接将字节从一个内存位置复制到另一个位置。要做到这一点,你必须使用寄存器作为中介存储。
在 x86 中,你通过[基址 + 索引*比例 + 位移]来指定内存操作数,其中基址和索引是 64 位寄存器,比例是一个整数,值为 1、2、4 或 8,位移是 32 位常数或符号。所有这些组件都是可选的。CPU 计算内存操作数表达式的结果,得到最终的内存地址。基址、索引和比例被编码在指令的 SIB 字节中,而位移则被编码在同名字段中。比例默认值为 1,位移默认值为 0。
这种内存操作数格式足够灵活,可以以简单直接的方式支持许多常见的代码范式。例如,你可以使用类似mov eax, DWORD PTR [rax*4 + arr]的指令来访问数组元素,其中arr是包含数组起始地址的位移量,rax包含你要访问的元素的索引,每个数组元素占 4 个字节。这里,DWORD PTR告诉汇编器你想从内存中获取 4 个字节(一个双字或 DWORD)。类似地,访问struct中字段的一种方式是将struct的起始地址存储在基址寄存器中,并添加你想访问字段的位移量。
在 x86-64 上,你可以使用rip(指令指针)作为内存操作数中的基址,尽管在这种情况下你不能使用索引寄存器。编译器常常利用这一点来实现位置无关代码和数据访问等功能,因此你会在 x86-64 二进制文件中看到大量rip相对寻址。
A.2.5 立即数
立即数是指令中硬编码的常数整数操作数。例如,在指令add rax, 42中,值 42 就是一个立即数。
在 x86 中,立即数以小端格式编码;多字节整数的最低有效字节首先出现在内存中。换句话说,如果你编写类似mov ecx, 0x10203040的汇编指令,相应的机器级指令会以字节反转的形式编码立即数,变成0x40302010。
为了编码有符号整数,x86 使用二进制补码表示法,这种方法通过获取该值的正值,然后翻转所有位并加 1,同时忽略溢出,来表示负数。例如,要编码值为 −1 的 4 字节整数,首先取整数0x00000001(十六进制表示 1),翻转所有位得到0xfffffffe,然后加 1 得到最终的二进制补码表示0xffffffff。当你在反汇编代码时看到一个立即数或内存值以大量0xff字节开头时,通常说明它是一个负值。
现在你已经了解了 x86 指令的基本格式和工作原理,接下来让我们看看一些常见指令的语义,这些指令你将在本书以及自己的二进制分析项目中遇到。
A.3 常见的 x86 指令
表 A-3 描述了常见的 x86 指令。要了解表中未列出的指令,可以在在线参考资料中查找,例如*ref.x86asm.net/,或在 Intel 手册中查找software.intel.com/en-us/articles/intel-sdm/*。表中列出的指令大部分是自解释的,但其中有一些需要更详细的讨论。
表 A-3: 常见的 x86 指令
| 指令 | 描述 |
|---|---|
| 数据传输 | |
➊ mov dst, src |
dst = src |
xchg dst1, dst2 |
交换dst1和dst2 |
➋ push src |
将src压入堆栈并递减rsp |
pop dst |
从堆栈中弹出值到dst并递增rsp |
| 算术操作 | |
add dst, src |
dst += src |
sub dst, src |
dst -= src |
inc dst |
dst += 1 |
dec dst |
dst -= 1 |
neg dst |
dst = –dst |
➌ cmp src1, src2 |
根据src1 – src2设置状态标志 |
| 逻辑/按位操作 | |
and dst, src |
dst &= src |
or dst, src |
*dst |
xor dst, src |
dst ^= src |
not dst |
dst = ~dst |
➍ test src1, src2 |
根据src1 & src2设置状态标志 |
| 无条件跳转 | |
jmp addr |
跳转到地址 |
call addr |
将返回地址压入堆栈,然后调用位于地址的函数 |
ret |
从堆栈中弹出返回地址并返回到该地址 |
➎ syscall |
进入内核执行系统调用 |
| 条件跳转(基于状态标志) jcc addr 仅在条件cc成立时跳转到地址,否则继续执行 |
jncc 反转条件,如果条件不成立则跳转 |
➏ je addr/jz addr |
如果零标志被设置则跳转(例如,操作数在上次cmp中相等) |
|---|---|
ja addr |
如果dst > src(“大于”)在上次比较中(无符号)则跳转 |
jb addr |
如果dst < src(“小于”)在上次比较中(无符号)则跳转 |
jg addr |
如果dst > src(“大于”)在上次比较中(有符号)则跳转 |
jl addr |
如果上次比较结果为 dst < src(“小于”)则跳转(有符号) |
jge addr |
如果上次比较结果为 dst >= src(有符号)则跳转 |
jle addr |
如果上次比较结果为 dst <= src(有符号)则跳转 |
js addr |
如果上次比较设置了符号位(表示结果为负)则跳转 |
| 其他杂项 | |
➐ lea dst, src |
将内存地址加载到 dst 中(dst = &src,其中 src 必须在内存中)nop 不执行任何操作(例如用于代码填充) |
首先,值得注意的是,mov ➊ 有些名不副实,因为它并不真正 移动 源操作数到目标位置。实际上,它是复制源操作数,源操作数保持不变。push 和 pop 指令 ➋ 在堆栈管理和函数调用中具有特殊意义,稍后你将会看到。
A.3.1 比较操作数并设置状态标志
cmp 指令 ➌ 在实现条件跳转时非常重要。它将第二个操作数从第一个操作数中减去,但并不会将操作结果存储到某个地方,而是根据结果在 rflags 寄存器中设置状态标志。随后的条件跳转会检查这些状态标志,以决定是否进行跳转。重要的标志包括 零标志(ZF)、符号标志(SF) 和 溢出标志(OF),分别表示比较结果为零、负数或溢出。
test 指令 ➍ 与 cmp 类似,但它通过操作数的按位与(bitwise AND)来设置状态标志,而不是通过减法操作。值得注意的是,除了 cmp 和 test 之外,还有一些其他指令也会设置状态标志。Intel 手册或在线指令参考文档会显示每条指令设置的具体标志。
A.3.2 实现系统调用
要执行系统调用,你需要使用 syscall 指令 ➎。在使用之前,你必须按照操作系统的要求准备好系统调用,选择系统调用编号并设置操作数。例如,要在 Linux 上执行 read 系统调用,你需要将值 0(read 的系统调用编号)加载到 rax 中;然后将文件描述符、缓冲区地址和要读取的字节数分别加载到 rdi、rsi 和 rdx 中;最后执行 syscall 指令。
要了解如何在 Linux 上配置系统调用,请参考 man syscalls 或像 filippo.io/linux-syscall-table/ 这样的在线参考资料。请注意,在 32 位 x86 系统上,你使用 sysenter 或 int 0x80 来进行系统调用(这会触发中断向量 0x80 的软件中断),而不是使用 syscall。此外,不同操作系统的系统调用约定可能有所不同,Linux 以外的操作系统也可能有所不同。
A.3.3 实现条件跳转
条件跳转指令 ➏ 通过与先前设置状态标志的指令(如cmp或test)配合工作来实现分支。如果给定的条件成立,它们会跳转到指定的地址或标签;如果条件不成立,则会跳转到下一条指令。例如,若要在rax < rbx(使用无符号比较)的情况下跳转到名为label的程序位置,你通常会使用如下的指令序列:
1 | cmp rax, rbx |
同样,如果rax不为零,要跳转到label,可以使用以下代码:
1 | test rax, rax |
A.3.4 加载内存地址
最后,lea指令 ➐ (加载有效地址) 计算内存操作数(格式为[base + index*scale + displacement])产生的地址,并将其存储在一个寄存器中,但不会解引用该地址。这等同于 C/C++中的地址运算符(&)。例如,lea r12, [rip+0x2000]将表达式rip+0x2000产生的地址加载到r12寄存器中。
既然你已经熟悉了最重要的 x86 指令,让我们来看一下这些指令如何结合在一起,实现常见的 C/C**++**代码结构。
A.4 汇编中的常见代码结构
像gcc、clang和 Visual Studio 等编译器,会生成一些常见的代码模式,用于实现像函数调用、if/else分支和循环这样的结构。你也会在手写的汇编代码中看到这些相同的代码模式。熟悉这些代码结构非常有帮助,这样你可以快速理解一段汇编或反汇编代码在做什么。让我们来看一下gcc 5.4.0生成的代码模式。其他编译器使用类似的模式。
你首先看到的代码结构是函数调用。但在你理解函数调用是如何在汇编层面实现之前,你需要了解栈在 x86 上的工作原理。
A.4.1 栈
栈是一个保留的内存区域,用于存储与函数调用相关的数据,如返回地址、函数参数和局部变量。在大多数操作系统中,每个线程都有自己的栈。
栈之所以得名,是因为它的访问方式。你不是在栈的任意位置写入值,而是以后进先出 (LIFO) 的顺序进行操作。也就是说,你可以通过压栈将值写入栈顶,并通过弹栈从栈顶移除值。这与函数调用非常吻合,因为它与函数的调用和返回方式一致:你最后调用的函数首先返回。图 A-3 展示了栈的访问模式。
在图 A-3 中,栈从地址0x7fffffff8000^(1)开始,最初包含五个值:a–e。栈的其余部分包含未初始化的内存(标记为“?”)。在 x86 架构下,栈是向低地址方向增长的,这意味着新压入的值会位于比旧值更低的地址。栈指针寄存器(rsp)始终指向栈顶,也就是最近压入的值的位置。最初,这个位置是位于地址0x7fffffff7fe0的 e。

图 A-3:将值 f 压入栈中,然后弹入 rax*
现在,当你压入一个新值 f 时,它会位于栈顶,rsp 会被递减以指向该位置。x86 架构上有专门的push和pop指令,用来在栈上插入或移除一个值,并自动更新rsp。类似地,x86 的call指令会自动将返回地址压入栈中,而ret指令则会弹出返回地址并跳转到该地址。
当你执行pop指令时,它会将栈顶的值复制到pop操作数中,然后递增rsp以反映新的栈顶。例如,图 A-3 中的pop rax指令会把 f 从栈中复制到rax寄存器中,并更新rsp指向 e,即新的栈顶。在弹出任何值之前,你可以先将任意数量的值压入栈中。当然,这取决于为栈分配的可用内存。
请注意,从栈中弹出一个值并不会清除它;它仅仅是复制该值并更新rsp。在pop操作之后,f 技术上仍然存在于内存中,直到被后续的push操作覆盖。如果你将敏感信息放到栈上,必须意识到除非你显式地清理它,否则它可能在后续仍然可以访问到。
现在你了解了栈的工作原理,让我们来看一下函数调用如何利用栈来存储它们的参数、返回地址和局部变量。
A.4.2 函数调用和函数帧
列表 A-3 展示了一个简单的 C 程序,包含两个函数调用,为了简洁起见省略了错误检查代码。首先,它调用getenv来获取argv[1]中指定的环境变量的值。然后,它使用printf打印这个值。
列表 A-4 展示了相应的汇编代码,该代码通过使用gcc 5.4.0编译 C 程序并使用objdump反汇编得到。请注意,对于这个示例,我使用了gcc的默认选项进行编译,如果启用优化或使用其他编译器,输出可能会有所不同。
列表 A-3:C 语言中的函数调用
1 |
|
列表 A-4:汇编中的函数调用
1 | Contents of .rodata: |
编译器将printf调用中使用的字符串常量%s=%s与代码分开存储,存储在.rodata(只读数据)区➊,地址为0x400634。你将在代码的后续部分看到这个地址作为printf参数使用。
原则上,x86 Linux 程序中的每个函数都有自己的函数框架(也叫栈框架),它被rbp(基指针)指向该函数框架的基址,rsp指向栈顶。函数框架用于存储函数的栈数据。请注意,在某些优化下,编译器可能会省略基指针(使得所有栈访问相对于rsp进行),并将rbp作为一个额外的通用寄存器使用。然而,以下示例假设所有函数都使用完整的函数框架。
图 A-4 显示了当你运行清单 A-4 中展示的程序时,为main和getenv创建的函数框架。为了理解这一点,让我们一起查看汇编清单,看看它如何生成图中所示的函数框架。

图 A-4:Linux 系统上 x86 函数框架示例
如第二章所述,main并不是典型 Linux 程序中首先运行的函数。现在,你只需要知道的是,main是通过一个call指令被调用的,该指令将返回地址放在栈上,main完成时会返回到这个地址(如图 A-4 左上角所示)。
函数序言、局部变量和读取参数
main做的第一件事是执行一个序言,设置它的函数框架。这个序言首先将rbp寄存器的内容保存在栈上,然后将rsp的值复制到rbp中➋(参见清单 A-4)。这样做的效果是保存了上一个函数框架的起始地址,并在栈顶创建了一个新的函数框架。由于push rbp; mov rbp,rsp指令序列非常常见,x86 有一条叫做enter的简写指令(在清单 A-4 中没有使用),它实现了相同的功能。
在 x86-64 Linux 中,rbx寄存器和r12–r15寄存器保证不会被你调用的任何函数污染。这意味着,如果一个函数确实污染了这些寄存器,它必须在返回之前恢复它们的原始值。通常,函数通过将需要保存的寄存器压入栈中(紧接着保存的基指针),并在返回之前将它们弹出栈来实现这一点。在清单 A-4 中,main没有这样做,因为它没有使用这些寄存器。
在设置好基本的函数框架后,main 将 rsp 减少 0x10 字节,以在栈上为两个 8 字节的局部变量预留空间 ➌。尽管程序的 C 版本没有显式地为局部变量分配空间,gcc 会自动生成它们,用作 argc 和 argv 的临时存储。在 x86-64 Linux 系统上,传递给函数的前六个参数分别通过 rdi、rsi、rdx、rcx、r8 和 r9 寄存器传递。^(2) 如果有超过六个参数,或者某些参数无法放入 64 位寄存器,剩余的参数将以相反的顺序(与它们在参数列表中的顺序相反)被压入栈中,具体如下:
1 | mov rdi, param1 |
请注意,一些流行的 32 位 x86 调用约定(如 cdecl)将所有参数按相反顺序(不使用寄存器)压入栈中,而其他调用约定(如 fastcall)则将某些参数通过寄存器传递。
在栈上预留空间后,main 将 argc(存储在 rdi 中)复制到其中一个局部变量,将 argv(存储在 rsi 中)复制到另一个局部变量 ➍。Figure A-4 的左侧展示了 main 完成序言后栈的布局。
红区
你可能会注意到在 Figure A-4 中栈顶部的 128 字节“红区”。在 x86-64 上,函数可以将红区用作临时空间,并保证操作系统不会修改它(例如,如果信号处理程序需要设置一个新的函数框架)。随后调用的函数会覆盖红区的一部分作为它们自己的函数框架,因此红区最适用于所谓的 叶函数,即不调用其他任何函数的函数。只要叶函数使用的栈空间不超过 128 字节,红区就能免去这些函数显式设置函数框架的需要,从而减少执行时间。在 32 位 x86 上,没有红区的概念。
准备参数并调用函数
在函数序言之后,main 通过首先加载 argv[0] 的地址,然后加上 8 字节(指针大小),并解引用得到 argv[1],将其加载到 rax 中。它将这个指针复制到 rdi 中,作为 getenv 的参数 ➎,然后调用 getenv ➏(见 Listing A-4)。call 指令会自动将返回地址(即 call 指令后面那条指令的地址)压入栈中,getenv 在返回时会使用这个地址。由于 getenv 是库函数,这里不再详细讨论它的代码。我们可以简单假设,它通过保存 rbp、可能保存某些寄存器以及为局部变量预留空间来设置一个标准的函数框架。Figure A-4 的中间部分展示了 getenv 被调用并完成序言后的栈布局,假设它没有压入任何寄存器来保存。
在 getenv 完成后,它将返回值保存在 rax 中(这是指定用于此目的的标准寄存器),然后通过增加 rsp 清理栈上的局部变量。接着,它从栈中弹出保存的基指针到 rbp,恢复 main 的函数帧。此时,栈顶是保存的返回地址,在本例中是 main 中的 0x400588。最后,getenv 执行 ret 指令,从栈中弹出返回地址并跳转到该地址,将控制权交回给 main。图 A-4 右侧显示的是 getenv 返回后栈的布局。
读取返回值
main 函数将返回值(指向请求的环境字符串的指针)复制到 rdx 中,作为 printf 调用的第三个参数 ➐。接下来,main 以与之前相同的方式再次加载 argv[1],并将其存储在 rsi 中,作为 printf 的第二个参数 ➑。第一个参数(在 rdi 中)是格式字符串 %s=%s 在 .rodata 部分的地址 0x400634,这在前面你已经看过了。
注意,与调用 getenv 不同,main 在调用 printf 之前将 rax 设置为零。这是因为 printf 是一个变参函数,它假定 rax 指定了通过向量寄存器传递的浮点参数的数量(在本例中没有浮点参数)。在准备好参数后,main 调用 printf ➒,将 printf 的返回地址压入栈中。
从函数返回
在 printf 完成后,main 通过将 rax 寄存器清零 ➓ 来准备自己的返回值(退出状态)。然后,它执行 leave 指令,这是 x86 的简写指令,等同于 mov rsp,rbp; pop rbp。这是一个标准的函数尾声,它执行与函数开头相反的操作。它通过将 rsp 指向帧基址(即保存的 rbp 所在位置)并恢复前一个帧的 rbp 来清理函数帧。最后,main 执行 ret 指令,从栈顶弹出保存的返回地址并跳转到该地址,结束 main 函数并将控制权交回给调用 main 的函数。
A.4.3 条件分支
接下来,让我们看一下另一个重要的构造:条件分支。清单 A-5 展示了一个包含 if/else 分支的 C 程序,如果 argc 大于 5,则打印消息 argc > 5,否则打印消息 argc <= 5。清单 A-6 展示了由 gcc 5.4.0 使用默认选项编译生成的对应汇编级别实现,经过 objdump 从二进制文件恢复。
清单 A-5:C 语言中的条件分支
1 |
|
清单 A-6:汇编中的条件分支
1 | Contents of .rodata: |
就像你在 A.4.2 节 中看到的,编译器将 printf 的格式字符串存储在 .rodata 部分 ➊➋,与代码分开存放,代码则在 .text 部分。main 函数从函数前言开始,并将 argc 和 argv 复制到局部变量中。
条件分支的实现从地址 ➌ 处的cmp指令开始,它将包含argc的局部变量与立即数0x5进行比较。接下来是一个jle指令,如果argc小于或等于0x5,则跳转到地址0x400547 ➍(else分支)。在该地址,会调用puts来打印字符串argc <= 5,然后是main的尾部和ret指令。
如果argc大于0x5,则不会执行jle,而是继续执行地址0x40053b处的下一条指令序列(if分支)。它调用puts来打印字符串argc > 5,然后跳转到main的尾部,在地址0x400551 ➎。请注意,这最后的jmp指令是必要的,用于跳过位于地址0x400547处的else分支代码。
A.4.4 循环
在汇编层面,你可以将循环看作条件分支的特例。就像常规的分支一样,循环是通过cmp/test指令和条件跳转指令实现的。列表 A-7 展示了一个在 C 语言中使用的while循环,它遍历所有给定的命令行参数,并以相反的顺序打印它们。列表 A-8 展示了一个相应的汇编程序。
列表 A-7:C 语言中的 while 循环
1 |
|
列表 A-8:汇编语言中的 while 循环
1 | 0000000000400526 <main>: |
在这种情况下,编译器选择将检查循环条件的代码放在循环的末尾。因此,循环通过跳转到地址0x40055a开始,在那里检查循环条件 ➊。
这个检查是通过cmp指令实现的,它将argc与零进行比较 ➋。如果argc大于零,代码会跳转到地址0x400537,循环体从那里开始 ➌。循环体会递减argc,打印argv中的下一个字符串,然后再次进入循环条件检查。
循环继续,直到argc为零,此时循环条件检查中的jg指令会跳转到main的尾部,在那里main清理其栈帧并返回。


