Adversaries are using low-and-slow password spraying tactics with rapidly changing volatile IP addresses to evade detection and maintain persistence. SOC teams should proactively hunt for this behavior in Azure Sentinel to identify potential credential compromise attempts and disrupt ongoing attacks before they escalate.
KQL Query
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
id: 3d217bb4-9cc2-4aba-838a-48e606e910e6
name: 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.'
requiredDataConnectors:
- connectorId: AzureActiveDirectory
dataTypes:
- SigninLogs
tactics:
- InitialAccess
- CredentialAccess
relevantTechniques:
- T1078
- T1078.004
- T1110
- T1110.004
- T1110.003
query: |
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), Fai
| Sentinel Table | Notes |
|---|---|
SigninLogs | Ensure this data connector is enabled |
Scenario: Scheduled System Maintenance or Patching Jobs
Description: Automated maintenance tasks or patching jobs may use temporary or dynamic IP addresses to communicate with external servers.
Filter/Exclusion: Exclude IP addresses associated with known internal or external maintenance tools (e.g., Ansible, Puppet, Jenkins) or use a src_ip field filter to exclude IPs from a predefined list of trusted maintenance IPs.
Scenario: Legitimate User-Initiated Remote Access via VPN
Description: A user may connect to the network via a VPN that assigns a new IP address each time they authenticate, especially if using a mobile or public network.
Filter/Exclusion: Exclude IP addresses from known corporate VPN gateways (e.g., Cisco ASA, Palo Alto PA) or use a src_ip field filter to exclude IPs from the organization’s internal or external VPN ranges.
Scenario: Automated Password Reset Tools
Description: Password reset tools (e.g., Okta, Microsoft Azure AD Password Reset) may generate temporary or dynamic IP addresses when initiating reset requests from external sources.
Filter/Exclusion: Exclude IP addresses associated with known password reset services or use a src_ip field filter to exclude IPs from the organization’s password reset service IP ranges.
Scenario: Internal Network Scanning or Discovery Tools
Description: Tools like Nmap, Masscan, or internal network discovery tools may use multiple IP addresses to scan for open ports or services, which could be mistaken for low-and-slow password attempts.
Filter/Exclusion: Exclude IP addresses associated with known internal scanning tools or use a src_ip field filter to exclude IPs from the organization’s internal network scanning tools.
Scenario: Cloud Infrastructure Provisioning or Decommissioning
Description: Cloud platforms (e.g., AWS, Azure) may assign temporary IP addresses during instance