Clang中的控制流完整性(CFI)详解:保护代码执行路径的技术指南

本文深入解析Clang中的控制流完整性(CFI)技术,包括其原理、多种保护选项(如间接调用、虚函数调用和类型转换检查),并通过实际示例展示如何防止内存破坏攻击和类型混淆漏洞,提升二进制程序的安全性。

让我们谈谈CFI:Clang版本 - The Trail of Bits博客

Artem Dinaburg
2016年10月17日
编译器、漏洞利用、缓解措施

我们之前的博客文章经常提到控制流完整性(CFI),但从未解释过CFI是什么、如何使用它,或者为什么你应该关心它。现在是时候弥补这一情况了!在这篇博客文章中,我们将从高层次解释CFI是什么、它做什么、不做什么,以及如何在你的项目中使用CFI。本篇博客文章中的示例是特定于Clang的,并已在Clang 3.9上测试,这是截至2016年10月的最新版本。

这篇文章将会很长,所以如果你已经知道CFI是什么,只是想在你用Clang编译的项目中使用它,这里有一个总结:

  • 确保你使用支持链接时优化(LTO)的链接器(如GNU gold或MacOS ld)。
  • 在构建和链接标志中添加-flto
  • 在构建标志中添加-fvisibility=hidden-fsanitize=cfi
  • 安心睡觉,知道你的二进制文件更受保护,免受二进制级漏洞利用。

有关在项目中使用CFI的示例,请查看我们CFI示例附带的Makefile。

什么是CFI?

控制流完整性(CFI)是一种漏洞利用缓解措施,类似于栈cookie、DEP和ASLR。与其他漏洞利用缓解措施一样,CFI的目标是防止错误转化为漏洞利用。程序中的错误,如缓冲区溢出、类型混淆或整数溢出,可能允许攻击者更改程序执行的代码,或乱序执行程序的部分内容。要将这些错误转化为漏洞利用,攻击者必须强制目标程序遵循程序员从未意图的代码路径。CFI通过减少攻击者这样做的能力来工作。理解CFI最简单的方法是,它旨在在运行时强制执行程序员在编译时的意图。

另一种理解CFI的方式是通过图。程序的控制流可以表示为一个图,称为控制流图(CFG)。CFG是一个有向图,其中每个节点是程序的一个基本块,每个有向边是一个可能的控制流转移。CFI确保程序在运行时遵循编译器在编译时确定的CFG,即使存在漏洞,否则这些漏洞将允许攻击者改变控制流。

还有更多技术细节,如前向边缘CFI、后向边缘CFI,但这些最好从众多关于控制流完整性的学术论文中吸收。

CFI的历史

微软研究院关于CFI的原始论文于2005年发布,自那以后,各种CFI方案的性能和功能都有了众多改进。持续的改进意味着CFI现在已成为主流:Clang编译器和Microsoft Visual Studio的最新版本都包含某种形式的CFI。

Clang的CFI

在这篇博客文章中,我们将查看Clang的CFI实现提供的各种选项,每个选项做什么和不保护什么,以及如何在你的项目中使用它。我们不会涵盖技术实现细节或性能数字;实现团队已经在他们的论文中提供了彻底的技术解释。

控制流完整性支持自版本3.7以来已进入主线Clang,作为支持的消毒剂套件的一部分调用。为了操作,CFI需要程序的全控制流图。由于程序通常由多个编译单元构建,全控制流直到链接时才可用。要启用CFI,Clang需要一个能够进行链接时优化的链接器。我们的代码示例假设一个Linux环境,因此我们将使用GNU gold链接器。GNU gold和最新版本的Clang都可以作为常见Linux发行版的软件包使用。GNU gold已包含在现代binutils软件包中;各种Linux发行版的Clang 3.9软件包可从LLVM软件包仓库获得。

Clang中的一些CFI选项实际上与控制流无关。相反,这些选项在无效转换或其他类似违规变成更严重的错误之前检测它们。然而,这些选项在精神上类似于CFI,因为它们确保“抽象完整性”——即,程序员意图发生的事情在运行时发生。

在Clang中使用CFI

