控制流完整性

从 2016 年开始,Android 上大约 86% 的漏洞与内存安全相关。大多数漏洞被攻击者所利用,他们的目的是改变应用的正常控制流,获取遭利用的应用的所有权限来执行任意恶意活动。控制流完整性 (CFI) 是一种安全机制,它不允许更改已编译二进制文件的原始控制流图,因而执行此类攻击变得异常困难。

在 Android 8.1 中,我们在媒体堆栈中启用了 LLVM 的 CFI 实现。在 Android 9 中,我们在更多组件以及内核中启用了 CFI。系统 CFI 默认处于启用状态,但内核 CFI 需要您手动启用。

LLVM 的 CFI 需要使用链接时优化 (LTO) 进行编译。LTO 会一直保留对象文件的 LLVM 位码表示法直至链接时,以便编译器更好地推断可以执行哪些优化。启用 LTO 可缩减最终二进制文件的大小并提高性能,但会增加编译时间。在 Android 上进行测试时,结合使用 LTO 和 CFI 对代码大小和性能开销的影响微乎其微;在少数情况下,这两者都会有所改善。

有关 CFI 以及如何处理其他前向控制检查的更多技术详情,请参阅 LLVM 设计文档

示例和源代码

CFI 由编译器提供,在编译时将插桩添加到二进制文件中。我们支持 Clang 工具链中的 CFI 和 AOSP 中的 Android 构建系统。

对于 Arm64 设备上位于 /platform/build/target/product/cfi-common.mk 的一组组件,CFI 默认处于启用状态。在一组媒体组件的 makefile/blueprint 文件(如 /platform/frameworks/av/media/libmedia/Android.bp/platform/frameworks/av/cmds/stagefright/Android.mk)中,也直接启用了 CFI。

实现系统 CFI

如果您使用 Clang 和 Android 构建系统,CFI 默认处于启用状态。因为 CFI 有助于确保 Android 用户的安全,所以不应将其停用。

事实上,我们强烈建议您为更多组件启用 CFI。理想的候选对象是特权原生代码,或可处理不可信用户输入的原生代码。如果您使用 Clang 和 Android 构建系统,则可以通过向 makefile 或 blueprint 文件中添加几行代码,在新组件中启用 CFI。

在 makefile 中启用 CFI

如需在 makefile(如 /platform/frameworks/av/cmds/stagefright/Android.mk)中启用 CFI,请添加以下几行代码:

LOCAL_SANITIZE := cfi
# Optional features
LOCAL_SANITIZE_DIAG := cfi
LOCAL_SANITIZE_BLACKLIST := cfi_blacklist.txt

  • LOCAL_SANITIZE 可在构建过程中将 CFI 指定为排错程序。
  • LOCAL_SANITIZE_DIAG 可开启 CFI 的诊断模式。诊断模式会在崩溃期间在 logcat 中输出额外的调试信息,这在开发和测试 build 时很有用。不过,请务必对正式版 build 移除诊断模式。
  • LOCAL_SANITIZE_BLACKLIST 支持组件针对个别函数或源代码文件选择性地停用 CFI 插桩。在万不得已时,您可以使用黑名单来修复用户会遇到的可能以其他方式存在的任何问题。如需了解详情,请参阅停用 CFI

在 blueprint 文件中启用 CFI

如需在 blueprint 文件(如 /platform/frameworks/av/media/libmedia/Android.bp)中启用 CFI,请添加以下几行代码:

   sanitize: {
        cfi: true,
        diag: {
            cfi: true,
        },
        blacklist: "cfi_blacklist.txt",
    },

问题排查

如果您在新组件中启用 CFI,则可能会遇到以下问题:函数类型不匹配错误和汇编代码类型不匹配错误。

之所以发生函数类型不匹配错误,是因为 CFI 将间接调用限制为仅跳转到如下函数:与调用中使用的静态类型具有相同的动态类型。CFI 将虚拟和非虚拟成员函数调用限制为仅跳转到如下对象:用于发出调用的对象的静态类型的派生类。这意味着,如果您的代码违反这些假设中的任何一个,CFI 添加的插桩将会中止。例如,堆栈轨迹显示 SIGABRT,logcat 则包含一行有关控制流完整性发现了不匹配错误的信息。

如需修复此问题,请确保调用的函数具有静态声明的类型。以下是两个示例 CL:

另一个可能的问题是尝试在包含对汇编代码的间接调用的代码中启用 CFI。由于未指定汇编代码的类型,因此这会导致类型不匹配。

如需修复此问题,请为每个汇编调用创建原生代码封装容器,并为封装容器提供与调用指针相同的函数签名。然后,封装容器便可直接调用汇编代码。由于直接分支不由 CFI 进行插桩测试(在运行时不能重新指向它们,所以不会产生安全风险),因此这样做可以修复此问题。

如果存在太多汇编函数,因而无法将存在问题的函数全部修复,您还可以将包含对汇编代码的间接调用的所有函数列入黑名单。我们建议不要采用这种做法,因为这样做会停用对这些函数的 CFI 检查,进而暴露攻击面。

停用 CFI

我们并未监测到任何性能开销,因此您不需要停用 CFI。不过,如果用户会受到影响,您可以通过在编译时提供排错程序黑名单文件,针对个别函数或源文件选择性地停用 CFI。该黑名单会指示编译器停用指定位置的 CFI 插桩。

Android 构建系统为 Make 和 Soong 提供对每个组件黑名单(允许您选择不接收 CFI 插桩的源文件或个别函数)的支持。如需详细了解黑名单文件的格式,请参阅上游 Clang 文档

验证

目前,没有专门针对 CFI 的 CTS 测试。不过,您可以确保无论是否启用 CFI,均能通过 CTS 测试,从而确认 CFI 不会影响设备。