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

CVE-2024-49138 is a Windows vulnerability detected by CrowdStrike as exploited in the wild. Microsoft patched the vulnerability on December 10th, 2024 with KB5048685 (for Windows 11 23H2/22H2).

The analysis of the patch reveals that Microsoft actually patched two distinct vulnerabilities in the following functions defined in clfs.sys:

  • CClfsBaseFilePersisted::LoadContainerQ()
  • CClfsBaseFilePersisted::WriteMetadataBlock()

I created two proof-of-concept exploits for these vulnerabilities that are available in this repository:

  • master branch: exploit leveraging the LoadContainerQ() vulnerability
  • second branch:  exploit leveraging the WriteMetadataBlock() vulnerability

The analysis was conducted on a Windows 11 23H2 machine having 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>

This article aims to analyze the LoadContainerQ() vulnerability and develop a proof of concept exploit.

The CLFS Background section below describes the CLFS data structures involved. 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. Finally, the Limitations and Improvements section analyzes the restriction and proposes improvements for the PoC, while the Patch Analysis section briefly discusses the patch and its effects.

For some background on Windows kernel driver exploitation, please refer to my previous articles in this series.

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

CLFS Background

The Windows Common Log File System (CLFS) provides a high-performance, general-purpose log file subsystem that dedicated client applications can use and multiple clients can share to optimize log access. Any user mode application that needs logging or recovery support can use CLFS.

The data structures and APIs described in this section are taken both from MSDN and from Alex Ionescu’s unofficial documentation. Here are some excellent resources describing the inner working of CLFS and some related exploits:

Base Log File

The Base Log File (BLF) is the .BLF file created/opened using the CreateLogFile() API. The BLF keeps track of containers and clients (among other things) where containers are files associated with the BLF containing the actual user data.

Containers are created with the AddLogContainer() API and the producer can write data (i.e., records) in containers using a combination of CLFS APIs. Microsoft provides examples for writing records to containers and reading records from containers.

The BLF is made up of 6 metadata blocks:

  • Control Metadata Block: stores a control record that contains information about all other blocks in the BLF
  • Control Metadata Block Shadow: a copy of the Control Metadata Block
  • General Metadata Block: stores a base log record containing information about clients and containers
  • General Metadata Block Shadow: a copy of the General Metadata Block
  • Scratch Metadata Block: contains information about truncate context
  • Scratch Metadata Block Shadow: contains a copy of Scratch Metadata Block

Every metadata block is made up of sectors where each one is 0x200 bytes large.

The following picture from securelist’s article describes the sizes and location of the different metadata blocks in a BLF.

Layout of a BLF file (source: Securelist’s blog)

We will focus mainly on the General Metadata Block as the vulnerability lies in how the driver handles it in memory.

General Metadata Block

The General Metadata Block as all the others block starts with a CLFS_LOG_BLOCK_HEADER:

typedef struct _CLFS_LOG_BLOCK_HEADER
{
    UCHAR MajorVersion;
    UCHAR MinorVersion;
    UCHAR Usn;
    CLFS_CLIENT_ID ClientId;
    USHORT TotalSectorCount;
    USHORT ValidSectorCount;
    ULONG Padding;
    ULONG Checksum;
    ULONG Flags;
    CLFS_LSN CurrentLsn;
    CLFS_LSN NextLsn;
    ULONG RecordOffsets[16];
    ULONG SignaturesOffset;
} CLFS_LOG_BLOCK_HEADER, *PCLFS_LOG_BLOCK_HEADER;

This is followed by a BASE_RECORD_HEADER:

typedef struct _CLFS_BASE_RECORD_HEADER
{
    CLFS_METADATA_RECORD_HEADER hdrBaseRecord;
    CLFS_LOG_ID cidLog;
    ULONGLONG rgClientSymTbl[CLIENT_SYMTBL_SIZE];
    ULONGLONG rgContainerSymTbl[CONTAINER_SYMTBL_SIZE];
    ULONGLONG rgSecuritySymTbl[SHARED_SECURITY_SYMTBL_SIZE];
    ULONG cNextContainer;
    CLFS_CLIENT_ID cNextClient;
    ULONG cFreeContainers;
    ULONG cActiveContainers;
    ULONG cbFreeContainers;
    ULONG cbBusyContainers;
    ULONG rgClients[MAX_CLIENTS_DEFAULT];
    ULONG rgContainers[MAX_CONTAINERS_DEFAULT];
    ULONG cbSymbolZone;
    ULONG cbSector;
    USHORT bUnused;
    CLFS_LOG_STATE eLogState;
    UCHAR cUsn;
    UCHAR cClients;
} CLFS_BASE_RECORD_HEADER, *PCLFS_BASE_RECORD_HEADER;

The rgContainers field corresponds to an array of offsets (from the beginning of the block) to CLFS_CONTAINER_CONTEXT structures (always inside the General Metadata Block) describing information about containers:

typedef struct _CLFS_CONTAINER_CONTEXT
{
    CLFS_NODE_ID cidNode;
    ULONGLONG cbContainer;
    CLFS_CONTAINER_ID cidContainer;
    CLFS_CONTAINER_ID cidQueue;
    union
    {
        CClfsContainer* pContainer;
        ULONGLONG ullAlignment;
    };
    CLFS_USN usnCurrent;
    CLFS_CONTAINER_STATE eState;
    ULONG cbPrevOffset;
    ULONG cbNextOffset;
} CLFS_CONTAINER_CONTEXT, *PCLFS_CONTAINER_CONTEXT;

