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.