CVE-2024-49138 Windows CLFS heap-based buffer overflow analysis – Part 2

In the previous article, we discussed a vulnerability in the LoadContainerQ() function inside clfs.sys. The root cause of the vulnerability was LoadContainerQ() using a CLFS_CONTAINER_CONTEXT.pContainer without checking if FlushImage() invalidated the General Metadata Block.

The previous article mentioned that it was possible to develop another exploit leveraging the patched vulnerability in WriteMetadataBlock(). The aim of this article is to analyze the vulnerability in WriteMetadataBlock() and develop another proof of concept exploit.

The Vulnerability Analysis section describes the vulnerability and the Exploitation section describes how to exploit the vulnerability to obtain arbitrary read/write in ring 0 and elevate privileges to system. Limitations and Improvements highlights some limitations of the existing PoC and provides some hints to improve it, while the Patch Analysis section briefly describes the patch applied by Microsoft. Finally, the Detection section provides some recommendations to detect exploitation of both vulnerabilities.

As previously, the analysis was conducted on a Windows 11 23H2 machine with the following hashes for ntoskrnl.exe and clfs.sys:

PS C:\Windows\System32\drivers> Get-FileHash .\clfs.sys

Algorithm       Hash                                                                   Path
---------       ----                                                                   ----
SHA256          B138C28F72E8510F9612D07D5109D73065CED6CBBF8079A663A1E0601FE0FBAA       C:\Windows\System32\drivers\c...


PS C:\Windows\System32\drivers>
PS C:\Windows\System32> Get-FileHash .\ntoskrnl.exe

Algorithm       Hash                                                                   Path
---------       ----                                                                   ----
SHA256          0CE15480462E9CD3F7CBF2D44D2E393CF5674EE1D69A3459ADFA0E913A7A2AEB       C:\Windows\System32\ntoskrnl.exe


PS C:\Windows\System32>

Note: All code snippets provided here come from reverse engineering and may not be entirely accurate.

Vulnerability Analysis

The following screenshot shows the various CLFS_METADATA_BLOCK structures in _CClfsBaseFilePersisted.m_rgBlocks:

General Metadata Block and Shadow in memory and reference counts

It is important to notice that the pbImage of the original block and the one of the shadow block point to the same memory location. However, the reference count of a shadow block is always 0 while the reference count of the original block is always higher than 1 and can increase.

In fact, the screenshot shows that the General Metadata Block was loaded in memory at address 0xffffd400ef9a000 and the same address was set also for the corresponding shadow block. However, the reference count of the General Metadata Block is set to 1 while the reference count of General Metadata Block Shadow is set to 0. 

AddSymbol()

The CClfsBaseFilePersisted::AddContainer() function is responsible for adding a new container and is triggered through the user-space API AddLogContainer():

__int64 __fastcall CClfsBaseFilePersisted::AddContainer(
        _CClfsBaseFilePersisted *this,
        struct _UNICODE_STRING *container_name,
        ULONGLONG *a3,
        char a4,
        unsigned __int8 arg20,
        unsigned __int8 arg28,
        struct _CLFS_CONTAINER_CONTEXT *a2)
{
[...]
a2->cidNode.cType = -1;
v11 = ExAcquireResourceExclusiveLite((PERESOURCE)this->m_presImage, 1u);
if ( !CClfsBaseFile::GetBaseLogRecord(this) )
goto LABEL_13;
if ( !RtlNumberOfClearBits(&this->bitmap) )
{
ret = -1072037870;
goto LABEL_15;
}
ExReleaseResourceForThreadLite((PERESOURCE)this->m_presImage, (ERESOURCE_THREAD)KeGetCurrentThread());
ret = CClfsBaseFilePersisted::AddSymbol(
this,
container_name,
&this->m_symtblContainer,
0x30,
&hashsym_container,
(unsigned __int64 *)offset_hashsymcontainer);
ret_1 = ret;
[...] }