Of particular interest is the pContainer field. On disk it is set to 0 while when the BLF is loaded in memory it corresponds to a kernel pointer pointing to a CClfsContainer object.

Each CLFS_CONTAINER_CONTEXT is preceded by a CLFSHASHSYM structure as defined below:

typedef struct _CLFSHASHSYM
{
    CLFS_NODE_ID cidNode;
    ULONG ulHash;
    ULONG cbHash;
    ULONGLONG ulBelow;
    ULONGLONG ulAbove;
    LONG cbSymName;
    LONG cbOffset;
    BOOLEAN fDeleted;
} CLFSHASHSYM, *PCLFSHASHSYM;

The BASE_RECORD_HEADER.rgContainerSymTbl array stores offsets to these CLFSHASHSYM structures. It is a hash table that uses the ClfsHashPJW() function, applied to the container’s full path, to compute the index in the table containing the right offset.

Here’s an example of Base Log File storing a container named “container1” (the BLF was opened inside ImHex):

General Metadata Block (ImHex)

In the diagram above you can see the beginning of the General Metadata Block that starts with a _CLFS_LOG_BLOCK_HEADER with MajorVersion=15, the Usn=3, the checksum and recordOffsets[0]=0x70. meaning that the _CLFS_BASE_RECORD_HEADER is located at 0x800+0x70 = 0x870.

At offset 0x8e0 lies the rgContainerSymTbl hash table (in orange) containing the offset value 0x1440 (from _CLFS_BASE_RECORD_HEADER) at index 6.

In fact, at 0x800+0x70+0x1440=0x1cb0 we can find the CLFSHASHSYM structure followed by the CLFS_CONTAINER_CONTEXT finally followed by the actual symbol, the full path to the file:

CLFS_CONTAINER_CONTEXT in General Metadata Block (ImHex)

Notice that the metadata blocks, when stored on disk, are encoded. In fact, it is possible to notice the values 0x10 0x03. This is the sector signature and corresponds to [Sector Block Type][Usn] as described by Alex Ionescu. Every sector (recall a sector is 0x200 bytes wide) presents this signature at the end.

When the metadata blocks are parsed from the BLF on disk and loaded in memory the signatures must be replaced with the actual data. For this purpose the _CLFS_LOG_BLOCK_HEADER.SignaturesOffset exists. It is an offset, from the beginning of the block, pointing to an array containing the actual data that must replace the sector signatures when blocks are decoded from disk and loaded in memory.

Finally, clfs.sys uses the CClfsBaseFilePersisted class to represent the Base Log File in memory having the following shape:

struct __unaligned __declspec(align(8)) _CClfsBaseFilePersisted
{
  _CClfsBaseFilePersisted_VTABLE_0_00000001C0014000::vtable *vftbl_0_00000001C0014000;
  ULONG m_cref;
  _BYTE gapC[4];
  PUCHAR m_pbImage;
  ULONG m_cbImage;
  _BYTE gap1C[4];
  __int64 m_presImage;
  USHORT m_cBlocks;
  _BYTE gap2A[6];
  CLFS_METADATA_BLOCK *m_rgBlocks;
  PUSHORT m_rgcBlockReferences;
  CLFSHASHTBL m_symtblClient;
  CLFSHASHTBL m_symtblContainer;
  CLFSHASHTBL m_symtblSecurity;
  ULONGLONG size;
  ULONG m_cbRawSectorSize;
  BOOLEAN m_fgeneralBlockReferenced;
  __declspec(align(4)) __int64 pCclfsContainer;
  __int64 event_a0;
  __int64 field_A8;
  char file_ea_info[16];
  __int64 field_C0;
  int field_C8;
  _BYTE gapCC[4];
  __int64 field_D0;
  _UNICODE_STRING filepath;
  RTL_BITMAP bitmap;
  char bitmapBuf[128];
  int field_178;
  _BYTE gap17C[4];
  char field_180[16];
  char field_190[16];
  char n_writes[16];
  __int64 field_1B0;
  __int64 controlrecord;
  int field_1C0;
};

The important fields here are m_rgBlocks, an array of CLFS_METADATA_BLOCK (containing pointers to the different blocks in memory), and m_rgcBlockReferences storing a reference count for each block.

The definition of CLFS_METADATA_BLOCK is:

struct _CLFS_METADATA_BLOCK
{
  PUCHAR pbImage;
  ULONG cbImage;
  ULONG cbOffset;
  CLFS_METADATA_BLOCK_TYPE eBlockType;
};

pbImage points to the Metadata Block in memory, cbImage corresponds to the size of the block. eBlockType is the following enum:

typedef enum _CLFS_METADATA_BLOCK_TYPE
{
    ClfsMetaBlockControl,
    ClfsMetaBlockControlShadow,
    ClfsMetaBlockGeneral,
    ClfsMetaBlockGeneralShadow,
    ClfsMetaBlockScratch,
    ClfsMetaBlockScratchShadow
} CLFS_METADATA_BLOCK_TYPE, *PCLFS_METADATA_BLOCK_TYPE;

The eBlockType of a General Metadata Block is ClfsMetaBlockGeneral that correponds to 2. CLFS_METADATA_BLOCK_TYPE is also used as index inside the _CClfsBaseFilePersisted.m_rgBlocks array.

Vulnerability Analysis

The base log file can be opened using the CreateLogFile() API. The API will lead to a call to CClfsLogFcbPhysical::Initialize() in the kernel:

