在本文中,我们将为读者详细介绍在Windows 11内部预览版中,KUSER_SHARED_DATA结构体发生了哪些新变化。下面,我们开始进入本文的下篇部分!
(接上文)
知道了这些,我们就会明白:在调用nt!MiReservePtes之前,就可以计算出对应于“静态”的KUSER_SHARED_DATA的PFN数据库的适当索引。这实质上意味着我们正在从PFN数据库中检索相应PFN记录(一个MMPFN结构)的虚拟地址。
我们可以把它看作是PFN数据库的基址,在本例中是0xffffc38000000000,它参与了相关操作。而最终的虚拟地址0xffffc380002df8a0(与“静态”KUSER_SHARED_DATA关联的PFN记录的虚拟地址)可以在下面的RBP中看到。将来,它将用作nt!MiMakeProtectionPfnCompatible函数调用的第二个参数。
我们可以通过将上述虚拟地址解析为MMPFN结构体来验证这一点,以查看PteAddress成员是否对应于“静态”KUSER_SHARED_DATA的已知PTE。我们知道,PTE位于0xffffb7fbc0000000。
由于PFN结构体的PteAddress成员与“静态”KUSER_SHARED_DATA关联的PTE的虚拟地址是对齐的,这说明它就是与“静态”KUSER_SHARED_DATA关联的PFN记录。
然后,这个值被用于对nt!MiReservePtes的调用,我们可以通过前面的两张图来确认这一点。根据__FastCall调用约定,该函数的第一个参数将进入RCX寄存器。这个参数实际上是一个nt!_MI_SYSTEM_PTE_TYPE结构体。
根据CodeMachine的文章来看,当对nt!MiReservePtes的调用发生时,这个结构体被用来定义如何进行内存分配,以便为正在创建的PTE预留内存。当用nt!MiReservePtes请求分配内存时,可能暗示了从系统PTE区域分配一块虚拟内存。系统PTE区域被用于内存的映射视图、内存描述符列表(MDL)和其他内容。有了这一信息,结合我们对两个虚拟地址是如何被映射到同一物理内存页的了解,就能确定:系统正在使用内存的不同 "视图"(例如,两个虚拟地址对应一个物理地址,所以,尽管两个虚拟地址包含相同的内容,但可能具有不同的权限)。此外,我们可以确认分配的内存来自系统PTE区域,因为nt!_MI_SYSTEM_PTE_TYPE结构体的VaType成员被设置为9,这是一个与MiVaSystemPtes对应的枚举值。这意味着,在这种情况下,分配的内存将来自系统PTE的内存区域。
我们可以看到:在调用发生后,返回值是一个内核模式的地址,位于系统PTE区域的同一地址空间内,并且是由BasePte成员定义的。
此时,OS基本上已经以未填充的PTE结构体的形式从系统PTE区域分配了内存,该区域通常用于映射内存的多个视图。下一步将是正确配置该PTE,并将其分配给一个内存地址。
之后,将继续调用nt!MiMakeProtectionPfnCompatible。如前所述,该函数的第二个参数将是来自PFN数据库的PFN记录的虚拟地址,该记录与应用于“静态”KUSER_SHARED_DATA的PTE相关联。
传递给nt!MiMakeProtectionPfnCompatible的第一个参数是常数4。这个价值从何而来?看一下ReactOS,我们可以看到两个常数,它们用于描述PTE强制执行的内存权限。
根据ReactOS的说法,还有一个名为MI_MAKE_HARDWARE_PTE_KERNEL的函数,也利用了这些常数;其原型和定义可以在下文中看到。
该函数提供了nt!MiMakeProtectionPfnCompatible和nt!MiMakeValidPte(稍后将看到的函数)所公开的功能的组合。而值4或MM_READWRITE实际上是名为MmProtectTopTemask的数组的索引。该数组负责将请求的页面权限(4,或MM_READWRITE)转换为与PTE兼容的掩码。
我们可以看到,前五个元素为:{0,PTE_READONLY,PTE_EXECUTE,PTE_EXECUTE_READ,PTE_READWRITE}。从这里我们可以确认,以4作为下标访问这个数组,就能访问PTE_READWRITE的PTE掩码,这正是nt!MmWriteableSharedUserData所期望的内存权限,因为我们知道:这应该是KUSER_SHARED_DATA的“新映射视图”,它是可写的。同时,别忘了:与“静态”KUSER_SHARED_DATA关联的PFN记录的虚拟地址是通过RDX在函数调用中使用的。
在函数调用之后,返回值是一个“与PTE兼容”的掩码,它表示一个可读和可写的内存页面。
到目前为止,我们已经掌握了:
1、当前为空的PTE地址;
2、PTE的“骨架”(例如,可提供可读/可写的掩码)
考虑到这一点,现在让我们将注意力转向对nt!MiMakeValidPte的调用。
nt!MiMakeValidPte实际上提供了前面所说的ReactOS函数MI_MAKE_HARDWARE_PTE_KERNEL的“其余”功能。并且,nt!MiMakeValiePte需要以下信息:
1、 新创建的空PTE的地址(这个PTE将被应用到nt!MmWriteableUserSharedData的虚拟地址);目前这个地址位于RCX中。
2、 一个PFN;目前位于RDX中(例如,不是来自PFN数据库的虚拟地址,而是原始的PFN“值”)。
3、 一个“兼容PTE的”掩码(例如,我们的读/写属性);目前位于R8中。
所有这些信息都可以在下面的屏幕截图中看到。
就“将同一物理内存映射到不同视图”而言,这里最重要的组成部分是RDX中的值,它是KUSER_SHARED_DATA的实际PFN值(原始值,而不是虚拟地址)。让我们首先回忆一下,PFN乘以一个页面的大小(0x1000字节,或4KB)后,实际上就是一个物理地址。这是真的,特别是在我们的案例中,因为我们正在处理最细化的内存类型:4KB对齐的内存块。由于没有更多的分页结构需要索引——这是PFN最常见的用途,因此,这就意味着:在这种情况下,PFN将被用来获取最终的、4KB对齐的内存页。
我们知道,这其实就是在正在执行的函数(nt!MiProtectSharedUserPage)里面创建了一个PTE(通过nt!MiReservePtes和nt!MiMakeValidPte)。正如我们所知,这个PTE将被应用于一个虚拟地址,并用于将所述虚拟地址映射到一个物理页面,本质上是通过与PTE相关的PFN实现的。目前,将用于这种映射的PFN被存储在RDX中。在较低的水平上,RDX中的这个值乘以一个页面的大小(4KB),就是虚拟地址被映射到的实际物理页面。
有趣的是,RDX中的这个值(在第二次调用nt!MI_READ_PTE_LOCK_FREE后保留下来的值),就是与KUSER_SHARED_DATA相关的PFN! 换句话说,我们给这个新创建的PTE分配的虚拟地址(最终应该是nt!MmWriteableUserSharedData)将映射到KUSER_SHARED_DATA结构体所在的物理内存,因此,当nt!MmWriteableUserSharedData的内容被更新时,该物理内存也将被更新。由于“静态”的KUSER_SHARED_DATA(0xfffff78000000000)也位于相同的物理内存中,它也会随之更新。实际上,即使只读的“静态”KUSER_SHARED_DATA不允许执行写操作,它仍然会收到nt!MmWriteableUserSharedData的更新,也就是说:它是可读和可写的。这是因为这两个虚拟地址都会映射到同一个物理内存,所以,只要对其中一个执行写操作,另一个也会随之发生变化!
既然如此,也就没有很好的理由让“正常的”KUSER_SHARED_DATA结构地址(例如0xfffff78000000000)不是只读的,因为现在有另一个内存地址可以用来代替它。这样做的好处是,可写的“版本”或“映射”(即nt!MmWriteableUserSharedData)是随机的!
现在继续,我们告诉操作系统:我们需要一个有效的、可读和可写的PTE,它由KUSER_SHARED_DATA的PFN(用于所有意图和目的的物理地址)提供支持,并且将被写入我们已经从系统PTE区域分配的PTE(因为这个内存用于映射“视图”)。
在执行该函数后,我们可以看到情况就是这样的!
下一个函数调用nt!MiPteInShadowRange实际上只是进行边界检查,看看我们的PTE是否位于影子空间中。回想一下前面的内容,对于内核虚拟地址影子(KVAS)的实现来说,分页结构是独立的:一组用于用户模式,一组用于内核模式。通常来说,“影子空间”(也称为用于用户模式寻址的结构体)是位于nt!MiPteInShadowRange的检查范围内的。不过,由于我们处理的是一个内核模式页面,因此,它所对应的PTE肯定不在“影子空间”内。就我们的目的而言,这并不是我们真正感兴趣的东西。
在这个函数调用之后,将会执行mov qword ptr[rdi],rbx指令。这将使用nt!MiMakeValidPte函数所创建的相应位,来更新我们分配的PTE(它之前是空白的)!这样,我们就得到了一个有效的PTE,并被保存到位于虚拟地址0xFFFF78000000000处的KUSER_SHARED_DATA所在的同一段物理内存中!
此时,我们离目标符号nt!MmWriteableUserSharedData仅有几条指令,该符号刚才用新的ASLR映射的KUser_Shared_Data视图进行了更新。然后,就可以将“静态”KUSER_SHARED_DATA设为只读(回想一下,在加载时,它还是可读/写的!)。
目前,通过RDI,我们就能得到用于新的、可读/写的PTE的地址和KUSER_SHARED_DATA的随机映射视图(通过nt!MiReservePtes生成)。上面的截图显示,会对RDI执行一些位运算,同时,我们可以看到页表项的基址也会参与运算。这些都是简单的编译器优化,用于将一个给定的PTE转换为PTE所应用的虚拟地址。
这是一个必要的步骤,回顾一下,到此为止,我们已经成功地从系统PTE区域生成了一个PTE,并将其标记为读/写,告诉它使用“静态”的KUSER_SHARED_DATA作为虚拟内存对应的物理内存,但是我们并没有将其实际应用于虚拟内存地址,该地址将由这个PTE来描述和映射!我们要应用这个PTE的虚拟地址,将是我们要存储在nt!MmWriteableUserSharedData中的值!
让我们再次回顾一下把新的PTE转换为相应虚拟地址的位运算。
正如我们所知,RDI寄存器存放的就是目标PTE的地址。同时我们还知道,检索与给定虚拟地址相关的PTE的步骤如下所示(即通过适当的索引访问PTE数组):
1、 通过将虚拟地址除以一个页面的大小(在标准Window系统上为0x1000字节),将虚拟地址转换成虚拟页面号(VPN)。
2、 用上述数值乘以PTE的大小(在64位系统上为0x8字节)。
3、 把这个值加到页表条目数组的基址上。
这相当于以PteBaseArray[VPN]方式来访问PTE数组。由于我们知道如何从虚拟地址转换为PTE,因此,只要将这些步骤倒过来,就能检索与给定PTE相关的虚拟地址。
知道PTE后,“反转”过程如下所示:
1、 将RDI中的PTE(目标PTE)减去PTE数组的基址,以提取PTE数组的索引;
2、 用这个值除以PTE的大小(0x8字节)来获取虚拟页面号(VPN);
3、 用这个值乘以一个页面的大小(0x1000)来检索虚拟地址。
我们还知道编译器会生成一条sar rdi,10H指令,对上述步骤生成的值进行算术右移,注意,这个过程其符号并不会发生变化。如果在WinDbg中复现这个过程,我们可以看到,最终值(0x0000A580A4002000)将转换为地址0xFFFFA580A4002000。
将计算得到的值与内核生成的值进行比较,我们可以看到,它就是对应于PTE的虚拟地址,该地址将映射到KUSER_SHARED_DATA所在的物理内存,并且两个地址最多匹配到0xffffa580a4002000!我们可以断定,这些位运算属于将PTE转换为虚拟地址的宏的一部分,这是编译器优化过的代码!
该功能在ReactOS中以名为mi_write_valid_pte的函数形式提供。正如我们所看到的,它本质上不仅将PTE内容写入PTE地址(在本例中是通过nt!MiReservePtes从系统PTE区域分配内存),而且还通过函数miptetoaddress获取与PTE相关联的虚拟地址。
太棒了!但是,我们还需要做最后一件事,那就是将“静态”KUSER_SHARED_DATA的地址转换为只读的。我们已经看到,当前正在排队等待调用nt!MiMakeProtectionPfnCompatible函数。在保存内存权限常量的RCX中,我们可以看到其值为1,或者MM_READONLY的值——还记得之前为KUSER_SHARED_DATA的读/写映射创建的兼容PTE的掩码吗?换句话说,该页面所拥有的唯一内存“权限”,就是读取。
在RDX中,存放的是我们对PFN数组的索引;通过将"静态"KUSER_SHARED_DATA的PTE的虚拟地址(位于0xffffb7fbc000000的PTE)与位于PFN结构MMPFN中的PTE进行比较,我们可以确定:我们已经得到了与"静态"KUSER_SHARED_DATA相关联的PFN,从而得到了一个与PTE兼容的值。
与上次一样,现在只是有了一个只读页面,我们还需调用nt!MiMakeValidPte,通过其PTE的虚拟地址(0xFFFFB7C000000000)为“静态”KUSER_SHARED_DATA分配只读权限。
调用成功后,会生成一个 PTE,以用于只读页面。
“静态”KUSER_SHARED_DATA结构体也是通过前面提到的相同方法(ReactOS中提供的方法称为MI_WRITE_VALID_PTE)进行更新的。
就我们的目的而言,对于nt!MiProtectSharedUserPage所做的各种事情,我们感兴趣的就是这些!我们现在有两个虚拟地址都映射到KUSER_SHARED_DATA所在的物理内存(一个地址是只读的,对应于0xfffff78000000000处的"静态"KUSER_SHARED_DATA结构体,另一个则对应于新的nt!MmWriteableUserSharedData版本,它是随机化的,并且是可读/写的)!
例如,我们现在可以在IDA中看到,KUSER_SHARED_DATA的更新过程,是通过随机化和可写的新符号来完成的。下图取自nt!KiUpdateTime,在这里我们可以看到KUSER_SHARED_DATA的几个偏移量被更新了(即变为0x328和0x320)。同样,在同一张图中,我们还可以看到,当读取来自KUSER_SHARED_DATA的成员时,Windows将使用旧的“静态”硬编码地址(在本例中,它们是0xfffff78000000008和0xfffff78000000320)。
小结
很明显,滥用这个代码洞的原语已不可用,之前被攻击者利用的静态结构体现在已经得到了安全加固。然而,对于如今的漏洞利用过程来说,要想实现代码执行,必须首先设法绕过kASLR机制——尽管这不是非常困难,但是,如果攻击者无法绕过kASLR,就无法将代码写入内存。不言而喻,如果攻击者能够在内核加载过程中通过竞态条件或其他原语尽早将代码写入内存,比如将代码写入静态的0xfffff78000000000+0x800处的KUSER_SHARED_DATA代码洞中,就能绕过这个障碍,因为我们知道:当内核第一次被映射到内存时,这个结构体仍然是可读和可写的。然而,当内核加载完成后,这个区域将变成只读的。但是,尽管如此,这仍然是可能的,因为初始化发生在内核加载过程中。实际上,有一些已公开的exploit就是利用了这个原语,例如chompie1337的SMBGhost概念验证就是如此,所以,作为防御方,不仅需要提高攻击者的门槛,还需要了解公开exploit的最新动向。虽然本文介绍的内容是一个相当小众的变动/缓解措施,但我认为它非常有趣,至少在此过程中学到了很多关于系统PTE区域和内存视图的知识。
如果您有任何意见、疑问、更正或建议,请随时联系我。
最后,祝大家阅读愉快!
本文翻译自:https://connormcgarr.github.io/kuser-shared-data-changes-win-11/如若转载,请注明原文地址