-fbounds-safety: 为 C 强制边界安全

概述

**注意:** 这是一个设计文档,此功能目前对用户不可用。有关更多详细信息,请参阅 对 -fbounds-safety 的实现计划

-fbounds-safety 是一个 C 扩展,用于强制边界安全以防止越界(OOB)内存访问,这仍然是 C 中安全漏洞的主要来源。 -fbounds-safety 旨在通过将 OOB 访问转换为确定性陷阱来消除此类错误。

-fbounds-safety 扩展提供边界注解,程序员可以使用这些注解将边界附加到指针。例如,程序员可以将 __counted_by(N) 注解添加到参数 ptr,指示该指针具有 N 个有效元素

void foo(int *__counted_by(N) ptr, size_t N);

使用此边界信息,编译器在每次指针解引用时插入边界检查,以确保程序不会访问指定边界之外的内存。编译器要求程序员提供足够的边界信息,以便在运行时或编译时检查访问 - 并且如果不能,它将拒绝代码。

-fbounds-safety 最重要的贡献在于它如何通过协调 ABI 边界处的边界注解与使用隐式宽指针(也称为“胖”指针)来减少程序员的注解负担,该指针在本地变量上携带边界信息,而无需注解。我们设计了这种模型,以便它在最大限度地减少采用工作量的情况下保持与 C 的 ABI 兼容性。

-fbounds-safety 扩展已在数百万行生产 C 代码中采用,并已证明可以在消费者操作系统环境中正常工作。该扩展旨在实现增量式采用 - 这是现实世界环境中的一个关键要求,在这种环境中,一次修改整个项目及其所有依赖项通常是不可能的。它还解决了使现有更安全 C 方言方法难以采用的多个其他实际挑战,提供了以下特性使其在实践中得到广泛采用

  • 它旨在保留应用程序二进制接口 (ABI)。

  • 它与普通 C 代码很好地互操作。

  • 它可以部分和增量式地采用,同时仍然提供安全优势。

  • 它是 C 的符合标准的扩展。

  • 因此,采用该扩展的源代码可以继续由不支持该扩展的工具链编译(警告:这仍然需要包含一个用于将边界注解宏定义为空的标头文件)。

  • 它的采用成本相对较低。

本文档讨论了 -fbounds-safety 的关键设计。该文档可能会随着更详细规范的发布而不断更新。

编程模型

概述

-fbounds-safety 通过执行边界检查来确保指针不会用于访问其边界之外的内存。如果边界检查失败,程序将在访问越界内存之前确定性地陷入陷阱。

在我们的模型中,每个指针都具有显式或隐式边界属性,该属性决定其边界并确保保证边界检查。考虑下面的示例,其中 __counted_by(count) 注解指示参数 p 指向包含 count 个元素的整数缓冲区。循环条件中存在一个越界错误,导致循环的最后一次迭代期间 p[i] 发生越界访问。编译器在解引用 p 之前插入一个边界检查,以确保访问保持在指定的边界内。

void fill_array_with_indices(int *__counted_by(count) p, unsigned count) {
   // off-by-one error (i < count)
   for (unsigned i = 0; i <= count; ++i) {
      // bounds check inserted:
      //   if (i >= count) trap();
      p[i] = i;
   }
}

边界注解定义了指针类型的不可变性,该模型确保此不可变性始终为真。在下面的示例中,使用 __counted_by(count) 注解的指针 p 必须始终指向包含至少 count 个指向类型元素的内存缓冲区。更改 count 的值(如以下示例所示)可能会违反此不可变性,并允许对指针进行越界访问。为了避免这种情况,编译器使用编译时限制,并根据需要发出运行时检查以确保新的计数值不超过缓冲区的实际长度。维护边界注解的正确性 部分提供了有关此编程模型的更多详细信息。

int g;

void foo(int *__counted_by(count) p, size_t count) {
   count++; // may violate the invariant of __counted_by
   count--; // may violate the invariant of __counted_by if count was 0.
   count = g; // may violate the invariant of __counted_by
              // depending on the value of `g`.
}

用显式边界信息对所有指针进行注解的要求可能会带来巨大的采用负担。为了解决这个问题,该模型引入了“宽指针”(也称为胖指针)的概念 - 一个更大的指针,它除了指针值之外还携带边界信息。利用宽指针可以潜在地减少采用负担,因为它在内部包含边界信息,并且无需显式边界注解。但是,宽指针在其数据布局方面不同于标准 C 指针,这可能会导致与应用程序二进制接口 (ABI) 不兼容。破坏 ABI 会使与尚未采用相同编程模型的外部代码的互操作性变得复杂。

-fbounds-safety 调和了宽指针和边界注解方法,以减少采用负担,同时保持 ABI。在此模型中,指针类型的局部变量被隐式地视为宽指针,允许它们携带边界信息,而无需显式边界注解。请注意,此方法不适用于被视为 ABI 可见的函数参数。由于局部变量通常对 ABI 隐藏,因此这种方法对 ABI 的影响很小。此外,-fbounds-safety 使用编译时限制来防止隐式宽指针静默地破坏 ABI(请参阅 默认边界注解的 ABI 影响)。与任何其他变量(包括函数参数)关联的指针被视为单个对象指针(即 __single),确保它们始终具有最严格的边界,并提供强大的边界安全保证。