__int64 __fastcall CClfsLogFcbPhysical::Initialize(
        _CClfsLogFcbPhysical *this,
        CLFS_LSN a2,
        struct _SECURITY_SUBJECT_CONTEXT *a3,
        ACCESS_MASK a4,
        ULONG DesiredShareAccess,
        struct _ACCESS_STATE *a6,
        char a7,
        struct _CLFS_FILTER_CONTEXT *a8,
        struct _FILE_OBJECT *FileObject,
        unsigned __int8 a10){

[...]
 if ( v26 )
    cclfsbaseFilePersisted = CClfsBaseFilePersisted::CClfsBaseFilePersisted(v26);
  else
    cclfsbaseFilePersisted = 0LL;
  this->pcclfBaseFilePersisted = cclfsbaseFilePersisted;
  if ( !cclfsbaseFilePersisted )
    goto LABEL_76;
  v75 = 1;
  CClfsBaseFile::AddRef((CClfsBaseFile *)cclfsbaseFilePersisted);
  ret = CClfsBaseFilePersisted::OpenImage(
          this->pcclfBaseFilePersisted,
          &Destination,
          (const struct _CLFS_FILTER_CONTEXT *)&v86,
          a10,
          (unsigned __int8 *)&v88);
[...]
 ret = CClfsBaseFilePersisted::LoadContainerQ(
          v51,
          (unsigned int *const)&this->field_558,
          0x400,
          (this->flags & 2) != 0,
          v53,
          (union _CLS_LSN)lsnRestart,
          (unsigned int *)&this->field_554,
          (unsigned int *)&this->field_550,
          &v84);
[...]
}

CClfsBaseFilePersisted::OpenImage()

This method creates a CClfsBaseFilePersisted object, initializes the object, and calls CClfsBaseFilePersisted::OpenImage():

__int64 __fastcall CClfsBaseFilePersisted::OpenImage(
        _CClfsBaseFilePersisted *this,
        struct _UNICODE_STRING *ExtFileName,
        const struct _CLFS_FILTER_CONTEXT *clfsFilterContext,
        char a4,
        unsigned __int8 *a5)
{
[...]
 ret = CClfsBaseFilePersisted::ReadImage(this, &controlRecord);
  ret_1 = ret;
  v31 = ret;
  if ( ret < 0 )
  {
LABEL_21:
    if ( ret != 0xC0000011 )
      goto end;
set_error:
    ret_1 = 0xC01A000D;
LABEL_45:
    v31 = ret_1;
    goto end;
  }
  ret_1 = CClfsContainer::GetContainerSize((_CClfsContainer *)this->pCclfsContainer, &this->size);
  v31 = ret_1;
  if ( ret_1 < 0 )
    goto end;
  BaseLogRecord = CClfsBaseFile::GetBaseLogRecord(this);
  if ( !BaseLogRecord )
    goto set_error;
  this->m_symtblClient.rgSymHash = BaseLogRecord->rgClientSymTbl;
  this->m_symtblClient.cHashElt = 11;
  this->m_symtblClient.pBaseFile = (CClfsBaseFile *)this;
  this->m_symtblContainer.rgSymHash = BaseLogRecord->rgContainerSymTbl;
  this->m_symtblContainer.cHashElt = 11;
  this->m_symtblContainer.pBaseFile = (CClfsBaseFile *)this;
  this->m_symtblSecurity.rgSymHash = BaseLogRecord->rgSecuritySymTbl;
  this->m_symtblSecurity.cHashElt = 11;
  this->m_symtblSecurity.pBaseFile = (CClfsBaseFile *)this;
  controlRecord_1 = controlRecord;
[...]
}

OpenImage will issue a call to CClfsBaseFilePersisted::ReadImage() to read the Control Block and the General Block from the BLF on disk and load them in memory at the addresses CClfsBaseFilePersisted.m_rgBlocks[blockType].pbImage:

__int64 __fastcall CClfsBaseFilePersisted::ReadImage(
        _CClfsBaseFilePersisted *this,
        struct _CLFS_CONTROL_RECORD **ppcontrolrecord)
{
[...]
  memset(m_rgBlocks, 0, 0x18LL * this->m_cBlocks);
  memset(this->m_rgcBlockReferences, 0, 2LL * this->m_cBlocks);
  this->m_rgBlocks->cbOffset = 0;
  this->m_rgBlocks->cbImage = 2 * this->m_cbRawSectorSize;
  this->m_rgBlocks[ClfsMetaBlockControlShadow].cbOffset = 2 * this->m_cbRawSectorSize;
  this->m_rgBlocks[ClfsMetaBlockControlShadow].cbImage = 2 * this->m_cbRawSectorSize;
  ret = CClfsBaseFile::GetControlRecord(this, ppcontrolrecord, 0);
[...]
 pbImageControlRecord = this->m_rgBlocks->pbImage;
  for ( i = 0; i < this->m_cBlocks; ++i )
  {
    v13 = i;
    controlrecord_1 = *ppcontrolrecord;
    controlblock = this->m_rgBlocks;
    *(_OWORD *)&controlblock[v13].pbImage = *(_OWORD *)&(*ppcontrolrecord)->rgBlocks[i].pbImage;
    *(_QWORD *)&controlblock[v13].eBlockType = *(_QWORD *)&controlrecord_1->rgBlocks[i].eBlockType;
    this->m_rgBlocks[v13].pbImage = 0LL;
  }
  this->m_rgBlocks->pbImage = pbImageControlRecord;
  this->m_rgBlocks[ClfsMetaBlockControlShadow].pbImage = pbImageControlRecord;
  ret = CClfsBaseFile::AcquireMetadataBlock(this, ClfsMetaBlockGeneral);
  ret_1 = ret;
  if ( ret >= 0 )
    this->m_fgeneralBlockReferenced = 1;
[...]
}

