Skip to content

Commit b7575a1

Browse files
rzikmCopilot
andauthored
Validate sizes and offsets when parsing NTLM challenge (#128692)
The managed NTLM client previously trusted the wire fields in `CHALLENGE_MESSAGE` without checking that the declared offsets, lengths, and AV pair sizes fit within the supplied blob. A malformed or truncated challenge could cause out-of-range slice exceptions or silently misparse target info. This addresses the long-standing `// TODO: Validate size and offsets` in `ProcessChallenge`. Changes: - `ProcessChallenge` rejects challenges smaller than the fixed `ChallengeMessage` header before reading. - `GetField` guards against negative offsets and uses `offset > payload.Length - length` so a huge offset can no longer wrap around to look in-range. - `ProcessTargetInfo` validates that each AV pair''s declared body fits in the remaining bytes and that the `Timestamp` AV is at least 8 bytes. On malformed input it returns `null`, and `ProcessChallenge` surfaces that as `InvalidToken` to the caller. - `targetInfoBuffer` is sized to include the trailing EOL bytes that the method writes. Behavior on well-formed challenges is unchanged; only malformed inputs now fail cleanly with `InvalidToken` instead of throwing or producing undefined data. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 67e6af4 commit b7575a1

2 files changed

Lines changed: 121 additions & 5 deletions

File tree

src/libraries/System.Net.Security/src/System/Net/NegotiateAuthenticationPal.ManagedNtlm.cs

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -378,7 +378,7 @@ private static ReadOnlySpan<byte> GetField(MessageField field, ReadOnlySpan<byte
378378
int offset = field.PayloadOffset;
379379
int length = field.Length;
380380

381-
if (length == 0 || offset + length > payload.Length)
381+
if (length == 0 || offset < 0 || offset > payload.Length - length)
382382
{
383383
return ReadOnlySpan<byte>.Empty;
384384
}
@@ -508,7 +508,7 @@ private unsafe void WriteChannelBindingHash(Span<byte> hashBuffer)
508508
}
509509
}
510510

