Detection rules › Elastic

M365 Identity User Account Lockouts

Status
production
Severity
medium
Time window
9m
Group by
Esql.time_window_date_trunc
Author
Elastic
Source
github.com/elastic/detection-rules

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.

MITRE ATT&CK coverage

Event coverage

Rules detecting the same action

Other rules on this platform that filter on the same API call or operation.

Rule body elastic

[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/"

Stages and Predicates

Stage 1: from

from logs-o365.audit-*

Stage 2: mv_expand

| mv_expand event.category

Stage 3: eval

| eval
    Esql.time_window_date_trunc = date_trunc(5 minutes, @timestamp)

Stage 4: where

| 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"

Stage 5: stats

| 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

Stage 6: eval

| eval
    Esql.event_duration_seconds = date_diff("seconds", Esql.timestamp_first_seen, Esql.timestamp_last_seen)

Stage 7: keep

| 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

Stage 8: where

| where
    Esql_priv.o365_audit_UserId_count_distinct >= 10 and
    Esql.event_count >= 10 and
    Esql.event_duration_seconds <= 300

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
Esql.event_countge
  • 10
Esql.event_duration_secondsle
  • 300
Esql_priv.o365_audit_UserId_count_distinctge
  • 10
data_stream.dataseteq
  • o365.audit
event.actionin
  • PasswordLogonInitialAuthUsingPassword
  • UserLoginFailed
event.categoryeq
  • authentication
event.providerin
  • AzureActiveDirectory
  • Exchange
o365.audit.LogonErroreq
  • IdsLocked
o365.audit.Target.Typein
  • 0
  • 10
  • 2
  • 6
source.as.organization.namene
  • MICROSOFT-CORP-MSN-as-BLOCK

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
Esql.time_window_date_truncKEEP Esql.time_window_date_trunc
Esql_priv.o365_audit_UserId_count_distinctKEEP Esql_priv.o365_audit_UserId_count_distinct
Esql_priv.o365_audit_UserId_valuesKEEP Esql_priv.o365_audit_UserId_values
Esql.source_ip_valuesKEEP Esql.source_ip_values
Esql.source_ip_count_distinctKEEP Esql.source_ip_count_distinct
Esql.source_as_organization_name_valuesKEEP Esql.source_as_organization_name_values
Esql.source_as_organization_name_count_distinctKEEP Esql.source_as_organization_name_count_distinct
Esql.source_geo_country_name_valuesKEEP Esql.source_geo_country_name_values
Esql.source_geo_country_name_count_distinctKEEP Esql.source_geo_country_name_count_distinct
Esql.o365_audit_ExtendedProperties_RequestType_valuesKEEP Esql.o365_audit_ExtendedProperties_RequestType_values
Esql.timestamp_first_seenKEEP Esql.timestamp_first_seen
Esql.timestamp_last_seenKEEP Esql.timestamp_last_seen
Esql.event_countKEEP Esql.event_count
Esql.event_duration_secondsKEEP Esql.event_duration_seconds