Potential Microsoft 365 User Account Brute Force

edit
A newer version is available. Check out the latest documentation.

Potential Microsoft 365 User Account Brute Force

edit

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.

Rule type: esql

Rule indices: None

Severity: medium

Risk score: 47

Runs every: 10m

Searches indices from: now-60m (Date Math format, see also Additional look-back time)

Maximum alerts per execution: 100

References:

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

Version: 414

Rule authors:

  • Elastic
  • Willem D’Haese
  • Austin Songer

Rule license: Elastic License v2

Investigation guide

edit

Triage and Analysis

Investigating Potential Microsoft 365 User Account Brute Force

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.

Rule query

edit
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
    event.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"

Framework: MITRE ATT&CKTM