- Affected binary:
clfs.sys10.0.19041.7291 → 10.0.19041.7548 - CVE: CVE-2026-49795
- Patch date: 2026-07-14
- CVSS: 8.8
- Class: Use-after-free
- Patch KB: KB5099536 and related
TL;DR
The Common Log File System driver (clfs.sys) snapshots the in-memory base-file image — log blocks plus _CLFS_CONTAINER_CONTEXT metadata — into a buffer that can later be persisted to disk and reloaded. CClfsBaseFileSnapshot::CopyImage copied each container context verbatim, including a live in-memory kernel pointer stored at offset +0x18. That pointer was baked into the snapshot, written out, and on reload was trusted as a valid object pointer and dereferenced — a classic persisted-stale-pointer use-after-free. The patch, gated behind the new Feature_796637497 killswitch, walks all 0x400 container contexts before the image copy, stashes each +0x18 pointer into a fresh 1024-entry save array, zeroes the field so the snapshot captures NULL, and restores the live pointers afterward. The result is that persisted metadata once again satisfies the invariant that it contains no live kernel pointers.
Background
CLFS is Windows’ general-purpose transactional logging engine, sitting behind everything from TxF/TxR to ETW-adjacent workloads and third-party logging clients. It is a perennial local-privilege-escalation target because its on-disk structures are richly self-describing and are parsed by the kernel with a lot of pointer chasing.
A CLFS log is described by a base file (.blf), an in-memory representation of which is managed by CClfsBaseFile and its derivatives. The base file tracks up to 0x400 (1024) containers — the physical files that hold log data — and each is described by a _CLFS_CONTAINER_CONTEXT. Crucially, that structure is a hybrid: parts of it are meaningful on disk, but it also caches transient in-memory state. At offset +0x18, each container context holds a live kernel pointer (a back-reference to the runtime container object). Contexts are reached through CClfsBaseFile::AcquireContainerContext(index, &ctx) / ReleaseContainerContext(&ctx), the standard ref-counted accessor pair.
CClfsBaseFileSnapshot exists to produce a coherent copy of the base-file image — for archival/metadata reads. CClfsLogCcb::ReadArchiveMetadata calls into CClfsBaseFileSnapshot::CopyImage, whose job is to serialize a requested byte range (param_1 offset, param_2 length) of the assembled base-file image into the caller’s buffer param_3. Internally it iterates the base file’s log blocks, ClfsEncodeBlocks each one (to fix up sector-signature encoding), memmoves the requested slice, and ClfsDecodeBlocks to restore the block. The image it emits can be persisted and later reparsed — which is exactly why it must contain only on-disk-meaningful data.
Root Cause
Look at the pre-patch inner loop. CopyImage iterates the base file’s log blocks and copies raw bytes out of the in-memory image:
p_Var4 = *(_CLFS_LOG_BLOCK_HEADER **)(*(longlong *)(this + 0x30) + uVar8 * 0x18);
local_74 = ClfsEncodeBlock(p_Var4, (uint)*(ushort *)(p_Var4 + 4) << 9,
(uchar)p_Var4[2], '\x10', '\x01');
...
memmove(param_3 + local_74,
(void *)((uVar7 & 0xffffffff) + *(longlong *)(local_48 + local_58 * 8)),
(ulonglong)uVar3);
That memmove is a straight byte copy of the in-memory base-file image into the output buffer. The image includes the container-context array, and nothing in the pre-patch function touches offset +0x18 of any container context. There is no acquire/release loop, no AcquireContainerContext in the call set at all — the pre-patch called list contains only ClfsDecodeBlock, ClfsEncodeBlock, ExAcquireResourceExclusiveLite, ExReleaseResourceForThreadLite, RtlULongAdd, WPP_SF_sl, and memmove.
So whatever value happens to sit in each _CLFS_CONTAINER_CONTEXT at +0x18 — a live kernel pointer to the runtime container object — is copied bit-for-bit into the snapshot. The snapshot is meant to be a persistable, reloadable artifact, but it now embeds a kernel-address-space pointer that is only valid for the lifetime of the current in-memory objects. This violates the core CLFS invariant that persisted metadata carries no live pointers: on-disk container contexts are supposed to have their transient in-memory fields cleared.
When that image is later persisted and the crafted base file is reloaded, CLFS’s parsing path finds a non-NULL value at container-context +0x18 and treats it as a valid in-memory object pointer — dereferencing it. Because the pointed-to object has long since been freed (or can be arranged to point at attacker-controlled reclaimed memory), the dereference is a use-after-free / controlled-pointer read against freed or attacker-shaped kernel memory.
The Patch
The fix spans two functions and is uniformly gated behind the new Feature_796637497__private_IsEnabledDeviceUsage() killswitch. Per CFR semantics this returns non-zero (enabled) by default, so the new logic is live — and here it wraps new behavior (Rule 2), which is the real fix, not a gate removal.
The constructor: allocating the save array
CClfsBaseFileSnapshot::CClfsBaseFileSnapshot grows by one gated memset:
*(undefined8 *)(this + 0x98) = 0;
uVar1 = Feature_796637497__private_IsEnabledDeviceUsage();
if ((int)uVar1 != 0) {
memset(this + 0xa0, 0, 0x2000);
}
0x2000 bytes at offset 0xa0 is exactly 1024 × 8 — one 64-bit slot per possible container (0x400). This is the scratch array where CopyImage will park the live pointers it strips out. Zero-initializing it matters: the restore pass keys off non-NULL slots, so any container that was never acquired must read back as 0.
CopyImage: save-and-zero before the copy
Right after acquiring the base-file resource exclusively and before the block-copy loop, the patched function adds a save pass over every container context:
uVar3 = Feature_796637497__private_IsEnabledDeviceUsage();
...
if ((int)uVar3 != 0) {
local_6c = 0;
while (uVar6 = (uint)uVar4, uVar6 < 0x400) {
lVar2 = CClfsBaseFile::AcquireContainerContext((CClfsBaseFile *)this, uVar6, &local_78);
if (lVar2 < 0) {
*(undefined8 *)(this + uVar4 * 8 + 0xa0) = 0; // acquire failed → save NULL
}
else {
*(undefined8 *)(this + uVar4 * 8 + 0xa0) = *(undefined8 *)(local_78 + 0x18); // save +0x18
*(undefined8 *)(local_78 + 0x18) = 0; // zero the live pointer
CClfsBaseFile::ReleaseContainerContext((CClfsBaseFile *)this, &local_78);
}
local_6c = uVar6 + 1;
uVar4 = (ulonglong)local_6c;
}
...
}
For each of the 0x400 container indices it acquires the context, copies the +0x18 pointer into this + index*8 + 0xa0 (the save array), and then zeroes +0x18 in the live structure. When AcquireContainerContext fails (returns a negative NTSTATUS), it simply records NULL for that slot so the restore pass will skip it. Because the field is now zero, the subsequent memmove block-copy captures a NULL at every container context’s +0x18 — the snapshot no longer leaks a live kernel pointer.
CopyImage: restore after the copy
After the copy loop and the LAB_0 cleanup path (which ClfsDecodeBlocks any block still left encoded), a symmetric restore pass runs:
uVar4 = Feature_796637497__private_IsEnabledDeviceUsage();
if ((int)uVar4 != 0) {
pCVar8 = this + 0xa0;
do {
if (*(longlong *)pCVar8 != 0) { // only slots we saved
lVar2 = CClfsBaseFile::AcquireContainerContext((CClfsBaseFile *)this, (ulong)uVar5, &local_78);
if (lVar2 < 0) {
*(longlong *)pCVar8 = 0;
}
else {
*(longlong *)(local_78 + 0x18) = *(longlong *)pCVar8; // write pointer back
*(longlong *)pCVar8 = 0;
CClfsBaseFile::ReleaseContainerContext((CClfsBaseFile *)this, &local_78);
}
}
uVar13 = (ulong)uVar5 + 1;
uVar5 = (ulonglong)uVar13;
pCVar8 = pCVar8 + 8;
} while (uVar13 < 0x400);
}
It walks the save array slot-by-slot (pCVar8 striding 8 bytes), and for every non-NULL entry re-acquires the corresponding container context and writes the original live pointer back to +0x18, clearing the save slot as it goes. This keeps the in-memory base file fully functional after the snapshot: the transient pointer is only absent for the duration of the copy, exactly the window during which the image is serialized. The whole thing sits inside the same exclusive-resource critical section (ExAcquireResourceExclusiveLite on this + 0x20), so no other thread can observe the temporarily-zeroed +0x18 fields.
The remaining diff churn — renamed locals, local_80[2]/local_84 shuffling, the & 0x8000000 mask replacing the equivalent >> 0x1b & 1 WPP trace test, and the new local_40 NULL-block bookkeeping — is compiler re-allocation around the inserted code and carries no semantic weight. The functional change is entirely the two feature-gated save/restore passes plus the array they use.
Exploitability
The primitive is a persisted stale kernel pointer that becomes a controlled kernel-pointer dereference on reload — the textbook shape of a CLFS local EoP. An authorized local user (no special privilege beyond the ability to create and operate on a CLFS log, which is available to normal users) drives the sequence:
- Open/create a CLFS log so an in-memory base file with populated container contexts exists, ensuring
+0x18holds a live kernel object pointer. - Trigger the archive-metadata read path (
CClfsLogCcb::ReadArchiveMetadata→CopyImage) so the base-file image, complete with the live+0x18pointer, is copied into a snapshot buffer that gets persisted. - Free/tear down the runtime container object so the persisted pointer now dangles (or groom the allocator so the address is reclaimed by an attacker-controlled object).
- Cause CLFS to reload that crafted base file. The parser finds the non-
NULL+0x18, treats it as a valid in-memory container-object pointer, and dereferences it.
Depending on how the freed slot is reclaimed, the attacker gets either an info-leak (the kernel treats attacker-shaped memory as a container object and acts on its fields) or a write/UAF via the operations CLFS performs through that pointer — enough, chained with standard CLFS grooming, to corrupt kernel state and elevate to SYSTEM. The CVSS 8.8 with local attack vector and low privileges reflects exactly this: no user interaction, low complexity in practice because the trigger is a deterministic metadata-copy call rather than a tight race, and the persisted-to-disk nature means the dangerous pointer survives across the free/reload boundary the attacker fully controls. The fix removes the primitive at the source by guaranteeing the persisted image can never contain a live pointer in the first place.
Detection
The high-signal indicator is a persisted CLFS base-file image (.blf or an archived metadata blob) whose _CLFS_CONTAINER_CONTEXT entries carry a non-zero, canonical-kernel-range value at offset +0x18. On a patched system every persisted container context should read back NULL there; a non-null kernel-space pointer in on-disk metadata is a strong tamper/exploitation signal worth alerting on.
Pre patch functions
Full decompilation of CClfsBaseFileSnapshot::CopyImage before the patch:
/* public: long __cdecl CClfsBaseFileSnapshot::CopyImage(unsigned long,unsigned long,unsigned char *
__ptr64,unsigned long & __ptr64) __ptr64 */
long __thiscall
CClfsBaseFileSnapshot::CopyImage
(CClfsBaseFileSnapshot *this,ulong param_1,ulong param_2,uchar *param_3,ulong *param_4)
{
char cVar1;
uint uVar2;
undefined4 in_register_00000014;
uint uVar3;
_CLFS_LOG_BLOCK_HEADER *p_Var4;
char cVar5;
uint uVar6;
ulonglong uVar7;
ulonglong uVar8;
char local_78;
uint local_74;
uint local_70;
ulong local_6c;
ulong local_68;
uint local_64;
_CLFS_LOG_BLOCK_HEADER *local_60;
longlong local_58;
uint local_50;
longlong local_48;
local_60 = (_CLFS_LOG_BLOCK_HEADER *)0x0;
uVar8 = 0;
local_64 = 0;
local_6c = 0;
uVar2 = 0;
if ((param_2 == 0) || (param_3 == (uchar *)0x0)) {
uVar2 = 0xc000000d;
}
else {
*param_4 = 0;
cVar1 = ExAcquireResourceExclusiveLite
(*(undefined8 *)(this + 0x20),
CONCAT71((int7)(CONCAT44(in_register_00000014,param_1) >> 8),1));
while( true ) {
local_78 = '\0';
p_Var4 = (_CLFS_LOG_BLOCK_HEADER *)0x0;
if (((uint)*(ushort *)(this + 0x28) <= (uint)uVar8) || (param_2 <= *param_4)) break;
local_58 = uVar8 * 3;
p_Var4 = *(_CLFS_LOG_BLOCK_HEADER **)(*(longlong *)(this + 0x30) + uVar8 * 0x18);
local_60 = p_Var4;
local_74 = ClfsEncodeBlock(p_Var4,(uint)*(ushort *)(p_Var4 + 4) << 9,(uchar)p_Var4[2],'\x10',
'\x01');
if ((int)uVar2 < 0) {
if (((undefined8 **)WPP_GLOBAL_Control != &WPP_GLOBAL_Control) &&
((*(uint *)((longlong)WPP_GLOBAL_Control + 0x2c) >> 0x1b & 1) != 0)) {
WPP_SF_sl(WPP_GLOBAL_Control[3],0x29,&WPP_21a56e5629ce36317aa3399b61ca14e2_Traceguids,
"CClfsBaseFileSnapshot::CopyImage");
}
local_70 = local_74;
uVar2 = local_74;
goto LAB_0;
}
cVar5 = '\x01';
local_78 = '\x01';
local_48 = *(longlong *)(this + 0x30);
uVar3 = *(uint *)(local_48 + 0xc + local_58 * 8);
uVar6 = *(uint *)(local_48 + 8 + local_58 * 8);
if ((param_1 < uVar6 + uVar3) && (uVar3 <= param_1)) {
local_50 = param_1 - uVar3;
uVar7 = (ulonglong)local_50;
local_74 = *param_4;
uVar3 = param_2 - local_74;
local_68 = uVar3;
uVar2 = RtlULongAdd(local_50,uVar3,&local_6c);
if ((int)uVar2 < 0) {
uVar2 = 0xc000000d;
local_78 = cVar5;
local_70 = 0xc000000d;
goto LAB_0;
}
if (uVar6 < local_6c) {
uVar3 = uVar6 - (int)uVar7;
local_68 = uVar3;
}
local_70 = uVar2;
memmove(param_3 + local_74,
(void *)((uVar7 & 0xffffffff) + *(longlong *)(local_48 + local_58 * 8)),
(ulonglong)uVar3);
if ((int)uVar2 < 0) goto LAB_0;
*param_4 = *param_4 + uVar3;
param_1 = param_1 + uVar3;
}
ClfsDecodeBlock(p_Var4,(uint)*(ushort *)(p_Var4 + 4),(uchar)p_Var4[2],'\x10',&local_74);
local_60 = (_CLFS_LOG_BLOCK_HEADER *)0x0;
local_64 = (uint)uVar8 + 1;
uVar8 = (ulonglong)local_64;
}
local_78 = '\0';
local_70 = uVar2;
if (*param_4 == 0) {
uVar2 = 0x8000001a;
local_70 = 0x8000001a;
}
LAB_0:
if (local_78 != '\0') {
ClfsDecodeBlock(p_Var4,(uint)*(ushort *)(p_Var4 + 4),(uchar)p_Var4[2],'\x10',&local_74);
}
if (cVar1 != '\0') {
ExReleaseResourceForThreadLite(*(undefined8 *)(this + 0x20),SystemReserved1[0xf]);
uVar2 = local_70;
}
}
return uVar2;
}
Full decompilation of CClfsBaseFileSnapshot::CClfsBaseFileSnapshot before the patch:
/* public: __cdecl CClfsBaseFileSnapshot::CClfsBaseFileSnapshot(void) __ptr64 */
CClfsBaseFileSnapshot * __thiscall
CClfsBaseFileSnapshot::CClfsBaseFileSnapshot(CClfsBaseFileSnapshot *this)
{
*(undefined4 *)(this + 0x90) = 0x200;
*(undefined4 *)(this + 8) = 0;
*(undefined ***)this = &_vftable_;
*(undefined8 *)(this + 0x10) = 0;
*(undefined4 *)(this + 0x18) = 0;
*(undefined8 *)(this + 0x20) = 0;
*(undefined2 *)(this + 0x28) = 0;
*(undefined8 *)(this + 0x30) = 0;
*(undefined8 *)(this + 0x38) = 0;
this[0x94] = (CClfsBaseFileSnapshot)0x0;
*(undefined8 *)(this + 0x98) = 0;
return this;
}
Post patch functions
Full decompilation of CClfsBaseFileSnapshot::CopyImage after the patch:
/* public: long __cdecl CClfsBaseFileSnapshot::CopyImage(unsigned long,unsigned long,unsigned char *
__ptr64,unsigned long & __ptr64) __ptr64 */
long __thiscall
CClfsBaseFileSnapshot::CopyImage
(CClfsBaseFileSnapshot *this,ulong param_1,ulong param_2,uchar *param_3,ulong *param_4)
{
char cVar1;
long lVar2;
ulonglong uVar3;
ulonglong uVar4;
undefined4 in_register_00000014;
ulonglong uVar5;
uint uVar6;
_CLFS_LOG_BLOCK_HEADER *p_Var7;
CClfsBaseFileSnapshot *pCVar8;
ulonglong uVar9;
longlong lVar10;
uint uVar11;
char cVar12;
uint uVar13;
uint local_84;
uint local_80 [2];
_CLFS_CONTAINER_CONTEXT *local_78;
uint local_70;
uint local_6c;
_CLFS_LOG_BLOCK_HEADER *local_68;
ulong local_60;
ulong local_5c;
uint local_58;
longlong local_50;
longlong local_48;
_CLFS_LOG_BLOCK_HEADER *local_40;
uVar5 = 0;
local_68 = (_CLFS_LOG_BLOCK_HEADER *)0x0;
local_40 = (_CLFS_LOG_BLOCK_HEADER *)0x0;
local_70 = 0;
local_60 = 0;
local_84 = 0;
local_78 = (_CLFS_CONTAINER_CONTEXT *)0x0;
if ((param_2 == 0) || (param_3 == (uchar *)0x0)) {
uVar6 = 0xc000000d;
}
else {
*param_4 = 0;
cVar1 = ExAcquireResourceExclusiveLite
(*(undefined8 *)(this + 0x20),
CONCAT71((int7)(CONCAT44(in_register_00000014,param_1) >> 8),1));
uVar3 = Feature_796637497__private_IsEnabledDeviceUsage();
uVar4 = uVar5;
uVar9 = uVar5;
if ((int)uVar3 != 0) {
local_6c = 0;
while (uVar6 = (uint)uVar4, uVar6 < 0x400) {
lVar2 = CClfsBaseFile::AcquireContainerContext((CClfsBaseFile *)this,uVar6,&local_78);
if (lVar2 < 0) {
*(undefined8 *)(this + uVar4 * 8 + 0xa0) = 0;
}
else {
*(undefined8 *)(this + uVar4 * 8 + 0xa0) = *(undefined8 *)(local_78 + 0x18);
*(undefined8 *)(local_78 + 0x18) = 0;
CClfsBaseFile::ReleaseContainerContext((CClfsBaseFile *)this,&local_78);
}
local_6c = uVar6 + 1;
uVar4 = (ulonglong)local_6c;
}
uVar4 = (ulonglong)local_70;
uVar9 = 0;
}
while( true ) {
cVar12 = '\0';
if (((uint)*(ushort *)(this + 0x28) <= (uint)uVar4) || (param_2 <= *param_4)) break;
local_50 = uVar4 * 3;
p_Var7 = *(_CLFS_LOG_BLOCK_HEADER **)(*(longlong *)(this + 0x30) + uVar4 * 0x18);
local_68 = p_Var7;
local_80[0] = ClfsEncodeBlock(p_Var7,(uint)*(ushort *)(p_Var7 + 4) << 9,(uchar)p_Var7[2],
'\x10','\x01');
if ((int)(uint)uVar9 < 0) {
if (((undefined8 **)WPP_GLOBAL_Control != &WPP_GLOBAL_Control) &&
((*(uint *)((longlong)WPP_GLOBAL_Control + 0x2c) & 0x8000000) != 0)) {
WPP_SF_sl(WPP_GLOBAL_Control[3],0x29,&WPP_85a4919860ac33115988c469d9304efe_Traceguids,
"CClfsBaseFileSnapshot::CopyImage");
}
local_84 = local_80[0];
uVar6 = local_80[0];
goto LAB_0;
}
cVar12 = '\x01';
local_48 = *(longlong *)(this + 0x30);
uVar6 = *(uint *)(local_48 + 0xc + local_50 * 8);
uVar13 = *(uint *)(local_48 + 8 + local_50 * 8);
if ((param_1 < uVar13 + uVar6) && (uVar6 <= param_1)) {
local_58 = param_1 - uVar6;
uVar4 = (ulonglong)local_58;
local_80[0] = *param_4;
uVar11 = param_2 - local_80[0];
lVar10 = local_50;
local_5c = uVar11;
local_84 = RtlULongAdd(local_58,uVar11,&local_60);
uVar9 = (ulonglong)local_84;
if ((int)local_84 < 0) {
local_84 = 0xc000000d;
uVar6 = 0xc000000d;
goto LAB_0;
}
if (uVar13 < local_60) {
uVar11 = uVar13 - (int)uVar4;
local_5c = uVar11;
}
memmove(param_3 + local_80[0],
(void *)((uVar4 & 0xffffffff) + *(longlong *)(local_48 + lVar10 * 8)),
(ulonglong)uVar11);
uVar6 = local_84;
if ((int)local_84 < 0) goto LAB_0;
*param_4 = *param_4 + uVar11;
param_1 = param_1 + uVar11;
}
ClfsDecodeBlock(p_Var7,(uint)*(ushort *)(p_Var7 + 4),(uchar)p_Var7[2],'\x10',local_80);
local_70 = local_70 + 1;
uVar4 = (ulonglong)local_70;
}
local_68 = local_40;
p_Var7 = local_40;
uVar6 = (uint)uVar9;
if (*param_4 == 0) {
local_84 = 0x8000001a;
cVar12 = (char)local_40;
uVar6 = local_84;
}
LAB_0:
if (cVar12 != '\0') {
ClfsDecodeBlock(p_Var7,(uint)*(ushort *)(p_Var7 + 4),(uchar)p_Var7[2],'\x10',local_80);
}
uVar4 = Feature_796637497__private_IsEnabledDeviceUsage();
if ((int)uVar4 != 0) {
pCVar8 = this + 0xa0;
do {
if (*(longlong *)pCVar8 != 0) {
lVar2 = CClfsBaseFile::AcquireContainerContext
((CClfsBaseFile *)this,(ulong)uVar5,&local_78);
if (lVar2 < 0) {
*(longlong *)pCVar8 = 0;
}
else {
*(longlong *)(local_78 + 0x18) = *(longlong *)pCVar8;
*(longlong *)pCVar8 = 0;
CClfsBaseFile::ReleaseContainerContext((CClfsBaseFile *)this,&local_78);
}
}
uVar13 = (ulong)uVar5 + 1;
uVar5 = (ulonglong)uVar13;
pCVar8 = pCVar8 + 8;
} while (uVar13 < 0x400);
}
if (cVar1 != '\0') {
ExReleaseResourceForThreadLite(*(undefined8 *)(this + 0x20),SystemReserved1[0xf]);
uVar6 = local_84;
}
}
return uVar6;
}
Full decompilation of CClfsBaseFileSnapshot::CClfsBaseFileSnapshot after the patch:
/* public: __cdecl CClfsBaseFileSnapshot::CClfsBaseFileSnapshot(void) __ptr64 */
CClfsBaseFileSnapshot * __thiscall
CClfsBaseFileSnapshot::CClfsBaseFileSnapshot(CClfsBaseFileSnapshot *this)
{
ulonglong uVar1;
*(undefined4 *)(this + 0x90) = 0x200;
*(undefined4 *)(this + 8) = 0;
*(undefined8 *)(this + 0x10) = 0;
*(undefined4 *)(this + 0x18) = 0;
*(undefined8 *)(this + 0x20) = 0;
*(undefined2 *)(this + 0x28) = 0;
*(undefined8 *)(this + 0x30) = 0;
*(undefined8 *)(this + 0x38) = 0;
this[0x94] = (CClfsBaseFileSnapshot)0x0;
*(undefined ***)this = &_vftable_;
*(undefined8 *)(this + 0x98) = 0;
uVar1 = Feature_796637497__private_IsEnabledDeviceUsage();
if ((int)uVar1 != 0) {
memset(this + 0xa0,0,0x2000);
}
return this;
}