McSema如何实现C++异常处理:二进制提升的技术突破

本文深入解析McSema如何解决C++异常处理的二进制提升难题,涵盖异常表解析、控制流恢复、LLVM语义转换等核心技术,展示唯一能正确处理异常控制流的二进制提升工具的实现细节。

How McSema Handles C++ Exceptions - The Trail of Bits Blog

How McSema Handles C++ Exceptions

Akshay Kumar
January 21, 2019
compilers, mcsema

使用异常的C++程序对二进制提升器来说是个难题。C++源代码中出现的非局部控制流"throw"和"catch"操作无法直接映射到简单的二进制表示。有人可能会说编译器、运行时和栈展开库共同协作才能使异常正常工作。我们最近完成了对异常的研究,可以毫无疑问地声称McSema是唯一能正确提升带有基于异常的控制流程序的二进制提升器。

我们在McSema上的工作必须弥合程序高级语言语义与其二进制表示之间的语义鸿沟,这需要完全理解异常在底层的运作方式。本文分为三个部分:首先,我们将解释在Linux x86-64架构下如何处理C++异常,并解释核心异常处理概念;其次,我们将展示如何利用这些知识在二进制级别恢复异常信息;最后,我们将解释如何为LLVM生态系统生成异常信息。

C++异常处理简要介绍

在本节中,我们将使用一个小型示例来演示C++异常在二进制级别的运作方式,并讨论在x86-64处理器上运行的Linux程序的异常语义。虽然异常在不同的操作系统、处理器和语言中的工作方式不同,但许多核心概念是相同的。

异常是一种编程语言构造,提供了一种标准化处理异常或错误情况的方式。当此类事件发生时,它们通过自动将执行流重定向到称为异常处理程序的特殊处理程序来工作。使用异常,可以明确操作可能失败的方式以及应如何处理这些失败。例如,对象实例化和文件处理等操作可能以多种方式失败。异常处理允许程序员以大块代码的通用方式处理这些失败,而不是手动验证每个单独的操作。

异常是C++的核心部分,尽管它们的使用是可选的。可能失败的代码被包围在try {…}块中,可能引发的异常通过catch {…}块捕获。异常条件的信号通过throw关键字触发,该关键字引发特定类型的异常。图1显示了一个使用C++异常语义的简单程序。尝试自己构建程序(clang++ -o exception exception.cpp)或在Compiler Explorer中查看代码。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <iostream>
#include <vector>
#include <stdexcept>

int main(int argc, const char *argv[]) {
  std::vector myVector(10);
  int var = std::atoi(argv[1]);

  try {
    if (var == 0) {
      throw std::runtime_error("Runtime error: argv[1] cannot be zero.");
    }

    if (argc != 2) {
      throw std::out_of_range("Supply one argument.");
    }

    myVector.at(var) = 100;

    while(true) {
      new int [100000000ul];
    }
  } catch (const std::out_of_range& e) {
    std::cerr << "Out of range error: " << e.what() << '\n';
    return 1;
  } catch (const std::bad_alloc& e) {
    std::cerr << "Allocation failed: " << e.what() << '\n';
    return 1;
  } catch (...) {
    std::cerr << "Unknown error.\n";
    return 1;
  }

  return 0;
}

图1: 抛出和捕获异常的示例C++程序,包括捕获所有子句。

这个简单程序可以根据输入参数显式抛出std::runtime_error和std::out_of_range异常。当内存耗尽时,它还会隐式抛出std::bad_alloc异常。程序安装了三个异常处理程序:一个用于std::out_of_range,一个用于std::bad_alloc,以及一个用于通用未知异常的捕获所有处理程序。运行以下示例输入以触发三种异常情况:

场景1: ./exception 0
未知错误。
程序检查输入参数,不期望输入为"0",并抛出std::runtime_error异常。

