Detection rules › Kusto

User Accounts - Sign in Failure due to CA Spikes

Status
available
Severity
medium
Time window
14d
Group by
AnomalyHour, DateHour, UserPrincipalName
Source
github.com/Azure/Azure-Sentinel

' Identifies spike in failed sign-ins from user accounts due to conditional access policied. Spike is determined based on Time series anomaly which will look at historical baseline values. Ref : https://docs.microsoft.com/azure/active-directory/fundamentals/security-operations-user-accounts#monitoring-for-failed-unusual-sign-ins This query has also been updated to include UEBA logs IdentityInfo and BehaviorAnalytics for contextual information around the results.'

MITRE ATT&CK coverage

TacticTechniques
Initial AccessT1078.004 Valid Accounts: Cloud Accounts

Rule body kusto

id: 3a9d5ede-2b9d-43a2-acc4-d272321ff77c
name: User Accounts - Sign in Failure due to CA Spikes
description: |
  ' Identifies spike in failed sign-ins from user accounts due to conditional access policied.
  Spike is determined based on Time series anomaly which will look at historical baseline values.
  Ref : https://docs.microsoft.com/azure/active-directory/fundamentals/security-operations-user-accounts#monitoring-for-failed-unusual-sign-ins
  This query has also been updated to include UEBA logs IdentityInfo and BehaviorAnalytics for contextual information around the results.'
severity: Medium
requiredDataConnectors:
  - connectorId: AzureActiveDirectory
    dataTypes:
      - SigninLogs
  - connectorId: AzureActiveDirectory
    dataTypes:
      - AADNonInteractiveUserSignInLogs
  - connectorId: BehaviorAnalytics
    dataTypes:
      - BehaviorAnalytics
  - connectorId: BehaviorAnalytics
    dataTypes:
      - IdentityInfo
queryFrequency: 1d
queryPeriod: 14d
triggerOperator: gt
triggerThreshold: 0
status: Available
tactics:
  - InitialAccess
relevantTechniques:
  - T1078.004
tags:
  - AADSecOpsGuide
query: |
  let riskScoreCutoff = 20; //Adjust this based on volume of results
  let starttime = 14d;
  let timeframe = 1d;
  let scorethreshold = 3;
  let baselinethreshold = 50;
  let aadFunc = (tableName:string){
    // Failed Signins attempts with reasoning related to conditional access policies.
    table(tableName)
    | where TimeGenerated between (startofday(ago(starttime))..startofday(now()))
    | where ResultDescription has_any ("conditional access", "CA") or ResultType in (50005, 50131, 53000, 53001, 53002, 52003, 70044)
    | extend UserPrincipalName = tolower(UserPrincipalName)
    | extend timestamp = TimeGenerated, AccountCustomEntity = UserPrincipalName
  };
  let aadSignin = aadFunc("SigninLogs");
  let aadNonInt = aadFunc("AADNonInteractiveUserSignInLogs");
  let allSignins = union isfuzzy=true aadSignin, aadNonInt;
  let TimeSeriesAlerts = 
  allSignins
  | make-series DailyCount=count() on TimeGenerated from startofday(ago(starttime)) to startofday(now()) step 1d by UserPrincipalName
  | extend (anomalies, score, baseline) = series_decompose_anomalies(DailyCount, scorethreshold, -1, 'linefit')
  | mv-expand DailyCount to typeof(double), TimeGenerated to typeof(datetime), anomalies to typeof(double), score to typeof(double), baseline to typeof(long)
  // Filtering low count events per baselinethreshold
  | where anomalies > 0 and baseline > baselinethreshold
  | extend AnomalyHour = TimeGenerated
  | project UserPrincipalName, AnomalyHour, TimeGenerated, DailyCount, baseline, anomalies, score;
  // Filter the alerts for specified timeframe
  TimeSeriesAlerts
  | where TimeGenerated > startofday(ago(timeframe))
  | join kind=inner ( 
    allSignins
    | where TimeGenerated > startofday(ago(timeframe))
    // create a new column and round to hour
    | extend DateHour = bin(TimeGenerated, 1h)
    | summarize PartialFailedSignins = count(), LatestAnomalyTime = arg_max(TimeGenerated, *) by bin(TimeGenerated, 1h), OperationName, Category, ResultType, ResultDescription, UserPrincipalName, UserDisplayName, AppDisplayName, ClientAppUsed, IPAddress, ResourceDisplayName
  ) on UserPrincipalName, $left.AnomalyHour == $right.DateHour
  | project LatestAnomalyTime, OperationName, Category, UserPrincipalName, UserDisplayName, ResultType, ResultDescription, AppDisplayName, ClientAppUsed, UserAgent, IPAddress, Location, AuthenticationRequirement, ConditionalAccessStatus, ResourceDisplayName, PartialFailedSignins, TotalFailedSignins = DailyCount, baseline, anomalies, score
  | extend timestamp = LatestAnomalyTime, Name = tostring(split(UserPrincipalName,'@',0)[0]), UPNSuffix = tostring(split(UserPrincipalName,'@',1)[0])
  | extend UserPrincipalName = tolower(UserPrincipalName)
  | join kind=leftouter (
      IdentityInfo
      | summarize LatestReportTime = arg_max(TimeGenerated, *) by AccountUPN
      | project AccountUPN, Tags, JobTitle, GroupMembership, AssignedRoles, UserType, IsAccountEnabled
      | summarize
          Tags = make_set(Tags, 1000),
          GroupMembership = make_set(GroupMembership, 1000),
          AssignedRoles = make_set(AssignedRoles, 1000),
          UserType = make_set(UserType, 1000),
          UserAccountControl = make_set(UserType, 1000)
      by AccountUPN
      | extend UserPrincipalName=tolower(AccountUPN)
  ) on UserPrincipalName
  | join kind=leftouter (
      BehaviorAnalytics
      | where ActivityType in ("FailedLogOn", "LogOn")
      | where isnotempty(SourceIPAddress)
      | project UsersInsights, DevicesInsights, ActivityInsights, InvestigationPriority, SourceIPAddress
      | project-rename IPAddress = SourceIPAddress
      | summarize
          UsersInsights = make_set(UsersInsights, 1000),
          DevicesInsights = make_set(DevicesInsights, 1000),
          IPInvestigationPriority = sum(InvestigationPriority)
      by IPAddress)
  on IPAddress
  | extend UEBARiskScore = IPInvestigationPriority
  | where UEBARiskScore > riskScoreCutoff
  | sort by UEBARiskScore desc 