The function ReadImage() calls GetControlRecord() that internally calls AcquireMetadataBlock() to load the Control Block (and the corresponding shadow block) in memory, and later calls AcquireMetadataBlock() again to load the General Block in memory (and the corresponding shadow block).

The pseudocode for AcquireMetadataBlock() follows. If the block wasn’t already loaded in memory, that is the reference count in m_rgcBlockReferences is equal to zero, it will call ReadMetadataBlock():

__int64 __fastcall CClfsBaseFile::AcquireMetadataBlock(
        _CClfsBaseFilePersisted *this,
        enum _CLFS_METADATA_BLOCK_TYPE blockType)
{
  __int64 BlockType; // rsi
  unsigned int v4; // edx
  int v5; // r8d
  int v6; // r9d

  BlockType = blockType;
  v4 = 0;
  if ( (int)BlockType < 0 || (int)BlockType >= this->m_cBlocks )
    return 0xC0000225LL;
  if ( ++this->m_rgcBlockReferences[BlockType] != 1 )
    return v4;
  v4 = ((__int64 (__fastcall *)(_CClfsBaseFilePersisted *, _QWORD))this->vftbl_0_00000001C0014000->CClfsBaseFilePersisted::ReadMetadataBlock(ulong))(
         this,
         (unsigned int)BlockType);
  if ( (v4 & 0x80000000) != 0 )
  {
    --this->m_rgcBlockReferences[BlockType];
    return v4;
  }
  if ( this->m_rgBlocks[BlockType].pbImage )
    return v4;
[...]
}

Here’s the execution state after OpenImage() completes successfully. It is possible to notice that the General Block was loaded in CClfsBaseFilePersisted.m_rgBlocks[2].pbImage = 0xFFFFD4000EF9A000, and the corresponding reference count is set to 1. In the Hex-View we can see that the memory at 0xFFFFD4000EF9A000 starts with 15 00 03 00 confirming it contains the block from from the BLF on disk:

General Metadata Block loaded in memory with reference count set to 1 after OpenImage()

CClfsBaseFilePersisted::LoadContainerQ()

OpenImage(), CClfsLogFcbPhysical::Initialize() then calls LoadContainerQ():