Clang CFI文档还有很多需要改进的地方。我们将描述每个选项的作用、它的限制,以及使用它将防止漏洞利用的示例场景。这些说明假设Clang 3.9和一个支持LTO的链接器已安装并正常工作。一旦安装,链接器和Clang 3.9应该“正常工作”;具体的安装说明超出了本篇博客文章的范围。

你的项目需要几个新的编译和链接标志:-flto启用链接时优化,-fsanitize=cfi启用所有CFI检查,-fvisibility=hidden设置默认LTO可见性。对于调试构建,你还需要添加-fno-sanitize-trap=all以在检测到CFI违规时看到描述性错误消息。对于发布构建,省略此标志。

回顾一下,你的调试命令行现在应该看起来像:

1
clang-3.9 -fvisibility=hidden -flto -fno-sanitize-trap=all -fsanitize=cfi -o [output] [input]

而你的发布命令行应该看起来像:

1
clang-3.9 -fvisibility=hidden -flto -fsanitize=cfi -o [output] [input]

你很可能想启用每个CFI检查,但如果你只想启用选定的检查(每个在下一节中描述),通过-fsanitize=[option]在你的标志中指定它们。

CFI示例

我们创建了带有特别设计的错误的样本来测试每个CFI选项。所有样本都设计为在绝对最大警告级别(-Weverything)下干净编译。这些示例中的错误不是由编译器静态识别的,而是在运行时通过CFI检测的。在可能的情况下,我们模拟没有CFI保护时可能发生的恶意行为。

每个示例构建两个二进制文件,一个带有CFI保护(例如cfi_icall),一个没有CFI保护(例如no_cfi_icall)。这些二进制文件从相同的源代码构建,用于说明CFI保护的区别。

我们提供了以下示例:

  • cfi_icall演示间接调用的控制流完整性。示例二进制文件接受单个命令行参数(有效值为0-3,但尝试对两个二进制文件使用无效值!)。命令行参数显示间接调用CFI保护的不同方面,或缺乏保护。
  • cfi_vcall显示CFI应用于虚函数调用的示例。此示例演示CFI如何防止类型混淆或类似攻击。
  • cfi_nvcall显示Clang通过非定义这些函数的对象调用非虚成员函数的保护。
  • cfi_unrelated_cast显示Clang如何防止不相关类型对象之间的转换。
  • cfi_derived_cast扩展了cfi_unrelated_cast,并显示如果对象实际上不是派生类的对象,Clang如何防止从基类对象到派生类对象的转换。
  • cfi_cast_strict展示了默认级别的基到派生转换保护(如cfi_derived_cast中)不会捕获非法转换的非常具体的情况。

*好吧,我们撒谎了,我们必须禁用两个警告,一个关于C++98兼容性,一个关于虚函数内联定义。重点仍然有效,因为这些警告与潜在错误无关。

CFI选项:-fsanitize=cfi

此选项启用所有CFI检查。使用此选项!各种CFI保护只会在需要的地方插入;你不通过不使用此选项和选择特定保护来节省任何东西。所以如果你想启用CFI,使用-fsanitize=cfi

截至Clang 3.9,当前实现的CFI检查在以下部分中更详细地描述。

CFI选项:-fsanitize=cfi-icall

cfi-icall选项是最直接的CFI形式。在每个间接调用站点,如通过函数指针的调用,一个额外的检查验证两个条件:

  • 被调用的地址是一个有效目的地,如函数的开始
  • 目的地的静态函数签名与编译时确定的签名匹配。

这些条件何时会被违反?当利用内存破坏攻击时!攻击者想要劫持程序的控制流以执行他们的命令。如今,反漏洞利用保护足够好,迫使攻击者重用现有程序的片段。程序重用技术称为ROP,片段被称为小工具。小工具几乎从来不是整个函数,而是接近控制流转移指令的机器代码片段。重要方面是这些小工具不在函数的开始;尝试开始ROP执行的攻击者将失败CFI检查。

攻击者可能足够聪明,将新的函数指针指向一个有效的函数。例如,想想如果对write的调用被改为调用system会发生什么。第二个条件试图通过确保目的地的运行时类型签名必须落在预选目的地的列表中来缓解这些错误。这两种条件违规在cfi_icall示例的选项2和3中说明。

