Skip to content

Password policy inference

Before brute-forcing, learn the policy. Filtering rockyou.txt from 14M lines to ~400k policy-compliant passwords is the difference between a feasible attack and a futile one.

# Probe policy by registering accounts with progressively-weaker passwords
Qwertyuiop123!@# → accepted: policy is satisfied
Qwertyuiop123 → "needs special character"
Qwertyiop!@# → "needs digit"
qwertyuiop123!@# → "needs uppercase"
Qwerty1! → "minimum 12 characters"
# Filter a wordlist to compliant passwords only (12+ chars, mixed case, digit, special)
grep -E '^.{12,}$' rockyou.txt | grep '[[:upper:]]' | grep '[[:lower:]]' \
| grep '[0-9]' | grep '[[:punct:]]'

Success indicator: a wordlist sized to the actual policy, not the universe of all possible passwords.

A standard wordlist (rockyou.txt) is 14 million entries. Brute-forcing against any modern login form - even with rate-limit bypasses - at one attempt per second takes 162 days. Filter to “must have uppercase, lowercase, digit, special, 12+ characters” and you’re at maybe 100k entries, plus most of those won’t be plausible candidates. With targeted filtering, brute-force becomes a 12-hour job instead of a half-year job.

The policy is also a hint about what real users in this organization probably picked - see the Password attacks section below.

Every form that creates or changes a password reveals the policy:

  • Registration form - fastest path; just try registering with weak passwords until one’s accepted
  • Password change page (post-login) - needs an account first
  • Password reset flow - sometimes leaks policy when setting the new password
  • API endpoints - JSON validation errors are often verbose
  • Embedded policy hints in HTML - <small>Password must be 12+ chars, mixed case, digit, special</small> is gift-wrapped

Try the registration page first. If self-registration isn’t open, the reset flow or password-change form (after compromising any account) is the next stop.

Most policies are combinations of these axes:

AxisCommon values
Minimum length6, 8, 10, 12, 14, 16
Maximum lengthusually unlimited; sometimes 20, 32, 64
Character classes requiredlowercase, uppercase, digit, special (any subset of the four)
Number of classes required”at least 3 of the 4” patterns are common
Repeated characters allowedusually yes, sometimes “no three in a row”
Dictionary checksometimes - rejects common passwords from a known list
Username must not appearsometimes - admin can’t pick Admin2024!
Previous passwordspassword-change-only - can’t reuse last 5/10/24

The first four are what you’ll see most often. The dictionary check is the surprising one - modern apps (and NIST 800-63B recommendations) push toward “check against a leaked-password list” rather than complexity rules, which both improves security and breaks some brute-force assumptions.

Start from the strongest plausible password, weaken one axis at a time:

1. Qwertyuiop123!@#$% 20 chars, all 4 classes Accepted? → policy is at most this strong
2. Qwertyuiop123!@# 15 chars, all 4 classes Accepted? → length max ≥ 15
3. Qwertyuiop123 13 chars, no specials Rejected → special required
4. Qwerty12! 9 chars, all 4 classes Accepted? → min ≤ 9
5. Qwerty1! 8 chars, all 4 classes Accepted? → min ≤ 8
6. Qwerty! 7 chars, no digit Rejected → digit required AND min > 7
7. qwerty12! 8 chars, no uppercase Rejected → uppercase required
8. QWERTY12! 8 chars, no lowercase Rejected → lowercase required

After 8 attempts, you know: min length 8, all 4 character classes required, max length ≥ 15.

This goes wrong without a tracking grid. Use this:

TriedPasswordLowerUpperDigitSpecial≥8≥12Accepted
YesqwertyXNo
YesQwertyXXNo
YesQwerty1XXXNo
YesQwerty1!XXXXX?
YesQwertyuiop1!XXXXXX?
YesQWERTY1!XXXX?
Yesqwerty1!XXXX?

Five to ten well-chosen attempts pin the policy. Note that some apps reveal the full policy in the first error message (“password must be 8+ characters with mixed case, digit, and special”) - read every error response carefully before assuming you need to infer.

If the response is generic (“password does not meet requirements”) without naming which requirement, infer by elimination. Try password variants that each violate exactly one axis and see which produce errors.

Some apps reveal one requirement at a time (“password must be at least 8 characters” → fix that, retry → “password must contain a digit”). Walk through them one error at a time until the password is accepted.

Once the policy is known, filter the base wordlist with grep:

Terminal window
# Minimum 12 chars
grep -E '^.{12,}$' rockyou.txt > /tmp/12plus.txt
# Plus uppercase required
grep '[[:upper:]]' /tmp/12plus.txt > /tmp/12plus-upper.txt
# Plus lowercase required
grep '[[:lower:]]' /tmp/12plus-upper.txt > /tmp/12plus-mixed.txt
# Plus digit required
grep '[0-9]' /tmp/12plus-mixed.txt > /tmp/12plus-mixed-digit.txt
# Plus special required
grep -E '[!@#$%^&*()_+={};:,<.>/?\\|`~"-]' /tmp/12plus-mixed-digit.txt \
> /tmp/policy-compliant.txt
wc -l /tmp/policy-compliant.txt

