~/learn / cryptography / hashing

Hashing and Password Storage

What cryptographic hashes guarantee, how MACs and length extension work, the cracking economics that make fast hashes wrong for passwords, and how salts, peppers, and memory-hard algorithms like Argon2id protect credentials.

beginnerupdated

A hash function maps input of any length to a fixed-length digest. The same input always gives the same output, and changing even one input bit changes roughly half the output bits unpredictably (the avalanche effect). It is a one-way fingerprint: easy to compute forward, infeasible to reverse. Try it in the hash generator, and identify an unknown digest with the hash identifier.

What a cryptographic hash guarantees

  • Preimage resistance: given a digest, you cannot find any input that produces it. This is what “one-way” means.
  • Second preimage resistance: given one input, you cannot find a different input with the same digest.
  • Collision resistance: you cannot find any two inputs that share a digest. This is the weakest to hold, because the birthday bound means a brute-force collision on an n-bit hash takes only about 2^(n/2) work, not 2^n. That is why a 256-bit hash gives 128-bit collision security.

MD5 and SHA-1 have practical collision attacks: researchers have produced distinct files, and even distinct valid TLS certificates (the Flame malware forged a Microsoft signature this way), with matching digests. They remain fine for non-adversarial checksums but are unacceptable for signatures or integrity against an attacker. Use SHA-256, SHA-3, or BLAKE2.

Hashes are not MACs, and length extension is why

A plain hash has no key, so anyone can compute it. If a server stores sha256(data) beside data to detect tampering, an attacker who edits the data simply recomputes the hash. Integrity against an attacker needs a secret.

The tempting homemade fix, sha256(secret + message), is broken by length extension. SHA-256, SHA-1, and MD5 use the Merkle-Damgard construction: they process the message in blocks, and the final digest is the internal state after the last block. An attacker who sees sha256(secret + message) can load that digest back in as the starting state and keep hashing, computing sha256(secret + message + padding + attacker_data) as a valid tag, without ever knowing the secret. This has broken real signed-API designs.

Use HMAC instead. HMAC nests two hashes with the key mixed in at both ends (H(key ⊕ opad || H(key ⊕ ipad || message))), which structurally defeats length extension. When you verify a MAC, compare in constant time (hmac.compare_digest in Python, crypto.timingSafeEqual in Node). An ordinary == returns at the first differing byte, and that timing difference leaks how many leading bytes were correct, enough to forge a tag one byte at a time.

Why fast hashes are wrong for passwords

SHA-256 is engineered to be fast, and that is exactly the problem for passwords. A single modern GPU computes billions of SHA-256 hashes per second; a rig of them reaches hundreds of billions. If a database of SHA-256 password hashes leaks, an attacker runs a large wordlist with millions of mangling rules through it and recovers every common password in minutes. The economics are brutal: cracking is embarrassingly parallel and cheap.

Two independent defenses fix it:

  1. A unique salt per password. A random value stored alongside the hash and mixed in before hashing. Identical passwords now produce different hashes, which destroys precomputed lookup tables (rainbow tables) and forces the attacker to attack each hash separately rather than all at once. A salt is not secret; its job is uniqueness.
  2. A deliberately slow, memory-hard algorithm. Make each single guess cost real time and real memory, dragging cracking speed from billions per second down to thousands. Memory-hardness is the clever part: GPUs and custom ASICs get their advantage from massive parallelism with little memory each, so an algorithm that demands a large memory buffer per guess erases that advantage.

An optional third layer, a pepper, is a secret key applied via HMAC before or after hashing and stored separately from the database (ideally in a KMS). A database-only leak then yields nothing crackable, because the attacker is missing the pepper.

The right algorithms