场景2: ./exception 0 1
超出范围错误:vector::_M_range_check
程序期望一个输入参数并检查它。如果输入参数多于一个,则抛出std::out_of_range异常。

场景3: ./exception 1
分配失败:std::bad_alloc
对于除"0"之外的输入,程序执行可能失败的大内存分配。这可能发生在运行时且可能被忽略。在这种情况下,内存分配器抛出std::bad_alloc异常以安全终止程序。

让我们在二进制级别查看相同的程序。Compiler Explorer显示编译器为此程序生成的二进制代码。编译器将throw语句转换为一对libstdc++函数调用(__cxa_allocate_exception和__cxa_throw),这些函数分配异常结构并开始清理导致异常栈展开的作用域中的本地对象的过程(参见Compiler Explorer中的第40-48行)。

栈展开: 从进程栈中移除已退出函数的栈帧。

catch语句被转换为处理异常并执行清理操作的函数,称为landingpad。编译器生成一个异常表,将操作系统分发异常所需的一切联系在一起,包括异常类型、关联的着陆垫和各种实用函数。

landingpad: 旨在捕获异常的用户代码。它通过个性函数从异常运行时获得控制权,并要么合并到正常用户代码中,要么通过恢复或引发新异常返回到运行时。

当异常发生时,栈展开器清理先前分配的变量并调用catch块。展开器:

  1. 调用libstdc++个性函数。首先,栈展开器调用libstdc++提供的一个特殊函数,称为个性函数。个性函数将确定引发的异常是否由调用栈上某处的函数处理。用高级术语来说,个性函数确定是否有应该为此异常调用的catch块。如果找不到处理程序(即异常未处理),个性函数通过调用std::terminate终止程序。

  2. 清理分配的对象。为了干净地调用catch块,展开器必须首先清理(即为try块内调用的每个函数调用每个分配对象的析构函数)。展开器将遍历调用栈,使用个性函数为每个栈帧标识清理方法。如果有任何清理操作,展开器调用关联的清理代码。

  3. 执行catch块。最终展开器将到达包含异常处理程序的函数的栈帧,并执行catch块。

  4. 释放内存。一旦catch块完成,将再次调用清理函数以释放为异常结构分配的内存。

对于好奇的读者,更多信息可在libgcc栈展开器的注释和源代码中找到。

个性函数: 由栈展开器调用的libstdc++函数。它确定是否有用于引发异常的catch块。如果未找到,程序以std::terminate终止。

恢复异常信息

对于像McSema这样的二进制分析工具来说,恢复基于异常的控制流是一个具有挑战性的命题。基本数据难以组装,因为异常信息分散在整个二进制文件中,并通过多个表联系在一起。利用异常数据恢复控制流很困难,因为影响流的操作,如栈展开、对个性函数的调用和异常表解码,发生在编译程序的范围之外。

以下是最终目标的快速总结。McSema必须识别每个可能引发异常的基本块(即try块的内容)并将其与适当的异常处理程序和清理代码(即catch块或着陆垫)关联。然后将使用此关联在LLVM级别重新生成异常处理程序。为了将块与着陆垫关联,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需要恢复异常信息的字段:

  • 着陆垫起始指针: 指向着陆垫代码起始的相对偏移量。
  • 类型表指针: 指向类型表的相对偏移量,该表描述了此过程片段的catch子句处理的异常类型。

在LSDA头之后,调用站点表列出了所有可能抛出异常的调用站点。调用站点表中的每个条目指示调用站点的位置、着陆垫的位置以及该调用站点的第一个操作记录。调用站点表中缺少的条目表示调用不应抛出异常。此表中的信息将在转换阶段由McSema使用,为可能抛出异常的调用站点发出适当的LLVM语义。