Internally it calls CClfsBaseFilePersisted::AddSymbol() to add the container (client and containers are symbols in CLFS) to the General Metadata Block:

__int64 __fastcall CClfsBaseFilePersisted::AddSymbol(
        _CClfsBaseFilePersisted *this,
        struct _UNICODE_STRING *symbol,
        struct _CLFSHASHTBL *hashtable,
        int symsize,
        struct _CLFSHASHSYM **phashsym,
        unsigned __int64 *poffset)
{
[...]
  while ( 1 )
  {
    ret_1 = CClfsBaseFile::FindSymbol(symbol, hashtable, 1, symsize, phashsym);
    if ( ret_1 >= 0 )
    {
      *poffset = *(_DWORD *)phashsym - (unsigned int)CClfsBaseFile::GetBaseLogRecord(this);
      goto ret;
    }
    if ( ret_1 != 0xC0000023 )
      goto ret1;
    container_size = 0LL;
    ret = CClfsContainer::GetContainerSize((_CClfsContainer *)this->pCclfsContainer, &container_size);
    if ( ret < 0 )
      break;
    ExReleaseResourceForThreadLite((PERESOURCE)this->m_presImage, (ERESOURCE_THREAD)KeGetCurrentThread());
    ret_2 = CClfsBaseFilePersisted::ExtendMetadataBlock(this, ClfsMetaBlockGeneral, (unsigned int)container_size >> 1);
    v11 = ExAcquireResourceExclusiveLite((PERESOURCE)this->m_presImage, 1u);
[...]
  return (unsigned int)ret_1;
}

Notice that it calls CClfsBaseFile::FindSymbol() to add the symbol. If it fails, it calls ExtendMetadataBlock().

FindSymbol()

FindSymbol() internally calls AllocSymbol():

__int64 __fastcall CClfsBaseFile::FindSymbol(
        struct _UNICODE_STRING *string,
        struct _CLFSHASHTBL *hashTable,
        char flag,
        int symbolsize,
        struct _CLFSHASHSYM **pphashsym)
{
[...]
  v14 = (_CClfsBaseFilePersisted *)*p_pBaseFile;
  v15 = (_DWORD)poffset - (unsigned int)CClfsBaseFile::GetBaseLogRecord(v14);
  v35 = (symbolsize + 7) & 0xFFFFFFF8;
  v16 = ((__int64 (__fastcall *)(_CClfsBaseFilePersisted *, _QWORD, struct _CLFSHASHSYM **))v14->vftbl_0_00000001C0014000->CClfsBaseFilePersisted::AllocSymbol(ulong,void * *))(
          v14,
          v10 + v35 + 0x30,
          &v36);
 if ( v16 < 0 )
  {
LABEL_29:
    *pphashsym = 0LL;
    return (unsigned int)v16;
[...]
}

AllocSymbol() fails when cbSymbolZone points to a location that is too close to the signatures array. This means that the General Metadata block must be extended:

NTSTATUS __fastcall CClfsBaseFilePersisted::AllocSymbol(_CClfsBaseFilePersisted *this, unsigned int size, void **ppout)
{
[...]
  logBlockHeader = (CLFS_LOG_BLOCK_HEADER *)this_1->m_rgBlocks[ClfsMetaBlockGeneral].pbImage;
  *ppout = 0LL;
  cbSymbolZone = BaseLogRecord->cbSymbolZone;
  if ( (char *)&BaseLogRecord[1] + cbSymbolZone + size_1 > (char *)(&logBlockHeader->MajorVersion
                                                                  + logBlockHeader->SignaturesOffset) )
    return 0xC0000023;
[...]
}

Therefore, FindSymbol() returns the error caused by AllocSymbol() and causes AddSymbol() to call CClfsBaseFilePersisted::ExtendMetadataBlock().

ExtendMetadataBlock()

The ExtendMetadataBlock() routine first loops over all the blocks starting from blockType, that corresponds to ClfsMetaBlockGeneral=2 when called by CClfsBaseFilePersisted::AddSymbol(), and calls AcquireMetadataBlock() on each of them:

__int64 __fastcall CClfsBaseFilePersisted::ExtendMetadataBlock(
        _CClfsBaseFilePersisted *this,
        __int64 blockType,
        __int64 extendedSectors)
{
[...]
  for ( j = blockType_1; j < this->m_cBlocks; j += 2 )
  {
    res = CClfsBaseFile::AcquireMetadataBlock(this, (enum _CLFS_METADATA_BLOCK_TYPE)j);
    res_1 = res;
    if ( res < 0 )
      goto exit1;
  }
  res = CClfsBaseFile::GetControlRecord(this, &controlRecord, 0);
  res_1 = res;
  if ( res < 0 )
    goto exit1;
  controlRecord_1 = controlRecord;
  eExtendState = controlRecord->eExtendState;
  if ( eExtendState )
  {
    v14 = (CClfsBaseFilePersisted *)(unsigned int)(eExtendState - 1);
    if ( (_DWORD)v14 )
    {
      if ( (_DWORD)v14 == 1 )
        goto clfsExtendStateFlusingBlock;
      goto exit;
    }
    goto clfsExtendStateExtendingFsd;
  }
  for ( i = 0; ; ++i )
  {
    i_1 = i;
    if ( i >= this->m_cBlocks )
      break;
    res = CClfsBaseFilePersisted::WriteMetadataBlock(this, (enum _CLFS_METADATA_BLOCK_TYPE)i, 0);
    res_1 = res;
    if ( res < 0 )
      goto exit;
  }
[...]
exit:
  while ( (unsigned int)blockType_1 < j )
  {
    CClfsBaseFile::ReleaseMetadataBlock(this, (enum _CLFS_METADATA_BLOCK_TYPE)blockType_1);
    LODWORD(blockType_1) = blockType_1 + 2;
  }
  if ( res < 0 )
[...]
return (unsigned int)res;
}

AcquireMetadataBlock() increases the reference count in m_rgcBlockReferences[blockType] or load the block in memory with ReadMetadataBlock() and sets the reference count to 1 in m_rgcBlockReferences[blockType]. Notice the AcquireMetadataBlock() is not called on shadow blocks.

After that, the function retrieves the CLFS_CONTROL_RECORD, calling CClfsBaseFile::GetControlRecord(), and checks CLFS_CONTROL_RECORD.eExtendState field.

If eExtendState is ClfsExtendStateNone, i.e., it is 0, it will loop over all the blocks and for each of them call CClfsBaseFilePersisted::WriteMetadataBlock(). Notice in this case it calls WriteMetadataBlock() also on the shadow blocks.

Vulnerable Scenario

Let’s suppose the following scenario:

  • The execution path follows AddContainer() > AddSymbol() > ExtendMetadataBlock().
  • ExtendMetadataBlock() first increases the reference count of the General Metadata Block and then starts looping over all the Blocks and for each of them calls WriteMetadataBlock().
  • In the loop it will call WriteMetadataBlock() on the General Metadata Block Shadow.
  • WriteMetadataBlock() calls ClfsEncodeBlock() and ClfsEncodeBlock() creates an invalid General Metadata Block. This can be triggered using the same trick used by the previous exploit, overlapping the signatures array with a sector signature and forcing the ullDumpCount to be even when WriteMetadataBlock() is invoked. 
  • WriteMetadataBlock() then calls ClfsDecodeBlock() that fails, since the block was invalidated, and triggers a call to ClfsReleaseMetadataBlock().
  • ClfsReleaseMetadataBlock() reads the reference count of the General Metadata Block Shadow. Since the reference count is 0 (shadow blocks always have the reference count set to 0), it simply returns (the pseudocode of ClfsReleaseMetadataBlock() can be found here).
  • Then WriteMetadataBlock() fails and causes ExtendMetadataBlock() to fail.

The effect is that ExtendMetadataBlock() fails and returns, but the reference count of General Metadata Block is larger than 1. So, the General Metadata Block is not freed, is invalid and in memory. This means that the subsequent API calls will use an invalid General Metadata Block. Using the trick employed for the previous exploit, the invalid General Metadata Block has a pContainer equal to a controllable user-space address.

Triggering the Vulnerability

As previously, in order to trigger the vulnerability the PoC first calls CreateLogFile() and AddContainer() to create both files on disk.

After that, it overwrites the generated base log file with a malicious base log file (embedded as resource file in the exploit and retrieved through FindResource()/LoadResource()/LockResource()) with the following characteristics:

Malicious BLF. Start of General Metadata Block (ImHex)
Malicious BLF. cbSymbolZone of General Metadata Block (ImHex)
Malicious BLF. CLFS_CONTAINER_CONTEXT and signatures array

Usn is set to 1 and ullDumpCount is set to 1, an odd value. This is done for the reason explained below.

ExtendMetadataBlock(), in a loop, calls WriteMetadataBlock() first on the General Metadata Block and then on the General Metadata Block Shadow. To trigger the vulnerable scenario, WriteMetadataBlock() must fail on the General Metadata Block Shadow. Setting the ullDumpCount to an odd value, the first WriteMetadataBlock() on the General Metadata Block just increases the ullDumpCount and not the UsnThe next WriteMetadataBlock() on the General Metadata Block Shadow has an even ullDumpCount that consequently changes the Usn, by incrementing it, and causes the ClfsDecodeBlock() to fail (it works in the same way as for the previous exploit).

Similarly to the previous exploit, the signaturesOffset is changed so that the signatures array overlaps one of the sector signatures (the red area at the bottom in the diagram above) and the CLFS_CONTAINER_CONTEXT is shifted in a way that the pContainer field overlaps with another sector signature.

In addition, cbSymbolZone was modified so that it points to the same location of the signatures array. This is done to force AddSymbol() to call ExtendMetadataBlock() by making FindSymbol() fail.

Finally, it was necessary to update also all the other offsets such as:

  • The offset in the rgContainer array.
  • The offset in the rgContainerSymTbl array.

It was also necessary to overwrite all the signatures to match the Usn and recompute the checksum.

This Python script can be useful to compute the checksum (created by ChatGPT, same as previous exploit):

import binascii

def calculate_crc32(input_file):
    """
    Reads a file containing hexadecimal numbers, calculates the CRC32 checksum
    using the polynomial 0x04C11DB7, prints the buffer length in hexadecimal,
    and displays the CRC32 checksum in both big-endian and little-endian formats.
    """
    try:
        # Read the input file
        with open(input_file, "r") as file:
            hex_data = file.read().strip()

        # Convert hex string to bytes
        hex_numbers = hex_data.split()
        byte_array = bytes(int(num, 16) for num in hex_numbers)

        # Calculate CRC32 using binascii
        crc32_checksum = binascii.crc32(byte_array) & 0xFFFFFFFF

        # Calculate buffer length in hexadecimal
        buffer_length = len(byte_array)

        # Convert checksum to little-endian hex sequence
        little_endian_hex = ' '.join(f"{b:02X}" for b in crc32_checksum.to_bytes(4, 'little'))

        # Print the results
        print(f"Buffer Length (hex): 0x{buffer_length:02X}")
        print(f"CRC32 Checksum (big-endian): 0x{crc32_checksum:08X}")
        print(f"CRC32 Checksum (little-endian as hex sequence): {little_endian_hex}")
    except Exception as e:
        print(f"Error: {e}")

# Example usage
if __name__ == "__main__":
    input_file = "hex_numbers.txt"  # Replace with your input file path
    calculate_crc32(input_file)

The PoC, after replacing the BLF on disk with the malicious one, calls again CreateLogFile() and after that calls AddLogContainer(). The execution in kernel follows the path: CClfsBaseFilePersisted::AddContainer() > CClfsBaseFilePersisted::AddSymbol() > CClfsBaseFilePersisted::ExtendMetadataBlock(). It starts looping over the blocks and for each them calls WriteMetadataBlock():

Execution before the call to WriteMetadataBlock() on General Metadata Block Shadow

The screenshot above shows the execution flow right before calling WriteMetadataBlock() on the General Metadata Block Shadow (RDX=0x3). The reference count of the General Metadata Block is 2 while the reference count of the General Metadata Block Shadow is 0 and both of them have pbImage pointing to the same memory location (the block is the same):

Execution after the call to WriteMetadataBlock() on General Metadata Block Shadow

The screenshot above shows the execution right after calling WriteMetadataBlock(). WriteMetadataBlock() failed with error 0xc01a0002 (stored in RAX), the reference count is still set to 2, and pContainer now corresponds to 0x0000000002100000:

Execution when returning from ExtendMetadataBlock. General Metadata Block is tampered and reference count is larger than 0

The screenshot above shows the execution flow right after ExtendMetadataBlock(). The reference count of the General Metadata Block is still set to 1 (larger than 0) and pbImage points to a tampered General Metadata Block with pContainer=0x0000000002100000.

This means that subsequent API calls to the opened BLF will use the tampered General Metadata Block and the tampered pContainer.

Exploitation

To exploit the vulnerability it is first necessary to find APIs that cause the kernel to manipulate the General Metadata Block and dereference pContainer. The exploit uses the API CreateLogContainerScanContext(). This API triggers a kernel call to CClfsBaseFile::ScanContainerInfo().

CClfsBaseFile::ScanContainerInfo()

CClfsBaseFile::ScanContainerInfo() first retrieves the _CLFS_BASE_RECORD_HEADER calling GetBaseLogRecord(). After that, it cycles over CLFS_CONTAINER_CONTEXT in a while loop and for each calls CClfsContainer::QueryContainerInfo() passing pContainer:

__int64 __fastcall CClfsBaseFile::ScanContainerInfo(
        _CClfsBaseFilePersisted *this,
        const unsigned int *const a2,
        const union _CLS_LSN *a3,
        const union _CLS_LSN *a4,
        CLFS_LSN *a5,
        unsigned int a6,
        char a7,
        struct _CLS_CONTAINER_INFORMATION *a8,
        char a9,
        unsigned int *a10,
        unsigned int *a11)
{
[...]
  BaseLogRecord = CClfsBaseFile::GetBaseLogRecord(this);
  v43 = BaseLogRecord;
  if ( !BaseLogRecord )
  {
    ret = 0xC01A000D;
    v35 = 0xC01A000D;
    goto LABEL_53;
  }
[...]
while ( v16 < containerCount )
    {
      if ( v19 >= v14 )
        break;
      if ( v16 >= 1024 )
        break;
      v20 = a2[((_WORD)v19 + (_WORD)a6) & 0x3FF];
      if ( (_DWORD)v20 == -1 )
        break;
      v21 = BaseLogRecord->rgContainers[v20];
      if ( (_DWORD)v21 )
      {
        ret = CClfsBaseFile::GetSymbol(this, v21, v20, (UCHAR **)&v41);
        v35 = ret;
        if ( ret < 0 )
          goto LABEL_52;
        v22 = v41;
        eState = v41->eState;
[...]
          CClfsContainer::QueryContainerInfo(v22->pContainer, v24);
          ret = CClfsBaseFile::GetContainerName(this, v20, &v40);
[...]
}

CClfsContainer::QueryContainerInfo()

CClfsContainer::QueryContainerInfo() calls CClfsContainer::QueryInformation() passing again pContainer (the this variable corresponds to pContainer):

NTSTATUS __fastcall CClfsContainer::QueryContainerInfo(_CClfsContainer *this, struct _CLS_CONTAINER_INFORMATION *a2)
{
[...]
  v6 = 0LL;
  v7 = 0LL;
  v8 = 0LL;
  result = CClfsContainer::QueryInformation(this, &v5, (struct _IRP *)&v6, 0x28u, 4u);
  if ( result >= 0 )
  {
    a2->ContainerSize = this->container_size;
    *(_OWORD *)&a2->CreationTime = v6;
    a2->LastWriteTime = v7;
    a2->FileAttributes = v8;
  }
  return result;
}

CClfsContainer::QueryInformation()

CClfsContainer::QueryInformation() calls IoGetRelatedDeviceObject() on this->fileObject and saves the result in RelatedDeviceObject. After that, it calls IofCallDriver() passing RelatedDeviceObject:

__int64 __fastcall CClfsContainer::QueryInformation(
        _CClfsContainer *this,
        struct _IO_STATUS_BLOCK *a2,
        struct _IRP *a3,
        ULONG a4,
        ULONG a5)
{
[...]
  v19 = 0uLL;
  Object = 0LL;
  RelatedDeviceObject = IoGetRelatedDeviceObject(this->fileObject);
  LOBYTE(v10) = RelatedDeviceObject->StackSize;
  Irp = (IRP *)IoAllocateIrpEx(RelatedDeviceObject, v10, 0LL);
  if ( !Irp )
    return 0xC000009ALL;
  v13 = ClfsCreateEventObject(v11, &Object);
  if ( v13 >= 0 )
  {
    Irp->Tail.Overlay.Thread = KeGetCurrentThread();
    Irp->UserIosb = (PIO_STATUS_BLOCK)&v19;
    stackLocation = Irp->Tail.Overlay.CurrentStackLocation;
    stackLocation[-1].FileObject = this->fileObject;
    stackLocation[-1].MajorFunction = IRP_MJ_QUERY_INFORMATION;
[...]
    CurrentStackLocation[-1].CompletionRoutine = (PIO_COMPLETION_ROUTINE)CClfsContainer::CompleteFsdRequest;
    v17 = Object;
    CurrentStackLocation[-1].Context = Object;
[...]
    IofCallDriver(RelatedDeviceObject, Irp);
    KeWaitForSingleObject(v17, Executive, 0, 0, 0LL);
[...]
   }
 IoFreeIrp(Irp);
  if ( Object )
    ObfDereferenceObject(Object);
  return (unsigned int)v13;
}

Since this corresponds to pContainer, RelatedDeviceObject is controllable. Therefore, it is possible to pass a controllable address to IofCallDriver(). Passing a controllable address to IofCallDriver() is an exploitable scenario already encountered here.

The general idea is the following:

  1. Allocate user-space memory at pContainer (that is 0x0000000002100000).
  2. Create a FILE_OBJECT structure at pContainer->fileObject so that IoGetRelatedDeviceObject() succeed and returns another user-space address for RelatedDeviceObject.
  3. Create a DEVICE_OBJECT structure at RelatedDeviceObject.
  4. When IofCallDriver() is invoked, it calls RelatedDeviceObject->DriverObject->MajorFunction[IRP_MJ_QUERY_INFORMATION](), an arbitrary function that can set _KTHREAD.PreviousMode of a thread to 0.

Proof of Concept Exploit

To summarize, here are the steps performed by the proof of concept exploit available here (second branch):

  1. Call CreateLogFile() and AddLogContainer() to create the .BLF and the container files under C:\temp\testlog.
  2. Fetch the malicious .BLF from the resources and overwrite the original .BLF with the malicious .BLF.
  3. Create a thread X with CreateThread().
  4. Allocate memory at address 0x0000000002100000 (stored in the variable pcclfscontainer).
  5. Create in the allocated memory space a FILE_OBJECT, DEVICE_OBJECT, DRIVER_OBJECT.
  6. Link FILE_OBJECT to pcclfscontainer, DEVICE_OBJECT to FILE_OBJECT and DRIVER_OBJECT to DEVICE_OBJECT.
  7. Set DRIVER_OBJECT.MajorFunction[IRP_MJ_QUERY_INFORMATION] to be nt!DbgkpTriageDumpRestoreState. Sets the _KTHREAD.PreviousMode address of thread X in DEVICE_OBJECT (the where of the write-what-where) and the value to write to that address in DEVICE_OBJECT (the what of the write-what-where).
  8. Call again CreateLogFile() and AddLogContainer() to trigger the vulnerability. Now, the next function calls using the opened BLF handle will use the corrupted General Metadata Block having a pContainer with value 0x0000000002100000).
  9. Call CreateLogContainerScanContext(). 

After the last API call, the main thread, in kernel mode, follows the path CClfsBaseFile::ScanContainerInfo() > CClfsContainer::QueryContainerInfo() passing as first parameter the controllable pContainer:

Calling QueryContainerInfo() with controllable pContainer

The execution continues following the path CClfsContainer::QueryContainerInfo() > CClfsContainer::QueryInformation() > IofCallDriver() > nt!DbgkpTriageDumpRestoreState() and overwrites with 0 the _KTHREAD.PreviousMode field of thread X.

At this point the main thread is stuck waiting indefinitely on KeWaitForSingleObject() inside CClfsContainer::QueryInformation() after returning from IofCallDriver().

Then, thread X kicks in and uses NtReadVirtualMemory()/NtWriteVirtualMemory() to overwrite the _EPROCESS.Token to elevate privileges, restores _KTHREAD.PreviousMode to 1 and spawns a new cmd as system.

Running second poc

Limitations and Improvements

The exploit retrieves the kernel address of _KTHREAD using NtQuerySystemInformation(). Starting from Windows 11 24h2, to access this API the user must have have the SeDebugPrivilege (meaning they must be an Administrator). It could be possible to user alternative APIs manipulating the General Metadata Block that allow to obtain an info leak.

The PreviousMode technique was mitigated by Microsoft starting from Windows 11 24h2. Attackers can still use other techniques to grant themselves read/write in kernel-land (I/O Ring for example).

Hardcoded offsets should be replaced with hash-based search in ntoskrnl.exe for nt!DbgkpTriageDumpRestoreState. Otherwise, offsets to functions and inside data structures may be fetched by PDB symbol server passing as input the ntoskrnl.exe file hash.

After the vulnerability is exploited, it is recommended to clean up the state in memory (a thread is stuck indefinitely) and then delete the container and the BLF file. For this purpose, the KernelForge project may be useful to call arbitrary APIs in kernel mode from user mode having an arbitrary read/write.

Patch Analysis

The screenshot below highlights the patch applied by Microsoft to WriteMetadataBlock():

WriteMetadataBlock() before and after the patch

The patch, before calling ReleaseMetadataBlock(), checks if the block is a shadow block. In this case, it calls ReleaseMetadataBlock() not on the shadow but on the original one by decrementing blockType by 1. The effect is that it decrements the reference count of the original block.

Detection

Both presented exploits provided in the repository require to open the BLF with CreateLogFile() after it was tampered by directly writing to it.

I recommend monitoring for CreateLogFile(), CreateFile(), WriteFile(). A CreateFile()/WriteFile() on file X followed by a CreateLogFile() on the same file X could be a reliable indicator of exploitation.

Regular users are not supposed to directly interact with a BLF file using WriteFile() or CreateFile(), but only with the APIs offered by CLFS. So, detecting such unusual activity could be a red flag.

Conclusions

The article first analyzed the vulnerability in WriteMetadataBlock() and described how to exploit it in a Windows 11 23H2 environment.

It highlighted limitations and proposed improvements to the PoC and later analyzed the patch applied by Microsoft.

Finally, it proposed a strategy for detecting (and preventing) exploitation attempts of these and other vulnerabilities based on tampering with a Base Log File.

References

Contacts

If you have any questions, feel free to reach out at: