H4CK0R Network Security ยท Recon ยท Education

๐Ÿงฎ Passwords, Hashing and Credential Attacks

Passwords, Hashing and Credential Attacks

Password security is usually taught backwards, as a list of composition rules for users. The useful way round is to start from how credentials are actually stolen, because that determines which defences are worth anything.

How credentials are really attacked

Why the hash function choice is everything

General-purpose hashes are designed to be fast, and speed is the attacker's entire budget. Published GPU benchmarks put a single high-end consumer card on the order of 1011 MD5 or NTLM guesses per second, and roughly 1010 for SHA-256 โ€” while bcrypt at cost 12 sits around 103 guesses per second on the same hardware. That is a factor of roughly a hundred million, bought with one configuration value. (Exact figures move with hardware; the ratio is the point.)

AlgorithmUse it?Concrete parameters
Argon2id (RFC 9106)First choiceOWASP's minimum is m=19 MiB, t=2, p=1. RFC 9106's first recommendation, where memory allows, is much heavier: t=1, m=2 GiB, p=4, with t=3, m=64 MiB, p=4 as the second option. Tune upward until one hash costs roughly 0.25โ€“1 s of server time at your peak login concurrency.
bcryptFine, and everywhereCost factor 10 at an absolute minimum, 12 or higher preferred. Cost is a base-2 exponent: 12 is four times the work of 10. Note the 72-byte input limit below.
scrypt (RFC 7914)Good if Argon2 is unavailableN=217, r=8, p=1 as a floor. N is memory and CPU; r and p tune block size and parallelism.
PBKDF2 (RFC 8018)Only when FIPS requires itOWASP's current iteration guidance: 600,000 with HMAC-SHA-256, 1,300,000 with HMAC-SHA-1, 210,000 with HMAC-SHA-512. It has no memory cost, so GPUs and ASICs keep their advantage.
SHA-256 / SHA-1 / MD5Never, for passwordsNot even "but it's salted" โ€” see below.
bcrypt's 72-byte ceiling bcrypt only consumes the first 72 bytes of its input and silently discards the rest, so a 200-character passphrase is no stronger than its first 72 bytes โ€” and some implementations also truncate at the first NUL byte. If you accept long passphrases, pre-hash: bcrypt(base64(SHA-256(password))). Base64 matters, because feeding raw binary in can reintroduce the NUL problem. Read the shucking warning further down before adopting that pattern.

Salts are per-user random values, at least 16 bytes from a CSPRNG, stored in plaintext next to the hash. They are not secret. Their job is to stop precomputation โ€” one rainbow table cannot cover every user, and two users with the same password no longer have the same hash. Peppers are a single secret value that is deliberately not in the database, ideally in an HSM or KMS: apply HMAC-SHA-256(pepper_key, password) before the slow hash, so a stolen database with no application secret is uncrackable. Peppers complicate rotation, so version them.

"It's salted SHA-256, that's fine" โ€” it is not A salt defeats precomputation. It does nothing about throughput. With a per-user salt, an attacker still tests a candidate password against a given user at ~1010 guesses per second, and the weak half of any real user corpus falls in minutes. Salting is necessary and nowhere near sufficient; the deliberate slowness is the actual control.
Storing the parameters with the hash

Never store a bare digest. Every modern algorithm serialises its parameters into the hash string, which is what lets you raise the cost later without touching existing rows:

$argon2id$v=19$m=19456,t=2,p=1$<base64 salt>$<base64 derived key>
|         |    |               |             |
|         |    |               |             +-- the derived key itself
|         |    |               +-- per-user salt
|         |    +-- memory in KiB, iterations, parallelism
|         +-- Argon2 version 19 (0x13)
+-- algorithm identifier

$2b$12$<22-char salt><31-char hash>      # bcrypt: $2b$ variant, cost 12

On every successful login, check whether the stored parameters are below current policy and, if so, re-hash the plaintext you already have in memory and update the row. That is how a hash store upgrades itself with no user disruption.

Migrating a legacy store without a mass reset

If you inherit unsalted md5(password), you cannot recover plaintexts โ€” so wrap them. Store argon2id(md5_hash) along with a scheme marker, and at login apply the legacy transform first, then the new one. Every existing row is upgraded immediately and offline cracking now faces Argon2. On the next successful login you can quietly replace the wrapped entry with a direct argon2id(password) and clear the marker.

The wrapping caveat: password shucking If the inner function is an unsalted hash like MD5, an attacker holding a large MD5-to-plaintext corpus can look up the inner digest instead of guessing the password โ€” "shucking" the outer bcrypt/Argon2 layer for any password that already appears in a public rainbow set. Wrapping is still a large improvement, but treat it as a transitional state, and prefer a keyed inner step (HMAC with a pepper) if you can.

Two more implementation details that matter: compare derived keys with a constant-time function, and run a dummy hash of the same cost when the username does not exist โ€” otherwise your login endpoint answers "no such user" in 2 ms and "wrong password" in 300 ms, which is a free user-enumeration oracle.

What actually makes a password strong

Entropy in bits is length ร— log2(alphabet size) โ€” but only when the password was generated uniformly at random. Applied to a human-chosen password the formula is fiction: P@ssw0rd!23 scores 72 bits on the naive calculation and falls to the first rule-based wordlist attack, because cracking tools apply exactly the substitutions humans think are clever. Length and unpredictability are what matter; character-class rules mostly produce Password1!.

