Detection rules › Kusto

Password spray attack against Microsoft Entra ID application

Status
available
Severity
medium
Time window
7d
Source
github.com/Azure/Azure-Sentinel

'Identifies evidence of password spray activity against Microsoft Entra ID applications by looking for failures from multiple accounts from the same IP address within a time window. If the number of accounts breaches the threshold just once, all failures from the IP address within the time range are bought into the result. Details on whether there were successful authentications by the IP address within the time window are also included. This can be an indicator that an attack was successful. The default failure acccount threshold is 5, Default time window for failures is 20m and default look back window is 1 day Note: Due to the number of possible accounts involved in a password spray it is not possible to map identities to a custom entity. References: https://docs.microsoft.com/azure/active-directory/reports-monitoring/reference-sign-ins-error-codes.'

MITRE ATT&CK coverage

TacticTechniques
Credential AccessT1110 Brute Force

Rule body kusto

id: 48607a29-a26a-4abf-8078-a06dbdd174a4
name: Password spray attack against Microsoft Entra ID application
description: |
  'Identifies evidence of password spray activity against Microsoft Entra ID applications by looking for failures from multiple accounts from the same IP address within a time window. If the number of accounts breaches the threshold just once, all failures from the IP address within the time range are bought into the result. Details on whether there were successful authentications by the IP address within the time window are also included.
  This can be an indicator that an attack was successful.
  The default failure acccount threshold is 5, Default time window for failures is 20m and default look back window is 1 day
  Note: Due to the number of possible accounts involved in a password spray it is not possible to map identities to a custom entity.
  References: https://docs.microsoft.com/azure/active-directory/reports-monitoring/reference-sign-ins-error-codes.'
severity: Medium
requiredDataConnectors:
  - connectorId: AzureActiveDirectory
    dataTypes:
      - SigninLogs
  - connectorId: AzureActiveDirectory
    dataTypes:
      - AADNonInteractiveUserSignInLogs
queryFrequency: 1d
queryPeriod: 7d
triggerOperator: gt
triggerThreshold: 0
status: Available
tactics:
  - CredentialAccess
relevantTechniques:
  - T1110
