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.
// 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