C++ 安全缓冲区

介绍

Clang 可用于强化您的 C++ 代码以防止缓冲区溢出,这在基于 C 的语言中是一个常见安全问题。

本文档中描述的解决方案是一个集成的编程模型,因为它结合了

  • 一组可选的 Clang 警告(-Wunsafe-buffer-usage),在编译期间发出这些警告,以帮助您更新代码以封装和传播与指针相关的边界信息;

  • 作为 (libc++ 强化模式) 的一部分实现的运行时断言,只要遵循编码约定并且边界信息因此可用且正确,就可以消除未定义的行为。

这项工作的目标是使开发边界安全的 C++ 代码成为可能。这不是一个“一键式”解决方案;根据您代码库的现有编码风格,您可能需要对代码进行重大(即使主要是机械的)更改。但是,它允许您在代码库的关键安全部分实现有价值的安全保证。

此解决方案正在积极开发中。它已经可以有效地用于其目的,但更多工作正在进行中,以提高人体工程学和安全保证并降低采用成本。

此解决方案在精神上与 Bjarne Stroustrup 提议 的与其他 C++ 安全功能一起标准化的“范围”安全配置文件相一致。

先决条件

为了实现边界安全,您的代码库需要访问经过良好封装的边界安全容器、视图和迭代器类型。如果您的项目使用 libc++,标准容器和视图类型(例如 std::vectorstd::span)可以通过启用“快速” 强化模式(将 -D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_FAST 传递给您的编译器)或任何更严格的强化模式来使其边界安全。

为了强化迭代器,您还需要获得使用 _LIBCPP_ABI_BOUNDED_ITERATORS 构建的 libc++ 二进制文件——这是一个 libc++ ABI 设置,如果您需要与平台的其余部分保持二进制兼容性,则需要为整个目标平台设置此设置。

建议使用一个比较新的 C++ 版本。特别是,非常有用的标准视图类 std::span 需要 C++20。

C++ 标准库的其他实现可能提供不同的标志来启用此类强化。

如果您使用的是自定义容器和视图,则也需要以这种方式强化它们,但您不需要提前执行此操作。

理论上,此方法可以应用于普通的 C 代码库,假设开发了安全原语来封装所有缓冲区访问,充当“强化自定义容器”以替换原始指针。但是,这种方法在 C 中将非常不方便,并且由于缺乏良好的封装技术,安全保证会更低。目前正在开发一种针对非 C++ 程序的更好的边界安全方法,-fbounds-safety

从技术上讲,如果没有强化整个技术堆栈(包括您的所有依赖项),就不能提供安全保证。但是,即使将此类强化技术应用于代码库的一小部分也可能比什么都不做要好得多。

C++ 的编程模型

假设强化后的容器、视图和迭代器类可用,剩下的工作就是确保在您的代码中始终如一地使用它们。下面我们将定义为了保证安全性需要遵循的特定编码约定,以及围绕 -Wunsafe-buffer-usage 的编译器技术如何帮助实现这一点。

缓冲区操作绝不应在原始指针上执行

每次进行内存访问时,边界安全的程序都必须保证访问的内存地址范围落在为要访问的对象分配的内存边界内。为了建立这样的保证,每次进行内存访问时,必须正式提供有关此有效地址范围的信息——与访问地址相关的 **边界信息**。

原始指针天生不携带任何边界信息。指针的边界信息可能在 **某个地方** 可用,但它不是以正式的方式与指针相关联的,因此通过原始指针执行的内存访问不能由编译器自动验证为边界安全的。

也就是说,安全缓冲区编程模型 **不** 试图消除 **所有** 指针使用。相反,它假设大多数指针指向单个对象,而不是缓冲区,因此它们通常与缓冲区溢出风险无关。为此,为了识别需要人工干预的代码,最好最初将重点从指针本身转移,而是关注它们的 **使用模式**。

编译器警告 -Wunsafe-buffer-usage 用于帮助您完成此过程。每当对原始指针执行以下 **缓冲区操作** 之一时,就会发出 -Wunsafe-buffer-usage 警告

  • 使用 [] 的数组索引,

  • 指针算术,

  • 边界不安全的标准 C 函数,例如 std::memcpy()

  • C++ 智能指针操作,例如 std::unique_ptr<T[N]>::operator[](),不幸的是,在 C++ 标准(截至 C++23)的规则范围内无法使其完全安全。

