Detection rules › Splunk
Detect Distributed Password Spray Attempts
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
| Tactic | Techniques |
|---|---|
| Credential Access | T1110.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 =unique_accounts > 30 AND unique_accounts >= upperBoundUser AND unique_src > 30 AND unique_src >= upperBoundsrc10Stage 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.
| Field | Kind | Excluded values |
|---|---|---|
Authentication.src | in | "-", "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.
| Field | Kind | Values |
|---|---|---|
Authentication.action | eq |
|
Authentication.user_agent | eq |
|
isOutlier | eq |
|
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.
| Stage | Term |
|---|---|
| 16 | foreach |
| 16 | * |
| 18 | iplocation |
| 18 | src |