Detection rules › Splunk

Detect Distributed Password Spray Attempts

Status
production
Group by
Authentication.user_agent, _time, aws::userAgent, counter, signature_id, sourcetype, unique_accounts, unique_src
Author
Dean Luxton
Source
github.com/splunk/security_content

This analytic employs the 3-sigma approach to identify distributed password spray attacks. A distributed password spray attack is a type of brute force attack where the attacker attempts a few common passwords against many different accounts, connecting from multiple IP addresses to avoid detection. By utilizing the Authentication Data Model, this detection is effective for all CIM-mapped authentication events, providing comprehensive coverage and enhancing security against these attacks.

MITRE ATT&CK coverage

TacticTechniques
Credential AccessT1110.003 Brute Force: Password Spraying

Rule body splunk

name: Detect Distributed Password Spray Attempts
id: b1a82fc8-8a9f-4344-9ec2-bde5c5331b57
version: 7
creation_date: '2024-07-01'
modification_date: '2026-05-13'
author: Dean Luxton
status: production
type: Hunting
description: This analytic employs the 3-sigma approach to identify distributed password spray attacks. A distributed password spray attack is a type of brute force attack where the attacker attempts a few common passwords against many different accounts, connecting from multiple IP addresses to avoid detection. By utilizing the Authentication Data Model, this detection is effective for all CIM-mapped authentication events, providing comprehensive coverage and enhancing security against these attacks.
data_source:
    - Azure Active Directory Sign-in activity
search: >-
    | tstats `security_content_summariesonly` dc(Authentication.user) AS unique_accounts
    dc(Authentication.src) as unique_src values(Authentication.app) as app values(Authentication.src)
    as src count(Authentication.user) as total_failures from datamodel=Authentication.Authentication
    where Authentication.action="failure" NOT Authentication.src IN ("-","unknown")
    Authentication.user_agent="*" by Authentication.signature_id, Authentication.user_agent,
    sourcetype, _time  span=10m
    | `drop_dm_object_name("Authentication")`
    ```fill out time buckets for 0-count events during entire search length```
    | appendpipe [| timechart limit=0 span=10m count | table _time]
    | fillnull value=0 unique_accounts, unique_src
    ``` Create aggregation field & apply to all null events```
    | eval counter=sourcetype+"__"+signature_id
    | eventstats values(counter) as fnscounter | eval counter=coalesce(counter,fnscounter)  |
    stats values(total_failures) as total_failures values(signature_id) as signature_id
    values(src) as src values(sourcetype) as sourcetype values(app) as app count by
    counter unique_accounts unique_src user_agent _time
      ``` remove 0 count rows where counter has data```
    | sort - _time unique_accounts
    | dedup _time counter
    ``` 3-sigma detection logic ```
    | eventstats avg(unique_accounts) as comp_avg_user , stdev(unique_accounts) as comp_std_user
    avg(unique_src) as comp_avg_src , stdev(unique_src) as comp_std_src by counter user_agent
    | eval upperBoundUser=(comp_avg_user+comp_std_user*3), upperBoundsrc=(comp_avg_src+comp_std_src*3)
    | eval isOutlier=if((unique_accounts > 30 and unique_accounts >= upperBoundUser)
    and (unique_src > 30 and unique_src >= upperBoundsrc), 1, 0)
    | replace "::ffff:*" with * in src
    | where isOutlier=1
    | foreach *
        [ eval <<FIELD>> = if(<<FIELD>>="null",null(),<<FIELD>>)]
    | mvexpand src  | iplocation src  | table _time, unique_src, unique_accounts, total_failures,
    sourcetype, signature_id, user_agent, src, Country
    | eval date_wday=strftime(_time,"%a"), date_hour=strftime(_time,"%H")
    | `detect_distributed_password_spray_attempts_filter`
how_to_implement: Ensure that all relevant authentication data is mapped to the Common Information Model (CIM) and that the src field is populated with the source device information. Additionally, ensure that fill_nullvalue is set within the security_content_summariesonly macro to include authentication events from log sources that do not feature the signature_id field in the results.
known_false_positives: It is common to see a spike of legitimate failed authentication events on monday mornings.
references:
    - https://attack.mitre.org/techniques/T1110/003/
