3 Commits

Author SHA1 Message Date
OfficeCom Codex
6e10a71820 Correlate Exchange IIS failures with security accounts
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 49s
2026-08-05 09:39:33 +02:00
OfficeCom Codex
7f4cd291e7 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
2026-08-05 09:16:46 +02:00
OfficeCom Codex
705b543e5a Publish beta 1.5.0-beta.7 manifest
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 24s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 47s
2026-08-03 01:21:25 +02:00
6 changed files with 245 additions and 36 deletions

View File

@@ -1,8 +1,8 @@
{ {
"channel": "beta", "channel": "beta",
"version": "1.5.0-beta.6", "version": "1.5.0-beta.7",
"publishedAtUtc": "2026-08-02T21:52:46.1209296Z", "publishedAtUtc": "2026-08-02T23:20:59.4454466Z",
"artifactUrl": "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/v1.5.0-beta.6/OCSentinelClient-win-x64.zip", "artifactUrl": "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/v1.5.0-beta.7/OCSentinelClient-win-x64.zip",
"sha256": "341f56e69f0e5e9836af6d87d86851b0d63638a5e902d459af88ee68f94010f5", "sha256": "b40da61499d0d9b5f95922f84aebbb3b6de6bf8a2ba2196873ecf2fab1789c0d",
"minUpdaterVersion": "1.0.0" "minUpdaterVersion": "1.0.0"
} }

View File

@@ -42,6 +42,7 @@ internal sealed class AttackScanner
ScanExchangeIisLogons(attacks, errors, since, configuration); ScanExchangeIisLogons(attacks, errors, since, configuration);
ScanIisFtpLogs(attacks, errors, since, configuration); ScanIisFtpLogs(attacks, errors, since, configuration);
ScanFileZillaLogs(attacks, errors, since, configuration); ScanFileZillaLogs(attacks, errors, since, configuration);
attacks = ExchangeIisAccountCorrelator.Enrich(attacks);
RansomwareBetaSummary ransomwareBeta = RansomwareBetaDetector.Scan(configuration, errors); RansomwareBetaSummary ransomwareBeta = RansomwareBetaDetector.Scan(configuration, errors);
if (configuration.ExcludedIps.Count > 0) if (configuration.ExcludedIps.Count > 0)
@@ -181,6 +182,7 @@ internal sealed class AttackScanner
Target = "Windows login", Target = "Windows login",
Username = ReadProperty(eventRecord, 5, "[unknown]"), Username = ReadProperty(eventRecord, 5, "[unknown]"),
Source = "Security", Source = "Security",
Service = ReadProperty(eventRecord, 10) == "3" ? "Network" : string.Empty,
InstanceId = 4625 InstanceId = 4625
}); });
}); });

View File

