In this section
TH4.4 Hunting Password Spray
The previous section covered hunting credential stuffing. This section covers hunting password spray.
Spray is the simplest attack, and the most common
Password spray is the most frequently observed brute force variant against Entra ID because it is the hardest to detect with simple threshold rules. An attacker who tries Summer2024! against 10,000 accounts generates one failure per account. No individual account reaches a lockout threshold. No individual IP generates enough failures to trigger a rate limit (the attacker distributes across proxies). The signal exists only in aggregate: thousands of accounts failing simultaneously.
Anti-Pattern
Hunting hunting password spray without a hypothesis
The hunter opens Advanced Hunting and starts writing queries without a clear hypothesis. They find interesting data but cannot determine whether it represents a threat, a misconfiguration, or normal activity. Every hunt starts with a hypothesis: a specific, testable statement about attacker behavior. Without a hypothesis, you are exploring, not hunting. Exploration has value, but it produces findings you cannot action without additional scoping.
Microsoft's Smart Lockout partially mitigates spray by detecting password submissions from unfamiliar IPs and applying extranet lockout thresholds separately from intranet (corporate network) lockout thresholds. But Smart Lockout's thresholds are configurable and may be set too high. An attacker who submits 3 attempts per account stays under a threshold of 5, and gets 3 chances to guess the password for every account in the directory.
Spray detection: the aggregate pattern
// Password Spray Hunt — many accounts, same time window, same error
let sprayWindow = 1h;
let minTargets = 20; // At least 20 accounts targeted
SigninLogs
| where TimeGenerated > ago(24h)
| where ResultType == "50126" // Invalid username or password
| summarize
TargetedAccounts = dcount(UserPrincipalName),
TotalAttempts = count(),
SourceIPs = make_set(IPAddress, 50),
IPCount = dcount(IPAddress),
Countries = make_set(
tostring(LocationDetails.countryOrRegion), 10),
UserAgents = make_set(UserAgent, 10),
Apps = make_set(AppDisplayName, 10),
AttemptsPerAccount = round(
todouble(count()) / todouble(dcount(UserPrincipalName)), 2)
by bin(TimeGenerated, sprayWindow)
| where TargetedAccounts >= minTargets
// A burst of 20+ accounts failing in the same 1-hour window
// is the textbook spray signature
| extend SprayConfidence = case(
AttemptsPerAccount <= 2 and IPCount <= 5,
"HIGH — classic spray (few attempts/account, few IPs)",
AttemptsPerAccount <= 2 and IPCount > 5,
"HIGH — distributed spray (proxied)",
AttemptsPerAccount > 5,
"MEDIUM — may be spray + brute force hybrid",
"LOW")
| sort by TargetedAccounts desc
// AttemptsPerAccount near 1 = pure spray (one password, all accounts)
// AttemptsPerAccount 2-3 = multi-password spray (2-3 passwords tested)
// UserAgents set: if all failures share one UserAgent, automated tool
// Apps set: should be "Microsoft Online" or "Office 365" — the
// Entra ID login endpoint. Other apps may indicate targeted spray
// against specific application sign-in pages
Distributed spray, many IPs, same campaign
// Distributed password spray — attacker uses proxy rotation
// Each IP sends only a few attempts, staying under per-IP thresholds
let detectionWindow = 4h;
SigninLogs
| where TimeGenerated > ago(detectionWindow)
| where ResultType == "50126"
| extend UserAgentKey = extract(
@"^([^\(]+)", 1, UserAgent) // Normalize user agent
| summarize
IPsUsed = dcount(IPAddress),
AccountsTargeted = dcount(UserPrincipalName),
TotalAttempts = count(),
AvgAttemptsPerIP = round(
todouble(count()) / todouble(dcount(IPAddress)), 2)
by UserAgentKey
| where AccountsTargeted >= 20 and IPsUsed >= 5
// Many IPs sharing the same UserAgent, all failing against many
// accounts = distributed spray via proxy network
// AvgAttemptsPerIP near 1-3 = each proxy used briefly then rotated
// The UserAgent is the correlating signal when IPs rotate
| sort by AccountsTargeted desc
Finding spray successes: the accounts that fell
The most dangerous spray outcome is a successful authentication. If the attacker finds even one account where Summer2024! (or whatever password they sprayed) works, they have a foothold.
// Find spray successes: accounts that authenticated from spray IPs
// Step 1: Identify spray IP set
let sprayIPs = SigninLogs
| where TimeGenerated > ago(24h)
| where ResultType == "50126"
| summarize Targets = dcount(UserPrincipalName) by IPAddress
| where Targets >= 20
| project IPAddress;
// Step 2: Find successes from those IPs
SigninLogs
| where TimeGenerated > ago(24h)
| where IPAddress in (sprayIPs)
| where ResultType == "0" // Successful sign-in
| project TimeGenerated, UserPrincipalName, IPAddress,
AppDisplayName, ConditionalAccessStatus,
AuthenticationRequirement,
MFAResult = tostring(
parse_json(AuthenticationDetails)[1].authenticationMethod),
DeviceDetail = tostring(DeviceDetail),
Country = tostring(LocationDetails.countryOrRegion)
// Each row = a SUCCESSFUL login from a known spray IP
// This user's password matched the spray password
// Check: did MFA block further access? (ConditionalAccessStatus)
// If ConditionalAccessStatus == "success" → FULL ACCESS ACHIEVED
// Immediate escalation to IR
// If ConditionalAccessStatus == "failure" → MFA blocked the spray
// success. Force password reset. Verify MFA method.
| sort by TimeGenerated asc
Understanding Smart Lockout behavior
// How is Smart Lockout performing against the spray?
// Are accounts getting locked, or is the attacker staying under?
SigninLogs
| where TimeGenerated > ago(24h)
| where ResultType in ("50053", "50055")
// 50053 = locked out, 50055 = smart lockout (extranet)
| summarize
LockedAccounts = dcount(UserPrincipalName),
LockoutEvents = count(),
LockoutIPs = make_set(IPAddress, 20)
by bin(TimeGenerated, 1h)
| sort by LockedAccounts desc
// If LockedAccounts is high during a spray window, Smart Lockout
// is working — catching the spray before all passwords are tested
// If LockedAccounts is 0 during a confirmed spray window,
// the attacker is staying under the lockout threshold
// Consider lowering the Smart Lockout threshold or duration
Figure TH4.4. Three brute force variants. Password spray and credential stuffing evade per-account lockout thresholds. Only aggregate analysis detects them.
Run the primary spray query (24-hour window, 20-account minimum). Do any time bins show 20+ accounts failing simultaneously?
If yes, run the spray success query against those IPs. Did any account authenticate successfully from a spray IP? If so, that account is compromised: the spray password worked.
Run the Smart Lockout query. Did lockouts occur during the spray window? If zero lockouts during a confirmed spray, your Smart Lockout threshold may be too high, consider lowering it.
Record the UserAgent strings from the spray. Automated spray tools often use a distinctive or outdated UserAgent. Adding this UserAgent to a watchlist enables continuous monitoring for future spray campaigns using the same tooling.
Compliance Context
Smart Lockout applies per-account lockout thresholds on the extranet. An attacker who submits 2 attempts per account with a lockout threshold of 5 is never locked out. Smart Lockout also requires Entra ID P1 or higher, organizations on Entra ID Free use basic lockout that does not distinguish between extranet and intranet attempts. Even with Smart Lockout functioning correctly, it blocks the account, it does not alert the SOC. A blocked spray attempt that nobody investigates leaves the attacker free to adjust their technique (switch to AiTM, use credential stuffing from a different database) without any organizational response. Hunting for spray patterns, even blocked ones, triggers investigation, identifies the campaign scope, and drives defensive improvements.
Extend this hunt
For environments with legacy authentication still enabled (POP, IMAP, SMTP), spray attacks may target these protocols specifically because legacy auth does not support MFA. The AppDisplayName field shows "Exchange ActiveSync" or "Other clients" for legacy auth. A spray campaign targeting legacy protocols is higher urgency than one targeting modern auth because a successful password match grants immediate access without an MFA challenge. If you find spray activity against legacy auth protocols, the immediate remediation is to block legacy authentication via conditional access, this eliminates the attack surface entirely.
References Used in This Subsection
- Microsoft. "Smart Lockout." Microsoft Learn. https://learn.microsoft.com/en-us/entra/identity/authentication/howto-password-smart-lockout
- Course cross-references: TH4.3 (credential stuffing, distinct pattern), TH4.5 (MFA fatigue, follow-up after spray success)
NE environmental considerations
NE's detection environment includes specific factors that influence this rule's operation:
Device diversity: 768 P2 corporate workstations with full Defender for Endpoint telemetry, 58 P1 manufacturing workstations with basic cloud-delivered protection, and 3 RHEL rendering servers with Syslog-only coverage. Rules targeting DeviceProcessEvents operate with full fidelity on P2 devices but may have reduced visibility on P1 devices. Manufacturing workstations in Sheffield and Sunderland represent a detection gap for endpoint-level detections.
Network topology: 11 offices connected via Palo Alto SD-WAN with full-mesh connectivity. The SD-WAN firewall logs feed CommonSecurityLog in Sentinel. Cross-site lateral movement generates firewall allow events that correlate with DeviceLogonEvents, enabling multi-source detection that single-table rules cannot achieve.
User population: 810 users with distinct behavioral profiles, office workers (predictable hours, consistent applications), field engineers (variable hours, travel patterns), IT administrators (elevated privilege, broad access patterns), and manufacturing operators (fixed shifts, limited application access). Each user population has different detection baselines.