From 6167d604af9513e6bc35a5b3af73fd5c338531e6 Mon Sep 17 00:00:00 2001 From: David Shulman Date: Tue, 19 Feb 2019 09:14:44 -0800 Subject: [PATCH] Fix Negotiate/SPNEGO Kerberos to NTLM fallback on Linux (dotnet/corefx#35383) This PR adds support to our Linux gssapi layer so that we properly handle the Negotiate protocol (SPNEGO) being able to to fall back from Kerberos to NTLM. Windows Negotiate SSPI implements the SPNEGO protocol. However, it has built-in error handling for dealing with quick fallback from Kerberos to NTLM in error cases such as where the client is not on a domain/Kerberos realm, the client is using a local credential, the client is talking to a non-domain server, etc. However, the Linux implementation of SPNEGO doesn't handle those error cases very well. So, I added the proper retry logic when generating the initial context token. Now, we will be able to fall back from SPNEGO-wrapped Kerberos to SPNEGO-wrapped NTLM protocol blobs. A similar bug exists in libcurl which is why Curl and CurlHandler are unable to connect to Negotiate servers when not part of the Kerberos realm. I am considering proposing my fixes to the libcurl repo as well. I added more tests to support this PR and did manual testing. I added some additional EventSource tracing as well. Contributes to dotnet/corefx#34878 Commit migrated from https://github.com/dotnet/corefx/commit/1fd3ba126538c2fed76d2279db26dc7ba5ceed41 --- .../Interop.NetSecurityNative.cs | 11 ++-- .../Microsoft/Win32/SafeHandles/GssSafeHandles.cs | 8 +-- .../System/Net/Security/NegotiateStreamPal.Unix.cs | 49 ++++++++++---- .../Net/Security/Unix/SafeDeleteNegoContext.cs | 36 +++++++++-- .../Unix/System.Net.Security.Native/pal_gssapi.c | 75 +++++++++++++++------- .../Unix/System.Net.Security.Native/pal_gssapi.h | 7 +- .../HttpClientHandlerTest.Authentication.cs | 15 +++-- 7 files changed, 147 insertions(+), 54 deletions(-) diff --git a/src/libraries/Common/src/Interop/Unix/System.Net.Security.Native/Interop.NetSecurityNative.cs b/src/libraries/Common/src/Interop/Unix/System.Net.Security.Native/Interop.NetSecurityNative.cs index 249fbc3..cd50a20 100644 --- a/src/libraries/Common/src/Interop/Unix/System.Net.Security.Native/Interop.NetSecurityNative.cs +++ b/src/libraries/Common/src/Interop/Unix/System.Net.Security.Native/Interop.NetSecurityNative.cs @@ -36,11 +36,12 @@ internal static partial class Interop int inputNameByteCount, out SafeGssNameHandle outputName); - [DllImport(Interop.Libraries.NetSecurityNative, EntryPoint="NetSecurityNative_ImportPrincipalName")] - internal static extern Status ImportPrincipalName( + [DllImport(Interop.Libraries.NetSecurityNative, EntryPoint="NetSecurityNative_ImportTargetName")] + internal static extern Status ImportTargetName( out Status minorStatus, string inputName, int inputNameByteCount, + bool isNtlmTarget, out SafeGssNameHandle outputName); [DllImport(Interop.Libraries.NetSecurityNative, EntryPoint="NetSecurityNative_ReleaseName")] @@ -76,13 +77,15 @@ internal static partial class Interop bool isNtlmOnly, IntPtr cbt, int cbtSize, - SafeGssNameHandle targetName, + bool isNtlmFallback, + SafeGssNameHandle targetNameKerberos, + SafeGssNameHandle targetNameNtlm, uint reqFlags, byte[] inputBytes, int inputLength, ref GssBuffer token, out uint retFlags, - out int isNtlmUsed); + out bool isNtlmUsed); [DllImport(Interop.Libraries.NetSecurityNative, EntryPoint="NetSecurityNative_AcceptSecContext")] internal static extern Status AcceptSecContext( diff --git a/src/libraries/Common/src/Microsoft/Win32/SafeHandles/GssSafeHandles.cs b/src/libraries/Common/src/Microsoft/Win32/SafeHandles/GssSafeHandles.cs index 0a10038..f44ece6 100644 --- a/src/libraries/Common/src/Microsoft/Win32/SafeHandles/GssSafeHandles.cs +++ b/src/libraries/Common/src/Microsoft/Win32/SafeHandles/GssSafeHandles.cs @@ -31,13 +31,13 @@ namespace Microsoft.Win32.SafeHandles return retHandle; } - public static SafeGssNameHandle CreatePrincipal(string name) + public static SafeGssNameHandle CreateTarget(string name, bool isNtlmTarget) { - Debug.Assert(!string.IsNullOrEmpty(name), "Invalid principal passed to SafeGssNameHandle create"); + Debug.Assert(!string.IsNullOrEmpty(name), "Invalid target name passed to SafeGssNameHandle create"); SafeGssNameHandle retHandle; Interop.NetSecurityNative.Status minorStatus; - Interop.NetSecurityNative.Status status = Interop.NetSecurityNative.ImportPrincipalName( - out minorStatus, name, Encoding.UTF8.GetByteCount(name), out retHandle); + Interop.NetSecurityNative.Status status = Interop.NetSecurityNative.ImportTargetName( + out minorStatus, name, Encoding.UTF8.GetByteCount(name), isNtlmTarget, out retHandle); if (status != Interop.NetSecurityNative.Status.GSS_S_COMPLETE) { diff --git a/src/libraries/Common/src/System/Net/Security/NegotiateStreamPal.Unix.cs b/src/libraries/Common/src/System/Net/Security/NegotiateStreamPal.Unix.cs index 6981f28..77c58b3 100644 --- a/src/libraries/Common/src/System/Net/Security/NegotiateStreamPal.Unix.cs +++ b/src/libraries/Common/src/System/Net/Security/NegotiateStreamPal.Unix.cs @@ -99,12 +99,14 @@ namespace System.Net.Security SafeGssCredHandle credential, bool isNtlm, ChannelBinding channelBinding, - SafeGssNameHandle targetName, + bool isNtlmFallback, + SafeGssNameHandle targetNameKerberos, + SafeGssNameHandle targetNameNtlm, Interop.NetSecurityNative.GssFlags inFlags, byte[] buffer, out byte[] outputBuffer, out uint outFlags, - out int isNtlmUsed) + out bool isNtlmUsed) { // If a TLS channel binding token (cbt) is available then get the pointer // to the application specific data. @@ -141,7 +143,9 @@ namespace System.Net.Security isNtlm, cbtAppData, cbtAppDataSize, - targetName, + isNtlmFallback, + targetNameKerberos, + targetNameNtlm, (uint)inFlags, buffer, (buffer == null) ? 0 : buffer.Length, @@ -149,7 +153,8 @@ namespace System.Net.Security out outFlags, out isNtlmUsed); - if ((status != Interop.NetSecurityNative.Status.GSS_S_COMPLETE) && (status != Interop.NetSecurityNative.Status.GSS_S_CONTINUE_NEEDED)) + if ((status != Interop.NetSecurityNative.Status.GSS_S_COMPLETE) && + (status != Interop.NetSecurityNative.Status.GSS_S_CONTINUE_NEEDED)) { throw new Interop.NetSecurityNative.GssApiException(status, minorStatus); } @@ -175,34 +180,51 @@ namespace System.Net.Security ref ContextFlagsPal outFlags) { bool isNtlmOnly = credential.IsNtlmOnly; + bool initialContext = false; if (context == null) { - // Empty target name causes the failure on Linux, hence passing a non-empty string - context = isNtlmOnly ? new SafeDeleteNegoContext(credential, credential.UserName) : new SafeDeleteNegoContext(credential, targetName); + if (NetEventSource.IsEnabled) + { + string protocol = isNtlmOnly ? "NTLM" : "SPNEGO"; + NetEventSource.Info($"EstablishSecurityContext: protocol = {protocol}, target = {targetName}"); + } + + initialContext = true; + context = new SafeDeleteNegoContext(credential, targetName); } SafeDeleteNegoContext negoContext = (SafeDeleteNegoContext)context; try { - Interop.NetSecurityNative.GssFlags inputFlags = ContextFlagsAdapterPal.GetInteropFromContextFlagsPal(inFlags, isServer: false); + Interop.NetSecurityNative.GssFlags inputFlags = + ContextFlagsAdapterPal.GetInteropFromContextFlagsPal(inFlags, isServer: false); uint outputFlags; - int isNtlmUsed; + bool isNtlmUsed; SafeGssContextHandle contextHandle = negoContext.GssContext; bool done = GssInitSecurityContext( ref contextHandle, credential.GssCredential, isNtlmOnly, channelBinding, - negoContext.TargetName, + negoContext.IsNtlmFallback, + negoContext.TargetNameKerberos, + negoContext.TargetNameNtlm, inputFlags, incomingBlob, out resultBuffer, out outputFlags, out isNtlmUsed); + // Remember if SPNEGO did a fallback from Kerberos to NTLM while generating the initial context. + if (initialContext && !isNtlmOnly && isNtlmUsed) + { + negoContext.IsNtlmFallback = true; + } + Debug.Assert(resultBuffer != null, "Unexpected null buffer returned by GssApi"); - outFlags = ContextFlagsAdapterPal.GetContextFlagsPalFromInterop((Interop.NetSecurityNative.GssFlags)outputFlags, isServer: false); + outFlags = ContextFlagsAdapterPal.GetContextFlagsPalFromInterop( + (Interop.NetSecurityNative.GssFlags)outputFlags, isServer: false); Debug.Assert(negoContext.GssContext == null || contextHandle == negoContext.GssContext); // Save the inner context handle for further calls to NetSecurity @@ -215,7 +237,12 @@ namespace System.Net.Security // Populate protocol used for authentication if (done) { - negoContext.SetAuthenticationPackage(Convert.ToBoolean(isNtlmUsed)); + negoContext.SetAuthenticationPackage(isNtlmUsed); + if (NetEventSource.IsEnabled) + { + string protocol = isNtlmOnly ? "NTLM" : isNtlmUsed ? "SPNEGO-NTLM" : "SPNEGO-Kerberos"; + NetEventSource.Info($"EstablishSecurityContext: completed handshake, protocol = {protocol}"); + } } SecurityStatusPalErrorCode errorCode = done ? diff --git a/src/libraries/Common/src/System/Net/Security/Unix/SafeDeleteNegoContext.cs b/src/libraries/Common/src/System/Net/Security/Unix/SafeDeleteNegoContext.cs index 9b07e53..a85c7e2 100644 --- a/src/libraries/Common/src/System/Net/Security/Unix/SafeDeleteNegoContext.cs +++ b/src/libraries/Common/src/System/Net/Security/Unix/SafeDeleteNegoContext.cs @@ -12,13 +12,28 @@ namespace System.Net.Security { internal sealed class SafeDeleteNegoContext : SafeDeleteContext { - private SafeGssNameHandle _targetName; + private SafeGssNameHandle _targetNameKerberos; + private SafeGssNameHandle _targetNameNtlm; private SafeGssContextHandle _context; + private bool _isNtlmFallback; private bool _isNtlmUsed; - public SafeGssNameHandle TargetName + public SafeGssNameHandle TargetNameKerberos { - get { return _targetName; } + get { return _targetNameKerberos; } + } + + public SafeGssNameHandle TargetNameNtlm + { + get { return _targetNameNtlm; } + } + + // Property represents if SPNEGO needed to fall back from Kerberos to NTLM when + // generating initial context token. + public bool IsNtlmFallback + { + get { return _isNtlmFallback; } + set { _isNtlmFallback = value; } } // Property represents if final protocol negotiated is Ntlm or not. @@ -38,7 +53,8 @@ namespace System.Net.Security Debug.Assert((null != credential), "Null credential in SafeDeleteNegoContext"); try { - _targetName = SafeGssNameHandle.CreatePrincipal(targetName); + _targetNameKerberos = SafeGssNameHandle.CreateTarget(targetName, isNtlmTarget: false); + _targetNameNtlm = SafeGssNameHandle.CreateTarget(targetName, isNtlmTarget: true); } catch { @@ -68,10 +84,16 @@ namespace System.Net.Security _context = null; } - if (_targetName != null) + if (_targetNameKerberos != null) + { + _targetNameKerberos.Dispose(); + _targetNameKerberos = null; + } + + if (_targetNameNtlm != null) { - _targetName.Dispose(); - _targetName = null; + _targetNameNtlm.Dispose(); + _targetNameNtlm = null; } } base.Dispose(disposing); diff --git a/src/libraries/Native/Unix/System.Net.Security.Native/pal_gssapi.c b/src/libraries/Native/Unix/System.Net.Security.Native/pal_gssapi.c index 9c5ef4c..1d82c43 100644 --- a/src/libraries/Native/Unix/System.Net.Security.Native/pal_gssapi.c +++ b/src/libraries/Native/Unix/System.Net.Security.Native/pal_gssapi.c @@ -144,25 +144,27 @@ NetSecurityNative_ImportUserName(uint32_t* minorStatus, char* inputName, uint32_ return gss_import_name(minorStatus, &inputNameBuffer, GSS_C_NT_USER_NAME, outputName); } -uint32_t NetSecurityNative_ImportPrincipalName(uint32_t* minorStatus, - char* inputName, - uint32_t inputNameLen, - GssName** outputName) +uint32_t NetSecurityNative_ImportTargetName(uint32_t* minorStatus, + char* inputName, + uint32_t inputNameLen, + uint32_t isNtlmTarget, + GssName** outputName) { assert(minorStatus != NULL); assert(inputName != NULL); + assert(isNtlmTarget == 0 || isNtlmTarget == 1); assert(outputName != NULL); assert(*outputName == NULL); gss_OID nameType; - if (strchr(inputName, '/') != NULL) + if (isNtlmTarget) { - nameType = GSS_KRB5_NT_PRINCIPAL_NAME; + nameType = GSS_C_NT_HOSTBASED_SERVICE; } else { - nameType = GSS_C_NT_HOSTBASED_SERVICE; + nameType = GSS_KRB5_NT_PRINCIPAL_NAME; } GssBuffer inputNameBuffer = {.length = inputNameLen, .value = inputName}; @@ -175,7 +177,9 @@ uint32_t NetSecurityNative_InitSecContext(uint32_t* minorStatus, uint32_t isNtlm, void* cbt, int32_t cbtSize, - GssName* targetName, + int32_t isNtlmFallback, + GssName* targetNameKerberos, + GssName* targetNameNtlm, uint32_t reqFlags, uint8_t* inputBytes, uint32_t inputLength, @@ -186,7 +190,9 @@ uint32_t NetSecurityNative_InitSecContext(uint32_t* minorStatus, assert(minorStatus != NULL); assert(contextHandle != NULL); assert(isNtlm == 0 || isNtlm == 1); - assert(targetName != NULL); + assert(isNtlm == 1 || targetNameKerberos != NULL); + assert(isNtlmFallback == 0 || isNtlmFallback == 1); + assert(targetNameNtlm != NULL); assert(inputBytes != NULL || inputLength == 0); assert(outBuffer != NULL); assert(retFlags != NULL); @@ -236,19 +242,44 @@ uint32_t NetSecurityNative_InitSecContext(uint32_t* minorStatus, gssCbt.application_data.value = cbt; } - uint32_t majorStatus = gss_init_sec_context(minorStatus, - claimantCredHandle, - contextHandle, - targetName, - desiredMech, - reqFlags, - 0, - (cbt != NULL) ? &gssCbt : GSS_C_NO_CHANNEL_BINDINGS, - &inputToken, - &outmech, - &gssBuffer, - retFlags, - NULL); + GssName* targetName; + if (isNtlm || isNtlmFallback) + { + targetName = targetNameNtlm; + } + else + { + targetName = targetNameKerberos; + } + + uint32_t majorStatus; + uint32_t retryCount = 0; + bool retry; + do + { + majorStatus = gss_init_sec_context(minorStatus, + claimantCredHandle, + contextHandle, + targetName, + desiredMech, + reqFlags, + 0, + (cbt != NULL) ? &gssCbt : GSS_C_NO_CHANNEL_BINDINGS, + &inputToken, + &outmech, + &gssBuffer, + retFlags, + NULL); + + // If SPNEGO fails to generate optimistic Kerberos token on initial call then fall back to SPNEGO-wrapped NTLM. + retry = false; + if ((majorStatus == GSS_S_FAILURE || majorStatus == GSS_S_BAD_NAMETYPE) && + *contextHandle == NULL && !isNtlm && ++retryCount <= 1) + { + targetName = targetNameNtlm; + retry = true; + } + } while (retry); // Outmech can be null when gssntlmssp lib uses NTLM mechanism if (outmech != NULL && gss_oid_equal(outmech, krbMech) != 0) diff --git a/src/libraries/Native/Unix/System.Net.Security.Native/pal_gssapi.h b/src/libraries/Native/Unix/System.Net.Security.Native/pal_gssapi.h index 4203916..b9e296f 100644 --- a/src/libraries/Native/Unix/System.Net.Security.Native/pal_gssapi.h +++ b/src/libraries/Native/Unix/System.Net.Security.Native/pal_gssapi.h @@ -79,9 +79,10 @@ NetSecurityNative_ImportUserName(uint32_t* minorStatus, char* inputName, uint32_ /* Shims the gss_import_name method with nametype = GSS_C_NT_USER_NAME. */ -DLLEXPORT uint32_t NetSecurityNative_ImportPrincipalName(uint32_t* minorStatus, +DLLEXPORT uint32_t NetSecurityNative_ImportTargetName(uint32_t* minorStatus, char* inputName, uint32_t inputNameLen, + uint32_t isNtlmTarget, GssName** outputName); /* @@ -109,7 +110,9 @@ DLLEXPORT uint32_t NetSecurityNative_InitSecContext(uint32_t* minorStatus, uint32_t isNtlm, void* cbt, int32_t cbtSize, - GssName* targetName, + int32_t isNtlmFallback, + GssName* targetNameKerberos, + GssName* targetNameNtlm, uint32_t reqFlags, uint8_t* inputBytes, uint32_t inputLength, diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.Authentication.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.Authentication.cs index 05a2895..6b47114 100644 --- a/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.Authentication.cs +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.Authentication.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. @@ -511,6 +511,10 @@ namespace System.Net.Http.Functional.Tests yield return new object[] { $"http://{server}/test/auth/ntlm/{authEndPoint}", false }; yield return new object[] { $"https://{server}/test/auth/ntlm/{authEndPoint}", false }; + // Curlhandler (due to libcurl bug) cannot do Negotiate (SPNEGO) Kerberos to NTLM fallback. + yield return new object[] { $"http://{server}/test/auth/negotiate/{authEndPoint}", true }; + yield return new object[] { $"https://{server}/test/auth/negotiate/{authEndPoint}", true }; + // Server requires TLS channel binding token (cbt) with NTLM authentication. // CurlHandler (due to libcurl bug) cannot do NTLM authentication with cbt. yield return new object[] { $"https://{server}/test/auth/ntlm-epa/{authEndPoint}", true }; @@ -523,7 +527,10 @@ namespace System.Net.Http.Functional.Tests [MemberData(nameof(ServerUsesWindowsAuthentication_MemberData))] public async Task Credentials_ServerUsesWindowsAuthentication_Success(string server, bool skipOnCurlHandler) { - if (IsCurlHandler && skipOnCurlHandler) return; + if (IsCurlHandler && skipOnCurlHandler) + { + throw new SkipTestException("CurlHandler (libCurl) doesn't handle Negotiate with NTLM fallback nor CBT"); + } using (HttpClientHandler handler = CreateHttpClientHandler()) using (var client = new HttpClient(handler)) @@ -554,9 +561,9 @@ namespace System.Net.Http.Functional.Tests [InlineData("Negotiate")] public async Task Credentials_ServerChallengesWithWindowsAuth_ClientSendsWindowsAuthHeader(string authScheme) { - if (authScheme == "Negotiate" && !PlatformDetection.IsWindows) + if (authScheme == "Negotiate" && IsCurlHandler) { - throw new SkipTestException("Issue #34878"); + throw new SkipTestException("CurlHandler (libCurl) doesn't handle Negotiate with NTLM fallback"); } await LoopbackServerFactory.CreateClientAndServerAsync( -- 2.7.4