rassstp.sys ScmCmCloseCall VC handle UAF

SSTP (Secure Socket Tunneling Protocol) carries PPP over HTTPS, and in the kernel rassstp.sys implements it as an NDIS miniport call manager: it owns the SSTP-level state machines and presents the rest of RRAS with a call-manager interface that opens, closes, and routes data over SSTP virtual connections. Each VC is tracked by a MiniportVcContext block carrying the connection’s state, queues, and the embedded header/links used by the call-manager handle table. The NDIS call-manager API identifies VCs by call parameters: when a caller opens a VC, the call manager hands back a CallParams structure that includes a UserContext slot the caller uses to associate its own state with the VC, and when a later close, IOCTL, or indication names that VC the same CallParams is passed back and the call manager reads UserContext to find the per-VC block.

The lifetime invariant is the hard part. A VC can be torn down at any time — peer drops the SSTP tunnel, RRAS initiates cleanup, an IOCTL aborts the connection — and each path calls InitiateSstpContextCleanup and ultimately CleanupSstpContext, which frees the MiniportVcContext. A close or IOCTL arriving after the context has been freed (queued, replayed, or raced against cleanup) must not dereference the freed pointer. The safe identifier across that gap is an opaque handle the kernel can mark invalid at free time. The vulnerable build used the object’s raw address, embedded directly in CallParams->UserContext:

// Vulnerable identification -- old build,
// paraphrased across the five affected entry points.
//
// VC creation (ScmCmCreateVc + ProcessIncomingCallRequest): the call manager
// hands the raw MiniportVcContext pointer to NDIS via NdisMCmCreateVc, and
// NDIS retains it verbatim as ProtocolVcContext. That pointer IS the
// caller-visible identity of the VC.
//
// VC teardown (CleanupSstpContext):
ExFreePoolWithTag(vc, 'StCx');                  // (1) VC freed; NDIS still holds the pointer
//
// Close (ScmCmCloseCall) -- arg1 is the retained pointer, used unchecked:
KeAcquireSpinLockRaiseToDpc(&SstpGlobals->HandleTableLock /* +0x1f0 */);
old = arg1->RefCount /* +0x00 */;              // (2) UAF: arg1 may already be freed pool
if (old == 0) { KeReleaseSpinLock(...); return; }
cmpxchg(&arg1->RefCount /* +0x00 */, old, old + 1);
KeReleaseSpinLock(...);
KeAcquireSpinLockRaiseToDpc(&arg1->Lock /* +0x20 */);
arg1->StateFlags /* +0x1258 */ |= CLOSE_PENDING;
InitiateSstpContextCleanup(arg1, ...);         // (3) more derefs of arg1 follow

There is no indirection, no generation counter, no handle table. The caller is effectively holding the kernel object’s address as its identifier, and teardown frees the context through FreePool with no mechanism to mark the address as stale — so every CallParams that ever named the VC still carries that address, now pointing at freed pool. Close and IOCTL paths resolve the VC by reading UserContext and casting, with no validation that the pointer still names a live VC. A close that arrives after teardown (queue replay, racing thread that did not notice the cleanup) dereferences freed pool. The same shape affects the miniport VC context itself, which previously embedded a raw pointer to the VC instead of a handle — both surfaces open to the same UAF.

The replay and race windows are real. A close-vs-cleanup race — RRAS issuing a close while InitiateSstpContextCleanup runs on another thread — dereferences freed pool if cleanup wins. A queued IOCTL replay reads UserContext out of a CallParams whose pointer is now stale. And a remote peer that can drive the SSTP listener to tear a VC down with a crafted SSTP message, then send another request the kernel resolves through the now-stale CallParams, gets the UAF from the network side. The trigger model is both local (any caller of the IOCTL) and remote (any peer that can reach the RRAS/SSTP listener); on a default-config Windows Server with RRAS exposed, the remote path is the more serious. The MiniportVcContext allocation is a predictable size from the non-paged pool, so once freed an attacker who can drive same-size allocations into the pool reclaims the slot with a controlled object — the next close or IOCTL reads attacker-controlled data as the VC context, including any function pointer, list link, or length field the close/IOCTL path then uses. The direct EoP is a controlled function-pointer call off the VC context; a write through the stale pointer into a neighbouring allocation corrupts a length or refcount for a separate overrun primitive; without controlled reuse, the dereference of an allocator-reclaimed slot bugchecks the system.