511-
private byte[] ProcessTargetInfo(ReadOnlySpan<byte> targetInfo, out DateTime time, out bool hasNbNames)
511+
private byte[]? ProcessTargetInfo(ReadOnlySpan<byte> targetInfo, out DateTime time, out bool hasNbNames)
512512
{
513513
int spnSize = _spn != null ? Encoding.Unicode.GetByteCount(_spn) : 0;
514514

@@ -518,10 +518,11 @@ private byte[] ProcessTargetInfo(ReadOnlySpan<byte> targetInfo, out DateTime tim
518518
}
519519

520520
bool hasNbComputerName = false, hasNbDomainName = false;
521-
byte[] targetInfoBuffer = new byte[targetInfo.Length + 20 /* channel binding */ + 4 + spnSize /* SPN */ + 8 /* flags */];
521+
byte[] targetInfoBuffer = new byte[targetInfo.Length + 20 /* channel binding */ + 4 + spnSize /* SPN */ + 8 /* flags */ + 4 /* EOL */];
522522
int targetInfoOffset = 0;
523523

524524
time = DateTime.UtcNow;
525+
hasNbNames = false;
525526

526527
if (targetInfo.Length > 0)
527528
{
@@ -538,8 +539,19 @@ private byte[] ProcessTargetInfo(ReadOnlySpan<byte> targetInfo, out DateTime tim
538539
break;
539540
}
540541

542+
// Make sure the AV pair fits in the remaining target info bytes.
543+
if (length > info.Length - 4)
544+
{
545+
return null;
546+
}
547+
541548
if (ID == AvId.Timestamp)
542549
{
550+
if (length < 8)
551+
{
552+
return null;
553+
}
554+
543555
time = DateTime.FromFileTimeUtc(BinaryPrimitives.ReadInt64LittleEndian(info.Slice(4, 8)));
544556
}
545557
else if (ID == AvId.TargetName || ID == AvId.ChannelBindings)
@@ -616,7 +628,12 @@ private static byte[] DeriveKey(ReadOnlySpan<byte> exportedSessionKey, ReadOnlyS
616628
// This gets decoded byte blob and returns response in binary form.
617629
private unsafe byte[]? ProcessChallenge(ReadOnlySpan<byte> blob, out NegotiateAuthenticationStatusCode statusCode)
618630
{
619-
// TODO: Validate size and offsets
631+
// The challenge must be at least large enough to hold the fixed-size header.
632+
if (blob.Length < sizeof(ChallengeMessage))
633+
{
634+
statusCode = NegotiateAuthenticationStatusCode.InvalidToken;
635+
return null;
636+
}
620637

621638
ref readonly ChallengeMessage challengeMessage = ref MemoryMarshal.AsRef<ChallengeMessage>(blob.Slice(0, sizeof(ChallengeMessage)));
622639

@@ -649,7 +666,12 @@ private static byte[] DeriveKey(ReadOnlySpan<byte> exportedSessionKey, ReadOnlyS
649666
}
650667

651668
ReadOnlySpan<byte> targetInfo = GetField(challengeMessage.TargetInfo, blob);
652-
byte[] targetInfoBuffer = ProcessTargetInfo(targetInfo, out DateTime time, out bool hasNbNames);
669+
byte[]? targetInfoBuffer = ProcessTargetInfo(targetInfo, out DateTime time, out bool hasNbNames);
670+
if (targetInfoBuffer is null)
671+
{
672+
statusCode = NegotiateAuthenticationStatusCode.InvalidToken;
673+
return null;
674+
}
653675

654676
// If NTLM v2 authentication is used and the CHALLENGE_MESSAGE does not contain both
655677
// MsvAvNbComputerName and MsvAvNbDomainName AVPairs and either Integrity is TRUE or

src/libraries/System.Net.Security/tests/UnitTests/NegotiateAuthenticationTests.cs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,5 +435,99 @@ public void NegotiateCorrectExchangeTest(bool requestMIC, bool requestConfidenti
435435
}
436436
while (!ntAuth.IsAuthenticated);
437437
}
438+
439+
public static IEnumerable<object[]> MalformedChallengeBlobs()
440+
{
441+
// Truncated below sizeof(ChallengeMessage). The fixed header is 56 bytes.
442+
yield return new object[] { "TooShort", (Func<byte[], byte[]>)(blob => blob.AsSpan(0, 40).ToArray()) };
443+
444+
// TargetInfo payload offset points past the end of the blob.
445+
yield return new object[] { "TargetInfoOffsetOutOfRange", (Func<byte[], byte[]>)(blob =>
446+
{
447+
byte[] copy = (byte[])blob.Clone();
448+
BinaryPrimitives.WriteUInt32LittleEndian(copy.AsSpan(44), (uint)(copy.Length + 100));
449+
return copy;
450+
}) };
451+
452+
// TargetInfo length extends past the end of the blob.
453+
yield return new object[] { "TargetInfoLengthOutOfRange", (Func<byte[], byte[]>)(blob =>
454+
{
455+
byte[] copy = (byte[])blob.Clone();
456+
BinaryPrimitives.WriteUInt16LittleEndian(copy.AsSpan(40), ushort.MaxValue);
457+
BinaryPrimitives.WriteUInt16LittleEndian(copy.AsSpan(42), ushort.MaxValue);
458+
return copy;
459+
}) };
460+
461+
// TargetInfo payload offset is negative (would underflow without the offset < 0 guard).
462+
yield return new object[] { "TargetInfoOffsetNegative", (Func<byte[], byte[]>)(blob =>
463+
{
464+
byte[] copy = (byte[])blob.Clone();
465+
BinaryPrimitives.WriteInt32LittleEndian(copy.AsSpan(44), -8);
466+
return copy;
467+
}) };
468+
469+
// AV pair declares a length longer than the remaining TargetInfo bytes.
470+
yield return new object[] { "AvPairOverrun", (Func<byte[], byte[]>)(blob =>
471+
{
472+
byte[] copy = (byte[])blob.Clone();
473+
int targetInfoOffset = (int)BinaryPrimitives.ReadUInt32LittleEndian(copy.AsSpan(44));
474+
// Overwrite the first AV pair length so it extends past the end of TargetInfo.
475+
BinaryPrimitives.WriteUInt16LittleEndian(copy.AsSpan(targetInfoOffset + 2), ushort.MaxValue);
476+
return copy;
477+
}) };
478+
479+
// Timestamp AV pair body is shorter than the required 8 bytes.
480+
yield return new object[] { "ShortTimestamp", (Func<byte[], byte[]>)(blob =>
481+
{
482+
byte[] copy = (byte[])blob.Clone();
483+
int targetInfoOffset = (int)BinaryPrimitives.ReadUInt32LittleEndian(copy.AsSpan(44));
484+
int targetInfoLength = BinaryPrimitives.ReadUInt16LittleEndian(copy.AsSpan(40));
485+
Span<byte> ti = copy.AsSpan(targetInfoOffset, targetInfoLength);
486+
// Walk to the Timestamp AV (AvId 7) emitted by FakeNtlmServer and shorten it.
487+
int pos = 0;
488+
while (pos + 4 <= ti.Length)
489+
{
490+
ushort id = BinaryPrimitives.ReadUInt16LittleEndian(ti.Slice(pos));
491+
ushort len = BinaryPrimitives.ReadUInt16LittleEndian(ti.Slice(pos + 2));
492+
if (id == 7 /* Timestamp */)
493+
{
494+
BinaryPrimitives.WriteUInt16LittleEndian(ti.Slice(pos + 2), 4);
495+
break;
496+
}
497+
pos += 4 + len;
498+
}
499+
return copy;
500+
}) };
501+
}
502+
503+
[ConditionalTheory(typeof(NegotiateAuthenticationTests), nameof(UseManagedNtlm))]
504+
[MemberData(nameof(MalformedChallengeBlobs))]
505+
public void NtlmMalformedChallenge_ReturnsInvalidToken(string scenario, Func<byte[], byte[]> corruptor)
506+
{
507+
_ = scenario;
508+
509+
using FakeNtlmServer fakeNtlmServer = new FakeNtlmServer(s_testCredentialRight);
510+
NegotiateAuthentication ntAuth = new NegotiateAuthentication(
511+
new NegotiateAuthenticationClientOptions
512+
{
513+
Package = "NTLM",
514+
Credential = s_testCredentialRight,
515+
TargetName = "HTTP/foo",
516+
});
517+
518+
NegotiateAuthenticationStatusCode statusCode;
519+
byte[]? negotiateBlob = ntAuth.GetOutgoingBlob((byte[])null, out statusCode);
520+
Assert.Equal(NegotiateAuthenticationStatusCode.ContinueNeeded, statusCode);
521+
Assert.NotNull(negotiateBlob);
522+
523+
byte[]? challengeBlob = fakeNtlmServer.GetOutgoingBlob(negotiateBlob);
524+
Assert.NotNull(challengeBlob);
525+
526+
byte[] malformed = corruptor(challengeBlob);
527+
528+
byte[]? response = ntAuth.GetOutgoingBlob(malformed, out statusCode);
529+
Assert.Equal(NegotiateAuthenticationStatusCode.InvalidToken, statusCode);
530+
Assert.Null(response);
531+
}
438532
}
439533
}

0 commit comments

Comments
 (0)