Algorithm Status
Argon2id First choice. Memory-hard, with tunable memory, time, and parallelism. Won the Password Hashing Competition.
scrypt Memory-hard, solid, widely available.
bcrypt Still acceptable. Its cost factor is a power of two, so cost 12 runs 2^12 iterations of its key schedule. Truncates input at 72 bytes, which matters for passphrases.
PBKDF2 Acceptable only with a very high iteration count. Not memory-hard, so it ages poorly, but it is often mandated for FIPS compliance.
MD5, SHA-1, SHA-256 (even salted, single round) Not acceptable for passwords, ever.

Tune the parameters so a single hash takes a noticeable fraction of a second on your hardware (a common target is around 250 to 500 milliseconds), then revisit as hardware improves. The cost is a dial you set against the attacker’s economics.

# Rails: has_secure_password uses bcrypt out of the box
class User < ApplicationRecord
  has_secure_password
end

user = User.create!(email: "[email protected]", password: "correct horse battery staple")
user.authenticate("wrong")  # => false

Recognizing hashes in the wild

Password hash formats are usually self-describing, which tells you the algorithm and its parameters at a glance:

  • $2b$12$... is bcrypt at cost 12.
  • $argon2id$v=19$m=65536,t=3,p=4$... is Argon2id with 64 MiB memory, 3 passes, 4 lanes.
  • A bare 32-character hex string is probably MD5 or NTLM; 40 hex is SHA-1; 64 hex is SHA-256.

The hash identifier narrows an unknown digest to likely candidates.

Field notes: identify, then crack

You cannot crack what you cannot identify. Read the format on sight, then confirm with hashid or hash-identifier:

Looks like Almost certainly hashcat mode
32 hex chars MD5 0
40 hex chars SHA-1 100
64 hex chars SHA-256 1400
$2b$12$... (60 chars) bcrypt 3200
$argon2id$... Argon2 (use the native tool)
sha512$... or pbkdf2_sha256$... Django 124xx
$6$... sha512crypt (Linux shadow) 1800
aad3b435...:... NTLM 1000

Then run a rig. The attack order that works: wordlist, then wordlist plus rules (hashcat’s best64.rule mutates password into P@ssw0rd!), then mask for known patterns, then pure brute force only as a last resort:

hashcat -m 0 -a 0 hashes.txt rockyou.txt -r rules/best64.rule
hashcat -m 0 -a 3 hashes.txt '?u?l?l?l?l?d?d?d?s'     # mask: UlllldddS

The economics are the whole point: a single modern GPU does tens of billions of MD5 guesses per second and essentially cannot touch a properly-tuned bcrypt or Argon2id hash. That gap is why the choice of algorithm, not the length of the password, usually decides the outcome.

Field notes: length extension and timing

  • Length-extension. If an app authenticates a message with MAC = SHA256(secret || message), Merkle-Damgard construction lets an attacker who has one valid (message, MAC) pair append data and compute a valid MAC for message || padding || extra without knowing the secret. hashpump does it: feed it the known MAC, the data to append, and the secret length. The fix is HMAC, which is immune, or a modern hash (SHA-3, BLAKE2) that is not vulnerable.
  • Timing side channel. Comparing a token or MAC with == returns early on the first wrong byte, leaking, through response time, how many leading bytes were correct. Over many requests this recovers the value byte by byte. Always compare secrets in constant time (hmac.compare_digest, secure_compare).
  • Salt confirms the point. Two identical passwords with different salts produce different hashes, which defeats precomputed rainbow tables and makes each hash a separate cracking job. An unsalted hash list cracks in one pass against a rainbow table.

Crack only hashes you are authorized to test, in a lab or an in-scope engagement.

Testing notes

  • Reset tokens or session ids shaped like md5(email + timestamp) are predictable and forgeable. Unguessable values must come from a CSPRNG, not a hash of known inputs.
  • For high-value systems, check for a pepper and for encryption at rest layered on top of hashing.
  • Confirm the login endpoint takes roughly constant time whether the username exists or not; a timing gap enumerates valid accounts. The same applies to the password comparison itself.