SOP-02: Brute Force & Credential Stuffing Response

·

This SOP covers brute force, password spray, and credential stuffing alerts against Entra ID-protected applications (M365, VPN, Entra-joined apps). The most dangerous outcome is a successful login after failed attempts — that check is Phase 3 and must run before containment actions.

Target: attack classified within 10 min; compromise determination within 5 min of classification; CA block applied within 20 min of confirmed malicious activity.

◈ Table of Contents

00 Quick Reference 01 Alert Triage + KQL 02 Spray vs Brute Classification 03 Compromise Check (Critical) 04 Containment 05 Entra ID + Identity Protection 06 Tuning & Close
📋

PHASE 00 — QUICK REFERENCE

METADATA
SOP IDSOP-02
OwnerT1 SOC Analyst
TriggerSentinel alert · Entra ID Identity Protection "Unfamiliar sign-in" · CA block spike · MDI brute force alert
Time TargetsAttack classified: < 10 min · Compromise check: < 5 min after classification · CA block applied: < 20 min
Escalate ToT2 immediately if successful login confirmed after failed attempts → trigger SOP-04
Escalate ToT2 if internal IP is source → potential endpoint compromise → trigger SOP-03
Tools RequiredMicrosoft Sentinel · portal.azure.com (Entra ID) · security.microsoft.com
🔍

PHASE 01 — ALERT TRIAGE + KQL

TARGET: < 10 MIN
01
Pull Attacker IP, Target Accounts, App Targeted
KQL REQUIRED
◈ Steps
  • 1From the alert: extract source IP(s), target UPN(s), application targeted (e.g., Azure Portal, VPN, Exchange ActiveSync), time window.
  • 2Run KQL to get the full picture of failed attempts by IP — this reveals attack velocity and which accounts were targeted:
KQL — FAILED LOGIN RATE BY SOURCE IP (Sentinel / Defender Advanced Hunting)
// Adjust ago(1h) window to match alert time range SigninLogs | where TimeGenerated >= ago(1h) | where ResultType != "0" // non-zero = failed login | summarize FailedAttempts = count(), TargetAccounts = make_set(UserPrincipalName), AccountCount = dcount(UserPrincipalName), FirstSeen = min(TimeGenerated), LastSeen = max(TimeGenerated) by IPAddress, AppDisplayName | where FailedAttempts > 10 | extend DurationMin = datetime_diff('minute', LastSeen, FirstSeen) | order by FailedAttempts desc
  • 3Record from results: AccountCount (how many distinct accounts targeted), FailedAttempts (raw volume), DurationMin (attack duration), AppDisplayName (attack surface).

Legacy auth protocols (SMTP, IMAP, MAPI) don't support MFA and are common spray targets. If AppDisplayName shows "Exchange ActiveSync" or "IMAP" → check for legacy auth CA policy gaps immediately.

📊

PHASE 02 — SPRAY VS BRUTE CLASSIFICATION

DETERMINE ATTACK TYPE
02
Classify the Attack Pattern
DECISION POINT
◈ Classification Logic
  • 1Brute Force: AccountCount = 1, many failed attempts. Single account at risk. Reset and lock that account.
  • 2Password Spray: AccountCount > 5, <3 attempts per account. Organisation-wide risk — attacker is evading lockout thresholds. Run spray KQL:
KQL — PASSWORD SPRAY DETECTION
SigninLogs | where TimeGenerated >= ago(1h) | where ResultType != "0" | summarize Attempts = count(), AccountCount = dcount(UserPrincipalName) by IPAddress, bin(TimeGenerated, 10m) | where AccountCount > 5 and Attempts > 10 | extend AvgAttemptsPerAccount = todouble(Attempts) / AccountCount | where AvgAttemptsPerAccount < 3 // spray signature: few per account | order by AccountCount desc
  • 3Credential Stuffing: High account count AND varied attempt timing (not regular interval). These use breach dump lists. Check if affected accounts exist on known breach dumps via your TI feeds or HaveIBeenPwned Enterprise API.
  • 4If source IP is internal: stop — do NOT block in CA yet. That device may be infected with a password sprayer. Go to Defender XDR → Devices → search IP and trigger SOP-03 for that endpoint.
🚨

PHASE 03 — COMPROMISE CHECK

CRITICAL — RUN BEFORE CONTAINMENT
03
Detect Successful Login After Failed Attempts
ESCALATION TRIGGER
◈ Steps
  • 1Run this KQL immediately — it finds any successful login from the same attacker IP that also had failures. A result here means an account was breached.
KQL — SUCCESS AFTER FAILURE (COMPROMISE INDICATOR)
// Build list of suspect IPs with >10 failures, then find successes from same IPs let SuspectIPs = SigninLogs | where TimeGenerated >= ago(2h) | where ResultType != "0" | summarize FailCount = count() by IPAddress | where FailCount > 10 | project IPAddress; SigninLogs | where TimeGenerated >= ago(2h) | where ResultType == "0" // SUCCESS | where IPAddress in (SuspectIPs) | project TimeGenerated, UserPrincipalName, IPAddress, AppDisplayName, Location, DeviceDetail, RiskLevelDuringSignIn | order by TimeGenerated desc
  • 2If results returned: STOP this SOP. For each compromised UPN → trigger SOP-04 immediately. Do containment in Phase 4 of this SOP in parallel but SOP-04 takes priority.
  • 3If no results: no confirmed compromise yet. Continue to Phase 4 containment. The attack is still ongoing or was unsuccessful.
  • 4Also check: portal.azure.com → Microsoft Entra ID → Security → Identity Protection → Risky sign-ins. Sort by Risk level. Any "High" risk sign-in for targeted accounts?

