From 7f4cd291e7966a032adaf946c213b7a36e3f06fb Mon Sep 17 00:00:00 2001 From: OfficeCom Codex Date: Wed, 5 Aug 2026 09:16:46 +0200 Subject: [PATCH] Refine Exchange IIS authentication detection --- src/OCSentinelCli/ExchangeIisLogParser.cs | 135 +++++++++++++----- .../ExchangeIisLogParserTests.cs | 25 ++++ 2 files changed, 128 insertions(+), 32 deletions(-) diff --git a/src/OCSentinelCli/ExchangeIisLogParser.cs b/src/OCSentinelCli/ExchangeIisLogParser.cs index b1567ee..f3c9ba8 100644 --- a/src/OCSentinelCli/ExchangeIisLogParser.cs +++ b/src/OCSentinelCli/ExchangeIisLogParser.cs @@ -4,9 +4,13 @@ namespace OCSentinelCli; internal static class ExchangeIisLogParser { + private static readonly TimeSpan AuthenticationCompletionWindow = TimeSpan.FromMinutes(2); + internal static IEnumerable ParseLines(IEnumerable lines, DateTimeOffset since) { Dictionary? fields = null; + var pendingFailures = new List(); + var attacks = new List(); foreach (string line in lines) { @@ -18,51 +22,77 @@ internal static class ExchangeIisLogParser continue; } - if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#') || fields is null) + if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#') || fields is null || !TryParseObservation(fields, line, since, out ExchangeIisObservation? observation)) { continue; } - string[] values = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); - if (!TryValue(fields, values, "date", out string date) || !TryValue(fields, values, "time", out string time) - || !TryValue(fields, values, "c-ip", out string sourceIp) || !TryValue(fields, values, "cs-uri-stem", out string path) - || !TryValue(fields, values, "sc-status", out string statusText) || !int.TryParse(statusText, out int status)) + if (observation is null) { continue; } - if (status is not 401 and not 403 || !TryClassify(path, out string service)) + FlushExpiredCandidates(pendingFailures, attacks, observation.Timestamp); + + if (observation.IsSuccessfulAuthentication) { - continue; + pendingFailures.RemoveAll(candidate => candidate.MatchesSuccessfulAuthentication(observation)); } - - if (!DateTime.TryParse($"{date} {time}", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out DateTime timestampUtc)) + else if (observation.IsCredentialFailure) { - continue; + pendingFailures.Add(observation); } - - DateTimeOffset timestamp = new(timestampUtc, TimeSpan.Zero); - if (timestamp < since || string.IsNullOrWhiteSpace(sourceIp) || sourceIp == "-") - { - continue; - } - - int? destinationPort = TryValue(fields, values, "s-port", out string portText) && int.TryParse(portText, out int parsedPort) ? parsedPort : null; - string username = TryValue(fields, values, "cs-username", out string loggedUser) && loggedUser != "-" ? loggedUser : "[not logged]"; - - yield return new AttackEvent - { - Timestamp = timestamp.ToLocalTime(), - SourceIp = sourceIp, - Target = $"Exchange {service} login", - Username = username, - Source = "IIS W3C", - Service = service, - DestinationPort = destinationPort, - Endpoint = path, - InstanceId = status - }; } + + attacks.AddRange(pendingFailures.Select(static candidate => candidate.ToAttackEvent())); + return attacks; + } + + private static void FlushExpiredCandidates(List pendingFailures, List attacks, DateTimeOffset currentTimestamp) + { + DateTimeOffset cutoff = currentTimestamp - AuthenticationCompletionWindow; + foreach (ExchangeIisObservation candidate in pendingFailures.Where(candidate => candidate.Timestamp < cutoff).ToList()) + { + attacks.Add(candidate.ToAttackEvent()); + pendingFailures.Remove(candidate); + } + } + + private static bool TryParseObservation(IReadOnlyDictionary fields, string line, DateTimeOffset since, out ExchangeIisObservation? observation) + { + observation = null; + string[] values = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (!TryValue(fields, values, "date", out string date) || !TryValue(fields, values, "time", out string time) + || !TryValue(fields, values, "c-ip", out string sourceIp) || !TryValue(fields, values, "cs-uri-stem", out string path) + || !TryValue(fields, values, "sc-status", out string statusText) || !int.TryParse(statusText, out int status) + || !TryClassify(path, out string service)) + { + return false; + } + + if ((status < 200 || status >= 400) && status is not 401 and not 403) + { + return false; + } + + if (!DateTime.TryParse($"{date} {time}", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out DateTime timestampUtc)) + { + return false; + } + + DateTimeOffset timestamp = new(timestampUtc, TimeSpan.Zero); + if (timestamp < since || string.IsNullOrWhiteSpace(sourceIp) || sourceIp == "-") + { + return false; + } + + int? destinationPort = TryValue(fields, values, "s-port", out string portText) && int.TryParse(portText, out int parsedPort) ? parsedPort : null; + string username = TryValue(fields, values, "cs-username", out string loggedUser) && loggedUser != "-" ? loggedUser : "[not logged]"; + string userAgent = TryValue(fields, values, "cs(User-Agent)", out string parsedUserAgent) && parsedUserAgent != "-" ? parsedUserAgent : string.Empty; + string substatus = TryValue(fields, values, "sc-substatus", out string parsedSubstatus) ? parsedSubstatus : string.Empty; + + observation = new ExchangeIisObservation(timestamp, sourceIp, path, service, destinationPort, username, userAgent, status, substatus); + return true; } private static bool TryValue(IReadOnlyDictionary fields, IReadOnlyList values, string field, out string value) @@ -94,4 +124,45 @@ internal static class ExchangeIisLogParser }; return service.Length > 0; } + + private sealed record ExchangeIisObservation( + DateTimeOffset Timestamp, + string SourceIp, + string Endpoint, + string Service, + int? DestinationPort, + string Username, + string UserAgent, + int Status, + string Substatus) + { + public bool IsSuccessfulAuthentication => Status is >= 200 and < 400; + + // IIS 401.0 and 401.2 commonly occur during normal authentication negotiation or server configuration checks. + public bool IsCredentialFailure => Status == 403 || (Status == 401 && (string.IsNullOrWhiteSpace(Substatus) || Substatus == "1")); + + public bool MatchesSuccessfulAuthentication(ExchangeIisObservation success) + { + return success.IsSuccessfulAuthentication + && success.Timestamp >= Timestamp + && success.Timestamp - Timestamp <= AuthenticationCompletionWindow + && string.Equals(success.SourceIp, SourceIp, StringComparison.OrdinalIgnoreCase) + && string.Equals(success.Endpoint, Endpoint, StringComparison.OrdinalIgnoreCase) + && success.DestinationPort == DestinationPort + && string.Equals(success.UserAgent, UserAgent, StringComparison.OrdinalIgnoreCase); + } + + public AttackEvent ToAttackEvent() => new() + { + Timestamp = Timestamp.ToLocalTime(), + SourceIp = SourceIp, + Target = $"Exchange {Service} login", + Username = Username, + Source = "IIS W3C", + Service = Service, + DestinationPort = DestinationPort, + Endpoint = Endpoint, + InstanceId = Status + }; + } } diff --git a/tests/OCSentinelCli.Tests/ExchangeIisLogParserTests.cs b/tests/OCSentinelCli.Tests/ExchangeIisLogParserTests.cs index c3c0d04..97cc878 100644 --- a/tests/OCSentinelCli.Tests/ExchangeIisLogParserTests.cs +++ b/tests/OCSentinelCli.Tests/ExchangeIisLogParserTests.cs @@ -39,4 +39,29 @@ public sealed class ExchangeIisLogParserTests Assert.Equal(444, attack.DestinationPort); Assert.Equal("user@example.test", attack.Username); } + + [Fact] + public void IgnoresNormalIisAuthenticationHandshake() + { + string[] lines = + [ + "#Fields: date time cs-uri-stem cs-username c-ip s-port cs(User-Agent) sc-status sc-substatus", + "2026-08-02 04:15:00 /mapi/emsmdb/ - 198.51.100.8 443 Outlook 401 1", + "2026-08-02 04:15:01 /mapi/emsmdb/ user@example.test 198.51.100.8 443 Outlook 200 0" + ]; + + Assert.Empty(ExchangeIisLogParser.ParseLines(lines, new DateTimeOffset(2026, 8, 2, 4, 0, 0, TimeSpan.Zero))); + } + + [Fact] + public void IgnoresNonCredentialIis401Substatus() + { + string[] lines = + [ + "#Fields: date time cs-uri-stem cs-username c-ip s-port sc-status sc-substatus", + "2026-08-02 04:15:00 /ews/Exchange.asmx - 198.51.100.8 443 401 0" + ]; + + Assert.Empty(ExchangeIisLogParser.ParseLines(lines, new DateTimeOffset(2026, 8, 2, 4, 0, 0, TimeSpan.Zero))); + } }