Query Details
let query_frequency = 15m;
let query_period = 30m;
let query_wait = 30m;
let account_threshold = 10;
let _SuccessResultTypes = toscalar(
_GetWatchlist("ResultType-SignInLogsErrorCodes")
| where Notes has_any ("[Expired]", "[Success]") and isnotempty(ResultDescription)
| summarize make_list(ResultType)
);
ADFSSignInLogs
| where TimeGenerated between (ago(query_period + query_wait) .. ago(query_wait))
// | where not(todynamic(AuthenticationDetails)[0].authenticationMethod == "Integrated Windows Authentication")
// | lookup kind=leftouter (
// union SigninLogs, AADNonInteractiveUserSignInLogs
// | where TimeGenerated > ago(query_period + query_wait)
// | distinct CorrelationId, SecondaryIPAddress = IPAddress
// ) on CorrelationId
| extend
SuccessfulAuthentication = ResultType in (_SuccessResultTypes),
SummarizeKey = iff(ipv4_is_private(IPAddress) and not(UserAgent in ("", "-")), strcat(IPAddress, "<<>>", UserAgent), IPAddress)
| as _Events
| join kind=leftsemi (
_Events
| evaluate activity_counts_metrics(UserId, TimeGenerated, ago(query_period + query_wait), ago(query_wait), query_frequency, SuccessfulAuthentication, SummarizeKey)
| summarize
Results = make_bag(pack(iff(SuccessfulAuthentication, "Success", "Failure"), ["new_dcount"]))
by TimeGenerated, SummarizeKey
| summarize
PreviousTimeGenerated = arg_min(TimeGenerated, PreviousResults = Results),
CurrentTimeGenerated = arg_max(TimeGenerated, CurrentResults = Results)
by SummarizeKey
| where CurrentTimeGenerated > ago(query_period + query_wait)
| extend PreviousResults = iff(PreviousTimeGenerated == CurrentTimeGenerated, dynamic([]), PreviousResults)
// Remove cases where distinct accounts with failures don't surpass the threshold
| where CurrentResults["Failure"] > account_threshold
or (isnotempty(PreviousResults["Failure"]) and not(PreviousResults["Failure"] > account_threshold) and (toint(PreviousResults["Failure"]) + toint(CurrentResults["Failure"])) > account_threshold)
// Remove cases where successes surpass failures and IP address is private and the UserAgent is empty
| where not(not(SummarizeKey has "<<>>") and (isnotempty(parse_ipv4(SummarizeKey)) and ipv4_is_private(SummarizeKey)) and toint(CurrentResults["Success"]) > toint(CurrentResults["Failure"]))
) on SummarizeKey
| summarize
StartTime = min(TimeGenerated),
EndTime = max(TimeGenerated),
FailureAccountCount = dcountif(UserPrincipalName, not(SuccessfulAuthentication)),
SuccessAccountCount = dcountif(UserPrincipalName, SuccessfulAuthentication),
FailureAccounts = array_sort_asc(make_set_if(UserPrincipalName, not(SuccessfulAuthentication), 250)),
SuccessAccounts = array_sort_asc(make_set_if(UserPrincipalName, SuccessfulAuthentication)),
take_anyif(Location, isnotempty(Location)),
take_any(IPAddress, UserAgent, TokenIssuerName)
by SummarizeKey
| extend
AlertName = strcat(
"Password spray attack against AD FS",
case(
array_length(SuccessAccounts) > 0 and not(ipv4_is_private(IPAddress)), " - Compromised account",
array_length(SuccessAccounts) between (1 .. 5) and ipv4_is_private(IPAddress), " - Potentially compromised account",
""
)
),
AlertSeverity = case(
array_length(SuccessAccounts) > 0 and not(ipv4_is_private(IPAddress)), "High",
array_length(SuccessAccounts) between (1 .. 5) and ipv4_is_private(IPAddress), "Medium",
not(array_length(SuccessAccounts) > 0) and ipv4_is_private(IPAddress), "Medium",
"Informational"
)
// If an account is believed to be compromised, expand the results, so it appears in Entities
| mv-expand SuccessAccount = iff(AlertName has " - Compromised account", SuccessAccounts, dynamic([""])) to typeof(string)
| project
StartTime,
EndTime,
IPAddress,
Location,
UserAgent,
FailureAccountCount,
SuccessAccountCount,
SuccessAccount,
SuccessAccounts,
FailureAccounts,
TokenIssuerName,
AlertName,
AlertSeverity
This query is designed to detect and alert on potential password spray attacks against Active Directory Federation Services (AD FS).
It first defines several variables such as the frequency and period of the query, the wait time, and the account threshold. It then retrieves a list of successful result types from a watchlist.
The query then examines AD FS sign-in logs within a specific timeframe, extending the data to include whether the authentication was successful and a summary key based on the IP address and user agent.
The query then joins this data with activity count metrics, summarizing the results by time generated and summary key. It filters out cases where the number of distinct accounts with failures doesn't surpass the threshold, and where the number of successes surpasses failures for private IP addresses and empty user agents.
The query then summarizes the start and end times, counts of successful and failed accounts, and other details, and assigns an alert name and severity based on the number of successful accounts and whether the IP address is private.
Finally, if an account is believed to be compromised, the query expands the results so it appears in entities, and projects the final results including start and end times, IP address, location, user agent, account counts, and alert details.

Jose Sebastián Canós
Released: August 22, 2023
Tables
Keywords
Operators