diff --git a/src/libraries/Common/src/System/Net/NTAuthentication.Common.cs b/src/libraries/Common/src/System/Net/NTAuthentication.Common.cs index a46e7314c9f50..2b5047e108042 100644 --- a/src/libraries/Common/src/System/Net/NTAuthentication.Common.cs +++ b/src/libraries/Common/src/System/Net/NTAuthentication.Common.cs @@ -196,9 +196,9 @@ internal int MakeSignature(byte[] buffer, int offset, int count, [AllowNull] ref return outgoingBlob; } - internal byte[]? GetOutgoingBlob(byte[]? incomingBlob, bool thrownOnError) + internal byte[]? GetOutgoingBlob(byte[]? incomingBlob, bool throwOnError) { - return GetOutgoingBlob(incomingBlob, thrownOnError, out _); + return GetOutgoingBlob(incomingBlob, throwOnError, out _); } // Accepts an incoming binary security blob and returns an outgoing binary security blob. diff --git a/src/libraries/System.Net.Security/tests/UnitTests/Fakes/FakeNtlmServer.cs b/src/libraries/System.Net.Security/tests/UnitTests/Fakes/FakeNtlmServer.cs new file mode 100644 index 0000000000000..ffb78a5c88f24 --- /dev/null +++ b/src/libraries/System.Net.Security/tests/UnitTests/Fakes/FakeNtlmServer.cs @@ -0,0 +1,359 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Buffers.Binary; +using System.Text; +using System.Net; +using System.Security.Cryptography; +using System.Net.Security; +using Xunit; + +namespace System.Net.Security +{ + // Implementation of subset of the NTLM specification + // https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-nlmp/b38c36ed-2804-4868-a9ff-8dd3182128e4 + // + // Server-side implementation of the NTLMv2 exchange is implemented with + // basic verification of the messages passed by the client against a + // specified set of authentication credentials. + // + // This is not indended as a production-quality code for implementing the + // NTLM authentication. It's merely to serve as a validation of challenges + // and responses for unit test purposes. The validation checks the + // structure of the messages, their integrity and use of specified + // features (eg. MIC). + internal class FakeNtlmServer + { + public FakeNtlmServer(NetworkCredential expectedCredential) + { + _expectedCredential = expectedCredential; + } + + // Behavior modifiers + public bool SendTimestamp { get; set; } = true; + public byte[] Version { get; set; } = new byte[] { 0x06, 0x00, 0x70, 0x17, 0x00, 0x00, 0x00, 0x0f }; // 6.0.6000 / 15 + public bool TargetIsServer { get; set; } = false; + public bool PreferUnicode { get; set; } = true; + + // Negotiation results + public bool IsAuthenticated { get; set; } + public bool IsMICPresent { get; set; } + public string? ClientSpecifiedSpn { get; set; } + + private NetworkCredential _expectedCredential; + + // Saved Negotiate and Challenge messages for MIC calculation + private byte[]? _negotiateMessage; + private byte[]? _challengeMessage; + + private MessageType _expectedMessageType = MessageType.Negotiate; + + // Minimal set of required negotiation flags + private const Flags _requiredFlags = + Flags.NegotiateNtlm2 | Flags.NegotiateNtlm | Flags.NegotiateAlwaysSign; + + // Fixed server challenge (same value as in Protocol Examples section of the specification) + private byte[] _serverChallenge = new byte[] { 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef }; + + private static ReadOnlySpan NtlmHeader => new byte[] { + (byte)'N', (byte)'T', (byte)'L', (byte)'M', + (byte)'S', (byte)'S', (byte)'P', 0 }; + + private enum MessageType : uint + { + Negotiate = 1, + Challenge = 2, + Authenticate = 3, + } + + [Flags] + private enum Flags : uint + { + NegotiateUnicode = 0x00000001, + NegotiateOEM = 0x00000002, + RequestTargetName = 0x00000004, + NegotiateSign = 0x00000010, + NegotiateSeal = 0x00000020, + NegotiateDatagram = 0x00000040, + NegotiateLMKey = 0x00000080, + NegotiateNtlm = 0x00000200, + NegotiateAnonymous = 0x00000800, + NegotiateDomainSupplied = 0x00001000, + NegotiateWorkstationSupplied = 0x00002000, + NegotiateAlwaysSign = 0x00008000, + TargetTypeDomain = 0x00010000, + TargetTypeServer = 0x00020000, + NegotiateNtlm2 = 0x00080000, + RequestIdenityToken = 0x00100000, + RequestNonNtSessionKey = 0x00400000, + NegotiateTargetInfo = 0x00800000, + NegotiateVersion = 0x02000000, + Negotiate128 = 0x20000000, + NegotiateKeyExchange = 0x40000000, + Negotiate56 = 0x80000000, + + AllSupported = + NegotiateUnicode | NegotiateOEM | RequestTargetName | + NegotiateSign | NegotiateSeal | NegotiateDatagram | + /* NegotiateLMKey | */ NegotiateNtlm | /* NegotiateAnonymous | */ + /* NegotiateDomainSupplied | NegotiateWorkstationSupplied | */ + NegotiateAlwaysSign | TargetTypeDomain | TargetTypeServer | + NegotiateNtlm2 | /* RequestIdenityToken | RequestNonNtSessionKey | */ + NegotiateTargetInfo | NegotiateVersion | Negotiate128 | + NegotiateKeyExchange | Negotiate56, + } + + private enum AvId + { + EOL, + NbComputerName, + NbDomainName, + DnsComputerName, + DnsDomainName, + DnsTreeName, + Flags, + Timestamp, + SingleHost, + TargetName, + ChannelBindings, + } + + [Flags] + private enum AvFlags : uint + { + ConstrainedAuthentication = 1, + MICPresent = 2, + UntrustedSPN = 4, + } + + private static ReadOnlySpan GetField(ReadOnlySpan payload, int fieldOffset) + { + uint offset = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(fieldOffset + 4)); + ushort length = BinaryPrimitives.ReadUInt16LittleEndian(payload.Slice(fieldOffset)); + + if (length == 0 || offset + length > payload.Length) + { + return ReadOnlySpan.Empty; + } + + return payload.Slice((int)offset, length); + } + + public byte[]? GetOutgoingBlob(byte[]? incomingBlob) + { + // Ensure the message is long enough + Assert.True(incomingBlob.Length >= 12); + Assert.Equal(NtlmHeader.ToArray(), incomingBlob.AsSpan(0, 8).ToArray()); + + var messageType = (MessageType)BinaryPrimitives.ReadUInt32LittleEndian(incomingBlob.AsSpan(8, 4)); + Assert.Equal(_expectedMessageType, messageType); + + switch (messageType) + { + case MessageType.Negotiate: + // We don't negotiate, we just verify + Assert.True(incomingBlob.Length >= 32); + Flags flags = (Flags)BinaryPrimitives.ReadUInt32LittleEndian(incomingBlob.AsSpan(12, 4)); + Assert.Equal(_requiredFlags, (flags & _requiredFlags)); + Assert.True((flags & (Flags.NegotiateOEM | Flags.NegotiateUnicode)) != 0); + if (flags.HasFlag(Flags.NegotiateDomainSupplied)) + { + string domain = Encoding.ASCII.GetString(GetField(incomingBlob, 16)); + Assert.Equal(_expectedCredential.Domain, domain); + } + _expectedMessageType = MessageType.Authenticate; + _negotiateMessage = incomingBlob; + return _challengeMessage = GenerateChallenge(flags); + + case MessageType.Authenticate: + // Validate the authentication! + ValidateAuthentication(incomingBlob); + _expectedMessageType = 0; + return null; + + default: + Assert.Fail($"Incorrect message type {messageType}"); + return null; + } + } + + private static int WriteAvIdString(Span buffer, AvId avId, string value) + { + int size = Encoding.Unicode.GetByteCount(value); + BinaryPrimitives.WriteUInt16LittleEndian(buffer, (ushort)avId); + BinaryPrimitives.WriteUInt16LittleEndian(buffer.Slice(2), (ushort)size); + Encoding.Unicode.GetBytes(value, buffer.Slice(4)); + return size + 4; + } + + private byte[] GenerateChallenge(Flags flags) + { + byte[] buffer = new byte[1000]; + byte[] targetName = Encoding.Unicode.GetBytes(TargetIsServer ? "Server" : _expectedCredential.Domain); + int payloadOffset = 56; + + // Loosely follow the flag manipulation in + // 3.2.5.1.1 Server Receives a NEGOTIATE_MESSAGE from the Client + flags &= ~(Flags.NegotiateLMKey | Flags.TargetTypeServer | Flags.TargetTypeDomain); + flags |= Flags.NegotiateNtlm | Flags.NegotiateAlwaysSign | Flags.NegotiateTargetInfo; + // Specification says to set Flags.RequestTargetName but it's valid only in NEGOTIATE_MESSAGE?! + flags |= TargetIsServer ? Flags.TargetTypeServer : Flags.TargetTypeDomain; + if (PreferUnicode && flags.HasFlag(Flags.NegotiateUnicode)) + { + flags &= ~Flags.NegotiateOEM; + } + // Remove any unsupported flags here + flags &= Flags.AllSupported; + + NtlmHeader.CopyTo(buffer.AsSpan(0, 8)); + BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(8), (uint)MessageType.Challenge); + BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(12), (ushort)targetName.Length); + BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(14), (ushort)targetName.Length); + BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(16), (ushort)payloadOffset); + targetName.CopyTo(buffer.AsSpan(payloadOffset, targetName.Length)); + payloadOffset += targetName.Length; + BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(20), (uint)flags); + _serverChallenge.CopyTo(buffer.AsSpan(24, 8)); + // 8 bytes reserved + // 8 bytes of TargetInfoFields (written below) + Version.CopyTo(buffer.AsSpan(48, 8)); + + int targetInfoOffset = payloadOffset; + int targetInfoCurrentOffset = targetInfoOffset; + targetInfoCurrentOffset += WriteAvIdString(buffer.AsSpan(targetInfoCurrentOffset), AvId.NbDomainName, _expectedCredential.Domain); + targetInfoCurrentOffset += WriteAvIdString(buffer.AsSpan(targetInfoCurrentOffset), AvId.NbComputerName, "Server"); + + if (SendTimestamp) + { + BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(targetInfoCurrentOffset), (ushort)AvId.Timestamp); + BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(targetInfoCurrentOffset + 2), (ushort)8); + BinaryPrimitives.WriteInt64LittleEndian(buffer.AsSpan(targetInfoCurrentOffset + 4), DateTime.UtcNow.ToFileTimeUtc()); + targetInfoCurrentOffset += 12; + } + + // TODO: DNS machine, domain, forest? + // EOL + targetInfoCurrentOffset += 4; + int targetInfoSize = targetInfoCurrentOffset - targetInfoOffset; + + BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(40), (ushort)targetInfoSize); + BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(42), (ushort)targetInfoSize); + BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(44), (uint)targetInfoOffset); + + return buffer.AsSpan(0, targetInfoCurrentOffset).ToArray(); + } + + private byte[] MakeNtlm2Hash() + { + byte[] pwHash = new byte[16]; + byte[] pwBytes = Encoding.Unicode.GetBytes(_expectedCredential.Password); + MD4.HashData(pwBytes, pwHash); + using (IncrementalHash hmac = IncrementalHash.CreateHMAC(HashAlgorithmName.MD5, pwHash)) + { + hmac.AppendData(Encoding.Unicode.GetBytes(_expectedCredential.UserName.ToUpper() + _expectedCredential.Domain)); + return hmac.GetHashAndReset(); + } + } + + private void ValidateAuthentication(byte[] incomingBlob) + { + ReadOnlySpan lmChallengeResponse = GetField(incomingBlob, 12); + ReadOnlySpan ntChallengeResponse = GetField(incomingBlob, 20); + ReadOnlySpan encryptedRandomSessionKey = GetField(incomingBlob, 52); + ReadOnlySpan mic = incomingBlob.AsSpan(72, 16); + + Flags flags = (Flags)BinaryPrimitives.ReadUInt32LittleEndian(incomingBlob.AsSpan(60)); + Assert.Equal(_requiredFlags, (flags & _requiredFlags)); + + // Only one encoding can be selected by the client + Assert.True((flags & (Flags.NegotiateOEM | Flags.NegotiateUnicode)) != 0); + Assert.True((flags & (Flags.NegotiateOEM | Flags.NegotiateUnicode)) != (Flags.NegotiateOEM | Flags.NegotiateUnicode)); + Encoding encoding = flags.HasFlag(Flags.NegotiateUnicode) ? Encoding.Unicode : Encoding.ASCII; + + string domainName = encoding.GetString(GetField(incomingBlob, 28)); + string userName = encoding.GetString(GetField(incomingBlob, 36)); + string workstation = encoding.GetString(GetField(incomingBlob, 44)); + Assert.Equal(_expectedCredential.UserName, userName); + Assert.Equal(_expectedCredential.Domain, domainName); + + byte[] ntlm2hash = MakeNtlm2Hash(); + Span sessionBaseKey = stackalloc byte[16]; + using (IncrementalHash hmac = IncrementalHash.CreateHMAC(HashAlgorithmName.MD5, ntlm2hash)) + { + hmac.AppendData(_serverChallenge); + hmac.AppendData(ntChallengeResponse.Slice(16)); + // If this matches then the password matched + IsAuthenticated = hmac.GetHashAndReset().AsSpan().SequenceEqual(ntChallengeResponse.Slice(0, 16)); + + if (!IsAuthenticated) + { + // Bail out + return; + } + + // Compute sessionBaseKey + hmac.AppendData(ntChallengeResponse.Slice(0, 16)); + hmac.GetHashAndReset(sessionBaseKey); + } + + ReadOnlySpan avPairs = ntChallengeResponse.Slice(16 + 28); + AvFlags avFlags = 0; + while (avPairs[0] != (byte)AvId.EOL) + { + AvId id = (AvId)avPairs[0]; + Assert.Equal(0, avPairs[1]); + ushort length = BinaryPrimitives.ReadUInt16LittleEndian(avPairs.Slice(2, 2)); + + if (id == AvId.Flags) + { + Assert.Equal(4, length); + avFlags = (AvFlags)BinaryPrimitives.ReadUInt32LittleEndian(avPairs.Slice(4, 4)); + } + else if (id == AvId.TargetName) + { + ClientSpecifiedSpn = Encoding.Unicode.GetString(avPairs.Slice(4, length)); + } + + avPairs = avPairs.Slice(length + 4); + } + + // Decrypt exportedSessionKey with sessionBaseKey + Span exportedSessionKey = stackalloc byte[16]; + if (flags.HasFlag(Flags.NegotiateKeyExchange) && + (flags.HasFlag(Flags.NegotiateSeal) || flags.HasFlag(Flags.NegotiateSign))) + { + using (RC4 rc4 = new RC4(sessionBaseKey)) + { + rc4.Transform(encryptedRandomSessionKey, exportedSessionKey); + } + } + else + { + sessionBaseKey.CopyTo(exportedSessionKey); + } + + // Calculate and verify message integrity if enabled + if (avFlags.HasFlag(AvFlags.MICPresent)) + { + IsMICPresent = true; + + Assert.NotNull(_negotiateMessage); + Assert.NotNull(_challengeMessage); + byte[] calculatedMic = new byte[16]; + using (var hmacMic = IncrementalHash.CreateHMAC(HashAlgorithmName.MD5, exportedSessionKey)) + { + hmacMic.AppendData(_negotiateMessage); + hmacMic.AppendData(_challengeMessage); + // Authenticate message with the MIC erased + hmacMic.AppendData(incomingBlob.AsSpan(0, 72)); + hmacMic.AppendData(new byte[16]); + hmacMic.AppendData(incomingBlob.AsSpan(72 + 16)); + hmacMic.GetHashAndReset(calculatedMic); + } + Assert.Equal(mic.ToArray(), calculatedMic); + } + } + } +} diff --git a/src/libraries/System.Net.Security/tests/UnitTests/NTAuthenticationTests.cs b/src/libraries/System.Net.Security/tests/UnitTests/NTAuthenticationTests.cs new file mode 100644 index 0000000000000..6a90b7665f944 --- /dev/null +++ b/src/libraries/System.Net.Security/tests/UnitTests/NTAuthenticationTests.cs @@ -0,0 +1,119 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO; +using System.Net.Security; +using System.Text; +using System.Threading.Tasks; +using System.Net.Test.Common; +using Xunit; + +namespace System.Net.Security.Tests +{ + public class NTAuthenticationTests + { + private static bool IsNtlmInstalled => Capability.IsNtlmInstalled(); + + private static NetworkCredential s_testCredentialRight = new NetworkCredential("rightusername", "rightpassword"); + private static NetworkCredential s_testCredentialWrong = new NetworkCredential("rightusername", "wrongpassword"); + + [Fact] + public void NtlmProtocolExampleTest() + { + // Mirrors the NTLMv2 example in the NTLM specification: + NetworkCredential credential = new NetworkCredential("User", "Password", "Domain"); + FakeNtlmServer fakeNtlmServer = new FakeNtlmServer(credential); + fakeNtlmServer.SendTimestamp = false; + fakeNtlmServer.TargetIsServer = true; + fakeNtlmServer.PreferUnicode = false; + + // NEGOTIATE_MESSAGE + // Flags: + // NTLMSSP_NEGOTIATE_KEY_EXCH + // NTLMSSP_NEGOTIATE_56 + // NTLMSSP_NEGOTIATE_128 + // NTLMSSP_NEGOTIATE_VERSION + // NTLMSSP_NEGOTIATE_TARGET_INFO + // NTLMSSP_NEGOTIATE_EXTENDED_SESSIONSECURITY + // NTLMSSP_TARGET_TYPE_SERVER + // NTLMSSP_NEGOTIATE_ALWAYS_SIGN + // NTLMSSP_NEGOTIATE_NTLM + // NTLMSSP_NEGOTIATE_SEAL + // NTLMSSP_NEGOTIATE_SIGN + // NTLMSSP_NEGOTIATE_OEM + // NTLMSSP_NEGOTIATE_UNICODE + // Domain: (empty) (should be "Domain" but the fake server doesn't check) + // Workstation: (empty) (should be "COMPUTER" but the fake server doesn't check) + // Version: 6.1.7600 / 15 + byte[] negotiateBlob = Convert.FromHexString("4e544c4d535350000100000033828ae2000000000000000000000000000000000601b01d0000000f"); + byte[]? challengeBlob = fakeNtlmServer.GetOutgoingBlob(negotiateBlob); + + // CHALLENGE_MESSAGE from 4.2.4.3 Messages + byte[] expectedChallengeBlob = Convert.FromHexString( + "4e544c4d53535000020000000c000c003800000033828ae20123456789abcdef" + + "00000000000000002400240044000000060070170000000f5300650072007600" + + "6500720002000c0044006f006d00610069006e0001000c005300650072007600" + + "6500720000000000"); + Assert.Equal(expectedChallengeBlob, challengeBlob); + + // AUTHENTICATE_MESSAGE from 4.2.4.3 Messages + byte[] authenticateBlob = Convert.FromHexString( + "4e544c4d5353500003000000180018006c00000054005400840000000c000c00" + + "480000000800080054000000100010005c00000010001000d8000000358288e2" + + "0501280a0000000f44006f006d00610069006e00550073006500720043004f00" + + "4d005000550054004500520086c35097ac9cec102554764a57cccc19aaaaaaaa" + + "aaaaaaaa68cd0ab851e51c96aabc927bebef6a1c010100000000000000000000" + + "00000000aaaaaaaaaaaaaaaa0000000002000c0044006f006d00610069006e00" + + "01000c005300650072007600650072000000000000000000c5dad2544fc97990" + + "94ce1ce90bc9d03e"); + byte[]? empty = fakeNtlmServer.GetOutgoingBlob(authenticateBlob); + Assert.Null(empty); + Assert.True(fakeNtlmServer.IsAuthenticated); + Assert.False(fakeNtlmServer.IsMICPresent); + } + + [ConditionalFact(nameof(IsNtlmInstalled))] + public void NtlmCorrectExchangeTest() + { + FakeNtlmServer fakeNtlmServer = new FakeNtlmServer(s_testCredentialRight); + NTAuthentication ntAuth = new NTAuthentication( + isServer: false, "NTLM", s_testCredentialRight, "HTTP/foo", + ContextFlagsPal.Connection | ContextFlagsPal.InitIntegrity, null); + + DoNtlmExchange(fakeNtlmServer, ntAuth); + + Assert.True(fakeNtlmServer.IsAuthenticated); + // NTLMSSP on Linux doesn't send the MIC and sends incorrect SPN (drops the service prefix) + if (!OperatingSystem.IsLinux()) + { + Assert.True(fakeNtlmServer.IsMICPresent); + Assert.Equal("HTTP/foo", fakeNtlmServer.ClientSpecifiedSpn); + } + } + + [ConditionalFact(nameof(IsNtlmInstalled))] + public void NtlmIncorrectExchangeTest() + { + FakeNtlmServer fakeNtlmServer = new FakeNtlmServer(s_testCredentialRight); + NTAuthentication ntAuth = new NTAuthentication( + isServer: false, "NTLM", s_testCredentialWrong, "HTTP/foo", + ContextFlagsPal.Connection | ContextFlagsPal.InitIntegrity, null); + + DoNtlmExchange(fakeNtlmServer, ntAuth); + + Assert.False(fakeNtlmServer.IsAuthenticated); + } + + private void DoNtlmExchange(FakeNtlmServer fakeNtlmServer, NTAuthentication ntAuth) + { + byte[]? negotiateBlob = ntAuth.GetOutgoingBlob(null, throwOnError: false); + Assert.NotNull(negotiateBlob); + byte[]? challengeBlob = fakeNtlmServer.GetOutgoingBlob(negotiateBlob); + Assert.NotNull(challengeBlob); + byte[]? authenticateBlob = ntAuth.GetOutgoingBlob(challengeBlob, throwOnError: false); + Assert.NotNull(authenticateBlob); + byte[]? empty = fakeNtlmServer.GetOutgoingBlob(authenticateBlob); + Assert.Null(empty); + } + } +} diff --git a/src/libraries/System.Net.Security/tests/UnitTests/System.Net.Security.Unit.Tests.csproj b/src/libraries/System.Net.Security/tests/UnitTests/System.Net.Security.Unit.Tests.csproj index 61c345127c82f..c0f761856c557 100644 --- a/src/libraries/System.Net.Security/tests/UnitTests/System.Net.Security.Unit.Tests.csproj +++ b/src/libraries/System.Net.Security/tests/UnitTests/System.Net.Security.Unit.Tests.csproj @@ -13,15 +13,11 @@ $(NetCoreAppCurrent)-windows;$(NetCoreAppCurrent)-Unix;$(NetCoreAppCurrent)-Browser;$(NetCoreAppCurrent)-OSX;$(NetCoreAppCurrent)-iOS;$(NetCoreAppCurrent)-Android annotations true + true - - - - @@ -30,10 +26,15 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +