← Back to SOC feed Coverage →

Low & slow password attempts with volatile IP addresses

kql MEDIUM Azure-Sentinel
T1078T1078.004T1110T1110.004T1110.003
SigninLogs
huntingmicrosoftofficial
This rule was pulled from an open-source repository and enriched with AI. Validate in a test environment before deploying to production.
View original rule at Azure-Sentinel →
Retrieved: 2026-06-04T11:00:00Z · Confidence: medium

Hunt Hypothesis

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

Analytic Rule Definition

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

Required Data Sources

Sentinel TableNotes
SigninLogsEnsure this data connector is enabled

MITRE ATT&CK Context

References

False Positive Guidance

Original source: https://github.com/Azure/Azure-Sentinel/blob/main/Hunting Queries/SigninLogs/LowAndSlowPasswordAttempt.yaml