Detection rules › Kusto
Privileged Accounts - Sign in Failure Spikes
' Identifies spike in failed sign-ins from Privileged accounts. Privileged accounts list can be based on IdentityInfo UEBA table. 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-privileged-accounts#things-to-monitor'
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Initial Access | T1078.004 Valid Accounts: Cloud Accounts |
Rule body kusto
id: 34c5aff9-a8c2-4601-9654-c7e46342d03b
name: Privileged Accounts - Sign in Failure Spikes
description: |
' Identifies spike in failed sign-ins from Privileged accounts. Privileged accounts list can be based on IdentityInfo UEBA table.
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-privileged-accounts#things-to-monitor'
severity: High
requiredDataConnectors:
- connectorId: AzureActiveDirectory
dataTypes:
- SigninLogs
- connectorId: AzureActiveDirectory
dataTypes:
- AADNonInteractiveUserSignInLogs
- connectorId: BehaviorAnalytics
dataTypes:
- IdentityInfo
queryFrequency: 1d
queryPeriod: 14d
triggerOperator: gt
triggerThreshold: 0
status: Available
tactics:
- InitialAccess
relevantTechniques:
- T1078.004
tags:
- AADSecOpsGuide
query: |
let starttime = 14d;
let timeframe = 1d;
let scorethreshold = 3;
let baselinethreshold = 5;
let aadFunc = (tableName:string){
IdentityInfo
| where TimeGenerated > ago(starttime)
| summarize arg_max(TimeGenerated, *) by AccountUPN
| mv-expand AssignedRoles
| where AssignedRoles contains 'Admin' or GroupMembership has "Admin"
| summarize Roles = make_list(AssignedRoles) by AccountUPN = tolower(AccountUPN)
| join kind=inner (
table(tableName)
| where TimeGenerated between (startofday(ago(starttime))..startofday(now()))
| where ResultType != 0
| extend UserPrincipalName = tolower(UserPrincipalName)
) on $left.AccountUPN == $right.UserPrincipalName
| extend timestamp = TimeGenerated, AccountCustomEntity = UserPrincipalName, Roles = tostring(Roles)
};
let aadSignin = aadFunc("SigninLogs");
let aadNonInt = aadFunc("AADNonInteractiveUserSignInLogs");
let allSignins = union isfuzzy=true aadSignin, aadNonInt;
let TimeSeriesAlerts =
allSignins
| make-series HourlyCount=count() on TimeGenerated from startofday(ago(starttime)) to startofday(now()) step 1h by UserPrincipalName, Roles
| extend (anomalies, score, baseline) = series_decompose_anomalies(HourlyCount, scorethreshold, -1, 'linefit')
| mv-expand HourlyCount 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, Roles, AnomalyHour, TimeGenerated, HourlyCount, 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, Roles, UserDisplayName, AppDisplayName, ClientAppUsed, IPAddress, ResourceDisplayName
) on UserPrincipalName, $left.AnomalyHour == $right.DateHour
| project LatestAnomalyTime, OperationName, Category, UserPrincipalName, Roles = todynamic(Roles), UserDisplayName, ResultType, ResultDescription, AppDisplayName, ClientAppUsed, UserAgent, IPAddress, Location, AuthenticationRequirement, ConditionalAccessStatus, ResourceDisplayName, PartialFailedSignins, TotalFailedSignins = HourlyCount, baseline, anomalies, score
| extend Name = tostring(split(UserPrincipalName,'@',0)[0]), UPNSuffix = tostring(split(UserPrincipalName,'@',1)[0])
entityMappings:
- entityType: Account
fieldMappings:
- identifier: FullName
columnName: UserPrincipalName
- identifier: Name
columnName: Name
- identifier: UPNSuffix
columnName: UPNSuffix
- entityType: IP
fieldMappings:
- identifier: Address
columnName: IPAddress
version: 1.1.1
kind: Scheduled
Stages and Predicates
Parameters
let starttime = 14d;
let timeframe = 1d;
let scorethreshold = 3;
let baselinethreshold = 5;
let aadSignin = aadFunc("SigninLogs");
let aadNonInt = aadFunc("AADNonInteractiveUserSignInLogs");
let allSignins = union isfuzzy=true aadSignin, aadNonInt;
Let binding: aadFunc
let aadFunc = (tableName:string){
IdentityInfo
| where TimeGenerated > ago(starttime)
| summarize arg_max(TimeGenerated, *) by AccountUPN
| mv-expand AssignedRoles
| where AssignedRoles contains 'Admin' or GroupMembership has "Admin"
| summarize Roles = make_list(AssignedRoles) by AccountUPN = tolower(AccountUPN)
| join kind=inner (
table(tableName)
| where TimeGenerated between (startofday(ago(starttime))..startofday(now()))
| where ResultType != 0
| extend UserPrincipalName = tolower(UserPrincipalName)
) on $left.AccountUPN == $right.UserPrincipalName
| extend timestamp = TimeGenerated, AccountCustomEntity = UserPrincipalName, Roles = tostring(Roles)
};
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 HourlyCount by UserPrincipalName, Roles
Stage 5: extend
extend anomalies, baseline, score
Stage 6: mv-expand
mv-expand HourlyCount
Stage 7: where
where anomalies > 0 and baseline > 5
The stages below run on TimeSeriesAlerts (the outer pipeline).
Stage 8: extend
extend AnomalyHour
Stage 9: project
project AnomalyHour, HourlyCount, Roles, 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, Roles, TotalFailedSignins, UserAgent, UserDisplayName, UserPrincipalName, anomalies, baseline, score
Stage 13: extend
extend Name, UPNSuffix
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.
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.
| Field | Source |
|---|---|
AppDisplayName | project |
AuthenticationRequirement | project |
Category | project |
ClientAppUsed | project |
ConditionalAccessStatus | project |
IPAddress | project |
LatestAnomalyTime | project |
Location | project |
OperationName | project |
PartialFailedSignins | project |
ResourceDisplayName | project |
ResultDescription | project |
ResultType | project |
Roles | project |
TotalFailedSignins | project |
UserAgent | project |
UserDisplayName | project |
UserPrincipalName | project |
anomalies | project |
baseline | project |
score | project |
Name | extend |
UPNSuffix | extend |