McSema如何处理C++异常 - Trail of Bits博客
使用异常的C++程序对二进制提升工具来说是个难题。C++源代码中出现的非局部控制流"throw"和"catch"操作无法直接映射到简单的二进制表示。可以说编译器、运行时和栈展开库共同协作才能使异常正常工作。我们最近完成了对异常的研究,可以毫无疑问地声称McSema是唯一能正确提升带有基于异常的控制流程序的二进制提升工具。
我们在McSema上的工作需要弥合程序高级语言语义与其二进制表示之间的语义鸿沟,这需要完全理解异常在底层的工作原理。本文分为三个部分:首先,我们将解释在Linux x86-64架构下如何处理C++异常,并解释核心异常处理概念;其次,我们将展示如何利用这些知识在二进制级别恢复异常信息;最后,我们将解释如何为LLVM生态系统生成异常信息。
C++异常处理简要介绍
在本节中,我们将使用一个小示例来演示C++异常在二进制级别的工作原理,并讨论在x86-64处理器上运行的Linux程序的异常语义。虽然异常在不同的操作系统、处理器和语言中工作方式不同,但许多核心概念是相同的。
异常是一种编程语言构造,提供了处理异常或错误情况的标准方法。它们通过在发生此类事件时自动将执行流重定向到称为异常处理程序的特殊处理程序来工作。使用异常,可以明确操作可能失败的方式以及应如何处理这些失败。例如,对象实例化和文件处理等操作可能以多种方式失败。异常处理允许程序员以大代码块的通用方式处理这些失败,而不是手动验证每个单独操作。
异常是C++的核心部分,尽管它们的使用是可选的。可能失败的代码被包围在try {…}块中,可能引发的异常通过catch {…}块捕获。异常条件的信号通过throw关键字触发,该关键字引发特定类型的异常。图1显示了一个使用C++异常语义的简单程序。
|
|
图1: 抛出和捕获异常的示例C++程序,包括捕获所有子句
这个简单程序可以根据输入参数显式抛出std::runtime_error和std::out_of_range异常。它还在内存耗尽时隐式抛出std::bad_alloc异常。程序安装了三个异常处理程序:一个用于std::out_of_range,一个用于std::bad_alloc,以及一个用于通用未知异常的捕获所有处理程序。
运行以下示例输入来触发三种异常情况:
场景1: ./exception 0
Unknown error.
程序检查输入参数,不希望输入为0
,抛出std::runtime_error异常。
场景2: ./exception 0 1 Out of range error: vector::_M_range_check 程序期望一个输入参数并检查它。如果输入参数多于一个,则抛出std::out_of_range异常。
场景3: ./exception 1
Allocation failed: std::bad_alloc
对于除0
之外的输入,程序进行可能失败的大内存分配。这可能在运行时发生并被忽略。在这种情况下,内存分配器抛出std::bad_alloc异常以安全终止程序。
让我们在二进制级别查看相同的程序。Compiler Explorer显示编译器为此程序生成的二进制代码。编译器将throw语句转换为一对libstdc++函数调用(__cxa_allocate_exception和__cxa_throw),这些函数分配异常结构并开始清理导致异常栈展开的作用域中的本地对象的过程(参见Compiler Explorer中的第40-48行)。
栈展开: 从进程栈中移除已退出函数的栈帧。
catch语句被转换为处理异常并执行清理操作的函数,称为landingpad。编译器生成一个异常表,将操作系统分发异常所需的一切联系在一起,包括异常类型、相关的landing pad和各种实用函数。
landingpad: 旨在捕获异常的用户代码。它通过personality函数从异常运行时获得控制权,要么合并到正常用户代码中,要么通过恢复或引发新异常返回到运行时。
当异常发生时,栈展开器清理先前分配的变量并调用catch块。展开器:
-
调用libstdc++ personality函数。首先,栈展开器调用libstdc++提供的特殊函数,称为personality函数。personality函数将确定引发的异常是否由调用栈上某处的函数处理。用高级术语来说,personality函数确定是否有应该为此异常调用的catch块。如果找不到处理程序(即异常未处理),personality函数通过调用std::terminate终止程序。
-
清理分配的对象。为了干净地调用catch块,展开器必须首先清理(即为try块内调用的每个函数调用每个分配对象的析构函数)。展开器将遍历调用栈,使用personality函数为每个栈帧标识清理方法。如果有任何清理操作,展开器调用相关的清理代码。
-
执行catch块。最终展开器将到达包含异常处理程序的函数的栈帧,并执行catch块。
-
释放内存。一旦catch块完成,将再次调用清理函数以释放为异常结构分配的内存。
恢复异常信息
对于像McSema这样的二进制分析工具来说,恢复基于异常的控制流是一个具有挑战性的命题。基本数据难以组装,因为异常信息分散在整个二进制文件中,并通过多个表联系在一起。利用异常数据恢复控制流很困难,因为影响流的操作,如栈展开、对personality函数的调用和异常表解码,发生在编译程序的范围之外。
以下是最终目标的快速总结。McSema必须识别每个可能引发异常的基本块(即try块的内容)并将其与适当的异常处理程序和清理代码(即catch块或landing pad)关联起来。然后将使用此关联在LLVM级别重新生成异常处理程序。为了将块与landing pad关联,McSema解析异常表以提供这些映射。
我们将详细介绍异常表。理解这一点很重要,因为这是允许McSema恢复基于异常的控制流的主要数据结构。
异常表
异常表为语言运行时提供支持异常的信息。它有两个级别:语言无关级别和语言特定级别。定位栈帧和恢复它们是语言无关的,因此存储在独立级别中。识别处理异常的帧并将控制权转移给它依赖于语言,因此这存储在语言特定级别中。
语言无关级别
该表存储在二进制文件的特殊部分,称为.eh_frame和.eh_framehdr。.eh_frame部分包含一个或多个以DWARF调试信息格式编码的调用帧信息记录。每个帧信息记录包含一个公共信息条目(CIE)记录,后跟一个或多个帧描述符条目(FDE)记录。它们一起描述了如何根据当前指令指针展开调用者。更多详细信息在Linux标准基础文档中描述。
语言特定级别
语言特定数据区域(LSDA)包含指向相关数据的指针、调用站点列表和操作记录列表。每个函数都有自己的LSDA,作为帧描述符条目(FDE)的增强数据提供。来自LSDA的信息对于恢复C++异常信息以及将其转换为LLVM语义至关重要。
图2: 来自当前指令指针(IP)的异常处理信息。图形改编自链接的原始内容。
LSDA头描述了异常信息如何应用于语言特定的过程片段。图4更详细地显示了LSDA。LSDA头中定义了两个McSema需要恢复异常信息的字段:
- landing pad起始指针: landing pad代码起始的相对偏移量
- 类型表指针: 类型表的相对偏移量,该表描述了此过程片段的catch子句处理的异常类型
在LSDA头之后,调用站点表列出了所有可能抛出异常的调用站点。调用站点表中的每个条目指示调用站点的位置、landing pad的位置以及该调用站点的第一个操作记录。调用站点表中缺少的条目表示调用不应抛出异常。此表中的信息将在转换阶段由McSema使用,为可能抛出异常的调用站点发出适当的LLVM语义。
操作表在LSDA中跟随调用站点表,并指定catch子句和异常规范。这里的异常规范是指备受诟病的C++功能"异常规范",它枚举了函数可能抛出的异常。两种记录类型具有相同的格式,仅通过每个条目的第一个字段区分。此字段的正值指定在catch子句中使用的类型。负值指定异常规范。图3显示了带有catch子句(红色)、捕获所有子句(橙色)、异常规范(蓝色)的操作表。(异常规范功能在C++17中已被弃用。)由于此功能已被弃用且很少使用,目前McSema不处理异常规范。
|
|
图3: LSDA部分中的操作表条目
提升异常信息
到目前为止,我们已经研究了C++异常在低级别的工作原理,异常信息如何存储,以及McSema如何恢复基于异常的控制流。现在我们将看看McSema如何将此控制流提升到LLVM。
要提升异常信息,必须从二进制文件中恢复上一节中描述的异常和语言语义,并将其转换为LLVM。恢复和转换是一个三阶段过程,需要更新McSema的控制流图(CFG)恢复、提升和运行时组件。
McSema的转换阶段使用从CFG恢复中收集的信息来生成处理异常语义的LLVM IR。为确保最终二进制文件像原始文件一样执行,必须发生以下步骤:
- McSema必须将异常处理程序和清理方法与引发异常的块关联起来。抛出异常的函数必须通过LLVM的invoke指令而不是call指令调用。
- 必须为引发异常的函数片段启用栈展开。这很复杂,因为转换后的代码可能有两个栈:一个本机栈(用于调用外部API)和一个提升栈。
- McSema必须确保提升代码和语言运行时之间有平滑的过渡。由语言运行时直接调用的处理程序必须将处理器状态序列化为提升代码期望的结构。
关联块和处理程序
可能抛出异常的块与这些异常的处理程序之间的初始关联是在CFG恢复期间通过从异常表中提取的信息执行的。需要这种关联,因为转换器必须确保可能抛出异常的函数通过LLVM的invoke语义调用,而不是典型的call指令。invoke指令有两个延续点:调用成功时的正常流和函数引发异常时的异常流(即异常处理程序)(图4)。用invoke替换call必须覆盖该函数的每次调用。函数的任何调用都会使优化器相信该函数不会抛出异常,不需要异常表。
|
|
图4: invoke指令替换了对可能抛出异常的函数的call指令
栈展开
当异常发生时,控制从throw语句转移到第一个可以处理异常的catch语句。在转移之前,必须正确销毁函数作用域中定义的变量。这称为栈展开。
McSema使用两个不同的栈:一个用于提升代码,一个用于本机代码(即外部函数)。分离的栈对栈展开有限制,因为本机执行(即libstdc++ API)没有完整的栈视图。为了支持栈展开,我们添加了一个新标志–abi-libraries,它允许提升和本机代码执行使用相同的栈。
–abi-libraries标志通过消除提升代码到本机转换的需要,允许本机和提升代码使用相同的栈。McSema需要转换栈,以便不了解McSema的外部函数可以看到原始程序中的CPU状态。应用程序二进制接口(ABI)库提供外部函数签名,包括返回值、参数类型和参数计数,允许提升代码在同一栈上直接调用本机函数。图5显示了通过ABI库定义的函数签名快照。
|
|
图5: 定义与异常相关的外部函数的ABI库
运行时异常处理
异常处理程序和清理方法由语言运行时调用,并期望遵循严格的调用约定。提升代码不遵循标准调用约定语义,因为它将原始指令表示为对CPU状态的操作。为了支持这些回调,我们实现了一个特殊的适配器,将本机状态转换为提升代码可用的机器上下文。特别注意保留RDX寄存器,该寄存器存储异常的类型索引。
发出功能异常处理程序还有一个技巧:正确排序类型索引。回想一下我们的动机示例(图1)有三个异常处理程序:std::out_of_range、std::bad_alloc和捕获所有处理程序。这些处理程序中的每一个都被分配了一个类型索引,比如分别为1、2、3(图6a),这意味着原始程序期望类型索引1对应于std::out_of_range。
|
|
图6(a & b): 原始和新二进制文件的异常表中的类型索引分配(std::out_of_range、std::bad_alloc和捕获所有异常类型分别分配类型索引1、2和3)
在提升过程中,McSema重新创建程序中使用的异常处理程序。分配给每个处理程序的类型索引在编译时生成。当提升的位码编译