The patch

The fix replaces the raw pointer with a 32-bit kernel-managed handle table. VC creation allocates a handle and stores it in both the miniport VC context and CallParams; close, IOCTL, and teardown paths resolve through the table under a spinlock and verify the resolved pointer matches the live VC:

// VC creation -- patched (ProcessIncomingCallRequest)
KeAcquireSpinLockRaiseToDpc(&SstpGlobals->HandleTableLock /* +0x1f0 */);
HfAllocateHandle32(SstpGlobals->HandleTable /* +0x1f8 */,
                   &vc->EmbeddedHdr /* +0x1e4 */,
                   &HandleOut);                          // (1) opaque 32-bit handle
vc->VcHandle /* +0x1290 */ = HandleOut;
KeReleaseSpinLock(...);
CallParams->UserContext = (void*)(ULONG_PTR)HandleOut;   // (2) embed handle, not ptr

// Close -- patched (ScmCmCloseCall)
if (arg3 != 0 && arg4 >= 4)
    CallerHandle = *arg3;                                // caller now passes a u32 handle
KeAcquireSpinLockRaiseToDpc(&SstpGlobals->HandleTableLock /* +0x1f0 */);
status = HfGetPointerFromHandle32(SstpGlobals->HandleTable /* +0x1f8 */,
                                  CallerHandle, &Resolved);
if (status < 0 || arg1 == 0 ||
    Resolved != &arg1->EmbeddedHdr /* +0x1e4 */)        // (3) verify it is THIS VC
    bail;                                                // (4) stale or replayed -> reject
old = arg1->RefCount /* +0x00 */;                       // safe: arg1 verified live
... operate on arg1 ...
KeReleaseSpinLock(...);

// Teardown -- patched (CleanupSstpContext)
KeAcquireSpinLockRaiseToDpc(&SstpGlobals->HandleTableLock /* +0x1f0 */);
if (vc->RefCount /* +0x00 */ != 0) { KeReleaseSpinLock(...); return; }
if (vc->VcHandle /* +0x1290 */ != 0)
    HfSuspendHandle32(SstpGlobals->HandleTable, vc->VcHandle);  // (5) future lookups fail
HfFreeHandle32(SstpGlobals->HandleTable, &vc->VcHandle);        // (6) slot reclaims
KeReleaseSpinLock(...);
ExFreePoolWithTag(vc, 'StCx');

The caller never sees the VC pointer; it sees a 32-bit opaque value it cannot forge. Close and IOCTL paths take the table spinlock, look up the handle, and get back either the live VC pointer or a failure status. Teardown suspends the handle (so it resolves to “not found” while cleanup is in progress) before freeing the slot, so an IOCTL or close arriving in the window between teardown start and free gets a clean rejection from HfGetPointerFromHandle32 rather than a stale pointer. Verifying that the resolved pointer matches &vc->EmbeddedHdr closes a further generation/reuse gap. The principle is that kernel objects with caller-visible identity are identified by handle, not address; a handle table with suspend and free gives a definitive “this handle is no longer valid” answer that a raw pointer cannot, and a single FreePool is the bug.

Attack path

sequenceDiagram
    participant U as caller, local IOCTL or remote SSTP peer
    participant C as CallParams buffer
    participant V as MiniportVcContext
    U->>V: open VC, vulnerable build embeds raw VC pointer in CallParams
    U->>V: trigger CleanupSstpContext, VC freed
    U->>C: replay or late close with old CallParams buffer
    C->>V: ScmCmCloseCall dereferences the raw pointer
    Note over V: use-after-free on freed pool

With the fix applied the late close resolves through the handle table, finds the handle suspended or freed, and bails before any dereference. The primitive — read or write through the stale pointer — is closed.