基于 IOMMU 的 GPU 隔离

基于 IOMMU 的 GPU 隔离是一种通过管理 GPU 访问系统内存的方式来增强系统安全性和稳定性的技术。 本文介绍了 WDDM 基于 IOMMU 的 GPU 隔离功能(适用于支持 IOMMU 的设备),以及开发人员如何在其图形驱动程序中实现该功能。

此功能从 Windows 10 版本 1803 (WDDM 2.4) 开始提供。 有关 IOMMU 的最新更新,请参阅 IOMMU DMA 重新映射

概述

基于 IOMMU 的 GPU 隔离允许 Dxgkrnl 利用 IOMMU 硬件对从 GPU 访问系统内存加以限制。 OS 可以提供逻辑地址,而不是物理地址。 这些逻辑地址可用于限制设备对系统内存的访问,使其只能访问允许访问的内存。 它通过确保 IOMMU 将 PCIe 上的内存访问转换为有效和可访问的物理页面来实现这一目标。

如果设备访问的逻辑地址无效,则设备就无法访问物理内存。 这一限制可防止一系列允许攻击者通过受损硬件设备访问物理内存的漏洞。 如果缺少它,攻击者就可以读取设备运行所不需要的系统内存内容。

默认情况下,只有为 Microsoft Edge 启用 Windows Defender 应用程序防护(即容器虚拟化)的电脑才会启用此功能。

出于开发目的,实际的 IOMMU 重新映射功能是通过以下注册表键值来启用或禁用的:

HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\GraphicsDrivers
DWORD: IOMMUFlags

0x01 Enabled
     * Enables creation of domain and interaction with HAL

0x02 EnableMappings
     * Maps all physical memory to the domain
     * EnabledMappings is only valid if Enabled is also set. Otherwise no action is performed

0x04 EnableAttach
     * Attaches the domain to the device(s)
     * EnableAttach is only valid if EnableMappings is also set. Otherwise no action is performed

0x08 BypassDriverCap
     * Allows IOMMU functionality regardless of support in driver caps. If the driver does not indicate support for the IOMMU and this bit is not set, the enabled bits are ignored.

0x10 AllowFailure
     * Ignore failures in IOMMU enablement and allow adapter creation to succeed anyway.
     * This value cannot override the behavior when created a secure VM, and only applies to forced IOMMU enablement at device startup time using this registry key.

如果启用此功能,IOMMU 将在适配器启动后立即启用。 所有在此之前分配的驱动程序在启用后都会被映射。

此外,如果将速度暂存密钥 14688597 设置为已启用,则会在创建安全虚拟机时激活 IOMMU。 目前,此暂存密钥默认为已禁用,以便在没有适当 IOMMU 支持的情况下允许自托管。

在启用后,如果驱动程序不提供 IOMMU 支持,则启动安全虚拟机会失败。

目前还无法在 IOMMU 启用后将其禁用。

内存访问

Dxgkrnl 可确保通过 IOMMU 对 GPU 可访问的所有内存进行重新映射,以确保可访问这些内存。 目前,GPU 需要访问的物理内存可分为四类:

  • 通过 MmAllocateContiguousMemory- 或 MmAllocatePagesForMdl-style 函数(包括 SpecifyCache 和扩展变体)进行的特定于驱动程序的分配必须在 GPU 访问它们之前被映射到 IOMMU。 Dxgkrnl 不会调用 Mm API,而是向内核模式驱动程序提供回调,以便一步完成分配和重新映射。 任何打算由 GPU 访问的内存都必须通过这些回调,否则 GPU 无法访问该内存。

  • GPU 在分页操作期间访问的所有内存,或者通过 GpuMmu 映射的所有内存,都必须被映射到 IOMMU。 此过程完全是视频内存管理器 (VidMm) 的内部过程,而视频内存管理器是 Dxgkrnl 的一个子组件。 VidMm 可在 GPU 需要访问该内存的任何时候处理逻辑地址空间的映射和取消映射,包括:

  • 为以下任一项映射分配的备份存储空间:

    • 向 VRAM 传输或从 VRAM 传输的整个持续时间。
    • 备份存储映射到系统内存或光圈段的整个时间。
  • 映射和取消映射被监控的围栏。

  • 在电源转换期间,驱动程序可能需要留出部分硬件保留内存。 为了应对这种情况,Dxgkrnl 为驱动程序提供了一种机制来指定存储这些数据的内存容量。 驱动程序所需的确切内存量可能会动态变化。 尽管如此,Dxgkrnl 还是会在初始化适配器时对上限进行用量认可,以确保在需要时获取物理页面。 Dxgkrnl 负责确保锁定该内存,并将其映射到 IOMMU,以便在电源转换期间进行传输。

  • 对于任何硬件保留资源,VidMm 都会确保在设备连接到 IOMMU 时正确映射 IOMMU 资源。 这包括使用 PopulatedFromSystemMemory 报告的内存段所报告的内存。 对于未通过 VidMm 段公开的保留内存(例如固件/BIOD 保留内存),Dxgkrnl 会调用 DXGKDDI_QUERYADAPTERINFO 来查询驱动程序需要提前映射的所有保留内存范围。 有关详细信息,请参阅硬件保留内存

