Clang/LLVM与Rust中的控制流防护技术解析

本文详细介绍了微软为Clang/LLVM和Rust编译器新增的Control Flow Guard(CFG)支持技术。CFG通过运行时检查间接分支目标地址来增强控制流完整性,有效防御代码复用攻击。文章包含技术实现原理、Rust与C/C++互操作案例、性能开销数据及未来发展方向。

Control Flow Guard for Clang/LLVM and Rust

作为我们持续推动更安全系统编程的一部分,我们很高兴宣布Windows控制流防护(CFG)支持现已可在Clang C/C++编译器和Rust中使用。

什么是控制流防护?

CFG是一种平台安全技术,旨在强制执行控制流完整性。自Windows 8.1以来即可用,现已在Windows 10中广泛使用。例如,给定初始内存安全漏洞,攻击者可能尝试发起代码复用攻击。这几乎总是要求攻击者改变程序的控制流,换句话说,违反控制流完整性。例如,攻击者可能尝试损坏函数指针,以便从程序代码中的任意位置继续执行。

CFG旨在通过强制执行粗粒度前向边缘控制流完整性来缓解此类漏洞利用。具体来说,它在允许分支完成之前使用运行时检查来验证每个间接分支指令(调用、跳转等)的目标地址。为实现这一点,CFG要求编译器做两件事:在适当位置添加运行时检查,并提供有效的间接分支目标列表。在编译期间,编译器识别所有间接分支并在每个此类分支上添加CFG检查。它还发出包含所有地址获取函数的相对地址的元数据。在运行时,如果二进制文件在支持CFG的操作系统上运行,加载器使用此CFG元数据生成地址空间的位图,并标记哪些地址包含有效的分支目标。在每个间接分支上,插入的代码检查目标地址是否在此位图中标记,如果没有,则终止进程。

CFG与其他漏洞利用缓解措施(如地址空间布局随机化(ASLR)和数据执行预防(DEP))互补。以前CFG仅适用于使用Microsoft Visual C++编译的C/C++代码。

LLVM和Clang中的控制流防护

LLVM项目是一组模块化和可重用的编译器和工具链技术。特别是,LLVM核心库为各种不同的编译器前端(包括Clang C/C++编译器和rustc,Rust的编译器)形成了一个独立于语言的基础。核心库包括一组通用的优化,并为多个CPU架构提供机器代码生成。

LLVM 10.0现在支持CFG。我们的CFG实现完全包含在核心库中,使其可被任何基于LLVM构建的编译器重用——前端编译器只需设置正确的标志。我们的实现支持CFG当前可用的所有目标架构,即x86(32位和64位)、ARM和Aarch64。我们已为C/C++项目向Clang 10.0添加了CFG支持。要在您的项目中启用CFG,只需使用-cfguard cc1选项(例如-Xclang -cfguard),或者如果您使用clang-cl兼容性驱动程序,使用与MSVC中相同的/guard:cf标志。Clang还支持__declspec(guard(nocf))修饰符以在指定函数中省略CFG检查,但这应仅在绝对必要时使用,因为它可能允许漏洞利用。

由于Chromium代码库是用Clang编译的,Chromium团队正在努力在Windows构建中启用CFG,作为在Google Chrome和Microsoft Edge中采用的第一步。

Rust中的控制流防护

正如之前的文章所解释的,微软正在探索使用Rust作为安全的系统编程语言。以前,Rust不支持CFG,这可能是其在我们软件内部使用的障碍。Rust的主要卖点之一是其所有权模型提供了强大的内存安全保证,这应防止用作漏洞利用起点的漏洞。那么为什么这样的语言需要像CFG这样的漏洞利用缓解措施?有两种主要情况您会希望在Rust中使用CFG:

  • 代码库中Rust与C/C++共存,
  • 包含任何不安全代码的纯Rust代码库。

1. Rust与C/C++链接

启用CFG的第一种情况是每当Rust与C/C++代码互操作时,无论是Rust程序调用C/C++库,还是反之。下面的简单Rust示例演示了两种调用add_one()函数的方式:要么通过名称直接调用,要么通过从main()传入的函数指针间接调用。有一个由外部C库提供的init()函数。C库编译时启用了CFG。正如预期的那样,C函数调用在一个不安全块中。然而,函数指针(fptr)从未由此不安全代码处理,因此我们不会期望它被修改。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#[link(name = "init")]
extern "C" { fn init(y: u32); }

// 一个简单的递增整数的函数
fn add_one(x: &mut i32) {
    *x += 1;
}

fn do_math(fptr: fn(&mut i32)) {
    unsafe{init(add_one as u32);}

    let mut x = 1;
    add_one(&mut x);
    println!("Calling function by name: 1 + 1 = {}", x);

    let mut x = 1;
    fptr(&mut x);
    println!("Calling via function pointer: 1 + 1 = {}", x);
}

fn main() {
    do_math(add_one);
}

