技术导读——CFI(控制流完整性)
Control Flow Integrity
介绍
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(应用二进制接口)。
^定义(DSO)::跨动态共享对象(DSO)是指程序在多个动态共享对象(例如共享库或动态链接库)之间进行函数调用或数据访问的情况。在这种情况下,不同的共享对象可能在运行时独立加载和链接,因此需要特别的机制来确保跨对象的控制流完整性和一致的行为。这对于动态加载模块和插件的程序尤为重要。^
可用方案
可用的 CFI 方案如下:
-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: 运行时错误:在从基类到派生类的类型转换过程中,类型 ‘B’ 的控制流完整性检查失败(虚表地址 0x000000425a50)
0x000000425a50: 注:虚表的类型为 ‘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)
此方案检查虚函数调用是否使用了正确动态类型的 vptr
。即,调用对象的动态类型必须是用于进行调用的对象的静态类型的派生类。此 CFI 方案可以通过 -fsanitize=cfi-vcall
启用。
要使该方案生效,所有包含虚成员函数定义的翻译单元(无论是否内联),除了被忽略的类型或具有公共 LTO 可见性的类型外,必须使用 -flto
或 -flto=thin
编译并静态链接到程序中。
性能
通过对经过 CFI 机制处理的 Chromium 浏览器运行 Dromaeo 基准测试套件,测量得出的性能开销不到 1%。另一个好的性能基准测试是虚函数调用较多的 SPEC 2006 xalancbmk。
需要注意的是,该方案尚未针对二进制大小进行优化,Chromium 的二进制文件大小可能增加高达 15%。
错误类型转换检查
该方案检查指针转换是否指向具有正确动态类型的对象,即对象的动态类型必须是转换目标类型的派生类。这些检查目前仅在被转换的类是多态类时引入。
错误的类型转换本身并不一定是控制流完整性(CFI)违规,但它们可能会引发安全漏洞,且其实现使用了许多与 CFI 相同的机制。
有两种类型的错误转换可能会被禁止:一种是从基类到派生类的错误转换(可以通过 -fsanitize=cfi-derived-cast
检查),另一种是从 void*
类型或其他不相关类型的错误转换(可以通过 -fsanitize=cfi-unrelated-cast
检查)。
这两类转换的区别在于,第一类在 C++ 标准中定义为会产生未定义的值,而第二类本身并不是未定义行为(将指针转换回其原始类型是合法的),除非对象尚未初始化并且转换是 static_cast
(参见 C++14 标准 [basic.life] 第 5 条)。
如果一个程序出于策略原因禁止第二类转换,这一限制通常可以被强制执行。然而,有时为了符合外部 API(例如标准库分配器的 allocate
成员函数),某些函数可能需要执行被禁止的转换。这些函数可以被忽略。
要使此方案生效,所有包含虚成员函数定义的翻译单元(无论是否内联),除了被忽略的类型或具有公共 LTO 可见性的类型外,必须启用 -flto
或 -flto=thin
进行编译,并静态链接到程序中。
非虚成员函数调用检查
此方案检查非虚函数调用是否使用了正确动态类型的对象。也就是说,被调用对象的动态类型必须是用于调用的对象的静态类型的派生类。这些检查目前仅在对象属于多态类类型时引入。此 CFI 方案可以通过 -fsanitize=cfi-nvcall
单独启用。
为使此方案生效,所有包含虚成员函数定义的翻译单元(无论是否内联),除了被忽略的类型或具有公共 LTO 可见性的类型外,必须启用 -flto
或 -flto=thin
进行编译,并静态链接到程序中。严格性
严格性
如果类具有单一的非虚基类,并且不引入或重写虚成员函数或字段(除了隐式定义的虚析构函数),那么该类将与其基类具有相同的布局和虚函数语义。默认情况下,这种类型的类转换会被视为转换到最少派生的类。
虽然将基类实例转换为派生类在技术上是未定义行为,但这种用法在大多数编译器中都较为常见,并且通常不会带来安全问题,因此默认允许这种转换。此行为可以通过 -fsanitize=cfi-cast-strict
禁用。
%%笔记::这是因为 C++ 的类型系统假定你只能在实际存在的继承关系中进行转换。如果基类对象本身并不是从派生类实例化的,那么类型转换时会导致程序试图访问超出基类范围的内存或函数,从而引发未定义的行为。%%
include
class Base {
public:
virtual void show() {
std::cout << “Base class show() function.” << std::endl;
}
};
class Derived : public Base {
public:
void show() override {
std::cout << “Derived class show() function.” << std::endl;
}
void derivedOnlyFunction() {
std::cout << "Derived class specific function." << std::endl;
}
};
int main() {
Base baseObj; // 基类对象
Derived derivedObj; // 派生类对象
Base* basePtr = &baseObj; // 指向基类对象的指针
Derived* derivedPtr;
// 错误的类型转换:将基类指针转换为派生类指针
derivedPtr = static_cast
// 尝试调用派生类特有的函数
derivedPtr->derivedOnlyFunction(); // 未定义行为
return 0;
}
间接函数调用检查
此方案检查函数调用是否使用了正确动态类型的函数。也就是说,函数的动态类型必须与调用时使用的静态类型匹配。该 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。
include
typedef void (*FuncPtr)(); // 定义一个函数指针类型
// 正确的函数
void correctFunction() {
std::cout << “Correct function called.” << std::endl;
}
// 错误的函数
void incorrectFunction(int x) {
std::cout << “Incorrect function called with argument: “ << x << std::endl;
}
int main() {
FuncPtr funcPtr = nullptr;
// 将函数指针指向正确的函数
funcPtr = correctFunction;
funcPtr(); // 正常调用
// 强制将函数指针指向具有不同签名的函数(未定义行为)
funcPtr = reinterpret_cast
funcPtr(); // 未定义行为,CFI 会检测到类型不匹配并报错
return 0;
}
-fsanitize-cfi-icall-generalize-pointers
不匹配的指针类型是导致 cfi-icall
检查失败的常见原因。通过为翻译单元编译时使用 -fsanitize-cfi-icall-generalize-pointers
标志,可以放宽该翻译单元中函数调用的指针类型检查规则,该规则适用于所有通过 -fsanitize=cfi-icall
编译的函数。
具体来说,只要指针所指向类型的限定符(如 const
、volatile
等)匹配,返回值和参数类型中的指针会被视为等效。例如,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 位有符号整数等),以便与显式定义大小的整数类型(例如 Rust 中的 i8
、i16
、i32
等)兼容。
-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 的用户有一些特别相关的限制:
每个导出函数都存在性能和代码大小的开销,因为每个函数都必须有一个关联的跳转表条目,即使该函数在程序中的任何地方都没有获取其地址,也必须为其生成跳转表条目,并且即使是 DSOs 之间的直接调用也必须使用此条目,此外还有 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
不同,kcfi
不需要 -flto
,不会导致函数指针被替换为跳转表引用,并且永远不会破坏跨 DSO 的函数地址相等性。这些属性使 KCFI 在低层软件中更容易采用。KCFI 仅限于检查函数指针,并且不兼容只读可执行内存。
成员函数指针调用检查
此方案检查通过成员函数指针的间接调用是否使用了正确动态类型的对象。具体而言,它检查成员函数指针引用的成员函数的动态类型是否与成员函数指针中的“函数指针”部分匹配,并确保成员函数的类类型与成员函数的基类类型相关联。此 CFI 方案可以通过 -fsanitize=cfi-mfcall
单独启用。
编译器仅会在成员函数指针的基类类型是完整类型时发出完整的 CFI 检查。这是因为基类的完整定义包含正确编译 CFI 检查所需的信息。为了确保编译器始终发出完整的 CFI 检查,建议同时使用 -fcomplete-member-pointers
标志,该标志启用了一个非标准的语言扩展,要求成员指针的基类类型在调用时必须是完整的。
为使此方案生效,所有包含虚成员函数定义的翻译单元(无论是否内联),除了被忽略的类型或具有公共 LTO 可见性的类型外,必须启用 -flto
或 -flto=thin
进行编译,并静态链接到程序中。
该方案目前不支持跨动态共享对象(DSO)CFI,也不兼容 Microsoft ABI。
include
class Base {
public:
virtual void func() {
std::cout << “Base class function called.” << std::endl;
}
};
class Derived : public Base {
public:
void func() override {
std::cout << “Derived class function called.” << std::endl;
}
void derivedOnlyFunc() {
std::cout << “Derived class specific function called.” << std::endl;
}
};
int main() {
// 成员函数指针类型
void (Base::*funcPtr)() = nullptr;
Base baseObj;
Derived derivedObj;
// 成员函数指针指向基类的函数
funcPtr = &Base::func;
// 正确调用基类的函数
(baseObj.*funcPtr)(); // 输出: Base class function called.
// 使用派生类对象调用,行为正常
(derivedObj.*funcPtr)(); // 输出: Derived class function called.
// 错误:尝试使用成员函数指针指向派生类特有的函数(未定义行为)
void (Derived::derivedFuncPtr)() = &Derived::derivedOnlyFunc;
funcPtr = reinterpret_cast<void (Base::)()>(derivedFuncPtr); // 非法转换
// 这将导致未定义行为,如果启用了 CFI,它会捕捉到错误
(baseObj.*funcPtr)(); // CFI 会捕获到错误,报告类型不匹配
return 0;
}
CFI设计文档
虚函数调用的前向边控制流完整性(CFI)
此方案通过为每个用于虚函数调用的静态类型分配一块只读存储区域来工作,该存储区域位于对象文件中,并包含一个位向量,该位向量映射到用于这些虚表的存储区域。位向量中的每个设置的位对应一个虚表的地址点,该虚表与为其构建位向量的静态类型兼容。
例如,考虑以下三个 C++ 类:
struct A {
virtual void f1();
virtual void f2();
virtual void f3();
};
struct B : A {
virtual void f1();
virtual void f2();
virtual void f3();
};
struct C : A {
virtual void f1();
virtual void f2();
virtual void f3();
};
此方案将导致 A
、B
和 C
的虚表按顺序排列。
每个静态类型A,B,C的bit向量会如下所示
位向量在对象文件中表示为字节数组。通过从字节数组中的索引偏移加载并应用掩码,程序可以使用相对较短的指令序列测试位集合中的位。只要使用不同的位,位向量可以重叠。有关详细信息,请参阅 ByteArrayBuilder
类。
在这种情况下,假设 A
位于位向量的偏移量 0 处,使用第 0 位,B
位于偏移量 0 处,使用第 1 位,C
位于偏移量 0 处,使用第 2 位,那么字节数组看起来像这样:
char bits[] = { 0, 0, 1, 0, 0, 0, 3, 0, 0, 0, 0, 5, 0, 0 };
为了发出一个虚函数调用,编译器将生成代码,以检查对象的虚表指针是否在界内并对齐,同时检查位向量中相关的位是否已设置。
例如,在 x86 架构下,典型的虚函数调用可能如下所示:
ca7fbb: 48 8b 0f mov (%rdi),%rcx
ca7fbe: 48 8d 15 c3 42 fb 07 lea 0x7fb42c3(%rip),%rdx
ca7fc5: 48 89 c8 mov %rcx,%rax
ca7fc8: 48 29 d0 sub %rdx,%rax
ca7fcb: 48 c1 c0 3d rol $0x3d,%rax
ca7fcf: 48 3d 7f 01 00 00 cmp $0x17f,%rax
ca7fd5: 0f 87 36 05 00 00 ja ca8511
ca7fdb: 48 8d 15 c0 0b f7 06 lea 0x6f70bc0(%rip),%rdx
ca7fe2: f6 04 10 10 testb $0x10,(%rax,%rdx,1)
ca7fe6: 0f 84 25 05 00 00 je ca8511
ca7fec: ff 91 98 00 00 00 callq *0x98(%rcx)
[…]
ca8511: 0f 0b ud2
编译器依赖于链接器的协作来为整个程序组装位向量。它目前通过结合 LLVM 的类型元数据机制和链接时优化(LTO)来完成这一操作。
优化
上述方案是该方案的完全通用版本。在大多数情况下,我们可以应用以下一种或多种优化来改善二进制文件大小或性能。
事实上,如果你使用当前版本的编译器尝试上述示例,你可能会发现它不会使用描述的虚表布局或机器指令。我们即将介绍的一些优化可能会导致编译器使用不同的布局或不同的机器指令序列。
去除位向量中的前导/尾随零
如果一个位向量包含前导或尾随的零,我们可以将这些零去掉。编译器将生成代码,检查指针是否在被设置为 1 的区域内,并使用截断后的位向量执行检查。例如,对于我们的类层次结构,位向量将像这样发出:
https://clang.llvm.org/docs/ControlFlowIntegrityDesign.html(未完)
CFI实践与使用
在LLVM源码中,使用make check-cfi
会有以下几个部分:
构建测试用例:
- 使用 LLVM 的编译工具(如
clang
)编译与 CFI 相关的测试用例。这些测试通常位于llvm/test
目录下,特别是llvm/test/CFI
子目录中。
运行测试:
lit
(LLVM 的测试框架)会自动运行这些测试,并检查测试结果是否符合预期。测试用例会模拟各种控制流场景,以验证 LLVM 是否正确应用了 CFI 的保护措施。
生成报告:
- 测试完成后,
lit
会输出测试结果,包括成功和失败的测试用例。如果某些测试失败,测试框架会输出详细的日志,帮助开发者排查错误。
典型的 make check-cfi
结果
运行 make check-cfi
之后,测试框架会输出类似以下的结果:
Testing Time: 12.34s
Expected Passes: 123
Unexpected Failures: 1
Expected Passes:表示测试成功的用例数量。
Unexpected Failures:表示出现了错误的测试用例,需要进一步分析和修复。
遇到的错误和问题
- 页大小不一致