基于调试器架构的交互式和针对性运行时验证
1. 引言
运行时验证(RV)旨在通过在运行时监控程序行为来检测各种形式的错误行为。这包括检查内存损坏和泄漏,以及线程死锁和竞态。RV包含三个阶段,这些阶段可以顺序或同时进行:
- 插桩
- 数据收集
- 分析
插桩是指对程序从其原始设计进行修改,例如通过添加日志记录或跟踪来监控程序的行为和性能。插桩可以在程序生命周期的大多数阶段进行:在源代码中、编译时以及生成的二进制文件中——无论是执行前还是执行期间。最后这种插桩形式——动态二进制插桩(DBI)——为用户提供了最大的灵活性,因为无需重新编译甚至不需要访问源代码。动态二进制插桩(DBI)最常见的功能之一是注入探针。这些函数被绑定到特定指令,并在每次执行该指令时被调用。
每次执行时。例如,它们可用于跟踪或修改程序变量。这些探针可以通过不同的方式注入。基于陷阱的探针使用系统中断来暂停程序的执行,直到探针函数运行完毕,然后将控制权交还。尽管这种方法提供了很大的灵活性,但在内核和用户空间之间来回切换会导致程序执行产生显著的性能开销。另一种方法是使用基于跳板的探针,该方法将探针函数加载到程序空间中,并用跳转指令替换被插桩的指令,以跳转到该位置。
这种方法使插桩效率显著提高,但跳转指令不能随意插入任意位置,因此相比基于陷阱的插桩灵活性较低。
数据收集和分析依赖于工具,通常由特定的库来处理。对这些库函数的调用通常通过插桩添加。
RV工具的一个关键属性是其性能。为了捕捉程序中即使是最罕见的缺陷,运行时验证需要在生产环境或至少在广泛的测试期间保持启用,这意味着RV工具的开销必须足够小,以免影响程序可用性。在某些情况下,例如嵌入式实时系统内存和计算能力的余量很小,这意味着能使用的工具非常少。
在本文中,我们介绍了一种基于GNU调试器(GDB)的新型交互式运行时验证(RV)架构。该架构依赖于一种新的插桩框架¹,使用特殊的基于跳板的探针,并结合调试器的功能,使其能够动态地将库加载到被调试程序中。该框架注重交互性和可访问性:插桩代码可以用C或C++编写,整个过程可以通过Python脚本和集成开发环境(IDE)界面进行管理。
本文对运行时验证领域做出了若干贡献:
- 一种新的RV框架,专注于灵活性、插桩的精确目标定位和性能。
- GDB的新型高效插桩功能,依赖于无信号的基于跳板的探针。
- Visual Studio Code环境中的多个模块,为RV工具提供了高级接口。
- 基于我们框架构建的现有运行时验证工具的多项改进:GDB快速条件断点、针对性动态地址消毒器和快速数据监视。
在下一节中,我们将简要概述有关插桩和运行时验证的文献。接着是第4节,讨论我们基于调试器的框架的详细信息。在第5节中,我们展示了使用我们的框架修改的几种工具,以体现其提供的不同功能。最后,在第6节中,我们对插桩框架以及基于该框架构建的应用程序进行了定量评估。
2. 相关工作
GDB作为框架
GNU调试器(GDB)是用于C和C++代码的一种非常流行的调试器。虽然其主要用途是通过断点进行交互式调试,但它包含大量功能,使其可作为一个框架用于不同目的,例如运行时验证(RV)。其中一些高级功能包括反向调试、基于跳板的跟踪点以及针对多线程程序的不停止调试。在7.9版本(2015)中,GDB还添加了compile命令,允许用户在被调试程序的上下文中编译并执行一段C/C++代码,并能够完全访问局部和全局变量。该调试器提供了命令行语法和Python两种脚本接口,以及一个机器接口,可用于与其他工具(如集成开发环境(IDE))通信,特别是通过调试适配器协议²。诸如GDB扩展功能³之类的工具就利用了这种脚本功能,为GDB增加了显示虚拟内存映射或汇编代码等功能。
动态二进制插桩
目前有多个可用于动态二进制插桩的框架,它们依赖不同的机制来注入或替换代码。一种非常基础的方法是结合使用GDB的断点和脚本功能对某些指令进行插桩。然而,这种方法会带来较大的性能影响,在大多数情况下无法使用。
Pin[1]是一个针对x86和ARM架构的插桩框架,它依赖于在代码执行时动态重新编译代码,并在此时插入不同的插桩探针。该框架对于全面插桩非常有效,但会带来一定的开销。
即使没有进行插桩,由于动态重新编译的开销,也会产生运行时开销。
Dyninst[2]使用跳板对二进制文件进行插桩,既可以在运行时动态插桩,也可以在二进制文件上静态插桩。每次单独的插桩都需要对二进制文件进行重量级分析,这意味着它在批量插入探针方面更为有效,而不适用于按需的交互式插桩。
LiteInst[3]是一种利用指令双关在程序中插入基于跳板的探针的工具。这使得无需运行大规模分析即可插入单个探针。当无法插入跳转指令时,它将转而依赖于基于陷阱的插桩。
运行时验证
存在大量专用的RV工具,用于检查程序中的特定属性,例如内存泄漏和损坏或线程竞争条件。用于这些任务的流行工具有Address Sanitizer[4](内存分析)和Thread Sanitizer[5](竞争检测)。这些工具在编译时对程序进行插桩,并在运行时加载一个自定义库。该库收集由插桩添加的信息,并利用它动态构建数据结构,以跟踪程序的状态。当数据结构中满足某个特定属性时,工具会生成错误以停止程序,并生成日志详细说明发生位置。例如,如果在某一时刻,表示不同线程上对同一地址进行内存写入的两个节点之间没有任何排序边连接,则Thread Sanitizer会为数据竞争生成错误。另一种更通用的方法由工具Valgrind[6,7]提供。它被设计为一个重型插桩框架,并提供诸如Memcheck[8](用于内存分析)和Helgrind[9](用于检测同步错误)等模块。与Address Sanitizer等工具不同,Valgrind工具是完全动态的,即不需要重新编译二进制文件。Valgrind通过在程序执行时动态重新编译代码并在虚拟机中运行来实现插桩。它还映射影子内存,以存储有关程序内存的元数据。这种重新编译和虚拟执行带来显著的性能影响,Valgrind工具通常会使程序变慢10到100倍⁴,⁵
一些工具通过让用户定义一系列规则,然后在运行时由监控进程检查这些规则,从而为运行时验证(RV)提供了一种完全通用的方法。采用这种方法的一些框架包括MOP[10], TeSSLa[11], MarQ[12]和Muffin[13]。然而,这些监控程序需要在编译时插入某种形式的跟踪,以从程序中提取内部数据,这意味着开发者需要为运行时验证构建一个独立二进制文件。
交互式运行时验证的一个主要优势是完全动态集成,这一概念由雅克塞等人[14]提出,并同时引入了一个名为Verde的基于GDB的框架。与其他RV框架一样,它允许用户定义场景和属性,以生成一个有限状态机来监控程序的执行。该自动机的节点是程序状态,节点之间的边是事件。每当触发此类事件时,自动机会相应更新,图形界面也会显示新的状态。这种类型的验证可以检查队列访问有效性,例如在push和pop操作时更新队列长度,并在用户从空队列出队时报告错误。然而,Verde的一个主要问题是它依赖GDB断点来监控事件(即基于陷阱的插桩),这导致性能较差,尤其是在事件频繁触发时会显著减慢程序执行速度。此外,该工具不适合用于适配和改进现有的运行时验证工具,因为这些工具需要完全重写才能适配Verde框架。
3. 基于调试器的运行时验证
3.1. 一种适用于运行时验证的多功能架构
大量运行时验证工具采用精简的方法,专注于某一项特定任务(如内存分析、线程争用分析等),并高效地完成该任务。然而,由于缺乏高效的运行时验证框架,大多数工具都是彼此独立构建的。所有基本功能都需要各自重新实现,从而产生开发成本。对于非核心功能,此类成本可能过高,导致这些功能被舍弃,尽管其他工具已经实现了这些功能。
如果具备适当的基础设施,大部分代码可以被复用或共享,诸如附加到正在运行的程序和动态插桩等功能将有望得到更广泛的采用。
存在更通用的工具,例如Valgrind,但提供这种通用性所需的机制会带来极大的性能开销,即使不执行任何分析也是如此。这使得这些工具不适合对特定问题进行轻量级的针对性分析。
调试器成为这两种方法之间的一个自然中间点。实际上,调试器是通用工具,已经实现了大量二进制操作功能,例如附加到程序和加载库。在不进行插桩的情况下,它们对执行性能的影响非常小,并且已经具备处理调试信息和遍历二进制文件的基础设施。此外,大多数集成开发环境已经为调试器实现了图形界面,与大多数RV工具所使用的命令行界面相比,这可以显著提升工具的可用性。最新的集成开发环境(如Visual Studio Code[15]和Eclipse Theia[16])支持用户设计的模块,通过为基于调试器的工具提供定制界面来改善用户体验。
调试器中一个关键缺失的功能是无法自由且高效地对代码进行插桩。为了展示基于调试器的运行时验证(RV)的多功能性,我们向GDB引入了新的插桩功能。通过扩展GDB的compile命令,我们实现了一种面向熟悉调试器的开发者的、具有非常直观接口的插桩机制。为了最大化效率,该机制采用基于跳板的插桩和指令双关技术实现。这一功能在我们的方法中至关重要,因为大多数先进的RV工具都使用某种形式的二进制文件插桩,而性能是工具可用性的关键因素。
我们提出的新型RV架构如图1所示。该架构分为两个领域:源代码和二进制文件。在源代码层面,存在一个模块化集成开发环境,可通过与language server交互来处理代码。该集成开发环境还负责用户交互,从而控制包含GDB和被调试程序的二进制层面。
该框架使得能够在不修改通常是关键部分的运行时库的情况下,为现有工具添加新功能。它支持对二进制文件进行高效的动态插桩,以及高级的用户交互功能,例如精确目标定位函数和源文件,并允许用户在程序运行时调整插桩。这为设计真正交互式、精确目标定位的高性能运行时验证工具提供了可能,而此类功能是其他框架(如Verde[14])所不具备的。
3.2. 框架接口
用户界面
作为我们交互式RV框架的一部分,我们为VSCode设计了一个多功能的核心应用,用于支持加载用户自定义的RV工具。我们为此类工具定义了一种标准格式,该格式使用JSON文件作为参考。一个RV工具包可以包含多个库和Python脚本,这些脚本通过集成开发环境中的自定义命令调用。系统会向用户提供一系列命令选项,其中部分命令会使用集成开发环境中的信息,例如当前打开的源码文件或光标位置。这些命令由集成开发环境转发给GDB,GDB可对程序进行插桩并运行程序。
编程接口
所提出的插桩工具集成在传统的GDB命令行界面中。它使用关键字patch以及类似于compile命令的语法。插桩位置可以通过函数名、源文件行或明确的内存地址来指定。与其他需要使用特定API来控制插桩的框架不同,该工具采用了一种简单的接口,调试器用户对此已经熟悉。要注入的插桩代码是一个标准的C或C++代码片段,通过GNU的GCC编译器进行编译。如果程序是带有调试信息编译的,则该代码片段可以引用被插桩的帧中的全局和局部变量,而无需任何声明。这些变量可以自由地读取和写入。
作为GNU调试器的一部分,此处介绍的插桩框架可以轻松地通过GDB的脚本功能进行操作。集成Python解释器使得设计运行时验证工具成为可能,这些工具能够随意对代码进行插桩。由于只需具备Python语言的基本理解即可使用GDB设计自定义RV工具,因此保证了易用性。该工具以Python编写,还可以利用大量可用的库和模块,便于创建高级工具。这个非常简单的脚本接收一个补丁列表,每个补丁包含位置和相应的代码,并将其注入到正在运行的程序中。
import gdb
# 补丁_dataistransmittedfromt
he
# IDE模块 to GDB
补丁_data = get_补丁_data()
for补丁 in补丁_data:
gdb.execute("补丁 ␣"+
补丁.location+补丁.code)
为了向用户提供高级交互性,集成开发环境模块也可以专门为RV工具进行设计。这样就能利用模块化集成开发环境中的所有API,提供自定义的交互功能,例如在单独标签页中显示数据或直接注解源代码。例如,我们设计了一个原型动态跟踪扩展,允许用户在代码中设置自定义跟踪点,以监控特定变量。此类扩展还可以利用集成开发环境中提供的高级源代码处理功能。我们的演示应用程序可以向语言服务器查询某个特定变量的所有出现位置,然后在其每次被修改的位置添加跟踪点,以跟踪其修改时机。
3.3. GDB中的高效插桩
本节描述了我们集成到GDB中的插桩框架的实现。介绍了查密斯等人[3]使用的指令双关技术以及我们在该方法基础上所做的改进。目前,唯一可用的实现是针对x86_64 Linux的,但大部分代码与架构无关。截至2020年9月,该项工作⁶尚未被纳入GDB主分支,但正处于提交流程中。
指令双关
我们选择使用基于跳板的探针来实现插桩框架。其工作原理是通过跳转指令替换代码中的指令,跳转到一个跳板。跳板是一块分配在被插桩指令附近的小内存区域,确保可以通过跳转到达。该跳板包含执行实际插桩相关各项任务所需的所有指令,即保存和恢复寄存器、执行被跳转指令替换的原始指令,并以正确的参数调用实际的探针函数。
在x86_64架构上,基于跳板的插桩存在一个重大问题,即跳转指令的长度。为了能够到达内存中足够大的未分配区域以放置跳板,通常需要使用5字节长跳转指令。这使得对较短指令的插桩变得复杂,而较短指令通常占程序指令总数的一半以上⁷。另一种方法是一次性重定位多条指令,直到腾出足够的空间来放置5字节跳转指令。然而,如果我们覆盖了一条指令,而程序中包含跳转到该指令原本位置的跳转指令,则会产生意外行为,并导致程序可能崩溃。
在接下来的段落中,我们将描述待插桩地址处的指令布局如下:
ABCDE
其中每个字母代表一个字节。大写字母对应指令起始位置,小写字母对应其他字节。例如
AbcDe
对应一条3字节长指令(我们想要插入的指令Abc)后跟一条至少2字节长的指令De。为了说明前面的例子,如果我们盲目地将指令A替换为一条跳转指令(操作码J)指向内存空闲区域(偏移量wxyz),我们将得到如下布局
Jwxyz
如果在稍后某个时间,线程跳转到先前指令De的位置,则它将仅读取字节yz,这些字节将被解释为与原始指令无关的指令。
我们的解决方案是对查密斯等人在LiteInst中引入的技术的改进[3],该技术使用指令双关来对二进制文件进行插桩。这种插桩方法的优势在于插入探针时需要对内存进行的修改较少。这具有提供快速插桩时间的优势,这在交互式框架中至关重要。
指令双关是一种用于代码压缩或混淆的技术,它利用了指令作为数据的双重性质。根据从哪一位置开始读取一组字节,得到的指令可能完全不同。更具体地说,在使用指令双关进行插桩时,我们可以根据指令A之后的指令来选择跳板的位置。在布局的情况下
AbcDe
我们可以尝试找到一个相对于指令A偏移量**de的跳板地址,其中标记为*的字节可以选择以提供更多可能性。如果存在字节y和z满足条件,则
Jy zde
是跳转到可以安装跳板的区域,那么即使程序跳转到指令D,其行为也会如同没有进行插桩一样。
然而,这种方法极大地限制了兼容跳板地址的搜索空间。例如,在前面的例子中,由于x86架构采用小端序,偏移量的最高有效字节被设置为e和d。如果e对应一个较大的负值,则无论其他三个字节为何值,该偏移量都将完全无效。
为了规避该限制,可以修改被覆盖的指令,只要我们能确保当线程跳转到指令D所在位置时行为保持一致。可以在该处放置一个生成SIGTRAP的指令,例如INT3。通过为该信号设置处理程序,我们能够将任何触发此陷阱的线程重定向到相应的跳板末尾,使其执行被覆盖的指令,然后跳回程序。使用非法指令而不仅仅是INT3,可以在选择跳转偏移量时提供更大的灵活性,因为非法指令更容易替代最高有效字节。这是因为某些非法指令可被解释为正整数(0×06,0×07),而INT3(0xCC)是一个负的有符号整数。
例如,使用与之前相同的示例,我们可以用字节De替换指令ix,其中i可以从16种可能性中选择(d、INT3或14种1字节非法指令中的任意一种),而x可以自由设置。这种方法通常比基于陷阱的插桩产生的运行时开销要低得多。实际上,只有当跳转直接指向信号触发指令时才会触发信号,即跳转指令跨越两个基本块的情况。如果跳转指令完全包含在某个基本块内,则在执行过程中不会引发任何信号。
在这些约束下,可能仍然没有空间用于跳板,这种情况可能出现在如下布局中:
AbcDE
这些限制非常严格。在这种情况下,查密斯等人会退回到基于陷阱的插桩,并直接将指令A替换为INT3,从而牺牲了运行时性能。
在我们的实现中,引入了一种新机制,我们称之为指令滑动。如果指令A的长度超过1字节,我们可以将其第一个字节替换为空操作,并尝试对后续字节进行插桩。例如,在布局中:
AbcDE(f)
偏移量的两个最高有效字节受到限制。然而,如果我们用空操作(N)替换字节A,则布局将变为
(N)BcDEf
我们的放置范围显著更广,因为我们对最高有效字节拥有完全控制权。
如果该技术失败或无法使用,我们将退回到基于陷阱的插桩。
GDB集成
在GDB中实现插桩时,我们利用了调试器的多个内部机制。通过使用GDB的gdbarch架构,我们能够将架构特定功能(如跳板内容或指令双关)的实现与可在其他架构上复用的通用功能清晰地分离。GDB中已存在处理信号的基础设施,因为它使用信号在代码中插入软件断点。通过添加一种特定类型的断点,跳转中的信号行为可由GDB后端进行处理。
我们还设计了一个特定的内存管理系统,用于确定跳板的可用放置位置。跳板仅写入专门映射的页面,但在可能的情况下,我们倾向于在跳板之间共享页面,以减少该工具的内存占用。对于每个跳板,我们预留256字节,因此每个4 KB页面最多可容纳16个跳板。新页面使用mmap MAP_FIXED选项进行映射。如果调用因页面不可用而失败,我们的系统会存储该信息,以避免重复执行失败的调用。
结果:在某个地址放置一个跳板,该跳板保持程序正确性
偏移量,指令_布局 = 读取_内存(地址)
当 跳板未放置 时执行
页面 = 页面_地址(地址+偏移量)
如果 页面空闲
则分配页面并构建跳板;
返回;
结束
如果 页面包含一个跳板
则
如果 有足够的空间放置跳板
则构建跳板;
返回;
结束
结束
偏移量_(偏移量, 指令_布局)
如果 偏移失败
则错误:无法构建跳板;
结束
结束
算法1:放置跳板
4. 应用集成
使用我们的工具,我们构建了多个应用,展示了如何扩展现有程序以提升其性能和灵活性。第一个应用是对GDB的内部功能——条件断点进行性能提升。在另外两个示例中,我们通过将最先进的RV工具集成到我们的框架中来扩展其功能,并展示了它们如何从高效、有针对性且动态的插桩中受益。
4.1. 快速条件断点
断点是交互式调试的核心,因为它们允许用户在程序执行到特定指令时中断程序。为了增强这一基本功能,大多数调试器(包括GDB)都提供了为断点添加某些条件的方式,这意味着只有在满足这些条件时才会中断执行。然而,GDB中的条件检查速度众所周知较慢,因此在代码的关键部分设置条件断点可能会显著增加程序的性能开销,即使条件从未被满足。这种性能开销的根本原因在于其架构:条件检查是在调试器内部执行的,而不是在底层程序中执行。这需要在应用程序之间进行切换,从而向内核发送一个信号。
通过使用我们的插桩框架,我们能够修改条件断点的行为,使条件检查在程序上下文中进行,从而避免了上下文切换和内核信号的产生。只有当条件满足时,才会触发信号,调试器将控制权交还给用户。正如第5节所述,这使得条件检查速度提高了5000倍,在保持相同灵活性的同时,显著提升了使用条件断点进行交互式调试的效率。
4.2. 定向动态地址消毒剂
地址消毒剂(ASan)[4]是谷歌维护的一款流行的内存泄漏和内存破坏检测工具。它能够检测堆、栈和全局变量上的缓冲区溢出和释放后使用等错误。对于全局变量和栈变量,地址消毒剂在编译时在变量周围分配红区,并在运行时对这些红区进行标记。任何对红区的访问都会导致错误。对于堆变量,该工具通过使用自定义内存分配器来工作,该分配器会映射额外的影子内存
表1 根据所用技术对不同插桩和运行时验证工具及框架的分类。
| 动态插桩 | 静态插桩 | 二进制重新编译 | 信号注入 | 基于跳板的探针 | 直接代码注入 |
|---|---|---|---|---|---|
| Pin | GDB | Dyninst | Clang | 框架 | LiteInst |
| GDB补丁 | RV框架 | Valgrind | Verde | 我们的框架 | MOP, TeSSLa, MarQ… |
| RV工具 | Memcheck | 数据监视 | 定向ASan | ASan | 快速数据监视 |
斜体部分为本文的贡献。
内存到已分配区域的元数据。它还使用编译时插桩来插入对内存访问的检查,通过读取影子内存以确定该区域是否已正确分配。其插桩设计由编译器实现,仅在每次内存访问时进行一次读取操作。所有影子内存的映射和写入均由与程序链接的共享库libasan处理。
该工具的主要缺点是,即使只需要检查其中一小部分,也必须对整个二进制文件进行重新编译和插桩才能应用。
使用我们的框架,我们设计了一种新的ASan方法,通过利用调试器中集成的完全动态插桩来规避这些限制。通过让用户仅针对代码中指定的部分进行操作,我们在灵活性和性能上都得到了提升。当程序在一系列表现异常后出现故障时,这种方法尤为有用。直接将ASan应用于源代码的修改部分通常能够快速定位错误,同时减少性能开销(相比对整个二进制文件进行插桩),并避免了重新编译的负担。
我们基于动态实现的Address Sanitizer架构如图3所示。它包含一个用于核心VSCode扩展的包,该包中包含一个用于控制插桩的Python脚本,以及libasan库。用户可以选择程序中需要进行插桩的部分,可以是几行代码、一个文件,或整个源码。VSCode扩展通过与clangd语言服务器交互,来确定所选区域内的不同内存访问。随后,集成开发环境通过GDB对预定义区域进行插桩,并在控制台中执行程序。目前,错误仍通过控制台报告,但该扩展可进一步改进,使错误直接显示在源文件中。
我们的动态实现的地址消毒剂并非旨在取代原始版本。实际上,它不分析栈和全局变量,并且在使用我们的方法对整个二进制文件进行插桩时,通常比使用适当选项完全重新编译要慢。这是因为,对于每个被插桩的文件,语言服务器都需要解析该文件并确定所有内存访问的位置。然而,当仅部分插桩二进制文件时,这种能力非常有用,而原始工具缺乏这种定向功能。动态能力还允许开发者仅处理单个二进制文件,而无需为地址消毒剂单独构建一个。同一个二进制文件可以在软件开发流程中从设计到验证和部署全程使用。
4.3. 快速数据监视
数据监视[17]是一种运行时验证工具,旨在程序执行期间识别堆内存损坏实例。它通过使用包装器覆盖C库的内存分配函数(malloc、realloc等)来实现。这些包装器会存储分配函数返回的原始地址以及请求的分配大小,并返回一个指向受保护内存页的污染地址。当污染地址被解引用时,系统会引发一个信号(在Linux上为SIGSEGV)。数据监视使用一个内核模块来捕获该信号。利用污染地址,它可以确定原始地址以及该内存访问相对于基础污染地址的偏移量。然后可以将此偏移量与已分配区域的大小进行比较,如果存在溢出,则返回错误。如果访问在边界内,内核模块会使用正确的地址模拟该指令,并将控制权返回给程序。
该工具在Linux操作系统上的主要问题是每次内存解引用都会产生一个信号,该信号必须由内核处理,从而需要进行内存保护环切换。这会耗费较长时间,并显著减慢程序的运行速度。
基于我们的新框架,我们成功设计了适用于x86_64 Linux的新一代数据监视工具,该工具在性能和可用性方面均有所提升。我们的版本称为快速数据监视(FDW),去除了内核模块,旨在将大部分检查保留在用户空间中。仅在解引用指令首次执行时会生成信号,后续执行将保持在程序上下文中,通过避免保护环的切换而产生极小的开销。我们还可以通过选择需要覆盖哪些内存分配函数的调用来使快速数据监视(FDW)针对指定的变量子集进行监控。
实现在多个层次上进行。首先,设计了一个简单的库,该库重写了内存分配函数,并提供了一个名为memory_check_and_correct()的新函数。此函数检查作为参数接收到的污染地址是否在已分配对象的边界内,并返回相应的未污染地址。
在调试器级别,向SIGSEGV信号添加一个信号处理程序。当该处理程序被调用时,它会分析错误指令,并确定哪些地址被解引用。然后,它将该指令替换为跳转到一个跳板,该跳板调用内存_检查_并_修正这些地址的函数(图4)。仍在跳板中,使用已修正的地址重新执行错误指令。未写入的寄存器被恢复为其污染值,程序跳转回已修正指令之后继续执行。此后每当再次执行该指令时,将直接运行跳板,在执行原始指令前完成正确的内存检查,而无需触发信号或进入内核。
在IDE级别,该应用通过一个特定包集成到我们的核心应用中。它包含用于信号处理的Python脚本以及两个版本的库。第一个版本直接重写了所有内存分配函数,而在第二个版本中,需要单独将内存分配调用重定向到修改后的版本。这使得能够以类似于定向ASan的方式对内存区域进行精确目标定位分析。
FDW使用我们修改后的新patch命令实现,以便能够直接写入机器代码以提高效率和性能。也可使用未修改的命令实现版本,但需要使用内联汇编来直接操作寄存器。信号处理通过Python脚本和capstone⁸库来分析指令完成。
与其他工具(如常规的AddressSanitizer)相比,该内存损坏检查器具有完全动态的优势,且除用于明确报告错误外,不需要源代码访问。此工具的一个问题是处理内核对污染指针的访问。我们需要在每个系统调用处设置断点,以检查其参数是否未被污染。然而,确保内核访问的所有数据都未被污染极为困难。例如,ioctl系统调用非常灵活,其行为取决于安装的不同驱动程序。一种解决方案是使用内核模块,与FDW并行运行,以管理内核中污染指针引发的故障。
4.4. 使用场景
在表1中,我们展示了我们的不同工具和框架在运行时验证领域中的位置。
就典型用例而言,像Memcheck这类用于在发布前对二进制文件进行重量级分析的重新编译工具是适用的。实际上,由于Memcheck具有很高的性能和内存开销,因此无法在生产环境中启用。基于信号的工具更适合交互式调试和精确目标定位。事实上,由于执行探针会带来开销,因此必须尽量减少生成的信号数量,以保持合理的执行开销。当用户对缺陷可能的来源有较明确判断时,这类工具非常有用。程序的静态插桩旨在提供最小的性能开销,并可在性能不是关键因素的生产环境程序中集成使用,这对于诊断在测试期间未出现的罕见缺陷是必要的。这对于长时间运行的在线程序(例如服务器上运行的程序)尤其有用。基于跳板的工具旨在介于二进制重新编译和静态插桩之间提供一种折中方案。通过能够动态地重新定位分析发生的位置,当用户知道程序中哪些部分可能存在问题是,它们可以比静态插桩带来更小的开销。例如,在修改现有程序时可以使用这种方法:在一段时间内,可以动态地对程序新增部分进行插桩,以检查生产环境中的错误。当经过一定时间未检测到错误后,还可以移除插桩,从而在不重启程序的情况下完全消除性能开销。
5. 评估
Experimental setup. 本文介绍的大多数测量都是在运行Ubuntu 18.04和Linux内核4.15.0‐88的计算机上进行的。CPU为Intel i7‐4790,配备32 GB的1600 MHz DDR3内存。程序使用GCC版本7.4.0编译,并对GDB版本9.0.50进行了补丁处理。
唯一的例外是Pin和LiteInst框架在图5上的评估,我们无法重现这些结果。我们直接展示了V. Zhao的[18]结果。
5.1. 插桩性能
为了评估插桩性能,我们使用了查密斯等人[3]在其论文中提出的相同基准测试。该基准测试包含SPEC基准测试中的一些常用工具。此基准测试涵盖了单线程(bzip2、h264、perlbench、sjeng)和多线程应用(fluid和blackscholes)的多种不同情况。这些基准测试代表了不同类型的真实应用程序,从文件压缩(bzip2)到人工智能(sjeng)。
在我们的基准测试中,我们测量了有和无插桩情况下的执行时间,以及设置插桩所需的时间。所选的插桩类型为过程计数,对每个函数入口进行插桩,并使用探针统计程序执行期间每个函数的调用总次数。在表2中,我们展示了每个应用程序安装的探针数量以及所产生的内存开销。除h264基准测试外,总体影响较小,因为在h264基准测试中,GDB会独立于我们的插桩工具分配大量内存。
总运行时间和执行时间分析的结果如图5所示。我们在设置中无法成功运行LiteInst基准测试。因此,当数据可用时,我们使用了V. Zhao[18]的结果将我们的方法与LiteInst和Pin进行比较。请注意,过程计数器的实现可能略有不同。然而,该工具足够简单,这种差异应该不显著。表3列出了平均探针安装和执行时间的测量结果。
我们的插桩框架性能略低于最先进的插桩工具,同时在功能上带来了显著的提升
表2 每个基准应用程序注入的探针数量。同时参考了为GDB和探针保留的内存,并与程序的整体内存影响进行比较。
| 基准测试 | 探针 | 内存开销 | 相对开销 |
|---|---|---|---|
| bzip2 | 134 | 1.39 MB | 0.6% |
| BlackScholes | 36 | 24 KB | 0.04% |
| h264 | 644 | 61 MB | 204% |
| 流体 | 108 | 308 KB | 0.04% |
| Perl | 1996 | 20.5 MB | 6.6% |
| sjeng | 188 | 1.88 MB | 1.04% |
表3 单个探针的平均安装和执行时间。
| 平均测量时间 | |
|---|---|
| 探针安装 | 30毫秒 |
| 探针执行 | 50纳秒 |
在灵活性和易用性方面,该框架支持使用符号和地址对程序进行插桩,并且结合使用Python和GDB使得程序和用户能够在程序执行过程中动态调整插桩。因此,通过我们的框架,可以利用已收集的数据在运行时动态地精细化数据收集。
5.2. 应用程序性能
快速条件断点 。为了评估我们实现的快速条件断点的性能,我们在一个简单的微基准测试上进行了测试。我们使用以下测试程序,以测量快速断点条件检查的平均执行时间,并与普通断点进行比较(见图6)。
int main()
{
int array[1000000];
for(int i = 0; i < 1000000; i++)
{
array[i] = i;
}
}
为了检查所有内存访问是否正确,我们设置了一个带有条件的调试器断点:
(i < 0 || i > 1000000)
此断点永远不会激活,因为其条件从未满足。我们比较了使用快速条件断点和普通条件断点时程序的执行时间。基线为未插桩程序运行时间。使用快速条件断点时执行速度显著更快,加速比约为
与现有的GDB条件断点相比,速度减慢了680倍。然而,与未插桩的基线相比存在显著的性能开销,这可以通过该基线的简单性来解释。对于更复杂的程序,例如如果我们使用rand()将array[i]设置为随机值,则性能开销更低(约为3倍)。
根据这些数据,我们可以得出快速条件断点的执行时间约为30纳秒,或100个CPU周期。这一改进使得GDB的条件断点在多种使用场景下变得可用,而普通的版本通常完全无法使用。
定向动态地址消毒剂 。为了评估我们实现的动态定向地址消毒剂,我们测量了其在对sjeng基准测试(模拟一个AI国际象棋玩家对抗预定义走法集合)的部分或全部进行插桩时的性能。部分插桩在sjeng.c源码文件上进行。不同情况下的执行时间如图7所示。运行时间基于10次运行的测量结果,图中显示了标准差。部分插桩期间注入的探针数量为25个,完全插桩时为1644个。我们通过在不同位置主动引入错误来验证工具的正确性,这些错误在执行期间被信号提示。
我们的地址消毒剂版本在部分插桩情况下运行速度几乎与未插桩二进制文件相同。甚至看起来略快一些,因为在此项测量中我们未考虑GDB的启动时间,该时间包括将二进制文件和共享库加载到内存中的过程。当考察包含设置插桩点所需时间的总减速时,我们观察到大约1.5倍的性能开销,这比运行
使用地址消毒剂进行的完整分析。对于完全插桩,我们的方法明显比编译时的地址消毒剂更慢。这是由于跳板的执行开销所致。当我们考虑启动时间时,差距更大,因为每个文件都需要在编译器中单独处理,以生成供我们的工具分析的抽象语法树。
显然,我们的应用程序并非旨在取代Address Sanitizer。它的目标是提供一种可动态执行且低成本的针对性地址检测替代方案。作为参考,我们对动态替代工具Valgrind Memcheck进行了测试,测得其平均运行时为790秒。这意味着与未插桩的执行相比,性能开销高达146倍,且比我们的方法慢2倍以上。
快速数据监视 。为了评估我们实现的快速数据监视的正确性,我们在一个已存在的ffmpeg二进制文件的缺陷上进行了测试。⁹该缺陷包括一小块内存泄漏,以及在向程序提供无效输入时,一大块内在退出时未被释放。使用我们的工具,我们能够复现Valgrind的诊断结果,并发现了退出时未释放的内存区域。然而,我们的工具无法明确区分彻底丢失的内存(不存在指向它的指针)和仅仅是从未被释放的内存。
在原始性能方面,我们无法将我们的DataWatch版本与原始的基于内核的版本进行比较,因为它们实现在不同的架构上。我们测量了我们的工具在插桩两个代表极端情况的程序时的性能:循环和展开循环。两者都使用相同的源码文件:
int main()
{
int *array =
(int *)malloc(
100000 *sizeof(int));
for(int i = 0; i < 100000; i++)
{
array[i] = i;
}
}
表4 使用我们实现的快速数据监视时极端情况的执行时间。
| 二进制文件 | 未插桩 | 快速数据监视 |
|---|---|---|
| Loop | 4 ms | 310毫秒 |
| 展开 | 4 ms | 57.6秒 |
表5 快速数据监视单个探针的平均安装和执行时间。
| 平均测量时间 | |
|---|---|
| 探针安装 | 70毫秒 |
| 探针执行 | 500纳秒 |
对于已展开循环的二进制文件,循环被完全展开,因此没有指令会被执行两次。这对我们的程序来说是最坏的情况,因为每次访问都会产生一个需要由内核和GDB处理的信号。对于这两个二进制文件,我们的工具均报告变量array在程序退出前未被释放。
表4表示了循环和展开循环二进制文件的执行时间。对于如此简单的情况,性能开销非常大,即使在理想情况下(78倍的性能开销),因为基础程序中最关键的指令被替换为超过200条指令,以执行跳板和内存边界检查。在更复杂的二进制文件上,该性能开销显著降低。需要注意的是,在310毫秒中,有190毫秒用于加载GDB,这部分时间不会随着二进制文件执行时间的增加而增加。在最坏情况(展开循环)下,性能开销极其严重,因为每条加载指令都会产生一个信号。在这种情况下,不建议使用我们的方法,而使用Valgrind Memcheck则明显更快(执行时间约为4.5秒)。
表5详细列出了在我们的机器上探针安装和执行的平均测量时间。探针安装时间随着已安装探针数量的增加而增加,因为它需要检查被插桩指令上的冲突。该测量值是在安装1000个探针时获得的。
6. 结论
在本文中,我们提出了一种新的交互式定向运行时验证框架,该框架提升了现有工具的性能并增强了其灵活性。作为该框架的一部分,我们在GNU调试器内设计了一个新的插桩框架,能够简单高效地对程序进行插桩。我们还设计了一个IDE模块,作为不同运行时验证工具的统一接口。我们已证明,我们的工具在性能上接近最先进的水平,同时增加了灵活性和易用性,更有利于广泛采用。
未来,我们希望进一步提升工具的性能,使其与其他灵活性较低的插桩框架相媲美。我们认为,在进一步优化方面仍有显著空间。在可访问性方面,IDE集成可以进一步改进,以便在源代码及其周围直接显示更多关于RV工具结果的数据,同时支持对运行时验证工具更精确和自动化的定向。
420

被折叠的 条评论
为什么被折叠?