这足以在您整个软件堆栈中识别程序中 **至少在一个点** 期间其生命周期的每个原始缓冲区指针。

例如,以下两个函数都由 -Wunsafe-buffer-usage 标记,因为 pointer 被识别为不安全的缓冲区指针。即使第二个函数没有直接访问缓冲区,但其中的指针算术操作也可能是程序中唯一正式的“提示”,表明该指针确实指向一个包含多个对象的缓冲区

int get_last_element(int *pointer, size_t size) {
  return ptr[sz - 1]; // warning: unsafe buffer access
}

int *get_last_element_ptr(int *pointer, size_t size) {
  return ptr + (size - 1); // warning: unsafe pointer arithmetic
}

所有缓冲区都需要封装到安全容器和视图类型中

从前面的要求中可以立即得出,一旦在原始指针生命周期的任何点识别到不安全的指针,就应该立即将其包装到安全容器类型中(如果分配站点“附近”)或安全视图类型中(如果分配站点“远离”)。不仅内存访问,而且指针算术等非访问操作也需要以这种方式进行处理,以便从相应的运行时边界检查中获益。

如果使用 **容器** 类型(std::arraystd::vectorstd::string)分配缓冲区,那么这是最佳情况,因为容器自然可以访问缓冲区的正确边界信息,并且运行时边界检查会立即启动。此外,容器类型可能为缓冲区提供自动生命周期管理(这可能需要也可能不需要)。

如果使用 **视图** 类型(std::spanstd::string_view),这通常意味着需要将“采用”指针的边界信息手动传递给视图的构造函数。这使得运行时检查立即相对于提供的边界信息启动,这比原始指针立即有了改进。但是,这种情况在本质上仍然不足以确保安全,因为 **以这种方式提供的边界信息不能保证是正确的**。

例如,我们之前在上一节中看到的函数 get_last_element() 可以用这种方式使其 **略微** 更安全

int get_last_element(int *pointer, size_t size) {
  std::span<int> sp(pointer, size);
  return sp[size - 1]; // warning addressed
}

这里 std::span 消除了潜在的担忧,即当 sz 等于 0 时,操作 size - 1 可能会溢出,从而导致缓冲区“下溢”。但是,此程序不能保证变量 sz 正确地表示 ptr 所指向的缓冲区的 **实际** 大小。以这种方式构造的 std::span 可能是无效的。它可能无法保护您免受溢出原始缓冲区。

以下示例演示了这种最危险的反模式之一

void convert_data(int *source_buf, size_t source_size,
                  int *target_buf, size_t target_size) {
  // Terrible: mismatched pointer / size.
  std::span<int> target_span(target_buf, source_size);
  // ...
}

std::span 的第二个参数不应是缓冲区的 **期望** 大小。它应该始终是缓冲区的 **实际** 大小。此类代码通常表明原始代码已经包含漏洞——并且使用安全视图类未能阻止它。

如果 target_span 实际上需要的大小为 source_size,则生成此类跨度的更安全的方法是首先使用正确的大小构建它,然后通过调用 .first() 将其调整为所需大小。

void convert_data(int *source_buf, size_t source_size,
                  int *target_buf, size_t target_size) {
  // Safer.
  std::span<int> target_span(target_buf, target_size).first(source_size);
  // ...
}

但是,这些仍然是权宜之计。此代码仍然以**非正式**方式从调用者处接受边界信息,而无法保证此类边界信息正确。

为了完全缓解此类问题,实施了第三个准则。

必须持续遵守边界信息的封装

对象的分配位置是该对象边界信息的唯一可靠来源。对于在软件堆栈中的多个函数甚至库中具有较长生命周期的对象,必须正式保留原始边界信息,因为它从一段代码传递到另一段代码。

标准容器和视图类旨在**通过构造**正确保留边界信息。但是,它们提供了一些“破坏”封装的方法,这可能会导致您暂时丢失对正确边界信息的跟踪。

  • 双参数构造函数 std::span(ptr, size) 允许您组装一个格式错误的 std::span

  • 相反,您可以通过调用其 .data().size() 方法,将容器或视图对象解包为原始指针和原始大小。

  • 在容器和迭代器类上找到的重载 operator&() 在这方面类似于 .data();诸如 &span[0]&*span.begin() 之类的操作实际上是不安全的。

