169 lines
7.1 KiB
C#
169 lines
7.1 KiB
C#
using System.Globalization;
|
|
|
|
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)
|
|
{
|
|
if (line.StartsWith("#Fields:", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
fields = line[8..].Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries)
|
|
.Select((field, index) => new { Field = field, Index = index })
|
|
.ToDictionary(item => item.Field, item => item.Index, StringComparer.OrdinalIgnoreCase);
|
|
continue;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#') || fields is null || !TryParseObservation(fields, line, since, out ExchangeIisObservation? observation))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (observation is null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
FlushExpiredCandidates(pendingFailures, attacks, observation.Timestamp);
|
|
|
|
if (observation.IsSuccessfulAuthentication)
|
|
{
|
|
pendingFailures.RemoveAll(candidate => candidate.MatchesSuccessfulAuthentication(observation));
|
|
}
|
|
else if (observation.IsCredentialFailure)
|
|
{
|
|
pendingFailures.Add(observation);
|
|
}
|
|
}
|
|
|
|
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)
|
|
{
|
|
value = string.Empty;
|
|
if (!fields.TryGetValue(field, out int index) || index >= values.Count)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
value = values[index];
|
|
return true;
|
|
}
|
|
|
|
private static bool TryClassify(string path, out string service)
|
|
{
|
|
string normalized = path.Trim().ToLowerInvariant();
|
|
service = normalized switch
|
|
{
|
|
var value when value.StartsWith("/owa/") => "OWA",
|
|
var value when value.StartsWith("/ecp/") => "ECP",
|
|
var value when value.StartsWith("/mapi/") => "MAPI/HTTP",
|
|
var value when value.StartsWith("/ews/") => "EWS",
|
|
var value when value.StartsWith("/microsoft-server-activesync") => "ActiveSync",
|
|
var value when value.StartsWith("/autodiscover/") => "Autodiscover",
|
|
var value when value.StartsWith("/rpc/") => "Outlook Anywhere",
|
|
var value when value.StartsWith("/powershell") => "Exchange PowerShell",
|
|
_ => string.Empty
|
|
};
|
|
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
|
|
};
|
|
}
|
|
}
|