域分配

在硬件初始化过程中,Dxgkrnl 会为系统上的每个逻辑适配器创建一个域。 域会管理逻辑地址空间,并跟踪页表和其他必要的映射数据。 单个逻辑适配器中的所有物理适配器都属于同一个域。 Dxgkrnl 会通过新的分配回调例程来跟踪所有映射的物理内存,以及 VidMm 本身分配的任何内存。

域将在首次创建安全虚拟机时附加到设备上,如果使用上述注册表项,则会在设备启动后不久附加到设备上。

独占访问

IOMMU 域的连接和分离速度很快,但目前还不是原子式的。 由于它不是原子式的,因此在交换到具有不同映射的 IOMMU 域时,通过 PCIe 发出的事务并不能保证正确转换。

为了处理这种情况,从 Windows 10 版本 1803 (WDDM 2.4) 开始,KMD 必须实现以下 DDI 对,以便 Dxgkrnl 调用:

这些 DDI 构成了开始/结束配对,其中 Dxgkrnl 会要求硬件在总线上保持静默。 每当设备切换到新的 IOMMU 域时,驱动程序必须确保其硬件保持无提示。 也就是说,驱动程序必须确保在这两次调用之间不会从设备读取或写入系统内存。

在这两个调用之间,Dxgkrnl 做出以下保证:

  • 计划程序已挂起。 所有活动工作负荷都被刷新,并且不会向硬件发送或在硬件上计划新的工作负荷。
  • 没有进行其他 DDI 调用。

作为这些调用的一部分,驱动程序可以选择在独占访问期间禁用和禁止中断(包括 Vsync 中断),即使没有 OS 的明确通知。

Dxgkrnl 会确保硬件上计划的任何待处理工作均已完成,然后进入该独占访问区域。 在此期间,Dxgkrnl 会将域分配给设备。 在这些调用之间,Dxgkrnl 不会对驱动程序或硬件提出任何请求。

DDI 更改

为支持基于 IOMMU 的 GPU 隔离,对 DDI 进行了以下更改:

内存分配和映射 IOMMU

Dxgkrnl 向内核模式驱动程序提供上表中的前六个回调,使其能够分配内存并将其重新映射到 IOMMU 的逻辑地址空间。 这些回调函数模拟了 Mm API 接口提供的例程。 它们为驱动程序提供 MDL 或指针,用于描述映射到 IOMMU 的内存。 这些 MDL 会继续描述物理页面,但 IOMMU 的逻辑地址空间是以相同的地址映射的。

Dxgkrnl 会跟踪对这些回调的请求,以帮助确保驱动程序不会发生泄漏。 分配回调会提供另一个句柄,作为输出的一部分,该句柄必须提供给相应的释放回调。

对于无法通过所提供的分配回调之一进行分配的内存,我们提供了 DXGKCB_MAPMDLTOIOMMU 回调,以便通过 IOMMU 来跟踪和使用驱动程序管理的 MDL。 使用此回调的驱动程序有责任确保 MDL 的有效期超过相应的取消映射调用。 否则,取消映射调用将产生未定义的行为。 这种未定义的行为可能会导致 MDL 页面的安全性受损,因为 Mm 页面在取消映射时会被重新使用。

VidMm 会自动管理它在系统内存中创建的任何分配(例如,DdiCreateAllocationCb、监控围栏等)。 驱动程序无需执行任何操作就能让这些分配生效。