query: |
    let timeRange = 1d;
    let lookBack = 7d;
    let authenticationWindow = 20m;
    let authenticationThreshold = 5;
    let isGUID = "[0-9a-z]{8}-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{12}";
    let failureCodes = dynamic([50053, 50126, 50055]); // invalid password, account is locked - too many sign ins, expired password
    let successCodes = dynamic([0, 50055, 50057, 50155, 50105, 50133, 50005, 50076, 50079, 50173, 50158, 50072, 50074, 53003, 53000, 53001, 50129]);
    // Lookup up resolved identities from last 7 days
    let aadFunc = (tableName:string){
    let identityLookup = table(tableName)
    | where TimeGenerated >= ago(lookBack)
    | where not(Identity matches regex isGUID)
    | where isnotempty(UserId)
    | summarize by UserId, lu_UserDisplayName = UserDisplayName, lu_UserPrincipalName = UserPrincipalName, Type;
    // collect window threshold breaches
    table(tableName)
    | where TimeGenerated > ago(timeRange)
    | where ResultType in(failureCodes)
    | summarize FailedPrincipalCount = dcount(UserPrincipalName) by bin(TimeGenerated, authenticationWindow), IPAddress, AppDisplayName, Type
    | where FailedPrincipalCount >= authenticationThreshold
    | summarize WindowThresholdBreaches = count() by IPAddress, Type
    | join kind= inner (
    // where we breached a threshold, join the details back on all failure data
    table(tableName)
    | where TimeGenerated > ago(timeRange)
    | where ResultType in(failureCodes)
    | extend LocationDetails = todynamic(LocationDetails)
    | extend FullLocation = strcat(LocationDetails.countryOrRegion,'|', LocationDetails.state, '|', LocationDetails.city)
    | summarize StartTime = min(TimeGenerated), EndTime = max(TimeGenerated), make_set(ClientAppUsed,20), make_set(FullLocation,20), FailureCount = count() by IPAddress, AppDisplayName, UserPrincipalName, UserDisplayName, Identity, UserId, Type
    // lookup any unresolved identities
    | extend UnresolvedUserId = iff(Identity matches regex isGUID, UserId, "")
    | join kind= leftouter (
     identityLookup
    ) on $left.UnresolvedUserId==$right.UserId
    | extend UserDisplayName=iff(isempty(lu_UserDisplayName), UserDisplayName, lu_UserDisplayName)
    | extend UserPrincipalName=iff(isempty(lu_UserPrincipalName), UserPrincipalName, lu_UserPrincipalName)
    | summarize StartTime = min(StartTime), EndTime = max(EndTime), make_set(UserPrincipalName,20), make_set(UserDisplayName,20), make_set(set_ClientAppUsed,20), make_set(set_FullLocation,20), make_list(FailureCount,20) by IPAddress, AppDisplayName, Type
    | extend FailedPrincipalCount = array_length(set_UserPrincipalName)
    ) on IPAddress
    | project IPAddress, StartTime, EndTime, TargetedApplication=AppDisplayName, FailedPrincipalCount, UserPrincipalNames=set_UserPrincipalName, UserDisplayNames=set_UserDisplayName, ClientAppsUsed=set_set_ClientAppUsed, Locations=set_set_FullLocation, FailureCountByPrincipal=list_FailureCount, WindowThresholdBreaches, Type
    | join kind= inner (
    table(tableName) // get data on success vs. failure history for each IP
    | where TimeGenerated > ago(timeRange)
    | where ResultType in(successCodes) or ResultType in(failureCodes) // success or failure types
    | summarize GlobalSuccessPrincipalCount = dcountif(UserPrincipalName, (ResultType in (successCodes))), ResultTypeSuccesses = make_set_if(ResultType, (ResultType in (successCodes))), GlobalFailPrincipalCount = dcountif(UserPrincipalName, (ResultType in (failureCodes))), ResultTypeFailures = make_set_if(ResultType, (ResultType in (failureCodes))) by IPAddress, Type
    | where GlobalFailPrincipalCount > GlobalSuccessPrincipalCount // where the number of failed principals is greater than success - eliminates FPs from IPs who authenticate successfully alot and as a side effect have alot of failures
    ) on IPAddress
    | project-away IPAddress1
    | extend timestamp=StartTime
    };
    let aadSignin = aadFunc("SigninLogs");
    let aadNonInt = aadFunc("AADNonInteractiveUserSignInLogs");
    union isfuzzy=true aadSignin, aadNonInt
entityMappings:
  - entityType: IP
    fieldMappings:
      - identifier: Address
        columnName: IPAddress
version: 1.0.7
kind: Scheduled

Stages and Predicates

Parameters

let timeRange = 1d;
let lookBack = 7d;
let authenticationWindow = 20m;
let authenticationThreshold = 5;
let isGUID = "[0-9a-z]{8}-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{12}";
let failureCodes = dynamic([50053, 50126, 50055]);
let successCodes = dynamic([0, 50055, 50057, 50155, 50105, 50133, 50005, 50076, 50079, 50173, 50158, 50072, 50074, 53003, 53000, 53001, 50129]);
let aadSignin = aadFunc("SigninLogs");
let aadNonInt = aadFunc("AADNonInteractiveUserSignInLogs");

Let binding: aadFunc

let aadFunc = (tableName:string){
let identityLookup = table(tableName)
| where TimeGenerated >= ago(lookBack)
| where not(Identity matches regex isGUID)
| where isnotempty(UserId)
| summarize by UserId, lu_UserDisplayName = UserDisplayName, lu_UserPrincipalName = UserPrincipalName, Type;

Derived from lookBack, isGUID.

Stage 1: union

union of 2 branches

Stage 2: source

aadFunc

Stage 3: source

aadFunc