当以这种方式破坏**标准**容器的封装时,会发出额外的 -Wunsafe-buffer-usage 警告。如果您使用的是非标准容器,则可以使用下一节中描述的工具来实现类似的效果:向后兼容性、与不安全代码的互操作性、自定义.

例如,我们之前尝试在 get_last_element() 中解决警告实际上已经引入了新的警告,该警告会提醒您注意传递到 std::span 的双参数构造函数中的潜在不正确的边界信息。

int get_last_element(int *pointer, size_t size) {
  std::span<int> sp(pointer, size); // warning: unsafe constructor
  return sp[size - 1];
}

为了解决此警告,您需要以正式方式从分配位置接收边界信息。该函数不一定需要知道分配位置在哪里;它只需要能够在**可用**时接受边界信息。您可以通过重构函数以接受 std::span 作为参数来实现这一点。

int get_last_element(std::span<int> sp) {
  return sp[size - 1];
}

此解决方案将确保跨度格式正确的责任放在**调用者**身上。他们应该做同样的事情,这样最终责任就会放在分配位置!

这样的定义也非常符合人体工程学,因为它自然地接受任意标准容器,而无需在调用站点进行任何额外的代码。

void use_last_element() {
  std::vector<int> vec { 1, 2, 3 };
  int x = get_last_element(vec);  // x = 3
}

此类代码天生边界安全,因为边界信息从分配位置传递到缓冲区访问位置。仅对容器类型执行安全操作。容器永远不会“伪造”成原始指针-大小对,也不会再“伪造”。这就是理想的边界安全 C++ 代码的样子。

向后兼容性、与不安全代码的互操作性、自定义

上面描述的一些代码更改可能有些侵入性。例如,将以前分别接受指针和大小的函数更改为接受 std::span,可能需要您更新函数的每个调用站点。这通常不可取,并且在需要向后兼容性时完全不可接受。

为了促进上面描述的编码规范的**逐步采用**,以及处理各种不寻常的情况,编译器提供了另外两个工具来让用户更好地控制 -Wunsafe-buffer-usage 诊断。

  • #pragma clang unsafe_buffer_usage 用于将代码标记为不安全并**抑制**该代码中的 -Wunsafe-buffer-usage 警告。

  • [[clang::unsafe_buffer_usage]] 用于注释边界信息不连续性的潜在来源 - 从而引入**更多** -Wunsafe-buffer-usage 警告。

在本节中,我们将详细描述这些工具,并展示它们如何帮助您处理各种不寻常的情况。

使用 #pragma clang unsafe_buffer_usage 抑制不需要的警告

如果您确实需要编写不安全的代码,则始终可以通过将 unsafe_buffer_usage 编译指示包围该代码,来抑制代码段中的所有 -Wunsafe-buffer-usage 警告。例如,如果您不想解决示例函数 get_last_element() 中的警告,则可以使用以下方法抑制它。

int get_last_element(int *pointer, size_t size) {
  #pragma clang unsafe_buffer_usage begin
  return ptr[sz - 1]; // warning suppressed
  #pragma clang unsafe_buffer_usage end
}

此行为类似于 #pragma clang diagnostic (文档) 但是,出于技术和非技术原因,建议使用 #pragma clang unsafe_buffer_usage 而不是 #pragma clang diagnostic。最重要的是,#pragma clang unsafe_buffer_usage 更适合安全审计,因为它更简单,并且以更正式的方式描述不安全的代码。相反,#pragma clang diagnostic 带有推送/弹出语法(与开始/结束语法相反),并且它提供了在不提及警告名称(例如 -Weverything)的情况下抑制警告的方法,这可能使得难以一目了然地确定在给定代码行上是否抑制了警告。

使用此编译指示有一些自然原因。

  • 在安全自定义容器的实现中。您需要这样做,因为最终 -Wunsafe-buffer-usage 无法帮助您验证您的自定义容器是否安全。它会自然地提醒您审核容器的实现,以确保它具有所有必要的运行时检查,但最终您需要在审核完成后抑制它。

  • 在边界安全相关运行时检查会导致不可接受的性能下降的性能关键代码中。编译器理论上可以将它们优化掉(例如,用循环之前的单次检查替换循环中的重复边界检查),但不能保证这样做。

  • 出于逐步采用目的。如果您想逐步采用编码规范,则始终可以使用 unsafe_buffer_usage 编译指示包围整个文件,然后在解决特定代码部分的警告时在其内“打洞”。

  • 在与不安全代码交互的代码中。这可能是永远不会遵循编程模型的代码(例如,永远不会转换为 C++ 的纯 C 代码)或尚未转换的代码。

