CVE-2021-31969 — cldflt.sys Pool Overflow (Restrictive Chunk Size) (EoP)

Last updated: 2026-04-10
Severity: High
Component: cldflt.sys (Windows Cloud Files Mini Filter Driver)
Affected versions: Windows 10 1809–21H2, Windows Server 2019
Bug Class: Integer Underflow → Heap Overflow
Privilege Escalation: User → SYSTEM
Patch: Windows 10 KB5003637 (June 2021) — validate someSize >= 4
Related: Cldflt, Cve 2024 30085, Heap Grooming, Integer Overflows, Primitives
Tags: integer-overflow, heap-overflow, kernel-mode, pool, oob-write, aaw, aar, token-steal

Vulnerability Summary

cldflt.sys processes cloud file reparse point data containing a user-supplied compressed buffer. someSize = HIWORD(v7) extracts a 16-bit value with no lower-bound check. When someSize < 4, the expression allocatedSize - 12 wraps to ~0xFFFFFFF4 (unsigned underflow), causing RtlDecompressBuffer to be called with a nearly-4GB decompressed size. By crafting LZNT1 headers to mark the data as uncompressed, the function behaves as memcpy, achieving a fully controlled paged pool overflow. The allocation is a fixed-size 0x20-byte chunk (LFH), requiring a non-standard technique: overflow past the entire LFH bucket into a VS subsegment to reach WNF and TOKEN objects.

Root Cause Analysis

// Vulnerable path (HsmpRpiDecompressBuffer)
someSize = HIWORD(v7);               // 16-bit field from user reparse data
allocatedSize = someSize + 8;        // no lower bound check on someSize

// If someSize == 0 → allocatedSize = 8
// Then (allocatedSize - 12) wraps: 8 - 12 = 0xFFFFFFF4 as DWORD
RtlDecompressBuffer(
    COMPRESSION_FORMAT_LZNT1,
    outputBuffer,
    allocatedSize - 12,              // ← unsigned underflow: ~4GB size!
    compressedBuffer,                // ← user-controlled content
    compressedSize,
    &finalUncompressedSize);

// Patch: validate someSize >= 4 before computing allocatedSize

The allocation tag is HsRp, allocated in the paged pool at 0x20 bytes (the restrictive element the blog title references).

Trigger Path

  1. Register a sync root: CfRegisterSyncRoot(rootDir, ...).
  2. Write crafted reparse data to a directory under the sync root.
  3. Open a file handle to trigger HsmFltPostCREATE → HsmiFltPostECPCREATE → HsmpSetupContexts → HsmpRpReadBuffer → HsmpRpiDecompressBuffer.

Crafted Reparse Buffer Layout

struct cstmData {
    WORD flag;             // 0x8000 — passes internal flag check
    WORD cstmDataSize;     // 0x0000 — triggers someSize=0 → underflow
    UCHAR compressedBuffer[];
};

LZNT1 “Uncompressed” Trick

LZNT1 chunk structure:

typedef struct {
    WORD Size;     // If high bit clear: compressed. If 0x8000: uncompressed marker.
    BYTE Data[4096];
} LZNT1Chunk;

By setting the LZNT1 chunk header’s Size field appropriately and leaving the data as-is, RtlDecompressBuffer treats the data as uncompressed and simply copies it — effectively turning the bug into a controlled memcpy of controlled length and content.

For the maximum overflow size, chain multiple LZNT1 page-sized chunks. HsmpRpReadBuffer reads up to 0x4000 bytes of reparse data, limiting effective overflow to just under 4 pages.

FSCTL Note

Use FSCTL_SET_REPARSE_POINT_EX (not FSCTL_SET_REPARSE_POINT) — the driver has a pre-op handler blocking the latter for non-Cloud-Filter requests.

The LFH Restriction

The allocation is 0x20 bytes. Under the Low Fragmentation Heap (LFH), the victim chunk can only be adjacent to other 0x20-byte objects in the same bucket. This prevents a simple direct overflow into a powerful object like WNF (which is 0x1000 bytes).

Solution: Cross-Subsegment Overflow

Different subsegment types (LFH and VS) can be contiguous in pool memory. By:

  1. Exhausting all existing 0x20 LFH buckets (spray _TERMINATION_PORT objects), forcing the backend allocator to create a new 0x20 LFH segment.
  2. Simultaneously spraying _WNF_STATE_DATA (VS segment) and _TOKEN (VS segment) objects to exhaust VS subsegments, forcing new VS subsegments to be allocated.
  3. With sufficient spray, the new 0x20 LFH bucket lands adjacent to (or near) the new VS subsegment.

If fewer than 4 pages of LFH chunks separate the victim from the VS subsegment, the overflow (≤ 4 pages of data) can reach WNF and TOKEN objects in the VS subsegment.

LFH Spray Object: _TERMINATION_PORT

struct _TERMINATION_PORT {
    struct _TERMINATION_PORT* Next;  //0x0
    VOID* Port;                      //0x8
};
// Size: 0x10 bytes → 0x20 allocation with pool header

Created via NtRegisterThreadTerminatePort(alpcPortHandle). Freed automatically when the thread terminates.

void SprayTerminationPort(DWORD count) {
    // Create ALPC port, then:
    for (int i = 0; i < count; i++)
        NtRegisterThreadTerminatePort(hConnPort);
    // Free by letting thread terminate or via control variable
}

Overflow Payload

The overflow DWORDS are filled with the value 0x1000. The goal: overwrite the _WNF_STATE_DATA.AllocatedSize and _WNF_STATE_DATA.DataSize fields with 0x1000, granting a relative page-sized read/write primitive via WNF query/update APIs.

