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

本文探讨了在osquery项目中应用控制流完整性(CFI)安全缓解措施的实际挑战,对比了ClangCFI和CFGuard的技术差异、开发成本及兼容性问题,揭示了严格安全策略与工程可行性之间的权衡。

部署安全缓解措施的挑战

计划

我们原本计划简单直接:为Windows版本的osquery启用CFGuard,为Linux版本启用ClangCFI。通过对比osquery测试套件在受保护和未受保护构建下的表现,进行定量测量。我们打算将补丁贡献回osquery代码库,最终写出一篇精彩的博客文章,并让osquery更加安全。

Windows版本的osquery启用CFGuard只用了大约15分钟。相关更改仅涉及一个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 设计