让我们谈谈CFI:Clang版本 - The Trail of Bits博客
Artem Dinaburg
2016年10月17日
编译器,漏洞利用,防护措施
我们之前的博客文章经常提到控制流完整性(CFI),但从未解释过CFI是什么、如何使用它,或者为什么你应该关心它。现在是时候弥补这个情况了!在这篇博客文章中,我们将从高层次解释CFI是什么、它能做什么、不能做什么,以及如何在你的项目中使用CFI。本篇博客文章中的示例是特定于Clang的,并且已在Clang 3.9上测试过,这是截至2016年10月的最新版本。
这篇文章将会很长,所以如果你已经知道CFI是什么,只是想在你用Clang编译的项目中使用它,这里有一个总结:
- 确保你使用支持链接时优化的链接器(如GNU gold或MacOS ld)
- 在构建和链接标志中添加
-flto
- 在构建标志中添加
-fvisibility=hidden
和-fsanitize=cfi
- 安心睡觉,知道你的二进制文件更能抵御二进制级别的攻击
有关在项目中使用CFI的示例,请查看我们CFI示例附带的Makefile。
什么是CFI?
控制流完整性(CFI)是一种漏洞利用防护措施,类似于栈保护符(stack cookies)、数据执行保护(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,片段被称为小工具(gadgets)。小工具几乎从来不是完整的函数,而是接近控制流转移指令的机器代码片段。重要的方面是这些小工具不在函数的开始;试图开始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
示例中,我们展示了假设的基类到派生类转换错误如何用于泄露内存内容。
示例输出
|
|