Detection rules › Elastic

M365 Identity User Brute Force Attempted

Status
production
Severity
medium
Time window
1h
Group by
Esql.time_window_date_trunc
Author
Elastic, Willem D'Haese, Austin Songer
Source
github.com/elastic/detection-rules

Identifies brute-force authentication activity targeting Microsoft 365 user accounts using failed sign-in patterns that match password spraying, credential stuffing, or password guessing behavior. Adversaries may attempt brute-force authentication with credentials obtained from previous breaches, leaks, marketplaces or guessable passwords.

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 = "2020/11/30"
integration = ["o365"]
maturity = "production"
updated_date = "2026/04/10"

[rule]
author = ["Elastic", "Willem D'Haese", "Austin Songer"]
description = """
Identifies brute-force authentication activity targeting Microsoft 365 user accounts using failed sign-in patterns that
match password spraying, credential stuffing, or password guessing behavior. Adversaries may attempt brute-force
authentication with credentials obtained from previous breaches, leaks, marketplaces or guessable passwords.
"""
false_positives = [
    """
    Automated processes that attempt to authenticate using expired credentials and unbounded retries may lead to false
    positives.
    """,
]
from = "now-60m"
interval = "10m"
language = "esql"
license = "Elastic License v2"
name = "M365 Identity User Brute Force Attempted"
note = """## Triage and Analysis

### Investigating M365 Identity User Brute Force Attempted

Identifies brute-force authentication activity targeting Microsoft 365 user accounts using failed sign-in patterns that match password spraying, credential stuffing, or password guessing behavior. Adversaries may attempt brute-force authentication with credentials obtained from previous breaches, leaks, marketplaces or guessable passwords.

### Possible investigation steps

- Review `user_id_list`: Enumerates the user accounts targeted. Look for naming patterns or privilege levels (e.g., admins).
- Check `login_errors`: A consistent error such as `"InvalidUserNameOrPassword"` confirms a spray-style attack using one or a few passwords.
- Examine `ip_list` and `source_orgs`: Determine if the traffic originates from a known corporate VPN, datacenter, or suspicious ASN like hosting providers or anonymizers.
- Review `countries` and `unique_country_count`: Geographic anomalies (e.g., login attempts from unexpected regions) may indicate malicious automation.
- Validate `total_attempts` vs `duration_seconds`: A high frequency of login attempts over a short period may suggest automation rather than manual logins.
- Cross-reference with successful logins: Pivot to surrounding sign-in logs (`azure.signinlogs`) or risk detections (`identityprotection`) for any account that eventually succeeded.
- Check for multi-factor challenges or bypasses: Determine if any of the accounts were protected or if the attack bypassed MFA.

### False positive analysis

- IT administrators using automation tools (e.g., PowerShell) during account provisioning may trigger false positives if login attempts cluster.
- Penetration testing or red team simulations may resemble spray activity.
- Infrequent, low-volume login testing tools like ADFS testing scripts can exhibit similar patterns.

### Response and remediation

- Initiate an internal incident ticket and inform the affected identity/IT team.
- Temporarily disable impacted user accounts if compromise is suspected.
- Investigate whether any login attempts succeeded after the spray window.
- Block the offending IPs or ASN temporarily via firewall or conditional access policies.
- Rotate passwords for all targeted accounts and audit for password reuse.
- Enforce or verify MFA is enabled for all user accounts.
- Consider deploying account lockout or progressive delay mechanisms if not already enabled.
"""
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 = "26f68dba-ce29-497b-8e13-b4fde1db5a2d"
severity = "medium"
tags = [
    "Domain: Cloud",
    "Domain: SaaS",
    "Data Source: Microsoft 365",
    "Data Source: Microsoft 365 Audit Logs",
    "Use Case: Identity and Access Audit",
    "Use Case: Threat Detection",
    "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),
    Esql_priv.o365_audit_UserId_lower = to_lower(o365.audit.UserId),
    Esql.o365_audit_LogonError = o365.audit.LogonError,
    Esql.o365_audit_ExtendedProperties_RequestType_lower = to_lower(o365.audit.ExtendedProperties.RequestType)
| where
    data_stream.dataset == "o365.audit" and
    event.category == "authentication" and
    event.provider in ("AzureActiveDirectory", "Exchange") and
    event.action in ("UserLoginFailed", "PasswordLogonInitialAuthUsingPassword") and
    Esql.o365_audit_ExtendedProperties_RequestType_lower rlike "(oauth.*||.*login.*)" and
    Esql.o365_audit_LogonError != "IdsLocked" and
    Esql.o365_audit_LogonError not in (
        "EntitlementGrantsNotFound",
        "UserStrongAuthEnrollmentRequired",
        "UserStrongAuthClientAuthNRequired",
        "InvalidReplyTo",
        "SsoArtifactExpiredDueToConditionalAccess",
        "PasswordResetRegistrationRequiredInterrupt",
        "SsoUserAccountNotFoundInResourceTenant",
        "UserStrongAuthExpired",
        "CmsiInterrupt"
    ) and
    Esql_priv.o365_audit_UserId_lower != "not available" and
    o365.audit.Target.Type in ("0", "2", "6", "10")
| stats
    Esql.o365_audit_UserId_lower_count_distinct = count_distinct(Esql_priv.o365_audit_UserId_lower),
    Esql_priv.o365_audit_UserId_lower_values = values(Esql_priv.o365_audit_UserId_lower),
    Esql.o365_audit_LogonError_values = values(Esql.o365_audit_LogonError),
    Esql.o365_audit_LogonError_count_distinct = count_distinct(Esql.o365_audit_LogonError),
    Esql.o365_audit_ExtendedProperties_RequestType_values = values(Esql.o365_audit_ExtendedProperties_RequestType_lower),
    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_geo_country_name_values = values(source.geo.country_name),
    Esql.source_geo_country_name_count_distinct = count_distinct(source.geo.country_name),
    Esql.source_as_organization_name_count_distinct = count_distinct(source.`as`.organization.name),
    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),
    Esql.brute_force_type = case(
        Esql.o365_audit_UserId_lower_count_distinct >= 15 and Esql.o365_audit_LogonError_count_distinct == 1 and Esql.event_count >= 10 and Esql.event_duration_seconds <= 1800, "password_spraying",
        Esql.o365_audit_UserId_lower_count_distinct >= 8 and Esql.event_count >= 15 and Esql.o365_audit_LogonError_count_distinct <= 3 and Esql.source_ip_count_distinct <= 5 and Esql.event_duration_seconds <= 600, "credential_stuffing",
        Esql.o365_audit_UserId_lower_count_distinct == 1 and Esql.o365_audit_LogonError_count_distinct == 1 and Esql.event_count >= 20 and Esql.event_duration_seconds <= 300, "password_guessing",
        "other"
    )
