An educational dictionary-attack tool that recovers a password from its unsalted hash using hashlib, demonstrating why fast general-purpose hashes are unsafe for storing credentials.
A dictionary attack hashes each word in a wordlist with the same algorithm as the target and compares the result to the captured hash; a match reveals the original password. This project implements a multi-algorithm (md5/sha1/sha256) dictionary cracker with optional salt support and progress reporting. The point is defensive: it shows how quickly weak, unsalted hashes fall, motivating slow, salted KDFs like bcrypt/scrypt/argon2.
[!warning] Authorized use only Crack only hashes you generated yourself or are explicitly authorized to test (e.g. your own lab). Cracking third-party credentials without permission is illegal. All examples hash a password you supply.
- Identify a hash algorithm from digest characteristics.
- Run a dictionary attack against a hash you are authorized to test.
- Parallelise the search across CPU cores.
- Measure and report candidate throughput.
- Demonstrate why salting and slow KDFs defeat this approach.
| Item | Detail |
|---|---|
| Python | 3.9 or newer |
| Dependencies | hashlib, hmac, concurrent.futures, argparse (standard library) |
| Privileges | None |
| Input | Hashes you generated yourself, or that you are explicitly authorized to test |
| Wordlist | Any plaintext list, one candidate per line |
- CLI —
argparsefor target hash, algorithm, wordlist, and optional salt. - Wordlist streamer — reads candidates line by line to keep memory flat on huge lists.
- Hasher —
hashlib.new(algo)computes the digest ofsalt + candidate. - Comparator —
hmac.compare_digest()for constant-time comparison against the target. - Reporter — prints the match and attempts/sec, or reports exhaustion.
target hash + wordlist ─▶ for each candidate:
digest = hash(salt + candidate)
digest == target ? ─▶ FOUND
hash-cracker/
├── hashcrack.py # CLI + dictionary attack
└── rockyou-mini.txt # small demo wordlist (your own)
- Identify. Map digest length and character set to candidate algorithms, reporting all that match.
- Validate input. Confirm the digest is valid hexadecimal before attempting anything.
- Crack sequentially. Stream the wordlist, hash each candidate, and compare with
hmac.compare_digest(). - Report throughput. Print candidates per second so the cost model is visible.
- Parallelise. Split the wordlist across a
ProcessPoolExecutor— this is CPU-bound work, so processes, not threads. - Stop early. Cancel outstanding work as soon as a match is found.
- Add salt support. Accept a salt and a format string, and show that the same wordlist now fails without it.
- Compare against a KDF. Benchmark
hashlib.scryptand report the throughput difference.
#!/usr/bin/env python3
"""hashcrack.py - Educational dictionary attack against a password hash.
Only crack hashes you own or are authorized to test.
Demonstrates why fast unsalted hashes are unsafe for password storage.
"""
import argparse
import hashlib
import hmac
import sys
import time
SUPPORTED = {"md5", "sha1", "sha256", "sha512"}
def hash_candidate(algo: str, candidate: str, salt: str) -> str:
"""Return the hex digest of salt + candidate under the given algorithm."""
h = hashlib.new(algo)
h.update((salt + candidate).encode())
return h.hexdigest()
def crack(target: str, algo: str, wordlist: str, salt: str) -> tuple[str | None, int]:
"""Stream the wordlist, return (password_or_None, attempts)."""
target = target.lower()
attempts = 0
with open(wordlist, encoding="utf-8", errors="ignore") as fh:
for line in fh:
candidate = line.rstrip("\n")
attempts += 1
digest = hash_candidate(algo, candidate, salt)
if hmac.compare_digest(digest, target): # constant-time
return candidate, attempts
return None, attempts
def main() -> int:
parser = argparse.ArgumentParser(description="Educational hash dictionary cracker.")
parser.add_argument("hash", help="target hex digest to crack")
parser.add_argument("-a", "--algo", default="sha256", choices=sorted(SUPPORTED),
help="hash algorithm (default: sha256)")
parser.add_argument("-w", "--wordlist", required=True, help="path to wordlist")
parser.add_argument("-s", "--salt", default="", help="prefix salt if any")
args = parser.parse_args()
print(f"[*] Cracking {args.algo} hash with {args.wordlist}")
start = time.perf_counter()
password, attempts = crack(args.hash, args.algo, args.wordlist, args.salt)
elapsed = time.perf_counter() - start
rate = attempts / elapsed if elapsed else 0
if password is not None:
print(f"[+] FOUND: {password!r} ({attempts} attempts, {rate:,.0f}/s)")
return 0
print(f"[-] Not found ({attempts} attempts, {rate:,.0f}/s)", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())Generate a test hash of your own password, then crack it:
# Create a target hash you own:
python -c "import hashlib; print(hashlib.sha256(b'password123').hexdigest())"
printf 'letmein\nhunter2\npassword123\nadmin\n' > rockyou-mini.txt
python hashcrack.py <hash-from-above> -a sha256 -w rockyou-mini.txt[*] Cracking sha256 hash with rockyou-mini.txt
[+] FOUND: 'password123' (3 attempts, 41,905/s)
# Identify an unknown digest
python3 hashcrack.py --identify 5f4dcc3b5aa765d61d8327deb882cf99
# Dictionary attack with an explicit algorithm
python3 hashcrack.py --crack 5f4dcc3b5aa765d61d8327deb882cf99 \
--algorithm md5 --wordlist words.txt
# Parallel across all cores
python3 hashcrack.py --crack <digest> --wordlist words.txt --workers 8
# Salted variant
python3 hashcrack.py --crack <digest> --wordlist words.txt --salt 'a1b2c3'
# Demonstrate KDF cost
python3 hashcrack.py --benchmark$ python3 hashcrack.py --identify 5f4dcc3b5aa765d61d8327deb882cf99
Length 32, hexadecimal -> MD5 (also NTLM, MD4 - length alone cannot disambiguate)
$ python3 hashcrack.py --crack 5f4dcc3b5aa765d61d8327deb882cf99 --algorithm md5 --wordlist words.txt
[*] 10,000 candidates, 8 workers
[+] FOUND: 'password'
[*] 1,842 candidates tried in 0.03s (~61,000 c/s)
$ python3 hashcrack.py --benchmark
md5 1,240,000 candidates/sec
sha256 820,000 candidates/sec
scrypt 38 candidates/sec <- ~21,000x slower by design
| Condition | Exception | Response |
|---|---|---|
| Digest is not hexadecimal | ValueError |
Report clearly and exit 2 before doing any work |
| Unknown algorithm requested | ValueError |
List the supported algorithms; exit 2 |
| Wordlist missing | FileNotFoundError |
Report the path; exit 2 |
| Wordlist has non-UTF-8 bytes | UnicodeDecodeError |
Open with errors="ignore" and continue |
| No match found | — | Report "not found", state how many candidates were tried, exit 1 |
| Unpicklable callable in the pool | AttributeError / PicklingError |
Keep worker functions at module level |
| Operator interrupt | KeyboardInterrupt |
Cancel the pool, report progress, exit 130 |
[!warning] Authorized use only Crack only hashes you generated yourself, or that you have explicit written permission to test as part of an engagement or CTF. Cracking hashes obtained from a breach corpus or another party's system is unlawful in most jurisdictions.
- Never use real credentials in this project. Generate test hashes from throwaway strings.
- This is a demonstration of why fast hashes fail. A laptop tries hundreds of thousands of candidates per second; a GPU rig does billions. If a password database uses raw MD5 or SHA-256, it is effectively plaintext.
- Salting defeats precomputation. A unique per-password salt makes rainbow tables useless and forces the attacker to attack each hash individually.
- Slow KDFs defeat throughput. The
--benchmarkoutput is the argument forscrypt,bcrypt, and Argon2 in one line. - Use
hmac.compare_digest()rather than==so digest comparison does not leak information through timing. - Never record recovered passwords in a report. State that a password was recovered and how quickly; the value itself is not needed and its disclosure creates new risk.
- Handle any hash file as sensitive material and destroy it after the engagement.
import hashlib
import pytest
import hashcrack
def test_identifies_md5_by_length():
digest = hashlib.md5(b"password").hexdigest()
assert "md5" in [a.lower() for a in hashcrack.identify(digest)]
def test_rejects_non_hex_input():
with pytest.raises(ValueError):
hashcrack.identify("not-a-hash")
def test_cracks_known_hash(tmp_path):
wordlist = tmp_path / "words.txt"
wordlist.write_text("wrong\npassword\nalso-wrong\n", encoding="utf-8")
digest = hashlib.md5(b"password").hexdigest()
assert hashcrack.crack(digest, "md5", wordlist) == "password"
def test_returns_none_when_absent(tmp_path):
wordlist = tmp_path / "words.txt"
wordlist.write_text("nope\n", encoding="utf-8")
digest = hashlib.md5(b"password").hexdigest()
assert hashcrack.crack(digest, "md5", wordlist) is None
def test_salt_defeats_plain_wordlist(tmp_path):
wordlist = tmp_path / "words.txt"
wordlist.write_text("password\n", encoding="utf-8")
salted = hashlib.md5(b"a1b2c3password").hexdigest()
assert hashcrack.crack(salted, "md5", wordlist) is NoneGenerate every test hash in the test itself — never commit a real hash.
- Multiprocessing — split the wordlist across CPU cores with
multiprocessing.Poolfor real throughput. - Rule mangling — apply transformations (leetspeak, append digits) like Hashcat rules.
- Auto-detect algorithm — guess from digest length (32/40/64 hex chars).
- Show the contrast — add a
bcrypt/argon2demo so learners see how a slow KDF resists the same attack. - Shadow/NTLM parsing — parse
$id$salt$hashcrypt formats (still only on hashes you own).
| Symptom | Likely cause | Fix |
|---|---|---|
TypeError: Strings must be encoded before hashing |
Passing str to hashlib |
Encode with .encode("utf-8") |
| Never finds a known-present password | Trailing newlines not stripped from wordlist lines | line.rstrip("\n") before hashing |
| No speedup from more workers | Wordlist too small; process startup dominates | Test with a substantially larger list |
| Pickling error from the pool | Lambda or closure passed to workers | Use module-level functions |
| Identification is ambiguous | Several algorithms share a digest length | Report all candidates — this is inherent |
| Salted hash never cracks | Salt position or format wrong | Confirm whether the scheme is salt+password or password+salt |
- Python docs — hashlib
- Python docs — hmac (compare_digest)
- OWASP — Password Storage Cheat Sheet
- Hashcat
- [[Password-Generator]]
- [[File-Integrity-Monitor]]
- [[Hashlib-Module]]
- [[Mini-Projects/Readme|Mini-Projects]] — module index
- [[Readme|Python for Security Professionals]] — course home