Android 提供了实现 Android 虚拟化框架所需的所有组件的参考实现。目前,此实现仅限于 ARM64。本页将介绍框架架构。
背景
Arm 架构允许最多 4 个异常级别,其中异常级别 0 (EL0) 的权限最小,而异常级别 3 (EL3) 的权限最大。Android 代码库(所有用户空间组件)中占比最大的部分以 EL0 运行。其余部分就是通常所说的“Android”,即 Linux 内核,这部分以 EL1 运行。
通过 EL2 层,您可以引入 Hypervisor 将内存和设备隔离到 EL1/EL0 的各个 pVM 中,并获得极高的机密性和完整性保证。
Hypervisor
受保护的基于内核的虚拟机 (pKVM) 基于 Linux KVM Hypervisor 构建而成,并已扩展为能够对在创建时标记为“受保护”的客户机虚拟机中所运行的载荷的访问权限加以限制。
KVM/arm64 支持不同的执行模式,具体取决于特定 CPU 功能的可用性,即虚拟化主机扩展 (VHE) 的可用性(ARMv8.1 及更高版本)。在其中一种模式(通常称为非 VHE 模式)下,Hypervisor 代码会在启动期间从内核映像中分离出来并以 EL2 进行安装,而内核本身则以 EL1 运行。虽然 KVM 的 EL2 组件是 Linux 代码库的一部分,但它是一个小型组件,负责在多个 EL1 之间进行切换。Hypervisor 组件使用 Linux 进行编译,但位于 vmlinux
映像内单独的专用内存区段中。pKVM 通过使用新功能扩展 Hypervisor 代码来利用此设计,使其能够对 Android 主机内核和用户空间施加限制,并限制主机对客户机内存和 Hypervisor 的访问。
pKVM 供应商模块
pKVM 供应商模块是一个特定硬件专用的模块,包含特定设备专用的功能,例如输入-输出内存管理单元 (IOMMU) 驱动程序。这些模块可让您将需要异常级别 2 (EL2) 访问权限的安全功能移植到 pKVM。
如需了解如何实现和加载 pKVM 供应商模块,请参阅实现 pKVM 供应商模块。
启动过程
下图描绘了 pKVM 启动过程:
- 引导加载程序以 EL2 进入通用内核。
- 通用内核检测到它在以 EL2 运行,并将自身权限下调至 EL1,而 pKVM 及其模块会继续以 EL2 运行。此外,此时还会加载 pKVM 供应商模块。
- 通用内核会继续正常启动,加载所有必要的设备驱动程序,直至到达用户空间为止。此时,pKVM 已就位并处理第 2 阶段页面表格。
启动过程会信任引导加载程序,以便仅在前期启动期间保持内核映像的完整性。在内核调低权限后,Hypervisor 将不再认为内核可信,并将负责进行自我保护,即使内核遭到入侵也不例外。
将 Android 内核与 Hypervisor 置于同一二进制文件映像中,可以在两者之间实现极度紧密耦合的通信接口。这种紧密耦合保证了这两个组件的原子级更新,从而无需让它们之间的接口保持稳定,并可在不影响长期可维护性的前提下提供极大的灵活性。通过这种紧密耦合,这两个组件还可以进行合作,而不会影响 Hypervisor 提供的安全保证。
此外,在 Android 生态系统中采用 GKI 会自动允许 pKVM Hypervisor 在与内核相同的二进制文件中部署到 Android 设备。
CPU 内存访问保护
Arm 架构会指定一个内存管理单元 (MMU),该单元拆分为两个独立的阶段,这两个阶段可用于对内存的不同部分实现地址转换和访问权限控制。第 1 阶段 MMU 由 EL1 控制,并允许进行第一级地址转换。Linux 使用第 1 阶段 MMU 来管理提供给每个用户空间进程及其自己的虚拟地址空间的虚拟地址空间。
第 2 阶段 MMU 由 EL2 控制,并支持对第 1 阶段 MMU 的输出地址应用第二次地址转换,从而产生实际地址 (PA)。Hypervisor 可以使用第 2 阶段转换来控制和转换来自所有客户机虚拟机的内存访问。如图 2 所示,在转换的两个阶段均已启用后,第 1 阶段的输出地址称为中间实际地址 (IPA) 注意:虚拟地址 (VA) 会转换为 IPA,然后再转换为 PA。
长期以来,在运行客户机时,KVM 会在启用第 2 阶段转换的情况下运行;而在运行主机 Linux 内核时,KVM 会在停用第 2 阶段转换的情况下运行。此架构允许来自主机第 1 阶段 MMU 的内存访问通过第 2 阶段 MMU,从而允许从主机无限制地访问客户机内存页面。另一方面,pKVM 甚至在主机上下文中实现了第 2 阶段保护,并让 Hypervisor 负责保护客户机内存页面(而非主机)。
KVM 会在第 2 阶段充分利用地址转换,以便为客户机实现复杂的 IPA/PA 映射。尽管存在物理碎片,但这会为客户机创造连续内存的假象。不过,主机的第 2 阶段 MMU 的使用仅限于访问权限控制。主机第 2 阶段会经过身份映射,以确保主机 IPA 空间中的连续内存在 PA 空间中是连续的。此架构允许在页面表格中使用大型映射,从而降低转译后备缓冲区 (TLB) 的压力。由于身份映射可由 PA 编入索引,因此主机第 2 阶段还可用于在页面表格中直接跟踪页面所有权。
直接内存访问 (DMA) 保护
如前所述,对于保护客户机内存而言,在 CPU 页面表格中将客户机页面从 Linux 主机取消映射是一个必要步骤,但这并不充分。pKVM 还需要防止支持 DMA 的设备在主机内核的控制下进行内存访问,并避免出现恶意主机发起 DMA 攻击的可能。为防止此类设备访问客户机内存,对于系统中的每台支持 DMA 的设备,pKVM 都需要相应的输入--输出内存管理单元 (IOMMU) 硬件,如图 3 所示。
至少,IOMMU 硬件提供了以页面粒度授予和撤销设备对物理内存的读/写访问权限的方法。不过,此 IOMMU 硬件会限制设备在 pVM 中的使用,因为它们假定采用经过身份映射的第 2 阶段。
为了确保虚拟机之间的隔离,代表不同实体生成的内存事务必须可由 IOMMU 区分,以保证适当的页面表格集可用于进行转换。
此外,减少 EL2 的 SoC 专用代码的数量是减少 pKVM 整体可信计算基 (TCB) 的关键策略,这与 Hypervisor 中包含 IOMMU 驱动程序背道而驰。为缓解此问题,EL1 的主机将负责辅助 IOMMU 管理任务,例如电源管理、初始化以及适当情况下的中断处理。
不过,让主机控制设备状态会对 IOMMU 硬件的编程接口提出额外的要求,以确保无法通过其他方法(如经过设备重置)绕过权限检查。
Arm 系统内存管理单元 (SMMU) 架构是用于 Arm 设备的标准且得到良好支持的 IOMMU,可同时实现隔离和直接分配。此架构是推荐的参考解决方案。
内存所有权
在启动时,系统会假定所有非 Hypervisor 内存均归主机所有,并由 Hypervisor 按此方式进行跟踪。在衍生 pVM 后,主机会贡献内存页面让 pVM 能够启动,Hypervisor 也会将相应页面的所有权从主机转移到 pVM。因此,Hypervisor 会在主机的第 2 阶段表格中设置访问权限控制限制,以防止其再次访问相应页面,从而保障客户机的机密性。
主机和客户机之间的通信是通过它们之间的受控内存共享实现的。客户机可以使用 hypercall 与主机重新共享部分页面,这会指示 Hypervisor 在主机第 2 阶段页面表格中重新映射这些页面。同样,主机与 TrustZone 的通信是通过内存共享和/或借用操作实现的,所有这些操作均由 pKVM 利用 Arm 固件框架 (FF-A) 规范进行密切监控和控制。
由于 pVM 的内存要求可以随着时间而改变,因此提供了 hypercall,以便将属于调用方的指定页面的所有权交还给主机。在实际操作中,此 hypercall 将与 Virtio Balloon 协议搭配使用,以允许 VMM 请求从 pVM 返回内存,并使 pVM 以受控方式将已交还的页面通知 VMM。
Hypervisor 负责跟踪系统中所有内存页面的所有权,以及这些页面是否已共享或借用给其他实体。此类状态跟踪大多是使用主机和客户机的第 2 阶段表格所附加的元数据完成的,即使用页面表格条目 (PTE) 中的预留位,顾名思义,预留位是为供软件使用而预留的。
主机必须确保自己不会尝试访问被 Hypervisor 设置为无法访问的页面。非法的主机访问会导致 Hypervisor 将同步异常注入主机,这可能会导致负责的用户空间任务收到 SEGV 信号,或主机内核崩溃。为了防止意外访问,向客户机贡献的页面已设置为无法由主机内核进行转换或合并。
中断处理和计时器
中断是客户机与设备的互动方式以及 CPU 之间的通信方式的重要环节,其中处理器间中断 (IPI) 是主要的通信机制。KVM 模型会将所有虚拟中断管理委托给 EL1 的主机,为此,后者会充当 Hypervisor 中不受信任的部分。
pKVM 会根据现有 KVM 代码提供完整的 Generic Interrupt Controller 版本 3 (GICv3) 模拟。系统会将计时器和 IPI 作为此不受信任的模拟代码的组成部分进行处理。
GICv3 支持
EL1 和 EL2 之间的接口必须确保完整的中断状态对 EL1 主机可见,包括与中断相关的 Hypervisor 寄存器的副本。这种可见性通常是使用共享内存区域来实现的,每个虚拟 CPU (vCPU) 都有一个共享内存区域。
系统寄存器运行时支持代码可以简化为仅支持对 Software Generated Interrupt Register (SGIR) 和 Deactivate Interrupt Register (DIR) 寄存器执行 trap。该架构会要求这些寄存器始终 trap 到 EL2,而其他 trap 迄今为止仅对缓解 errata 有用。其他所有操作都在硬件中进行处理。
在 MMIO 端,所有操作都以 EL2 进行模拟,从而重复使用 KVM 中的所有当前基础架构。最后,Wait for Interrupt (WFI) 始终会中继到 EL1,因为这是 KVM 使用的基本调度基元之一。
计时器支持
必须在每个执行 trap 的 WFI 上将虚拟计时器的比较器值公开给 EL1,以便 EL1 能够在 vCPU 遭屏蔽时注入计时器中断。该物理计时器是完全模拟的,并且所有 trap 都会中继到 EL1。
MMIO 处理
如需与虚拟机监控器 (VMM) 进行通信并执行 GIC 模拟,MMIO trap 必须中继回 EL1 中的主机,以进一步进行分类。pKVM 需要以下各项:
- 访问的 IPA 和大小
- 数据(若为写入)
- 执行 trap 的位置的 CPU 字节序
此外,将通用寄存器 (GPR) 作为来源/目的地的 trap 将使用抽象传输伪寄存器进行中继。
客户机接口
客户机可以结合使用 hypercall 以及对已执行 trap 的区域的内存访问来与受保护的客户机进行通信。hypercall 将按照 SMCCC 标准公开,并将预留一定范围供 KVM 进行供应程序分配。以下 hypercall 对 pKVM 客户机特别重要。
通用 hypercall
- PSCI 为客户机提供了一种标准机制用于控制其 vCPU 的生命周期,包括上线、离线和系统关机。
- TRNG 为客户机提供了一种标准机制用于从 pKVM 请求熵,该操作会将调用中继到 EL3。在无法信任主机以对硬件随机数生成器 (RNG) 进行虚拟化的情况下,这种机制尤其有用。
pKVM hypercall
- 与主机共享内存。主机最初无法访问任何客户机内存,但对于共享内存通信以及依赖于共享缓冲区的半虚拟化设备来说,主机访问是必需的。通过用于与主机共享和取消共享页面的 hypercall,客户机可以准确判断 Android 的其余组件可以访问内存的哪些部分,而无需进行握手。
- 将内存交还给主机。所有客户机内存通常都属于客户机,直到其销毁为止。对于内存要求会随时间变化的长期虚拟机,此状态可能不足以满足要求。
relinquish
hypercall 可让客户机明确地将页面的所有权转回给主机,而无需终止客户机。 - 将内存访问 trap 到主机。过去,如果 KVM 客户机访问的地址与有效内存区域不对应,vCPU 线程会退出到主机,且相应访问通常由 MMIO 使用并由 VMM 在用户空间中模拟。为便于进行此类处理,pKVM 需要将错误指令的有关详情(例如其地址、寄存器参数,可能还有其内容)传回主机,而如果发生预料之外的 trap,这可能会意外公开受保护的客户机中的敏感数据。为解决此问题,除非客户机之前已发出 hypercall 将错误 IPA 范围标识为允许系统针对其将访问 trap 回主机,否则 pKVM 便会将这些错误视为严重错误。此解决方案称为“MMIO guard”。
虚拟 I/O 设备 (virtio)
Virtio 是一项流行、可移植且成熟的标准,用于实现半虚拟化设备以及与此类设备进行互动。公开给受保护客户机的大多数设备都是使用 Virtio 实现的。Virtio 还可为用于在受保护客户机与 Android 的其余组件之间进行通信的 vsock 实现提供支持。
Virtio 设备通常由 VMM 在主机的用户空间中实现,这会拦截从客户机到 Virtio 设备 MMIO 接口的已执行 trap 的内存访问,并模拟预期的行为。MMIO 访问的成本相对较高,因为每次访问设备都需要往返 VMM,因此设备和客户机之间的实际数据传输大多是通过内存中的一组 virtqueue 发生的。Virtio 的一项关键假设是主机可以随意访问客户机内存。virtqueue 的设计明显反映了这种假设(其中可能包含指向设备模拟计划直接访问的客户机缓冲区的指针)。
尽管前面所述的内存共享 hypercall 可以用于将 Virtio 数据缓冲区从客户机共享到主机,但这种共享必须以页面粒度执行,并且在缓冲区空间小于页面空间时可能会导致公开的数据多于所需数据量。相反,客户机已配置为从固定的共享内存窗口分配 virtqueue 及其对应的数据缓冲区,并根据需要将数据复制(退回)到该窗口以及从该窗口复制(退回)数据。
与 TrustZone 互动
尽管客户机无法直接与 TrustZone 互动,但主机必须仍能够向安全域发出 SMC 调用。这些调用可以指定主机无法访问的具有实际地址的内存缓冲区。由于安全软件通常不清楚缓冲区的可访问性,因此恶意主机可能会使用此缓冲区来执行代理混淆攻击(类似于 DMA 攻击)。为防止此类攻击,pKVM 会将所有主机 SMC 调用 trap 到 EL2,并在 EL3 的主机和安全监控器之间充当代理。
系统会对来自主机的 PSCI 调用进行最低限度的修改,并将其转发到 EL3。具体而言,系统会重写 CPU 上线或从挂起状态恢复的入口点,以便在返回 EL1 的主机之前,以 EL2 安装第 2 阶段页面表格。在启动期间,这种保护将由 pKVM 强制执行。
此架构依赖于支持 PSCI 的 SoC(最好通过使用最新版本的 TF-A 作为其 EL3 固件)。
Arm 固件框架 (FF-A) 可以标准化普通域和安全域之间的互动,尤其是在存在安全 Hypervisor 的情况下。该规范的主要部分定义了一种机制,用于使用通用消息格式以及明确定义的底层页面权限模型来与安全域共享内存。pKVM 会代理 FF-A 消息,以确保主机不会尝试与其不具备足够权限的安全端共享内存。
此架构依赖于强制执行内存访问模型的安全域软件,以确保受信任的应用以及在安全域中运行的任何其他软件只有在其为安全域专有或已使用 FF-A 明确与其共享的情况下,才能访问内存。在具有 S-EL2 的系统中,强制执行内存访问模型应通过 Hafnium 等安全分区管理器核心 (SPMC) 来完成,SPMC 可为安全域维护第 2 阶段页面表格。在没有 S-EL2 的系统中,TEE 可以改为通过其第 1 阶段页面表格来强制执行内存访问模型。
如果对 EL2 的 SMC 调用并非 PSCI 调用或 FF-A 定义的消息,系统会将未处理的 SMC 转发到 EL3。我们假定(必定是受信任的)安全固件可以安全地处理未处理的 SMC,因为此固件了解维护 pVM 隔离所需的预防措施。
虚拟机监控器
crosvm 是一种虚拟机监控器 (VMM),它通过 Linux 的 KVM 接口来运行虚拟机。crosvm 的独特之处在于十分注重安全性:它使用 Rust 编程语言,并围绕虚拟设备设置沙盒,从而保护主机内核。如需详细了解 crosvm,请点击此处查看其官方文档。
文件描述符和 ioctl
KVM 使用构成 KVM API 的 ioctl 将 /dev/kvm
字符设备公开给用户空间。ioctl 分属以下类别:
- 系统 ioctl 可查询和设置会影响整个 KVM 子系统的全局属性,并创建 pVM。
- 虚拟机 ioctl 可查询和设置用于创建虚拟 CPU (vCPU) 和设备的属性,并影响整个 pVM,例如其中包括内存布局以及虚拟 CPU (vCPU) 和设备的数量。
- vCPU ioctl 可查询和设置用于控制单个虚拟 CPU 运行的属性。
- 设备 ioctl 可查询和设置用于控制单个虚拟设备运行的属性。
每个 crosvm 进程都只运行一个虚拟机实例。此进程使用 KVM_CREATE_VM
系统 ioctl 创建可用于发出 pVM ioctl 的虚拟机文件描述符。虚拟机 FD 上的 KVM_CREATE_VCPU
或 KVM_CREATE_DEVICE
ioctl 会创建一个 vCPU/设备,并返回指向新资源的文件描述符。vCPU 或设备 FD 上的 ioctl 可用于控制使用虚拟机 FD 上的 ioctl 创建的设备。对于 vCPU,这包括运行客户机代码的重要任务。
在内部,crosvm 使用由边缘触发的 epoll
接口向内核注册虚拟机的文件描述符。这样一来,只要任何文件描述符中有待处理的新事件,内核就会通知 crosvm。
pKVM 添加了新功能 KVM_CAP_ARM_PROTECTED_VM
,该功能可用于获取有关 pVM 环境的信息以及为虚拟机设置受保护的模式。如果传递了 --protected-vm
标志,crosvm 会在 pVM 创建过程中使用该功能,以便针对 pVM 固件查询并预留适量的内存,然后启用受保护的模式。
内存分配
VMM 的主要职责之一是分配虚拟机的内存并管理其内存布局。crosvm 会生成一种固定的内存布局,大致如下表所示。
普通模式下的 FDT | PHYS_MEMORY_END - 0x200000
|
可用空间 | ...
|
Ramdisk | ALIGN_UP(KERNEL_END, 0x1000000)
|
内核 | 0x80080000
|
引导加载程序 | 0x80200000
|
BIOS 模式下的 FDT | 0x80000000
|
物理内存基数 | 0x80000000
|
pVM 固件 | 0x7FE00000
|
设备内存 | 0x10000 - 0x40000000
|
系统会使用 mmap
分配物理内存,并将内存贡献给虚拟机,以便使用 KVM_SET_USER_MEMORY_REGION
ioctl 填充其内存区域(称为“memslot”)。因此,所有客户机 pVM 内存都会归因于对其进行管理的 crosvm 实例;如果主机的可用内存开始用尽,这可能会导致相应进程终止(终止虚拟机)。虚拟机停止后,内存会自动被 Hypervisor 擦除并返回主机内核。
在常规 KVM 下,VMM 会保留对所有客户机内存的访问权限。对于 pKVM,将客户机内存贡献给客户机时,客户机内存会从主机实际地址空间取消映射。唯一的例外情况是客户机明确反向共享的内存,例如 Virtio 设备。
客户机地址空间中的 MMIO 区域将保持未映射状态。客户机对这些区域的访问会执行 trap,并在虚拟机 FD 上导致 I/O 事件。此机制用于实现虚拟设备。在受保护的模式下,客户机必须确认系统使用 hypercall 将其地址空间的某个区域用于 MMIO,以降低意外泄露信息的风险。
调度
每个虚拟 CPU 都由一个 POSIX 线程表示,并由主机 Linux 调度程序进行调度。线程会在 vCPU FD 上调用 KVM_RUN
ioctl,从而导致 Hypervisor 切换到客户机 vCPU 上下文。主机调度程序会将在客户机上下文中花费的时间视为对应 vCPU 线程所使用的时间。当有必须由 VMM 处理的事件(例如 I/O、中断结束或 vCPU 暂停)时,KVM_RUN
会返回。VMM 会处理该事件并再次调用 KVM_RUN
。
在 KVM_RUN
期间,主机调度程序会让线程保持可抢占状态,但 EL2 Hypervisor 代码的执行除外(不可抢占)。客户机 pVM 本身没有用于控制此行为的机制。
由于所有 vCPU 线程的调度方式与任何其他用户空间任务并无区别,因此它们会受所有标准 QoS 机制的约束。具体而言,每个 vCPU 线程都可以与物理 CPU 关联、放置在 cpuset 中、使用利用率限制进行提升或设置上限、更改优先级/调度政策,等等。
虚拟设备
crosvm 支持多种设备,其中包括以下各项:
- virtio-blk,用于复合磁盘映像(只读或读写)
- vhost-vsock,用于与主机通信
- virtio-pci,作为 virtio 传输
- pl030 实时时钟 (RTC)
- 16550a UART,用于串行通信
pVM 固件
pVM 固件 (pvmfw) 是 pVM 执行的第一个代码,类似于实体设备的启动 ROM。pvmfw 的主要目标是引导安全启动并派生 pVM 的唯一密钥。pvmfw 并非仅限与任何特定操作系统(例如 Microdroid)搭配使用,只要是受 crosvm 支持且经过适当签名的操作系统即可。
pvmfw 二进制文件存储在同名的刷写分区中,并使用 OTA 进行更新。
设备启动
以下步骤序列已添加到已启用 pKVM 的设备的启动过程中:
- Android 引导加载程序 (ABL) 将 pvmfw 从其分区加载到内存中,并验证映像。
- ABL 从信任根获取设备标识符组合引擎 (DICE) 密钥(复合设备标识符 [CDI] 和 DICE 证书链)。
- ABL 会为 pvmfw 派生出必要的 CDI,并将其附加到 pvmfw 二进制文件。
- ABL 向 DT 添加
linux,pkvm-guest-firmware-memory
预留内存区域节点,用于说明 pvmfw 二进制文件以及上一步骤中派生的密钥的位置和大小。 - ABL 将控制权移交给 Linux,接着 Linux 初始化 pKVM。
- pKVM 将 pvmfw 内存区域从主机第 2 阶段页面表格中取消映射,并在设备正常运行时间内保护其不受主机(和客户机)影响。
设备启动后,系统会根据 Microdroid 文档的启动序列部分中所述的步骤启动 Microdroid。
pVM 启动
创建 pVM 时,crosvm(或其他 VMM)必须创建一个足够大的 memslot,以便由 Hypervisor 填充 pvmfw 映像。此外,在 VMM 可设置初始值的寄存器列表中,VMM 也受限(对于主 vCPU,设置为 x0-x14;对于辅助 vCPU,不做设置)。其余寄存器会被预留,并且是 Hypervisor-pvmfw ABI 的一部分。
pVM 运行时,Hypervisor 会先将主 vCPU 的控制权移交给 pvmfw。固件需要 crosvm 已按照已知的偏移量,将 AVB 签名的内核(可以是引导加载程序或任何其他映像)和未签名的 FDT 加载到内存。pvmfw 会验证 AVB 签名;如果验证成功,pvmfw 会从收到的 FDT 生成可信设备树,从内存中擦除其密钥,并分支到载荷的入口点。如果其中某个验证步骤失败,固件会发出 PSCI SYSTEM_RESET
hypercall。
在两次启动之间,系统会将 pVM 实例的相关信息存储在某个分区(virtio-blk 设备)中,并使用 pvmfw 的密钥对其进行加密,以确保在重新启动后,系统会将密钥配置到正确的实例。