mssecflt.sys — Use-after-free during process-context enumeration under shared lock (CWE-416) fixed
KB5078752
1. Overview
- Unpatched Binary:
mssecflt_unpatched.sys - Patched Binary:
mssecflt_patched.sys - Overall Similarity Score: 0.9862
- Diff Statistics: 877 matched, 25 changed, 852 identical, 0 unmatched (in both directions).
- Verdict: The patch adds a reference-and-snapshot enumeration path to
SecEnumerateProcessContextsthat pins each process-context entry with a reference count and releases the shared push lock before invoking the per-entry callback. This removes a use-after-free / race window in which a callback (or a concurrent path) could free the entry that enumeration was still using. The new path is on by default; the old in-lock path is retained behind a registry-controlled feature bit.
2. Vulnerability Summary
Finding 1: Callback-Under-Shared-Lock Use-After-Free (Medium)
- Vulnerability Class: Use-After-Free / Race Condition (CWE-416, CWE-362, CWE-667)
- Affected Function:
SecEnumerateProcessContexts(unpatched @0x14002CF04) - Root Cause: The unpatched code iterates the 128-bucket process-context hash table (
SecProcessTable, buckets at[SecProcessTable+0x140]) while holding only a shared push lock (FltAcquirePushLockSharedExonSecProcessTable+8). For each list node it computes the entry object atnode-0x10, advances the next pointer, and invokes an indirect, CFG-dispatched callback (__guard_dispatch_icall_fptron the pointer inrax, originallyrcx) with the entry as the first argument. No reference is taken on the entry across the callback. If the callback (a per-process policy handler such asSecApplyReadWriteTelemetryPolicyForProcess,SecApplyFileOpenMonitoringForProcess, orSecApplyTiLocalExecProtectVmForProcess) drops the entry's last reference or otherwise triggers its teardown, the entry can be freed/recycled while enumeration continues to use it and its next pointer, producing a use-after-free. - The Fix: The patched
SecEnumerateProcessContexts(@0x14002CFC0) adds a code path selected whenSecData+0x510bit 0 is clear. On this path the function walks the table under the shared lock, callsSecReferenceProcessContexton every entry to pin it, stores the pinned entry pointers into a pool buffer sized from the entry count at[SecProcessTable+0x148](tag'Scce'), releases the shared lock, and only then invokes the callback on each pinned entry, callingSecReleaseProcessContextafter each. The old in-lock traversal is preserved and runs only when the bit is set. - Attacker-Reachable Entry Point: The enumerator is invoked from the driver-configuration/policy-apply paths (
SecSetDriverConfiguration) and from process create/exit and timer maintenance. The configuration path is reachable through the\MicrosoftSecFilterControlPortminifilter communication port viaSecMessage, but sending policy-configuration messages to that port normally requires the privileged management service; an unprivileged attacker is not shown to reach it. - Data Flow (a reachable chain):
SecMessage(the port message handler) dispatches a configuration message toSecSetDriverConfiguration.SecSetDriverConfigurationapplies policy and callsSecEnumerateProcessContextswith a per-process callback.SecEnumerateProcessContextsacquires the shared lock and, on the unpatched build, invokes the callback on each entry while the lock is held.
Finding 2: Timer/Notification-Driven Enumeration Race (Low)
- Vulnerability Class: Use-After-Free / Race Condition (CWE-416, CWE-362)
- Affected Function:
SecPerformProcessAssertionsForAllProcesses(unpatched @0x14002DD1C) - Root Cause: Same in-lock-traversal shape as Finding 1. Under the shared
SecProcessTablelock it iterates every bucket and, for each entry, callsPsLookupProcessByProcessIdon the entry's process id ([entry+0x20]), thenSecDetPerformProcessAssertionsWithContext, thenObfDereferenceObject, without pinning the entry across the work. - The Fix: The patched version (@
0x14002DF1C) adds the sameSecData+0x510bit-0 check; when the bit is clear (default) it delegates toSecEnumerateProcessContexts(the reference-and-snapshot path) instead of traversing under the lock. The old in-lock traversal is kept only when the bit is set. - Attacker-Reachable Entry Point: Autonomous. Called from the timer-driven maintenance routine
SecDetTimerPerformDeferredAssertionsImpl(@0x140042400). Process creation/termination populates and mutates the same table, so the race is driven by process-lifetime activity rather than a direct user-mode request.
3. Pseudocode Diff
High-level logic of the unpatched SecEnumerateProcessContexts and its patched replacement, focusing on the traversal.
// UNPATCHED: SecEnumerateProcessContexts @ 0x14002CF04
void SecEnumerateProcessContexts(callback_t callback, void* context) {
if (!(SecData->flags_4f8 & 4)) return; // not initialized
FltAcquirePushLockSharedEx(SecProcessTable + 8, 0); // SHARED lock
for (bucket = 0; bucket < 0x80; bucket++) {
node = SecProcessTable->buckets[bucket]; // [SecProcessTable+0x140]
while (node != sentinel) {
entry = node - 0x10; // process-context object
node = *node; // advance BEFORE callback
callback(entry, context); // *** callback WHILE lock held,
// entry NOT reference-pinned ***
}
}
FltReleasePushLockEx(SecProcessTable + 8, 0);
}
// PATCHED: SecEnumerateProcessContexts @ 0x14002CFC0
void SecEnumerateProcessContexts(callback_t callback, void* context) {
if (!(SecData->flags_4f8 & 4)) return;
FltAcquirePushLockSharedEx(SecProcessTable + 8, 0);
if (SecData[0x510] & 1) {
// Old in-lock path, retained; runs only when the feature bit is SET
// ... same callback-under-shared-lock traversal ...
FltReleasePushLockEx(SecProcessTable + 8, 0);
return;
}
// Default path (feature bit CLEAR): reference + snapshot, then release, then call
int count = SecProcessTable->entry_count; // [SecProcessTable+0x148]
if (count <= 0) { FltReleasePushLockEx(SecProcessTable + 8, 0); return; }
void** snap = ExAllocatePoolWithTag(PagedPool, count * 8, 'Scce');
if (!snap) { FltReleasePushLockEx(SecProcessTable + 8, 0); return; }
int n = 0;
for (bucket = 0; bucket < 0x80; bucket++) {
node = SecProcessTable->buckets[bucket];
while (node != sentinel) {
entry = node - 0x10;
SecReferenceProcessContext(entry); // pin each entry
snap[n++] = entry;
node = *node;
}
}
FltReleasePushLockEx(SecProcessTable + 8, 0); // release BEFORE callbacks
for (int i = 0; i < n; i++) {
callback(snap[i], context); // safe: entries are pinned
SecReleaseProcessContext(snap[i]);
}
SecFreePool(snap, 'Scce');
}
4. Assembly Analysis
Unpatched SecEnumerateProcessContexts, showing the callback invoked while the shared lock is held with no reference on the entry.
; ---- SecEnumerateProcessContexts @ 0x14002CF04 (unpatched) ----
000000014002CF29 mov r8d, [rax+4F8h]
000000014002CF30 test r8b, 4 ; initialization flag
000000014002CF34 jz short loc_14002CFAF
000000014002CF36 mov rcx, cs:SecProcessTable
000000014002CF3F add rcx, 8
000000014002CF43 call FltAcquirePushLockSharedEx_0 ; SHARED lock — window opens
000000014002CF53 mov ebp, 80h ; 128 buckets
000000014002CF58 mov r8, [rax+140h] ; bucket array base
000000014002CF5F mov rsi, [rbx+r8] ; node = bucket head
000000014002CF65 lea rcx, [rsi-10h] ; entry = node-0x10 (arg1)
000000014002CF69 mov rdx, r14 ; context (arg2)
000000014002CF6C mov rsi, [rsi] ; advance node = node->next
000000014002CF6F mov rax, r15 ; callback pointer
000000014002CF72 call cs:__guard_dispatch_icall_fptr ; callback(entry, context)
; *** invoked under SHARED lock,
; entry not reference-pinned ***
000000014002CF78 mov rax, cs:SecProcessTable
000000014002CF7F mov r8, [rax+140h]
000000014002CF86 lea rax, [rdi+r8]
000000014002CF8A cmp rsi, rax
000000014002CF8D jnz short loc_14002CF65
000000014002CF97 sub rbp, 1
000000014002CF9B jnz short loc_14002CF5F ; next bucket
000000014002CFAA call FltReleasePushLockEx_0 ; window closes
The patched reference-and-snapshot path (default) collects and pins entries, then releases the lock before the callbacks:
; ---- SecEnumerateProcessContexts @ 0x14002CFC0 (patched), snapshot path ----
000000014002D013 test byte ptr cs:xmmword_14001B740+8, 1 ; SecData+0x510 bit 0
000000014002D01A jz short loc_14002D086 ; bit CLEAR -> snapshot path (default)
; bit SET -> old in-lock path
000000014002D08D movsxd rax, dword ptr [rcx+148h] ; entry count
000000014002D0C9 call cs:__imp_ExAllocatePoolWithTag ; tag 'Scce' (65636353h)
000000014002D121 call SecReferenceProcessContext ; pin each entry under lock
000000014002D12C mov [rsi+rax*8], rbx ; store pinned entry in snapshot
000000014002D166 call FltReleasePushLockEx_0 ; release BEFORE callbacks
000000014002D189 call cs:__guard_dispatch_icall_fptr ; callback on pinned entry
000000014002D192 call SecReleaseProcessContext ; release reference
000000014002D1A9 call SecFreePool ; free snapshot buffer (tag 'Scce')
5. Trigger Conditions
For Finding 1 the following conditions must coincide:
- The minifilter is loaded and the process-context table has entries (populated as processes are tracked via
SecCreateProcessContext). - An enumeration is in progress: a policy/configuration apply (
SecSetDriverConfiguration→SecEnumerateProcessContexts), a process create/exit notification, or timer maintenance drives a traversal that invokes callbacks on each entry. - Concurrently, the entry currently being enumerated has its last reference dropped — either by the callback itself or by a concurrent teardown (
SecCleanupProcessContexts, which unlinks entries and releases them) — so the entry is freed or recycled to the process-context lookaside list while enumeration still holds a raw pointer to it. - The freed/recycled entry (or its stale next pointer) is then used by the callback or the following loop iteration.
Observable effect: a dereference of freed or recycled pool memory in kernel context. Depending on timing and pool state this manifests as a kernel bugcheck (denial of service); a controllable memory-corruption primitive is not demonstrated by the diff.
6. Impact Assessment
- Primitive: Kernel-pool use-after-free of a process-context object during shared-lock enumeration. The object is an internal, reference-counted structure managed by
SecReferenceProcessContext/SecReleaseProcessContextand recycled through the process-context lookaside list atSecProcessTable+0xC0. - Demonstrable impact: Loss of memory safety in kernel mode during enumeration — a potential denial of service (bugcheck) and, in principle, a local elevation-of-privilege surface if the freed allocation can be reclaimed with attacker-influenced contents. The reclamation and any control-flow or write primitive are not demonstrated by this diff and are not claimed here.
- Why the fix matters: By pinning every entry with a reference and moving all callbacks outside the lock, the patched path guarantees that no entry can be freed while a callback is operating on it, closing the window regardless of what the callback does.
7. Debugger Notes
For examination of the unpatched binary:
Breakpoints
bp mssecflt_unpatched!SecEnumerateProcessContexts+0x3f ; 0x14002cf43 FltAcquirePushLockSharedEx
bp mssecflt_unpatched!SecEnumerateProcessContexts+0x68 ; 0x14002cf6c mov rsi,[rsi] (advance)
bp mssecflt_unpatched!SecEnumerateProcessContexts+0x6e ; 0x14002cf72 callback dispatch
bp mssecflt_unpatched!SecCleanupProcessContexts ; 0x14002c3a4 entry teardown
Why these matter:
- +0x3f — acquisition of the shared lock; start of the enumeration window.
- +0x68 — the next-pointer read; if rsi refers to freed/recycled pool the traversal is following a stale node.
- +0x6e — the CFG-dispatched callback on the entry at rcx = node-0x10; the entry is not reference-pinned here.
- SecCleanupProcessContexts — unlinks and releases process-context entries; if it runs concurrently and drops the last reference on the enumerated entry, the pointer held by the enumerator becomes stale.
Watch values
r15(callback pointer) andr14(callback context) captured at entry.r8at0x14002cf58= bucket array base ([SecProcessTable+0x140]).rsiat0x14002cf65= current list node;rcxat0x14002cf72= entry object (node-0x10).[SecData+0x4f8]bit 2 = initialization flag gating the traversal.- Patched only:
[SecProcessTable+0x148]= entry count used to size the snapshot;[SecData+0x510]bit 0 = path selector (clear = snapshot, set = old path).
8. Changed Functions — Full Triage
Security-relevant fixes:
- SecEnumerateProcessContexts (unpatched 0x14002CF04 → patched 0x14002CFC0, Sim 0.1295): adds the default reference-and-snapshot path (pin each entry, release the shared lock, then invoke callbacks and release references). Old in-lock callback path retained behind the SecData+0x510 bit-0 feature bit.
- SecPerformProcessAssertionsForAllProcesses (unpatched 0x14002DD1C → patched 0x14002DF1C, Sim 0.8106): adds the same feature-bit check; delegates to SecEnumerateProcessContexts (snapshot path) when the bit is clear (default), old in-lock traversal retained when set.
Supporting changes:
- SecCleanupProcessContexts (0x14002C3A4, Sim 0.9898): decrements the entry count at [SecProcessTable+0x148] on removal, keeping the count the snapshot path uses for allocation sizing accurate.
- SecCreateProcessContext (0x14002C550, Sim 0.9923): increments the entry count at [SecProcessTable+0x148] on insertion; the insertion feature-flag test is updated (bit 2 → bit 4).
- SecInitializeBootConfig / boot-config population (report-listed as sub_140029EF0, Sim 0.8862): extends the feature-flag block copied into SecData+0x508 from a single 8-byte value to two 8-byte values (16 bytes, zero-initialized then optionally loaded from the BootFeatureFlags registry binary value), so the second qword — which holds the SecData+0x510 bit-0 path selector — is now read.
Feature-staging / servicing churn (not security fixes):
- sub_14003BC14 (Sim 0.6076): adds a SeGetCachedSigningLevel fast path gated by a feature bit in xmmword_14001B740, plus a speculation barrier before the second query. Feature-gated; old path retained.
- sub_14003BA6C (Sim 0.7844): adds a signing-level check path gated by SecData+0x510 bit 2.
- sub_14002F788 (Sim 0.9677): adds an early-exit path gated by SecData+0x510 bit 4.
- sub_140032D20 (Sim 0.8544): dispatch-table handlers now capture return values.
- sub_14004054C (Sim 0.9084): widens a feature-flag early-exit test (adds a bit-1 check).
- sub_14003F6CC (Sim 0.9438): adds a bit-1 feature check alongside the existing bit-0 check.
- sub_14003F9DC (Sim 0.9498): adds a bit-1 feature check; captures the argument into a register at entry.
- sub_14003EBD8 (Sim 0.8815): adds a feature-gated early-return condition.
- sub_140029EF0 behavioral extension is listed above under supporting changes.
Cosmetic / relocation changes:
- sub_140024910, sub_1400393F8 (version string 0x3f → 0x41), sub_14003C294 (feature-bit reordering), SecMessage (0x140021CA0), SecSetDriverConfiguration (0x14003A814, call targets updated to the patched enumerator), sub_14000AC40, sub_14002CEA4, sub_140039F98 (feature-word store widened to a 128-bit write), SecDetTimerPerformDeferredAssertionsImpl (0x140042400, call target updated to the patched SecPerformProcessAssertionsForAllProcesses), j_sub_14002f788, and sub_140029CE0 are address shifts, jump-thunk updates, and recompilation differences with no behavioral change.
9. Unmatched Functions
- Removed: None
- Added: None
10. Confidence & Caveats
- Confidence: High for the mechanism. The unpatched callback-under-shared-lock traversal and the patched reference-pin + snapshot + release-then-callback path are both present in the disassembly of
SecEnumerateProcessContexts, and the same treatment applies toSecPerformProcessAssertionsForAllProcesses. - Direction: The default (feature bit clear) path is the safe one; the old vulnerable path runs only when
SecData+0x510bit 0 is set. The feature block is zero-initialized and then optionally overwritten from a registry binary value, so the safe path is the default and the old path is a registry-controlled fallback. - Caveats:
- The severity is bounded by demonstrable impact: a kernel-mode use-after-free/race during enumeration. Reclaiming the freed allocation with controlled contents, and any resulting control-flow or write primitive, are not demonstrated by the diff and are not claimed.
- The policy-configuration entry point via
\MicrosoftSecFilterControlPortnormally requires the privileged management service; unprivileged reachability of that path is not established. The timer and process-notification paths (Finding 2) are autonomous and driven by process-lifetime activity.