In this section

TH4.4 Hunting Password Spray

6-8 hours · Module 4
What you already know

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
PASSWORD SPRAY VS CREDENTIAL STUFFING VS BRUTE FORCE PASSWORD SPRAY 1-2 passwords → ALL accounts 1 attempt per account (under lockout) Signal: many accounts, same window, same error CREDENTIAL STUFFING Unique passwords → specific accounts From breach databases (password per user) Signal: 50076 events, distributed IPs BRUTE FORCE Many passwords → 1 account Triggers lockout (easy to detect) Signal: many failures, single account, lockout Spray and stuffing stay under lockout thresholds. They require aggregate analysis to detect. Brute force triggers lockout automatically — it is the easiest to detect and the least sophisticated. TH4.3 hunts credential stuffing. TH4.4 hunts password spray. Both matter — both stay under threshold rules.

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.

Checkpoint

Hunt window: ___ to ___

Spray campaigns detected (20+ accounts in 1h window): ___

Peak accounts targeted in single window: ___

Source IPs identified: ___

Spray successes (ResultType 0 from spray IPs): ___

MFA-blocked successes (ResultType 50076): ___

Smart Lockout activations during spray: ___

Legacy auth targeted: Yes/No

Accounts requiring password reset: ___

Incidents opened: ___

References Used in This Subsection

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.