部署安全缓解措施的技术挑战:ClangCFI与CFGuard的对比分析

本文详细分析了ClangCFI与CFGuard在部署控制流完整性(CFI)时的技术差异,包括严格性、全程序分析要求及对现有代码库的兼容性,并探讨了实际应用中的开发成本与可用性挑战。

部署安全缓解措施的挑战

计划

计划很简单:我们为Windows版本的osquery启用CFGuard,为Linux版本的osquery启用ClangCFI。通过osquery的测试套件比较受保护和未受保护构建之间的差异,作为量化测量。我们将贡献补丁回osquery代码库,从而产生一篇优秀的博客文章和更安全的osquery。

我们在大约15分钟内就让Windows版本的osquery在CFGuard下运行。这是为Windows启用CFGuard的拉取请求。更改仅涉及一个CMake脚本中的两行代码。

然而,经过数周的努力,我们仍未能在Linux构建中启用ClangCFI。这种差异直接源于善意的安全选择所带来的深远影响。这些努力并非徒劳;我们报告了两个clang错误(一个两个),遇到了一个最近已解决的问题,并与clang开发人员进行了非常有见地的对话。他们非常耐心地解释了ClangCFI的细节,识别了我们遇到的问题,并慷慨提供了调试帮助。

让我们逐步分析每个安全选择及其带来的后果。

ClangCFI比CFGuard更严格

对于每个受保护的间接调用,ClangCFI允许的有效目标比CFGuard少。这是好事:更少的目标意味着将漏洞转化为利用的途径更少。ClangCFI还比CFGuard检测更多潜在错误(例如,强制转换检查,确保虚拟调用目标位于对象层次结构中,等等)。

图1: 使用间接调用示例(ClangCFI,CFGuard)展示有效调用目标的差异示例。真正的有效目标以绿色突出显示,其余为红色。

每种CFI方案允许的具体内容对可用性有重要影响。对于ClangCFI,间接调用目标必须与调用点的类型签名匹配。ClangCFI的虚拟方法调用检查甚至更严格。例如,ClangCFI检查目标方法是否属于同一对象层次结构。对于CFGuard,间接调用目标可以是有效的函数入口点[1]。

图2: ClangCFI、CFGuard的有效间接调用目标的理想化视图,以及它们与(理想化的)有效间接调用目标集的比较。

ClangCFI的类型签名验证和虚拟方法调用检查需要全程序分析。全程序分析需求导致两个额外要求:

  • 通常,组成最终程序的每个链接对象和静态库都必须启用CFI构建[2]。
  • 使用ClangCFI时需要链接时优化(LTO),因为全程序分析直到链接时才有可能。

这些新要求是合理的:要求所有内容启用CFI确保程序的任何部分都受到保护。LTO不仅允许全程序分析,还允许全程序优化,可能抵消CFI相关的性能损失。

CFGuard使用的较宽松验证标准安全性较低,但不需要全程序分析。使用CFGuard构建的对象验证间接调用;未使用CFGuard构建的对象则不验证。两种对象可以在同一程序中共存。然而,链接器必须了解CFGuard,以便在PE头中发出具有适当表和标志的二进制文件。

ClangCFI是全有或全无。CFGuard是增量的。

通常,ClangCFI必须为程序中的每个对象文件和静态库启用:将启用CFI的代码与非CFI代码链接是错误的[2]。这个错误容易犯但难以识别,因为链接器不检查对象的ClangCFI保护。链接器不会报告错误,但生成的可执行文件将无法通过运行时CFI检查。

表1: 使用ClangCFI时的有效链接。这些链接在一般情况下是有效的,假设链接项之间存在间接调用。跨动态共享对象(DSO)的调用在使用实验性-f[no-]sanitize-cfi-cross-dso标志的情况下是有效的。

Osquery按设计静态链接每个依赖项,包括libc++。这些依赖项又静态链接其他依赖项,依此类推。要为osquery启用ClangCFI,我们必须为整个osquery依赖树启用ClangCFI。正如我们将在下一节中看到的,这是一项艰巨的任务。我们无法为这篇博客文章证明这种时间投入是合理的,尽管我们希望在将来这样做。

CFGuard可以在每个编译单元级别应用。CFGuard的文档明确提到允许混合使用启用CFG和未启用CFG的对象和库[3]。跨DSO(即Windows术语中的DLL)的调用完全支持。这种灵活性对于为osquery启用CFGuard至关重要;我们为osquery本身启用了CFGuard,并链接到现有的未受保护的依赖项。幸运的是,Windows附带了受CFGuard保护的系统库,当主程序映像支持CFGuard时会使用这些库。未受保护的代码仅限于构建osquery时使用的静态库。

ClangCFI对某些代码库过于严格

ClangCFI对某些代码库过于严格。这不是clang的错:一些代码使用了可能不完全符合严格标准的快捷方式和便利性。我们在尝试为strongSwan启用ClangCFI时遇到了这个问题。我们的目标是尝试一个比osquery更小的示例,并为我们的VPN解决方案Algo创建一个安全增强版本的strongSwan。

图3: 真实现有代码与ClangCFI和CFGuard的间接调用目标的关系。存在程序员预期的有效目标,这些目标超出了ClangCFI和CFGuard定义的域。

我们无法创建启用CFI的strongSwan版本,因为strongSwan的核心组件libstrongswan使用了一种类似OOP的C系统。该系统用接口包装大多数间接调用,这些接口无法通过ClangCFI的严格检查。ClangCFI在技术上是正确的:调用者和被调用者的类型签名应该匹配。实际上,存在类型签名不匹配的已发布代码。

幸运的是,ClangCFI有一个放松严格性的功能:CFI黑名单。黑名单将禁用与正则表达式匹配的源文件、函数或类型的CFI检查。不幸的是,在这种情况下,几乎每个间接调用点都必须被列入黑名单,使得CFI实际上无用。

CFGuard不太可能引起相同的问题:(可能)存在一些间接调用到函数中间的代码,但此类代码比类型签名不匹配的代码罕见得多。

结论

从安全角度来看,ClangCFI比CFGuard“更好”。它更严格,要求保护整个程序,并测试更多运行时错误。利用ClangCFI保护大型复杂代码库是可能的:优秀的Google Chrome团队做到了这一点。然而,增强的安全性带来了高昂的成本。启用ClangCFI可能变成一项复杂的任务,需要大量的开发时间和严格的测试。

相反,CFGuard灵活得多。程序可以混合受保护和未受保护的代码,并且CFGuard不太可能破坏现有代码。这些妥协使得CFGuard更容易为现有代码库启用。

我们使用ClangCFI和CFGuard的经验反映了这些权衡。启用ClangCFI的osquery比启用CFGuard的osquery更安全。然而,启用CFGuard的Windows版osquery现在已经存在。经过数周的试验和错误,启用ClangCFI的Linux版osquery仍在进行中。


[1] 这并不严格正确。例如,被抑制的函数是函数入口点,但是无效的间接调用目标。

[2] 再次强调,这并不严格正确;混合规则有特定的例外。例如,CFI支持库不是用CFI构建的。如果非CFI对象中的每个函数仅被直接调用,链接CFI和非CFI对象是可以的。参见Evgeniy Stepanov的此评论

[3] 来自此页面:“…混合启用CFG和未启用CFG的代码将正常执行。”

如果您喜欢这篇文章,请分享: Twitter LinkedIn GitHub Mastodon Hacker News

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