操作表在LSDA中跟随调用站点表,并指定catch子句和异常规范。这里的异常规范是指备受诟病的C++功能"异常规范",它枚举了函数可能抛出的异常。两种记录类型具有相同的格式,仅通过每个条目的第一个字段区分。此字段的正值指定在catch子句中使用的类型。负值指定异常规范。图3显示了带有catch子句(红色)、捕获所有子句(橙色)和异常规范(蓝色)的操作表。(异常规范功能在C++17中已被弃用。)由于此功能已被弃用且很少使用,目前McSema不处理异常规范。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
.gcc_except_table:4022CF db 7Fh; ar_filter[1]: -1(exception spec index = 4022EC)
.gcc_except_table:4022D0 db 0  ; ar_next[1]: 0 (end)
.gcc_except_table:4022D1 db 0  ; ar_filter[2]: 0 (cleanup)
.gcc_except_table:4022D2 db 7Dh; ar_next[2]: -3 (next: 1 => 004022CF)
.gcc_except_table:4022D3 db 4  ; ar_filter[3]: 4 (catch typeinfo = 000000)
.gcc_except_table:4022D4 db 0  ; ar_next[3]: 0 (end)
.gcc_except_table:4022D5 db 1  ; ar_filter[4]: 1 (catch typeinfo = 00603280)
.gcc_except_table:4022D6 db 7Dh; ar_next[4]: -3 (next: 3 => 004022D3)
.gcc_except_table:4022D7 db 3  ; ar_filter[5]: 3 (catch typeinfo = 603230)
.gcc_except_table:4022D8 db 7Dh; ar_next[5]: -3 (next: 4 => 004022D5)

图3: LSDA部分中的操作表条目

提升异常信息

到目前为止,我们已经研究了C++异常在低级别如何工作,异常信息如何存储,以及McSema如何恢复基于异常的控制流。现在我们将看看McSema如何将此控制流提升到LLVM。

为了提升异常信息,必须从二进制文件中恢复上一节中描述的异常和语言语义,并将其转换为LLVM。恢复和转换是一个三阶段过程,需要更新McSema的控制流图(CFG)恢复、提升和运行时组件。

McSema的转换阶段使用从CFG恢复中收集的信息生成处理异常语义的LLVM IR。为确保最终二进制文件像原始文件一样执行,必须发生以下步骤:

  1. McSema必须将异常处理程序和清理方法与引发异常的块关联。必须通过LLVM的invoke指令而不是call指令调用抛出异常的函数。
  2. 必须为引发异常的函数片段启用栈展开。这很复杂,因为转换后的代码可能有两个栈:一个本机栈(用于调用外部API)和一个提升栈。
  3. McSema必须确保提升代码和语言运行时之间有平滑的过渡。由语言运行时直接调用的处理程序必须将处理器状态序列化为提升代码期望的结构。

关联块和处理程序

可能抛出异常的块与这些异常的处理程序之间的初始关联在CFG恢复期间执行,通过从异常表中提取的信息。这种关联是必需的,因为转换器必须确保可能抛出异常的函数通过LLVM的invoke语义调用,而不是典型的call指令。invoke指令有两个延续点:调用成功时的正常流和函数引发异常时的异常流(即异常处理程序)(图4)。用invoke替换call必须覆盖该函数的每次调用。函数的任何调用都会使优化器相信该函数不会抛出异常,不需要异常表。

1
2
3
4
5
6
7
8
9
%1403 = call i64 @__mcsema_get_stack_pointer()
store i64 %1403, i64* %stack_ptr_var
%1404 = call i64 @__mcsema_get_frame_pointer()
store i64 %1404, i64* %frame_ptr_var
%1405 = load %struct.Memory*, %struct.Memory** %MEMORY
%1406 = load i64, i64* %PC
%1407 = invoke %struct.Memory* @ext_6032a0__Znam(%struct.State* %0,
i64 %1406, %struct.Memory* %1405)
to label %block_40119f unwind label %landingpad_4012615

图4: 用invoke指令替换可能抛出异常的函数的call指令

栈展开

