mrxsmb.sys — defensive hardening of SMB-over-QUIC connection-parameter buffer handling
KB5074109
1. Overview
| Field | Value |
|---|---|
| Unpatched binary | mrxsmb_unpatched.sys |
| Patched binary | mrxsmb_patched.sys |
| Overall similarity | 0.9898 |
| Matched functions | 1230 |
| Changed functions | 6 (behavioural cluster); remaining textual diffs are data-symbol relocations |
| Identical functions | 1224 |
| Unmatched (unpatched → patched) | 0 / 0 |
Verdict: The patch removes the Feature_3679766840__private_IsEnabledDeviceUsageNoInline() feature-staging gate across a cluster of SMB-over-QUIC functions and, in SmbQuicInitiateAsynchronousConnect, replaces a buffer-reuse code path with an unconditional "query-first" call to MsQuicConnectionGetParam (NULL output buffer, zeroed stack-local size). This is a defensive/hardening refactor. The behaviour that the earlier draft framed as an exploitable stale-pointer write is not demonstrable against these binaries: the connection block is zero-initialized at allocation, and the cached buffer pointer/size fields are always kept consistent (allocated-and-sized together, freed-and-nulled together). No reachable memory-safety primitive was found, so no CWE/severity is assigned.
2. Change Summary
Finding 1 — SmbQuicInitiateAsynchronousConnect buffer handling (informational, no demonstrable vulnerability)
- Severity: None (defense-in-depth hardening)
- Function:
SmbQuicInitiateAsynchronousConnect(unpatched base0x14008F110, patched base0x14008F100) - Class: Refactor of an output-buffer allocation path; removal of a feature-staging gate
What the code actually does
SmbQuicInitiateAsynchronousConnect operates on a connection block passed in as *(a1+0x40) (call it blk; the same pointer is rbx in the disassembly). The block is allocated by SmbMmAllocateServerTransport via ExAllocatePool2(0x40, 664, 'SmCe') — pool flag 0x40 does not set the uninitialized bit, so the whole block, including the two fields of interest, is zero-filled on allocation.
Two fields matter:
- blk+0xE0 — a cached output buffer pointer (pool tag 'SmQu', 0x75516D53).
- blk+0xE8 — the ULONG size associated with that buffer.
The function queries a QUIC connection parameter (level 0x5000018) through the MsQuic dispatch table at vtable offset +0x20.
Unpatched. The first MsQuicConnectionGetParam call is gated by Feature_3679766840:
- Feature disabled (default): pass the block's cached buffer *(blk+0xE0) as the output buffer and blk+0xE8 as the size in/out pointer. On the very first connect these are 0/0, so MsQuic returns STATUS_BUFFER_TOO_SMALL without writing; the function then allocates a 'SmQu' buffer of the returned size, stores it into blk+0xE0, and retries. This is a buffer-reuse path: a buffer allocated on one pass can be reused on a later pass.
- Feature enabled: pass a NULL output buffer and a zeroed stack-local size, then allocate and retry.
Patched. The feature gate is gone. The first call is always made with a NULL output buffer and a zeroed stack-local size, the function allocates a fresh 'SmQu' buffer of the returned size, calls again with that fresh buffer, and only then stores the buffer/size into blk+0xE0/blk+0xE8. The cached buffer is never handed to the API before validation.
Why this is not a demonstrable vulnerability
The earlier framing was that *(blk+0xE0) could be a stale/freed/uninitialized pointer that MsQuic would then write into. That premise is not supported by the binaries:
- Zero-initialized block. The block comes from
ExAllocatePool2(0x40, 664, 'SmCe')(no uninitialized flag), soblk+0xE0andblk+0xE8are0on first use. A NULL buffer / zero size yieldsSTATUS_BUFFER_TOO_SMALLwith no write — identical outcome to the patched query-first path. - Buffer and size stay consistent. The only writes to
blk+0xE0set it either to a freshly allocated buffer (withblk+0xE8set to its size) or to0(withblk+0xE8set to0on the failure path). There is no path that leavesblk+0xE0non-NULL with an inconsistent size. - Every free nulls the field. The
'SmQu'buffer atblk+0xE0is freed in two places — the local failure path (ExFreePoolWithTagimmediately followed byblk+0xE0 = 0,blk+0xE8 = 0) andSmbQuicTearDown(ExFreePoolWithTagfollowed by amemsetthat zeroes the field). No free leaves a dangling pointer inblk+0xE0.
No code path was found that re-enters this function on a block whose blk+0xE0 holds a freed pointer, and the block is not observed to be used uninitialized. The patch therefore reads as defense-in-depth: it stops trusting a stored output-buffer pointer and always queries the size first with a NULL buffer.
3. Decompilation Diff
// ===== UNPATCHED: SmbQuicInitiateAsynchronousConnect (feature-gated) =====
// blk = *(a1 + 0x40); v15 -> connection handle at blk+0x70
Feature_3679766840__private_IsEnabledDeviceUsageNoInline();
v43 = 0; // stack-local size
v25 = nullptr;
IsEnabled = Feature_3679766840__private_IsEnabledDeviceUsageNoInline();
v27 = *v15; // connection handle
v29 = *(GetParam)(MsQuic + 32);
if ( IsEnabled == 0 ) { // default: reuse cached buffer/size
v31 = (unsigned int *)(blk + 232); // &blk[0xE8]
v30 = v29(v27, 83886104, blk + 232, *(_QWORD *)(blk + 224)); // cached buffer *(blk+0xE0)
} else { // feature on: query-first
v30 = v29(v27, 83886104, (__int64)&v43, 0); // NULL buffer, stack size
v31 = (unsigned int *)(blk + 232);
}
if ( v30 == 0xC0000023 /*STATUS_BUFFER_TOO_SMALL*/ ) {
if ( Feature_3679766840...() != 0 ) {
v33 = ExAllocatePool2(66, v43, 0x75516D53); // stack size
...
} else {
v38 = ExAllocatePool2(66, *v31, 0x75516D53); // blk[0xE8] size
*(_QWORD *)(blk + 224) = v38; // store buffer
...
}
v39 = (*GetParam)(*v15, 83886104, v37, v36); // retry with buffer
}
// ===== PATCHED: unconditional query-first =====
v31 = 0; // stack-local size
v22 = (*GetParam)(*v12, 83886104, &v31, 0); // NULL buffer, stack size
if ( v22 == 0xC0000023 /*STATUS_BUFFER_TOO_SMALL*/ ) {
v23 = ExAllocatePool2(66, v31, 0x75516D53); // fresh allocation
if ( v23 == 0 ) goto fail;
v25 = (*GetParam)(*v12, 83886104, &v31, v23); // retry with fresh buffer
if ( v25 >= 0 ) {
*(_QWORD *)(v1 + 224) = v24; // store buffer *after* success
*(_DWORD *)(v1 + 232) = v31; // store size
v31 = 0;
} else {
ExFreePoolWithTag(v24, 0x75516D53);
}
}
Difference: the unpatched default branch reuses the stored buffer *(blk+0xE0) on the first call; the patched code never does — it always queries with a NULL buffer and a stack-local size, allocates fresh, and stores into blk+0xE0/blk+0xE8 only after a successful second call. The Feature_3679766840 gate is removed.
4. Assembly Analysis
Unpatched SmbQuicInitiateAsynchronousConnect — first MsQuicConnectionGetParam call
000000014008FA07 call Feature_3679766840__private_IsEnabledDeviceUsageNoInline
000000014008FA0C and [rsp+78h+arg_0], 0 ; stack-local size = 0
000000014008FA16 call Feature_3679766840__private_IsEnabledDeviceUsageNoInline
000000014008FA1B mov rcx, [r12] ; rcx = connection handle
000000014008FA1F test eax, eax ; feature enabled?
000000014008FA21 mov rax, cs:MsQuic
000000014008FA28 mov r15d, 5000018h ; QUIC param level
000000014008FA2E mov edx, r15d
000000014008FA31 mov rax, [rax+20h] ; MsQuic vtable +0x20 = GetParam
000000014008FA35 jz short loc_14008FA50 ; feature OFF -> reuse cached buffer
000000014008FA37 xor r9d, r9d ; feature ON: r9 = NULL buffer
000000014008FA3A lea r8, [rsp+78h+arg_0] ; r8 = &stack size
000000014008FA42 call _guard_dispatch_icall ; GetParam(handle, 0x5000018, &stacksize, NULL)
000000014008FA47 lea r14, [rbx+0E8h]
000000014008FA4E jmp short loc_14008FA66
000000014008FA50 mov r9, [rbx+0E0h] ; feature OFF: r9 = cached buffer *(blk+0xE0)
000000014008FA57 lea r14, [rbx+0E8h] ; r14 = &blk[0xE8]
000000014008FA5E mov r8, r14 ; r8 = &blk[0xE8] (size in/out)
000000014008FA61 call _guard_dispatch_icall ; GetParam(handle, 0x5000018, &blk[0xE8], cached buffer)
000000014008FA66 mov r9d, eax
000000014008FA69 cmp eax, 0C0000023h ; STATUS_BUFFER_TOO_SMALL?
000000014008FA6E jnz loc_14008FC20
000000014008FA74 call Feature_3679766840__private_IsEnabledDeviceUsageNoInline
000000014008FA79 mov ebp, 75516D53h ; pool tag 'SmQu'
000000014008FA7E mov ecx, 42h ; POOL_FLAG_NON_PAGED | POOL_FLAG_UNINITIALIZED
000000014008FA83 mov r8d, ebp
000000014008FA86 test eax, eax
000000014008FA88 jz short loc_14008FAF6 ; feature OFF -> alloc using blk[0xE8] size
000000014008FA8A mov edx, [rsp+78h+arg_0] ; feature ON: size from stack local
000000014008FA91 call cs:__imp_ExAllocatePool2
; ...
000000014008FAF6 mov edx, [r14] ; feature OFF: size = *(blk+0xE8) (set by MsQuic)
000000014008FAF9 call cs:__imp_ExAllocatePool2
000000014008FB05 mov [rbx+0E0h], rax ; store fresh buffer into blk[0xE0]
; ... retry GetParam with the fresh buffer ...
Patched SmbQuicInitiateAsynchronousConnect — unconditional query-first
000000014008F923 and dword ptr [rbx+0A8h], 0
000000014008F92A lea r8, [rsp+78h+arg_0] ; r8 = &stack size
000000014008F932 and [rsp+78h+arg_0], 0 ; stack size = 0
000000014008F93A mov ebp, 5000018h ; QUIC param level
000000014008F93F mov rax, cs:MsQuic
000000014008F946 xor r9d, r9d ; r9 = NULL buffer (always)
000000014008F949 mov rcx, [r15] ; connection handle
000000014008F94C mov edx, ebp
000000014008F953 mov rax, [rax+20h] ; GetParam
000000014008F957 call _guard_dispatch_icall ; GetParam(handle, 0x5000018, &stacksize, NULL)
000000014008F95F cmp eax, 0C0000023h ; STATUS_BUFFER_TOO_SMALL?
000000014008F964 jnz loc_14008FA6D
000000014008F96A mov edx, [rsp+78h+arg_0] ; size from stack local
000000014008F971 mov r12d, 75516D53h ; pool tag 'SmQu'
000000014008F97A mov ecx, 42h
000000014008F97F call cs:__imp_ExAllocatePool2 ; fresh allocation
; ...
000000014008F9D1 mov rax, cs:MsQuic
000000014008F9D8 lea r8, [rsp+78h+arg_0]
000000014008F9E3 mov r9, rsi ; r9 = fresh buffer
000000014008F9EC call _guard_dispatch_icall ; retry GetParam with fresh buffer
; ...
000000014008FA4C mov [rbx+0E0h], rsi ; store buffer *after* success
000000014008FA5A mov [rbx+0E8h], eax ; store size
Diff takeaway: The unpatched default branch loads the cached buffer pointer from blk+0xE0 and hands it to MsQuic on the first call; the patched code always uses r9 = NULL with a zeroed stack-local size, allocates a fresh buffer, and stores the buffer/size only after the retry succeeds. The Feature_3679766840 A/B gate is removed entirely (the function itself is no longer present in the patched binary).
5. Reachability
SmbQuicInitiateAsynchronousConnect is reached when a user-mode operation resolves a UNC path bound to SMB-over-QUIC transport, routed through RDBSS/mrxsmb.sys connection establishment (RxCeInitiateConnectRequest → per-transport callout → SmbQuicInitiateAsynchronousConnect). The connection block is freshly built and zero-allocated per connect callout (RxCeBuildConnectionOverMultipleTransports / RxCepBuildConnectCalloutsForTdi → SmbMmAllocateServerTransport).
The parameter data that MsQuicConnectionGetParam(level=0x5000018) returns is influenced by the QUIC peer (the SMB server). In both builds the buffer that receives this data is a correctly sized 'SmQu' allocation obtained from the size MsQuic reports, so the write length matches the allocation. No attacker-controlled write-primitive was identified in either build.
6. Exploit Assessment
No exploit primitive is demonstrable. The output buffer handed to MsQuicConnectionGetParam is, in both builds, a heap allocation sized to the value MsQuic returns for parameter 0x5000018; the write stays within that allocation. In the unpatched default path the first call may pass the block's cached blk+0xE0 buffer, but that field is either 0 (fresh, zero-initialized block) or a valid buffer whose recorded size matches, and it is nulled whenever the buffer is freed. There is no reachable stale/dangling/uninitialized pointer, no attacker-controlled length/target mismatch, and therefore no pool-overflow or use-after-free primitive to develop.
7. Debugger Notes
For observing the change on the unpatched target:
bp mrxsmb!SmbQuicInitiateAsynchronousConnect ; entry; blk = *(rcx+0x40)
bp mrxsmb!SmbQuicInitiateAsynchronousConnect+0x940 ; 0x14008FA50 - feature-OFF: mov r9,[rbx+0xE0]
bp mrxsmb!SmbQuicInitiateAsynchronousConnect+0x951 ; 0x14008FA61 - GetParam (feature-OFF path)
bp mrxsmb!SmbQuicInitiateAsynchronousConnect+0x959 ; 0x14008FA69 - STATUS_BUFFER_TOO_SMALL compare
What to inspect:
- At 0x14008FA50, dq @rbx+0xE0 l1 and dd @rbx+0xE8 l1. On a fresh connect these are 0/0, so the call at 0x14008FA61 receives a NULL buffer and returns STATUS_BUFFER_TOO_SMALL without writing.
- Register contract at the 0x14008FA61 indirect call: rcx = connection handle (*(blk+0x70)), edx = 0x5000018, r8 = &blk[0xE8] (size in/out), r9 = cached buffer pointer.
- On the patched target the equivalent site (0x14008F957) always shows r9 = 0 and r8 = &[rsp+...] (a stack local); there is no load of a cached buffer before the first call.
Struct/offset notes for SmbQuicInitiateAsynchronousConnect:
- Connection block blk = *(a1+0x40) — pool tag 'SmCe' (0x65436D53), allocated by SmbMmAllocateServerTransport via ExAllocatePool2(0x40, 664, ...) (zero-initialized).
- blk+0x70 — MsQuic connection handle.
- blk+0xE0 — cached output buffer pointer for MsQuicConnectionGetParam (pool tag 'SmQu', 0x75516D53).
- blk+0xE8 — output size (ULONG) for the same call.
- MsQuic dispatch table +0x20 slot — MsQuicConnectionGetParam; parameter level 0x5000018.
8. Changed Functions — Triage
| Function | Type | Note |
|---|---|---|
SmbQuicInitiateAsynchronousConnect |
behavioural / hardening | Removes the Feature_3679766840 gate; replaces the cached-buffer-reuse path with an unconditional query-first (NULL buffer, stack-local size) call. Not a demonstrable vulnerability (see §2). |
SmbQuicDisconnectEvent |
behavioural | Removes Feature_3679766840 gating of ETW/WPP tracing; WPP GUID refresh (WPP_29dfa564… → WPP_204de8b894eb30a604d55fc295d80353). Not security-relevant. |
SmbQuicConnEventPeerCertificateReceived |
behavioural | Feature-gate removal / ETW tracing unification; WPP GUID refresh. Not security-relevant. |
SmbQuicAsynchronousConnectCompletion |
behavioural | Feature-gate removal / ETW tracing unification. Not security-relevant. |
RxCeCompleteConnectRequest |
behavioural | Feature-gate removal / ETW tracing unification; rundown-protection release accounting simplified alongside the gate removal. Signature-type inference differences are decompiler artifacts. Not security-relevant. |
DriverEntry |
cosmetic | WPP GUID updates; minor address shifts. Not security-relevant. |
Independent whole-binary diff. Comparing the two builds function-by-function (matching by content across relocations) shows the behavioural changes are confined to the SMB-over-QUIC cluster above, all driven by removing two feature-staging gates (Feature_3679766840… and Feature_Servicing_EnableSMBHardeningTelemetry…) plus WPP GUID refreshes. The gate functions themselves and their WIL feature-staging infrastructure (wil_details_*, wil_InitializeFeatureStaging) are removed/updated as a consequence. Telemetry helpers (EEL_*, EdpEnforcementLog_SmbAccessDenied) and WPP SOCKADDR trace helpers changed as part of the same servicing/telemetry churn. Every other function that differs textually (SmbWskReceiveEvent, VctIndReceive, VctIndDataReady, MRxSmbDevFcbXXXControlFile, SeEncodeSamClaimsBlob/SeEncodeSamClaimsSet, SmbMmGetMaxBufferLength, SmbCeCompleteVNetRootContextInitialization, RxSmbdGetSMBDirectServicePath, MRxSmbGetStatistics, WitnessUpcall*) differs only by relocated global-data symbol addresses (e.g. dword_1400666F8 → dword_1400666E8) or statistics-counter layout — identical logic, no behavioural change. No security-relevant change was omitted from this report.
9. Unmatched Functions
- Added: none.
- Removed (relative to the QUIC cluster): the
Feature_3679766840…andFeature_Servicing_EnableSMBHardeningTelemetry…staging-gate helpers are no longer present. No sanitizer or bounds check was removed; no new mitigation function was added.
10. Confidence
Confidence: High that the described change exists as decoded above, and high that it is a defensive/hardening refactor rather than a fix for a demonstrable memory-safety flaw. The connection block is zero-initialized, the blk+0xE0/blk+0xE8 buffer/size pair is kept consistent, and every free of the buffer nulls the field, so no stale/dangling/uninitialized pointer reaches the MsQuicConnectionGetParam call in the unpatched build. The remaining changed functions are feature-staging/telemetry/WPP servicing churn and data-symbol relocations with no security impact.