示例输出

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
$ ./no_cfi_icall 2
Calling a function:
CFI should protect transfer to here
In float_arg: (0.000000)
$ ./cfi_icall 2
Calling a function:
cfi_icall.c:83:12: runtime error: control flow integrity check for type 'int (int)' failed during indirect function call (cfi_icall+0x424610): note: (unknown) defined here
$ ./no_cfi_icall 3
Calling a function:
CFI ensures control flow only transfers to potentially valid destinations
In not_entry_point: (2)
$ ./cfi_icall 3
Calling a function:
cfi_icall.c:83:12: runtime error: control flow integrity check for type 'int (int)' failed during indirect function call
(cfi_icall+0x424730): note: (unknown) defined here

限制

  • 间接调用保护不跨越共享库边界;进入共享库的间接调用不受保护。
  • 所有翻译单元必须用-fsanitize=cfi-icall编译。
  • 仅在x86和x86_64架构上工作。
  • 间接调用保护不检测对相同函数签名的调用。想想将调用从delete_user(const char *username)改为make_admin(const char *username)。我们在cfi_icall选项1中显示此限制:
1
2
3
4
$ ./cfi_icall 1
Calling a function:
CFI will not protect transfer to here
In bad_int_arg: (1)

CFI选项:-fsanitize=cfi-vcall

要解释cfi-vcall,我们需要快速回顾虚函数。回想一下,虚函数是可以在派生类中专门化的函数。虚函数是动态绑定的——即,实际调用的函数在运行时确定,取决于对象的类型。由于动态绑定,所有虚调用都将是间接调用。但这些间接调用可能合法地调用具有不同签名的函数,因为类名是函数签名的一部分。cfi-vcall保护通过验证虚函数调用目的地始终是源对象类层次结构中的函数来解决这一差距。

那么这样的错误何时会发生?经典示例是复杂基于C++的软件中的类型混淆错误,如PDF阅读器、脚本解释器和Web浏览器。在类型混淆中,一个对象被重新解释为不同类型的对象。攻击者然后可以使用这种不匹配将虚函数调用重定向到攻击者控制的位置。cfi_vcall示例中模拟了这样的场景。

示例输出

1
2
3
4
5
6
7
8
9
$ ./no_cfi_vcall
Derived::printMe
CFI Prevents this control flow
Evil::makeAdmin
$ ./cfi_vcall
Derived::printMe
cfi_vcall.cpp:45:5: runtime error: control flow integrity check for type 'Derived' failed during virtual call (vtable address 0x00000042eb20)
0x00000042eb20: note: vtable is of type 'Evil'
00 00 00 00 c0 6f 42 00 00 00 00 00 d0 6f 42 00 00 00 00 00 00 70 42 00 00 00 00 00 00 00 00 00

