Common Log File System (Part 1)
Common Log File System (CLFS) is a general-purpose logging service that is accessible to both kernel-mode and user-mode programs [4]. The service offers a public user-mode API for any programs want to store log records on the file system. CLFS is designed to run in kernel to enhance its resilience against system failures. However, due to the complex structure of the log format (BLF), CLFS has made itself a huge attack vector, with 24 CVEs reported in the past 5 years [5].
CLFS uses the “Base Log File” (BLF) format to maintain necessary metadata for the log and uses container files to hold the actual records. The format of BLF is largely undocumented; however, efforts by Alex Ionescu [6] have made the reverse engineering process significantly easier.
General knowledge
A BLF file consists of six metadata blocks: Control Block, Base Block, Truncate Block, and their corresponding shadow blocks. Shadow blocks contain copies of the metadata, which can be used for data consistency. Every block starts with a 70-byte log block header, called CLFS_LOG_BLOCK_HEADER
, followed by their records.
Base Log File format
The blocks are stored in sectors, each of which is 512 bytes in size. At the end of every sector will be a signature, which is composed of two bytes: the sector block type and the update sequence number (USN). The signatures are used for data consistency.
The value of RecordOffsets[0]
in a CLFS_LOG_BLOCK_HEADER
is the offset to its record.
Control Record
The control record follows the log block header of the control block, which is defined by the following structure:
typedef struct _CLFS_CONTROL_RECORD
{
CLFS_METADATA_RECORD_HEADER hdrControlRecord;
ULONGLONG ullMagicValue;
UCHAR Version;
CLFS_EXTEND_STATE eExtendState;
USHORT iExtendBlock;
USHORT iFlushBlock;
ULONG cNewBlockSectors;
ULONG cExtendStartSectors;
ULONG cExtendSectors;
CLFS_TRUNCATE_CONTEXT cxTruncate;
USHORT cBlocks;
ULONG cReserved;
CLFS_METADATA_BLOCK rgBlocks[ANYSIZE_ARRAY];
} CLFS_CONTROL_RECORD, *PCLFS_CONTROL_RECORD;
Base Record
The base record contains information about the clients and containers associated with the log file.
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;
Common Log File System API
Clfsw32 (the user-space library for CLFS) provides 43 functions, of which only 3 are used in the PoC:
CreateLogFile
: create/open log file.DeleteLogByHandle
: delete log file by handle.AddLogContainer
: add a container to the log handle.
The following diagram describes the trace from user-mode CreateLogFile
API to kernel-mode CLFS driver. The call stack was extracted using WinDBG.
Call stack of
CreateLogFile
Exploit targets
RemoveContainer’s double vtable calls
There is an interesting call stack during the call to DeleteLogByHandle
and CloseHandle
:
__int64 __fastcall CClfsBaseFilePersisted::RemoveContainer(CClfsBaseFilePersisted *this, unsigned int a2)
{
...
Symbol = CClfsBaseFile::GetSymbol(&this->m_ClfsBaseFile, v8, v2, &v17);
v10 = v17;
...
pContainer = v10->pContainer;
if ( pContainer )
{
v10->ullAlignment = 0i64;
ExReleaseResourceForThreadLite(this->m_ClfsBaseFile.ImageResource, (ERESOURCE_THREAD)KeGetCurrentThread());
v4 = 0;
(*((void (__fastcall **)(CClfsContainer *))pContainer->vftable + 3))(pContainer);
(*((void (__fastcall **)(CClfsContainer *))pContainer->vftable + 1))(pContainer);
v7 = v16;
goto LABEL_20;
}
...
}
Pseudocode of CClfsBaseFilePersisted::RemoveContainer
The function CClfsBaseFilePersisted::RemoveContainer
makes two vtable calls using the pContainer
field of the container context. Therefore we will get two RIP controls if we are able to corrupt the pContainer
field.
These two calls are handy when we want to disable SMEP: the first call flip the 20-th bit of CR4; the second call enables arbitrary code execution. Note that due to PatchGuard, the shellcode should restore the CR4 register (with a gadget) before jumping back to kernel.
CVE-2022-24521
During the encoding stage (ClfsEncodeBlock
), the signature bytes (the last 2 bytes in each sectors) are copied into an array pointed by SignaturesOffset
header field. While decoding (ClfsDecodeBlock
), the array bytes are written back into their respective sectors. Therefore all modified data in that array is restored after two stages of decoding and encoding.
In the following function (CClfsBaseFilePersisted::WriteMetadataBlock
), the base block is encoded in stage 1 (the signature bytes are moved back into the signature array). The array pointed by SignaturesOffset field was forged such that its address lies close to CLFS_CONTAINER_CONTEXT
structure, therefore the context->pContainer
pointer is restored (to our crafted address) after the encoding stage.
...
for ( i = 0; i < 0x400; ++i ) // Obtain all container contexts represented in blf
// Save pContainer class pointer for each valid container context
{
v20 = CClfsBaseFile::AcquireContainerContext(&this->m_ClfsBaseFile, i, &context);
v15 = &this->m_ClfsBaseFile.vftable + i;
if ( v20 >= 0 )
{
v16 = context;
v15[0x38] = context->pContainer; // 0x38=offset rgContainerClassPointers
// For each valid container save pContainer
v16->pContainer = 0i64; // And set the initial pContainer to zero
CClfsBaseFile::ReleaseContainerContext(&this->m_ClfsBaseFile, &context);
}
else
{
v15[0x38] = 0i64;
}
}
// Stage [1] Encode block, prepare it for writing
ClfsEncodeBlock(pbImage, pbImage->TotalSectorCount << 9, pbImage->Usn, 0x10u, 1u);
v10 = CClfsContainer::WriteSector( // Write modified data
this->field_98_CClfsContainer,
this->ReadEvent,
0i64,
this->m_ClfsBaseFile.rgBaseBlocks[v8].pbImage,
pbImage->TotalSectorCount,
(union _LARGE_INTEGER *)v21);
...
CVE-2022-24481
During the internal initialization of CClfsLogFcbPhysical
(the CClfsLogFcbPhysical::Initialize
function used to read image), the field offset 1A8h
is assigned with clientContext->llCreateTime
.
...
v35 = clientContext;
this->field_1A8 = clientContext->llCreateTime.QuadPart;
this->field_1B0 = v35->llAccessTime.QuadPart;
this->field_1B8 = v35->llWriteTime.QuadPart;
this->field_1D0 = 0i64;
*(_QWORD *)&this->field_538 = v35->lsnOwnerPage.Internal;
this->field_1E8.Internal = v35->lsnArchiveTail.Internal;
this->field_1E0.Internal = v35->lsnBase.Internal;
this->field_1F0.Internal = v35->lsnLast.Internal;
this->field_1F8.Internal = v35->lsnRestart.Internal;
*(_DWORD *)&this->gap171[3] = v35->cShadowSectors;
eState = v34->eState;
...
When the file is being closed, the driver calls CClfsLogFcbPhysical::FlushMetadata
to flush the internal data. During the process, clientContext->llCreateTime
is restored back its initial value, which mean it’s possible to forge this structure so that the field overlaps with some pointers. In this exploit we forged the BLF image so that clientContext->llCreateTime
overlaps with containerContext->pContainer
.
...
clientContext = 0i64;
v2 = CClfsBaseFile::AcquireClientContext(&this->field_2B0_CClfsBaseFilePersisted->m_ClfsBaseFile, 0, &clientContext);
if ( v2 >= 0 && (v3 = clientContext) != 0i64 )
{
eState = clientContext->eState;
v5 = this->flags;
clientContext->llCreateTime.QuadPart = this->field_1A8;//restore
v3->llAccessTime.QuadPart = this->field_1B0;
v3->llWriteTime.QuadPart = this->field_1B8;
v3->lsnOwnerPage.Internal = *(_QWORD *)&this->field_538;
v3->lsnArchiveTail.Internal = this->field_1E8.Internal;
v3->lsnBase.Internal = this->field_1E0.Internal;
v3->lsnLast.Internal = this->field_1F0.Internal;
...
CVE-2023-28252
During the decoding step, ClfsDecodeBlockPrivate
doesn’t verify the ValidSectorCount
field, although ClfsEncodeBlockPrivate
does.
The function ClfsEncodeBlock
invalidates the block by zeroing the checksum when ClfsEncodeBlockPrivate
returns negative.
__int64 __fastcall ClfsEncodeBlock(
struct _CLFS_LOG_BLOCK_HEADER *a1,
unsigned int a2,
UCHAR a3,
unsigned __int8 a4,
unsigned __int8 a5)
{
int v7; // edi
a1->Checksum = 0; // zeros the checksum
v7 = ClfsEncodeBlockPrivate(a1, a2, a3, a4);
if ( v7 >= 0 && a5 )
a1->Checksum = CCrc32::ComputeCrc32(&a1->MajorVersion, a2);
return (unsigned int)v7;
}
In conclusion, it is clear that the driver accepts the log block during its decoding step, and corrupts it in encoding phase. The following snippet sketchs the flow of CClfsBasePersisted::ReadMetadataBlock
:
__int64 __fastcall CClfsBaseFilePersisted::ReadMetadataBlock(...)
{
block_shadow = block + 1;
result1 = ClfsDecodeBlock(block);
result2 = ClfsDecodeBlock(block_shadow);
if (!SUCCEEDED(result1)) {
if (SUCCEEDED(result2)) {
// use shadow block
} else {
// fail
}
} else {
if (SUCCEEDED(result2)) {
// compare the DumpCount field
if (CClfsBaseFile::IsYoungerBlock(block, block_shadow)) {
// use block
} else {
// use shadow block
}
} else {
// use block
}
}
}
The program reads, decodes the block and shadow block from the disk, and compares their DumpCount
; if one of them is corrupted, the function uses the remaining block. But there’s a catch: the program doesn’t always re-verify the fields after an invocation to CClfsBaseFilePersisted::ReadMetadataBlock
! In fact, the driver only does that in CClfsBaseFilePersisted::OpenImage
, but not in CClfsBaseFilePersisted::ExtendMetadataBlock
:
__int64 __fastcall CClfsBaseFilePersisted::ExtendMetadataBlock(...)
{
...
for ( i = v3; i < this->m_ClfsBaseFile.BlockCount; i += 2 )
{
EventObject = CClfsBaseFile::AcquireMetadataBlock(&this->m_ClfsBaseFile, i);// call ReadMetadataBlock
k = EventObject;
if ( EventObject < 0 )
goto LABEL_50;
}
EventObject = CClfsBaseFile::GetControlRecord(&this->m_ClfsBaseFile, &v34);
k = EventObject;
if ( EventObject >= 0 )
{
...
for ( j = 0; ; ++j )
{
v35 = j;
if ( j >= this->m_ClfsBaseFile.BlockCount )
break;
EventObject = CClfsBaseFilePersisted::WriteMetadataBlock(this, j, 0); // corrupt the CheckSum
k = EventObject;
if ( EventObject < 0 )
goto LABEL_50;
}
...
// subsequence calls to ExtendMetadataBlock uses modified iFlushBlock field
...
CClfsBaseFilePersisted::WriteMetadataBlock(this, (unsigned __int16)iFlushBlock, 0); // trigger the exploit
...
}
In conclusion, the whole trigger process could be summarized into 6 steps:
- When opening the
trigger.blf
file,ExtendMetadataBlock
is invoked, subsequently callsReadMetadataBlock
. ReadMetadataBlock
compares the DumpCount of the control and control shadow blocks, and select the control block.ExtendMetadataBlock
runsWriteMetadataBlock
, subsequently callsClfsEncodeBlock
and corrupts the checksum field and writes the changes to the disk.- When adding a new container,
ExtendMetadataBlock
is called again - This time,
ReadMetadataBlock
uses the control shadow block (since the control block was corrupted). ExtendMetadataBlock
invokesWriteMetadataBlock
with maliciousiFlushBlock
field from control shadow block. The functionWriteMetadataBlock
reads the maliciousCLFS_LOG_BLOCK_HEADER
from an overflowed array index.
__int64 __fastcall CClfsBaseFilePersisted::WriteMetadataBlock(CClfsBaseFilePersisted *this, unsigned int a2, char a3)
{
v4 = a2;
...
v8 = (unsigned int)v4;
pbImage = (struct _CLFS_LOG_BLOCK_HEADER *)this->m_ClfsBaseFile.rgBaseBlocks[v4].pbImage;// overflow
p_MajorVersion = (unsigned int *)&pbImage->MajorVersion;
if ( pbImage )
{
v7 = 1;
v11 = pbImage->RecordOffsets[0]; // corrupted?
v12 = ++*(_QWORD *)(&pbImage->MajorVersion + v11) & 1i64;
...
rgBaseBlocks
is an array of six CLFS_METADATA_BLOCK
structures that reside in the NonPaged Pool. Suppose that we could control the heap chunk behind that, this function eventually allows arbitrary increment.
The problem comes down to where we should point pbImage
. Fortunately, since blocks are allocated in Paged Pool whose address can be leaked via NtQuerySystemInformation
, we can forge the control block and the pbImage field to corrupt rgContainers[0]
.
Using CreatePipe
is a common method to allocate memory on the kernel heap [10]. In fact, to reliably control the overflowed data, we must spray the kernel heap and create holes for the victim chunk using CloseHandle
.