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.