通过基于 ABI 可见性实现默认边界注解,很大一部分 C 代码可以在此编程模型中无需修改即可运行,从而减少了采用负担。

本节的其余部分将更详细地讨论各个边界注解和编程模型。

边界注解

指向单个对象的指针注解

C 语言允许对任意指针进行指针运算,这已成为许多边界安全问题的原因。在实践中,许多指针只是指向单个对象,并且对这种指针进行递增或递减会立即使指针越界。为了防止这种不安全性,-fbounds-safety 提供了注解 __single,该注解会使对带注解指针的指针运算成为编译时错误。

  • __single : 指示指针要么指向单个对象,要么为空。因此,具有 __single 的指针不允许指针运算,也不允许使用非零索引进行下标运算。解引用 __single 指针是允许的,但它需要进行空值检查。不需要进行上限和下限检查,因为 __single 指针应指向一个有效对象,除非它为空。

__single 是 ABI 可见指针的默认注解。这提供了强大的安全保证,因为这些指针不能被递增或递减,除非它们具有显式覆盖边界注解,可以用于验证操作的安全性。当 __single 指针用于指针运算或数组访问时,编译器会发出错误,因为这些操作会立即导致指针超过其边界。因此,这会促使程序员为指针提供足够的边界信息。在下面的示例中,参数 p 上的指针默认情况下是单一的,并且用于数组访问。因此,编译器会生成一个错误,建议向指针添加 __counted_by

void fill_array_with_indices(int *p, unsigned count) {
   for (unsigned i = 0; i < count; ++i) {
      p[i] = i; // error
   }
}

外部边界注解

“外部”边界注解提供了一种方法来表达指针变量与包含指针边界信息的另一个变量(或表达式)之间的关系。在下面的示例中,__counted_by(count) 注解使用另一个参数 count 表达参数 p 的边界。这种模型与许多 C 接口和结构体自然地协同工作,因为指针的边界通常与指针本身相邻,例如,在同一个函数原型中的另一个参数中,或者在同一个结构体声明中的另一个字段中。

void fill_array_with_indices(int *__counted_by(count) p, size_t count) {
   // off-by-one error
   for (size_t i = 0; i <= count; ++i)
      p[i] = i;
}

外部边界注解包括 __counted_by__sized_by__ended_by。这些注解不会更改指针表示形式,这意味着它们不会对 ABI 产生影响。

  • __counted_by(N) : 该指针指向包含 N 个指向类型元素的内存。 N 是一个整数类型的表达式,可以是简单的声明引用,包括对常量函数调用的常量,或者没有副作用的算术表达式。 __counted_by 注解不能应用于指向不完整类型或没有大小的类型的指针,例如 void *。 相反,可以使用 __sized_by 来描述字节数。

  • __sized_by(N) : 该指针指向包含 N 个字节的内存。 与 __counted_by 的参数一样,N 是一个整数类型的表达式,可以是常量,简单的声明引用,或者没有副作用的算术表达式。 这主要用于指向不完整类型或没有大小的类型的指针,例如 void *

  • __ended_by(P) : 该指针的值上限为 P,它指向指针最后一个元素的下一个元素。 换句话说,此注解描述了一个范围,从具有此注解的指针开始,到 P(注解的参数)结束。 P 本身可能被注释为 __ended_by(Q)。 在这种情况下,范围的结束扩展到指针 Q。 这是在 C 中用于“迭代器”支持的,您从一个指针值迭代到另一个指针值,直到到达最终指针值(最终指针值不可解引用)。

访问指定边界之外的指针会导致运行时陷阱或编译时错误。 此外,当指针和/或包含边界信息的相关值被更新或作为参数传递时,模型会维护边界注解的正确性。 这是通过编译时限制或运行时检查来完成的(有关详细信息,请参阅 维护边界注解的正确性)。 例如,将 buf 初始化为 null,同时将非零值分配给 count,如以下示例所示,将违反 __counted_by 注解,因为空指针不指向任何有效的内存位置。 为避免这种情况,编译器会产生编译时错误或运行时陷阱。

void null_with_count_10(int *__counted_by(count) buf, unsigned count) {
   buf = 0;
   // This is not allowed as it creates a null pointer with non-zero length
   count = 10;
}

但是,在某些情况下,指针要么为空指针,要么指向指定大小的内存。 为了支持这种习惯用法,-fbounds-safety 提供了 *_or_null 变体,__counted_by_or_null(N)__sized_by_or_null(N)__ended_by_or_null(P)。 访问具有任何这些边界注解的指针将需要额外的空检查以避免空指针解引用。

内部边界注解

