C++ 控制流完整性(CFI):在 C++ 编译器加固中通过间接跳转表校验防御高级内存劫持攻击
C 控制流完整性CFI在 C 编译器加固中通过间接跳转表校验防御高级内存劫持攻击引言C与高级内存劫持攻击的挑战C 语言以其卓越的性能和强大的底层控制能力在操作系统、嵌入式系统、高性能计算以及游戏开发等领域占据核心地位。然而这种对内存的直接访问能力也使得 C 程序极易受到内存安全漏洞的攻击。缓冲区溢出、Use-After-Free、双重释放等经典漏洞若被攻击者成功利用往往能导致程序控制流的劫持从而执行恶意代码危害系统安全。传统的防御机制如地址空间布局随机化ASLR、数据执行保护DEP/NX等虽然在一定程度上增加了攻击的难度但它们并非万无一失。ASLR 依赖于地址的随机化但信息泄露漏洞可以绕过它DEP 阻止了在数据段执行代码但攻击者可以通过代码重用技术如ROP/JOP/COOP利用程序自身的合法代码片段来构造恶意逻辑从而绕过 DEP。这些高级内存劫持攻击不再是简单地注入和执行恶意代码而是通过篡改程序内部的指针和数据使得程序在执行时跳转到攻击者精心构造的合法代码序列。为了应对这些日益复杂的攻击控制流完整性Control Flow Integrity, CFI应运而生。CFI 的核心思想是在程序执行的任何时刻其控制流都必须遵循预先定义好的、合法的路径。任何偏离这些合法路径的跳转或调用都将被视为攻击行为并被阻止。CFI 旨在构建一道坚固的防线确保即使内存数据被篡改程序的执行流程也无法被劫持到非预期的目标。控制流完整性CFI基础在深入探讨间接跳转表校验之前我们需要理解控制流的基础概念。程序的执行流程即指令的执行顺序构成了程序的“控制流”。在编译时编译器可以构建一个程序的控制流图Control Flow Graph, CFG它由节点基本块即一系列连续的指令和边表示控制流的转移组成。CFG 描述了程序所有可能的执行路径。CFI 的基本原则是在程序运行时实际发生的控制流转移必须与编译时确定的 CFG 中的合法转移相匹配。任何不符合 CFG 规则的跳转或调用都会被检测并阻止。CFI 通常分为两大类前向边 CFI (Forward-edge CFI)主要关注间接调用和间接跳转。这些操作的特点是在编译时其目标地址可能不确定但在运行时才根据一个指针或寄存器的值来确定。前向边攻击通常通过篡改函数指针、虚函数表指针vptr或全局偏移表GOT/过程链接表PLT条目来劫持程序的控制流使其跳转到任意的合法或非法的代码地址。后向边 CFI (Backward-edge CFI)主要关注函数返回。当一个函数返回时控制流应该返回到调用它的指令的下一条指令。后向边攻击通常通过篡改栈上的返回地址来劫持控制流例如栈溢出攻击。本文的重点是前向边 CFI 的一种关键实现间接跳转表校验。间接跳转表校验防御前向边攻击的利器前向边攻击是现代内存劫持攻击中的主要威胁之一。攻击者可以利用内存破坏漏洞修改存储在内存中的函数指针、虚函数表指针vptr或者动态链接库的全局偏移表GOT中的条目。一旦这些指针被篡改当程序执行到间接调用或间接跳转指令时控制流就会被劫持到攻击者指定的目标地址。这些目标地址可以是程序已有的合法代码片段ROP/JOP也可以是攻击者注入的恶意代码如果 DEP 被绕过。间接跳转表校验的核心思想是在程序的编译或链接阶段为每一个间接调用点例如通过函数指针或虚函数进行的调用确定一个合法目标地址的集合。在程序运行时每当发生一个间接调用时CFI 机制会检查实际的目标地址是否属于该调用点预先确定的合法目标集合。如果目标地址不在集合中则判定为控制流劫持尝试并采取相应的防御措施如终止程序。在 C 中常见的间接调用点包括虚函数调用 (vtable)当通过基类指针或引用调用虚函数时实际调用的函数是通过对象的虚函数表vtable来查找的。攻击者可以通过篡改vptr或vtable中的条目来改变虚函数的调用目标。函数指针调用C 允许使用函数指针来间接调用函数。攻击者可以通过篡改函数指针的值来劫持控制流。std::function和 Lambda 表达式的调用这些现代 C 特性在底层也可能涉及函数指针或虚函数机制因此也需要 CFI 保护。动态库/共享库中的间接调用 (GOT/PLT)当程序调用共享库中的函数时通过 GOT 和 PLT 机制进行间接跳转。攻击者可以通过篡改 GOT 条目来劫持这些调用。间接跳转表校验通过在编译时建立这种“合法目标集合”的映射并在运行时进行强制检查从而有效地防御了这些前向边攻击。编译器加固中的间接跳转表校验实现细节实现间接跳转表校验是一个复杂的过程它通常涉及编译器前端、中间表示IR分析和后端代码生成等多个阶段。A. 编译时阶段识别合法目标与类型推断在编译时编译器需要执行以下关键任务识别所有间接调用点这包括虚函数调用、函数指针调用、std::function调用以及其他形式的间接跳转。识别所有潜在的函数目标地址程序中所有可被调用的函数的入口地址都是潜在的合法目标。为每个间接调用点构建合法目标集合这是 CFI 最具挑战性的部分。理想情况下每个间接调用点都应该有一个尽可能小的、精确的合法目标集合。函数签名匹配与类型标签 (Type Tagging)最基本的合法性判断是基于函数签名。一个函数指针只能指向一个与自身签名匹配的函数。然而仅仅签名匹配是不够的因为攻击者可能将一个签名为void (*)(int)的函数指针指向另一个同样签名的、但并非预期目标的函数。为了提高精度许多 CFI 实现采用类型标签 (Type Tagging)机制。其思想是为每个函数或更精确地说为每个函数类型生成一个唯一的“标签”或哈希值。在编译时每个函数定义处编译器计算其签名的哈希值或其他唯一标识并将其作为标签附加到函数上。在每个间接调用点编译器也计算其预期目标函数签名的哈希值。在运行时当进行间接调用时CFI 机制会检查目标函数的标签是否与调用点预期的标签匹配。C 虚函数表的处理C 的虚函数机制是其多态性的核心。每个包含虚函数的类都会有一个虚函数表vtable其中存储了该类及其基类中所有虚函数的地址。每个对象实例则包含一个虚函数表指针vptr指向其类对应的 vtable。编译器在处理虚函数时可以精确地知道一个基类指针或引用在调用虚函数时可能指向哪些派生类的对象。每个派生类的 vtable 中特定虚函数的实际地址是什么。因此对于一个虚函数调用编译器可以构建一个集合包含所有可能被调用的虚函数的实际地址。例如如果Base* b new Derived(); b-foo();编译器知道foo()可能调用Base::foo()或Derived::foo()或其他继承自Base的类的foo()。它会将这些地址作为合法目标。代码示例虚函数表分析// 假设这是编译器在编译时看到的代码 class Base { public: virtual void foo() { /* Base implementation */ } virtual void bar() { /* Base implementation */ } }; class DerivedA : public Base { public: void foo() override { /* DerivedA implementation */ } // Inherits bar() }; class DerivedB : public Base { public: void foo() override { /* DerivedB implementation */ } void bar() override { /* DerivedB implementation */ } }; void callFoo(Base* obj) { obj-foo(); // 间接虚函数调用点 } int main() { Base* ptr1 new DerivedA(); callFoo(ptr1); // 目标可能是 DerivedA::foo() Base* ptr2 new DerivedB(); callFoo(ptr2); // 目标可能是 DerivedB::foo() Base* ptr3 new Base(); callFoo(ptr3); // 目标可能是 Base::foo() // 攻击者尝试劫持 vtable 或 vptr // 假设攻击者修改了 ptr1-vptr使其指向一个恶意 vtable // 或修改了 DerivedA 的 vtable 条目 return 0; }编译时分析结果概念性对于obj-foo()这个间接调用点编译器能够识别出所有Base类及其派生类中名为foo的虚函数。合法目标集合 {Base::foo,DerivedA::foo,DerivedB::foo}。每个函数如Base::foo会被赋予一个唯一的类型标签或哈希值这个标签代表其签名和在继承体系中的位置。函数指针的处理函数指针的分析更为复杂因为函数指针可以在程序执行期间被任意赋值。编译器需要进行全程序分析Whole Program Analysis, WPA来尽可能准确地跟踪函数指针的赋值和使用。识别函数指针类型编译器知道每个函数指针变量的类型例如void (*)(int, char)。识别函数指针赋值操作当一个函数的地址被赋给一个函数指针时这个函数就成为了该指针的潜在目标。构建集合对于每个函数指针变量编译器或链接器会尝试构建一个包含所有可能被赋给该指针的函数地址的集合。代码示例函数指针分析void funcA(int x) { /* ... */ } void funcB(int x) { /* ... */ } void funcC(int x, int y) { /* ... */ } // 签名不匹配 typedef void (*IntFuncPtr)(int); void executeFunc(IntFuncPtr ptr, int val) { ptr(val); // 间接函数指针调用点 } int main() { IntFuncPtr p1 funcA; executeFunc(p1, 10); // 目标可能是 funcA IntFuncPtr p2 funcB; executeFunc(p2, 20); // 目标可能是 funcB // IntFuncPtr p3 funcC; // 编译错误签名不匹配 // 攻击者尝试篡改 p1 的值使其指向一个非法的地址 // 或者指向一个签名匹配但并非预期的函数 return 0; }编译时分析结果概念性对于ptr(val)这个间接调用点其类型是void (*)(int)编译器会识别出所有签名为void (*)(int)的函数funcA,funcB。合法目标集合 {funcA,funcB}。每个函数如funcA同样被赋予一个类型标签。B. 运行时阶段强制执行与校验机制在编译时构建了合法目标集合后运行时 CFI 机制需要在每个间接调用点之前插入额外的校验代码插桩。间接跳转表 (Indirect Jump Table) 或目标集合的存储为了在运行时高效地查找目标地址是否合法编译器需要将这些合法目标集合存储在程序的可执行文件中通常是在一个只读的数据段中。存储方式可以是哈希表 (Hash Table)将函数的类型标签作为键函数地址作为值。位图 (Bitmaps)如果地址空间相对紧凑可以使用位图来表示哪些地址是合法的函数入口点。独立的校验函数 (Validator Functions)对于每个类型标签可以生成一个专门的校验函数它知道哪些地址是合法的。插桩 (Instrumentation)编译器会在每个间接调用指令之前插入一段校验代码。这段代码负责获取目标地址从寄存器或内存中读取将要跳转到的目标地址。获取目标类型标签从目标地址处或通过查询辅助数据结构获取目标函数的类型标签。获取调用点预期类型标签根据当前调用点的类型获取其预期的合法类型标签。执行校验比较目标地址是否在合法目标集合中以及目标函数的类型标签是否与调用点预期的标签匹配。错误处理如果校验失败则说明发生了控制流劫持程序会立即终止或报告错误。代码示例运行时插桩概念性以虚函数调用为例原始代码片段// ... Base* obj getObject(); // obj-vptr 可能被篡改 obj-foo(); // 虚函数调用 // ...编译器插桩后的概念性代码// ... Base* obj getObject(); void* target_addr obj-vptr[vtable_offset_for_foo]; // 获取目标地址 uint64_t target_tag get_cfi_tag(target_addr); // 获取目标函数的CFI标签 // 编译器在编译时确定 obj-foo() 期望的类型标签 uint64_t expected_tag_for_foo CALCULATED_TAG_FOR_BASE_FOO_FAMILY; // 插入校验代码 if (!__cfi_check(target_addr, expected_tag_for_foo, CFI_CALL_TYPE_VCALL)) { // 校验失败报告错误并终止程序 __cfi_fail(CFI check failed: vcall to unexpected target.); } // 如果校验通过则执行原始调用 ((void(*)(Base*))target_addr)(obj); // 实际的虚函数调用 // ...这里的__cfi_check函数会根据target_addr和expected_tag_for_foo来查询预先构建的合法目标集合。CFI_CALL_TYPE_VCALL可以帮助__cfi_check函数选择正确的校验逻辑。细粒度 CFI 与 粗粒度 CFICFI 的精度直接影响其安全性和性能细粒度 CFI (Fine-grained CFI)为每个间接调用点构建一个尽可能精确的合法目标集合。这意味着每个调用点可能有一个非常小的、唯一的合法目标列表。这种方法的安全性最高因为它能严格限制控制流但其运行时开销查找和存储也最大。粗粒度 CFI (Coarse-grained CFI)允许多个间接调用点共享一个较大的合法目标集合。例如所有签名为void (*)(int)的函数调用都可能共享一个包含所有void (*)(int)类型函数的合法目标集合。这种方法的安全性相对较低攻击者可以将控制流劫持到集合内的任何函数但其运行时开销较小更容易实现。现代 CFI 实现通常尝试在细粒度和粗粒度之间找到一个平衡点例如在类型匹配的基础上进一步通过类层次结构或模块边界来细化目标集合。CFI 的强化模式CFI 也可以配置为不同的模式ENFORCEMENT(严格模式)任何检测到的控制流违规都会立即终止程序。这是最安全的模式但可能导致生产环境中出现意外崩溃。MONITORING(监控模式)检测到的违规会被记录下来但程序不会终止。这对于在开发和测试阶段发现潜在的 CFI 违规非常有用可以帮助开发者理解程序的真实控制流并调整 CFI 策略。C. 链接时优化与二进制重写链接时优化 (LTO)当编译器进行 LTO 时它可以访问整个程序的所有模块的 IR。这对于构建更精确的合法目标集合至关重要尤其是对于函数指针和跨模块的虚函数调用。LTO 使得编译器能够进行更全面的类型分析和控制流分析从而提高 CFI 的精度和效率。二进制重写工具 (e.g., LLVM BOLT)一些工具可以在二进制层面进行 CFI 的插桩和优化。它们可以在不修改源代码的情况下对已编译的二进制文件进行分析和修改插入 CFI 检查。这对于处理没有源代码的第三方库或遗留系统非常有用。典型实现案例与代码演示LLVM/Clang 是现代编译器中实现 CFI 的一个优秀案例。它通过fsanitizecfi选项提供了强大的 CFI 功能。A. LLVM CFI (Clang/LLVM)LLVM 的 CFI 实现主要通过在中间表示IR层进行插桩并利用类型哈希值进行校验。fsanitizecfi这是一个总开关启用 LLVM 的 CFI 检查。它包含了多种子检查例如cfi-vcall保护虚函数调用。cfi-icall保护间接函数调用通过函数指针。cfi-nvcall保护非虚成员函数调用通过指针。cfi-mfcall保护成员函数指针调用。LLVM CFI 的核心是类型哈希为每个类型生成唯一的哈希值编译器会为每个函数类型例如void (int)和每个类层次结构中的虚函数签名生成一个稳定的哈希值。函数入口点标记在每个函数的入口点编译器会插入一个特殊的指令或数据存储该函数的类型哈希值。间接调用点插桩在每个间接调用点编译器会插入代码执行以下操作获取目标地址。从目标地址处读取其类型哈希值。将读取到的哈希值与当前调用点期望的哈希值进行比较。如果哈希值不匹配则触发 CFI 失败。B. 代码示例虚函数调用加固 (LLVM CFI 概念性原理)假设有以下 C 代码// example.cpp class Base { public: virtual void foo() { /* ... */ } virtual void bar() { /* ... */ } }; class Derived : public Base { public: void foo() override { /* ... */ } // Inherits bar() }; void callVirtual(Base* obj) { obj-foo(); // 间接虚函数调用点 } int main() { Derived d; callVirtual(d); return 0; }概念性的编译时分析与插桩过程类型哈希生成编译器为void (Base*)类型的foo虚函数生成一个唯一的哈希值例如HASH_TYPE_VIRTUAL_FOO_BASE。Base::foo和Derived::foo都将共享这个哈希值因为它们在虚函数表中占据相同的槽位且签名兼容。Base::bar会有另一个哈希值例如HASH_TYPE_VIRTUAL_BAR_BASE。函数入口点标记在Base::foo的实际实现代码的入口处编译器会添加一个元数据或指令指示其类型哈希为HASH_TYPE_VIRTUAL_FOO_BASE。在Derived::foo的实际实现代码的入口处同样标记为HASH_TYPE_VIRTUAL_FOO_BASE。在Base::bar的实际实现代码的入口处标记为HASH_TYPE_VIRTUAL_BAR_BASE。虚函数表 (VTable) 处理Derived类的 vtable 结构简化--------------------- | Derived::foo | -- 标记为 HASH_TYPE_VIRTUAL_FOO_BASE --------------------- | Base::bar | -- 标记为 HASH_TYPE_VIRTUAL_BAR_BASE ---------------------间接调用点obj-foo()的插桩原始 LLVM IR (简化); %obj_ptr_val 是 obj 对应的 Base* %vtable_ptr load i8**, i8*** %obj_ptr_val, align 8 %func_ptr_slot getelementptr inbounds i8*, i8** %vtable_ptr, i64 0 ; 假设 foo 在槽位0 %target_func_ptr load i8*, i8** %func_ptr_slot, align 8 call void %target_func_ptr(ptr %obj_ptr_val)LLVM CFI 插桩后的 IR (概念性实际更复杂); %obj_ptr_val 是 obj 对应的 Base* %vtable_ptr load i8**, i8*** %obj_ptr_val, align 8 %func_ptr_slot getelementptr inbounds i8*, i8** %vtable_ptr, i64 0 %target_func_ptr load i8*, i8** %func_ptr_slot, align 8 ; --- CFI 校验开始 --- ; 获取目标函数的类型哈希 (例如通过一个特殊的 CFI 运行时函数) %target_cfi_hash call i64 __sanitizer_cfi_get_target_type_hash(i8* %target_func_ptr) ; 预期目标函数的类型哈希 (编译时常量) %expected_cfi_hash i64 HASH_TYPE_VIRTUAL_FOO_BASE ; 执行比较 %is_valid icmp eq i64 %target_cfi_hash, %expected_cfi_hash br i1 %is_valid, label %cfi_pass, label %cfi_failcfi_fail:; CFI 校验失败调用运行时失败处理函数call void __sanitizer_cfi_fail(i8* %target_func_ptr, i64 %expected_cfi_hash, i64 %target_cfi_hash, i32 CFI_CALL_TYPE_VCALL)unreachablecfi_pass:; CFI 校验通过执行原始调用call void %target_func_ptr(ptr %obj_ptr_val); — CFI 校验结束 —通过这种方式如果攻击者篡改了 obj-vptr 或 vtable 中的条目使其指向一个不具有 HASH_TYPE_VIRTUAL_FOO_BASE 标签的函数或者指向一个完全无关的地址CFI 校验就会失败从而阻止攻击。 #### C. 代码示例函数指针调用加固 (LLVM CFI 概念性原理) 再看函数指针的例子 cpp // example_fptr.cpp void safe_func(int x) { /* ... */ } void another_safe_func(int x) { /* ... */ } void malicious_func(int x, int y) { /* ... */ } // 签名不匹配 void some_other_func(int x) { /* ... */ } // 签名匹配但不是预期目标 typedef void (*IntFuncPtr)(int); void execute_ptr(IntFuncPtr ptr, int val) { ptr(val); // 间接函数指针调用点 } int main() { IntFuncPtr p safe_func; execute_ptr(p, 10); // 假设攻击者现在篡改了 p 的值 // p (IntFuncPtr)some_other_func_address; // 攻击者劫持 // execute_ptr(p, 20); // 攻击尝试 return 0; }概念性的编译时分析与插桩过程类型哈希生成编译器为void (int)类型的函数指针生成一个唯一的哈希值例如HASH_TYPE_FPTR_VOID_INT。safe_func和another_safe_func、some_other_func都将标记为HASH_TYPE_FPTR_VOID_INT。malicious_func由于签名不同将有不同的哈希值。函数入口点标记safe_func、another_safe_func、some_other_func的入口点都会被标记为HASH_TYPE_FPTR_VOID_INT。间接调用点ptr(val)的插桩原始 LLVM IR (简化); %fptr 是 IntFuncPtr 类型的函数指针 call void %fptr(i32 %val)LLVM CFI 插桩后的 IR (概念性); %fptr 是 IntFuncPtr 类型的函数指针 %target_func_ptr %fptr ; --- CFI 校验开始 --- %target_cfi_hash call i64 __sanitizer_cfi_get_target_type_hash(i8* %target_func_ptr) %expected_cfi_hash i64 HASH_TYPE_FPTR_VOID_INT %is_valid icmp eq i64 %target_cfi_hash, %expected_cfi_hash br i1 %is_valid, label %cfi_pass, label %cfi_failcfi_fail:call void __sanitizer_cfi_fail(i8* %target_func_ptr, i64 %expected_cfi_hash, i64 %target_cfi_hash, i32 CFI_CALL_TYPE_ICALL)unreachablecfi_pass:call void %target_func_ptr(i32 %val); — CFI 校验结束 —在这个例子中如果攻击者将 p 篡改为 malicious_func 的地址签名不匹配则哈希值不匹配CFI 会拦截。如果攻击者将 p 篡改为 some_other_func 的地址签名匹配CFI 仍会通过这说明了粗粒度 CFI 的局限性。要防御这种攻击需要更细粒度的 CFI例如通过分析 p 的赋值上下文确定 p 只能指向 safe_func 或 another_safe_func。这通常需要更复杂的全程序数据流分析。 ### 性能考量、挑战与局限性 虽然 CFI 提供了强大的安全保障但其实现并非没有代价。 #### A. 性能开销 * **运行时校验开销**每次间接调用都需要执行额外的校验逻辑内存读取、哈希计算、比较、可能的数据结构查找。这会增加 CPU 指令周期导致程序运行变慢。 * **代码大小增加**插入的校验代码会增加最终可执行文件的大小。 * **数据段增加**存储合法目标集合、类型标签等辅助数据需要额外的内存空间。 * **优化可能性**编译器和运行时库可以通过缓存最近的校验结果、使用高效的数据结构如布隆过滤器或将校验代码内联来减少开销。 在实际应用中LLVM CFI 通常会导致 0-10% 的性能下降具体取决于程序的间接调用频率和 CFI 的细粒度程度。 #### B. 兼容性问题 * **与遗留代码和第三方库的集成**如果一部分代码没有用 CFI 编译或者使用了动态代码生成JIT那么 CFI 可能会失效或产生误报。全程序 CFI 需要所有相关模块都使用 CFI 编译。 * **动态特性支持**反射、动态加载代码、JIT 编译器等机制会动态地改变控制流这与 CFI 的静态分析假设相冲突需要特殊的处理或限制。 #### C. 部署挑战 * **编译器支持**CFI 需要编译器层面的深度支持旧版本的编译器可能不支持。 * **全程序编译**为了实现细粒度 CFI通常需要进行全程序分析和链接时优化LTO这会增加编译时间。 #### D. 局限性 * **无法防御所有类型的攻击**CFI 专注于保护控制流。它无法直接防御数据篡改攻击例如修改敏感数据而非代码指针、信息泄露攻击CFI 不会阻止攻击者读取内存。 * **针对特定攻击模式的防御效果有限**如果攻击者能够将控制流劫持到 CFI 允许的合法目标集合内的恶意函数例如一个签名匹配但并非预期目的的库函数则 CFI 可能无法阻止攻击。这被称为“gadget”利用是粗粒度 CFI 的弱点。 * **C 多态性的复杂性**C 的虚继承、多重继承、模板元编程等复杂特性使得编译器在静态分析时精确地确定所有合法控制流目标变得更加困难。 ### CFI的未来发展与生态系统 CFI 仍然是一个活跃的研究领域未来的发展方向包括 * **硬件辅助 CFI**例如 Intel 的 CET (Control-flow Enforcement Technology) 和 ARM 的 MTE (Memory Tagging Extension)。这些技术通过硬件层面支持影子栈用于后向边 CFI和间接跳转目标校验用于前向边 CFI有望在提供高安全性的同时显著降低性能开销。 * **与其它安全机制的结合**CFI 并非银弹它应作为纵深防御体系中的一环与 ASLR、DEP、沙箱、内存安全语言特性如 Rust等协同工作共同提高系统安全性。 * **对语言特性更深入的理解和支持**随着 C 标准的演进CFI 需要更好地理解和支持新的语言特性例如协程、模块等以确保其有效性。 * **模糊测试与 CFI**通过模糊测试Fuzzing工具结合 CFI 的监控模式可以有效地发现程序中的潜在漏洞和 CFI 违规。 ### 结语 C 控制流完整性CFI特别是通过间接跳转表校验实现的机制是防御高级内存劫持攻击的关键技术。它通过在编译时构建合法控制流目标集合并在运行时进行严格校验有效地限制了攻击者劫持程序执行流程的能力。尽管在性能、兼容性和精度方面仍面临挑战但随着编译器技术和硬件辅助安全特性的发展CFI 正日益成为构建健壮、安全的 C 软件不可或缺的一部分为软件安全提供了坚实的底层保障。