利用内核的常用方法是使用错误来覆盖存储在内存中的函数指针,例如存储了回调函数的指针,或已被推送到堆栈的返回地址。这允许攻击者执行任意内核代码来完成利用,即使他们不能注入自己的可执行代码。这种获取代码执行能力的方法在内核中特别受欢迎,因为它使用了大量的函数指针,以及使代码注入更具挑战性的现有内存保护机制。
CFI 尝试通过添加额外的检查来确认内核控制流停留在预先设计的版图中,以便缓解这类攻击。尽管这无法阻止攻击者利用一个已存在的 bug 获取写入权限,从而更改函数指针,但它会严格限制可被其有效调用的目标,这使得攻击者在实践中利用漏洞的过程变得更加困难。
[[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-urC8Inqg-1631076756697)(https://user-gold-cdn.xitu.io/2018/12/17/167bad5fceff00b3?imageView2/0/w/1280/h/960/ignore-error/1)]
](https://user-gold-cdn.xitu.io/2018/12/17/167bad5fceff00b3?w=1600&h=845&f=png&s=163276)
图 1. 在 Android 设备内核中,LLVM 的 CFI 将 55% 的间接调用限制为最多 5 个可能的目标,80% 限制为最多 20 个目标。
为了确定每个间接分支的所有有效调用目标,编译器需要立即查看所有内核代码。传统上,编译器一次处理单个编译单元(源代文件),并将目标文件合并到链接器。LLVM 的 CFI 要求使用 LTO,其编译器为所有 C 编译单元生成特定于 LLVM 的 bitcode,并且 LTO 感知链接器使用 LLVM 后端来组合 bitcode,并将其编译为本机代码。
[[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zt6J0DNl-1631076756700)(https://user-gold-cdn.xitu.io/2018/12/17/167bad5fcdfa7e50?imageView2/0/w/1280/h/960/ignore-error/1)]
](https://user-gold-cdn.xitu.io/2018/12/17/167bad5fcdfa7e50?w=1600&h=868&f=png&s=105589)
图 2. LTO 在内核中的工作原理的简单概述。所有 LLVM bitcode 在链接时被组合,优化并生成本机代码。
几十年来,Linux 一直使用 GNU 工具链来汇编,编译和链接内核。虽然我们继续将 GNU 汇编程序用于独立的汇编代码,但 LTO 要求我们切换到 LLVM 的集成汇编程序以进行内联汇编,并将 GNU gold 或 LLVM 自己的 lld 作为链接器。在巨大的软件项目上切换到未经测试的工具链会导致兼容性问题,我们已经在内核版本 4.9 和 4.14 的 arm64 LTO 补丁集中解决了这些问题。
除了使 CFI 成为可能,由于全局优化,LTO 还可以生成更快的代码。但额外的优化通常会导致更大的二进制尺寸,这在资源受限的设备上可能是不需要的。禁用 LTO 特定的优化(比如全局内联和循环展开)可以通过牺牲一些性能收益来减少二进制尺寸。使用 GNU gold 时,可以通过以下方式设置 LDFLAGS 来禁用上述优化:
LDFLAGS += -plugin-opt=-inline-threshold=0 \ -plugin-opt=-unroll-threshold=0
注意,禁用单个优化的标志不是稳定 LLVM 接口的一部分,在将来的编译器版本中可能会更改。
LLVM 的 CFI 实现在每个间接分支之前添加一个检查,以确认目标地址指向一个拥有有效签名的函数。这可以防止一个间接分支跳转到任意代码位置,甚至限制可以调用的函数。由于 C 编译器没有对间接分支强制执行类似限制,函数类型声明不匹配导致了几个 CFI 违规,即使在我们在内核的 CFI 补丁集中解决的内核 4.9 和 4.14 中也是如此。
内核模块为 CFI 添加了另一个复杂功能,因为它们在运行时加载,并且可以独立于内核的其它部分进行编译。为了支持可加载模块,我们在内核中实现了 LLVM 的 cross-DSO CFI 支持,包括用来加速跨模块查找的 CFI 影子。在使用 cross-DSO 支持进行编译时,每个内核模块都会包含有关有效本地分支目标的信息,内核根据目标地址和模块的内存布局从正确的模块中查找信息。
[[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yguXtoXk-1631076756701)(https://user-gold-cdn.xitu.io/2018/12/17/167bad5fd0ab06a0?imageView2/0/w/1280/h/960/ignore-error/1)]
](https://user-gold-cdn.xitu.io/2018/12/17/167bad5fd0ab06a0?w=1600&h=697&f=png&s=215332)
图 3. 注入 arm64 内核的 cross-DSO CFI 检查示例。类型信息在 X0 中传递,目标地址在 X1 中验证。
CFI 检查会给间接分支增加一些开销,但由于更积极的优化,我们的测试表明影响很小,在很多情况下整体系统性能甚至提高了 1-2%。
arm64 中的 CFI 需要 clang 版本 >= 5.0 并且 binutils >= 2.27。内核构建系统还假定 LLVMgold.so 插件在 LD_LIBRARY_PATH 中可用。clang 和 binutils 预构建工具链二进制文件可在 AOSP 获得,也可使用上游二进制文件。
启用内核 CFI 需要开启以下内核配置选项:
CONFIG_LTO_CLANG=y CONFIG_CFI_CLANG=y
在调试 CFI 违规或设备启动期间,使用 CONFIG_CFI_PERMISSIVE=y 可能会有所帮助。此选项将违规转换为警告而不是内核恐慌。
如前一节所述,我们在 Pixel 3 上启用 CFI 时遇到的最常见问题是由函数指针类型不匹配引起的良性违规。当内核遇到这种违规时,它会打印出一个运行时警告,其中包含失败时的调用堆栈,以及未通过 CFI 检查的目标调用。更改代码以使用正确的函数指针类型可以解决问题。虽然我们已经修复了 Android 内核中所有已知的间接分支类型不匹配的问题,但在设备特定的驱动程序中仍然可能发现类似的问题,例如。
CFI failure (target: [<fffffff3e83d4d80>] my_target_function+0x0/0xd80): ------------[ cut here ]------------ kernel BUG at kernel/cfi.c:32! Internal error: Oops - BUG: 0 [#1] PREEMPT SMP … 调用堆栈: … [<ffffff8752d00084>] handle_cfi_failure+0x20/0x28 [<ffffff8752d00268>] my_buggy_function+0x0/0x10 …
图 4. CFI 故障引起的内核恐慌示例
[] my_buggy_function+0x0/0x10
…
图 4. CFI 故障引起的内核恐慌示例