当异常发生时,控制从throw语句转移到可以处理异常的第一个catch语句。在转移之前,必须正确销毁函数作用域中定义的变量。这称为栈展开。

McSema使用两个不同的栈:一个用于提升代码,一个用于本机代码(即外部函数)。分离的栈对栈展开有限制,因为本机执行(即libstdc++ API)没有完整的栈视图。为了支持栈展开,我们添加了一个新标志–abi-libraries,它允许提升和本机代码执行使用相同的栈。

–abi-libraries标志通过消除提升代码到本机转换的需要,允许本机和提升代码使用相同的栈。McSema需要转换栈,以便不了解McSema的外部函数可以看到原始程序中的CPU状态。应用程序二进制接口(ABI)库提供外部函数签名,包括返回值、参数类型和参数计数,允许提升代码在同一栈上直接调用本机函数。图5显示了通过ABI库定义的函数签名快照。

1
2
3
4
5
6
7
8
declare i8* @__cxa_allocate_exception(i64) #0
declare void @__cxa_free_exception(i8*) #0
declare i8* @__cxa_allocate_dependent_exception() #0
declare void @__cxa_free_dependent_exception(i8*) #0
declare void @__cxa_throw(i8*, %"class.std::type_info"*, void (i8*)*) #0
declare i8* @__cxa_get_exception_ptr(i8*) #0
declare i8* @__cxa_begin_catch(i8*) #0
declare void @__cxa_end_catch() #0

图5: 定义与异常相关的外部函数的ABI库。

运行时异常处理

异常处理程序和清理方法由语言运行时调用,并期望遵循严格的调用约定。提升代码不遵循标准调用约定语义,因为它将原始指令表示为CPU状态上的操作。为了支持这些回调,我们实现了一个特殊的适配器,将本机状态转换为提升代码可用的机器上下文。特别注意保留RDX寄存器,该寄存器存储异常的类型索引。

发出功能异常处理程序还有一个技巧:正确排序类型索引。回想一下我们的动机示例(图1)有三个异常处理程序:std::out_of_range、std::bad_alloc和捕获所有处理程序。这些处理程序中的每一个都被分配了一个类型索引,比如分别为1、2、3(图6a),这意味着原始程序期望类型索引1对应于std::out_of_range。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
.gcc_except_table:402254 db 3      ; ar_filter[1]: 3 (catch typeinfo = 000000)
.gcc_except_table:402255 db 0      ; ar_next[1]: 0 (end)
.gcc_except_table:402256 db 2      ; ar_filter[2]: 2 (catch typeinfo = 603280)
.gcc_except_table:402257 db 7Dh    ; ar_next[2]: -3 (next: 1 => 402254)
.gcc_except_table:402258 db 1      ; ar_filter[3]: 1 (catch typeinfo = 603230)
.gcc_except_table:402259 db 7Dh    ; ar_next[3]: -3 (next: 2 => 402256)
.gcc_except_table:40225A db 0
.gcc_except_table:40225B db 0
.gcc_except_table:40225C dd 0      ; Type index 3
.gcc_except_table:402260 dd 603280h; Type index 2
.gcc_except_table:402264 dd 603230h; Type index 1
1
2
3
4
5
6
7
.gcc_except_table:41A78E db 1      ; ar_filter[1]: 1 (catch typeinfo = 000000)
.gcc_except_table:41A78F db 0      ; ar_next[1]: 0 (end)
.gcc_except_table:41A790 db 2      ; ar_filter[2]: 2 (catch typeinfo = 61B450)
.gcc_except_table:41A791 db 7Dh    ; ar_next[2]: -3 (next: 1 => 0041A78E)
.gcc_except_table:41A792 db 3      ; ar_filter[3]: 3 (catch typeinfo = 61B4A0)
.gcc_except_table:41A793 db 7Dh    ; ar_next[3]: -3 (next: 2 => 0041A790)
.gcc_except_table:41A794 dd 61B4A
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计