宽指针(有时被称为“胖”指针)是一个指针,它在内部(作为其数据的一部分)携带额外的边界信息。 边界需要额外的存储空间,使宽指针比普通指针更大,因此被称为“宽指针”。 宽指针的内存布局等效于一个结构体,该结构体以指针、上限和(可选地)下限作为其字段,如下所示。

struct wide_pointer_datalayout {
   void* pointer; // Address used for dereferences and pointer arithmetic
   void* upper_bound; // Points one past the highest address that can be
                      // accessed
   void* lower_bound; // (Optional) Points to lowest address that can be
                      // accessed
};

即使有了这种表示上的变化,宽指针在语法上也与普通指针的行为相同,允许标准指针操作,例如指针解引用(*p)、数组下标(p[i])、成员访问(p->)和指针运算,对边界不安全的用法有一些限制。

-fbounds-safety 有一组“内部”边界注解,用于将指针转换为宽指针。 它们是 __bidi_indexable__indexable。 当指针具有这两个注解中的任何一个时,编译器会将指针更改为相应的宽指针。 这意味着这些注解会破坏 ABI 并且与普通 C 不兼容,因此通常不应在 ABI 表面使用它们。

  • __bidi_indexable : 带有此注解的指针将变为一个宽指针,用于携带上限和下限,其布局等效于 struct { T *ptr; T *upper_bound; T *lower_bound; };。 如其名称所示,具有此注解的指针是“双向可索引的”,这意味着它们可以用负偏移量或正偏移量索引,并且指针可以使用指针运算进行递增或递减。 __bidi_indexable 指针允许保存越界指针值。 虽然在 C 中创建 OOB 指针是未定义的行为,但 -fbounds-safety 使其成为定义良好的行为。 也就是说,使用 __bidi_indexable 的指针运算溢出被定义为等效于二进制补码整数计算,在 LLVM IR 级别,这意味着 getelementptr 不会获得 inbounds 关键字。 使用 OOB 指针访问内存将通过运行时边界检查来阻止。

  • __indexable : 带有此注解的指针将变为一个宽指针,用于携带上限(但没有显式下限),其布局等效于 struct { T *ptr; T *upper_bound; };。 由于 __indexable 指针没有单独的下限,因此指针值本身充当下限。 __indexable 指针只能在正方向递增或索引。 在负方向索引它将触发编译时错误。 否则,编译器会插入运行时检查以确保指针运算不会使指针小于原始 __indexable 指针(请注意,__indexable 没有下限,因此指针值实际上是下限)。 由于指针运算溢出将使指针小于原始指针,因此它将在运行时导致陷阱。 与 __bidi_indexable 类似,__indexable 指针允许具有高于上限的指针值,并且创建这样的指针是定义良好的行为。 但是,解引用这样的指针会导致运行时陷阱。

  • __bidi_indexable 在此模型中的所有指针注解中提供了最佳的灵活性,因为 __bidi_indexable 指针可用于任何指针操作。 但是,在该模型中可用的指针注解中,它的代码大小和内存成本最大。 在某些情况下,使用 __bidi_indexable 注解可能会复制程序中其他地方存在的边界信息。 在这种情况下,使用外部边界注解可能是更好的选择。

__bidi_indexable 是对非 ABI 可见指针的默认注解,例如局部指针变量——也就是说,如果程序员没有指定其他边界注解,则局部指针变量隐式地为 __bidi_indexable。 由于 __bidi_indexable 指针自动携带边界信息,并且对可以与这些指针一起使用的指针操作类型没有限制,因此函数内部的大多数代码无需修改即可按原样工作。 在下面的示例中,int *buf 不需要手动注解,因为它隐式地为 int *__bidi_indexable buf,它携带从 malloc 的返回值传递的边界信息,这对于为 buf[i] 插入边界检查是必要的。

void *__sized_by(size) malloc(size_t size);

int *__counted_by(n) get_array_with_0_to_n_1(size_t n) {
   int *buf = malloc(sizeof(int) * n);
   for (size_t i = 0; i < n; ++i)
      buf[i] = i;
   return buf;
}

用于哨兵分隔数组的注解

C 字符串是字符数组。 空终止符——数组中的第一个空字符(’0’)元素——标记字符串的结束。 -fbounds-safety 提供了 __null_terminated 来注释 C 字符串,以及通用形式 __terminated_by(T) 来注释以哨兵值标记结束的指针和数组。 该模型阻止解引用 __terminated_by 指针超出其结束位置。 计算结束位置(即哨兵值地址)需要在内存中读取整个数组,这会带来一些性能成本。 为了避免无意的性能损失,该模型对这些指针的使用方式有一些限制。 __terminated_by 指针不能被索引,只能一次递增一个元素。 为了允许这些操作,必须使用内在函数 __unsafe_terminated_by_to_indexable(P, T)(或 __unsafe_null_terminated_to_indexable(P))将指针显式转换为 __indexable 指针,该函数将 __terminated_by 指针 P 转换为 __indexable 指针。

  • __null_terminated : 指针或数组以 NULL0 终止。 修改终止符或在终止符之后递增指针将被阻止在运行时。

  • __terminated_by(T) : 指针或数组以 T 结尾,其中 T 是一个常量表达式。不允许访问或递增指针超出终止符。这是 __null_terminated 的泛化,它被定义为 __terminated_by(0)

