在现代计算机系统中,有效的内存管理是保障系统性能和安全的关键。随着应用程序变得越来越复杂,对内存的访问控制变得尤为重要。保护键(Protection Key)是一种高级的内存管理技术,它允许操作系统和应用程序更精确地控制对特定内存区域的访问权限. 保护键是一种内存访问控制机制,用于提高操作系统和应用程序对内存的访问安全性。在英特尔的架构中,保护键通过与每个线性地址相关联,允许对内存访问进行更细粒度的控制。每个保护键都与一组内存页相关联,操作系统可以为每个保护键设置不同的访问权限,例如只读、只写或完全禁止访问.
Protection-Key 技术的实现依赖与页表和指定寄存器,在 4 级分页和 5 级分页中,每个内存页都与一个 4 位的保护键关联, 这个键位于分页结构条目的 [62 : 59)位,这些条目映射了包含该线性地址的页面. 另外提供了两种保护键权限寄存器: 用户模式页的 PKRU 和监督模式页的 IA32_PKRS MSR, 这些寄存器决定基于页面的保护键的可. 两个寄存器的结构如上图:
- 每个保护键权限寄存器包含 16 对禁用控制,以阻止基于其保护键的线性地址的数据访问
- 对于每个保护键 i(0 ≤ i ≤ 15),寄存器中的两个位分别用于控制:
- 访问禁用(ADi): 如果设置,则处理器阻止对具有保护键 i 的线性地址的任何数据访问
- 写入禁用(WDi): 如果设置,则处理器阻止对具有保护键 i 的线性地址的写入访问
接下来使用用户模式页 PKRU 与页表的 PK 字段如何配合使用的. 应用程序首先在 PKRU 里找到一个空闲的控制组,控制组包含两个控制位: AD 和 WD,然后用户进程使用 RDPKRU 和 WRPKRU 指令可以访问 PKRU 寄存器,然后进程将配置好的 AD 和 WB 信息写入到 PKRU 寄存器里. 接着将控制组组号信息写入控制区域对应页表的 ProtectionKey 字段,那么接下来该区域会被进程配置的访问控制起来.
Linux 提供三个系统调用用于完成上述任务,每个系统调用的作用如下:
- pkey_alloc: Linux 操作系统提供的一个系统调用,用于分配保护键(Protection Key)并设置相应的访问权限。这个系统调用是 Linux 中实现内存保护键(Memory Protection Keys,MPK)功能的一部分,允许应用程序和操作系统更细粒度地控制对内存的访问.
- pkey_mprotect: Linux 操作系统提供的 mprotect_key 系统调用是与内存保护键(Memory Protection Keys,MPK)机制相关的一部分。这个调用主要用于修改已经分配的内存区域的保护键和访问权限。具体来说 mprotect_key 允许应用程序改变现有内存区域(由 mmap 或类似机制分配)的保护键设置,这样可以在运行时调整内存访问控制策略
- pkey_free: Linux 操作系统提供的 pkey_free 系统调用用于释放一个之前通过 pkey_alloc 系统调用分配的保护键(Protection Key)。在使用保护键机制管理内存访问权限时,pkey_free 是收尾工作的一个重要部分.
Linux 的页表已经提供了类似写保护和 PROTNONE 可以实现 Protection Key 的能力,为什么还需要再使用 ProtectionKey 呢? 引入 Protection Key(保护键)机制主要是为了提供更细粒度和更灵活的内存访问控制,特别是在用户空间应用中。以下是引入保护键机制的几个主要原因:
- 细粒度控制: 页表级别的保护通常是以整个页面(通常是 4KB) 为单位的。保护键允许在更细的粒度上控制内存访问,例如,可以针对特定的数据结构或内存区域应用不同的保护策略,这里的更细粒度不是只更小的区域.
- 性能优化: 修改页表来改变内存访问权限通常涉及到昂贵的操作,如刷新 TLB(Translation Lookaside Buffer). 相比之下,修改保护键的访问权限通常成本更低,因为它不需要改变页表本身
- 用户空间控制: 保护键机制允许用户空间的应用程序直接管理其内存的保护策略,而无需进行系统调用来修改页表。这为应用程序提供了更大的灵活性和控制能力
- 动态安全管理: 保护键使得应用程序能够在运行时动态调整内存区域的安全策略,例如在某些操作之后将内存区域从可写改为只读,以防止数据被意外或恶意修改
- 与现有架构的兼容: 保护键机制是在现代处理器架构中引入的,它与现有的硬件和操作系统特性(如分页)相互补充,提供了一种与现代硬件更加协同的内存保护方法
页表可以提供基本的内存访问控制,保护键机制为操作系统和应用程序提供了更加灵活、高效和细粒度的内存保护手段。这在现代多任务和安全敏感的计算环境中尤其重要. 那么接下来通过一个实践案例了解 ProtectionKey 如何使用,实践案例在 BiscuitOS 上的部署逻辑如下:
cd BiscuitOS
make menuconfig
[*] Package --->
[*] DIY BiscuitOS/Broiler Hardware --->
[*] Support Host CPU Feature Passthrough
[*] Paging Mechanism --->
[*] Intel Protection-Key --->
# 进入源码目录
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-INTEL-PK-default/
# 部署源码
make download
# 在 BiscuitOS 中实践
make build
实践案例由一个应用程序构成,应用程序主要用途是通过 ProtectionKey 将虚拟内存设置为不可访问的,然后测试 PK 是否可以对虚拟内存有保护作用. 函数首先在 45 行调用 mmap 函数分配虚拟内存,然后在 56 行对虚拟内存进行访问,以此让虚拟内存通过页表映射到物理内存上,然后在 63 行调用 pkey_alloc 系统调用,然后从 PKRU 获得一个可用的控制组,并将控制组对应的索引存储在 pkey 变量里,接着通过 71 行 pkey_set 函数将 PKEY_DISABLE_ACCESS 写入到 PKRU 寄存器 pkey 对应的控制组里,此时写入可以在用户空间进行. 函数接着在 80 行调用 mprotect_key 系统调用将 [base, base + PAGE_SIZE) 区域对应页表的 ProtectoionKey 字段设置为 pkey 索引。设置完毕之后在 89 行对虚拟内存进行读操作,此时可以测试 ProtectionKey 是否可以阻止进程的访问操作. 最后函数在 92 行释放 ProtectionKey 和回收内存,以上便是一个最简单的实践案例,接下来在 BiscuitOS 上实践:
BiscuitOS 启动之后,直接运行 RunBiscuitOS.sh 脚本,脚本里包含了运行实践案例的所有命令,可以看到程序前 1s 运行正常,1s 之后程序访问了被 ProtectionKey 保护的区域,该区域禁止访问,那么此时可以看到进程访问了保护区域,系统直接发送 SIG_BUS 让进程 SegmentFault 异常退出,从 SegmentFault 的 error 字段为 25 可以看到此时发生了缺页,并且缺页原因包括 PF_PK. 以上实践验证了 ProtectionKey 确实可以对指定虚拟区域进行保护. 那么接下来分析一下技术细节.
pkey_alloc 系统调用的主要目的是从 PKRU 里分配一组可用的控制组,通过源码分析可以知道,进程地址空间 STRUCT mm_struct 的 mm->context.pkey_allocation_map 的使用情况,因此分配的逻辑是在该数据了找到一个可用的组即可.
pkey_mprotect 系统调用的主要任务是将进程使用的 ProtectionKey 组索引信息写入到保护区域对应的页表里,由于这是动态修改页表,因此底层实现上调用了 mprotect 系统调用相关逻辑,其实现也很简单,将准备好的新页表内容通过 CHANGE-PAGE-RANGE 找到对应最后一级页表,然后将新页表内容写入到页表即可,该过程无需刷 TLB,这也是 ProtectionKey 的一大亮点.
pkey_free 系统调用的主要目的是回收进程的 pkey,通过函数调用可以知道,其逻辑也很简单,将进程地址空间 STRUCT mm_struct 的 mm->context.pkey_allocation_map 对应的比特位清零即可. 通过上面三个系统调用分析,基本可以知道 ProtectionKey 的工作原理,接下来开发者可以使用 BS_DEBUG 工具对整个过程在进行实践,最后还有一个技术细节,也就是当进程访问了 ProtectionKey 的保护区域之后的异常处理,这个与缺页有关,可以继续查看下面文章获得: