Low & slow password attempts with volatile IP addresses
Description
'This hunting query will identify instances where a single user account has seen a high incidence of failed attempts from highly volatile IP addresses Changing IP address for every password attempt is becoming a more common technique amongst sophisticated threat groups. Often threat groups will randomise the user agent they are using as well as IP address. This technique has been enabled by the emergence of services providing huge numbers of residential IP addresses. These services are often enabled through malicious browser plugins. This query is best executed over longer timeframes. Reduce the timeRange if you have too much data. Results with the highest "IPs", "Failures" and "DaysWithAttempts" are good candidates for further investigation. This query intentionally does not cluster on UserAgent, IP etc. This query is clustering on the highly volatile IP behaviour.'
Query · kql
let starttime = todatetime('{{StartTimeISO}}');
let endtime = todatetime('{{EndTimeISO}}');
let timeRange = 365d;
let UnsuccessfulLoginCountryThreshold = 5; // Number of failed countries attempting to login, good way to filter.
SigninLogs
| where TimeGenerated between(starttime..endtime)
// Limit to username/password failure errors, most common when bruteforcing/spraying
| where ResultType has_any("50055","50126")
// Find instances where an IP has only been used once
| summarize IPLogins=count(), make_list(TimeGenerated) by IPAddress, Location, UserPrincipalName
| where IPLogins == 1
// We only keep instances where there is 1 event, so we know there will only be one datetime in the list
| extend LoginAttemptTime = format_datetime(todatetime(list_TimeGenerated[0]), 'dd-MM-yyyy')
// So far we've only collected failures, we join back to the log to ensure there were no successful logins from the IP
| join kind=leftouter (
SigninLogs
| where TimeGenerated > ago(timeRange)
| where ResultType == 0
| summarize count() by IPAddress, UserPrincipalNameSuccess=UserPrincipalName
) on $left.IPAddress == $right.IPAddress
// Where there have been fewer than 2 successful logins from the IP
| where count_ < 2 or isempty(count_)
// Confirm that the result is for the same account where possible
| where UserPrincipalName == UserPrincipalNameSuccess or isempty(UserPrincipalNameSuccess)
// Summarize the collected details around the users email address
| mv-expand list_TimeGenerated to typeof(datetime)
| summarize IPs=dcount(IPAddress), UnsuccessfulLoginCountryCount=dcount(Location), make_list(IPAddress), make_list(Location), DaysWithAttempts=dcount(LoginAttemptTime), Failures=count(), StartTime=min(list_TimeGenerated), EndTime=max(list_TimeGenerated) by UserPrincipalName
| project UserPrincipalName, StartTime, EndTime, Failures, IPs, UnsuccessfulLoginCountryCount, DaysWithAttempts, IPAddresses=list_IPAddress, IPAddressLocations=list_Location
// Join back to get countries the user has successfully authenticated from to compare with failures
| join kind=leftouter (
SigninLogs
| where TimeGenerated > ago(timeRange)
| where ResultType == 0
// If there is no location make the output pretty
| extend Location = iff(isempty(Location), "NODATA", Location)
| summarize SuccessfulLoginCountries=make_set(Location), SuccessfulLoginCountryCount=dcount(Location) by UserPrincipalName
) on $left.UserPrincipalName == $right.UserPrincipalName
| project-away UserPrincipalName1
| order by UnsuccessfulLoginCountryCount desc
// Calculate the difference between countries with successful vs. failed logins
| extend IPIncreaseOnSuccess = UnsuccessfulLoginCountryCount - SuccessfulLoginCountryCount
// The below line can be removed if the actor is using IPs in one country
| where UnsuccessfulLoginCountryCount > UnsuccessfulLoginCountryThreshold
| project StartTime, EndTime, UserPrincipalName, Failures, IPs, DaysWithAttempts, UnsuccessfulLoginCountryCount, UnuccessfulLoginCountries=IPAddressLocations, SuccessfulLoginCountries, FailureIPAddresses=IPAddresses
| extend timestamp = StartTime, AccountCustomEntity = UserPrincipalName, IPCustomEntity = FailureIPAddresses