0x00 前言
对于攻击者来说,随着像代码完整性(CI)、控制流防护(CFG)这样防止内存损坏的安全技术不断出现,他们开始将目光转向数据损坏这一方面。攻击者利用数据破坏技术,可以修改系统安全策略、提升特权、篡改安全证明、修改“一次初始化”数据结构等。
内核数据保护(Kernel Data Protection,KDP)是一项新技术,可以通过基于虚拟化的安全性(VBS)保护部分Windows内核和驱动程序。KDP是一组API,可以将某些内核内存标记为只读,从而防止攻击者修改受保护的内存。例如,我们已经发现有攻击者使用经过签名但易受攻击的驱动程序来攻击策略数据结构,并安装未经签名的恶意驱动程序。KDP可以通过保证策略数据结构不会被篡改的方式来缓解此类攻击。
这种保护内核内存为只读状态的概念,在Windows内核、收件箱组件、安全产品甚至第三方驱动程序(例如:反作弊和数字版权管理DRM软件)之中都有重要的应用。除了该技术在安全性的重要性以及应用程序防篡改保护之外,其他优点还在于:
1、性能改进:KDP减轻了证明组件(attestation component)的负担,不再需要定期验证已经设置写保护的数据变量;
2、可靠性改进:借助KDP,可以更轻松地诊断出内核损坏问题,而这些问题不一定属于安全性漏洞。
3、可以促进驱动程序开发人员和厂商改善虚拟化安全方案的兼容性,并在生态系统中提高这些技术的采用率。
KDP使用安全核心计算机上默认支持的技术,这些技术实现了一组特定的设备要求,而这些要求将隔离最佳实践、对Windows操作系统的信任最小化等安全性应用到这些技术之中。KDP可以为敏感的系统配置数据增加另一层保护,从而增强由安全核心PC(Secured-core PC)功能提供的安全性。
在这篇文章中,我们将分享有关内核数据保护的工作原理,以及如何在Windows 10上实现的技术细节,希望能促进驱动程序开发人员和厂商充分利用这项旨在防范数据损坏攻击的安全技术。
0x01 内核数据保护:概述
在VBS环境中,常规NT内核在称为VTL0的虚拟化环境中运行,而安全内核在称为VTL1的环境中运行,后者是一个更加安全的隔离环境。有关VBS和安全内核的更多详细信息,请参考这里(https://channel9.msdn.com/Blogs/Seth-Juarez/Isolated-User-Mode-in-Windows-10-with-Dave-Probert)和这里(https://channel9.msdn.com/Blogs/Seth-Juarez/Windows-10-Virtual-Secure-Mode-with-David-Hepkin)。KDP的目标是保护Windows内核中运行的驱动程序和软件(即操作系统代码本身)不会受到数据方面的攻击。KDP分为两个部分来实现:
1、静态KDP能使内核模式下运行的软件能够静态保护其自身映像的一部分,以避免被VTL0中的任何其他实体篡改。
2、动态KDP帮助内核模式软件从“安全池”分配和释放只读内存。从池中返回的内存只能初始化一次。
由KDP管理的内存始终由安全内核(VTL1)验证,并由虚拟机管理程序(hypervisor)使用二级地址转换(SLAT)表进行保护。最终,在NT内核(VTL0)中运行的任何软件都将无法修改受保护内存的内容。
在最新版本的Windows 10 Insider Build中已经提供了动态KDP和静态KDP,并且可以使用任何类型的内存(可执行页面除外)。虚拟机管理程序保护的代码完整性(HVCI)已经为可执行页面提供了保护,该功能可以防止任何未经签名的内存被设置为可执行状态,授予W^X(可写或者可执行,不能二者同时)的条件。
本文没有对HVCI和W^X条件进行介绍,有关更多详细信息,请参阅即将出版的Windows Internals图书。
1.1 静态KDP
如果驱动程序需要通过静态KDP保护其映像,则应该调用MmProtectDriverSection API,该API具有以下原型:
NTSTATUS MmProtectDriverSection (PVOID AddressWithinSection, SIZE_T Size, ULONG Flags)
驱动程序指定一个位于数据段内部的地址、受保护区域的大小(可选)和一些标志。在撰写本文时,“size”参数暂时保留以供将来使用。地址所驻留的整个数据部分将始终受到API保护。
如果函数成功执行,则支持静态部分的内存对于VTL0变为只读,并且由SLAT保护。在这里,不允许卸载带有受保护部分的驱动程序,如果尝试卸载,将会产生蓝屏错误。但是,我们知道有时应该可以卸载驱动程序。因此,我们引入了MM_PROTECT_DRIVER_SECTION_ALLOW_UNLOAD标志。如果调用方指定了它,则系统可以卸载目标驱动程序,这意味着在这种情况下,受保护的部分将首先转为不受保护的状态,然后再由NtUnloadDriver释放。
1.2 动态KDP
动态KDP允许驱动程序使用安全池提供的服务来分配和初始化只读内存,该池由安全内核管理。使用者首先创建与标签关联的安全池上下文,之后所有的内存分配都将与创建的安全池上下文相关联。在创建上下文后,可以通过ExAllocatePool3 API的新扩展参数执行只读分配:
PVOID ExAllocatePool3 (POOL_FLAGS Flags, SIZE_T NumberOfBytes, ULONG Tag, PCPOOL_EXTENDED_PARAMETER ExtendedParameters, ULONG Count);
然后,调用方可以在POOL_EXTENDED_PARAMS_SECURE_POOL数据结构中指定分配的大小,以及从中复制内存的初始缓冲区。返回的内存区域不能由在VTL0中运行的任何实体修改。另外,在分配时,调用方提供标记和Cookie值,它们被编码并嵌入到分配中。使用者可以随时验证地址是否在为动态KDP分配保留的内存范围内,预期的Cookie和标记实际上已经编码为指定的分配。这样一来,调用者就可以检查其指向安全池分配的指针是否还没有使用其他分配进行切换。
与静态KDP类似,动态KDP在默认情况下无法释放或修改内存区域。调用方可以在分配时使用SECURE_POOL_FLAGS_FREEABLE和SECURE_POOL_FLAG_MODIFIABLE标志去指定分配是否可释放或可修改。使用这些标志,会降低分配的安全性,但可以允许动态KDP内存用于无法泄露所有分配的场景中,例如按照主机上的每个进程而进行的分配。
0x02 Windows 10上的KDP实现
如前所述,静态KDP和动态KDP都依赖于虚拟机管理程序(hypervisor)中SLAT保护的物理内存。当处理器支持SLAT时,它将使用另一层进行内存地址转换。AMD是通过“嵌入页表”(nested page table)来实现SLAT,而Intel则使用“扩展页表”(extended page table)这一术语。
2.1 二级地址转换(SLAT)
当虚拟机管理程序(Hypervisor)启用SLAT支持,并且虚拟机在VMX非root模式下执行时,处理器会将称为“客体虚拟地址”(GVA,在ARM64中称为第一阶段虚拟地址)的初始虚拟地址转换成叫做“客体物理地址”(GPA,在ARM64中称为IPA)的中间物理地址。这一转换过程仍然由页表管理,由客体操作系统管理的CR3控制寄存器进行寻址。转换的最终结果将在客体页表中指定访问保护的情况下,将GPA返回给处理器。在这里,只有在内核模式下运行的软件才能与页表进行交互。Rootkit通常在内核模式下运行,并且可以修改中间物理页面的保护。
虚拟机管理程序帮助处理器使用扩展页表(嵌入页表)来转换GPA。在非SLAT系统上,当TLB中不存在虚拟地址时,处理器需要查找层次结构中的所有页表,以重建最终的虚拟地址。如下图所示,虚拟地址分为4个部分(在LA48系统上)。每个部分代表该层次结构的页表中的索引。初始PML4表的物理地址由CR3寄存器指定。这就解释了为什么处理器始终能够转换地址,并获得层次结构中下一个表的下一个物理地址。请务必注意,在层次结构的每个页表条目中,NT内核都通过一组属性来指定页保护。只有在每个页表条目上指定的保护全部都允许的情况下,才能访问最终的物理地址。
X64第一阶段地址转换(虚拟地址到客体物理地址):
启用SLAT时,需要将客体的CR3寄存器中指定的中间物理地址转换为真实系统物理地址(SPA)。这个机制与上面类似,虚拟机管理程序将表示当前正在执行的虚拟机的活动虚拟机控制块(VMCB)的nCR3字段设置为嵌入页表(扩展页表)的物理地址。(在Intel架构中,该字段称为“EPT指针”)。嵌入页表的构建方式与标准的页表相似,因此处理器需要扫描整个层次结构,以找到正确的物理地址,如下图所示。在图中,“n”表示层次结构中的嵌套页表,由虚拟机管理程序管理,而“g”表示客体页表,由NT内核管理。
下图展示了从GPA到SPA的X64第二阶段物理地址转换。
X64第二阶段物理地址转换(GPA到SPA):
如图所示,客体虚拟地址到系统物理地址的最终转换需要经过两次转换:GVA到GPA,这是由客体虚拟机的内核配置,以及GPA到SPA,这是由虚拟机管理程序配置。需要注意的是,在最不理想的情况下,由于转换涉及到4个页面层次结构,将会导致20个表查找。这个机制可能会比较慢,并会随着处理器对增强TLB的支持而得到缓解。在TLB条目中,包含了另一个标识当前正在执行的虚拟机的ID(在Intel系统中称为虚拟处理器标识符或VPID,在AMD系统中称为地址空间ID或ASID),因此处理器可以缓存属于两个不同虚拟机的一个虚拟地址的转换结果,不会产生任何冲突。
层次结构中NPT页表的嵌入条目:
如上图所示,NPT条目指定了多个访问保护属性。这使得虚拟机管理程序可以进一步保护系统物理地址。除了管理程序本身之外,其他任何实体都不能访问NPT。当处理器尝试读取、写入或运行NPT不允许访问的地址时,会引发NPT冲突(Intel体系结构中叫做“EPT冲突”),并导致虚拟机退出。由于NPT冲突导致的虚拟机退出事件不会经常发生。通常,这种情况会在嵌套配置或在HVCI使用MBEC软件时产生。如果由于其他原因导致了NPT冲突,Microsoft虚拟机管理程序会向当前虚拟处理器(VP)发送一个访问冲突异常,该异常会由客体操作系统以不同方式进行管理,如果没有异常处理程序选择处理异常,则通常会进入到Bug检查中。
2.2 静态KDP实现
SLAT保护是KDP的重要基础。在Windows中,动态KDP和静态KDP的实现相类似,都由安全内核管理。安全内核是唯一能够向虚拟机管理程序发出ModifyVtlProtectionMask超级调用(hypercall)的实体,作用是修改较低VTL0中映射的物理页面的SLAT访问保护。
对于静态KDP,NT内核会验证驱动程序并不是会话驱动程序,也没有映射大页面。如果上述验证没有通过,或者是属于可丢弃段(Discardable Section),则无法应用静态KDP。如果调用MmProtectDriverSection API的实体没有请求卸载目标映像,则NT内核将会对安全内核执行第一个调用,该调用将固定与驱动程序关联的普通地址范围(NAR)。“固定”操作可以防止重复使用驱动程序的地址空间,从而使驱动程序不可卸载。然后,NT内核将属于该段的所有页面都放入内存,并将它们设置为私有。然后,页面在子PTE结构中标记为只读(在上图中以“gPTE”表示)。在这一阶段,NT内核最终可以通过SLAT调用安全内核来保护基础物理页面。安全内核的保护分为两个阶段:
1、注册属于该段的所有物理页面,在数据库中添加适当的NTE(普通表地址)并更新属于VTL1的基础安全PFN,从而将它们标记为“由VTL0拥有”。这样将会让安全内核可以跟踪仍然属于NT内核的物理页面。
2、对VTL0 SLAT表应用只读保护。虚拟机管理程序针对每个VTL使用一个SLAT表和VMCB。
现在,目标映像的区域已经受到保护。VTL0中的任何实体都无法写入属于该段的任何页面。在这种情况下,安全内核保护了NT内核最初在VTL0中分配的一些内存页面。
2.3 动态KDP实现
动态KDP使用新Segment Heap提供的服务,从安全池分配内存,这个池几乎完全由安全内核管理。
在启动过程的早期,NT内存管理器会计算用于安全池的512GB区域随机虚拟基址,该地址恰好包含了256个内核PML4条目之一。在第一阶段的后面,NT内存管理器会发出一个安全调用,内部称为INITIALIZE_SECURE_POOL,其中包含计算出的内存区域,并运行安全内核初始化安全池。
安全内核会创建一个NAR,表示属于不安全NT内核的整个512GB虚拟区域,并初始化属于NAR的所有相对NTE。安全内核中的安全池虚拟地址空间为256GB,这意味着映射它的PML4会与其他一些内容共享,并且与NT相比不在同一个基址上。因此,在初始化安全池描述符时,安全内核还会计算一个Delta值,该Delta值是安全内核中安全池基址与NT内核中保留池基址之间的差(如下图所示)。这非常关键,因为它允许安全内核向NT内核指定将属于安全池的物理页面映射到的位置。
安全池VTL1与VTL0的DELTA值:
当在VTL0内核中运行的软件请求从安全池中分配一些内存时,将对安全内核进行安全调用,该调用将调用内部RtlpHpAllocateHeap堆函数,该函数在两个VTL中都公开。如果Segment Heap计算出安全池中没有剩余的可用内存段,则将调用SkmmAllocatePoolMemory例程,该例程为池分配新的内存页。如果没有需要,堆会避免提交新的内存页面。
与NT内核公开的NtAllocateVirtualMemory API一样,SkmmAllocatePoolMemory API支持两种操作——保留和提交。保留操作允许安全内核的内存管理器保留池分配所需的一些PTE。提交操作实际上分配了空闲的物理页面。
物理页是从属于安全内核(其安全PFN处于安全状态)的一组空闲页面中分配的,并映射到VTL 1的页表中,这种情况就表明此前所有VTL 1分页表层次结构都已经分配。与静态KDP一样,安全内核会将ModifyVtlProtectionMask超级调用(hypercall)发送到虚拟机管理程序,目的是在VTL0 SLAT表中将物理页面映射为只读。在页面变为VTL0可访问之后,安全内核将复制调用方指定的数据,并回调NT。
NT内核使用内存管理器提供的服务来映射VTL0中的客体物理页。需要注意的是,VTL0和VTL1的整个root分区物理地址空间都通过身份映射进行映射,这意味着在VTL0中有效的客体物理页号在VTL1中也同样有效。安全内核要求NT内存管理器准确知道该页面应该映射到哪个虚拟地址,从而映射属于安全池的页面。而这一计算,就需要用到在先前第一阶段中计算出的Delta值。
分配在VTL0中返回给调用方。与静态KDP一样,底层页面也不能从VTL0中的任何实体写入。
一些细心的读者可能会注意到,上面描述的KDP仅涉及为支持特定受保护内存的客体物理地址建立SLAT保护。KDP不会强制转换映射受保护区域的虚拟地址范围。现在,安全内核仅定期验证受保护的内存区域转换为适当的、受SLAT保护的GPA。KDP的设计允许未来的扩展性,可以对受保护内存区域的地址转换层次结构进行更直接地控制。
0x03 KDP在收件箱组件上的应用
为了演示KDP如何提供有价值的两个收件箱组件,我们重点介绍如何在CI.dll、Windows代码完整性以及Windows Defender System Guard运行时证明引擎中实现KDP。
首先,我们来介绍CI.dll。这里使用KDP的目的是在内部策略状态初始化后(即从注册表中读取,或在引导时生成)保护内部策略状态。这些数据结构一旦被篡改则影响非常大,经过正确签名但容易受到攻击的驱动程序有可能会攻击策略数据结构,然后在系统上安装未经签名的驱动程序。使用KDP,就可以确保策略数据结构不被篡改,从而缓解这种攻击。
对于Windows Defender System Guard,为了提供运行时认证,仅允许认证代理连接到认证驱动程序一次。这是因为状态存储在VTL1内存中。驱动程序将连接状态存储在其内存中,并且需要对其进行保护,以防止尝试使用可能被篡改的代理重置连接。KDP可以锁定这些变量,并确保只能在代理和驱动程序之间建立单个连接。
代码完整性和Windows Defender System Guard是安全核心PC的两个关键功能。KDP增强了对这些重要安全系统的保护,并提高了攻击者破坏安全核心PC的技术门槛。
这些仅仅是几个示例,来说明以只读方式保护内核和驱动程序内存对于系统的安全性和完整性的促进。随着KDP被更加广泛地采用,我们希望能够不断扩大保护的范围,更广泛地防御数据损坏攻击。
0x04 使用KDP
除了运行基于虚拟化的安全性这一要求之外,动态KDP和静态KDP都没有其他要求。理想情况下,我们可以在任何支持下述功能的计算机上启动VBS:
1、Intel、AMD或ARM虚拟化扩展;
2、二级地址转换:AMD中称为NPT,Intel中称为EPT、ARM中称为二级地址转换;
3、硬件MBEC,可以降低与HVCI相关的性能成本(可选)。
有关VBS要求的更多信息,请参考这里(https://docs.microsoft.com/en-us/windows-hardware/design/device-experiences/oem-vbs)。在安全核心PC上,支持虚拟化的安全性,并且默认启用了硬件支持的安全性功能。大家可以从各种合作伙伴供应商那里购买到具有核心安全功能的个人计算机,目前已经使用KDP进行了加固。