Detection rules › Kusto
MFA Spamming followed by Successful login
'Identifies MFA Spamming followed by Successful logins and by a successful authentication within a given time window. Default Failure count is 10 and 1 successful login with default Time Window is 5 minutes.'
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Credential Access | T1110 Brute Force |
Rule body kusto
id: a8cc6d5c-4e7e-4b48-b4ac-d8a116c62a8b
name: MFA Spamming followed by Successful login
description: |
'Identifies MFA Spamming followed by Successful logins and by a successful authentication within a given time window.
Default Failure count is 10 and 1 successful login with default Time Window is 5 minutes.'
severity: High
requiredDataConnectors:
- connectorId: AzureActiveDirectory
dataTypes:
- SigninLogs
queryFrequency: 1d
queryPeriod: 1d
triggerOperator: gt
triggerThreshold: 0
status: Available
tactics:
- CredentialAccess
relevantTechniques:
- T1110
query: |
// Filter for sign-in logs ingested within the last day
SigninLogs
| where ingestion_time() > ago(1d)
// Filter for records with AuthenticationRequirement set to multiFactorAuthentication
| where AuthenticationRequirement == "multiFactorAuthentication"
// Extract information from dynamic columns DeviceDetail and LocationDetails
| extend DeviceDetail = todynamic(DeviceDetail), LocationDetails = todynamic(LocationDetails)
// Extract specific attributes from DeviceDetail and LocationDetails
| extend
OS = tostring(DeviceDetail.operatingSystem),
Browser = tostring(DeviceDetail.browser),
State = tostring(LocationDetails.state),
City = tostring(LocationDetails.city),
Region = tostring(LocationDetails.countryOrRegion)
// Expand multi-value property AuthenticationDetails into separate records
| mv-expand todynamic(AuthenticationDetails)
// Parse AuthResult from JSON in AuthenticationDetails and convert to string
| extend AuthResult = tostring(parse_json(AuthenticationDetails).authenticationStepResultDetail)
// Summarize data by aggregating statistics for each user, IP, and AuthResult
| summarize FailedAttempts = countif(AuthResult == "MFA denied; user declined the authentication" or AuthResult == "MFA denied; user did not respond to mobile app notification"), SuccessfulAttempts = countif(AuthResult == "MFA successfully completed"), InvolvedOS = make_set(OS, 5), InvolvedBrowser = make_set(Browser), StartTime = min(TimeGenerated), EndTime = max(TimeGenerated) by UserPrincipalName, IPAddress, State, City, Region
// Calculate AuthenticationWindow by finding time difference between start and end times
| extend AuthenticationWindow = (EndTime - StartTime)
// Filter for records with more than 10 failed attempts in 5-minute window and at least 1 successful attempt
| where FailedAttempts > 10 and AuthenticationWindow <= 5m and SuccessfulAttempts >= 1
// Extract user's name and UPN suffix using split function
| 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.0.4
kind: Scheduled
Stages and Predicates
Stage 1: source
SigninLogs
Stage 2: where
| where ingestion_time() > ago(1d)
Stage 3: where
| where AuthenticationRequirement == "multiFactorAuthentication"
Stage 4: extend
| extend DeviceDetail = todynamic(DeviceDetail), LocationDetails = todynamic(LocationDetails)
Stage 5: extend
| extend
OS = tostring(DeviceDetail.operatingSystem),
Browser = tostring(DeviceDetail.browser),
State = tostring(LocationDetails.state),
City = tostring(LocationDetails.city),
Region = tostring(LocationDetails.countryOrRegion)
Stage 6: mv-expand
| mv-expand todynamic(AuthenticationDetails)
Stage 7: extend
| extend AuthResult = tostring(parse_json(AuthenticationDetails).authenticationStepResultDetail)
Stage 8: summarize
| summarize FailedAttempts = countif(AuthResult == "MFA denied; user declined the authentication" or AuthResult == "MFA denied; user did not respond to mobile app notification"), SuccessfulAttempts = countif(AuthResult == "MFA successfully completed"), InvolvedOS = make_set(OS, 5), InvolvedBrowser = make_set(Browser), StartTime = min(TimeGenerated), EndTime = max(TimeGenerated) by UserPrincipalName, IPAddress, State, City, Region
Stage 9: extend
| extend AuthenticationWindow = (EndTime - StartTime)
Stage 10: where
| where FailedAttempts > 10 and AuthenticationWindow <= 5m and SuccessfulAttempts >= 1
Stage 11: extend
| extend Name = tostring(split(UserPrincipalName, '@', 0)[0]), UPNSuffix = tostring(split(UserPrincipalName, '@', 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.
| Field | Kind | Values |
|---|---|---|
AuthenticationRequirement | eq |
|
AuthenticationWindow | le |
|
FailedAttempts | gt |
|
SuccessfulAttempts | ge |
|
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 |
|---|---|
City | summarize |
EndTime | summarize |
FailedAttempts | summarize |
IPAddress | summarize |
InvolvedBrowser | summarize |
InvolvedOS | summarize |
Region | summarize |
StartTime | summarize |
State | summarize |
SuccessfulAttempts | summarize |
UserPrincipalName | summarize |
AuthenticationWindow | extend |
Name | extend |
UPNSuffix | extend |