| keep
    Esql.time_window_date_trunc,
    Esql.o365_audit_UserId_lower_count_distinct,
    Esql_priv.o365_audit_UserId_lower_values,
    Esql.o365_audit_LogonError_values,
    Esql.o365_audit_LogonError_count_distinct,
    Esql.o365_audit_ExtendedProperties_RequestType_values,
    Esql.source_ip_values,
    Esql.source_ip_count_distinct,
    Esql.source_as_organization_name_values,
    Esql.source_geo_country_name_values,
    Esql.source_geo_country_name_count_distinct,
    Esql.source_as_organization_name_count_distinct,
    Esql.timestamp_first_seen,
    Esql.timestamp_last_seen,
    Esql.event_duration_seconds,
    Esql.event_count,
    Esql.brute_force_type
| where Esql.brute_force_type != "other"
'''


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

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),
    Esql_priv.o365_audit_UserId_lower = to_lower(o365.audit.UserId),
    Esql.o365_audit_LogonError = o365.audit.LogonError,
    Esql.o365_audit_ExtendedProperties_RequestType_lower = to_lower(o365.audit.ExtendedProperties.RequestType)

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
    Esql.o365_audit_ExtendedProperties_RequestType_lower rlike "(oauth.*||.*login.*)" and
    Esql.o365_audit_LogonError != "IdsLocked" and
    Esql.o365_audit_LogonError not in (
        "EntitlementGrantsNotFound",
        "UserStrongAuthEnrollmentRequired",
        "UserStrongAuthClientAuthNRequired",
        "InvalidReplyTo",
        "SsoArtifactExpiredDueToConditionalAccess",
        "PasswordResetRegistrationRequiredInterrupt",
        "SsoUserAccountNotFoundInResourceTenant",
        "UserStrongAuthExpired",
        "CmsiInterrupt"
    ) and
    Esql_priv.o365_audit_UserId_lower != "not available" and
    o365.audit.Target.Type in ("0", "2", "6", "10")

Stage 5: stats

| stats
    Esql.o365_audit_UserId_lower_count_distinct = count_distinct(Esql_priv.o365_audit_UserId_lower),
    Esql_priv.o365_audit_UserId_lower_values = values(Esql_priv.o365_audit_UserId_lower),
    Esql.o365_audit_LogonError_values = values(Esql.o365_audit_LogonError),
    Esql.o365_audit_LogonError_count_distinct = count_distinct(Esql.o365_audit_LogonError),
    Esql.o365_audit_ExtendedProperties_RequestType_values = values(Esql.o365_audit_ExtendedProperties_RequestType_lower),
    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_geo_country_name_values = values(source.geo.country_name),
    Esql.source_geo_country_name_count_distinct = count_distinct(source.geo.country_name),
    Esql.source_as_organization_name_count_distinct = count_distinct(source.`as`.organization.name),
    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),
    Esql.brute_force_type = case(
        Esql.o365_audit_UserId_lower_count_distinct >= 15 and Esql.o365_audit_LogonError_count_distinct == 1 and Esql.event_count >= 10 and Esql.event_duration_seconds <= 1800, "password_spraying",
        Esql.o365_audit_UserId_lower_count_distinct >= 8 and Esql.event_count >= 15 and Esql.o365_audit_LogonError_count_distinct <= 3 and Esql.source_ip_count_distinct <= 5 and Esql.event_duration_seconds <= 600, "credential_stuffing",
        Esql.o365_audit_UserId_lower_count_distinct == 1 and Esql.o365_audit_LogonError_count_distinct == 1 and Esql.event_count >= 20 and Esql.event_duration_seconds <= 300, "password_guessing",
        "other"
    )
Esql.brute_force_type =
ifEsql.o365_audit_UserId_lower_count_distinct >= 15 and Esql.o365_audit_LogonError_count_distinct == 1 and Esql.event_count >= 10 and Esql.event_duration_seconds <= 1800"password_spraying"
elifEsql.o365_audit_UserId_lower_count_distinct >= 8 and Esql.event_count >= 15 and Esql.o365_audit_LogonError_count_distinct <= 3 and Esql.source_ip_count_distinct <= 5 and Esql.event_duration_seconds <= 600"credential_stuffing"
elifEsql.o365_audit_UserId_lower_count_distinct == 1 and Esql.o365_audit_LogonError_count_distinct == 1 and Esql.event_count >= 20 and Esql.event_duration_seconds <= 300"password_guessing"
else"other"

Stage 7: keep

| keep
    Esql.time_window_date_trunc,
    Esql.o365_audit_UserId_lower_count_distinct,
    Esql_priv.o365_audit_UserId_lower_values,
    Esql.o365_audit_LogonError_values,
    Esql.o365_audit_LogonError_count_distinct,
    Esql.o365_audit_ExtendedProperties_RequestType_values,
    Esql.source_ip_values,
    Esql.source_ip_count_distinct,
    Esql.source_as_organization_name_values,
    Esql.source_geo_country_name_values,
    Esql.source_geo_country_name_count_distinct,
    Esql.source_as_organization_name_count_distinct,
    Esql.timestamp_first_seen,
    Esql.timestamp_last_seen,
    Esql.event_duration_seconds,
    Esql.event_count,
    Esql.brute_force_type

Stage 8: where

| where Esql.brute_force_type != "other"

Exclusions

Top-level NOT(...) conjuncts: predicates this rule actively suppresses.

FieldKindExcluded values
Esql.o365_audit_LogonErrorinCmsiInterrupt, EntitlementGrantsNotFound, InvalidReplyTo, PasswordResetRegistrationRequiredInterrupt, SsoArtifactExpiredDueToConditionalAccess, SsoUserAccountNotFoundInResourceTenant, UserStrongAuthClientAuthNRequired, UserStrongAuthEnrollmentRequired, UserStrongAuthExpired

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.brute_force_typene
  • other
Esql.o365_audit_ExtendedProperties_RequestType_lowerregex_match
  • (oauth.*||.*login.*)
Esql.o365_audit_LogonErrorne
  • IdsLocked
Esql_priv.o365_audit_UserId_lowerne
  • not available
data_stream.dataseteq
  • o365.audit
event.actionin
  • PasswordLogonInitialAuthUsingPassword
  • UserLoginFailed
event.categoryeq
  • authentication
event.providerin
  • AzureActiveDirectory
  • Exchange
o365.audit.Target.Typein
  • 0
  • 10
  • 2
  • 6

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.o365_audit_UserId_lower_count_distinctKEEP Esql.o365_audit_UserId_lower_count_distinct
Esql_priv.o365_audit_UserId_lower_valuesKEEP Esql_priv.o365_audit_UserId_lower_values
Esql.o365_audit_LogonError_valuesKEEP Esql.o365_audit_LogonError_values
Esql.o365_audit_LogonError_count_distinctKEEP Esql.o365_audit_LogonError_count_distinct
Esql.o365_audit_ExtendedProperties_RequestType_valuesKEEP Esql.o365_audit_ExtendedProperties_RequestType_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_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.source_as_organization_name_count_distinctKEEP Esql.source_as_organization_name_count_distinct
Esql.timestamp_first_seenKEEP Esql.timestamp_first_seen
Esql.timestamp_last_seenKEEP Esql.timestamp_last_seen
Esql.event_duration_secondsKEEP Esql.event_duration_seconds
Esql.event_countKEEP Esql.event_count
Esql.brute_force_typeKEEP Esql.brute_force_type