Refine Exchange IIS authentication detection
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 25s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 52s

This commit is contained in:
OfficeCom Codex
2026-08-05 09:16:46 +02:00
parent 705b543e5a
commit 7f4cd291e7
2 changed files with 128 additions and 32 deletions

View File

@@ -4,9 +4,13 @@ namespace OCSentinelCli;
internal static class ExchangeIisLogParser
{
private static readonly TimeSpan AuthenticationCompletionWindow = TimeSpan.FromMinutes(2);
internal static IEnumerable<AttackEvent> ParseLines(IEnumerable<string> lines, DateTimeOffset since)
{
Dictionary<string, int>? fields = null;
var pendingFailures = new List<ExchangeIisObservation>();
var attacks = new List<AttackEvent>();
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<ExchangeIisObservation> pendingFailures, List<AttackEvent> 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<string, int> 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<string, int> fields, IReadOnlyList<string> 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
};
}
}