Detection rules › Kusto

Password spray attack against Microsoft Entra ID Seamless SSO

Status
available
Severity
medium
Time window
1h
Group by
IPAddress, ResultType, Type
Source
github.com/Azure/Azure-Sentinel

'This query detects when there is a spike in Microsoft Entra ID Seamless SSO errors. They may not be caused by a Password Spray attack, but the cause of the errors might need to be investigated. Microsoft Entra ID only logs the requests that matched existing accounts, thus there might have been unlogged requests for non-existing accounts.'

MITRE ATT&CK coverage

TacticTechniques
Credential AccessT1110 Brute Force

Rule body kusto

id: fb7ca1c9-e14c-40a3-856e-28f3c14ea1ba
name: Password spray attack against Microsoft Entra ID Seamless SSO
description: |
  'This query detects when there is a spike in Microsoft Entra ID Seamless SSO errors. They may not be caused by a Password Spray attack, but the cause of the errors might need to be investigated.
  Microsoft Entra ID only logs the requests that matched existing accounts, thus there might have been unlogged requests for non-existing accounts.'
severity: Medium
requiredDataConnectors:
  - connectorId: AzureActiveDirectory
    dataTypes:
      - AADNonInteractiveUserSignInLogs
queryFrequency: 1h
queryPeriod: 1h
triggerOperator: gt
triggerThreshold: 0
status: Available
tactics:
  - CredentialAccess
relevantTechniques:
  - T1110
query: |
  let account_threshold = 5;
  AADNonInteractiveUserSignInLogs
  //| where ResultType == "81016"
  | where ResultType startswith "81"
  | summarize DistinctAccounts = dcount(UserPrincipalName), DistinctAddresses = make_set(IPAddress,100) by ResultType
  | where DistinctAccounts > account_threshold
  | mv-expand IPAddress = DistinctAddresses
  | extend IPAddress = tostring(IPAddress)
  | join kind=leftouter (union isfuzzy=true SigninLogs, AADNonInteractiveUserSignInLogs) on IPAddress
  | summarize
      StartTime = min(TimeGenerated),
      EndTime = max(TimeGenerated),
      UserPrincipalName = make_set(UserPrincipalName,100),
      UserAgent = make_set(UserAgent,100),
      ResultDescription = take_any(ResultDescription),
      ResultSignature = take_any(ResultSignature)
      by IPAddress, Type, ResultType
  | project Type, StartTime, EndTime, IPAddress, ResultType, ResultDescription, ResultSignature, UserPrincipalName, UserAgent = iff(array_length(UserAgent) == 1, UserAgent[0], UserAgent)
  | extend Name = tostring(split(UserPrincipalName[0],'@',0)[0]), UPNSuffix = tostring(split(UserPrincipalName[0],'@',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.0.4
kind: Scheduled

Stages and Predicates

Parameters

let account_threshold = 5;

Stage 1: source

AADNonInteractiveUserSignInLogs

Stage 2: where

| where ResultType startswith "81"

Stage 3: summarize

| summarize DistinctAccounts = dcount(UserPrincipalName), DistinctAddresses = make_set(IPAddress,100) by ResultType
Threshold
gt 5

Stage 4: where

| where DistinctAccounts > account_threshold

Stage 5: mv-expand

| mv-expand IPAddress = DistinctAddresses

Stage 6: extend

| extend IPAddress = tostring(IPAddress)

Stage 7: join

| join kind=leftouter (union isfuzzy=true SigninLogs, AADNonInteractiveUserSignInLogs) on IPAddress

Stage 8: summarize

| summarize
    StartTime = min(TimeGenerated),
    EndTime = max(TimeGenerated),
    UserPrincipalName = make_set(UserPrincipalName,100),
    UserAgent = make_set(UserAgent,100),
    ResultDescription = take_any(ResultDescription),
    ResultSignature = take_any(ResultSignature)
    by IPAddress, Type, ResultType

Stage 9: project

| project Type, StartTime, EndTime, IPAddress, ResultType, ResultDescription, ResultSignature, UserPrincipalName, UserAgent = iff(array_length(UserAgent) == 1, UserAgent[0], UserAgent)

Stage 10: extend

| extend Name = tostring(split(UserPrincipalName[0],'@',0)[0]), UPNSuffix = tostring(split(UserPrincipalName[0],'@',1)[0])

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
DistinctAccountsgt
  • 5 transforms: cased
ResultTypestarts_with
  • 81

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
EndTimeproject
IPAddressproject
ResultDescriptionproject
ResultSignatureproject
ResultTypeproject
StartTimeproject
Typeproject
UserAgentproject
UserPrincipalNameproject
Nameextend
UPNSuffixextend