Learn Ethical Hacking (#7) - Passwords - Why Humans Are the Weakest Cipher
Learn Ethical Hacking (#7) - Passwords - Why Humans Are the Weakest Cipher

What will I learn
- How passwords are stored: plaintext, MD5, SHA-256, bcrypt, argon2;
- Rainbow tables and precomputed hash attacks;
- Dictionary attacks: wordlists, rules, mutations;
- Hashcat and John the Ripper: GPU-accelerated hash cracking;
- Password spraying vs brute force vs credential stuffing;
- Building your own hash cracker in Python from scratch;
- The defense: why bcrypt wins (work factor, salts, constant-time comparison).
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- Your hacking lab from Episode 2 (Kali Linux);
- Python 3 with hashlib (standard library);
- The ambition to learn ethical hacking and security research.
Difficulty
- Beginner
Curriculum (of the Learn Ethical Hacking series):
- Learn Ethical Hacking (#1) - Why Hackers Win
- Learn Ethical Hacking (#2) - Your Hacking Lab
- Learn Ethical Hacking (#3) - How the Internet Actually Works - For Attackers
- Learn Ethical Hacking (#4) - Reconnaissance - The Art of Not Being Noticed
- Learn Ethical Hacking (#5) - Active Scanning - Mapping the Attack Surface
- Learn Ethical Hacking (#6) - The AI Slop Epidemic - Why AI-Generated Code Is a Security Disaster
- Learn Ethical Hacking (#7) - Passwords - Why Humans Are the Weakest Cipher (this post)
Solutions to Episode 6 Exercises
Exercise 1 -- AI code audit:
Typical AI-generated vulnerabilities: (a) SQL injection via f-string/format in SELECT query, (b) path traversal -- open(filename) without validating the path stays within allowed directory, (c) XSS via innerHTML assignment of user content, (d) insecure deserialization via pickle.load, (e) security group with 0.0.0.0/0 on all ports. Secure alternatives: parameterized queries, os.path.realpath + prefix check, text content or DOMPurify, JSON instead of pickle, restrict CIDR to specific IPs + specific ports only.
The key insight: AI generates the pattern it saw most often in training data. The most common pattern is usually the simplest, which is usually the least secure.
Exercise 2 -- ai_code_auditor.py:
import re, sys
PATTERNS = [
(r'f["\']SELECT.*\{', "SQL injection via f-string", "Use parameterized queries"),
(r'["\']SELECT.*["\'] *%', "SQL injection via % formatting", "Use parameterized queries"),
(r'pickle\.load', "Insecure deserialization", "Use json.load instead"),
(r'open\(.*(variable|param|arg|input|request)', "Potential path traversal", "Validate path prefix"),
(r'AKIA[A-Z0-9]{16}', "Hardcoded AWS access key", "Use environment variables"),
]
def audit(filepath):
with open(filepath) as f:
for i, line in enumerate(f, 1):
for pattern, desc, fix in PATTERNS:
if re.search(pattern, line):
print(f" Line {i}: {desc}")
print(f" Fix: {fix}")
print(f" Code: {line.strip()[:80]}")
if __name__ == '__main__':
audit(sys.argv[1])
The key insight: even simple regex-based scanning catches a surprising number of AI-generated vulnerabilities. Professional SAST tools (Semgrep, CodeQL) do this with AST awareness for much higher accuracy.
Exercise 3 -- Economics essay:
Key points: developers benefit from faster output (productivity metrics, shipping deadlines). Companies benefit from reduced development cost. Users bear the cost when breaches occur. Current market incentives reward speed over security because breach costs are often externalized (the company gets hacked, but it's the USERS whose data is stolen). Regulation (GDPR fines, SEC disclosure requirements) partially corrects this but lags behind AI adoption speed.
Learn Ethical Hacking (#7) - Passwords - Why Humans Are the Weakest Cipher
Passwords. The oldest security mechanism on computers and still the most common. Also still the most broken. Not because the technology is bad (modern hashing is excellent) but because humans are predictably terrible at choosing and managing them.
In this episode we're going to attack passwords from the attacker's side -- cracking hashes, spraying logins, stuffing credentials -- and then understand exactly what makes certain defenses effective. We'll also build our own hash cracker in Python, because (as always in this series) understanding the tool means building the tool.
How Passwords Are Stored
Let me get this out of the way first: some applications still store passwords in plaintext. In 2026. In production databases. I wish I was joking. When these get breached, every user's password is immediately compromised -- no cracking needed.
Most competent applications store a hash of the password. A hash is a one-way mathematical function: easy to compute in one direction (password -> hash), computationally infeasible to reverse (hash -> password). When you log in, the application hashes what you typed and compares it to the stored hash. If the hashes match, you're in. The server never needs to know your actual password -- only its hash.
We covered hashing in the Learn Python Series, but now we're looking at it through a security lens. The question isn't "how do hashes work?" but "how easily can an attacker undo them?"
import hashlib
password = "P@ssw0rd123"
# MD5 -- NEVER use for passwords (fast = bad for password hashing)
md5_hash = hashlib.md5(password.encode()).hexdigest()
print(f"MD5: {md5_hash}")
# Output: MD5: a3a858b0e6a0ed6e47bea9c57a1fe929
# SHA-256 -- better but still TOO FAST for passwords
sha256_hash = hashlib.sha256(password.encode()).hexdigest()
print(f"SHA256: {sha256_hash}")
# These can be cracked at BILLIONS of hashes per second on a modern GPU
The problem with MD5 and SHA-256 for passwords? They're fast. A modern GPU can compute billions of MD5 hashes per second. That means an attacker with a stolen hash database can try billions of password guesses per second until one matches.
Wait -- why would speed be bad? Because password hashing is the one context where you WANT the function to be slow. Every legitimate login only needs one hash computation (the user's password). But an attacker needs to compute millions or billions of hashes to brute-force the database. If each hash takes a nanosecond, the attacker finishes in seconds. If each hash takes 250 milliseconds, the attacker needs years.
The solution is slow hashing -- algorithms specifically designed to be computationally expensive:
import bcrypt
password = b"P@ssw0rd123"
# bcrypt -- designed to be SLOW (cost factor = number of rounds)
hashed = bcrypt.hashpw(password, bcrypt.gensalt(rounds=12))
print(f"bcrypt: {hashed}")
# Takes ~250ms per hash at rounds=12 -- on purpose!
# Attacker goes from billions/sec to ~4/sec per core
bcrypt adds a random salt (unique per password, stored alongside the hash) so identical passwords produce different hashes. It has a configurable work factor that makes it slower as hardware gets faster -- you just bump the rounds parameter up every few years. And it uses constant-time comparison to prevent timing attacks (where an attacker measures how long the comparison takes to determine how many characters matched).
argon2 is the newer standard (winner of the Password Hashing Competition in 2015). It's memory-hard -- it deliberately uses a lot of RAM, which makes GPU-based parallel cracking much harder because GPUs have limited memory per core:
# argon2id -- current best practice
from argon2 import PasswordHasher
ph = PasswordHasher()
hash = ph.hash("P@ssw0rd123")
print(f"argon2: {hash}")
# Memory-hard: uses ~64MB RAM per hash, resists GPU attacks
If you remember episode 6 where we talked about AI code assistants generating insecure code -- password hashing is a prime example. Ask an AI to "hash a password in Python" and you'll frequently get hashlib.sha256(password.encode()).hexdigest(). Functional? Yes. Secure for password storage? Absolutely not. The AI doesn't understand why bcrypt exists because it can't reason about attack contexts.
Rainbow Tables: Precomputed Attacks
A rainbow table is a precomputed database of password-to-hash mappings. Instead of computing hashes in real-time, the attacker downloads a table containing billions of pre-calculated hashes and does a lookup. For common passwords with unsalted hashes, cracking becomes a dictionary lookup -- essentially instant.
Here's what that looks like conceptually:
# Simplified rainbow table concept
rainbow_table = {}
# Precompute (attacker does this ONCE, reuses forever)
with open("/usr/share/wordlists/rockyou.txt", errors='replace') as f:
for line in f:
word = line.strip()
h = hashlib.md5(word.encode()).hexdigest()
rainbow_table[h] = word
# Crack (instant lookup instead of computation)
stolen_hash = "5f4dcc3b5aa765d61d8327deb882cf99"
if stolen_hash in rainbow_table:
print(f"Cracked: {rainbow_table[stolen_hash]}")
# Output: Cracked: password
For unsalted MD5/SHA-256, rainbow tables make cracking essentially instant. Sites like CrackStation host free rainbow tables with billions of entries. You paste in a hash, get the password back. Zomaar.
This is why salts are critical. If every password has a unique random salt prepended before hashing, the attacker needs a seperate rainbow table for every possible salt value -- which is computationally impossible. The salt doesn't need to be secret (bcrypt stores it right next to the hash). It just needs to be unique per password. bcrypt and argon2 include salts automatically, which is another reason to use them instead of rolling your own.
Dictionary Attacks and Wordlists
Most humans choose predictable passwords. Security researchers maintain wordlists -- collections of commonly used passwords, leaked credentials, and mutations:
# Kali comes with wordlists
ls /usr/share/wordlists/
# rockyou.txt -- 14 million real passwords from the 2009 RockYou breach
wc -l /usr/share/wordlists/rockyou.txt
# 14344392 lines
# Top 10 most common passwords (from rockyou.txt)
head -10 /usr/share/wordlists/rockyou.txt
# 123456
# 12345
# 123456789
# password
# iloveyou
# princess
# 1234567
# rockyou
# 12345678
# abc123
14 million passwords, and the #1 most common is 123456. This list came from an actual breach -- RockYou stored passwords in plaintext (see what I said earlier?) and got hacked in 2009. 14 million real passwords, exposed, downloadable. Humans are wonderfully predictable.
There are bigger lists too. SecLists on Github has curated wordlists for different targets (web app passwords, common admin passwords, country-specific passwords). The "Collection #1" breach compilation leaked in 2019 contained 773 million unique email addresses and 21 million unique passwords. Password reuse means that a credential leaked from one site is likely valid on other sites too.
Cracking with Hashcat
Hashcat is the industry-standard password cracking tool. It leverages GPU acceleration to achieve extraordinary speeds. If you've got a decent graphics card in your host machine, hashcat will use it. Even without a GPU, CPU-mode works -- just slower.
# Create a test hash file
echo -n "password123" | md5sum | cut -d' ' -f1 > /tmp/test_hash.txt
# Crack MD5 hash with rockyou wordlist
hashcat -m 0 /tmp/test_hash.txt /usr/share/wordlists/rockyou.txt
# -m 0 = MD5 hash mode
# Cracks in under a second
# With rules (mutations: capitalize, add numbers, leet speak)
hashcat -m 0 /tmp/test_hash.txt /usr/share/wordlists/rockyou.txt -r /usr/share/hashcat/rules/best64.rule
# Brute force (all combinations up to 8 chars)
hashcat -m 0 /tmp/test_hash.txt -a 3 ?a?a?a?a?a?a?a?a
# ?a = all printable ASCII characters
The -r flag is where things get interesting. Rules tell hashcat to mutate each wordlist entry: capitalize the first letter, append "1", append "123", replace a with @, replace e with 3, reverse the word, double it... the best64.rule file contains 64 of the most effective mutations based on real password analysis. This means your 14-million-word rockyou.txt effectively becomes 14 million x 64 = almost a billion guesses. And it still runs in seconds for MD5.
John the Ripper is the other major cracking tool. It's older than hashcat and CPU-oriented, but has excellent format support (especially for cracking Unix /etc/shadow hashes, Windows NTLM, and office documents). Hashcat is generally faster for GPU-accelerated cracking; John is more versatile for exotic hash formats. Most pentesters keep both in their toolkit.
Hashcat speed depends on hash type and GPU:
| Hash Type | Speed (RTX 4090) |
|---|---|
| MD5 | ~164 billion/sec |
| SHA-256 | ~22 billion/sec |
| bcrypt (cost 12) | ~184 thousand/sec |
| argon2 | ~dozens/sec |
See the difference? MD5 at 164 billion guesses per second means a brute-force of every 8-character lowercase password (26^8 = 208 billion combinations) takes about 1.3 seconds. bcrypt at the same hardware? Over 13 days. argon2? Years. That's not a marginal improvement -- it's the difference between "cracked before you finish your coffee" and "still running when the sun burns out."
Building Your Own Hash Cracker
As with everything in this series: understand the tool by building it yourself. This brings together Python concepts we covered in the Learn Python Series (file I/O, hashlib, sys.argv) but applied to an offensive security context:
#!/usr/bin/env python3
"""
Simple dictionary hash cracker -- educational purposes only.
Demonstrates WHY fast hashes (MD5, SHA) are terrible for passwords.
"""
import hashlib
import sys
import time
def crack_md5(target_hash, wordlist_path):
"""Attempt to crack an MD5 hash using a wordlist."""
start = time.time()
attempts = 0
with open(wordlist_path, 'r', errors='replace') as f:
for line in f:
password = line.strip()
attempts += 1
if hashlib.md5(password.encode()).hexdigest() == target_hash:
elapsed = time.time() - start
print(f"[+] CRACKED: {password}")
print(f" Attempts: {attempts:,}")
print(f" Time: {elapsed:.2f}s")
print(f" Speed: {attempts/elapsed:,.0f} hashes/sec")
return password
elapsed = time.time() - start
print(f"[-] Not found in {attempts:,} attempts ({elapsed:.2f}s)")
return None
if __name__ == '__main__':
target = sys.argv[1] if len(sys.argv) > 1 else "5f4dcc3b5aa765d61d8327deb882cf99" # "password"
wordlist = sys.argv[2] if len(sys.argv) > 2 else "/usr/share/wordlists/rockyou.txt"
print(f"[*] Cracking: {target}")
print(f"[*] Wordlist: {wordlist}")
crack_md5(target, wordlist)
Run it:
# Generate a hash to crack
echo -n "sunshine" | md5sum
# 0571749e2ac330a7455571e54f60a9b9
python3 hash_cracker.py 0571749e2ac330a7455571e54f60a9b9 /usr/share/wordlists/rockyou.txt
# [+] CRACKED: sunshine
# Attempts: 14
# Time: 0.00s
14 attempts. Because "sunshine" is the 14th most common password in rockyou.txt. Veertien pogingen.
Now compare that to what happens when we try to crack a bcrypt hash with the same approach:
import bcrypt
import time
password = b"sunshine"
hashed = bcrypt.hashpw(password, bcrypt.gensalt(rounds=12))
# Try to crack it (this will be PAINFULLY slow)
start = time.time()
attempts = 0
with open("/usr/share/wordlists/rockyou.txt", errors='replace') as f:
for line in f:
attempts += 1
candidate = line.strip().encode()
if bcrypt.checkpw(candidate, hashed):
elapsed = time.time() - start
print(f"[+] CRACKED: {line.strip()}")
print(f" Attempts: {attempts}")
print(f" Time: {elapsed:.2f}s")
print(f" Speed: {attempts/elapsed:.1f} hashes/sec")
break
if attempts % 100 == 0:
elapsed = time.time() - start
print(f" Progress: {attempts} attempts, {attempts/elapsed:.1f}/sec")
Even though "sunshine" is only 14 entries in, the bcrypt version still takes a few seconds because each checkpw call takes ~250ms. And here's the kicker: if the password was at position 1,000,000 in the wordlist instead of position 14, the MD5 cracker would find it in about 0.5 seconds. The bcrypt cracker would need approximately 69 hours. Same wordlist, same password, same hardware -- just a different hash function. That is what slow hashing buys you.
Password Spraying vs Brute Force vs Credential Stuffing
Three different attack strategies for different situations. Understanding when to use each is the difference between a methodical pentester and someone randomly pounding on doors:
Brute Force: try every possible combination against one account. Gets locked out by account lockout policies. Slow. Last resort. You only brute-force when you know the password space is small (4-digit PIN) or when there's no lockout mechanism (offline hash cracking is technically brute-force).
Dictionary Attack: try a wordlist of common passwords against one account. More efficient than brute force because humans are predictable. Still gets locked out by online lockout policies. Works great for offline cracking (stolen hash databases) where there's no lockout.
Password Spraying: try ONE common password against MANY accounts. Then wait. Then try the NEXT common password against all accounts. Avoids lockout because each account only sees 1-2 attempts per time window. If an organization has 1000 employees and 3% use "Summer2026!", spraying finds 30 valid accounts. This is the #1 attack against enterprise environments -- Active Directory, Office 365, VPN gateways.
Credential Stuffing: use username/password pairs from PREVIOUS breaches against new services. Works because people reuse passwords across sites. Massive databases of leaked credentials exist (Collection #1-5: 3.2 billion unique email/password pairs). If you used the same password on LinkedIn (breached 2012, 117 million passwords) and your bank account... well.
# Password spraying concept (pseudocode -- do NOT run against real targets)
common_passwords = ["Summer2026!", "Password1!", "Company123!", "Welcome1!"]
users = ["john.doe", "jane.smith", "admin", "helpdesk"]
for password in common_passwords:
for user in users:
result = attempt_login(user, password)
if result.success:
print(f"[+] Valid: {user}:{password}")
time.sleep(1800) # wait 30 min between rounds to avoid lockout
That 30-minute sleep is the key to spraying. Most lockout policies reset after 15-30 minutes. By spacing your rounds, you stay under the lockout threshold while systematically testing the entire user base. Penetration testers regulary find 5-15% of users have passwords matching the top 20 most common patterns. The bigger the organization, the better spraying works -- somebody always uses a weak password.
Having said that, credential stuffing is arguably even more devastating at scale. There's no guessing involved -- you're using known valid credentials from other breaches. The only defense is for the user to have a unique password per site. Which (let's be honest) most people don't.
The Human Element
Password cracking is a technical exercise, but password weakness is a human problem. People choose passwords based on:
- Memorability: words they know, dates they remember, pets they love
- Minimum effort: meet the complexity requirement and stop (
Password1!has uppercase, lowercase, number, special character -- technically compliant, practically useless) - Patterns: keyboard walks (
qwerty,zxcvbn), sequences (abc123), character substitutions they think are clever but aren't (p@$$w0rd)
Every single one of these patterns is in the wordlists. Pentesters and crackers study human password behavior specifically to build better wordlists and rule sets. The best64.rule file in hashcat exists because someone analyzed millions of real passwords and found the 64 most common transformations people apply.
This connects directly to the social engineering concepts we'll explore in upcoming episodes -- because the most effective way to get someone's password isn't always to crack it. Sometimes you just ask. Phishing emails that mimic password reset pages, helpdesk calls from "IT support", shoulder surfing in coffee shops... there are entire attack categories built around exploiting the human element rather than the mathematical one.
The Defense
After seeing how easily passwords fall, here's what actually works:
- Use bcrypt or argon2 -- never MD5, SHA-1, or SHA-256 for password storage. If you're a developer and your ORM or framework defaults to SHA-256 for passwords, change it. Today.
- Enforce length over complexity -- a 20-character passphrase ("correct horse battery staple") beats a complex 8-character password (
P@ssw0rd!) every time. The math is clear: 26^20 >> 95^8. Complexity requirements annoy users and produce predictable patterns. - Multi-factor authentication -- even if the password is cracked, MFA blocks the login. Colonial Pipeline didn't have MFA on one VPN account. Cost: $4.4 million ransom.
- Credential monitoring -- check if your users' credentials appear in breach databases (Have I Been Pwned API). If
[email protected]shows up in a breach, force a password reset. - Account lockout + rate limiting -- prevents brute force and spraying (but set sensible thresholds -- lock for 15 min after 5 failures, don't permanently lock accounts or you create a DoS vector)
- Password managers -- generate unique 20+ character random passwords per site, eliminate reuse entirely. The user remembers one master password; the manager handles everything else.
The single most effective defense? MFA. It makes stolen passwords useless. I keep coming back to Colonial Pipeline because it's the perfect case study: an entire fuel distribution network shut down, a $4.4 million ransom paid, all because ONE legacy VPN account had a crackable password and no second factor. One account. One password. Tens of millions in damage.
And yet, in 2026, plenty of organizations still don't enforce MFA on their critical systems. Job security for pentesters, I suppose ;-)
Exercises
Exercise 1: On your Kali VM, extract the password hashes from Metasploitable2's /etc/shadow file (hint: you can read it because msfadmin has sudo access -- sudo cat /etc/shadow). Copy the hashes to your Kali VM. Use John the Ripper to crack them: john --wordlist=/usr/share/wordlists/rockyou.txt shadow_hashes.txt. How many did it crack? How long did it take? Which passwords were the weakest?
Exercise 2: Enhance the Python hash cracker to support multiple hash types (MD5, SHA-1, SHA-256) via a --type flag, and add rule-based mutations: for each wordlist entry, also try: capitalized first letter, appended "1", appended "123", appended "!", and leet speak substitutions (a->4, e->3, i->1, o->0). Measure: how many MORE passwords does the mutated version crack compared to the plain wordlist?
Exercise 3: Write a Python script that generates a bcrypt hash for a given password, then attempts to crack it using the same rockyou.txt wordlist. Measure the speed (hashes/second) and compare it to your MD5 cracker from this episode. Calculate: if MD5 cracks "sunshine" in 0.001 seconds, how long would the same wordlist take against bcrypt with cost factor 12? Show the math.