@@ -0,0 +1,53 @@
namespace OCSentinelCli;
internal static class ExchangeIisAccountCorrelator
{
private static readonly TimeSpan CorrelationWindow = TimeSpan.FromMinutes(2);
internal static List<AttackEvent> Enrich(IReadOnlyList<AttackEvent> attacks)
{
List<AttackEvent> securityFailures = attacks
.Where(IsNetworkSecurityFailure)
.Where(HasRecordedAccount)
.ToList();
return attacks.Select(attack => IsUnresolvedExchangeIisFailure(attack)
? EnrichFromSecurityFailure(attack, securityFailures)
: attack).ToList();
}
private static AttackEvent EnrichFromSecurityFailure(AttackEvent iisFailure, IReadOnlyList<AttackEvent> securityFailures)
{
List<string> accounts = securityFailures
.Where(failure => string.Equals(failure.SourceIp, iisFailure.SourceIp, StringComparison.OrdinalIgnoreCase))
.Where(failure => (failure.Timestamp - iisFailure.Timestamp).Duration() <= CorrelationWindow)
.Select(failure => failure.Username)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
// A shared NAT address can produce concurrent failures; do not guess between accounts.
return accounts.Count == 1 ? iisFailure with { Username = accounts[0] } : iisFailure;
}
private static bool IsUnresolvedExchangeIisFailure(AttackEvent attack)
{
return string.Equals(attack.Source, "IIS W3C", StringComparison.OrdinalIgnoreCase)
&& (string.Equals(attack.Username, "[not logged]", StringComparison.OrdinalIgnoreCase)
|| string.Equals(attack.Username, "[unknown]", StringComparison.OrdinalIgnoreCase));
}
private static bool IsNetworkSecurityFailure(AttackEvent attack)
{
return attack.InstanceId == 4625
&& string.Equals(attack.Source, "Security", StringComparison.OrdinalIgnoreCase)
&& string.Equals(attack.Service, "Network", StringComparison.OrdinalIgnoreCase)
&& string.Equals(attack.Target, "Windows login", StringComparison.OrdinalIgnoreCase);
}
private static bool HasRecordedAccount(AttackEvent attack)
{
return !string.IsNullOrWhiteSpace(attack.Username)
&& !string.Equals(attack.Username, "[unknown]", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(attack.Username, "[not logged]", StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -4,9 +4,13 @@ namespace OCSentinelCli;
internal static class ExchangeIisLogParser internal static class ExchangeIisLogParser
{ {
private static readonly TimeSpan AuthenticationCompletionWindow = TimeSpan.FromMinutes(2);
internal static IEnumerable<AttackEvent> ParseLines(IEnumerable<string> lines, DateTimeOffset since) internal static IEnumerable<AttackEvent> ParseLines(IEnumerable<string> lines, DateTimeOffset since)
{ {
Dictionary<string, int>? fields = null; Dictionary<string, int>? fields = null;
var pendingFailures = new List<ExchangeIisObservation>();
var attacks = new List<AttackEvent>();
foreach (string line in lines) foreach (string line in lines)
{ {
@@ -18,51 +22,77 @@ internal static class ExchangeIisLogParser
continue; 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; continue;
} }
string[] values = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); if (observation is null)
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))
{ {
continue; 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));
} }
else if (observation.IsCredentialFailure)
if (!DateTime.TryParse($"{date} {time}", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out DateTime timestampUtc))
{ {
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) 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; 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
};
}
} }

View File

@@ -0,0 +1,58 @@
using OCSentinelCli;
using Xunit;
namespace OCSentinelCli.Tests;
public sealed class ExchangeIisAccountCorrelatorTests
{
[Fact]
public void EnrichesAnIisFailureFromOneMatchingSecurityFailure()
{
DateTimeOffset timestamp = new(2026, 8, 5, 4, 0, 0, TimeSpan.Zero);
List<AttackEvent> events =
[
IisFailure(timestamp),
SecurityFailure(timestamp.AddSeconds(20), "user@example.test")
];
AttackEvent enriched = Assert.Single(ExchangeIisAccountCorrelator.Enrich(events), attack => attack.Source == "IIS W3C");
Assert.Equal("user@example.test", enriched.Username);
}
[Fact]
public void KeepsIisAccountUnresolvedWhenMultipleAccountsMatch()
{
DateTimeOffset timestamp = new(2026, 8, 5, 4, 0, 0, TimeSpan.Zero);
List<AttackEvent> events =
[
IisFailure(timestamp),
SecurityFailure(timestamp.AddSeconds(20), "first@example.test"),
SecurityFailure(timestamp.AddSeconds(30), "second@example.test")
];
AttackEvent unresolved = Assert.Single(ExchangeIisAccountCorrelator.Enrich(events), attack => attack.Source == "IIS W3C");
Assert.Equal("[not logged]", unresolved.Username);
}
private static AttackEvent IisFailure(DateTimeOffset timestamp) => new()
{
Timestamp = timestamp,
SourceIp = "198.51.100.8",
Username = "[not logged]",
Source = "IIS W3C",
Target = "Exchange ActiveSync login"
};
private static AttackEvent SecurityFailure(DateTimeOffset timestamp, string username) => new()
{
Timestamp = timestamp,
SourceIp = "198.51.100.8",
Username = username,
Source = "Security",
Service = "Network",
Target = "Windows login",
InstanceId = 4625
};
}

View File

@@ -39,4 +39,29 @@ public sealed class ExchangeIisLogParserTests
Assert.Equal(444, attack.DestinationPort); Assert.Equal(444, attack.DestinationPort);
Assert.Equal("user@example.test", attack.Username); 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)));
}
} }