Skip to content

Latest commit

 

History

History
281 lines (210 loc) · 11.7 KB

File metadata and controls

281 lines (210 loc) · 11.7 KB

Hash Cracker

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.

Project Overview

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.

Objectives

  • 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.

Requirements

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

Architecture

  • CLIargparse for target hash, algorithm, wordlist, and optional salt.
  • Wordlist streamer — reads candidates line by line to keep memory flat on huge lists.
  • Hasherhashlib.new(algo) computes the digest of salt + candidate.
  • Comparatorhmac.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

Directory Structure

hash-cracker/
├── hashcrack.py        # CLI + dictionary attack
└── rockyou-mini.txt    # small demo wordlist (your own)

Implementation Plan

  1. Identify. Map digest length and character set to candidate algorithms, reporting all that match.
  2. Validate input. Confirm the digest is valid hexadecimal before attempting anything.
  3. Crack sequentially. Stream the wordlist, hash each candidate, and compare with hmac.compare_digest().
  4. Report throughput. Print candidates per second so the cost model is visible.
  5. Parallelise. Split the wordlist across a ProcessPoolExecutor — this is CPU-bound work, so processes, not threads.
  6. Stop early. Cancel outstanding work as soon as a match is found.
  7. Add salt support. Accept a salt and a format string, and show that the same wordlist now fails without it.
  8. Compare against a KDF. Benchmark hashlib.scrypt and report the throughput difference.

Complete Example Implementation

#!/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

Output

[*] Cracking sha256 hash with rockyou-mini.txt
[+] FOUND: 'password123'  (3 attempts, 41,905/s)

Usage

# 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

Example Output

$ 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

Error Handling

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

Security Considerations

[!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 --benchmark output is the argument for scrypt, 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.

Testing

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 None

Generate every test hash in the test itself — never commit a real hash.

Extension Ideas

  • Multiprocessing — split the wordlist across CPU cores with multiprocessing.Pool for 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/argon2 demo so learners see how a slow KDF resists the same attack.
  • Shadow/NTLM parsing — parse $id$salt$hash crypt formats (still only on hashes you own).

Troubleshooting

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

References

Related

  • [[Password-Generator]]
  • [[File-Integrity-Monitor]]
  • [[Hashlib-Module]]
  • [[Mini-Projects/Readme|Mini-Projects]] — module index
  • [[Readme|Python for Security Professionals]] — course home