与不安全代码的交互可能需要大量抑制。鼓励您为需要定期执行的各种不安全操作引入“不安全包装函数”。

例如,如果您经常从不安全代码接收指针/大小对,则可能需要为不安全的跨度构造函数引入一个包装函数。

#pragma clang unsafe_buffer_usage begin

template <typename T>
std::span<T> unsafe_forge_span(T *pointer, size_t size) {
  return std::span(pointer, size);
}

#pragma clang unsafe_buffer_usage end

此类包装函数可用于以更符合人体工程学的方式抑制关于不安全的跨度构造函数使用的警告。

void use_unsafe_c_struct(unsafe_c_struct *s) {
  // No warning here.
  std::span<int> sp = unsafe_forge_span(s->pointer, s->size);
  // ...
}

代码仍然不安全,但也仍然易于阅读,并且证明了 -Wunsafe-buffer-usage 已尽力通知您潜在的不安全性。安全审计员需要关注此类不安全的包装器。**仍然由您来确认传递到包装器中的边界信息是否正确。**

使用 [[clang::unsafe_buffer_usage]] 标记边界信息不连续性

clang 属性 [[clang::unsafe_buffer_usage]] (属性文档) 允许用户将各种对象(如函数或成员变量)注释为与安全缓冲区编程模型不兼容。鼓励您出于任意原因这样做,但通常主要原因是当需要提供不安全的函数以确保向后兼容性时。

例如,在上一节中,我们已经看到示例函数 get_last_element() 需要更改其参数类型,以便在从调用者接收缓冲区指针时保留边界信息的连续性。但是,此类更改会破坏 API 和 ABI 兼容性。在更新该函数的每个调用站点之前,以前使用此函数的代码将无法编译或链接。您可以通过添加“兼容性重载”来恢复向后兼容性 - 在 API 和 ABI 方面。

int get_last_element(std::span<int> sp) {
  return sp[size - 1];
}

[[clang::unsafe_buffer_usage]] // Please use the new function.
int get_last_element(int *pointer, size_t size) {
  // Avoid code duplication - simply invoke the safe function!
  // The pragma suppresses the unsafe constructor warning.
  #pragma clang unsafe_buffer_usage begin
  return get_last_element(std::span(pointer, size));
  #pragma clang unsafe_buffer_usage end
}

此类重载允许周围的代码继续工作。它既源兼容又二进制兼容。它也比原始函数更安全,因为它使用安全的 std::span 访问替换了通过原始指针的unsafe 缓冲区访问,无论如何调用它。但是,因为它要求调用者分别传递指针和大小,所以它违反了我们的“边界信息连续性”原则。这意味着需要关注边界安全的调用者需要被鼓励使用基于 std::span 的重载。幸运的是,属性 [[clang::unsafe_buffer_usage]] 会在兼容性重载的每个调用站点显示 -Wunsafe-buffer-usage 警告,以提醒调用者更新其代码。

void use_last_element() {
  std::vector<int> vec { 1, 2, 3 };

  // no warning
  int x = get_last_element(vec);

  // warning: this overload introduces unsafe buffer manipulation
  int x = get_last_element(vec.data(), vec.size());
}

可以使用上一节中描述的 unsafe_forge_span() 包装器进一步简化兼容性重载 - 甚至使其不需要编译指示。

[[clang::unsafe_buffer_usage]] // Please use the new function.
int get_last_element(int *pointer, size_t size) {
  // Avoid code duplication - simply invoke the safe function!
  return get_last_element(unsafe_forge_span(pointer, size));
}

请注意,属性 [[clang::unsafe_buffer_usage]] 本身 **不会** 抑制函数内的警告。类似地,整个定义被 #pragma clang unsafe_buffer_usage 覆盖的函数 **不会** 自动被注释为属性 [[clang::unsafe_buffer_usage]]。它们服务于不同的目的。

  • pragma 表示函数的编写方式 **不安全**;

  • 属性表示函数的 **使用** 不安全。

