Cybersecurity-Projects/PROJECTS/foundations/hash-identifier/hash_identifier.py

680 lines
29 KiB
Python

"""
©AngelaMos | 2026
hash_identifier.py
Identify what kind of hash a string is, by inspecting its shape
When someone hands you a string of gibberish like
`5f4dcc3b5aa765d61d8327deb882cf99` or `$2b$12$EixZaYVK1fsbw1ZfbX3OXe...`,
the first question is: what algorithm produced it? You can NOT crack
or analyze a hash without knowing what flavor of hash you are looking
at. Every cracking tool — hashcat, john the ripper — needs you to
specify an algorithm before it will start
This script does the looking-at part. Given a hash string, it returns
ranked candidates with a confidence score and a short reason
────────────────────────────────────────────────────────────────────
How identification actually works
────────────────────────────────────────────────────────────────────
There is no magic here. Hash strings carry shape clues
1. PREFIX. Many modern hashes are stored in "PHC string format"
a self-describing format that starts with a marker like `$2b$`
(bcrypt) or `$argon2id$` (Argon2id). When we see a known prefix
we know the algorithm with HIGH confidence
2. LENGTH. Raw hex output of a hash function is always the same
length: MD5 produces 16 bytes = 32 hex chars; SHA-1 produces 20
bytes = 40 hex chars; SHA-256 produces 32 bytes = 64 hex chars,
and so on. Length alone narrows the field
3. CHARSET. Different formats use different alphabets. Hex hashes
use only 0-9 and a-f. Base64 uses 0-9, A-Z, a-z, +, /, and =.
A string with `+` in it is not a hex hash
So our algorithm is: try prefix rules first, fall back to length +
charset rules, return the candidates ranked by confidence
────────────────────────────────────────────────────────────────────
What this script can and cannot do
────────────────────────────────────────────────────────────────────
CAN: suggest likely algorithms for a hash you found
CANNOT: tell you the password the hash was made from
(that is hashcat's job — see ../../beginner/hash-cracker)
────────────────────────────────────────────────────────────────────
What this file exposes
────────────────────────────────────────────────────────────────────
HashCandidate — one ranked guess (algorithm, confidence, reason)
identify(text) — return ranked candidates for a hash string
main() — CLI entry point used by `hashid <hash>`
"""
# Standard library: parse command-line flags like `--top 3` into a
# nice object so we do not have to slice `sys.argv` by hand.
import argparse
# Standard library: access to interpreter internals — we use it to
# write to stderr and to exit the process with a specific status code.
import sys
# Standard library: a decorator that turns a class into a small,
# immutable data record without writing `__init__` boilerplate.
from dataclasses import dataclass
# Standard library: a type hint that pins a value to a small fixed
# set of strings (here: "high" / "medium" / "low"). Mypy catches typos.
from typing import Literal
# Third-party (rich): the printer that actually draws the table to
# the terminal, with color and Unicode support.
from rich.console import Console
# Third-party (rich): builds the colored ASCII table we print for
# ranked hash candidates.
from rich.table import Table
# =============================================================================
# Confidence type — only three valid values
# =============================================================================
# Literal["high", "medium", "low"] is a type hint that says "this string
# can ONLY be one of these three values." Mypy will catch typos like
# "hgih" at edit time. We chose Literal over an Enum because I
# prefer Literals for small fixed sets
Confidence = Literal["high", "medium", "low"]
# =============================================================================
# Result type — what identify() returns for each guess
# =============================================================================
@dataclass(frozen = True, slots = True)
class HashCandidate:
"""
One possible identification of a hash string
`frozen=True` makes the dataclass immutable — once created, its
fields cannot change. `slots=True` makes instances lightweight in
memory. Together these two flags create a clean "value object"
Fields
------
algorithm
Human-readable algorithm name like "SHA-256" or "bcrypt"
confidence
How sure we are. "high" comes from definitive prefix matches,
"medium" from length matches that have only one obvious
candidate, "low" for length matches that could be many things
reason
Short explanation we display next to the algorithm name. Keeps
the output debuggable — the user can see WHY each guess fired
"""
algorithm: str
confidence: Confidence
reason: str
# =============================================================================
# Prefix rules — strongest signal we have
# =============================================================================
# Modern hashes use PHC-style strings: a leading `$` marker tells you
# exactly what algorithm produced the hash. When we see one of these
# prefixes we report HIGH confidence. The third element of each tuple
# is a short note we include in the reason field
#
# Order matters when prefixes overlap. We list more specific prefixes
# FIRST so they match before generic ones (e.g. `$argon2id$` before
# `$argon2$` would matter if both existed in the table)
PREFIX_RULES: list[tuple[str, str, str]] = [
# Argon2 family — won the 2015 Password Hashing Competition
("$argon2id$", "Argon2id", "modern PHC string, the current standard"),
("$argon2i$", "Argon2i", "PHC string, side-channel-resistant variant"),
("$argon2d$", "Argon2d", "PHC string, GPU-resistant variant"),
# bcrypt and its many variants — workhorse for the past 15 years
("$2y$", "bcrypt", "bcrypt PHC string, 2y variant (PHP)"),
("$2b$", "bcrypt", "bcrypt PHC string, 2b variant (current)"),
("$2a$", "bcrypt", "bcrypt PHC string, 2a variant (legacy)"),
("$2x$", "bcrypt", "bcrypt PHC string, 2x variant (legacy fix)"),
# Unix crypt(3) family — what /etc/shadow uses on Linux
("$6$", "SHA-512 crypt", "Unix crypt(3) using SHA-512 (default on Linux)"),
("$5$", "SHA-256 crypt", "Unix crypt(3) using SHA-256"),
("$1$", "MD5 crypt", "Unix crypt(3) using MD5 (legacy, weak)"),
# Apache htpasswd MD5 variant — same MD5 family as $1$ above but
# with Apache's own salt-handling tweak. This is the format that
# `htpasswd -m` emits by default, which makes it FAR more common
# in the wild than the Unix $1$ form (every basic-auth tutorial
# ends with one of these in a .htpasswd file)
("$apr1$", "Apache MD5-crypt", "Apache htpasswd MD5 variant (`htpasswd -m`)"),
# yescrypt — newer Linux default in some distributions
("$y$", "yescrypt", "PHC string, modern Linux crypt successor"),
# phpass — used by WordPress, phpBB, and other PHP apps
("$P$", "phpass", "WordPress / phpBB password hash"),
("$H$", "phpass", "phpBB-style phpass variant"),
# Drupal 7
("$S$", "Drupal 7 (SHA-512)", "Drupal 7 PHC-style hash"),
# scrypt as some implementations encode it
("$7$", "scrypt", "scrypt PHC-style hash"),
# Django's default — recognizable by the algorithm name in the prefix
("pbkdf2_sha256$", "Django PBKDF2-SHA256", "Django default password hash"),
("pbkdf2_sha1$", "Django PBKDF2-SHA1", "Django legacy password hash"),
("bcrypt_sha256$", "Django bcrypt-SHA256", "Django bcrypt wrapper"),
("argon2$", "Django Argon2", "Django Argon2 wrapper"),
# LDAP password schemes — base64 payload after the marker
("{SSHA}", "LDAP SSHA", "LDAP salted SHA-1 (base64 payload)"),
("{SHA}", "LDAP SHA", "LDAP SHA-1 (base64 payload)"),
("{SMD5}", "LDAP SMD5", "LDAP salted MD5 (base64 payload)"),
("{MD5}", "LDAP MD5", "LDAP MD5 (base64 payload)"),
("{CRYPT}", "LDAP CRYPT", "LDAP wrapping a crypt(3) hash"),
]
# =============================================================================
# Length-and-hex rules — fallback when no prefix matched
# =============================================================================
# Raw hash output is always the same length, so a string of N hex chars
# narrows down the algorithm. The list of algorithms for each length is
# sorted by REAL-WORLD prevalence. The first item gets MEDIUM confidence
# (the "most likely default"), the rest LOW (still possible)
# Hex chars are 0-9 plus a-f (or A-F if uppercase)
HEX_CHARSET: frozenset[str] = frozenset("0123456789abcdefABCDEF")
# Uppercase-only hex charset — some formats (MySQL5) ONLY ever emit
# uppercase hex because they print via the `%02X` C format specifier,
# so checking membership in this tighter charset lets us reject
# lowercase strings as false-positives instead of confidently saying
# "yes that is MySQL5" to an obviously hand-edited input
_HEX_UPPER_CHARSET: frozenset[str] = frozenset("0123456789ABCDEF")
# Length-in-hex-chars → list of algorithms, ordered by commonality
HEX_LENGTH_RULES: dict[int, list[str]] = {
# 16 hex chars = 8 bytes = 64 bits. This is the pre-MySQL-4.1
# OLD_PASSWORD() output — still produced today by MySQL's
# OLD_PASSWORD() SQL function for legacy compatibility, and
# still appears in CTFs and old MySQL breach dumps. The only
# other thing that produces 16 hex chars in a security context
# is a 64-bit CRC, which is rare enough that MySQL323 outranks it
16: ["MySQL323", "CRC-64"],
# 32 hex chars = 16 bytes = 128 bits
32: ["MD5", "NTLM", "MD4", "RIPEMD-128"],
# 40 hex chars = 20 bytes = 160 bits
40: ["SHA-1", "RIPEMD-160"],
# 48 hex chars = 24 bytes = 192 bits
48: ["Tiger-192"],
# 56 hex chars = 28 bytes = 224 bits
56: ["SHA-224", "SHA3-224"],
# 64 hex chars = 32 bytes = 256 bits
64: ["SHA-256", "SHA3-256", "BLAKE2s-256", "RIPEMD-256"],
# 80 hex chars = 40 bytes = 320 bits (uncommon)
80: ["RIPEMD-320"],
# 96 hex chars = 48 bytes = 384 bits
96: ["SHA-384", "SHA3-384"],
# 128 hex chars = 64 bytes = 512 bits
128: ["SHA-512", "SHA3-512", "BLAKE2b-512", "Whirlpool"],
}
# =============================================================================
# Helpers
# =============================================================================
def _is_hex(text: str) -> bool:
"""
Return True iff every character in text is a hex digit and text is non-empty
A hash like "5f4dcc..." passes; an empty string or anything with
a non-hex character fails. We use the HEX_CHARSET frozenset for
membership tests because `c in frozenset` is O(1) lookup, faster
than `c in "0123456789abcdef..."` for big inputs
"""
return bool(text) and all(c in HEX_CHARSET for c in text)
# MySQL5 layout: `*` followed by 40 uppercase hex chars.
# Pulling both numbers out as named constants keeps the helper below
# from reading like sprinkled-in magic numbers — `_MYSQL5_TOTAL_LENGTH`
# tells the reader WHY the helper compares to 41 (40 hex chars + the
# leading `*`) instead of leaving 41 unexplained
_MYSQL5_HEX_BODY_LENGTH = 40
_MYSQL5_TOTAL_LENGTH = _MYSQL5_HEX_BODY_LENGTH + 1
def _is_mysql5(text: str) -> bool:
"""
Return True for MySQL5 password format: `*` then 40 UPPERCASE hex chars
MySQL5 stores SHA-1(SHA-1(password)) printed in uppercase hex with
a leading `*`. Real MySQL5 output uses the `%02X` C format
specifier, which is uppercase-only. We reject lowercase here so
we do not return a confident HIGH-confidence "MySQL5" verdict on
a hand-edited or mistyped string — better to fall through to
no-match than to lie with conviction
NOTE: we cannot just call `body.isupper()` to enforce the case.
Python's `str.isupper` returns False when a string contains NO
cased characters at all, which would wrongly reject an all-digit
hex body like "0123456789ABCDEF..." with no letters in it.
Checking membership in an uppercase-only charset is the test
that actually matches the spec
"""
if len(text) != _MYSQL5_TOTAL_LENGTH or not text.startswith("*"):
return False
body = text[1 :]
return all(c in _HEX_UPPER_CHARSET for c in body)
# Traditional DES crypt — legacy /etc/passwd hashes from pre-shadow
# Unix systems. They have no prefix at all: just 13 characters drawn
# from a specific 64-char alphabet (the same alphabet used by all
# crypt(3) variants for their base64-style output). We pull both the
# charset and the expected length out as named constants so the helper
# below reads like its meaning instead of like sprinkled magic numbers
_DESCRYPT_CHARSET: frozenset[str] = frozenset(
"./0123456789"
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
"abcdefghijklmnopqrstuvwxyz"
)
_DESCRYPT_TOTAL_LENGTH = 13
def _is_descrypt(text: str) -> bool:
"""
Return True for traditional 13-char DES crypt (legacy /etc/passwd)
No prefix at all — just 13 characters drawn from `./0-9A-Za-z`.
Old enough that you mostly see it in retro CTFs now, but real
enough that hashcat still ships a mode (1500) for cracking it.
We report MEDIUM (not HIGH) confidence on a match because a 13-char
string in that charset CAN be other things (some session IDs,
encoded values), and a beginner deserves an honest confidence
level rather than a false-positive at HIGH
"""
return (
len(text) == _DESCRYPT_TOTAL_LENGTH
and all(c in _DESCRYPT_CHARSET for c in text)
)
# =============================================================================
# The actual identifier
# =============================================================================
# pylint flags this function for having many return statements and many
# branches. That is the cost of writing the function as six numbered,
# linear steps — each step gets its own short block and returns the
# moment it finds a match, which is the structure that maps cleanly
# onto the docstring. Refactoring into helper functions would obscure
# the pedagogical line-by-line flow, so we accept the extra returns
# here as a deliberate teaching choice and silence the two warnings
# pylint: disable=too-many-return-statements,too-many-branches
def identify(raw_input: str) -> list[HashCandidate]:
"""
Return ranked candidates for what algorithm produced `raw_input`
Algorithm
---------
Whitespace is trimmed from `raw_input` first. Then the six
matching steps below run in order — each step's labeled comment
(`# ----- Step N: ... -----`) inside the function body matches
the number in this list, so the docstring and the code stay in
lockstep:
1. Walk the PREFIX_RULES table. The first prefix that matches
wins (HIGH confidence). Each table row is unique enough that
at most one entry can match a given input
2. Check special non-PHC formats in order — NetNTLMv1/v2
challenge-response records, MySQL5 (`*<40 hex>`), and the
legacy 13-char DES crypt. Each has a distinctive shape that
takes precedence over generic length-based matching
3. If the input is pure hex, look up its length in
HEX_LENGTH_RULES and report each candidate. The first entry
at each length gets MEDIUM confidence (the modern default);
the rest LOW
4. If the input has a `$<algo>$...` shape but no PREFIX_RULES
row matched, fall back to a generic PHC string match — LOW
confidence because we only matched the shape, not a specific
rule
5. If the input looks like a JWT (`eyJ...`) or a base64 blob
(contains `+`, `/`, or `=`), say so with LOW confidence —
these are not hashes, but a beginner deserves to know what
they pasted instead of a silent no-match
6. If nothing matched at all, return an empty list
Parameters
----------
raw_input
The hash string to identify. Whitespace is trimmed before
analysis but the rest is treated literally — case matters
because some algorithms use uppercase output
Returns
-------
list[HashCandidate]
Possibly empty. When non-empty, candidates are ordered by
confidence (high before medium before low) and within each
confidence band by likelihood
"""
# Trim whitespace — copy-pasted hashes often arrive with trailing
# newlines or leading spaces. We do not modify case because some
# formats are case-sensitive
text = raw_input.strip()
if not text:
return []
# ----- Step 1: prefix rules -----
# Walk the table top-to-bottom. Entries in PREFIX_RULES are crafted
# so that no two of them can EVER both match the same input — the
# first prefix that matches is THE match. So we return it right
# away instead of continuing the loop. HIGH confidence is the
# right label because a known prefix is a definitive self-
# identification: the hash literally announces what algorithm
# produced it, we are not guessing
for prefix, algorithm, note in PREFIX_RULES:
if text.startswith(prefix):
return [
HashCandidate(
algorithm = algorithm,
confidence = "high",
reason = f"prefix `{prefix}` — {note}",
)
]
# ----- Step 2: special non-PHC formats -----
# Formats that do not fit the `$algo$...` PHC mold but still have
# unmistakable shapes. We check each in turn before falling through
# to the generic length-based step
# NetNTLMv1 / NetNTLMv2 — the dominant outputs of AD pentest
# tools like Responder, secretsdump, ntlmrelayx, and Inveigh.
# They are NOT hashes in the "irreversible function" sense —
# they are challenge-response records — but every beginner who
# runs their first AD lab pastes one of these into a hash
# identifier. The literal `::` (an empty second field, where the
# LM-hash used to live) is the structural giveaway. We test v2
# FIRST because v2's hmac field at index 4 is 32 hex chars while
# v1's nthash at the same index is 48 — so if we tested v1 first
# we would still match v2 inputs at parts[3] (also 48 in v2's
# case for the challenge length in some encodings)
if "::" in text and text.count(":") >= 4:
parts = text.split(":")
# NetNTLMv2 layout:
# user :: domain : challenge : hmac(32 hex) : blob(>=32 hex)
# The hmac at parts[4] is exactly 32 hex chars — that single
# property is enough to disambiguate v2 from v1
if (len(parts) >= 6 and len(parts[4]) == 32 and _is_hex(parts[4])):
return [
HashCandidate(
algorithm = "NetNTLMv2",
confidence = "high",
reason =
"user::domain:challenge:hmac(32 hex):blob shape",
)
]
# NetNTLMv1 layout:
# user :: domain : lmhash(48 hex) : nthash(48 hex) : challenge
# The lmhash at parts[3] is exactly 48 hex chars
if (len(parts) >= 6 and len(parts[3]) == 48 and _is_hex(parts[3])):
return [
HashCandidate(
algorithm = "NetNTLMv1",
confidence = "high",
reason =
"user::domain:lm(48 hex):nt(48 hex):challenge shape",
)
]
# MySQL5 — literal `*` + 40 uppercase hex chars
if _is_mysql5(text):
return [
HashCandidate(
algorithm = "MySQL5",
confidence = "high",
reason =
"starts with `*` followed by 40 uppercase hex chars",
)
]
# Traditional 13-char DES crypt — legacy /etc/passwd format
# with no prefix at all. We report MEDIUM (not HIGH) because the
# 13-char `./0-9A-Za-z` shape isn't fully unique to DES crypt
if _is_descrypt(text):
return [
HashCandidate(
algorithm = "DES crypt",
confidence = "medium",
reason =
"13 chars in `./0-9A-Za-z` — legacy /etc/passwd format",
)
]
# ----- Step 3: length + hex charset -----
if _is_hex(text):
algorithms = HEX_LENGTH_RULES.get(len(text), [])
candidates: list[HashCandidate] = []
for index, algorithm in enumerate(algorithms):
# The first listed algorithm for each length is the modern
# default and gets MEDIUM confidence. The rest are still
# possible but less common in 2026 — LOW confidence
confidence: Confidence = "medium" if index == 0 else "low"
label = (
"most likely candidate at this length"
if index == 0 else "also possible at this length"
)
candidates.append(
HashCandidate(
algorithm = algorithm,
confidence = confidence,
reason = f"{len(text)} hex chars — {label}",
)
)
return candidates
# ----- Step 4: generic PHC string fallback -----
# If the input starts with `$<name>$...` and <name> looks like a
# plausible algorithm identifier, it is almost certainly a PHC
# string from an algorithm we do not have a specific rule for.
# Passlib alone ships ~30 PHC encodings; our PREFIX_RULES table
# covers only the most common 20-or-so. Reporting it as a generic
# PHC string with the extracted algorithm name is strictly better
# than silence, and the LOW confidence label is honest about not
# having a specific rule to confirm the algorithm
if text.startswith("$"):
# Drop the leading `$`, then look for the second `$` that
# closes the algorithm-name field. If there's no second `$`,
# this isn't a PHC string at all, just a string that happens
# to start with `$` — fall through
rest = text[1 :]
if "$" in rest:
algo_name = rest.split("$", 1)[0]
# The PHC spec restricts algorithm IDs to alphanumeric
# plus `-` and `_`. We accept exactly that charset and
# reject anything weirder — anything containing spaces,
# punctuation, etc. is almost certainly not a real PHC
# string and we'd rather stay silent than make up a name
if algo_name and all(c.isalnum() or c in "-_"
for c in algo_name):
return [
HashCandidate(
algorithm = f"PHC string ({algo_name})",
confidence = "low",
reason =
f"`${algo_name}$...` shape — generic PHC, no specific rule",
)
]
# ----- Step 5: not-a-hash shape hints -----
# Beginners often paste JWTs or base64 blobs into a hash
# identifier — they are auth tokens, not hashes, but the user
# may not know that yet. Returning a short shape-hint at LOW
# confidence is more educational than a silent no-match: the
# user finds out WHAT they pasted and that this tool is not
# going to crack it for them
if text.startswith("eyJ"):
# JWTs always begin with `eyJ` because their JSON header
# `{"alg":...}` base64-encodes to a string starting with
# those three characters (`{"` → base64 → `eyI`/`eyJ`)
return [
HashCandidate(
algorithm = "JWT (not a hash)",
confidence = "low",
reason =
"leading `eyJ` is base64 of `{\"` — JWT, not a hash",
)
]
if any(c in text for c in "+/=") and len(text) > 8:
# Hex hashes NEVER contain `+`, `/`, or `=`. If our input
# does, it is almost certainly base64-encoded data of some
# kind. The `> 8` length floor avoids flagging short
# strings like "a+b=c" as base64
return [
HashCandidate(
algorithm = "Base64 blob (not a hash)",
confidence = "low",
reason = "contains base64-only chars (`+`, `/`, `=`)",
)
]
# ----- Step 6: nothing matched -----
# If we got here, the input has no known prefix, no special
# shape, no hex length we recognize, no PHC-string shape, and
# no obvious not-a-hash shape either. Returning an empty list
# is better than returning bad guesses — the CLI prints a
# clean "could not identify" message instead
return []
# =============================================================================
# CLI — argparse + a rich table
# =============================================================================
def _build_argument_parser() -> argparse.ArgumentParser:
"""
Construct the argparse parser used by main()
Pulled out into its own function so tests can call it without
actually running the CLI. Each argument is documented inline
"""
parser = argparse.ArgumentParser(
prog = "hashid",
description = (
"Identify a hash string by prefix, length, and charset. "
"Returns ranked candidates with confidence and reasoning."
),
)
parser.add_argument(
"hash",
help =
"The hash string to identify (wrap in single quotes if it contains $).",
)
parser.add_argument(
"--top",
"-n",
type = int,
default = 5,
help = "Show at most this many candidates (default: 5).",
)
return parser
def _render_table(
raw_input: str,
candidates: list[HashCandidate],
console: Console,
) -> None:
"""
Print a rich Table showing the identified candidates
A Table is a bordered grid. We give it three columns: algorithm,
confidence (color-coded), and reason
"""
table = Table(
title = f"Candidates for: {raw_input.strip()}",
title_style = "bold cyan",
show_lines = False,
)
table.add_column("algorithm", style = "bold white", no_wrap = True)
table.add_column("confidence", no_wrap = True)
table.add_column("reason", style = "dim")
# Color confidence levels so the eye can scan them quickly.
# green → yellow → cyan is a gradient that reads "strong, weaker,
# weakest" without ever colliding with red, which is reserved for
# the no-match error message printed elsewhere in main(). Painting
# "low" in red would make three weak-but-valid guesses look like
# three errors at a glance — broken visual hierarchy
confidence_colors: dict[Confidence,
str] = {
"high": "green",
"medium": "yellow",
"low": "cyan",
}
for candidate in candidates:
color = confidence_colors[candidate.confidence]
table.add_row(
candidate.algorithm,
f"[{color}]{candidate.confidence}[/{color}]",
candidate.reason,
)
console.print(table)
def main() -> int:
"""
CLI entry point — return an exit code (0 = ok, 1 = nothing found)
Wrapping the body in a function (instead of running at module
import time) means the test suite can import this module without
accidentally executing the CLI
"""
parser = _build_argument_parser()
args = parser.parse_args()
console = Console()
candidates = identify(args.hash)
if not candidates:
# `[red]...[/red]` is rich's inline color markup
console.print(
"[red]No identification possible.[/red] "
"Input did not match any known prefix, special format, "
"or hex length."
)
return 1
# Trim to the requested top-N
trimmed = candidates[: args.top]
_render_table(args.hash, trimmed, console)
# Helpful nudge — point the user at the cracker once they know
# what algorithm to target. Foundations tier is meant to chain
if trimmed[0].confidence == "high":
console.print(
"\n[dim]Next step: try the matching cracker mode "
"(see ../../beginner/hash-cracker).[/dim]"
)
return 0
# Standard "if invoked directly as a script" guard — lets the file be
# imported by tests without firing main()
if __name__ == "__main__":
sys.exit(main())