cldflt.sys HsmiBitmapNORMALOpenOnDisk out of bounds read
cldflt.sys is the kernel side of the Windows Cloud Files API (the ProjFS
family). A sync provider — OneDrive, third-party cloud sync clients, anything
on the Projected File System API — registers a directory tree as a cloud-sync
root. Files in that tree are placeholders: metadata exists locally, but
contents are fetched on first open (hydrated) from the provider and may be
evicted (dehydrated) again later when local space is tight. To track which
parts of a placeholder are currently hydrated, cldflt.sys keeps a per-file
hydration bitmap. The bitmap is persisted by the provider into a small blob
in the file’s reparse point data, and read back on demand by
HsmiBitmapNORMALOpenOnDisk. That makes the bitmap’s source a sync blob the
provider writes — and in the attacker model the provider (or whatever can
place that reparse point) is hostile. A user does not need to be an admin to
register a sync root they control.
The bitmap format is small and regular. A header carries SegmentCount and
SubSegmentCount, then the segment data follows. The in-memory representation
that HsmiBitmapNORMALOpenOnDisk builds is a fixed-size per-segment slot
array — exactly one segment row of up to three sub-segments. Anything larger
is corruption; the format itself only ever stores that one row. The context
holds a push lock at BitmapCtx->Lock /* +0x30 */, taken exclusive on entry,
and the slot array at BitmapCtx->SlotArray /* +0xa0 */: two header pointers
followed by one segment of three sub-segment pointers — five pointers, 0x28
bytes total.
The vulnerable build read the header counts — SegmentCount and
SubSegmentCount, the function’s arg2 and arg3 — and used them straight
as loop bounds and array indices, with only a SegmentCount != 0
short-circuit:
// HsmiBitmapNORMALOpenOnDisk - vulnerable build
NTSTATUS HsmiBitmapNORMALOpenOnDisk(
HSMI_BITMAP_CONTEXT* BitmapCtx, // arg1
ULONG SegmentCount, // arg2 - read straight from the on-disk bitmap header
ULONG SubSegCount, // arg3 - read straight from the on-disk bitmap header,
...) // neither value is bounded before use
{
FltAcquirePushLockExclusiveEx(&BitmapCtx->Lock /* +0x30 */, 0);
// (1) one fixed allocation: 2 header ptrs + 1 segment of 3 sub-segment ptrs = 5 ptrs = 0x28 bytes
if (BitmapCtx->SlotArray /* +0xa0 */ == NULL) {
BitmapCtx->SlotArray = ExAllocatePool2(POOL_FLAG_PAGED, 0x28, 'HsBe');
if (BitmapCtx->SlotArray == NULL)
return STATUS_INSUFFICIENT_RESOURCES; // 0xC000009A
}
// (2) outer segment loop, bounded only by the attacker's SegmentCount
for (i = 0; i < SegmentCount; i++) {
HsmpFileCachePreparePinWrite(BitmapCtx->SlotArray[i + 1], ...); // header slot for segment i
// (3) inner sub-segment loop, bounded only by the attacker's SubSegCount
for (j = 0; j < SubSegCount; j++) {
// flat layout: 2 header ptrs, then 3 ptrs per segment
HsmiBitmapNormalOpenStream(...,
&BitmapCtx->SlotArray[i * 3 + j + 2]); // off the end when i > 0 or j > 2
}
}
...
}
Step (1) allocates exactly five pointer slots — two header pointers plus one
segment of three sub-segment pointers (0x28 bytes). Step (2)‘s outer loop
iterates SegmentCount times, and step (3)‘s inner loop iterates SubSegCount
times within each segment. As soon as either count exceeds what the slot array
actually holds, the indexed access SlotArray[i * 3 + j + 2] runs off the end
of the allocation. The layout is flat, so each extra unit of SegmentCount
advances three pointer slots (24 bytes) past the end, and each extra unit of
SubSegCount past 3 — even with SegmentCount == 1 — advances one pointer
slot (8 bytes) past the end of the single in-range row. The loop body treats the out-of-range slot as live state —
reading whatever bytes are there as if they were a real HSMI_SLOT, and
writing what should have been slot updates into the adjacent pool. The slot
array is allocated from a small, predictable pool bucket, so the neighbouring
objects are also small kernel structures an attacker can stage around it: the
read side gives a pool-content info-leak, the write side gives a controlled
overwrite of a neighbour object, and the fixed small dimensions make the heap
layout particularly stable. The trigger is any operation on the placeholder
that requires hydration state — a read, an enumeration, anything that loads
the bitmap — and cldflt.sys calls HsmiBitmapNORMALOpenOnDisk on its own.
The patch
The patch adds an explicit check up front that enforces what the format itself permits:
// HsmiBitmapNORMALOpenOnDisk - patched build
FltAcquirePushLockExclusiveEx(&BitmapCtx->Lock /* +0x30 */, 0);
// NEW: bound the attacker-controlled counts at the boundary, before any indexing.
// The format only ever stores 1 segment of 3 sub-segments; reject anything larger.
if (SegmentCount != 0) {
if (SegmentCount > 1 || SubSegCount > 3)
return 0xC000CF02; // cldflt: malformed on-disk bitmap header
}
// allocation and loops proceed unchanged; the counts are now provably in range
if (BitmapCtx->SlotArray /* +0xa0 */ == NULL) {
BitmapCtx->SlotArray = ExAllocatePool2(POOL_FLAG_PAGED, 0x28, 'HsBe');
if (BitmapCtx->SlotArray == NULL)
return STATUS_INSUFFICIENT_RESOURCES;
}
for (i = 0; i < SegmentCount; i++) {
for (j = 0; j < SubSegCount; j++) {
HsmiBitmapNormalOpenStream(...,
&BitmapCtx->SlotArray[i * 3 + j + 2]); // index now always within [0, 4]
}
}
The on-disk count is the attacker’s claim; the format’s real maximum is the authority. Any value the format cannot legitimately contain is rejected at the boundary, before the loops run.
Attack path
flowchart TD
A["attacker registers a sync root or plants a reparse point, no admin needed"] --> B["crafts a placeholder whose bitmap header has huge counts"]
B --> C["user opens the file, cldflt loads the hydration bitmap"]
C --> D["HsmiBitmapNORMALOpenOnDisk allocates a fixed 1-by-3 slot array, loops use on-disk SegmentCount and SubSegmentCount unbounded"]
D --> E["indexed access past the slot array into adjacent pool, info-leak on read or controlled overwrite on write"]
Counts read from an on-disk header are attacker-controlled; they have to be
bounded against the structure that will be indexed by them. “The format only
ever stores N” is the bound — when a serialised structure has a small fixed
maximum, the parser must enforce it, instead of treating the on-disk count as
the loop bound. This is the same class as the prjflt tombstone and
stream-context fixes this month: a filter that trusts header fields it read
off disk or from a provider and uses them before validating. Every “read
count from header, then loop that many times” site is worth auditing with the
fixed in-memory structure as the bound, never the on-disk count.