但当我们运行这个简单程序时,结果令人惊讶:

1
2
Calling function by name: 1 + 1 = 2
Calling via function pointer: 1 + 1 = 1

在这个例子中,init()函数超越其堆栈帧并损坏堆栈,使fptr指向add_one()函数中间的某个位置(如果重现此示例,您可能需要更改0x2D偏移量,取决于您的平台和编译器设置)。

1
2
3
4
5
6
7
8
#include <stdint.h>
void init(uint32_t y) {
    for (uint32_t n = 0x01; n < 0x20; n++) {
        if (*(&y + n) == y) { 
            *(&y + n) += 0x2D;
        }
    }
}

虽然这是一个非常人为的例子(希望没有真实代码库包含此代码),但它说明了攻击者可能在链接的C/C++代码中找到内存损坏漏洞并使用它来违反(安全)Rust代码中的控制流完整性的可能性。即使C/C++代码编译时启用了CFG,我们还需要为Rust代码启用CFG以缓解此漏洞。

此外,即使您没有显式链接到C/C++,任何用户空间Rust程序很可能在底层使用操作系统提供的C/C++功能(例如,如上所述打印输出到终端),因此启用CFG总是一个好主意。

2. 使用不安全代码的纯Rust代码库

启用CFG的第二种情况是在使用任何不安全代码的纯Rust代码库中。类似于上面的例子,init()函数可能是一个纯Rust函数,包含不安全代码,修改堆栈上的函数指针,如下所示。

1
2
3
4
5
6
fn init(y: u32) {
    for n in 0x20..0x50 {
        unsafe { if *(&y as *const u32).offset(n) == y { 
            *((&y as *const u32).offset(n) as *mut _) = y + 0x2D }; };
    }
}

同样,这是一个人为的例子,但它说明了不安全Rust代码中的漏洞可能影响(安全)Rust程序其他部分的可能性。如上例所示,这也通过为Rust启用CFG来缓解。

除了使用不安全代码的代码库外,CFG还可以帮助缓解由Rust核心语言或标准库中的错误引起的漏洞。

如何为Rust启用CFG?

CFG在Rust 1.47(当前夜间版本)中可用。要启用CFG,只需添加-C control-flow-guard标志。如果您使用cargo构建,可以使用rustc命令cargo rustc – -C control-flow-guard启用CFG。重要的是,要获得完全保护,您需要使用也启用了CFG的Rust标准库版本。目前,预构建的标准库版本中尚未启用CFG,但您可以通过在config.toml文件中添加control-flow-guard = true来在您自己的构建中启用它。

控制流防护的开销

启用此类控制流完整性强制执行通常会在二进制大小和运行时性能方面产生一些开销。CFG经过高度优化以最小化这两个方面。MSVC和LLVM实现产生非常相似的开销,因为两者使用相同的操作系统提供的检查逻辑。任何开销的大小取决于被编译程序中间接调用的数量和频率。例如,为Rust标准库启用CFG会使二进制大小增加约0.14%。在用Clang/LLVM编译的SPEC CPU 2017整数速度基准测试套件中启用CFG会产生高达8%的近似运行时开销,几何平均为2.9%,如下表所示:

基准测试 无CFG(秒) 有CFG(秒) 开销
600.perlbench_s 314 322 2.5%
602.gcc_s 538 546 1.5%
605.mcf_s 723 767 6.1%
620.omnetpp_s 486 521 7.2%
623.xalancbmk_s 225 243 8.0%
625.x264_s 186 193 3.8%
631.deepsjeng_s 326 323 -0.9%
641.leela_s 435 428 -1.6%
657.xz_s 487 488 0.2%
几何平均 381.6 392.7 2.9%

这些基准测试在Intel Xeon W-2155 CPU @ 3.30GHz上运行,使用clang-cl和Windows/MSVC的默认SPEC CPU标志。引用的时间是三次运行的中位数。648.exchange2s基准测试需要Fortran,因此未包括。631.deepsjeng_s和641.leela_s基准测试的性能在启用CFG时实际上有所改善,可能是由于更好的缓存对齐。

展望未来

控制流完整性是一个重要主题,学术文献和现实世界系统中都提出了许多解决方案。一些方法提供更细粒度的强制执行,以便进一步减少有效分支目标的集合。例如,微软最近宣布了XFG,作为CFG的继任者。其他解决方案利用新的CPU硬件,如英特尔最近宣布的CET,一旦广泛部署,可能会进一步改善性能。CFG只是这个设计空间中的一个点,尽管有其他解决方案即将出现,CFG仍然可以帮助防御漏洞利用,并且今天在所有Windows 10设备上可用。

致谢

与LLVM和Rust开源社区的合作是一次非常积极的经历。我们特别感谢那些通过设计建议、代码审查和其他建议为这项工作做出贡献的社区成员。

Andrew Paverd, 高级研究员, MSRC & Microsoft Research

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计