clfs.sys CClfsLogCcb Cleanup reservation double release

CLFS is the kernel logging layer NTFS, the registry and KTM build on. A client that wants guaranteed append space later reserves bytes up front; the log’s reservation manager records “this stream owes N” in the restart area and container tables of the .blf, and subtracts N back out when the client releases. That metadata is exactly what every later append and flush walks to find free containers and lay out records, so corrupting it is the well-known route to a CLFS EoP — a wrong container or reservation count sends the next append’s table walk off the end of a pool buffer. “Release exactly once, exactly what was reserved” is a security invariant, not bookkeeping.

Each open log handle has a client-context block (CClfsLogCcb), and the reservation this client currently holds sits in one field on it, ReservedLogSpace /* +0x68 */. Cleanup is what runs when the handle is closed, and among other things it has to hand ReservedLogSpace back to the reservation manager through a vtable on the manager reached via pLogFcb. The vulnerable build does that like this (typed decompilation; the public PDB is stripped, so the field names are reverse-engineered from use):

// CClfsLogCcb::Cleanup — vulnerable build
ExAcquireResourceExclusiveLite(&this->ResourceLock, TRUE);             /* +0x98 */
this->StateFlags |= 0x4;                                               /* +0x1c, cleanup in progress */
ExReleaseResourceLite(&this->ResourceLock);

pReadContext = this->pReadContext;                                     /* +0x100, tear down read context first */
if (pReadContext) { (*(*pReadContext + 0x10))(); (*(*pReadContext + 8))(); }
this->pReadContext = NULL;
CClfsLogCcb::ResetFileSystemFlag(this);

pLogFcb = this->pLogFcb;                                               /* +0x48 */
mgr     = *(pLogFcb->pReservationStore + 0x78);                        /* +0x18 on FCB, then +0x78, reservation manager */
if (this->SectorCount > 0)                                             /* +0x28 */
    (*(*mgr + 0x58))(mgr, pLogFcb, this->SectorCount, ..., &this->ReserveContext);   /* +0x70 */

int64_t r = this->ReservedLogSpace;                                    /* +0x68, read the reservation NOW, at release */
if (r > 0) {
    delta = -r;
    (*(*mgr + 0x128))(mgr, this->pLogFcb, &delta, ..., &this->ReserveContext);       /* release */
}
CClfsLogCcb::Unlink(this);

The mistake is the last read. ReservedLogSpace is fetched fresh at the point of release, deep in the function, after the read context is already torn down, and the field is never zeroed before or after. So the field keeps holding the reservation amount even after it has been released once. Two things become reachable.

The obvious one is a double cleanup. Cleanup is not inherently single-run; a close racing a log operation (or a re-entrant teardown) can drive it twice for the same context. The second pass reads the same non-zero ReservedLogSpace and releases it again — the manager subtracts N from a bucket that only ever had N added, and the accounting underflows.

The subtler one is re-arm. Even on a single cleanup the read happens late — after the pReadContext teardown and the SectorCount adjust — late enough that a racing log operation on the same stream can touch ReservedLogSpace between the start of cleanup and the read, and whatever value that read returns is released, with no atomic claim guarding it.

Either way the symptom is the same: a release count that does not match the take count. Once the reservation manager has subtracted too much, a container or reservation field in the on-disk restart area goes negative (an unsigned underflow to a huge value), and the next append — which sizes and places its writes from that field — runs off a pool buffer. CLFS-metadata-corruption-to-pool- overflow is the same shape as the public CLFS EoP class, so this reads elevation-grade.

The patch

The fix claims the reservation once, atomically, before anything else in cleanup runs — snapshot it into a local and zero the field:

// CClfsLogCcb::Cleanup — patched
int64_t reserved = this->ReservedLogSpace;     /* +0x68, snapshot at entry */
this->ReservedLogSpace = 0;                    /* +0x68, and clear — nobody else can release it now */
...
if (reserved > 0) {
    delta = -reserved;
    (*(*mgr + 0x128))(mgr, this->pLogFcb, &delta, ..., &this->ReserveContext);   /* release */
}

With the field emptied under the same operation that consumes it, a second pass reads zero and releases nothing; the question “has this been released?” can only ever be answered yes once. This is the standard shape for any cleanup that acts on a “how much do I owe” field — claim it into a local, zero the stored count, act on the local. A teardown that reads the field at the release site and leaves it intact is the one-line smell to grep for.

Attack path

sequenceDiagram
    participant T1 as close thread
    participant T2 as racing log op or re-entered cleanup
    participant R as CLFS reservation account
    T1->>R: cleanup reads ReservedLogSpace N, releases N
    T2->>R: races the same CCB, releases N again
    Note over R: reservation underflows, next flush walks a corrupted table, OOB pool op to EoP

CLFS reservation and container metadata lives in the log file itself, so the corrupted counter is directly attacker-influenceable through subsequent log I/O on the same stream — the trigger is local (open a log, race the cleanup), not privileged.