用户模式访问器

用户模式访问器(UMA)是一组 DDI,旨在从内核模式代码安全地访问和操作用户模式内存。 这些 DDI 解决了内核模式驱动程序访问用户模式内存时可能发生的常见安全漏洞和编程错误。

内核模式代码访问或操作用户模式内存时,将很快需要使用 UMA。

从内核模式访问用户模式内存时可能出现的问题

当内核模式代码需要访问用户模式内存时,会出现以下几个难题:

  • 用户模式应用程序可以将恶意或无效的指针传递给内核模式代码。 缺少适当的验证可能会导致内存损坏、崩溃或安全漏洞。

  • 用户模式代码是多线程代码。 因此,不同的线程可能会在单独的内核模式访问之间修改相同的用户模式内存,这可能会导致内核内存损坏。

  • 内核模式开发人员通常会在访问用户模式内存之前忘记探测用户模式内存,这是一个安全问题。

  • 编译器假定单线程执行,因此可能去除似乎是冗余的内存访问。 程序员不知道此类优化可以编写不安全的代码。

以下代码片段说明了这些问题。

示例 1:用户模式下的多线程可能导致内存损坏

需要访问用户模式内存的内核模式代码必须在 __try/__except 块内执行,以确保内存有效。 以下代码片段显示了用于访问用户模式内存的典型模式:

// User-mode structure definition
typedef struct _StructWithData {
    ULONG Size;
    CHAR* Data[1];
} StructWithData;

// Kernel-mode call that accesses user-mode memory
void MySysCall(StructWithData* Ptr) {
    __try {
        // Probe user-mode memory to ensure it's valid
        ProbeForRead(Ptr, sizeof(StructWithData), 1);

        // Allocate memory in the kernel
        PVOID LocalData = ExAllocatePool2(POOL_FLAG_PAGED, Ptr->Size);
        
        // Copy user-mode data into the heap allocation
        RtlCopyMemory(LocalData, Ptr->Data, Ptr->Size);
    } __except (…) {
        // Handle exceptions
    }
}

此代码片段首先探测内存,这是一个重要的但经常被忽略的步骤。

但是,此代码中可能发生的一个问题是由于用户模式下的多线程处理。 具体而言, Ptr->Size 在调用 ExAllocatePool2 之后可能会更改,但在调用 RtlCopyMemory 之前,可能会导致内核中的内存损坏。

示例 2:编译器优化导致的可能问题

尝试解决示例 1 中的多线程问题可能是在分配和复制之前,将 Ptr->Size 先复制到一个本地变量中:

void MySysCall(StructWithData* Ptr) {
    __try {
        // Probe user-mode memory to ensure it's valid
        ProbeForRead(Ptr, sizeof(StructWithData), 1);
        
        // Read Ptr->Size once to avoid possible memory change in user mode
        ULONG LocalSize = Ptr->Size;
        
        // Allocate memory in the kernel
        PVOID LocalData = ExAllocatePool2(POOL_FLAG_PAGED, LocalSize);
        
        //Copy user-mode data into the heap allocation
        RtlCopyMemory(LocalData, Ptr, LocalSize);
    } __except (…) {}
}

虽然此方法可缓解多线程导致的问题,但它仍然不安全,因为编译器不知道多个线程,因此假定单个线程执行。 作为一种优化,编译器可能会发现它的堆栈上已经有一个 Ptr->Size 所指向值的副本,因此不会执行复制到 LocalSize 的操作。

用户模式访问器解决方案

UMA 接口解决了从内核模式访问用户模式内存时遇到的问题。 UMA 提供:

  • 自动探测:不再需要显式探测(ProbeForRead/ProbeForWrite),因为所有 UMA 函数都可确保地址安全。

  • 易失性访问:所有 UMA DDI 都使用可变语义来防止编译器优化。

  • 易于移植性:全面的 UMA DDI 集使客户可以轻松移植其现有代码以使用 UMA DDI,确保安全正确访问用户模式内存。

使用 UMA DDI 的示例

使用以下代码片段使用以前定义的用户模式结构,演示如何使用 UMA 安全地访问用户模式内存。

void MySysCall(StructWithData* Ptr) {
    __try {

        // This UMA call probes the passed user-mode memory and does a
        // volatile read of Ptr->Size to ensure it isn't optimized away by the compiler.
        ULONG LocalSize = ReadULongFromUser(&Ptr->Size);
        
        // Allocate memory in the kernel.
        PVOID LocalData = ExAllocatePool2(POOL_FLAG_PAGED, LocalSize);
        
        //This UMA call safely copies UM data into the KM heap allocation.
        CopyFromUser(&LocalData, Ptr, LocalSize);
        
        // To be safe, set LocalData->Size to be LocalSize, which was the value used
        // to make the pool allocation just in case LocalData->Size was changed.
        ((StructWithData*)LocalData)->Size = LocalSize;

    } __except (…) {}
}

UMA 实现和用法

UMA 接口作为 Windows 驱动程序工具包(WDK)的一部分提供:

  • 函数声明位于 usermode_accessors.h 头文件中。
  • 函数实现位于名为 umaccess.lib 的静态库中。

UMA 适用于所有版本的 Windows,而不仅仅是最新的版本。 需要使用最新的 WDK 从 usermode_accessors.humaccess.lib 分别获取函数声明和实现。 生成的驱动程序将在较旧版本的 Windows 上运行。