__int64 __fastcall CClfsBaseFilePersisted::LoadContainerQ(
        _CClfsBaseFilePersisted *this,
        unsigned int *const a2,
        int a3,
        unsigned __int8 a4,
        char a5,
        union _CLS_LSN a6,
        unsigned int *a7,
        unsigned int *a8,
        unsigned __int64 *pcbContainer)
{
[...]
 BaseLogRecord = CClfsBaseFile::GetBaseLogRecord(this);
[...]
 rgContainers = BaseLogRecord->rgContainers;
[...]
 rgContainers_1 = rgContainers;
[..]
 while ( (unsigned int)k < MAX_CONTAINERS_DEFAULT )
  {
[...]
    offset = rgContainers_1[k];
    if ( offset )
    {
      if ( offset < 0x1338 )
        goto error;
      if ( (int)CClfsBaseFile::GetSymbol(this, offset, k, (UCHAR **)&container_context) < 0 )
        goto error;
      container_context_2 = (CLFS_CONTAINER_CONTEXT *__shifted(_CLFSHASHSYM,0x30))CClfsBaseFile::OffsetToAddr(
                                                                                    this,
                                                                                    offset);
      if ( !container_context_2 )
        goto error;
      v40 = (CLFS_CONTAINER_CONTEXT *)CClfsBaseFile::OffsetToAddr(this, ADJ(container_context_2)->cbSymName);
      v41 = v40;
      if ( !v40 )
[...]
      }
      j_1 = (_CClfsContainer *)pcbContainer;
      containerContext = container_context;
[...]
 if ( (containerContext->eState & ClfsContainerInitializing) != 0 )
        {
          containerContext->eState = ClfsContainerInactive;
          ret = CClfsBaseFilePersisted::FlushImage(this);
          ret_2 = ret;
          if ( ret < 0 )
            goto LABEL_116;
        }
        v57 = &a2[containerContext->cidQueue & 0x3FF];
        if ( *v57 != -1 )
        {
LABEL_118:
          ret = 0xC01A000D;
          ret_2 = 0xC01A000D;
LABEL_116:
          ((void (__fastcall *)(_CClfsContainer *))containerContext->pContainer->vftbl_0_00000001C0014040->CClfsContainer::Release(void))(containerContext->pContainer);
          containerContext->pContainer = 0LL;
          v19 = a8;
          v13 = a7;
          v9 = a2;
          goto LABEL_145;
[...]
  }
[...]
}

The routine starts looping over the offsets in _CLFS_BASE_RECORD_HEADER.rgContainers. For each of them, it retrieves a pointer to the associated CLFS_CONTAINER_CONTEXT (containerContext variable in the pseudocode).

containerContext will point to the CLFS_CONTAINER_CONTEXT structure inside the block at CClfsBaseFilePersisted->m_rgBlocks[2].pbImage.

In case containerContext->eState is ClfsContainerInitializing, it calls CClfsBaseFilePersisted::FlushImage() and in case it fails, then calls method CClfsContainer::Release() of containerContext->pContainer.

The pseudocode of FlushImage() follows.  Notice it tries to write to disk the General Metadata Block using CClfsBaseFilePersisted::WriteMetadataBlock() and in case it fails it directly returns the error:

__int64 __fastcall CClfsBaseFilePersisted::FlushImage(_CClfsBaseFilePersisted *this)
{
  char v2; // dl
  char v3; // si
  int v4; // edi
  unsigned int v6; // [rsp+30h] [rbp-18h]

  v2 = 1;
  v3 = ((__int64 (__fastcall *)(__int64, char))nt_ExAcquireResourceExclusiveLite)(this->m_presImage, v2);
  v4 = CClfsBaseFilePersisted::WriteMetadataBlock(this, ClfsMetaBlockGeneral, 1);
  v6 = v4;
  if ( v4 >= 0 )
[...]
return v4;
}

CClfsBaseFilePersisted::WriteMetadataBlock()

The partial pseudocode of WriteMetadataBlock follows:

_int64 __fastcall CClfsBaseFilePersisted::WriteMetadataBlock(
        _CClfsBaseFilePersisted *this,
        enum _CLFS_METADATA_BLOCK_TYPE blocktype,
        char a3)
{
  [...]
  blockType_1 = (unsigned int)blocktype;
  pbImage = (CLFS_LOG_BLOCK_HEADER *)this->m_rgBlocks[blockType_1].pbImage;
  v27 = pbImage;
  if ( !pbImage )
  {
    ret = 0xC01A000D;
    ret_1 = 0xC01A000D;
    goto LABEL_23;
  }
  pbImagenotnull = 1;
  recordOffset = pbImage->RecordOffsets[0];
  v11 = ++*(_QWORD *)(&pbImage->MajorVersion + recordOffset) & 1LL;
  m_rgBlocks = this->m_rgBlocks;
  [...]
LABEL_9:
  if ( v11 )
    ++pbImage->Usn;
  container_idx = 0;
[...]
 while ( container_idx < MAX_CONTAINERS_DEFAULT )
  {
    ret_1 = CClfsBaseFile::AcquireContainerContext(this, container_idx, &containercontext);
    v15 = &this->vftbl_0_00000001C0014000 + container_idx;
    if ( ret_1 >= 0 )
    {
      containercontext_1 = containercontext;
      v15[56] = (_CClfsBaseFilePersisted_VTABLE_0_00000001C0014000::vtable *)containercontext->pContainer;
      containercontext_1->pContainer = 0LL;
      CClfsBaseFile::ReleaseContainerContext(this, &containercontext);
    }
    else
    {
      v15[56] = 0LL;
    }
    v25 = ++container_idx;
  }
[...]
ret = ClfsEncodeBlock(pbImage, pbImage->TotalSectorCount << 9, pbImage->Usn, 0x10u, 1u);
  ret_1 = ret;
if ( ret >= 0 )
  {
    encodeBlock_successul = 1;
    ret = CClfsContainer::WriteSector(
            (_CClfsContainer *)this->pCclfsContainer,
            (PRKEVENT)this->event_a0,
            0LL,
            this->m_rgBlocks[blockType].pbImage,
            pbImage->TotalSectorCount,
            &a6);
    ret_1 = ret;
[...]
 if ( pbImagenotnull )
  {
    if ( encodeBlock_successul )
    {
      v17 = ClfsDecodeBlock(pbImage, pbImage->TotalSectorCount, pbImage->Usn, 0x10u, (unsigned int *)&a6);
      if ( v17 < 0 ){
            CClfsBaseFile::ReleaseMetadataBlock(this, (enum _CLFS_METADATA_BLOCK_TYPE)blockType_1);
       }
[...]

Here are the important things to notice about this code:

  1. It increments _CLFS_METADATA_RECORD_HEADER.ullDumpCount v11 = ++*(_QWORD *)(&pbImage->MajorVersion + recordOffset) & 1LL;, checks if it is even or odd and stores the result in v11.
  2. If the result is odd (v11 == 1), it updates the Usn field of the metadata block ++pbImage->Usn;.
  3. It loops over containers and for each of them it sets the CLFS_CONTAINER_CONTEXT.pContainer field to 0 (as in memory it is a kernel pointer to a CClfsContainer object).
  4. Calls ClfsEncodeBlock() and if successful then writes the metadata block to the .BLF file calling CClfsContainer::WriteSector() and finally calls ClfsDecodeBlock().
  5. If ClfsDecodeBlock() fails it calls CClfsBaseFile::ReleaseMetadataBlock().

Let’s now inspect CClfsBaseFile::ReleaseMetadataBlock():

 PUCHAR *__fastcall CClfsBaseFile::ReleaseMetadataBlock(
        _CClfsBaseFilePersisted *this,
        enum _CLFS_METADATA_BLOCK_TYPE blocktype)
{
  __int64 blocktype_1; // rbx
  PUSHORT m_rgcBlockReferences; // rcx
  PUCHAR *ptr; // rax

  blocktype_1 = blocktype;
  m_rgcBlockReferences = this->m_rgcBlockReferences;
  ptr = (PUCHAR *)m_rgcBlockReferences[blocktype];
  if ( (_WORD)ptr )
  {
    m_rgcBlockReferences[blocktype] = (_WORD)ptr - 1;
    ptr = (PUCHAR *)this->m_rgcBlockReferences;
    if ( !*((_WORD *)ptr + (int)blocktype) )
    {
      if ( this->m_rgBlocks[blocktype].pbImage )
        ((void (__fastcall *)(_CClfsBaseFilePersisted *))this->vftbl_0_00000001C0014000->CClfsBaseFile::FreeMetadataBlock(uchar *))(this);
      this->m_rgBlocks[blocktype_1].pbImage = 0LL;
      ptr = &this->m_rgBlocks->pbImage;
      ptr[3 * blocktype_1 + 3] = 0LL;
    }
  }
  return ptr;
}

It decrements the block reference count in the CClfsBaseFilePersisted->m_rgcBlockReferences array and in case it goes to zero it frees the block.

Vulnerable Scenario

Let’s suppose the following scenario:

  1. The execution path follows LoadContainerQ()->FlushImage()->WriteMetadataBlock() writing the General Metadata Block.
  2. Inside WriteMetadataBock() all CLFS_CONTAINER_CONTEXT.pContainer will be set to 0 and the final call to ClfsDecodeBlock() fails and so it frees the block with ReleaseMetadataBlock().
  3. When returning from FlushImage() there is no check that the General Metadata Block was freed due to a fail of ClfsDecodeBlock(). 

Therefore the containerContext->pContainer of LoadContainerQ() will potential point to invalid memory as it belongs to the memory region freed by ReleaseMetadataBlock().

In case the freed memory region is not reallocated, containerContext->pContainer should point to a controllable invalid address (WriteMatadataBlock() at step 2 sets to 0 all CLFS_CONTAINER_CONTEXT.pContainer fields).

For successful exploitation, it is required to to make ClfsEncodeBlock() succeed and at the same time ClfsDecodeBlock() fail 

ClfsEncodeBlock()/ClfsDecodeBlock()

The pseudocode of ClfsDecodeBlock() is:

__int64 __fastcall ClfsDecodeBlock(
        struct _CLFS_LOG_BLOCK_HEADER *logBlockHeader,
        unsigned int n_sectors,
        char usn,
        unsigned __int8 a4,
        unsigned int *a5)
{
[..]

  Checksum = logBlockHeader->Checksum;
  if ( Checksum )
  {
    if ( Checksum != -1 )
    {
      logBlockHeader->Checksum = 0;
      v9 = CCrc32::ComputeCrc32(&logBlockHeader->MajorVersion, n_sectors << 9);
      if ( v10 == v9 )
        return ClfsDecodeBlockPrivate(logBlockHeader, n_sectors, usn, a4, a5);
      logBlockHeader->Checksum = v10;
    }
  }
  [...]
}

It first checks the checksum (i.e., a CRC32 of the whole MetadataBlock) and then calls ClfsDecodeBlockPrivate():

__int64 __fastcall ClfsDecodeBlockPrivate(
        _CLFS_LOG_BLOCK_HEADER *blockHeader,
        unsigned int n_sectors,
        char usn,
        unsigned __int8 flags,
        unsigned int *a5)
{
 [...]
  if ( !n_sectors )
    return 0xC000000DLL;
  if ( blockHeader->MajorVersion != 0x15 || blockHeader->MinorVersion )
    return 0xC01A0009LL;
  if ( n_sectors < blockHeader->TotalSectorCount )
    return 0xC01A000ALL;
  if ( flags > 0x10u )
    return 0xC000000DLL;
  v9 = 0x10111;
  if ( !_bittest(&v9, flags) )
    return 0xC000000DLL;
  if ( (blockHeader->Flags & 1) == 0 )
    return 0xC01A000ALL;
  SignatureOffset = blockHeader->SignaturesOffset;
  block_size = n_sectors << 9;
  offset_to_end_of_signatures = SignatureOffset + 2 * n_sectors;
  signatures_array = &blockHeader->MajorVersion + SignatureOffset;
  if ( offset_to_end_of_signatures < (unsigned int)SignatureOffset
    || (SignatureOffset & 7) != 0
    || offset_to_end_of_signatures > block_size )
  {
    return 0xC01A000ALL;
  }
  n_sectors_1 = n_sectors;
  while ( 1 )
  {
    v15 = n_sectors == n_sectors_1;
    *(_QWORD *)&i = (unsigned int)(n_sectors_1 - 1);
    sector_block_begin = SECTOR_BLOCK_BEGIN;
    sector_block_end = SECTOR_BLOCK_END;
    if ( !v15 )
      sector_block_end = 0;
    if ( i )
      sector_block_begin = 0;
    sector_block_flags = flags | sector_block_begin | sector_block_end;
    offset_start_block = (unsigned int)(i << 9);
    currentsector_blocktype = *((_BYTE *)&blockHeader[4].RecordOffsets[5] + offset_start_block + 2);
    if ( currentsector_blocktype < 0 )
      break;
    if ( *((_BYTE *)&blockHeader[4].RecordOffsets[5] + offset_start_block + 3) != usn )
    {
      result = 0xC01A0002LL;
      goto LABEL_38;
    }
    if ( (sector_block_flags & SECTOR_BLOCK_DATA) != 0 && (currentsector_blocktype & SECTOR_BLOCK_DATA) == 0
      || (sector_block_flags & SECTOR_BLOCK_BEGIN) != 0 && (currentsector_blocktype & SECTOR_BLOCK_BEGIN) == 0
      || (sector_block_flags & SECTOR_BLOCK_END) != 0 && (currentsector_blocktype & SECTOR_BLOCK_END) == 0
      || (sector_block_flags & SECTOR_BLOCK_OWNER) != 0 && (currentsector_blocktype & SECTOR_BLOCK_OWNER) == 0
      || (sector_block_flags & SECTOR_BLOCK_BASE) != 0 && (currentsector_blocktype & SECTOR_BLOCK_BASE) == 0 )
    {
      result = 0xC01A0001LL;
      goto LABEL_38;
    }
    *(_WORD *)((char *)&blockHeader[4].RecordOffsets[5] + offset_start_block + 2) = *(_WORD *)&signatures_array[2 * *(_QWORD *)&i];
    n_sectors_1 = i;
   [...]
}

ClfsDecodeBlockPrivate() first checks multiple fields such as MinorVersion, MajorVersion, TotalSectorCount of CLFS_LOG_BLOCK_HEADER.

In the while loop, it checks that every signature (at the end of each sector 0x200 bytes wide) is valid.

Therefore, the second byte matches with the Usn and first byte (sector block type) has the bits properly set according to the following constants (taken directly from Alex Ionescu’s unofficial documentation):

const UCHAR SECTOR_BLOCK_NONE   = 0x00;
const UCHAR SECTOR_BLOCK_DATA   = 0x04;
const UCHAR SECTOR_BLOCK_OWNER  = 0x08;
const UCHAR SECTOR_BLOCK_BASE   = 0x10;

const UCHAR SECTOR_BLOCK_END    = 0x20;
const UCHAR SECTOR_BLOCK_BEGIN  = 0x40;

Every signature, after the check, is replaced with the actual content in the signature array at offset SignaturesOffset.

Find below the pseudocode of ClfsEncodeBlock(). Internally, it calls ClfsEncodeBlockPrivate() to encode the block and later computes the checksum with CCrc32::ComputeCrc32():

__int64 __fastcall ClfsEncodeBlock(
        struct _CLFS_LOG_BLOCK_HEADER *a1,
        unsigned int size,
        unsigned __int8 usn,
        unsigned __int8 flags,
        unsigned __int8 needchecksum)
{
 [...]
  a1->Checksum = 0;
  v7 = ClfsEncodeBlockPrivate(a1, size, usn, flags);
  if ( v7 >= 0 && needchecksum )
    a1->Checksum = CCrc32::ComputeCrc32(&a1->MajorVersion, size);
  return (unsigned int)v7;
}

ClfsEncodeBlockPrivate() does the following:

  1. It checks again some fields like TotalSectorCount.
  2. It checks that SignaturesOffset is 8-bytes aligned.
  3. It loops over all the sector signatures, copies the original value in the Signatures array at SignaturesOffset and writes the sector signature.
__int64 __fastcall ClfsEncodeBlockPrivate(
        struct _CLFS_LOG_BLOCK_HEADER *blockHeader,
        unsigned int size,
        UCHAR usn,
        unsigned __int8 flags)
{
[...]

  TotalSectorCount = blockHeader->TotalSectorCount;
  if ( !(_WORD)TotalSectorCount
    || blockHeader->ValidSectorCount < (unsigned __int16)TotalSectorCount
    || TotalSectorCount << 9 > size )
  {
    return 0xC01A000ALL;
  }
  if ( flags > 0x10u )
    return 0xC000000DLL;
  v6 = 0x10111;
  if ( !_bittest(&v6, flags) )
    return 0xC000000DLL;
  if ( (blockHeader->Flags & 1) != 0 )
    return 0xC01A000ALL;
  SignaturesOffset = blockHeader->SignaturesOffset;
  n_sectors = size >> 9;
  blockHeader->Usn = usn;
  HIBYTE(usn_1) = usn;
  signatures_array = &blockHeader->MajorVersion + SignaturesOffset;
  offset_to_end_of_signatures = SignaturesOffset + 2 * (size >> 9);
  if ( offset_to_end_of_signatures < (unsigned int)SignaturesOffset
    || (SignaturesOffset & 7) != 0
    || offset_to_end_of_signatures > size )
  {
    return 0xC01A000ALL;
  }
  for ( i = 0; i < n_sectors; *(_WORD *)((char *)&blockHeader[4].RecordOffsets[5] + (unsigned int)v14 + 2) = usn_1 )
  {
   [...]
    *(_WORD *)signatures_array = *(_WORD *)((char *)&blockHeader[4].RecordOffsets[5] + v14 + 2);
    signatures_array += 2;
  }
[...]
}

The SignaturesOffset typically should point to an array that is in the last sector and doesn’t have to overlap with the last sector’s signature. However, this is not enforced.

In fact, the idea to make ClfsDecodeBlock() fail, right after ClfsEncodeBlock(), is the following:

  1.  Change the SignaturesOffset so that the signatures array spans over two consecutive sectors, sector X and sector X+1 and therefore overlaps with signature of sector X.
  2. Set the CLFS_METADATA_RECORD_HEADER.ullDumpCount of the block to be even. This way, WriteMetadataBlock() will increment it by one and will increment the CLFS_LOG_BLOCK_HEADER.Usn.

At this point when ClfsEncodeBlockPrivate() finishes to write the signatures, a sector signature will be invalid. A metadata block after the return from ClfsEncodeBlockPrivate() is shown below:

Beginning of a Metadata Block after ClfsEncodePrivate()

Notice the ullDumpCount was initially 2 (an even value), that WriteMetadataBlock() incremented setting it to 3, and the Usn was set to 2. This means every sector’s signature must have the second byte equal to 0x2. At the same time the SignaturesOffset points to FFFFC380949E7480+0x03f8 = 0xFFFFC380949E7878.

Also notice that the second sector signature (the one overlapping the signatures array) corresponds to 0x10 0x1 that is invalid as it should be 0x10 0x2 (since the Usn is 0x2). This will cause ClfsDecodeBlock() to fail:

Tampered section signature

Triggering the Vulnerability

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 (BLF) with a malicious BLF (embedded as resource file in the exploit and retrieved through FindResource()/LoadResource()/LockResource()) with the following characteristics:

malicious BLF. Start of General Metadata Block
Malicious BLF. CLFS_CONTAINER_CONTEXT and signatures array

The Usn is set to 1 and the ullDumpCount is set to 2 (an even value). The signaturesOffset is changed so that the signatures array overlap one of the sector signatures (the red area at the bottom).

In addition, the CLFS_CONTAINER_CONTEXT is shifted in a way that the pContainer field overlap with another sector signature, and eState is set to 1.

It is necessary to update also all other offsets such as:

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

Finally, it is necessary to overwrite all signatures to match the Usn and recompute the checksum.

This Python script can be useful to compute the checksum (created by ChatGPT):

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)

It would be enough to place inside the hex_numbers.txt file the hex numbers composing the metadata block (which can be extracted with ImHex or another hex editor).

The PoC, after replacing the BLF on disk with the malicious one, calls again CreateLogFile() and the execution reaches LoadContainerQ() and then FlushImage():

General Metadata Block before FlushImage() is valid

Notice the reference count of the General Metadata Block is set to 1 meaning the block loaded in memory is valid.

The execution flow continues reaching WriteMetadataBlock() on the General Metadata Block, that internally calls ClfsEncodeBlock(). ClfsEncodeBlock() will produce an invalid block and cause ClfsDecodeBlock() to fail and call ReleaseMetadataBlock().

Notice at this point that the pContainer field now has value 0x0000000002100000:

General metadata block invalidated by ClfsEncodeBlock(). ClfsDecodeBlock() fails and reaches ReleaseMetadataBlock()

After returning from FlushImage(), the reference count of the General Metadata Block is 0 and the corresponding pbImage pointer is also 0 confirming it was freed:

General Metadata Block after FlushImage(). It is invalid and was freed by ReleaseMetadataBlock()

Stepping through the instructions, the execution flow will load in RCX the tampered pContainer:

RCX tampered. Execution flow can be hijacked

The following instructions will store in RAX a value coming from [RCX] and then call RAX allowing to hijack the execution flow to an arbitrary kernel address.

Note: If between the ReleaseMetadataBlock() and the mov RCX, [RDI+0x18] the memory region is reallocated and then overwritten with other data, the value stored in RCX will be random and may lead to a crash. Heap spraying may help deal with this scenario.

Exploitation

To summarize, here are the steps performed by the proof of concept exploit available here (master 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. Allocate memory at address 0x0000000002100000 (stored in the variable pcclfscontainer).
  4. Create a fake CClfsContainer object with a fake vtable that points to the address of nt!PoFxProcessorNotification.
  5. Write additional data in the allocated memory region such as the address of nt!DbgkpTriageDumpRestoreState and the address of _KTHREAD.PreviousMode of the current thread.
  6. Call again CreateLogFile().

When the PoC invokes CreateLogFile() on the malicious BLF the driver does the following:

  1. Dereference the malicious CClfsContainer object at address 0x0000000002100000.
  2. Call nt!PoFxProcessorNotification.
  3. nt!PoFxProcessorNotification redirects the execution flow to nt!DbgkpTriageDumpRestoreState.
  4. nt!DbgkpTriageDumpRestoreState is used to obtain an arbitrary write of 8 bytes (already discussed here). In this case it is exploited to overwrite the _KTHREAD.PreviousMode to 0 of the current thread, granting us arbitrary read/write primitives.

At this point the PoC does the following:

  1. Issue a series of calls to NtReadVirtualMemory()/NtWriteVirtualMemory() to overwrite the _EPROCESS.Token of the current process with the system process (PID 4).
  2. Restore _KTHREAD.PreviousMode to 1 with a final NtWriteVirtualMemory() and finally spawn a cmd shell.
Running POC and elevating privileges

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 the SeDebugPrivilege (meaning they must be an Administrator).

As already outlined, if between the free and the loading of the tampered pContainer in RCX the memory region is reallocated and overwritten with other data, then exploitation may fail. Maybe this can be addressed using heap spraying.

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!PoFxProcessorNotification and nt!DbgkpTriageDumpRestoreState. Otherwise, offsets 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 (container1 cannot be deleted) 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 LoadContainerQ():

LoadContainerQ before and after patch

The address of pContainer is copied in v58 before calling FlushImage(), before the attacker can tamper pContainer. After FlushImage() fails, the program performs a call to CClfsContainer::Release(v58). This time v58 points to a valid instance of CClfsContainer, therefore it not possible anymore to hijack the execution flow tampering the General Metadata Block.

Conclusions

Initially, the article provided some background information on the Common Log File System focusing on the data structure of a Base Log File. Later it analyzed the vulnerability in LoadContainerQ() and described how to exploit it in a Windows 11 23H2 environment.

Finally, it highlighted limitations and proposed improvements to the PoC and analyzed the patch applied by Microsoft.

Part 2 will analyze the other vulnerability, this time in WriteMetadataBlock(), that similarly to this one, allows again to hijack the execution flow and escalate privileges on Windows systems.

References

Contacts

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