← Back to Explore
elasticmediumTTP
M365 Identity User Account Lockouts
Detects a burst of Microsoft 365 user account lockouts within a short 5-minute window. A high number of IdsLocked login errors across multiple user accounts may indicate brute-force attempts for the same users resulting in lockouts.
Detection Query
from logs-o365.audit-*
| mv_expand event.category
| eval
Esql.time_window_date_trunc = date_trunc(5 minutes, @timestamp)
| where
data_stream.dataset == "o365.audit" and
event.category == "authentication" and
event.provider in ("AzureActiveDirectory", "Exchange") and
event.action in ("UserLoginFailed", "PasswordLogonInitialAuthUsingPassword") and
to_lower(o365.audit.ExtendedProperties.RequestType) rlike "(oauth.*||.*login.*)" and
o365.audit.LogonError == "IdsLocked" and
to_lower(o365.audit.UserId) != "not available" and
o365.audit.Target.Type in ("0", "2", "6", "10") and
source.`as`.organization.name != "MICROSOFT-CORP-MSN-as-BLOCK"
| stats
Esql_priv.o365_audit_UserId_count_distinct = count_distinct(to_lower(o365.audit.UserId)),
Esql_priv.o365_audit_UserId_values = values(to_lower(o365.audit.UserId)),
Esql.source_ip_values = values(source.ip),
Esql.source_ip_count_distinct = count_distinct(source.ip),
Esql.source_as_organization_name_values = values(source.`as`.organization.name),
Esql.source_as_organization_name_count_distinct = count_distinct(source.`as`.organization.name),
Esql.source_geo_country_name_values = values(source.geo.country_name),
Esql.source_geo_country_name_count_distinct = count_distinct(source.geo.country_name),
Esql.o365_audit_ExtendedProperties_RequestType_values = values(to_lower(o365.audit.ExtendedProperties.RequestType)),
Esql.timestamp_first_seen = min(@timestamp),
Esql.timestamp_last_seen = max(@timestamp),
Esql.event_count = count(*)
by Esql.time_window_date_trunc
| eval
Esql.event_duration_seconds = date_diff("seconds", Esql.timestamp_first_seen, Esql.timestamp_last_seen)
| keep
Esql.time_window_date_trunc,
Esql_priv.o365_audit_UserId_count_distinct,
Esql_priv.o365_audit_UserId_values,
Esql.source_ip_values,
Esql.source_ip_count_distinct,
Esql.source_as_organization_name_values,
Esql.source_as_organization_name_count_distinct,
Esql.source_geo_country_name_values,
Esql.source_geo_country_name_count_distinct,
Esql.o365_audit_ExtendedProperties_RequestType_values,
Esql.timestamp_first_seen,
Esql.timestamp_last_seen,
Esql.event_count,
Esql.event_duration_seconds
| where
Esql_priv.o365_audit_UserId_count_distinct >= 10 and
Esql.event_count >= 10 and
Esql.event_duration_seconds <= 300
Author
Elastic
Created
2025/05/10
Data Sources
Microsoft 365Microsoft 365 Audit Logs
References
- https://learn.microsoft.com/en-us/security/operations/incident-response-playbook-password-spray
- https://learn.microsoft.com/en-us/purview/audit-log-detailed-properties
- https://securityscorecard.com/research/massive-botnet-targets-m365-with-stealthy-password-spraying-attacks/
- https://github.com/0xZDH/Omnispray
- https://github.com/0xZDH/o365spray
Tags
Domain: CloudDomain: SaaSData Source: Microsoft 365Data Source: Microsoft 365 Audit LogsUse Case: Threat DetectionUse Case: Identity and Access AuditTactic: Credential AccessResources: Investigation Guide
Raw Content
[metadata]
creation_date = "2025/05/10"
integration = ["o365"]
maturity = "production"
updated_date = "2026/04/10"
[rule]
author = ["Elastic"]
description = """
Detects a burst of Microsoft 365 user account lockouts within a short 5-minute window. A high number of IdsLocked login
errors across multiple user accounts may indicate brute-force attempts for the same users resulting in lockouts.
"""
from = "now-9m"
interval = "8m"
language = "esql"
license = "Elastic License v2"
name = "M365 Identity User Account Lockouts"
note = """## Triage and Analysis
### Investigating M365 Identity User Account Lockouts
Detects a burst of Microsoft 365 user account lockouts within a short 5-minute window. A high number of IdsLocked login errors across multiple user accounts may indicate brute-force attempts for the same users resulting in lockouts.
This rule uses ESQL aggregations and thus has dynamically generated fields. Correlation of the values in the alert document may need to be performed to the original sign-in and Graph events for further context.
### Investigation Steps
- Review the `user_id_list`: Are specific naming patterns targeted (e.g., admin, helpdesk)?
- Examine `ip_list` and `source_orgs`: Look for suspicious ISPs or hosting providers.
- Check `duration_seconds`: A very short window with a high lockout rate often indicates automation.
- Confirm lockout policy thresholds with IAM or Entra ID admins. Did the policy trigger correctly?
- Use the `first_seen` and `last_seen` values to pivot into related authentication or audit logs.
- Correlate with any recent detection of password spraying or credential stuffing activity.
- Review the `request_type` field to identify which authentication methods were used (e.g., OAuth, SAML, etc.).
- Check for any successful logins from the same IP or ASN after the lockouts.
### False Positive Analysis
- Automated systems with stale credentials may cause repeated failed logins.
- Legitimate bulk provisioning or scripted tests could unintentionally cause account lockouts.
- Red team exercises or penetration tests may resemble the same lockout pattern.
- Some organizations may have a high volume of lockouts due to user behavior or legacy systems.
### Response Recommendations
- Notify affected users and confirm whether activity was expected or suspicious.
- Lock or reset credentials for impacted accounts.
- Block the source IP(s) or ASN temporarily using conditional access or firewall rules.
- Strengthen lockout and retry delay policies if necessary.
- Review the originating application(s) involved via `request_types`.
"""
references = [
"https://learn.microsoft.com/en-us/security/operations/incident-response-playbook-password-spray",
"https://learn.microsoft.com/en-us/purview/audit-log-detailed-properties",
"https://securityscorecard.com/research/massive-botnet-targets-m365-with-stealthy-password-spraying-attacks/",
"https://github.com/0xZDH/Omnispray",
"https://github.com/0xZDH/o365spray",
]
risk_score = 47
rule_id = "de67f85e-2d43-11f0-b8c9-f661ea17fbcc"
severity = "medium"
tags = [
"Domain: Cloud",
"Domain: SaaS",
"Data Source: Microsoft 365",
"Data Source: Microsoft 365 Audit Logs",
"Use Case: Threat Detection",
"Use Case: Identity and Access Audit",
"Tactic: Credential Access",
"Resources: Investigation Guide",
]
timestamp_override = "event.ingested"
type = "esql"
query = '''
from logs-o365.audit-*
| mv_expand event.category
| eval
Esql.time_window_date_trunc = date_trunc(5 minutes, @timestamp)
| where
data_stream.dataset == "o365.audit" and
event.category == "authentication" and
event.provider in ("AzureActiveDirectory", "Exchange") and
event.action in ("UserLoginFailed", "PasswordLogonInitialAuthUsingPassword") and
to_lower(o365.audit.ExtendedProperties.RequestType) rlike "(oauth.*||.*login.*)" and
o365.audit.LogonError == "IdsLocked" and
to_lower(o365.audit.UserId) != "not available" and
o365.audit.Target.Type in ("0", "2", "6", "10") and
source.`as`.organization.name != "MICROSOFT-CORP-MSN-as-BLOCK"
| stats
Esql_priv.o365_audit_UserId_count_distinct = count_distinct(to_lower(o365.audit.UserId)),
Esql_priv.o365_audit_UserId_values = values(to_lower(o365.audit.UserId)),
Esql.source_ip_values = values(source.ip),
Esql.source_ip_count_distinct = count_distinct(source.ip),
Esql.source_as_organization_name_values = values(source.`as`.organization.name),
Esql.source_as_organization_name_count_distinct = count_distinct(source.`as`.organization.name),
Esql.source_geo_country_name_values = values(source.geo.country_name),
Esql.source_geo_country_name_count_distinct = count_distinct(source.geo.country_name),
Esql.o365_audit_ExtendedProperties_RequestType_values = values(to_lower(o365.audit.ExtendedProperties.RequestType)),
Esql.timestamp_first_seen = min(@timestamp),
Esql.timestamp_last_seen = max(@timestamp),
Esql.event_count = count(*)
by Esql.time_window_date_trunc
| eval
Esql.event_duration_seconds = date_diff("seconds", Esql.timestamp_first_seen, Esql.timestamp_last_seen)
| keep
Esql.time_window_date_trunc,
Esql_priv.o365_audit_UserId_count_distinct,
Esql_priv.o365_audit_UserId_values,
Esql.source_ip_values,
Esql.source_ip_count_distinct,
Esql.source_as_organization_name_values,
Esql.source_as_organization_name_count_distinct,
Esql.source_geo_country_name_values,
Esql.source_geo_country_name_count_distinct,
Esql.o365_audit_ExtendedProperties_RequestType_values,
Esql.timestamp_first_seen,
Esql.timestamp_last_seen,
Esql.event_count,
Esql.event_duration_seconds
| where
Esql_priv.o365_audit_UserId_count_distinct >= 10 and
Esql.event_count >= 10 and
Esql.event_duration_seconds <= 300
'''
[[rule.threat]]
framework = "MITRE ATT&CK"
[[rule.threat.technique]]
id = "T1110"
name = "Brute Force"
reference = "https://attack.mitre.org/techniques/T1110/"
[[rule.threat.technique.subtechnique]]
id = "T1110.001"
name = "Password Guessing"
reference = "https://attack.mitre.org/techniques/T1110/001/"
[[rule.threat.technique.subtechnique]]
id = "T1110.003"
name = "Password Spraying"
reference = "https://attack.mitre.org/techniques/T1110/003/"
[[rule.threat.technique.subtechnique]]
id = "T1110.004"
name = "Credential Stuffing"
reference = "https://attack.mitre.org/techniques/T1110/004/"
[rule.threat.tactic]
id = "TA0006"
name = "Credential Access"
reference = "https://attack.mitre.org/tactics/TA0006/"
[[rule.threat]]
framework = "MITRE ATT&CK"
[[rule.threat.technique]]
id = "T1078"
name = "Valid Accounts"
reference = "https://attack.mitre.org/techniques/T1078/"
[[rule.threat.technique.subtechnique]]
id = "T1078.004"
name = "Cloud Accounts"
reference = "https://attack.mitre.org/techniques/T1078/004/"
[rule.threat.tactic]
id = "TA0001"
name = "Initial Access"
reference = "https://attack.mitre.org/tactics/TA0001/"