与边界不安全代码交互的注释

带有 __unsafe_indexable 注释的指针的行为与普通的 C 指针相同。也就是说,指针没有任何边界信息,并且指针操作不会被检查。

__unsafe_indexable 可用于标记来自系统头文件或来自未采用 -fbounds 安全性的代码的指针。这允许在使用 -fbounds-safety 的代码和不使用该功能的代码之间进行交互操作。

默认指针类型

ABI 可见性与默认注释

要求 -fbounds-safety 采用者为代码库中的所有指针添加边界注释将是一项重大的采用负担。为了避免这种情况,并默认情况下保护所有指针,-fbounds-safety 将默认边界注释应用于指针类型。默认注释适用于声明中使用的指针类型

-fbounds-safety 将默认边界注释应用于声明中使用的指针类型。默认注释由指针的 ABI 可见性决定。如果更改指针的大小或表示形式会影响 ABI,则指针类型是 ABI 可见的。例如,更改函数参数中使用的类型的尺寸将影响 ABI,因此函数参数中使用的指针是 ABI 可见的指针。另一方面,更改局部变量的类型不会产生这种 ABI 影响。因此,-fbounds-safety 将局部变量的最外层指针类型视为非 ABI 可见。其余的指针,如嵌套指针类型、全局变量的指针类型、结构体字段和函数原型,都被视为 ABI 可见。

所有 ABI 可见的指针默认情况下都被视为 __single,除非另有注释。此默认行为既保留了 ABI,又默认情况下使这些指针安全。此行为可以通过宏进行控制,即 __ptrcheck_abi_assume_*ATTR*(),将 ABI 可见指针的默认注释设置为 __single__bidi_indexable__indexable__unsafe_indexable。例如,__ptrcheck_abi_assume_unsafe_indexable() 将使所有 ABI 可见的指针成为 __unsafe_indexable。非 ABI 可见的指针(局部变量的最外层指针类型)默认情况下是 __bidi_indexable,这样这些指针就具有执行边界检查所需边界信息,而无需手动注释。所有 const char 指针或任何与 const char 指针等效的 typedef 默认情况下都是 __null_terminated。这意味着 char8_tunsigned char,因此 const char8_t * 默认情况下不会是 __null_terminated。同样,const wchar_t * 默认情况下也不会是 __null_terminated,除非平台将其定义为 typedef char wchar_t。但是,请注意,程序员仍然可以在任何其他指针中显式使用 __null_terminated,例如,char8_t *__null_terminatedwchar_t *__null_terminatedint *__null_terminated 等,如果它们应该被视为 __null_terminated。其他注释也是如此。在系统头文件中,ABI 可见指针的默认指针属性默认情况下设置为 __unsafe_indexable