Secret (randomly generated)KeyspaceEntropyFull search at 1011/s (fast hash)At 103/s (bcrypt cost 12)
8 chars, aโ€“z0โ€“9368 โ‰ˆ 2.8ร—101241 bits~30 seconds~90 years
12 chars, aโ€“zAโ€“Z0โ€“96212 โ‰ˆ 3.2ร—102171 bits~1,000 years~1011 years
6-word EFF diceware passphrase77766 โ‰ˆ 2.2ร—102377 bits~70,000 years~1013 years
16 chars, all 95 printable ASCII9516 โ‰ˆ 4.4ร—1031105 bits~1013 years~1021 years

Halve those times for the expected (average) case. Two lessons fall out. First, an 8-character password is not a password against an offline attack, whatever symbols it contains. Second, the difference between the first row and the second is 30 seconds versus a millennium โ€” and it cost four extra characters.

Diceware generates passphrases by choosing words uniformly at random from a published list; the EFF long list has 7776 entries, so each word contributes log2(7776) โ‰ˆ 12.9 bits. Six words is about 77 bits and is genuinely typeable and memorable. The critical requirement is that the words are chosen by dice or a CSPRNG โ€” a phrase you thought of is not diceware and carries a small fraction of that entropy. Estimate a password's strength โ†’ ยท generate one โ†’

What NIST actually says

NIST SP 800-63B (Digital Identity Guidelines: Authentication and Lifecycle Management) is the standard people cite for the old advice and almost never for the current advice. Since the 2017 revision it says, for verifiers:

Checking against breach corpora is the highest-value item on that list, and it does not require handing the password to anyone. The k-anonymity range API takes only the first five hex characters of the password's SHA-1 hash, returns every hash suffix that shares that prefix โ€” hundreds of them โ€” and the match is made locally. This site's breach tool works exactly that way: the password and its full hash never leave your browser, and the five-character prefix reveals nothing useful. Check a password against breach corpora โ†’

Moving past the password

TOTP (RFC 6238)

Time-based one-time passwords are HOTP (RFC 4226) with the counter replaced by the clock. Server and authenticator share a base32 secret; both compute:

T    = floor((unix_time - T0) / X)          # T0 = 0, X = 30 seconds
H    = HMAC-SHA-1(K, T as 8-byte big-endian)   # 20 bytes
o    = H[19] & 0x0f                         # dynamic truncation offset
S    = (H[o] & 0x7f) << 24 | (H[o+1] & 0xff) << 16
                           | (H[o+2] & 0xff) <<  8 | (H[o+3] & 0xff)
code = S mod 10^6                           # zero-padded to 6 digits

otpauth://totp/Example:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example&algorithm=SHA1&digits=6&period=30

Servers normally accept the adjacent time steps to tolerate clock drift, and must mark a used code as spent so it cannot be replayed within its window. TOTP's limits are structural: the secret is symmetric, so a server-side breach exposes every user's seed, and the code is not bound to a site, so it can be phished and relayed in real time. It is still a very large improvement over a password alone. Generate and verify TOTP codes โ†’

SMS

SMS one-time codes are phishable like TOTP and exposed to SIM swapping, carrier account takeover, and interception in the signalling network. NIST treats SMS as a restricted authenticator. It is better than nothing and worse than everything else; if you offer it, do not make it the account-recovery backdoor that bypasses stronger factors.

WebAuthn and passkeys

WebAuthn (a W3C standard, with FIDO2/CTAP2 underneath) replaces the shared secret with a per-site key pair. Registration stores a public key on the server; authentication has the authenticator sign a challenge. Two properties make it categorically different:

Passkeys are WebAuthn credentials that a platform may sync across a user's devices, trading a little hardware binding for the recovery story that kept security keys niche. Keep an eye on the recovery path โ€” an account that falls back to an emailed link is only as strong as the mailbox โ€” and remember that phishing resistance is a property of the credential, not of the word "MFA".

Try it: password strength analyzer โ†’ ยท breach check (k-anonymity) โ†’ ยท password generator โ†’ ยท TOTP tool โ†’ ยท hash generator โ†’

Key takeaways
  • Credential stuffing beats cracking by orders of magnitude in the real world; unique passwords, MFA and breach-list checks are the counters.
  • Argon2id first, bcrypt (cost โ‰ฅ 12) as the pragmatic default, scrypt as an alternative, PBKDF2 only for FIPS. Never a bare fast hash.
  • Salts stop precomputation; only slowness stops throughput. "Salted SHA-256" is still broken for passwords.
  • bcrypt silently truncates input past 72 bytes โ€” pre-hash long inputs carefully, and be aware of password shucking when the inner hash is unsalted.
  • Store algorithm parameters with the hash, and re-hash on login when they fall below policy.
  • Length and randomness beat character-class rules; 8 characters is not a password against an offline attack.
  • SP 800-63B: check against breach lists, allow long passphrases and paste, drop composition rules and scheduled rotation.
  • TOTP and SMS are phishable in real time. WebAuthn/passkeys are origin-bound, which is what actually stops proxy phishing.