帧缓冲区保留

对于必须在电源转换期间将帧缓冲区的保留部分保存到系统内存的驱动程序,Dxgkrnl 会在适配器初始化时对所需内存进行用量认可。 如果驱动程序报告 IOMMU 隔离支持,则 Dxgkrnl 将在查询物理适配器上限后立即调用 DXGKDDI_QUERYADAPTERINFO,内容如下:

Dxgkrnl 会按驱动程序指定的数量进行用量认可,以确保始终能根据请求获取物理页面。 此操作是通过为每个物理适配器创建一个唯一的区域对象来完成的,该对象为最大尺寸指定了一个非零值。

驱动程序报告的最大大小必须是 PAGE_SIZE 的倍数。

与帧缓冲区之间的传输可以在驱动程序选择的时间进行。 为了帮助传输,Dxgkrnl 向内核模式驱动程序提供了上表中的最后四个回调。 这些回调可用于映射适配器初始化时创建的区域对象的相应部分。

在调用这四个回调函数时,驱动程序必须始终为 LDA 链中的主导设备提供 hAdapter

驱动程序有两个实现帧缓冲区保留的选项:

  1. (首选方法)驱动程序应使用 DXGKDDI_QUERYADAPTERINFO 来调用为每个物理适配器分配空间,以指定每个适配器所需的存储空间。 在电源转换时,驱动程序应每次保存或恢复一个物理适配器的内存。 此内存会被分割成多个区域对象,每个物理适配器一个。

  2. (可选)驱动程序可以将所有数据保存或还原到单个共享区域对象中。 要执行此操作,可在 DXGKDDI_QUERYADAPTERINFO 调用中为物理适配器 0 指定一个较大的最大大小,然后为所有其他物理适配器指定一个零值。 这样,驱动程序就可以将整个区域对象固定下来,供所有物理适配器的所有保存/还原操作使用。 这种方法的主要缺点是需要一次性锁定更多内存,因为它不支持只将内存的子范围固定到 MDL 中。 因此,在内存压力下,这种操作更容易失败。 此外,驱动程序还应使用正确的页面偏移量将 MDL 中的页面映射到 GPU。

驱动程序应执行以下任务,以完成向帧缓冲区或从帧缓冲区的传输:

  • 在初始化过程中,驱动程序应使用其中一个分配回调例程预先分配一小块 GPU 可访问内存。 如果无法一次性映射/锁定整个区域对象,则使用该内存来帮助确保向前推进。

  • 在电源转换时,驱动程序应首先调用 Dxgkrnl 以固定帧缓冲区。 成功后,Dxgkrnl 会为驱动程序提供一个 MDL,用于锁定映射到 IOMMU 的页面。 然后,驱动程序就可以通过任何对硬件最有效的方式直接向这些页面执行传输。 然后,驱动程序应调用 Dxgkrnl 来解锁/取消映射内存。

  • 如果 Dxgkrnl 无法立即锁定整个帧缓冲区,则驱动程序必须尝试使用初始化时分配的预分配缓冲区来向前推进。 在这种情况下,驱动程序按小块来执行传输。 在每次迭代传输过程中(针对每个区块),驱动程序必须要求 Dxgkrnl 提供一个可将结果复制到其中的区域对象映射范围。 然后,驱动程序必须在下一次迭代之前取消区域对象的映射。

下面的伪代码是实现这种算法的一个示例。


#define SMALL_SIZE (PAGE_SIZE)

PMDL PHYSICAL_ADAPTER::m_SmallMdl;
PMDL PHYSICAL_ADAPTER::m_PinnedMdl;

NTSTATUS PHYSICAL_ADAPTER::Init()
{
    DXGKARGCB_ALLOCATEPAGESFORMDL Args = {};
    Args.TotalBytes = SMALL_SIZE;
    
    // Allocate small buffer up front for forward progress transfers
    Status = DxgkCbAllocatePagesForMdl(SMALL_SIZE, &Args);
    m_SmallMdl = Args.pMdl;

    ...
}