ResultType = "0" from the same IP as the brute force is NOT always a false positive — the attacker may have changed IP for the successful login. Also check UrlClickEvents and DeviceLogonEvents for the targeted accounts across the full time window.

🛡️

PHASE 04 — CONTAINMENT

ENTRA ID + CONDITIONAL ACCESS
04
Block Attacker IP via Conditional Access Named Location
ENTRA ID ACTION
◈ Steps — External IP Source
  • 1Navigate: portal.azure.com → Microsoft Entra ID → Security → Conditional Access → Named Locations → + New IP ranges location.
  • 2Name it: BLOCK-[date]-[IP]-BruteForce. Add the attacking IP or CIDR range. Save.
  • 3Apply to CA policy: Conditional Access → Policies → [your block policy] OR create a new policy: Users = All users, Cloud apps = All cloud apps, Conditions → Locations → Include [new named location], Grant = Block access.
  • 4Set policy to Report-only first for 5 min to confirm no legitimate users come from that IP. Then switch to On.
  • 5Verify block is active: re-run the failed logins KQL after 5 min. If the IP is still appearing with new attempts, the CA policy is applied and showing as blocked (ConditionalAccessStatus = Failure).
KQL — VERIFY CA BLOCK IS WORKING
SigninLogs | where TimeGenerated >= ago(15m) | where IPAddress == "ATTACKER_IP" | project TimeGenerated, UserPrincipalName, ResultType, ConditionalAccessStatus, ResultDescription | order by TimeGenerated desc
  • 6Expected result after CA block: ResultType = 53003 (blocked by CA) or ConditionalAccessStatus = failure. If still seeing ResultType != 0 with no CA block status → check policy assignment.
🔐

PHASE 05 — ENTRA ID + IDENTITY PROTECTION

IDENTITY HARDENING
05
Confirm Risk State + Harden Targeted Accounts
ENTRA ID ACTION
◈ Entra ID Identity Protection Steps
  • 1portal.azure.com → Microsoft Entra ID → Security → Identity Protection → Risky sign-ins. Find sign-ins from the attacking IP. Select → Confirm sign-in(s) risky. This feeds the risk engine and auto-blocks future attempts by this user if your risk-based CA policy is active.
  • 2For targeted accounts where lockout occurred: Entra ID → Users → [user] → Reset password → Require change on next sign-in = ON. This forces a new credential even if the old one was guessed correctly during the attack window.
  • 3Check MFA enrollment for all targeted accounts: Entra ID → Users → [user] → Authentication methods. If any account has NO MFA registered → that's a CA policy gap. Note for Phase 6.
  • 4Check legacy auth exposure: Entra ID → Sign-in logs → filter: Client app = "Other clients". If any target accounts use legacy auth → these bypass MFA entirely and must be blocked via CA legacy auth policy.
KQL — CHECK LEGACY AUTH EXPOSURE FOR TARGETED ACCOUNTS
SigninLogs | where TimeGenerated >= ago(7d) | where UserPrincipalName in~ ("[email protected]", "[email protected]") | where ClientAppUsed in ("Exchange ActiveSync", "IMAP4", "POP3", "SMTP", "Other clients") | summarize LegacyLogins = count() by UserPrincipalName, ClientAppUsed | order by LegacyLogins desc

PHASE 06 — TUNING & CLOSE

FINAL STEPS
06
Detection Gap Review + Ticket Close
CLOSE
◈ Steps
  • 1Check: Entra ID → Security → Conditional Access → Policies — is there an active Sign-in risk policy that blocks Medium and High risk sign-ins with MFA? If not, this is why the attack wasn't auto-blocked.
  • 2Check: Is there an active CA policy blocking Legacy authentication? If the spray targeted legacy auth apps → that's the gap.
  • 3Add attacker IP to Sentinel Watchlist for ongoing monitoring. If the same IP returns: auto-alert fires faster next time.
  • 4If source country has zero business presence: consider geo-block CA policy. Named Locations → Countries location → Add country. Apply as block in CA.
  • 5Document: attack type, IP(s), accounts targeted, accounts locked, CA policy applied, compromise status (confirmed/not confirmed), gap identified.
Closure Checklist
  • Attacker IP blocked via CA Named Location
  • Compromise check KQL run — result documented (compromised / not compromised)
  • Targeted accounts: passwords reset, MFA verified
  • Identity Protection risk events marked as confirmed
  • Legacy auth exposure checked and documented
  • CA policy gap (if found) recorded as follow-up remediation item

For additional SOC SOPs covering account compromise, malware triage, and ransomware response, visit cyberhawkthreatintel.com/sops

◈ Stay Connected

Follow CyberHawk Threat Intel for threat intelligence, deployment guides and hands-on SOC tooling content.

🌐 Website ▶️ YouTube ▶️ YouTube (2) 𝕏 Twitter / X ♪ TikTok ✈️ Telegram
🔍 IOC Scanner 🛠️ Live Tools 📚 Courses 🚨 Threat Intel 📝 Blog 📋 SOPs

"They can't exploit you if you are the Exploit."