From c10b372a2d5064c7d87efc42fb514422797047d7 Mon Sep 17 00:00:00 2001 From: Radek Zikmund Date: Thu, 9 Apr 2026 09:44:22 +0200 Subject: [PATCH 1/4] Work around Schannel TLS resume disable race on older Windows On Windows Server 2022 (build 20348) and older, ApplyControlToken with SSL_SESSION_DISABLE_RECONNECTS races with Schannel's internal session cache. ISC's LookupCacheByName finds a fresh resumable entry and embeds the session ID in the ClientHello before ApplyControlToken can expire it. Work around by following the pattern used by Schannel's own webcli.c test and http.sys: after ApplyControlToken, delete the security context and retry InitializeSecurityContext with a null context so the new ClientHello is generated without a stale session ID. The workaround is conditioned on build < 22000 (pre-Windows 11), since newer Schannel builds correctly prevent cache population when ApplyControlToken is used. Also re-enables the ClientDisableTlsResume_Succeeds test that was disabled due to this issue. Fixes #103449 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Net/Security/SslStreamPal.Windows.cs | 50 ++++++++++++++++++- .../SslStreamAllowTlsResumeTests.cs | 1 - 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.Windows.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.Windows.cs index aecaa2feca9e07..fecf03e3ab2eeb 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.Windows.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.Windows.cs @@ -26,6 +26,15 @@ internal static class SslStreamPal // API is supported since Windows 10 1809 (17763) but there is no reason to use at the moment. Environment.OSVersion.Version.Major >= 10 && Environment.OSVersion.Version.Build >= 18836; + // On Windows Server 2022 (build 20348) and older, Schannel has a race condition where + // ApplyControlToken(SSL_SESSION_DISABLE_RECONNECTS) doesn't reliably prevent the session + // cache from being repopulated. The workaround is to delete the context and retry + // InitializeSecurityContext after ApplyControlToken. This follows the same pattern used by + // Schannel's own webcli.c test and http.sys. The issue was fixed in newer Schannel builds + // shipping with Windows 11+ (build 22000+). + private static readonly bool NeedsDisableTlsResumeWorkaround = + Environment.OSVersion.Version.Build < 22000; + private const string SecurityPackage = "Microsoft Unified Security Protocol Provider"; private const Interop.SspiCli.ContextFlags RequiredFlags = @@ -206,11 +215,50 @@ public static ProtocolToken InitializeSecurityContext( ref context, in securityBuffer)); - if (result.ErrorCode != SecurityStatusPalErrorCode.OK) { token.Status = result; } + else if (NeedsDisableTlsResumeWorkaround) + { + // On affected builds, Schannel's internal LookupCacheByName finds a fresh + // resumable entry and embeds the session ID in the ClientHello before + // ApplyControlToken can expire it. Deleting the context and retrying ISC + // ensures the new ClientHello is generated without a stale session ID. + context?.Dispose(); + context = null; + token.ReleasePayload(); + token = default; + token.RentBuffer = true; + + scoped InputSecurityBuffers retryInputBuffers = default; + retryInputBuffers.SetNextBuffer(new InputSecurityBuffer(inputBuffer, SecurityBufferType.SECBUFFER_TOKEN)); + retryInputBuffers.SetNextBuffer(new InputSecurityBuffer(default, SecurityBufferType.SECBUFFER_EMPTY)); + if (sslAuthenticationOptions.ApplicationProtocols is { Count: > 0 }) + { + Span retryLocalBuffer = stackalloc byte[64]; + SetAlpn(ref retryInputBuffers, sslAuthenticationOptions.ApplicationProtocols, retryLocalBuffer); + } + + errorCode = SSPIWrapper.InitializeSecurityContext( + GlobalSSPI.SSPISecureChannel, + ref credentialsHandle, + ref context, + targetName, + RequiredFlags | Interop.SspiCli.ContextFlags.InitManualCredValidation, + Interop.SspiCli.Endianness.SECURITY_NATIVE_DREP, + ref retryInputBuffers, + ref token, + ref unusedAttributes); + + token.Status = SecurityStatusAdapterPal.GetSecurityStatusPalFromNativeInt(errorCode); + + consumed = inputBuffer.Length; + if (retryInputBuffers._item1.Type == SecurityBufferType.SECBUFFER_EXTRA) + { + consumed -= retryInputBuffers._item1.Token.Length; + } + } } return token; diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs index 54031f0224d717..34cb3b50901d12 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs @@ -34,7 +34,6 @@ private bool CheckResumeFlag(SslStream ssl) [ConditionalTheory] [InlineData(true)] [InlineData(false)] - [ActiveIssue("https://github.com/dotnet/runtime/issues/103449", TestPlatforms.Windows)] public async Task ClientDisableTlsResume_Succeeds(bool testClient) { SslServerAuthenticationOptions serverOptions = new SslServerAuthenticationOptions From a710b74026c41f10fc363df7447ba57a203bca3f Mon Sep 17 00:00:00 2001 From: Radek Zikmund Date: Thu, 9 Apr 2026 09:58:25 +0200 Subject: [PATCH 2/4] Simplify workaround: reuse inputBuffers, share consumed calculation Move the ApplyControlToken/workaround block before the consumed/ SECBUFFER_EXTRA check so both the normal and retry paths share it. Reuse the original inputBuffers for the retry since this only runs on the very first ISC call (newContext == true) where the input is empty and the buffers are unmodified. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Net/Security/SslStreamPal.Windows.cs | 33 ++++++------------- 1 file changed, 10 insertions(+), 23 deletions(-) diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.Windows.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.Windows.cs index fecf03e3ab2eeb..77d4de1bec6c4c 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.Windows.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.Windows.cs @@ -197,13 +197,6 @@ public static ProtocolToken InitializeSecurityContext( token.Status = SecurityStatusAdapterPal.GetSecurityStatusPalFromNativeInt(errorCode); - consumed = inputBuffer.Length; - if (inputBuffers._item1.Type == SecurityBufferType.SECBUFFER_EXTRA) - { - // not all data were consumed - consumed -= inputBuffers._item1.Token.Length; - } - bool allowTlsResume = sslAuthenticationOptions.AllowTlsResume && !LocalAppContextSwitches.DisableTlsResume; if (!allowTlsResume && newContext && context != null) @@ -225,21 +218,14 @@ public static ProtocolToken InitializeSecurityContext( // resumable entry and embeds the session ID in the ClientHello before // ApplyControlToken can expire it. Deleting the context and retrying ISC // ensures the new ClientHello is generated without a stale session ID. + // We can reuse inputBuffers since this only runs on the very first ISC call + // (newContext == true) where the input is empty. context?.Dispose(); context = null; token.ReleasePayload(); token = default; token.RentBuffer = true; - scoped InputSecurityBuffers retryInputBuffers = default; - retryInputBuffers.SetNextBuffer(new InputSecurityBuffer(inputBuffer, SecurityBufferType.SECBUFFER_TOKEN)); - retryInputBuffers.SetNextBuffer(new InputSecurityBuffer(default, SecurityBufferType.SECBUFFER_EMPTY)); - if (sslAuthenticationOptions.ApplicationProtocols is { Count: > 0 }) - { - Span retryLocalBuffer = stackalloc byte[64]; - SetAlpn(ref retryInputBuffers, sslAuthenticationOptions.ApplicationProtocols, retryLocalBuffer); - } - errorCode = SSPIWrapper.InitializeSecurityContext( GlobalSSPI.SSPISecureChannel, ref credentialsHandle, @@ -247,20 +233,21 @@ public static ProtocolToken InitializeSecurityContext( targetName, RequiredFlags | Interop.SspiCli.ContextFlags.InitManualCredValidation, Interop.SspiCli.Endianness.SECURITY_NATIVE_DREP, - ref retryInputBuffers, + ref inputBuffers, ref token, ref unusedAttributes); token.Status = SecurityStatusAdapterPal.GetSecurityStatusPalFromNativeInt(errorCode); - - consumed = inputBuffer.Length; - if (retryInputBuffers._item1.Type == SecurityBufferType.SECBUFFER_EXTRA) - { - consumed -= retryInputBuffers._item1.Token.Length; - } } } + consumed = inputBuffer.Length; + if (inputBuffers._item1.Type == SecurityBufferType.SECBUFFER_EXTRA) + { + // not all data were consumed + consumed -= inputBuffers._item1.Token.Length; + } + return token; } From 74fef871c638eff68c0d5ff8a6a2e5e926caf450 Mon Sep 17 00:00:00 2001 From: Radek Zikmund <32671551+rzikm@users.noreply.github.com> Date: Thu, 9 Apr 2026 10:01:45 +0200 Subject: [PATCH 3/4] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../src/System/Net/Security/SslStreamPal.Windows.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.Windows.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.Windows.cs index 77d4de1bec6c4c..1a3fd533bf49a1 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.Windows.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.Windows.cs @@ -220,7 +220,7 @@ public static ProtocolToken InitializeSecurityContext( // ensures the new ClientHello is generated without a stale session ID. // We can reuse inputBuffers since this only runs on the very first ISC call // (newContext == true) where the input is empty. - context?.Dispose(); + context.Dispose(); context = null; token.ReleasePayload(); token = default; From f9460da578fb33ae8d7e2d9b305531f6d47d464b Mon Sep 17 00:00:00 2001 From: Radek Zikmund Date: Thu, 9 Apr 2026 14:13:33 +0200 Subject: [PATCH 4/4] Fix CS8602: use null-conditional on context after ref parameter ApplyControlToken takes 'ref context' which makes the compiler lose the null-state guarantee from the outer 'context != null' check. Use context?.Dispose() to satisfy nullable analysis. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/System/Net/Security/SslStreamPal.Windows.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.Windows.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.Windows.cs index 1a3fd533bf49a1..77d4de1bec6c4c 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.Windows.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.Windows.cs @@ -220,7 +220,7 @@ public static ProtocolToken InitializeSecurityContext( // ensures the new ClientHello is generated without a stale session ID. // We can reuse inputBuffers since this only runs on the very first ISC call // (newContext == true) where the input is empty. - context.Dispose(); + context?.Dispose(); context = null; token.ReleasePayload(); token = default;