A typical chain - starting from rockyou.txt’s 14M entries and filtering down to a strict 12+ chars, mixed-case, digit, special policy - produces about 100-500k entries.

Terminal window
grep -E '^.{12,}$' rockyou.txt \
| grep '[[:upper:]]' \
| grep '[[:lower:]]' \
| grep '[0-9]' \
| grep -E '[!@#$%^&*()_+={};:,<.>/?\\|`~"-]' \
> policy-compliant.txt

The policy might have a max length too - many apps cap at 20 or 32:

Terminal window
# 12-20 chars
grep -E '^.{12,20}$' rockyou.txt | grep '[[:upper:]]' | ...

If the policy bans the username, post-filter:

Terminal window
# Exclude passwords containing the username (case-insensitive)
grep -viE '(^|[^a-z])admin([^a-z]|$)' policy-compliant.txt > final.txt

Knowing the policy also tells you what users probably picked. The policy is the lower bound of complexity; real passwords cluster just above it.

If the policy is “8 chars, mixed case, digit, special,” common real-world patterns include:

<CommonWord>1! Spring1!, Summer1!, Winter1!, Autumn1!
<CommonWord>123! Password123!, Welcome123!
<CompanyName>1! Acmecorp1!, Initech1!
<Year>! 2024!, 2025!
<Season><Year>! Spring2024!, Summer2024!
P@ssw0rd! the granddaddy of weak compliant passwords
Welcome1! onboarding default that "got changed" - to itself

If the policy is “12 chars” the patterns lengthen but follow the same form:

Password1234!
Welcome2024!!
CompanyName2024!

A bespoke wordlist of 100 entries following these patterns often outperforms a 500k-line filtered rockyou.

Terminal window
# Common patterns scoped to "acme corp"
cat > /tmp/acme-patterns.txt << 'EOF'
Acme2024!
Acme2025!
Acme1234!
ACME2024!
Acmecorp1!
Acmecorp2024!
AcmeCorp1!
AcmeCorp2024!
Welcome2024!
Spring2024!
Summer2024!
Autumn2024!
Winter2024!
EOF

Mix these with cewl output (a wordlist generator that crawls a target site for terms) for an even more targeted set.

NIST’s modern guidance flipped the conventional wisdom:

  • Don’t require composition rules (mixed case, special chars)
  • Don’t require periodic rotation
  • Do check against a known-leaked-passwords list
  • Do encourage long passphrases (length > complexity)

Applications following the new guidance often don’t have a complexity policy at all - they just reject passwords from a leaked-password list. The brute-force implications:

  • Wordlist filtering by complexity is useless (no complexity policy to filter against)
  • Most rockyou.txt entries are rejected outright as “known leaked”
  • New passwords cluster around long-but-unique strings - much harder to brute-force

If you find an application with no apparent complexity policy, test by submitting a known-leaked password like password123 during registration. If it’s rejected, you’re against a modern policy and brute-forcing through a leaked-password list is closed.

When self-registration isn’t available, the password change page (post-login on any account, including your test account if you can create one) reveals the same policy. Same matrix-driven approach.

For applications where you can’t get even one account:

Terminal window
# Sometimes the password policy is in robots.txt, README, or API docs
curl -s https://<TARGET>/robots.txt
curl -s https://<TARGET>/api/auth/policy 2>/dev/null
curl -s https://<TARGET>/help/security-faq
# Or in client-side validation JavaScript
curl -s https://<TARGET>/login | grep -iE 'password.*length|password.*regex'
curl -s https://<TARGET>/js/*.js 2>/dev/null | grep -iE 'password.*length'

Modern apps often validate client-side first using a regex visible in the page source. That regex is the policy.

Once you have a compliant wordlist, brute-force with awareness of bruteforce protections:

Terminal window
# Standard form brute-force with CSRF token re-fetch and rate-limit-aware throttling
ffuf -w policy-compliant.txt:PASS \
-u https://<TARGET>/login \
-X POST -d "user=admin&pass=PASS" \
-t 1 -p 7 \
-fr "Invalid credentials"
Terminal window
# Submit a definitely-invalid password and read every error in the response
curl -s -X POST -d "user=test&pass=a" \
https://<TARGET>/register | grep -iE '(password|requirement|character|length)'

A verbose response page that lists all the requirements is the cheapest possible policy disclosure. Always read the response carefully before inferring through trial-and-error.

  • Document the policy. Once inferred, write it down. Engagements that span multiple days waste effort re-deriving the same policy after a break.
  • Length bounds matter on both ends. A few apps cap the maximum password length surprisingly low (16 characters) - passwords longer than the max are accepted at registration but silently truncated, leading to weird login failures during testing.
  • The “complexity” axis isn’t the only one. Some apps reject passwords containing the username, dictionary words, sequential characters (abcd, 1234), or repeated characters. Each of these requires its own grep filter.
  • Server-side vs. client-side policy. Sometimes the policy enforced in JavaScript is stricter than the server actually enforces. Bypass the JS and submit a weaker password directly to the endpoint - if it’s accepted, your wordlist filter can be looser.