让我们谈谈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违规时看到描述性错误消息。对于发布构建,省略此标志。
回顾一下,你的调试命令行现在应该看起来像:
|
|
而你的发布命令行应该看起来像:
|
|
你很可能想启用每个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中说明。
示例输出
|
|
限制
- 间接调用保护不跨越共享库边界;进入共享库的间接调用不受保护。
- 所有翻译单元必须用
-fsanitize=cfi-icall
编译。 - 仅在x86和x86_64架构上工作。
- 间接调用保护不检测对相同函数签名的调用。想想将调用从
delete_user(const char *username)
改为make_admin(const char *username)
。我们在cfi_icall
选项1中显示此限制:
|
|
CFI选项:-fsanitize=cfi-vcall
要解释cfi-vcall
,我们需要快速回顾虚函数。回想一下,虚函数是可以在派生类中专门化的函数。虚函数是动态绑定的——即,实际调用的函数在运行时确定,取决于对象的类型。由于动态绑定,所有虚调用都将是间接调用。但这些间接调用可能合法地调用具有不同签名的函数,因为类名是函数签名的一部分。cfi-vcall
保护通过验证虚函数调用目的地始终是源对象类层次结构中的函数来解决这一差距。
那么这样的错误何时会发生?经典示例是复杂基于C++的软件中的类型混淆错误,如PDF阅读器、脚本解释器和Web浏览器。在类型混淆中,一个对象被重新解释为不同类型的对象。攻击者然后可以使用这种不匹配将虚函数调用重定向到攻击者控制的位置。cfi_vcall
示例中模拟了这样的场景。
示例输出
|
|
限制
- 仅适用于使用虚函数的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
示例中显示了使用仅数据错误的这种攻击类型:低权限用户对象被用来代替高权限管理员对象,导致应用程序内权限升级。
示例输出
|
|
限制
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_create
的arg
参数。目标函数无法确定void*
参数是否是正确类型。类似情况发生在复杂应用程序中,尤其是在那些使用IPC、队列或其他跨组件消息传递的应用程序中。cfi_unrelated_cast
示例显示了一个受cfi-unrelated-cast
选项保护的示例场景。
示例输出
|
|
限制
- 所有翻译单元必须用
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
示例中,我们展示了假设的基到派生转换错误如何用于披露内存内容。
示例输出
|
|