-
Notifications
You must be signed in to change notification settings - Fork 4.8k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add basic fake NTLM server to test NTAuthentication round-trip scenar…
…ios (#65611) * Add basic NTLM fake server to test NTAuthentication round-trip scenarios * Treat MIC as optional on Linux * Add guard for IsNtlmInstalled * Add comments; add protocol exchange example from NTLM specification * Test both correct and incorrect credentials * Check the SPN sent by client * Replace NtlmAssert with regular Xunit Assert to get better messages * Tweak algorithm for calculating flags in CHALLENGE_MESSAGE, add support for OEM encoding * Set the NegotiateTargetInfo on CHALLENGE_MESSAGE; relax the check for Linux due to a bug in gss-ntlmssp * Remove duplicate implementation of RC4. * Fix paths. * Rename EnableDllImportGenerator to EnableLibraryImportGenerator.
- Loading branch information
1 parent
1b930a9
commit 6e49909
Showing
4 changed files
with
634 additions
and
7 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
359 changes: 359 additions & 0 deletions
359
src/libraries/System.Net.Security/tests/UnitTests/Fakes/FakeNtlmServer.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<byte> 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<byte> GetField(ReadOnlySpan<byte> 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<byte>.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<byte> 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<byte> lmChallengeResponse = GetField(incomingBlob, 12); | ||
ReadOnlySpan<byte> ntChallengeResponse = GetField(incomingBlob, 20); | ||
ReadOnlySpan<byte> encryptedRandomSessionKey = GetField(incomingBlob, 52); | ||
ReadOnlySpan<byte> 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<byte> 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<byte> 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<byte> 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); | ||
} | ||
} | ||
} | ||
} |
Oops, something went wrong.