entityMappings:
  - entityType: Account
    fieldMappings:
      - identifier: FullName
        columnName: UserPrincipalName
      - identifier: Name
        columnName: Name
      - identifier: UPNSuffix
        columnName: UPNSuffix
  - entityType: IP
    fieldMappings:
      - identifier: Address
        columnName: IPAddress
version: 2.0.5
kind: Scheduled

Stages and Predicates

Parameters

let riskScoreCutoff = 20;
let starttime = 14d;
let timeframe = 1d;
let scorethreshold = 3;
let baselinethreshold = 50;
let aadSignin = aadFunc("SigninLogs");
let aadNonInt = aadFunc("AADNonInteractiveUserSignInLogs");
let allSignins = union isfuzzy=true aadSignin, aadNonInt;

Let binding: aadFunc

let aadFunc = (tableName:string){
  table(tableName)
  | where TimeGenerated between (startofday(ago(starttime))..startofday(now()))
  | where ResultDescription has_any ("conditional access", "CA") or ResultType in (50005, 50131, 53000, 53001, 53002, 52003, 70044)
  | extend UserPrincipalName = tolower(UserPrincipalName)
  | extend timestamp = TimeGenerated, AccountCustomEntity = UserPrincipalName
};

Derived from starttime.

The stages below define let TimeSeriesAlerts (the rule's main pipeline source).

Stage 1: union

union of 2 branches

Stage 2: source

aadFunc

Stage 3: source

aadFunc

Stage 4: summarize

summarize DailyCount by UserPrincipalName

Stage 5: extend

extend anomalies, baseline, score

Stage 6: mv-expand

mv-expand DailyCount

Stage 7: where

where anomalies > 0 and baseline > 50

The stages below run on TimeSeriesAlerts (the outer pipeline).

Stage 8: extend

extend AnomalyHour

Stage 9: project

project AnomalyHour, DailyCount, TimeGenerated, UserPrincipalName, anomalies, baseline, score

Stage 10: where

where ...

Stage 11: join

join kind=inner (...)

Stage 12: project

project AppDisplayName, AuthenticationRequirement, Category, ClientAppUsed, ConditionalAccessStatus, IPAddress, LatestAnomalyTime, Location, OperationName, PartialFailedSignins, ResourceDisplayName, ResultDescription, ResultType, TotalFailedSignins, UserAgent, UserDisplayName, UserPrincipalName, anomalies, baseline, score

Stage 13: extend

extend Name, UPNSuffix, timestamp

Stage 14: extend

extend UserPrincipalName

Stage 15: join

join kind=leftouter (...)

Stage 16: join

join kind=leftouter (...)

Stage 17: extend

extend UEBARiskScore

Stage 18: where

where UEBARiskScore > 20

Stage 19: sort

sort by UEBARiskScore

Indicators

Each row is a field, operator, and value that the rule matches. The corpus column counts how many other rules in the catalog look for the same combination: high numbers point to widely-used, community-vetted indicators. Blank or 1 shows that the indicator is specific to this rule.

FieldKindValues
ActivityTypein
  • FailedLogOn transforms: cased
  • LogOn transforms: cased
SourceIPAddressis_not_null
  • (no value, null check)
UEBARiskScoregt
  • 20 transforms: cased
anomaliesgt
  • 0 transforms: cased
baselinegt
  • 50 transforms: cased

Output fields

Fields the rule emits when it matches. Chronicle authors list these in the outcome block; they appear on the detection and $risk_score drives alerting. Sentinel / Defender XDR rules build them up through project / summarize / extend stages. Sentinel maps these into alert fields via entityMappings and customDetails; Defender XDR custom detections surface them as alert fields directly.

FieldSource
AppDisplayNameproject
AuthenticationRequirementproject
Categoryproject
ClientAppUsedproject
ConditionalAccessStatusproject
IPAddressproject
LatestAnomalyTimeproject
Locationproject
OperationNameproject
PartialFailedSigninsproject
ResourceDisplayNameproject
ResultDescriptionproject
ResultTypeproject
TotalFailedSigninsproject
UserAgentproject
UserDisplayNameproject
UserPrincipalNameextend
anomaliesproject
baselineproject
scoreproject
Nameextend
UPNSuffixextend
timestampextend
UEBARiskScoreextend