Umaccess.lib 为所有 DIS 提供安全的下层实现。 在 Windows 内核的 UMA 感知版本中,驱动程序的所有功能都将重定向到 ntoskrnl.exe中实现的更安全版本。

由于访问用户模式内存时可能存在异常,所有用户模式访问器函数都必须在结构化异常处理程序(SEH)内执行。

用户模式访问器 DDI 的类型

UMA 为不同类型的用户模式内存访问提供各种 DDI。 大多数这些 DDI 适用于基本数据类型,例如 BOOLEAN、ULONG 和指针。 此外,UMA 还提供若干 DDI,用于批量内存访问、字符串长度检索和互锁作业。

基本数据类型的泛型 DDI

UMA 提供六个函数变体,用于读取和写入简单数据类型。 例如,以下这些函数可用于布尔值:

函数名 Description
ReadBooleanFromUser 从用户模式内存中读取值。
ReadBooleanFromUserAcquire 从用户模式内存中读取值,并获取用于内存排序的语义。
ReadBooleanFromMode 基于模式参数从用户模式或内核模式内存中读取。
WriteBooleanToUser 将值写入用户模式内存。
WriteBooleanToUserRelease 将值写入用户模式内存,其中包含用于内存排序的发布语义。
WriteBooleanToMode 基于模式参数写入用户模式或内核模式内存。

对于 ReadXxxFromUser 函数, Source 参数必须指向用户模式虚拟地址空间(VAS)。 在读取 XxxFromMode 版本时,也是如此Mode == UserMode

对于ReadXxxFromMode,当Mode == KernelMode发生时,源参数必须指向内核模式 VAS。 如果定义了预处理器 DBG,则操作会很快失败并显示FAST_FAIL_KERNEL_POINTER_EXPECTED代码。

WriteXxxToUser 函数中, 目标 参数必须指向用户模式 VAS。 写入 XxxToMode 版本时Mode == UserMode也是如此。

复制和内存操作的DDI

UMA 提供用于在用户和内核模式之间复制和移动内存的功能,包括非临时副本和对齐副本的变体。 这些函数标有注释,指示潜在的 SEH 异常和 IRQL 要求(最大APC_LEVEL)。

示例包括 CopyFromUserCopyToModeCopyFromUserToMode

在执行复制操作之前,像 CopyFromModeAlignedCopyFromUserAligned 这样的宏包括安全对齐探测。

CopyFromUserNonTemporalCopyToModeNonTemporal 等宏提供非时间性的副本,以避免缓存污染。

结构读/写宏

用于在模式之间读取和写入结构的宏可确保类型兼容性和对齐方式,并调用带有大小和模式参数的辅助函数。 示例包括 WriteStructToModeReadStructFromUser 及其对齐变体。

填充和清零内存函数

为了在用户地址空间或模式地址空间中填充或清零内存,提供了 DDI,参数包含指定的目标、长度、填充值和模式。 这些函数还包含 SEH 和 IRQL 注释。

示例包括 FillUserMemoryZeroModeMemory

互锁操作

UMA 包括用于原子内存访问的互锁操作,这对于在并发环境中的线程安全内存操作至关重要。 提供适用于 32 位和 64 位值的 DDI,版本分别针对用户模式内存和内核模式内存。

示例包括 InterlockedCompareExchangeToUserInterlockedOr64ToModeInterlockedAndToUser

字符串长度 DDI

包含用于从用户或模式内存安全确定字符串长度的函数,同时支持 ANSI 和宽字符字符串。 这些函数旨在引发不安全内存访问的异常,并受 IRQL 约束。

示例包括 StringLengthFromUserWideStringLengthFromMode

大型整数和 Unicode 字符串访问器

UMA 提供 DDI 来在用户模式和内核模式内存之间读取和写入 LARGE_INTEGER、ULARGE_INTEGER 和 UNICODE_STRING 数据类型。 变体具有获取和发布语义,并借助模式参数来确保安全性和正确性。

示例包括 ReadLargeIntegerFromUserWriteUnicodeStringToModeWriteULargeIntegerToUser

获取和发布语义

在某些体系结构(如 ARM)上,CPU 可以重新排序内存访问。 如果需要保证内存访问未针对用户模式访问重新排序,则泛型 DDI 都具有 Acquire/Release 实现。

  • 获取语义防止加载操作相对于其他内存操作重新排序。
  • 发布语义可防止存储操作与其他内存操作之间的重新排序。

UMA 中获取和发布语义的示例包括 ReadULongFromUserAcquireWriteULongToUserRelease

有关详细信息,请参阅 “获取和发布语义”。

最佳做法

  • 从内核代码访问用户模式内存时,请始终使用 UMA DDI
  • 使用适当的__try/__except
  • 当代码可能同时处理用户模式和内核模式内存时,请使用基于模式的 DDI
  • 在内存排序对于用例非常重要时,请考虑获取/释放语义
  • 在将数据复制到内核内存后验证复制的数据,以确保一致性。

未来的硬件支持

用户模式访问器旨在支持未来的硬件安全功能,例如:

  • SMAP(管理模式访问防护):防止内核代码访问用户模式内存,除非通过指定函数(例如 UMA DDIs)。
  • ARM PAN(从不特权访问):在 ARM 体系结构中的类似安全保护。

通过一致地使用 UMA DDI,驱动程序将在将来的 Windows 版本中启用时与这些安全增强功能兼容。