struct _WNF_STATE_DATA {
    _WNF_NODE_HEADER Header;   //0x0
    ULONG AllocatedSize;       //0x4  ← overwrite to 0x1000
    ULONG DataSize;            //0x8  ← overwrite to 0x1000
    ULONG ChangeStamp;         //0xc
};

With DataSize=0x1000, NtQueryWnfStateData returns STATUS_BUFFER_TOO_SMALL for the original small allocation, identifying the corrupted object. With DataSize enlarged, the API returns up to 0x1000 bytes, giving relative read into adjacent memory including the _TOKEN object.

Post-Overflow: Finding the Corrupted WNF

Scan all WNF objects:

  1. Query with original DataSize → those returning STATUS_BUFFER_TOO_SMALL are corrupted.
  2. Check the adjacent _TOKEN content: confirm it’s an untouched TOKEN by looking for the known 0x1000 sentinel at wnfObjectSize + 0x50.
  3. Use stored token handle array + GetTokenInformation(TokenStatistics) to match the TOKEN by TokenId.LowPart.

Arbitrary Read via TOKEN

// NtQueryInformationToken(TokenBnoIsolation) path:
case TokenBnoIsolation:
    if (Token->BnoIsolationHandlesEntry) {
        memmove(
            TokenInformation + 16,
            Token->BnoIsolationHandlesEntry->EntryDescriptor.IsolationPrefix.Buffer,
            Token->BnoIsolationHandlesEntry->EntryDescriptor.IsolationPrefix.MaximumLength);
    }

Set Token->BnoIsolationHandlesEntry to a usermode struct with forged Buffer (read source) and MaximumLength (read size). Data lands at TokenInformation+16. This gives arbitrary kernel read.

Arbitrary Write via TOKEN

// NtSetInformationToken(TokenDefaultDacl) → SepAppendDefaultDacl:
result = memmove(
    &Token->DynamicPart[*(BYTE*)(Token->PrimaryGroup + 1) + 2],
    UserBuffer,
    UserBuffer[1]);  // UserBuffer cast as ACL, AclSize at [2]

Constraints:

  • UserBuffer must look like a valid ACL: AclRevision = 2–4, AceCount = 0
  • UserBuffer[1] (AclSize high byte) = size of write ≥ 0x8
  • DynamicPart and PrimaryGroup should point to the same address to avoid spurious memmove in SepFreeDefaultDacl

Target: Set both DynamicPart and PrimaryGroup to _KTHREAD + 0x229. Then:

  • *(BYTE*)(PrimaryGroup+1) = 0 (null byte in KTHREAD at that offset)
  • Write destination = DynamicPart[0+2] = KTHREAD + 0x229 + 8 + 8 = KTHREAD + 0x239… adjusted to hit PreviousMode at KTHREAD + 0x2b1
  • Sbz1 = 0x00 overwrites PreviousMode → kernel believes caller is in kernel mode

Side effect: BasePriority of thread set to 0x8 (THREAD_PRIORITY_BELOW_NORMAL) — acceptable.

EPROCESS Leak Chain

Since NtQuery* kernel ASLR leaks are removed in Win11 Build 25915+, CVE-2021-31969 (Win10 era) uses a different technique:

TOKEN->SessionObject → AlIn allocation (search pool for 'nIlA' tag)
AlIn allocation + 0x38 → IoCompletion object pointer
Scan pool near IoCompletion for 'RwtE' (EtwR allocation)
EtwR + 0x30 → EPROCESS pointer

Walk EPROCESS.ActiveProcessLinks to find own process (PID match) and System (PID=4).

Get Shell

// PreviousMode = 0 → NtArbitraryRead/Write now work
StealToken(OwnEproc, SystemEproc) {
    NtArbitraryRead(&SystemEproc->Token, &token, 8);
    NtArbitraryWrite(&OwnEproc->Token, &token, 8);
}
// Restore PreviousMode, spawn cmd.exe as SYSTEM

Key Primitives Used

PrimitiveTechnique
Integer underflowHIWORD(v7)allocatedSize-12 wraps to ~4GB
Controlled pool overflowLZNT1 uncompressed trick → RtlDecompressBuffer as memcpy
Cross-subsegment reachExhaust LFH + VS buckets → adjacent LFH/VS boundary
Relative paged read/writeWNF DataSize overwrite → page-sized OOB via NtQueryWnfStateData
Arbitrary kernel readTOKEN.BnoIsolationHandlesEntry → NtQueryInformationToken
Arbitrary kernel writeTOKEN.DynamicPart/PrimaryGroup → SepAppendDefaultDacl memmove
PreviousMode nullACL Sbz1=0 written to KTHREAD+PreviousMode via NtSetInformationToken
EPROCESS leakSessionObject → AlIn → IoCo → EtwR+0x30 → EPROCESS

Mitigations Bypassed

  • KASLR: Defeated via relative read + pool tag pattern matching chain (SessionObject → EtwR → EPROCESS)
  • Pool randomization: Defeated by cross-subsegment spray technique

Patch Analysis

Patch adds a simple lower-bound check:

if (someSize < 4)
    return STATUS_INVALID_PARAMETER;

This prevents the underflow without changing the broader architecture. The same general cldflt reparse-point attack surface remained open, leading to CVE-2024-30085 three years later.

References

  • Alex Birnberg (SSD Security Research), “Exploitation of a kernel pool overflow from a restrictive chunk size (CVE-2021-31969)”, SSD Blog
  • Pool overflow techniques: Angelboy, “Windows Kernel Heap — Segment Heap in Windows Kernel”, speakerdeck.com/scwuaptx
  • Cloud Filter API: https://learn.microsoft.com/en-us/windows/win32/api/_cloudapi/