简介

CFI 是 Control Flow Integrity 的缩写(即控制流完整性)。CFI是一种软件安全技术,旨在保护程序的控制流程不被攻击者篡改,从而防止一些恶意攻击,比如 代码注入、ROP(Return-Oriented Programming) 等攻击手段。

针对上述攻击,人们提出了许多巧妙的漏洞缓解方法来防御,比如 stack canaries[1]、缓冲区溢出运行消除[2]、randomization and artificial heterogeneity[[3,4]、tainting of suspect data[5],但是这些缓解机制依赖于硬件修改或以损失性能为代价,所以显得不切实际,而且目前攻击者已经找到了绕过这些缓解机制的方法[6]。本文介绍 CFI(控制流完整性)防护可以消减上述攻击。并且通过对应用于SPEC CPU2006基准测试以及Chromium Web浏览器常见基准测试的安全性、性能和资源消耗的分析和评估,结果显示该防护手段更为实用[7]

CFI 技术通常通过在 编译期间 或 运行期间 生成 控制流程的元数据,将其与程序中实际的控制流程进行比较,以此检测是否存在控制流程的异常。如果检测到异常,程序就会立即停止执行,从而避免了被攻击者利用漏洞进行攻击的风险。

CFI 技术在现代操作系统和编译器中得到了广泛的应用,它可以提供一定程度上的安全保障,但并不能完全防止所有的攻击。因此,在实际应用中,CFI通常被视为一种安全增强手段,而不是一种解决所有安全问题的银弹。

CFI 技术目前已经在业界广泛应用,比如:

Google Chrome 浏览器:官方 Chromeon Linux x86-64(M68及更高版本)启用了间接调用防护的CFI[8]

Mozilla Firefox 浏览器:Firefox 早在2016年就在其构建系统中添加了CFI保护的支持[9]

Microsoft Windows:Microsoft的 CFI 实现也叫 CFG,早在2014年的Visual Studio就添加了相关的支持选项[10], Microsoft Edge 浏览器也使能了CFI防护。

下文的介绍 CFI 都是 Clang 的实现,详细介绍参考Clang 官网的描述[11]

使用介绍

Clang 包括许多控制流完整性(CFI)方案的实现,这些方案旨在在检测到某些形式的未定义行为时中止程序,这些行为可能允许攻击者颠覆程序的控制流。这些方案已针对性能进行了优化,开发人员放心在版本构建中启用它们。

要启用 Clang 的所有可用 CFI 防护,可以使用编译选项 -fsanitize=cfi,也可以通过更细粒度的编译选项 -fsanitize=cfi-icall,-fsanitize=cfi-vcall 等只启用部分防护。目前实施的所有防护都 依赖于链接时优化(LTO);因此需要开启编译选项 -flto(使用的链接器必须支持LTO,如 lld、gold[12]),如:

clang -O2 -fvisibility=hidden -flto -fuse-ld=lld -fsanitize=cfi -fno-sanitize-trap=all -o cfi_icall cfi_icall.c

为了能够有效地实现这些检查,代码需要按照一定的方式组织,以使得目标文件在启用CFI的情况下进行编译,并且静态链接到程序中。这表示动态链接的共享库可能不支持 CFI 检查。

编译器在能够推断一个类的隐藏 LTO(链接时优化)可见性时,才会为一个类生成 CFI 检查。LTO可见性是类的一个属性,由编译选项 -fvisibility=default/hidden 或在源码添加相关attribute(如:__attribute__((visibility("default"))) )指定。有关更多详细信息,请参阅 LTO可见性的文档[13]

使用编译选项 -fsanitize=cfi-{vcall,nvcall,derived-cast,unrelated-cast} 时要求同时加上编译选项 -fvisibility=[default|internal|hidden|protected]。这是因为默认的可见性设置是 -fvisibility=default,这会禁用没有可见性属性的类的CFI检查。通常我们使用编译选项 -fvisibility=hidden 为类启用CFI检查。

不要求类具有隐藏的 LTO 可见性的 DSO(动态共享对象)的控制流完整性支持目前是实验性质的,它的ABI(Application Binary Interface)还不稳定(可能会有改动)。

1. 间接调用CFI防护举例

这里举一个间接调用函数包含错误动态类型参数的例子。

#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
typedef int (*int_arg_fn)(int);
typedef int (*float_arg_fn)(float);
static int int_arg(int arg) {
    printf("In %s: (%d)\n", __FUNCTION__, arg);
    return 0;
}
static int float_arg(float arg) {
    printf("CFI should protect transfer to here\n");
    printf("In %s: (%f)\n", __FUNCTION__, (double)arg);
    return 0;
}
static int bad_int_arg(int arg) {
    printf("CFI will not protect transfer to here\n");
    printf("In %s: (%d)\n", __FUNCTION__, arg);
    return 0;
}
static int not_entry_point(int arg) {
    // x86/x86-64 的 nop sled
    // 这些指令充当一个缓冲区
    // 用于间接的控制流传输,以跳过有效的函数入口点,但继续执行正常的代码
    __asm__ volatile (
            "nop\n" "nop\n" "nop\n" "nop\n" "nop\n" "nop\n" "nop\n" "nop\n" "nop\n" "nop\n"
            "nop\n" "nop\n" "nop\n" "nop\n" "nop\n" "nop\n" "nop\n" "nop\n" "nop\n" "nop\n"
            "nop\n" "nop\n" "nop\n" "nop\n" "nop\n" "nop\n" "nop\n" "nop\n" "nop\n" "nop\n"
            "nop\n" "nop\n" "nop\n" "nop\n" "nop\n" "nop\n" "nop\n" "nop\n" "nop\n" "nop\n"
            "nop\n" "nop\n" "nop\n" "nop\n" "nop\n" "nop\n" "nop\n" "nop\n" "nop\n" "nop\n"
            "nop\n" "nop\n" "nop\n" "nop\n" "nop\n" "nop\n" "nop\n" "nop\n" "nop\n" "nop\n"
            "nop\n" "nop\n" "nop\n" "nop\n" "nop\n" "nop\n" "nop\n" "nop\n" "nop\n" "nop\n"
            "nop\n" "nop\n" "nop\n" "nop\n" "nop\n" "nop\n" "nop\n" "nop\n" "nop\n" "nop\n"
            );
    printf("CFI ensures control flow only transfers to potentially valid destinations\n");
    printf("In %s: (%d)\n", __FUNCTION__, arg);
    // need to exit or the program will segfault anyway, since the indirect call skipped the function preamble
    exit(arg);
}
struct foo {
    int_arg_fn int_funcs[1];
    int_arg_fn bad_int_funcs[1];
    float_arg_fn float_funcs[1];  
    int_arg_fn not_entries[1];
};
// the struct aligns the function pointer arrays so indexing past the end will reliably call working function pointers
static struct foo f = {
    .int_funcs = {int_arg},
    .bad_int_funcs = {bad_int_arg},
    .float_funcs = {float_arg},
    .not_entries = {(int_arg_fn)((uintptr_t)(not_entry_point)+0x20)}
};
int main(int argc, const char *argv[]) {
    if(argc != 2) {
        printf("Usage: %s <option>\n", argv[0]);
        printf("Option values:\n");
        printf("\t0\tCall correct function\n");
        printf("\t1\tCall the wrong function but with the same signature\n");
        printf("\t2\tCall a float function with an int function signature\n");
        printf("\t3\tCall into the middle of a function\n");
        printf("\n");
        printf("\tAll other options are undefined, but should be caught by CFI :)\n");
        printf("\n\n");
        printf("Here are some pointers so clang doesn't optimize away members of `struct foo f`:\n");
        printf("\tint_funcs: %p\n", (void*)f.int_funcs);
        printf("\tbad_int_funcs: %p\n", (void*)f.bad_int_funcs);
        printf("\tfloat_funcs: %p\n", (void*)f.float_funcs);
        printf("\tnot_entries: %p\n", (void*)f.not_entries);
        return 1;
    }
    printf("Calling a function:\n");
    int idx = argv[1][0] - '0';
    return f.int_funcs[idx](idx);
}

在这个例子中,我们定义了一个结构体 struct foo, 它的四个成员变量分别是四个大小为 1 的函数指针数组。在主函数中,我们接受一个命令行参数用来指定调用哪个函数(传入参数都是整数类型):

(1)./cfi_icall 0:用来调用正确的函数(int_arg形参是整数)。

(2)./cfi_icall 1 :来调用错误的函数(数组越界)但函数形参正确。

(3)./cfi_icall 2:用来调用函数 float_arg, 它的形参是浮点类型,但传入类型int,这里存在类型不匹配的问题,且在编译时没法检查出来,如果形参和实参不存在可用的隐式类型转换,这里就可能造成一个难以排查的运行时问题。

(4)./cfi_icall 3:用来调用一个不存在的函数(这个例子里会跳转到inline asm 的一堆nop指令),通常这会直接导致段错误。

我们可以下面这样编译带控制流保护和不带控制流保护的程序作为对比:

$ clang -B/llvm-project/install/bin/lld -Weverything -Werror -pedantic -O2 -fvisibility=hidden -flto -fno-sanitize-trap=all -Wno-declaration-after-statement -fsanitize=cfi-icall -o cfi_icall cfi_icall.c
$ clang -B/llvm-project/install/bin/lld -Weverything -Werror -pedantic -O2 -fvisibility=hidden -flto -fno-sanitize-trap=all -Wno-declaration-after-statement -o no_cfi_icall cfi_icall.c

运行结果对比:

2. 关于间接函数调用检查

此方案检查函数调用 是否使用正确动态类型;也就是说,函数的 动态类型 必须与调用时使用的 静态类型 匹配。这种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、AArch64 和 WASM。

3. 其他可用防护方案包括

(1)-fsanitize=cfi-cast-strict:启用严格的类型转换检查。

(2)-fsanitize=cfi-derived-cast:从基类到派生类的类型转换时动态类型错误。

(3)-fsanitize=cfi-unrelated-cast:从void*或其他不相关类型到 错误的动态类型 的转换。

(4)-fsanitize=cfi-nvcall:通过vptr对具有错误的动态类型的对象进行非虚拟调用。

(5)-fsanitize=cfi-vcall:通过vptr对具有错误的动态类型的对象进行虚拟调用。

(6)-fsanitize=cfi-icall:间接调用具有错误的动态类型的函数。

(7)-fsanitize=cfi-mfcall:通过具有错误的动态类型的成员函数指针进行间接调用。

可以使用 -fsanitize=cfi 启用所有防护方案,并使用编译选项 -fno-sanitize 根据需要缩小方案集。例如,可以使用 -fsanitize=cfi -fno-sanitize=cfi-nvcall,cfi-icall 构建程序,以使用除非虚拟成员函数调用和间接调用检查之外的所有方案。

需要注意的是,如果启用了至少一个 CFI 防护方案,必须在编译时加上编译选项 -flto 或 -flto=thin。

4. 忽略列表

可以使用 Sanitizer 特殊情况列表 来放宽对特定源文件、函数和类型的 CFI 检查,对应 src、fun 和 type 实体类型。可以使用 [section] 标题来指定特定的 CFI 模式。

配置文件所在路径如:llvm-project/install/lib/clang/15.0.4/share/cfi_ignorelist.txt。

# 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*

5. 其他注意事项

当前没有硬件指令支持,需要使用运行时库进行检查,构建 LLVM 时需要加上 -DCOMPILER_RT_BUILD_SANITIZERS=ON, 通常情况下依赖这两个运行时库:

~/install/lib/clang/15.0.4/lib/linux/libclang_rt.ubsan_standalone_cxx-aarch64.a
~/install/lib/clang/15.0.4/lib/linux/libclang_rt.ubsan_standalone-aarch64.a

依赖这几个文件:

set(UBSAN_STANDALONE_SOURCES
  ubsan_diag_standalone.cpp
  ubsan_init_standalone.cpp
  ubsan_signals_standalone.cpp
  )

控制流完整性设计

1. Forward-Edge CFI for 间接函数调用

在间接函数调用的前向边CFI下,每个独特的函数类型都有自己的 位向量,而在每个调用点,我们需要检查函数指针是否是函数类型的位向量的成员。这种方案的工作方式类似于虚拟调用的前向边CFI,区别在于我们需要构建 函数入口点的位向量,而不是虚函数表的位向量。

相关向量表的设计与优化化简,请参考Clang 官方相关文档(https://clang.llvm.org/docs/ControlFlowIntegrityDesign.html)。

与重新排列全局变量时不同,我们无法按特定顺序重新排列函数,并基于函数入口点的布局进行计算,因为我们不知道特定函数最终会有多大(函数大小甚至可能取决于我们如何排列函数)。相反,我们构建了一个 跳转表,这是一个代码块,由每个位集中的函数的一个分支指令组成,该指令分支到目标函数,并将任何被执行的函数地址重定向到相应的跳转表条目。通过这种方式,函数入口点之间的距离是可预测和可控的。在目标文件的符号表中,目标函数的符号也引用了跳转表条目,以便在模块内部进行的任何验证都可以通过模块外部获取的地址。

更具体地说,假设我们有三个函数f()、g()、h(),它们都是相同类型的,还有一个函数foo(),返回它们的地址:

f:
mov 0, %eax # eax 传递函数返回值,这里表示函数返回 0
ret

g:
mov 1, %eax # 函数返回 1
ret

h:
mov 2, %eax # 函数返回 2
ret

foo:
mov f, %eax
mov g, %edx
mov h, %ecx
ret

我们的跳转表(概念上)看起来像这样:

f:
jmp .Ltmp0 ; 5 bytes
int3       ; 1 byte
int3       ; 1 byte
int3       ; 1 byte

g:
jmp .Ltmp1 ; 5 bytes
int3       ; 1 byte
int3       ; 1 byte
int3       ; 1 byte

h:
jmp .Ltmp2 ; 5 bytes
int3       ; 1 byte
int3       ; 1 byte
int3       ; 1 byte

.Ltmp0:
mov 0, %eax
ret

.Ltmp1:
mov 1, %eax
ret

.Ltmp2:
mov 2, %eax
ret

foo:
mov f, %eax
mov g, %edx
mov h, %ecx
ret

因为 f()、g()、h() 的地址在2的幂次方的等间距分布,并且函数类型不会重叠(与带有基类的类类型不同),所以我们通常可以应用 消除全1位向量的位向量检查 优化, 从而简化每个调用点的检查为范围和对齐检查。

消除全1位向量的位向量检查:如果位向量全为1,则位向量检查是多余的;我们只需要检查地址是否在范围内,并且对齐良好。如果填充虚拟表,则更有可能发生这种情况。

2. 实现案例

下面以 2.1章节 的 C 程序为例说明针对间接函数调用的CFI防护编译器的具体实现。先插入全局的向量表:

@.src = private unnamed_addr constant [12 x i8] c"cfi_icall.c\00", align 1
@anon.fad58de7366495db4650cfefac2fcd61.0 = private unnamed_addr constant { i16, i16, [12 x i8] } { i16 -1, i16 0, [12 x i8] c"'int (int)'\00" }
@anon.fad58de7366495db4650cfefac2fcd61.1 = private unnamed_addr global { i8, { ptr, i32, i32 }, ptr } { i8 4, { ptr, i32, i32 } { ptr @.src, i32 89, i32 12 }, ptr @anon.fad58de7366495db4650cfefac2fcd61.0 }

在函数调用前,进行类型检查:

检查的具体步骤:

总结

在本文中,我们描述了如何使用编译选项使能 CFI 防护,并具体介绍了间接函数调用的 CFI 防护的效果与具体防护的实现。

在 LLVM 社区,AMDGPU、RISCV、MSP430 等后端也在积极支持 CFI 功能。针对内核的CFI防护,一些新的不使用"跳转表"的防护方案也正在完善[14],这侧面说明,控制流完整性防护确实是一项重要的漏洞缓解措施,我们应在尽可能的情况下使用。毕昇编译器早已支持这些功能,在构建脚本上简单加上几个编译选项就能让程序得到更好的防护。

参考文档

(1)C. Cowan, C. Pu, D. Maier, J. Walpole, P. Bakke, S. Beattie, A. Grier, P. Wagle, Q. Zhang, and H. Hinton. StackGuard: Automatic adaptive detection and prevention of buffer-overflow attacks. In Proceedings of the Usenix Security Symposium, pages 63–78, 1998.

(2)O. Ruwase and M. Lam. A practical dynamic buffer overflow detector. In Proceedings of Network and Distributed System Security Symposium, 2004.

(3)PaX Project. The PaX project, 2004. http://pax.grsecurity.net/.

(4)J. Xu, Z. Kalbarczyk, and R. Iyer. Transparent runtime randomization for security. In Proceedings of the Symposium on Reliable and Distributed Systems, 2003

(5)G. Suh, J. W. Lee, D. Zhang, and S. Devadas. Secure program execution via dynamic information flow tracking. In Proceedings of the International Conference on Architectural Support for Programming Languages and Operating Systems, pages 85–96, 2004. http://csg.csail.mit.edu/pubs/memos/Memo-467/memo-467.pdf

(6)Lucas Davi, Ahmad-Reza Sadeghi, and Thorsten Holz. Control Flow Integrity: Principles, Implementations, and Applications

(7)https://static.googleusercontent.com/media/research.google.com/en//pubs/archive/42808.pdf

(8)https://www.chromium.org/developers/testing/control-flow-integrity/

(9)https://bugzilla.mozilla.org/show_bug.cgi?id=1302891

(10)https://learn.microsoft.com/en-us/windows/win32/secbp/control-flow-guard?redirectedfrom=MSDN

(11)https://clang.llvm.org/docs/ControlFlowIntegrity.html

(12)https://llvm.org/docs/GoldPlugin.html

(13)https://clang.llvm.org/docs/LTOVisibility.html

(14)https://lwn.net/Articles/898040/

Logo

鲲鹏展翅 立根铸魂 深耕行业数字化

更多推荐