NTSTATUS PHYSICAL_ADAPTER::OnPowerDown()
{    
    Status = DxgkCbPinFrameBufferForSave(&m_pPinnedMdl);
    if(!NT_SUCCESS(Status))
    {
        m_pPinnedMdl = NULL;
    }
    
    if(m_pPinnedMdl != NULL)
    {        
        // Normal GPU copy: frame buffer -> m_pPinnedMdl
        GpuCopyFromFrameBuffer(m_pPinnedMdl, Size);
        DxgkCbUnpinFrameBufferForSave(m_pPinnedMdl);
    }
    else
    {
        SIZE_T Offset = 0;
        while(Offset != TotalSize)
        {
            SIZE_T MappedOffset = Offset;
            PVOID pCpuPointer;
            Status = DxgkCbMapFrameBufferPointer(SMALL_SIZE, &MappedOffset, &pCpuPointer);
            if(!NT_SUCCESS(Status))
            {
                // Driver must handle failure here. Even a 4KB mapping may
                // not succeed. The driver should attempt to cancel the
                // transfer and reset the adapter.
            }
            
            GpuCopyFromFrameBuffer(m_pSmallMdl, SMALL_SIZE);
            
            RtlCopyMemory(pCpuPointer + MappedOffset, m_pSmallCpuPointer, SMALL_SIZE);
            
            DxgkCbUnmapFrameBufferPointer(pCpuPointer);
            Offset += SMALL_SIZE;
        }
    }
}

NTSTATUS PHYSICAL_ADAPTER::OnPowerUp()
{
    Status = DxgkCbPinFrameBufferForSave(&m_pPinnedMdl);
    if(!NT_SUCCESS(Status))
    {
        m_pPinnedMdl = NULL;
    }
    
    if(pPinnedMemory != NULL)
    {
        // Normal GPU copy: m_pPinnedMdl -> frame buffer
        GpuCopyToFrameBuffer(m_pPinnedMdl, Size);
        DxgkCbUnpinFrameBufferForSave(m_pPinnedMdl);
    }
    else
    {
        SIZE_T Offset = 0;
        while(Offset != TotalSize)
        {
            SIZE_T MappedOffset = Offset;
            PVOID pCpuPointer;
            Status = DxgkCbMapFrameBufferPointer(SMALL_SIZE, &MappedOffset, &pCpuPointer);
            if(!NT_SUCCESS(Status))
            {
                // Driver must handle failure here. Even a 4KB mapping may
                // not succeed. The driver should attempt to cancel the
                // transfer and reset the adapter.
            }
                        
            RtlCopyMemory(m_pSmallCpuPointer, pCpuPointer + MappedOffset, SMALL_SIZE);
            
            GpuCopyToFrameBuffer(m_pSmallMdl, SMALL_SIZE);

            DxgkCbUnmapFrameBufferPointer(pCpuPointer);
            Offset += SMALL_SIZE;
        }
    }
}

硬件保留内存

在设备连接到 IOMMU 之前,VidMm 会映射硬件保留内存。

VidMm 会自动处理任何作为带有 PopulatedFromSystemMemory 标记的内存段报告的内存。 VidMm 会根据提供的物理地址来映射该内存。

对于未通过段公开的专用硬件保留区域,VidMm 会调用 DXGKDDI_QUERYADAPTERINFO 驱动程序来查询其范围。 所提供的范围不得与 NTOS 内存管理器使用的任何内存区域相重叠;VidMm 会验证是否存在此类交叉。 此验证可确保驱动程序不会意外报告超出保留范围的物理内存区域,因而违反该功能的安全保证。

调用一次查询是为了查询所需的范围数量,而随后的第二次调用是为了填充保留范围的数组。

测试

如果驱动程序选择使用此功能,HLK 测试将扫描驱动程序的导入表,以确保没有调用以下 Mm 函数:

  • MmAllocateContiguousMemory
  • MmAllocateContiguousMemorySpecifyCache
  • MmFreeContiguousMemory
  • MmAllocatePagesForMdl
  • MmAllocatePagesForMdlEx
  • MmFreePagesFromMdl
  • MmProbeAndLockPages

所有连续内存和 MDL 的内存分配都应通过 Dxgkrnl 的回调接口,使用列出的函数来进行。 驱动程序也不应锁定任何内存。 Dxgkrnl 会为驱动程序管理锁定的页面。 一旦重新映射了内存,提供给驱动程序的页面逻辑地址可能不再与物理地址一致。