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
Raw source Low & slow password attempts with volatile IP addresses · KQL
Esc
Published by Azure/Azure-Sentinel ↗, licensed under MIT ↗. Reproduced here unmodified.
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), 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
entityMappings:
  - entityType: Account
    fieldMappings:
      - identifier: FullName
        columnName: UserPrincipalName
  - entityType: IP
    fieldMappings:
      - identifier: Address
        columnName: IPAddresses
version: 1.0.0

Detection rules belong to the projects that publish them and remain under their own licenses. This site indexes and links to them; it claims no rights in them.