Detect Low And Slow Password Spray


Low-and-slow spray pattern (Storm-0940-shaped): many unique users from the *same* IP in a day, with roughly one failed attempt per user; includes optional legacy-user-agent hints.

KQL Library  /  Analytics Rules

 Analytics Rules Password Spray detect-low-and-slow-password-spray.kql

Low-and-slow spray pattern (Storm-0940-shaped): many unique users from the *same* IP in a day, with roughly one failed attempt per user; includes optional legacy-user-agent hints.

 Download .kql
// Author: Ian D. Hanley (DevSecOpsDad) | linkedin.com/in/ianhanley | devsecopsdad.com | devsecopsdadattack.com
// Detect low-and-slow password spray patterns (e.g., Storm-0940) in Azure AD sign-ins via Sentinel (SigninLogs).

// Heuristic: many unique users from the SAME IP in a day, with ~one failed attempt per user, and optional legacy UA hints.

// --- Parameters / indicators ---
let lookback = 14d;                      // How far back to search
let ua_indicators = dynamic([            // Optional: suspicious/legacy user-agent strings to flag
  "Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; rv:11.0) like Gecko",
  "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.149 Safari/537.36"
]);
SigninLogs
| where TimeGenerated >= ago(lookback)                   // Limit to the lookback window
| where ResultType != 0                                  // Keep failed sign-ins only (ResultType==0 is success)
| extend LD = todynamic(column_ifexists("LocationDetails", "{}"))  // Cast LocationDetails to dynamic (schema differs by tenant)
| extend Country = tostring(LD.countryOrRegion)          // Extract country/region for context in the output
| extend UAHit = iif(UserAgent in (ua_indicators), 1, 0) // Flag events whose UserAgent matches our legacy UA list
| summarize
    Attempts = count(),                                  // Total failed attempts from this IP on this day
    Users    = make_set(UserPrincipalName, 1000),        // Unique users targeted by this IP on this day (up to 1000)
    UAHits   = sum(UAHit)                                // Count of events that matched legacy UA indicators
  by bin(TimeGenerated, 1d), IPAddress, Country          // Aggregate per day, per source IP, per country
| extend UsersTargeted = array_length(Users)             // Convert the user set into a numeric count
| where UsersTargeted >= 10                              // Spray breadth threshold: at least 10 unique users/day/IP
  and Attempts <= UsersTargeted + 2                      // "One attempt per user per day" pattern (allow tiny slack)
| order by TimeGenerated desc, UsersTargeted desc        // Show most recent / widest sprays first