另外请注意,我们为一个 **安全** 函数创建了一个 **不安全** 的包装器。这比为一个 **不安全** 函数创建一个 **安全** 的包装器要好得多。换句话说,以下解决方案比之前的解决方案要 **不安全** 和 **不可取** 得多。

int get_last_element(std::span<int> sp) {
  // You've just added that attribute, and now you need to
  // immediately suppress the warning that comes with it?
  #pragma clang unsafe_buffer_usage begin
  return get_last_element(sp.data(), sp.size());
  #pragma clang unsafe_buffer_usage end
}


[[clang::unsafe_buffer_usage]]
int get_last_element(int *pointer, size_t size) {
  // This access is still completely unchecked. What's the point of having
  // perfect bounds information if you aren't performing runtime checks?
  #pragma clang unsafe_buffer_usage begin
  return ptr[sz - 1];
  #pragma clang unsafe_buffer_usage end
}

**结构体和类** 与函数不同,不能重载。如果一个结构体包含一个不安全的缓冲区(以嵌套数组或指针/大小对的形式),则通常不可能用安全的容器(如 std::arraystd::span)替换它们,而不会破坏结构体的布局,并引入周围客户端代码的源代码和二进制不兼容性。

此外,类的成员变量不能自然地从客户端代码中 “隐藏”。如果一个类需要被还没有更新到 C++20 的客户端使用,则不能使用 C++20 特定的 std::span 作为成员变量类型。如果结构体的定义与直接操作成员变量的普通 C 代码共享,则不能对这些成员变量使用任何 C++ 特定的类型。

在这种情况下,通常没有向后兼容的方式直接使用安全类型。最好的选择通常是通过使用属性 [[clang::unsafe_buffer_usage]] 对成员变量进行注释来阻止客户端直接使用成员变量,然后更改类的接口以提供对不安全数据的安全 “访问器”。

例如,假设最坏的情况:struct foo 是一个不安全的结构体类型,完全定义在普通 C 代码和 C++ 代码之间共享的头文件中。

struct foo {
  int *pointer;
  size_t size;
};

在这种情况下,可以通过将成员变量注释为不安全并将它们封装到安全访问器方法中,在 C++ 代码中实现安全性。

struct foo {
  [[clang::unsafe_buffer_usage]]
  int *pointer;
  [[clang::unsafe_buffer_usage]]
  size_t size;

// Avoid showing this code to clients who are unable to digest it.
#if __cplusplus >= 202002L
  std::span<int> get_pointer_as_span() {
    #pragma clang unsafe_buffer_usage begin
    return std::span(pointer, size);
    #pragma clang unsafe_buffer_usage end
  }

  void set_pointer_from_span(std::span<int> sp) {
    #pragma clang unsafe_buffer_usage begin
    pointer = sp.data();
    size = sp.size();
    #pragma clang unsafe_buffer_usage end
  }

  // Potentially more utility functions.
#endif
};

未来工作

-Wunsafe-buffer-usage 技术正在积极开发中。该警告在很大程度上已准备好用于日常使用,但它正在不断改进,以减少不必要的噪音,以及涵盖一些更棘手的非安全操作。

用于 -Wunsafe-buffer-usage 的修复建议

一个代码转换工具正在开发中,它可以半自动地转换大量的代码,以遵循 C++ 安全缓冲区编程模型。目前可以通过传递实验标志 -fsafe-buffer-usage-suggestions 以及 -Wunsafe-buffer-usage 来访问它。

以这种方式产生的修复建议目前假设本文档中描述的默认方法,因为它们建议使用标准容器和视图(最显著的是 std::spanstd::array)来替换原始缓冲区指针。这还需要 libc++ 强化,以便使运行时边界检查实际发生。

静态分析以识别可疑的边界信息来源

不安全的构造函数 span(pointer, size) 在与不安全代码交互时通常是必要的。但是,将正确的边界信息传递给这样的构造函数通常很困难。为了检测这些 span(target_pointer, source_size) 反模式,clang 静态分析器 执行的路径敏感分析可以被教导识别指针和大小来自 “可疑的不同” 来源的情况。

这样的分析将能够比编译器更精确地识别信息来源,使其在识别代码中不正确的边界信息方面更有效,同时产生更少的警告。它还需要绕过 #pragma clang unsafe_buffer_usage 抑制,并 “看穿” 像 unsafe_forge_span 这样的不安全包装器 - 这是静态分析器自然能够做到的事情。