控制流完整性¶
简介¶
Clang 包含几种控制流完整性 (CFI) 方案的实现,这些方案旨在检测可能允许攻击者破坏程序控制流的某些形式的未定义行为时中止程序。这些方案已针对性能进行了优化,允许开发人员在发布版本中启用它们。
要启用 Clang 的可用 CFI 方案,请使用标志 -fsanitize=cfi
。您也可以启用可用 方案 的子集。按照目前的实现,所有方案都依赖于链接时优化 (LTO);因此需要指定 -flto
,并且使用的链接器必须支持 LTO,例如通过 gold 插件。
为了有效地实现检查,程序的结构必须使某些目标文件在启用 CFI 的情况下进行编译,并被静态链接到程序中。在某些情况下,这可能会阻止使用共享库。
编译器将只为可以推断出隐藏的 LTO 可见性的类生成 CFI 检查。LTO 可见性是类的一个属性,它从标志和属性中推断出来。有关更多详细信息,请参阅 LTO 可见性 的文档。
-fsanitize=cfi-{vcall,nvcall,derived-cast,unrelated-cast}
标志要求也指定 -fvisibility=
标志。这是因为默认可见性设置是 -fvisibility=default
,它将禁用对没有可见性属性的类的 CFI 检查。大多数用户将希望指定 -fvisibility=hidden
,它将为这些类启用 CFI 检查。
目前已存在对 跨 DSO 控制流完整性 的实验性支持,该支持不需要类具有隐藏的 LTO 可见性。此跨 DSO 支持目前具有不稳定的 ABI。
可用方案¶
可用方案为
-fsanitize=cfi-cast-strict
:启用 严格的转换检查。
-fsanitize=cfi-derived-cast
:基类到派生类的错误动态类型转换。
-fsanitize=cfi-unrelated-cast
:从void*
或其他不相关类型到错误动态类型的转换。
-fsanitize=cfi-nvcall
:通过其 vptr 为错误动态类型的对象的非虚调用。
-fsanitize=cfi-vcall
:通过其 vptr 为错误动态类型的对象的虚调用。
-fsanitize=cfi-icall
:间接调用具有错误动态类型的函数。
-fsanitize=cfi-mfcall
:通过具有错误动态类型的成员函数指针进行间接调用。
您可以使用 -fsanitize=cfi
启用所有方案,并使用 -fno-sanitize
标志缩小方案集,具体取决于您的需要。例如,您可以使用 -fsanitize=cfi -fno-sanitize=cfi-nvcall,cfi-icall
构建您的程序,以使用除非虚成员函数调用和间接调用检查之外的所有方案。
请记住,如果您启用了至少一个 CFI 方案,则必须提供 -flto
或 -flto=thin
。
捕获和诊断¶
默认情况下,CFI 会在检测到控制流完整性违规时立即中止程序。您可以使用 -fno-sanitize-trap= 标志使 CFI 在程序中止之前打印类似于下面的诊断信息。
bad-cast.cpp:109:7: runtime error: control flow integrity check for type 'B' failed during base-to-derived cast (vtable address 0x000000425a50)
0x000000425a50: note: vtable is of type 'A'
00 00 00 00 f0 f1 41 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 20 5a 42 00
^
如果启用了诊断,您还可以通过使用 -fsanitize-recover= 标志配置 CFI 以继续执行程序而不是中止。
针对虚函数调用的前向边 CFI¶
此方案检查虚函数调用是否使用正确动态类型的 vptr 进行;也就是说,被调用对象的动态类型必须是用于进行调用的对象的静态类型的派生类。此 CFI 方案可以使用 -fsanitize=cfi-vcall
单独启用。
为了使此方案正常工作,所有包含虚成员函数定义的翻译单元(无论是否内联),除了 被忽略 类型或具有公共 LTO 可见性 的类型的成员,都必须在启用了 -flto
或 -flto=thin
的情况下进行编译,并被静态链接到程序中。
性能¶
通过对 Chromium 网络浏览器进行检测版本的 Dromaeo 基准测试套件运行,已测量出小于 1% 的性能开销。另一个针对此机制的良好性能基准测试是虚拟调用密集型的 SPEC 2006 xalancbmk。
请注意,此方案尚未针对二进制大小进行优化;对于 Chromium,已观察到高达 15% 的增加。
错误转换检查¶
此方案检查指针转换是否被制作到具有正确动态类型的对象;也就是说,对象的动态类型必须是转换目标类型的指针类型的派生类。这些检查目前仅在被转换到的类是多态类时才引入。
错误转换本身并不是控制流完整性违规,但它们也会创建安全漏洞,并且实现使用许多相同的机制。
可能禁止两种类型的错误转换:从基类到派生类的错误转换(可以使用 -fsanitize=cfi-derived-cast
检查),以及从 void*
类型或其他不相关类型的指针进行的错误转换(可以使用 -fsanitize=cfi-unrelated-cast
检查)。
这两种转换类型的区别在于,第一种由 C++ 标准定义为产生未定义的值,而第二种本身并非未定义行为(将指针转换回其原始类型是定义明确的),除非对象未初始化,并且转换是 static_cast
(请参阅 C++14 [basic.life]p5)。
如果程序作为策略禁止第二种转换类型,则通常可以强制执行此限制。但是,在某些情况下,函数可能需要执行禁止的转换才能符合外部 API(例如,标准库分配器的 allocate
成员函数)。此类函数可能会 被忽略。
为了使此方案正常工作,所有包含虚成员函数定义的翻译单元(无论是否内联),除了 被忽略 类型或具有公共 LTO 可见性 的类型的成员,都必须在启用了 -flto
或 -flto=thin
的情况下进行编译,并被静态链接到程序中。
非虚成员函数调用检查¶
此方案检查非虚调用是否使用具有正确动态类型的对象进行;也就是说,被调用对象的动态类型必须是用于进行调用的对象的静态类型的派生类。这些检查目前仅在对象是多态类类型时才引入。此 CFI 方案可以使用 -fsanitize=cfi-nvcall
单独启用。
为了使此方案正常工作,所有包含虚成员函数定义的翻译单元(无论是否内联),除了 被忽略 类型或具有公共 LTO 可见性 的类型的成员,都必须在启用了 -flto
或 -flto=thin
的情况下进行编译,并被静态链接到程序中。
严格性¶
如果类具有单个非虚基类,并且除了隐式定义的虚析构函数之外,没有引入或覆盖虚成员函数或字段,则它将与基类具有相同的布局和虚函数语义。默认情况下,对这些类的转换将被检查,就好像它们被制作到最不派生的此类类一样。
将基类实例转换为这种派生类在技术上是未定义的行为,但它是一种相对常见的技巧,用于在具有特定属性的类实例上引入成员函数,它在大多数编译器下都可以正常工作,并且不应该有安全隐患,因此我们默认情况下允许它。可以使用 -fsanitize=cfi-cast-strict
禁用它。
间接函数调用检查¶
此方案检查函数调用是否使用具有正确动态类型的函数进行;也就是说,函数的动态类型必须与调用时使用的静态类型匹配。此 CFI 方案可以使用 -fsanitize=cfi-icall
单独启用。
为了使此方案正常工作,程序中的每个间接函数调用(除了 被忽略 函数中的调用)都必须调用一个函数,该函数要么是在启用了 -fsanitize=cfi-icall
的情况下进行编译的,要么其地址是在启用 -fsanitize=cfi-icall
的情况下编译的翻译单元中的函数获取的。
如果用 -fsanitize=cfi-icall
编译的翻译单元中的函数获取了未用 -fsanitize=cfi-icall
编译的函数的地址,则该地址可能与未用 -fsanitize=cfi-icall
编译的翻译单元中的函数获取的地址不同。从技术上讲,这违反了 C 和 C++ 标准,但它不应该影响大多数程序。
每个用 -fsanitize=cfi-icall
编译的翻译单元必须静态链接到程序或共享库中,跨共享库边界的调用将被视为被调用者没有用 -fsanitize=cfi-icall
编译。
此方案目前仅在有限的目标集上支持:x86、x86_64、arm、arch64 和 wasm。
-fsanitize-cfi-icall-generalize-pointers
¶
不匹配的指针类型是 cfi-icall 检查失败的常见原因。用 -fsanitize-cfi-icall-generalize-pointers
标记编译的翻译单元会放宽该翻译单元中调用站点的指针类型检查,应用于所有用 -fsanitize=cfi-icall
编译的函数。
具体来说,返回值和参数类型中的指针被视为等效类型,只要它们指向的类型的限定符匹配。例如,char*
、char**
和 int*
被视为等效类型。但是,char*
和 const char*
被视为不同的类型。
-fsanitize-cfi-icall-generalize-pointers
与 -fsanitize-cfi-cross-dso
不兼容。
-fsanitize-cfi-icall-experimental-normalize-integers
¶
此选项允许将整数类型规范化为供应商扩展类型,以支持与其他语言的跨语言 LLVM CFI/KCFI,这些语言无法表示和编码 C/C++ 整数类型。
具体来说,整数类型被编码为其定义的表示形式(例如,8 位有符号整数、16 位有符号整数、32 位有符号整数,……),以兼容那些定义显式大小整数类型的语言(例如,i8、i16、i32,……,在 Rust 中)。
-fsanitize-cfi-icall-experimental-normalize-integers
与 -fsanitize-cfi-icall-generalize-pointers
兼容。
此选项目前处于实验阶段。
-fsanitize-cfi-canonical-jump-tables
¶
Clang 间接函数调用检查器的默认行为将用跳转表条目的地址替换输出文件符号表中每个 CFI 检查函数的地址,该条目将通过 CFI 检查。我们将此称为使跳转表规范。此属性允许未用 -fsanitize=cfi-icall
编译的代码获取函数的 CFI 有效地址,但它有一些注意事项,对于跨 DSO CFI 的用户来说尤其重要。
每个导出的函数都与性能和代码大小开销相关联,因为每个此类函数都必须有一个相关的跳转表条目,即使在函数在程序中从未被取地址的常见情况下也必须发出该条目,并且即使对于跨 DSO 的直接调用,也必须使用该条目,此外还有 PLT 开销。
没有很好的方法可以获取用汇编语言或 Clang 不支持的语言编写的函数的 CFI 有效地址。原因是代码生成器需要插入一个跳转表才能为汇编函数形成一个 CFI 有效的地址,但通常没有办法让代码生成器确定函数的语言。这在 DSO 内部的情况下使用 LTO 可能可行,但在跨 DSO 的情况下,唯一可用的信息是函数声明。一个可能的解决方案是为每个汇编函数添加一个 C 包装器,但这些包装器会给汇编的重度用户带来很大的维护负担,还会增加运行时开销。
出于这些原因,我们提供了一个选项,使用 -fno-sanitize-cfi-canonical-jump-tables
标记使跳转表非规范。当跳转表被设置为非规范时,符号表条目将直接指向函数体。函数地址在 C 中被取地址的任何实例都将被替换为跳转表地址。
但是,此方案确实有自己的注意事项。与默认行为相比,它最终会更积极地破坏函数地址相等性,尤其是在跨 DSO 模式下,该模式通常会完全保留函数地址相等性。
此外,有时需要未用 -fsanitize=cfi-icall
编译的代码获取对 CFI 有效的函数地址。例如,当函数的地址被汇编代码取走然后由 CFI 检查的 C 代码调用时,这是必要的。__attribute__((cfi_canonical_jump_table))
属性可用于使特定函数的跳转表条目规范,以便外部代码最终将获得一个将通过 CFI 检查的函数地址。
-fsanitize=cfi-icall
和 -fsanitize=function
¶
此工具类似于 -fsanitize=function
,因为这两个工具都检查函数调用的类型。但是,这两个工具占据了设计空间的不同点;-fsanitize=function
是一种开发工具,旨在发现本地开发构建中的错误,而 -fsanitize=cfi-icall
是一种安全加固机制,旨在部署在发布版本中。
-fsanitize=function
由于在间接调用站点进行了更复杂的类型检查,因此具有更高的空间和时间开销,这可能使其不适合部署。
另一方面,-fsanitize=function
更符合 C++ 标准以及用户对与共享库交互的期望;函数指针的身份得以保持,跨共享库边界的调用与单一程序或共享库内部的调用没有区别。
-fsanitize=kcfi
¶
这是一种针对低级系统软件(如操作系统内核)设计的替代间接调用控制流完整性方案。与 -fsanitize=cfi-icall
不同,它不需要 -flto
,不会导致函数指针被替换为跳转表引用,并且永远不会破坏跨 DSO 函数地址相等性。这些特性使得 KCFI 在低级软件中更容易采用。KCFI 仅限于检查函数指针,并且与仅可执行内存不兼容。
成员函数指针调用检查¶
此方案检查通过成员函数指针进行的间接调用是否使用正确动态类型的对象进行。具体来说,我们检查成员函数指针引用的成员函数的动态类型是否与成员函数指针的“函数指针”部分匹配,以及成员函数的类类型是否与成员函数的基类型相关。可以使用 -fsanitize=cfi-mfcall
启用此 CFI 方案。
如果成员函数指针的基类型已完成,则编译器将仅发出完整的 CFI 检查。这是因为基类型的完整定义包含正确编译 CFI 检查所需的信息。为了确保编译器始终发出完整的 CFI 检查,建议还传递 -fcomplete-member-pointers
标记,该标记启用一个非标准语言扩展,该扩展要求成员指针基类型已完成,如果它们可能用于调用。
为了使此方案正常工作,所有包含虚成员函数定义的翻译单元(无论是否内联),除了 被忽略 类型或具有公共 LTO 可见性 的类型的成员,都必须在启用了 -flto
或 -flto=thin
的情况下进行编译,并被静态链接到程序中。
此方案目前与跨 DSO CFI 或 Microsoft ABI 不兼容。
忽略列表¶
可以使用 Sanitizer 特例列表 使用 src
、fun
和 type
实体类型放宽对某些源文件、函数和类型的 CFI 检查。可以使用 [section]
标题指定特定的 CFI 模式。
# Suppress all CFI checking for code in a file.
src:bad_file.cpp
src:bad_header.h
# Ignore all functions with names containing MyFooBar.
fun:*MyFooBar*
# Ignore all types in the standard library.
type:std::*
# Disable only unrelated cast checks for this function
[cfi-unrelated-cast]
fun:*UnrelatedCast*
# Disable CFI call checks for this function without affecting cast checks
[cfi-vcall|cfi-nvcall|cfi-icall]
fun:*BadCall*
设计¶
请参考 设计文档.
出版物¶
控制流完整性:原理、实现和应用。Martin Abadi、Mihai Budiu、Úlfar Erlingsson、Jay Ligatti。
在 GCC & LLVM 中强制执行前向边控制流完整性。Caroline Tice、Tom Roeder、Peter Collingbourne、Stephen Checkoway、Úlfar Erlingsson、Luis Lozano、Geoff Pike。