改进 FreeBSD 中的内存权限
作者:Brooks Davis
进程的虚拟地址空间包含若干映射到内存中的物理页。这些页可能来自程序、库、普通文件,或初始为零页的匿名页。这些映射维护在转译后备缓冲器(TLB)中。在现代架构上,TLB 允许页以读、写、执行权限的组合进行映射。这使得进程间可以只读共享代码和数据,以提高物理内存利用率。
较旧的架构(如 MIPS、早期 i386)只支持读和写权限,但现代 CPU 通常还支持执行权限。正确使用执行权限可以缓解许多常见的安全漏洞。例如,过去常见的攻击方式是将代码(通常称为 shellcode)写入栈上未正确检查边界的字符串中,然后修改函数的保存返回地址使其指向该字符串。通过移除栈的执行权限,可以防止这种攻击。大多数 FreeBSD 架构都这样做。
不出所料,破解了简单的基于栈的攻击后,攻击者会寻找其他漏洞。最简单的下一步是找到一种方法将代码写入已映射为可执行的页,然后破坏栈使返回地址指向它。一种流行的缓解措施是写异或执行策略(W^X)。该策略禁止同时映射具有写和执行权限的页。对于大多数程序,这无需修改程序即可工作(运行时链接器除外),但有些程序如 Java 虚拟机和 Web 浏览器使用即时编译器(JIT)生成并运行代码。这些 JIT 对实现合理的性能至关重要,但简单实现的话无法与 W^X 配合。幸运的是,通常只需将页映射为可写、写入生成的代码、然后使其可执行即可。不过,并非所有程序都能轻易转换,因此 W^X 实现通常提供一种为特定程序禁用 W^X 的方式。
FreeBSD 目前不支持 W^X,但相关工作正在进行中。主要难点在于实现一个合适的框架来标记必须退出的二进制文件,并提供机制来测试加入或退出。我们已经添加了一种通用机制(和 ELF note)用于在二进制文件中设置加入和退出位,以及 procctl 中的标志,允许在程序的给定执行中启用或禁用功能。我们预计 W^X 将在 FreeBSD 13 中可用,并希望默认启用(至少对新程序)。后者将取决于我们对测试现有软件的信心。
设置最大页权限
在内核中,每个页都有一组权限和一组最大权限。例如,由进程可读但不可写的文件支持的页,在两组权限中都不包含写权限。匿名页在 FreeBSD 中所有情况下都有读、写和执行权限。文件支持的页取决于进程对文件的访问权限以及打开文件描述符时施加的限制。
虽然内核知道这些权限,但操作页权限的标准 API——mmap() 和 mprotect()——并不知道。这导致默认最大权限过大。例如,通过 mmap() 为 malloc() 分配的页默认有读和写权限,但可以通过 mprotect() 设为可执行。几乎没有任何情况需要这样做,但目前无法阻止。
转向 CHERI
在讨论此问题的解决方案之前,我们先考虑另一个问题。CHERI 架构添加了一种新的硬件类型——capability。这些 capability(不要与 Capsicum 的 capability 混淆)授予对虚拟内存区域的访问。它们指定了可访问地址的范围以及一组权限——加载、存储和执行——与页权限类似,但粒度为字节而非页(通常 4KiB)。CHERI capability 设计为在 C 指针中使用。在 CheriABI 的工作中,我们创建了一个基于 FreeBSD 的进程运行时和编译环境(我们的 fork 称为 CheriBSD),其中程序中的所有指针都是 CHERI capability。这包括 mmap() 返回的指针。
在 CheriABI 中设置指针边界时,我们试图遵循最小权限原则,即不应授予超过必要的权限。对于 mmap() 返回的指针,我们最初的倾向是从请求的页保护中映射权限(例如,读写变为加载和存储)。这在大多数情况下可行,但并非适用于所有使用模式。首先,运行时链接器为每个库进行初始映射时不带任何权限,只是保留空间。然后用适当的权限映射文件的部分。如果我们返回不带权限的指针,将无法访问文件(或创建具有更多权限的新映射)。其次,JIT 需要先映射为可写,然后将这些映射转为可执行。在这两种情况下,我们需要一种方法来指定映射的最大预期权限,以便创建具有这些权限的指针。
在解决 CheriABI 中 mmap() 的问题时,我们希望改动最小。特别是,我们希望源代码能在非 CHERI 系统上继续工作,理想情况下任何 mmap() 扩展都足够小,便于应用到跨平台代码库。最终,我们决定从参数 prot 中借用一些位,并添加第二组位用于最大权限。我们创建了宏 PROT_MAX(),与权限进行 OR 运算以指定最大权限。例如,以前用 PROT_NONE 映射的库映射将变为 PROT_NONE | PROT_MAX(PROT_READ | PROT_WRITE | PROT_EXEC)。要在不支持 PROT_MAX() 的系统上工作,可以轻松地将其定义为 0。为了避免大量代码修改,我们选择将提供的权限视为最大权限,除非显式指定最大权限。这需要修改运行时链接器,但除此之外,FreeBSD 基本系统中的代码在这种行为变化下可以直接工作。
FreeBSD 中的 PROT_MAX()
当我们开始探索 W^X 时,一个突出的问题是几乎所有映射在其最大权限集中都有执行权限。我们意识到 PROT_MAX() 可以解决这个问题。2019 年 6 月,我们提交了向 FreeBSD 内核添加 PROT_MAX() 支持的变更。它与 CheriBSD 版本的不同之处在于,除非程序请求 PROT_NONE、设置了 sysctl 来暗示最大权限、或使用 procctl 显式启用为该进程暗示最大权限,否则我们保留旧的最大权限逻辑。
与 W^X 一样,我们相信在需要的地方通过 PROT_MAX() 注释暗示最大权限提供了最小权限原则的最佳实现,是正确的方向。相关工作正在进行中,以测试最大权限可以安全暗示到何种程度,我们希望最终在未来的 FreeBSD 发行版中默认启用暗示。
兼容性和限制
mmap() 的扩展很少能跨平台兼容。NetBSD 有一个类似的扩展 PROT_MPROTECT(),它相对于权限集向最大权限集添加权限。其缺点是不容易通过 mprotect() 以后降低最大权限。
我们在其他操作系统中没有发现类似的扩展。
PROT_MAX() 模型确实有一些限制。目前无法用它将最大权限设为 PROT_NONE。也无法在不单独处理每个子范围或将其所有权限设为相同的情况下,降低具有混合权限的页范围的最大权限。
页粒度内存权限的一个主要限制是,大多数编程语言对象比一页小得多。链接器将应具有相似权限的对象组合在一起以允许限制权限,但限制 malloc() 分配的内存更加困难。
结论
页粒度内存权限是抵御多种攻击的有效防御手段。特别是,它们允许将最小权限原则应用于系统内存。更高级的系统如 CHERI 的基于 capability 的指针允许进一步应用细粒度权限。
本文涵盖了适合当前 mmap() 权限模型的、与机器无关的内存权限基础。架构特定的实现各不相同,超出了本文概述的范围,其他相关的缓解措施如 Intel 的管理模式访问/执行防止(SMAP/SMEP)和 ARM 的特权访问禁止(PAN)也是如此——它们相关但保护内核免受用户空间攻击,而非保护用户空间免受自身攻击。
Brooks Davis 作为 FreeBSD 开发者已有近二十年,是 FreeBSD 核心团队成员。他从事过网络栈、高性能计算、构建系统,以及最近增强 FreeBSD 以支持 CHERI 架构的工作。他目前在 SRI International 工作。
最后更新于