限制

  • 仅适用于使用虚函数的C++代码。
  • 所有翻译单元必须用-fsanitize=cfi-vcall编译。
  • 输出二进制文件大小可能有明显增加。
  • 构建时需要指定-fvisibility标志(对于大多数目的,使用-fvisibility=hidden

CFI选项:-fsanitize=cfi-nvcall

cfi-nvcall选项在精神上类似于cfi-vcall选项,除了它适用于非虚调用。关键区别是非虚调用是编译时静态知道的直接调用,所以这种保护严格来说不是控制流完整性问题。cfi-nvcall选项做的是识别非虚调用并确保运行时调用对象的类型可以从编译时已知的对象类型派生。

简单来说,想象一个Balls的类层次结构和一个Bricks的类层次结构。使用cfi-nvcall,编译时对Ball::Throw的调用可能执行Baseball::Throw,但永远不会执行Brick::Throw,即使攻击者用Brick对象替换Ball对象。

cfi-nvcall修复的情况可能源于内存破坏、类型混淆和反序列化。虽然这些实例本身不允许攻击者重定向控制流,但这些错误可能导致仅数据攻击,或启用足够的恶行以允许未来的错误工作。cfi-nvcall示例中显示了使用仅数据错误的这种攻击类型:低权限用户对象被用来代替高权限管理员对象,导致应用程序内权限升级。

示例输出

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ ./no_cfi_nvcall
Admin check:
Account name is: admin
Would do admin work in context of: admin
User check:
Account name is: user
Admin Work not permitted for a user account!
Account name is: user
CFI Should prevent the actions below:
Would do admin work in context of: user
$ ./cfi_nvcall
Admin check:
Account name is: admin
Would do admin work in context of: admin
User check:
Account name is: user
Admin Work not permitted for a user account!
Account name is: user
CFI Should prevent the actions below:
cfi_nvcall.cpp:54:5: runtime error: control flow integrity check for type 'AdminAccount' failed during non-virtual call (vtable address 0x00000042f300)
0x00000042f300: note: vtable is of type 'UserAccount'
00 00 00 00 80 77 42 00 00 00 00 00 a0 77 42 00 00 00 00 00 90 d4 f0 00 00 00 00 00 41 f3 42 00

限制

  • cfi-nvcall检查仅适用于多态对象。
  • 所有翻译单元必须用-fsanitize=cfi-nvcall编译。
  • 构建时需要指定-fvisibility标志(对于大多数目的,使用-fvisibility=hidden

CFI选项:-fsanitize=cfi-unrelated-cast

这是三个与控制流完整性保护分组但与控制流无关的转换相关选项中的第一个。这些转换选项验证“抽象完整性”。使用这些转换检查防止可能最终导致控制流劫持的阴险C++错误。

cfi-unrelated-cast选项执行两个运行时检查。首先,它验证对象类型之间的转换必须在相同的类层次结构中。想想这允许从类型Ball*的变量转换为Baseball*,但不允许从类型Ball*的变量转换为Brick*。第二个运行时检查验证从void*到对象类型的转换引用该类型的对象。想想这确保指向Ball对象的类型void*的变量只能转换回Ball,而不是Brick

此属性最有效地在运行时验证,因为编译器被迫将所有从void*到另一种类型的转换视为合法。cfi-unrelated-cast选项确保这样的转换在程序的运行时上下文中有意义。

这种违规何时会发生?void*指针的常见用途是在程序的不同部分之间传递对象引用。经典示例是pthread_createarg参数。目标函数无法确定void*参数是否是正确类型。类似情况发生在复杂应用程序中,尤其是在那些使用IPC、队列或其他跨组件消息传递的应用程序中。cfi_unrelated_cast示例显示了一个受cfi-unrelated-cast选项保护的示例场景。

示例输出

1
2
3
4
5
6
7
$ ./no_cfi_unrelated_cast
I am in fooStuff
And I would execute: system("/bin/sh")
$ ./cfi_unrelated_cast
cfi_unrelated_cast.cpp:55:19: runtime error: control flow integrity check for type 'Foo' failed during cast to unrelated type (vtable address 0x00000042ec40)
0x00000042ec40: note: vtable is of type 'Bar'
00 00 00 00 70 71 42 00 00 00 00 00 a0 71 42 00 00 00 00 00 00 00 00 00 00 00 00 00 88 ec 42 00

限制

  • 所有翻译单元必须用cfi-unrelated-cast编译
  • 构建时需要指定-fvisibility标志(对于大多数目的,使用-fvisibility=hidden
  • 一些函数(例如分配器)合法地分配一种类型的内存,然后将其转换为不同的、不相关的对象。这些函数可以从保护中列入黑名单。

CFI选项:-fsanitize=cfi-derived-cast

这是三个转换相关“抽象完整性”选项中的第二个。cfi-derived-cast选项确保基类的对象不能被转换为派生类的对象,除非对象实际上是派生对象。作为示例,cfi-derived-cast将防止类型Ball*的变量被转换为Baseball*。这是比cfi-unrelated-cast更强的保证,后者验证目标类型与源在同一类层次结构中。

此问题的潜在原因与此列表上的大多数其他问题相同,即内存破坏、反序列化问题和类型混淆。在cfi_derived_cast示例中,我们展示了假设的基到派生转换错误如何用于披露内存内容。

示例输出

1
2
$ ./no_cfi_derived_cast
I am: derived class, my member variable is: 12345678
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计