Hash Functions (password storage / cracking)
Security & Cryptography · Nutthakorn Chalaemwongwan
Today
- What a cryptographic hash actually guarantees (and doesn't)
- Why MD5/SHA-1 are "broken" — and why that barely matters here
- The real failure: fast hash + no salt = passwords fall in ms
- The fix: salting + slow KDFs (bcrypt/argon2/scrypt)
- 🔓 Game: Crack the Leaked DB
Recap — Week 1
- Week 1: adopt an attacker's mindset; threat-model a crypto system before trusting it
- A system can use a perfectly sound primitive and still fail — because of how it's used
- This week: the first concrete case study — a hash function, misused
What a cryptographic hash guarantees
- Any input, any size → fixed-size digest (deterministic)
- One-way: cheap to compute forward, expensive to invert
- Small input change → unrecognizably different output (avalanche effect)
- Used for: integrity checks, digital signatures, password storage (if done right)
Three security properties
| Property | An attacker can't... | Prevents |
|---|---|---|
| Preimage resistance | find any m with H(m) = h, given h | reversing a leaked hash |
| 2nd-preimage resistance | find m₂ ≠ m₁ with H(m₂) = H(m₁), given m₁ | swapping your file for a same-hash fake |
| Collision resistance | find any m₁ ≠ m₂ with H(m₁) = H(m₂) | forging two same-hash documents from scratch |
MD5 / SHA-1: broken — but where does it matter?
- MD5 (2004) and SHA-1 ("SHAttered," Google/CWI, 2017) — practical collision attacks exist
- Collision resistance is what digital signatures rely on → broken badly (attacker forges a second document with the same signature)
- Password storage relies on something else entirely: the attacker not being able to guess fast enough
The real failure: passwords aren't "reversed"
- An attacker with a leaked hash file does not invert the hash
- They guess and hash — try a candidate, compare, repeat, at billions/sec
- A fast hash (MD5, even plain SHA-256) is a gift to that attacker
- Speed is a feature for integrity checks — it's a bug for password storage
CWE-916 — Use of Password Hash With Insufficient Computational Effort
Worked example: the vulnerable store
# vulnerable_app.py — /login
stored = lookup(username) # md5 hex, no salt (users_vulnerable.csv)
ok = hashlib.md5(password.encode()).hexdigest() == stored
- Same password → same hash, for every user, every time
- One precomputed wordlist × MD5 table cracks the whole file at once
- CWE-916 (fast primitive) + CWE-759 (no salt at all)
The attack: dictionary & rainbow tables
- Dictionary attack: hash each candidate word, compare against the leaked hash
- Unsalted + fast → one precomputed table cracks every matching row in the leak
- Rainbow table: precomputed hash chains — trade storage for time, invert fast unsalted hashes at internet scale
- Verified in this lab:
admin's password cracked from a 101-word list in ~0.1 ms
Fix 1 — salting
- Salt: a random value per user, stored alongside the hash — not secret
- Store
H(salt ‖ password)— identical passwords → different hashes - Defeats: precomputed rainbow tables (attacker needs a fresh table per salt) and cross-user correlation ("did two users pick the same password?")
- A reused or constant salt gives none of this protection — it's just a longer password
CWE-759 / CWE-760 — one-way hash without a salt / with a predictable salt
Fix 2 — slow KDFs & work factor
- bcrypt / argon2 / scrypt — deliberately, tunably slow
- The cost / work factor controls how long one hash takes to compute
- Raising cost: the defender pays once per real login (negligible); the attacker pays per guess, per row — the cost compounds against them
- bcrypt buys work factor, not invincibility
The corrected construction
# fixed_app.py — /login
stored = lookup(username) # $2b$<cost>$<salt><hash> (users_fixed.csv)
ok = bcrypt.checkpw(password.encode(), stored)
- One-line diff from the vulnerable version:
bcrypt.checkpw(...)vs.md5(...) == - Same md5-precompute-over-wordlist technique → 0 / 8 rows match (verified)
- But:
admin's real password (sunshine2021) is in the wordlist — a patient, slow, per-hash bcrypt dictionary attack would still find it, just at orders-of-magnitude higher cost
🔓 Game — Crack the Leaked DB
Two identical services, same users/passwords, different hashing:
| Service | Port | Store | Crackable by fast offline dictionary? |
|---|---|---|---|
vulnerable_app.py | :8094 | unsalted MD5 | Yes — milliseconds |
fixed_app.py | :8095 | bcrypt, per-user salt + cost | No (to this technique) |
- Exfil the leaked
users_vulnerable.csv, crackadmin's MD5 withwordlist.txt POST /loginthe recovered password, thenGET /adminon the same session → flag- Run the same technique against
users_fixed.csv→ prove it matches nothing
docker compose up -d # vulnerable_app.py :8094, fixed_app.py :8095
python exploit.py # PASS :8094 (flag) · PASS :8095 (0 bcrypt matches)
Lab today
📋 Worksheet 2 —
labs/week02-hash/worksheet.md· kickoff:docker compose up -d
- Part 2 (Q1–Q8): properties, broken hashes, fast-vs-slow, salts, rainbow tables, pepper
- Part 3 (Tasks 0–5): crack the MD5 store, capture the flag, confirm bcrypt resists it
- 🤖 Audit the AI: critique an AI's "secure"
store_password— bare SHA-256 + a hardcoded constant"salt", framed as a benefit - 🧠 EiPE + Prompt Problem: explain the mechanism in plain English; stress-test an AI's password-storage advice
Key takeaways
- Hash functions are textbook-secure — the math (preimage/collision resistance) holds
- The real-system failure: using a fast, general-purpose hash where a slow, purpose-built KDF belongs
- The attacker doesn't break the math — they guess and hash, fast, at scale
- Fix = salt (kills precomputation) + slow KDF (raises cost) — still pair with a strong-password policy
Questions?
Next week: (see course roadmap)