analytic_story:
    - Compromised User Account
    - Active Directory Password Spraying
asset_type: Endpoint
atomic_guid:
    - 90bc2e54-6c84-47a5-9439-0a2a92b4b175
mitre_attack_id:
    - T1110.003
product:
    - Splunk Enterprise
    - Splunk Enterprise Security
    - Splunk Cloud
category: application
security_domain: access
tests:
    - name: True Positive Test
      attack_data:
        - data: https://media.githubusercontent.com/media/splunk/attack_data/master/datasets/attack_techniques/T1110.003/azure_ad_distributed_spray/azure_ad_distributed_spray.log
          source: azure:monitor:aad
          sourcetype: azure:monitor:aad
      description: PORTED MANUAL TEST - The dataset & hardcoded timerange doesn't meet the criteria for this detection.
      test_type: experimental

Stages and Predicates

Stage 1: tstats

| tstats `security_content_summariesonly` dc(Authentication.user) AS unique_accounts dc(Authentication.src) as unique_src values(Authentication.app) as app values(Authentication.src) as src count(Authentication.user) as total_failures from datamodel=Authentication.Authentication where Authentication.action="failure" NOT Authentication.src IN ("-","unknown") Authentication.user_agent="*" by Authentication.signature_id, Authentication.user_agent, sourcetype, _time  span=10m

Stage 2: search

| `drop_dm_object_name("Authentication")`

Stage 3: appendpipe

| appendpipe [| timechart limit=0 span=10m count | table _time]

Stage 4: fillnull

| fillnull value=0 unique_accounts, unique_src

Stage 5: eval

| eval counter=sourcetype+"__"+signature_id

Stage 6: eventstats

| eventstats values(counter) as fnscounter

Stage 7: eval

| eval counter=coalesce(counter,fnscounter)

Stage 8: stats

| stats values(total_failures) as total_failures values(signature_id) as signature_id values(src) as src values(sourcetype) as sourcetype values(app) as app count by counter unique_accounts unique_src user_agent _time

Stage 9: sort

| sort - _time unique_accounts

Stage 10: dedup

| dedup _time counter

Stage 11: eventstats

| eventstats avg(unique_accounts) as comp_avg_user , stdev(unique_accounts) as comp_std_user avg(unique_src) as comp_avg_src , stdev(unique_src) as comp_std_src by counter user_agent

Stage 12: eval

| eval upperBoundUser=(comp_avg_user+comp_std_user*3), upperBoundsrc=(comp_avg_src+comp_std_src*3)

Stage 13: eval

| eval isOutlier=if((unique_accounts > 30 and unique_accounts >= upperBoundUser) and (unique_src > 30 and unique_src >= upperBoundsrc), 1, 0)
isOutlier =
ifunique_accounts > 30 AND unique_accounts >= upperBoundUser AND unique_src > 30 AND unique_src >= upperBoundsrc1
else0

Stage 14: replace

| replace "::ffff:*" with * in src

Stage 15: where

| where isOutlier=1

Stage 16: search

| foreach *
    [ eval <<FIELD>> = if(<<FIELD>>="null",null(),<<FIELD>>)]

Stage 17: mvexpand

| mvexpand src

Stage 18: search

| iplocation src

Stage 19: table

| table _time, unique_src, unique_accounts, total_failures, sourcetype, signature_id, user_agent, src, Country

Stage 20: eval

| eval date_wday=strftime(_time,"%a"), date_hour=strftime(_time,"%H")

Stage 21: search

| `detect_distributed_password_spray_attempts_filter`

Exclusions

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

FieldKindExcluded values
Authentication.srcin"-", "unknown"

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.

Search terms

Bare-string tokens in the SPL search body. Splunk matches each token against _raw (the untyped raw event text) anywhere it appears, not against a specific field. These don't surface in the Indicators table because they aren't predicates on a known field.

StageTerm
16foreach
16*
18iplocation
18src