__ptrcheck_abi_assume_*ATTR*() 宏在工具链头文件中定义为pragma(有关工具链头文件的更多详细信息,请参阅 不支持扩展的工具链的移植性


#define __ptrcheck_abi_assume_single()

_Pragma(“clang abi_ptr_attr set(single)”)

#define __ptrcheck_abi_assume_indexable()

_Pragma(“clang abi_ptr_attr set(indexable)”)

#define __ptrcheck_abi_assume_bidi_indexable()

_Pragma(“clang abi_ptr_attr set(bidi_indexable)”)

#define __ptrcheck_abi_assume_unsafe_indexable()

_Pragma(“clang abi_ptr_attr set(unsafe_indexable)”)

默认边界注释的 ABI 影响

虽然简单地修改局部变量的类型通常不会影响 ABI,但获取此类修改类型的地址可能会创建具有 ABI 不匹配的指针类型。查看以下示例,int *local 隐式为 int *__bidi_indexable,因此 &local 的类型是指向 int *__bidi_indexable 的指针。另一方面,在 void foo(int **) 中,参数类型是指向 int *__single 的指针(即 void foo(int *__single *__single))(或者是指向 int *__unsafe_indexable 的指针,如果它是来自系统头文件)。编译器会为元素具有不兼容指针属性的指针之间的强制转换报告错误。这样,-fbounds-safety 可以防止隐式为 __bidi_indexable 的指针静默地逃逸,从而破坏 ABI。

void foo(int **);

void bar(void) {
   int *local = 0;
   // error: passing 'int *__bidi_indexable*__bidi_indexable' to parameter of
   // incompatible nested pointer type 'int *__single*__single'
   foo(&local);
}

如果 typeof() 获取局部变量的类型来定义接口,则局部变量仍然可能暴露给 ABI,如下面的示例所示。

// bar.c
void bar(int *) { ... }

// foo.c
void foo(void) {
   int *p; // implicitly `int *__bidi_indexable p`
   extern void bar(typeof(p)); // creates an interface of type
                               // `void bar(int *__bidi_indexable)`
}

如果参数在函数 bar() 的定义中不是 __bidi_indexable,则这样做可能会破坏 ABI,这很可能是这种情况,因为参数默认情况下是 __single,没有显式注释。

为了避免隐式宽指针静默地破坏 ABI,当 typeof() 在任何 ABI 可见上下文(例如,函数原型、结构体定义等)中用于隐式宽指针时,编译器会发出警告。

typeof() 中的默认指针类型

typeof() 获取表达式时,它会尊重表达式类型上的边界注释,包括隐式边界注释。例如,以下代码中的全局变量 g 隐式为 __single,因此 typeof(g) 获取 char *__single。参数 p 也是如此,因此 typeof(p) 返回 void *__single。局部变量 l 隐式为 __bidi_indexable,因此 typeof(l) 变成 int *__bidi_indexable

char *g; // typeof(g) == char *__single

void foo(void *p) {
   // typeof(p) == void *__single

   int *l; // typeof(l) == int *__bidi_indexable
}

当表达式类型具有“外部”边界注释(例如,__sized_by__counted_by 等)时,如果注释创建了与另一个声明或变量的依赖关系,则编译器可能会在 typeof 上报告错误。例如,编译器在以下代码中显示的 typeof(p1) 上报告错误,因为允许它可能在不同的上下文中创建另一个依赖于参数 size 的类型(请注意,参数上的外部边界注释可能只引用同一个函数的另一个参数)。另一方面,typeof(p2) 可以正常工作,得到 int *__counted_by(10),因为它不依赖于任何其他声明。

void foo(int *__counted_by(size) p1, size_t size) {
   // typeof(p1) == int *__counted_by(size)
   // -> a compiler error as it tries to create another type
   // dependent on `size`.

   int *__counted_by(10) p2; // typeof(p2) == int *__counted_by(10)
                             // -> no error

}

typeof() 获取类型名称时,编译器不会在命名的指针类型上应用隐式边界注释。例如,typeof(int*) 返回 int *,没有任何边界注释。边界注释可能会根据上下文在事后添加。在以下示例中,typeof(int *) 返回 int *,因此它与局部变量声明为 int *l 等效,因此它最终隐式地成为 __bidi_indexable

void foo(void) {
   typeof(int *) l; // `int *__bidi_indexable` (same as `int *l`)
}

程序员仍然可以在 typeof 内部命名的类型上显式添加边界注释,例如,typeof(int *__bidi_indexable),它计算为 int *__bidi_indexable

sizeof() 中的默认指针类型

sizeof() 使用类型名称时,编译器不会对命名指针类型应用隐式边界注解。这意味着,如果没有指定边界注解,评估的指针类型将与普通的 C 指针类型完全相同。因此,无论是否使用 -fbounds-safetysizeof(int*) 的值保持不变。也就是说,程序员可以显式地向类型添加属性,例如 sizeof(int *__bidi_indexable),在这种情况下,sizeof 将评估为类型 int *__bidi_indexable 的大小(与 3 * sizeof(int*) 相等的值)。

sizeof() 使用表达式时,即 sizeof(expr,它表现得像 sizeof(typeof(expr)),不同之处在于 sizeof(expr) 不会在 expr 具有类型(该类型具有依赖于另一个声明的外部边界注解)时报告错误,而 typeof() 对同一个表达式的操作将是错误的,如 在 typeof() 中的默认指针类型 中所述。以下示例描述了这种行为。

void foo(int *__counted_by(size) p, size_t size) {
   // sizeof(p) == sizeof(int *__counted_by(size)) == sizeof(int *)
   // typeof(p): error
};

alignof() 中的默认指针类型

alignof() 只接受类型名称作为参数,它不接受表达式。类似于 sizeof()typeof,编译器不会对在 alignof() 中命名的指针类型应用隐式边界注解。因此,无论是否使用 -fbounds-safetyalignof(T *) 的值保持不变,它将评估为原始指针 T * 的对齐方式。程序员可以显式地向类型添加边界注解,例如 alignof(int *__bidi_indexable),它将返回 int *__bidi_indexable 的对齐方式。包含内部边界注解的边界注解(即 __indexable__bidi_indexable)不会影响原始指针的对齐方式。因此,alignof(int *__bidi_indexable) 等于 alignof(int *)

C 风格强制转换中使用的默认指针类型

在 C 风格强制转换中使用的指针类型(例如 (int *)src)将继承 src 类型中相同的指针属性。例如,如果 src 的类型是 T *__single(其中 T 是任意 C 类型),则 (int *)src 将是 int *__single。这种行为背后的原因是,为了使 C 风格强制转换不会因为隐式边界属性强制转换而导致任何意外的副作用。

指针强制转换可以具有显式边界注解。例如,(int *__bidi_indexable)src 将强制转换为 int *__bidi_indexable,只要 src 具有可以隐式转换为 __bidi_indexable 的边界注解。如果 src 的类型是 int *__single,它可以隐式转换为 int *__bidi_indexable,然后将拥有指向第一个元素之后的元素的上边界。但是,如果 src 的类型是 int *__unsafe_indexable,则显式强制转换 (int *__bidi_indexable)src 将导致错误,因为 __unsafe_indexable 无法强制转换为 __bidi_indexable,因为 __unsafe_indexable 没有边界信息。 强制转换规则 更详细地描述了指针之间具有不同边界注解时允许进行哪些类型的强制转换。

在 typedef 中的默认指针类型

typedef 中的指针类型没有隐式默认边界注解。相反,边界注解是在使用 typedef 时确定的。以下示例表明,在 typedef pint_t 中没有指定指针注解,而每个实例的 typedef 指针都根据使用该类型的上下文来获得其边界注解。

typedef int * pint_t; // int *

pint_t glob; // int *__single glob;

void foo(void) {
   pint_t local; // int *__bidi_indexable local;
}

typedef 中的指针类型仍然可以具有显式注解,例如 typedef int *__single,在这种情况下,边界注解 __single 将应用于 typedef 的每个使用。

数组到指针的提升到安全数组(包括 VLA)

函数原型上的数组

在 C 中,函数原型上的数组会被提升(或“衰减”)为指向其第一个元素的指针(例如 &arr[0])。在 -fbounds-safety 中,数组也会衰减为指针,但会加上一个隐式边界注解,其中包括可变长度数组(VLA)。如以下示例所示,函数原型上的数组会衰减为相应的 __counted_by 指针。

// Function prototype: void foo(int n, int *__counted_by(n) arr);
void foo(int n, int arr[n]);

// Function prototype: void bar(int *__counted_by(10) arr);
void bar(int arr[10]);

这意味着数组参数在函数内部被视为 __counted_by 指针,函数的调用者也将其视为相应的 __counted_by 指针。

函数原型上的不完整数组会导致编译器错误,除非其括号中具有 __counted_by 注解。

void f1(int n, int arr[]); // error

void f3(int n, int arr[__counted_by(n)]); // ok

void f2(int n, int arr[n]); // ok, decays to int *__counted_by(n)

void f4(int n, int *__counted_by(n) arr); // ok

void f5(int n, int *arr); // ok, but decays to int *__single,
                          // and cannot be used for pointer arithmetic

数组引用

在 C 中,与函数原型上的数组类似,对数组的引用会自动提升(或“衰减”)为指向其第一个元素的指针(例如 &arr[0])。

-fbounds-safety 中,数组引用会提升为 __bidi_indexable 指针,其中包含数组的上边界和下边界,等效于 &arr[0] 作为下边界,而 &arr[array_size](或最后一个元素之后的一个元素)作为上边界。这适用于所有类型的数组,包括常量长度数组、可变长度数组(VLA)和用 __counted_by 注解的灵活数组成员。

在以下示例中,对 vla 的引用提升为 int *__bidi_indexable,其中 &vla[n] 作为上边界,而 &vla[0] 作为下边界。然后,它被复制到 int *p 中,它隐式地是 int *__bidi_indexable p。请注意,在本例中,用于创建上边界的 n 的值为 10,而不是 100,因为 10vla 的实际长度,是分配数组时 n 的值。

void foo(void) {
   int n = 10;
   int vla[n];
   n = 100;
   int *p = vla; // { .ptr: &vla[0], .upper: &vla[10], .lower: &vla[0] }
                 // it's `&vla[10]` because the value of `n` was 10 at the
                 // time when the array is actually allocated.
   // ...
}

通过将数组引用提升为 __bidi_indexable,在 -fbounds-safety 中,所有数组访问都会进行边界检查,就像 __bidi_indexable 指针一样。

维护边界注解的正确性

-fbounds-safety 通过在更新指针对象及其包含边界信息的相关值时执行额外的检查来维护边界注解的正确性。

例如,__single 表达了一个不变式,即指针必须要么指向一个有效的单个对象,要么是一个空指针。为了维护这种不变式,编译器在初始化 __single 指针时会插入检查,如以下示例所示

void foo(void *__sized_by(size) vp, size_t size) {
   // Inserted check:
   // if ((int*)upper_bound(vp) - (int*)vp < sizeof(int) && !!vp) trap();
   int *__single ip = (int *)vp;
}

此外,显式边界注释,例如 int *__counted_by(count) buf,定义了两个变量 bufcount 之间的关系:即,bufcount 个可用元素。即使在任何这些相关变量更新之后,这种关系也必须保持。为此,模型要求对 bufcount 的赋值必须并排进行,它们之间没有副作用。这防止了 bufcount 由于更新发生在远处而暂时不同步。

下面的示例展示了一个函数 alloc_buf,它初始化一个结构体,该结构体的成员使用 __counted_by 注释。编译器允许这些赋值,因为 sbuf->bufsbuf->count 是并排更新的,赋值之间没有任何副作用。

此外,编译器插入额外的运行时检查以确保新的 buf 至少与新的 count 指示的元素数量一样多,如以下示例中函数 alloc_buf() 的转换伪代码所示。

typedef struct {
   int *__counted_by(count) buf;
   size_t count;
} sized_buf_t;

void alloc_buf(sized_buf_t *sbuf, sized_t nelems) {
   sbuf->buf = (int *)malloc(sizeof(int) * nelems);
   sbuf->count = nelems;
}

// Transformed pseudo code:
void alloc_buf(sized_buf_t *sbuf, sized_t nelems) {
   // Materialize RHS values:
   int *tmp_ptr = (int *)malloc(sizeof(int) * nelems);
   int tmp_count = nelems;
   // Inserted check:
   //   - checks to ensure that `lower <= tmp_ptr <= upper`
   //   - if (upper(tmp_ptr) - tmp_ptr < tmp_count) trap();
   sbuf->buf = tmp_ptr;
   sbuf->count = tmp_count;
}

编译器是否可以优化这些运行时检查取决于指针的上界是如何推导的。如果源指针有 __sized_by__counted_by 或其变体,则编译器假定上界计算不会溢出,例如 ptr + size(其中 ptr 的类型为 void *__sized_by(size)),因为当 __sized_by 指针被初始化时,-fbounds-safety 会插入运行时检查以确保 ptr + size 不会溢出,并且 size >= 0

假设上界计算不会溢出,编译器可以将陷阱条件 upper(tmp_ptr) - tmp_ptr < tmp_count 简化为 size < tmp_count,因此如果 sizetmp_count 的值在编译时已知,并且满足 0 <= tmp_count <= size,优化器可以移除检查。

ptr + size 仍然可能溢出,如果 __sized_by 指针是从未启用 -fbounds-safety 的代码创建的,这会导致未定义行为。

在前面的代码示例中,转换后的 alloc_buf() 的上界是从 void *__sized_by_or_null(size) 推导出来的,它是 malloc() 的返回类型。因此,指针运算不会溢出,或者 tmp_ptr 为空。因此,如果 nelems 是一个编译时常量,则编译器可以移除检查。

强制转换规则

-fbounds-safety 不会强制执行整体类型安全,并且在某些情况下,边界不变性仍然可能由于不正确的强制转换而被破坏。也就是说,-fbounds-safety 阻止以违反目标指针注释的边界不变性的方式改变边界属性的类型转换。如果类型转换改变边界属性不会违反目标的边界不变性,或者可以在运行时进行验证,则可能会被允许。以下是一些重要的强制转换规则。

两个指针在其嵌套指针类型上具有不同边界注释的指针是不兼容的,不能隐式强制转换为彼此。例如,T *__single *__single 不能转换为 T *__bidi_indexable *__single。可以使用显式强制转换(例如,C 样式强制转换)允许这种不兼容嵌套边界注释之间的转换。此后,规则仅适用于顶层指针类型。 __unsafe_indexable 不能使用强制转换转换为任何其他安全指针类型(__single__bidi_indexable__counted_by 等)。该扩展提供了内置函数来强制进行此转换,__unsafe_forge_bidi_indexable(type, pointer, char_count) 将指针转换为具有 char_count 个可用字节的类型为 __bidi_indexable 的指针,以及 __unsafe_forge_single(type, pointer) 将指针转换为类型为 type 的单指针。以下示例展示了这些函数的使用方法。函数 example_forge_bidi() 通过调用 get_buf() 从不安全的库中获取外部缓冲区,该函数返回 void *__unsafe_indexable. 在类型规则下,这不能直接赋值给 void *buf(隐式地为 void *__bidi_indexable)。因此,使用 __unsafe_forge_bidi_indexable 从不安全缓冲区手动创建一个 __bidi_indexable

// unsafe_library.h
void *__unsafe_indexable get_buf(void);
size_t get_buf_size(void);

// my_source1.c (enables -fbounds-safety)
#include "unsafe_library.h"
void example_forge_bidi(void) {
   void *buf =
     __unsafe_forge_bidi_indexable(void *, get_buf(), get_buf_size());
   // ...
}

// my_source2.c (enables -fbounds-safety)
#include <stdio.h>
void example_forge_single(void) {
   FILE *fp = __unsafe_forge_single(FILE *, fopen("mypath", "rb"));
   // ...
}
  • 函数 example_forge_single 通过调用系统头文件 stdio.h 中定义的 fopen 获取文件句柄。假设 stdio.h 没有采用 -fbounds-safetyfopen 的返回类型将隐式地为 FILE *__unsafe_indexable,因此它不能直接赋值给边界安全的源代码中的 FILE *fp。为了允许此操作,使用 __unsafe_forge_singlefopen 的返回值创建一个 __single

  • __unsafe_indexable 相似,任何非指针类型(包括 intintptr_tuintptr_t 等)不能转换为任何安全指针类型,因为它们没有边界信息。必须使用 __unsafe_forge_single__unsafe_forge_bidi_indexable 来强制进行转换。

  • 任何安全指针类型都可以强制转换为 __unsafe_indexable,因为它没有要维护的任何不变性。

  • __single 强制转换为 __bidi_indexable,如果指向的类型具有已知的大小。转换后,生成的 __bidi_indexable 将具有 __single 指向的类型单个对象的尺寸。如果指向的类型不完整或无尺寸,则 __single 不能强制转换为 __bidi_indexable。例如,void *__single 不能转换为 void *__bidi_indexable,因为 void 是一个不完整的类型,因此编译器无法正确确定单个 void 指针的上界。

  • 同样,如果指向的类型具有已知的大小,则 __single 可以强制转换为 __indexable。生成的 __indexable 将具有单个指向的类型对象的尺寸。

  • __single 强制转换为 __counted_by(E) 仅当 E 为 0 或 1 时。

  • __single 可以强制转换为 __single,包括当它们具有不同的指向类型时,只要它在底层的 C 标准中允许。 -fbounds-safety 不会保证类型安全。

  • __bidi_indexable__indexable 可以强制转换为 __single。编译器可能会插入运行时检查以确保指针至少具有一个元素,或者是一个空指针。

  • __bidi_indexable 强制转换为 __indexable,如果指针没有下溢。编译器可能会插入运行时检查以确保指针没有低于下界。

  • __indexable 强制转换为 __bidi_indexable。生成的 __bidi_indexable 将获得与指针值相同的下界。

  • 类型转换可能包括位转换和边界注释转换。例如,从 int *__bidi_indexable 转换为 char *__single 包括一个位转换(int *char *)和一个边界注释转换(__bidi_indexable__single)。在这种情况下,编译器执行位转换,然后转换边界注释。这意味着,int *__bidi_indexable 将被转换为 char *__bidi_indexable 然后转换为 char *__single

  • __terminated_by(T) 无法转换为任何安全指针类型,除非具有相同的 __terminated_by(T) 属性。要执行转换,程序员可以使用内在函数,例如 __unsafe_terminated_by_to_indexable(P),以强制转换。

  • __terminated_by(T) 可以转换为 __unsafe_indexable

  • 任何没有 __terminated_by(T) 的类型都无法转换为 __terminated_by(T),除非显式使用内在函数来允许它。

    • __unsafe_terminated_by_from_indexable(T, PTR [, PTR_TO_TERM]) 将任何安全指针 PTR 转换为 __terminated_by(T) 指针。 PTR_TO_TERM 是一个可选参数,程序员可以在其中提供终止符的确切位置。有了这个参数,该函数可以跳过读取整个数组以找到指针的末尾(或上限)。提供错误的 PTR_TO_TERM 将导致运行时陷阱。

    • __unsafe_forge_terminated_by(T, P, E) 创建 T __terminated_by(E) 指针,给定任何指针 P。T 必须是指针类型。

与不支持扩展的工具链的移植性

语言模型的设计使其不会改变原始 C 程序的语义,除了在行为未定义和/或不安全的情况下引入确定性陷阱。Clang 提供一个工具链头文件(ptrcheck.h),它在启用 -fbounds-safety 时将注释作为类型属性进行宏定义,并在禁用扩展时将其定义为空。因此,采用 -fbounds-safety 的代码可以通过包含头文件或添加宏来定义注释为空,从而在不支持此扩展的工具链上进行编译。例如,不支持此扩展的工具链可能没有定义 __counted_by 的头文件,因此使用 __counted_by 的代码必须将其定义为空或包含具有定义的头文件。

#if defined(__has_feature) && __has_feature(bounds_safety)
#define __counted_by(T) __attribute__((__counted_by__(T)))
// ... other bounds annotations
#else #define __counted_by(T) // defined as nothing
// ... other bounds annotations
#endif

// expands to `void foo(int * ptr, size_t count);`
// when extension is not enabled or not available
void foo(int *__counted_by(count) ptr, size_t count);

边界注释的其他潜在应用

-fbounds-safety 编程模型提供的边界注释除了语言扩展本身之外,还有潜在的用例。例如,静态和动态分析工具可以使用边界信息来改进对越界访问的诊断,即使没有使用 -fbounds-safety。边界注释可以用于改进 C 与边界安全语言的互操作性,在安全语言接口中提供更好的边界安全类型映射。边界注释还可以用作文档,指定声明之间的关系。

局限性

-fbounds-safety 旨在为 C 语言带来边界安全保障,但它不保证其他类型的内存安全属性。因此,它可能无法阻止由其他类型的安全违规(例如类型混淆)导致的一些次要边界安全违规。例如,-fbounds-safety 不会对不同指针类型(例如,char *__singlevoid *__singleint *__single)之间的 __single` 指针的转换执行类型安全检查,超出基础语言(C/C++)提供的范围。

-fbounds-safety 在很大程度上依赖于运行时检查来保持边界安全和类型系统的健全性。这可能会导致未优化构建中的代码大小开销显着增加,并将一些采用错误留到运行时才被捕获。然而,这不是一个根本性的限制,因为逐步添加必要的静态分析将允许我们尽早发现问题,并在未优化构建中删除不必要的边界检查。