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.