diff --git a/hate_crack.py b/hate_crack.py index 178655d..7a56b99 100755 --- a/hate_crack.py +++ b/hate_crack.py @@ -30,6 +30,7 @@ def _sync_globals_to_main(): "hcatHashFileOrig", "pipalPath", "debug_mode", + "hcatUsernamePrefix", ): if name in globals(): setattr(_main, name, globals()[name]) diff --git a/hate_crack/main.py b/hate_crack/main.py index 9367419..1053d21 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -76,6 +76,7 @@ from hate_crack.cli import ( # noqa: E402 ) from hate_crack import attacks as _attacks # noqa: E402 from hate_crack.menu import interactive_menu # noqa: E402 +from hate_crack.username_detect import detect_username_hash_format # noqa: E402 # Import HashcatRosetta for rule analysis functionality try: @@ -354,6 +355,13 @@ else: hcatPotfilePath = os.path.join(hate_path, hcatPotfilePath) +def _maybe_append_username_flag(cmd): + """Append --username if the active hash file has user:hash format and + the flag isn't already present (from hcatTuning or elsewhere).""" + if hcatUsernamePrefix and "--username" not in cmd: + cmd.append("--username") + + def _append_potfile_arg(cmd, *, use_potfile_path=True, potfile_path=None): if use_potfile_path: pot = potfile_path or hcatPotfilePath @@ -367,6 +375,7 @@ def _append_potfile_arg(cmd, *, use_potfile_path=True, potfile_path=None): except OSError: pass cmd.append(f"--potfile-path={pot}") + _maybe_append_username_flag(cmd) _debug_cmd(cmd) @@ -729,6 +738,7 @@ hcatGenerateRulesCount = 0 hcatPermuteCount = 0 hcatProcess: subprocess.Popen[Any] | None = None debug_mode = False +hcatUsernamePrefix: bool = False def _open_wordlist(path): @@ -1250,16 +1260,21 @@ def _dedup_netntlm_by_username( def _run_hashcat_show(hash_type, hash_file, output_path): + cmd = [ + hcatBin, + "--show", + # Use hashcat's built-in potfile unless configured otherwise. + *([f"--potfile-path={hcatPotfilePath}"] if hcatPotfilePath else []), + "-m", + str(hash_type), + hash_file, + ] + # If username:hash format was detected, --show also needs --username + # to parse the input correctly; otherwise it treats "user:hash" as a + # literal hash and finds no matches in the potfile. + _maybe_append_username_flag(cmd) result = subprocess.run( - [ - hcatBin, - "--show", - # Use hashcat's built-in potfile unless configured otherwise. - *([f"--potfile-path={hcatPotfilePath}"] if hcatPotfilePath else []), - "-m", - str(hash_type), - hash_file, - ], + cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, check=False, @@ -4940,6 +4955,15 @@ def main(): _cleanup_preprocessing_temps() sys.exit(1) + # Detect username:hash format to inject --username into hashcat commands. + # Skip modes already handled by the NTLM (1000) and NetNTLM (5500/5600) + # preprocessing blocks above. + global hcatUsernamePrefix + if hcatHashType not in ("1000", "5500", "5600"): + hcatUsernamePrefix = detect_username_hash_format(hcatHashFile, hcatHashType) + if hcatUsernamePrefix: + print("[*] Username prefixes detected \u2014 adding --username flag") + # Check POT File for Already Cracked Hashes if not os.path.isfile(hcatHashFile + ".out"): hcatOutput = open(hcatHashFile + ".out", "w+") diff --git a/hate_crack/username_detect.py b/hate_crack/username_detect.py new file mode 100644 index 0000000..f334e5f --- /dev/null +++ b/hate_crack/username_detect.py @@ -0,0 +1,88 @@ +"""Detect ``username:hash`` format in hashcat input files. + +This module owns the allowlist/blocklist of hash modes and the regex-based +per-line validation used to decide whether to pass ``--username`` to hashcat. +""" +from __future__ import annotations + +import re +from typing import Final + +# Modes where bare hashes are the normal input AND files commonly carry a +# ``username:`` prefix. Value is the expected hex length of the hash field. +USERNAME_HASH_MODES: Final[dict[str, int]] = { + "0": 32, # MD5 + "10": 32, # md5($pass.$salt) + "20": 32, # md5($salt.$pass) + "30": 32, # md5(unicode($pass).$salt) + "40": 32, # md5($salt.unicode($pass)) + "50": 32, # HMAC-MD5 + "60": 32, # HMAC-MD5(key=$pass) + "100": 40, # SHA1 + "101": 40, # nsldap/SHA1(Base64) + "110": 40, # sha1($pass.$salt) + "120": 40, # sha1($salt.$pass) + "130": 40, # sha1(unicode($pass).$salt) + "140": 40, # sha1($salt.unicode($pass)) + "150": 40, # HMAC-SHA1 + "160": 40, # HMAC-SHA1(key=$pass) + "900": 32, # MD4 + "1000": 32, # NTLM bare + "1400": 64, # SHA2-256 + "1410": 64, "1420": 64, "1430": 64, "1440": 64, "1450": 64, "1460": 64, + "1700": 128, # SHA2-512 + "1710": 128, "1720": 128, "1730": 128, "1740": 128, "1750": 128, "1760": 128, + "3000": 16, # LM (single half) +} + +# Modes explicitly excluded even if they appear in allowlist (they don't, but +# this constant is the documentation of the intentional blocklist). Binary +# formats, IKE-PSK, and NetNTLM (already preprocessed elsewhere). +USERNAME_DETECT_BLOCKLIST: Final[frozenset[str]] = frozenset({ + "2500", "22000", "2501", "16800", "16801", "22001", # WPA variants (binary) + "5300", "5400", # IKE-PSK + "5500", "5600", # NetNTLM (own preprocess) + "1800", "3200", # non-hex hash formats +}) + + +def detect_username_hash_format( + hash_file: str, + hash_type: str, + *, + sample_size: int = 10, +) -> bool: + """Return True if every sampled non-empty line of ``hash_file`` looks like + ``username:`` with the expected hex length for ``hash_type``. + + Returns False if: + - ``hash_type`` is in the blocklist + - ``hash_type`` is not in the allowlist + - the file is missing/unreadable/empty + - any sampled non-empty non-comment line does not match the pattern + """ + if hash_type in USERNAME_DETECT_BLOCKLIST: + return False + hex_len = USERNAME_HASH_MODES.get(hash_type) + if hex_len is None: + return False + + pattern = re.compile(rf"^[^:]+:[0-9a-fA-F]{{{hex_len}}}$") + + try: + with open(hash_file, "r", encoding="utf-8-sig") as fh: + samples: list[str] = [] + for raw in fh: + line = raw.strip().replace("\x00", "") + if not line or line.startswith("#"): + continue + samples.append(line) + if len(samples) >= sample_size: + break + except OSError: + return False + + if not samples: + return False + + return all(pattern.match(line) for line in samples) diff --git a/tests/test_username_detect.py b/tests/test_username_detect.py new file mode 100644 index 0000000..d7df7e3 --- /dev/null +++ b/tests/test_username_detect.py @@ -0,0 +1,367 @@ +"""Tests for the ``username:hash`` format detection and ``--username`` injection. + +Covers both the pure-logic ``detect_username_hash_format`` function and the +command-building integration point in ``_append_potfile_arg`` / +``_maybe_append_username_flag``. +""" + +from __future__ import annotations + +import sys +import importlib +from unittest.mock import MagicMock, patch + +import pytest + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def main_module(monkeypatch): + """Load hate_crack.main with SKIP_INIT to access helpers directly.""" + monkeypatch.setenv("HATE_CRACK_SKIP_INIT", "1") + if "hate_crack.main" in sys.modules: + mod = sys.modules["hate_crack.main"] + importlib.reload(mod) + return mod + import hate_crack.main as mod + + return mod + + +@pytest.fixture +def detect(): + """Return the pure-logic detection function.""" + from hate_crack.username_detect import detect_username_hash_format + + return detect_username_hash_format + + +def _make_mock_proc(): + proc = MagicMock() + proc.wait.return_value = None + proc.pid = 12345 + return proc + + +# --------------------------------------------------------------------------- +# Unit tests: detect_username_hash_format +# --------------------------------------------------------------------------- + + +class TestDetectPositiveCases: + """Per-mode positive cases: sample matches ``user:`` with correct length.""" + + def test_md5_user_hash(self, tmp_path, detect): + f = tmp_path / "md5.txt" + f.write_text("alice:5f4dcc3b5aa765d61d8327deb882cf99\n") + assert detect(str(f), "0") is True + + def test_sha1_user_hash(self, tmp_path, detect): + f = tmp_path / "sha1.txt" + f.write_text("alice:5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8\n") + assert detect(str(f), "100") is True + + def test_sha256_user_hash(self, tmp_path, detect): + f = tmp_path / "sha256.txt" + f.write_text( + "bob:5e884898da28047151d0e56f8dc6292773603d0d" + "6aabbdd62a11ef721d1542d8\n" + ) + assert detect(str(f), "1400") is True + + def test_sha512_user_hash(self, tmp_path, detect): + f = tmp_path / "sha512.txt" + f.write_text( + "carol:b109f3bbbc244eb82441917ed06d618b9008dd09b" + "3befd1b5e07394c706a8bb980b1d7785e5976ec049b46df" + "5f1326af5a2ea6d103fd07c95385ffab0cacbc86\n" + ) + assert detect(str(f), "1700") is True + + def test_ntlm_user_hash(self, tmp_path, detect): + f = tmp_path / "ntlm.txt" + # Mode 1000 with user:hash shape is a legitimate hashcat input. + f.write_text("alice:aad3b435b51404eeaad3b435b51404ee\n") + assert detect(str(f), "1000") is True + + def test_lm_user_hash(self, tmp_path, detect): + f = tmp_path / "lm.txt" + f.write_text("alice:aad3b435b51404ee\n") + assert detect(str(f), "3000") is True + + def test_multiple_lines_all_match(self, tmp_path, detect): + f = tmp_path / "md5.txt" + f.write_text( + "alice:5f4dcc3b5aa765d61d8327deb882cf99\n" + "bob:e10adc3949ba59abbe56e057f20f883e\n" + "carol:25f9e794323b453885f5181f1b624d0b\n" + ) + assert detect(str(f), "0") is True + + +class TestDetectNegativeCases: + """Detection must return False on non-matching content.""" + + def test_bare_hashes_no_colon(self, tmp_path, detect): + f = tmp_path / "bare.txt" + f.write_text("5f4dcc3b5aa765d61d8327deb882cf99\n") + assert detect(str(f), "0") is False + + def test_wrong_hex_length(self, tmp_path, detect): + """SHA1-length hex with MD5 hash type must not match.""" + f = tmp_path / "wrong.txt" + f.write_text("alice:5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8\n") + assert detect(str(f), "0") is False + + def test_non_hex_field2(self, tmp_path, detect): + f = tmp_path / "nonhex.txt" + f.write_text("alice:ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ\n") + assert detect(str(f), "0") is False + + def test_mixed_valid_and_invalid(self, tmp_path, detect): + """All sampled lines must match; one bad line fails detection.""" + f = tmp_path / "mixed.txt" + f.write_text( + "alice:5f4dcc3b5aa765d61d8327deb882cf99\n" + "bob:not_a_hash_at_all\n" + ) + assert detect(str(f), "0") is False + + def test_pwdump_format_mode_1000(self, tmp_path, detect): + """pwdump (user:RID:LM:NT:::) has a numeric field 2, not hex.""" + f = tmp_path / "pwdump.txt" + f.write_text( + "user1:1001:aad3b435b51404eeaad3b435b51404ee" + ":31d6cfe0d16ae931b73c59d7e0c089c0:::\n" + ) + assert detect(str(f), "1000") is False + + def test_trailing_garbage_after_hash(self, tmp_path, detect): + """Anchored regex must reject lines with extra trailing fields.""" + f = tmp_path / "trailing.txt" + f.write_text("alice:5f4dcc3b5aa765d61d8327deb882cf99:extra\n") + assert detect(str(f), "0") is False + + +class TestDetectBlocklist: + """Blocklisted modes must return False regardless of content.""" + + @pytest.mark.parametrize( + "mode", ["2500", "22000", "5300", "5400", "5500", "5600", "1800", "3200"] + ) + def test_blocklist_modes(self, tmp_path, detect, mode): + f = tmp_path / "any.txt" + # Content that *looks* like a user:md5-hash, still blocked. + f.write_text("alice:5f4dcc3b5aa765d61d8327deb882cf99\n") + assert detect(str(f), mode) is False + + +class TestDetectUnknownMode: + def test_unknown_mode_returns_false(self, tmp_path, detect): + f = tmp_path / "x.txt" + f.write_text("alice:5f4dcc3b5aa765d61d8327deb882cf99\n") + assert detect(str(f), "99999") is False + + +class TestDetectFileHandling: + def test_missing_file(self, tmp_path, detect): + assert detect(str(tmp_path / "does_not_exist.txt"), "0") is False + + def test_empty_file(self, tmp_path, detect): + f = tmp_path / "empty.txt" + f.write_text("") + assert detect(str(f), "0") is False + + def test_only_blank_lines(self, tmp_path, detect): + f = tmp_path / "blank.txt" + f.write_text("\n\n\n") + assert detect(str(f), "0") is False + + def test_only_comments(self, tmp_path, detect): + f = tmp_path / "comments.txt" + f.write_text("# just a comment\n# another\n") + assert detect(str(f), "0") is False + + def test_blank_leading_lines_skipped(self, tmp_path, detect): + f = tmp_path / "b.txt" + f.write_text("\n\nalice:5f4dcc3b5aa765d61d8327deb882cf99\n") + assert detect(str(f), "0") is True + + def test_comment_lines_skipped(self, tmp_path, detect): + f = tmp_path / "c.txt" + f.write_text( + "# sample file\nalice:5f4dcc3b5aa765d61d8327deb882cf99\n" + "bob:e10adc3949ba59abbe56e057f20f883e\n" + ) + assert detect(str(f), "0") is True + + def test_bom_handled(self, tmp_path, detect): + f = tmp_path / "bom.txt" + f.write_bytes( + b"\xef\xbb\xbfalice:5f4dcc3b5aa765d61d8327deb882cf99\n" + ) + assert detect(str(f), "0") is True + + def test_crlf_handled(self, tmp_path, detect): + f = tmp_path / "crlf.txt" + f.write_bytes(b"alice:5f4dcc3b5aa765d61d8327deb882cf99\r\n") + assert detect(str(f), "0") is True + + def test_null_bytes_stripped(self, tmp_path, detect): + f = tmp_path / "null.txt" + f.write_bytes(b"alice\x00:5f4dcc3b5aa765d61d8327deb882cf99\n") + assert detect(str(f), "0") is True + + def test_unicode_username(self, tmp_path, detect): + f = tmp_path / "u.txt" + f.write_text( + "alicé:5f4dcc3b5aa765d61d8327deb882cf99\n", encoding="utf-8" + ) + assert detect(str(f), "0") is True + + +class TestDetectSampleSize: + def test_sample_size_honored(self, tmp_path, detect): + """With sample_size=3, a later bad line is not read and detection passes.""" + lines = ["alice:5f4dcc3b5aa765d61d8327deb882cf99\n"] * 3 + lines += ["bob:not_a_valid_hash\n"] * 47 + f = tmp_path / "big.txt" + f.write_text("".join(lines)) + assert detect(str(f), "0", sample_size=3) is True + # With sample_size large enough to read the bad lines, it fails. + assert detect(str(f), "0", sample_size=10) is False + + +# --------------------------------------------------------------------------- +# Integration tests: --username injection via _append_potfile_arg +# --------------------------------------------------------------------------- + + +class TestAppendUsernameFlag: + """_append_potfile_arg should append --username when hcatUsernamePrefix.""" + + def test_append_when_flag_true(self, main_module): + cmd = ["hashcat", "-m", "0", "hashes.txt"] + with patch.object(main_module, "hcatUsernamePrefix", True), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "_debug_cmd"): + main_module._append_potfile_arg(cmd) + assert "--username" in cmd + + def test_no_append_when_flag_false(self, main_module): + cmd = ["hashcat", "-m", "0", "hashes.txt"] + with patch.object(main_module, "hcatUsernamePrefix", False), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "_debug_cmd"): + main_module._append_potfile_arg(cmd) + assert "--username" not in cmd + + def test_no_duplicate_when_tuning_adds_username(self, main_module): + """If --username is already in cmd (e.g. from hcatTuning), don't add a second.""" + cmd = ["hashcat", "-m", "0", "hashes.txt", "--username"] + with patch.object(main_module, "hcatUsernamePrefix", True), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "_debug_cmd"): + main_module._append_potfile_arg(cmd) + assert cmd.count("--username") == 1 + + def test_append_with_use_potfile_path_false(self, main_module): + """When potfile handling is disabled, --username must still be injected.""" + cmd = ["hashcat", "-m", "0", "hashes.txt"] + with patch.object(main_module, "hcatUsernamePrefix", True), \ + patch.object(main_module, "_debug_cmd"): + main_module._append_potfile_arg(cmd, use_potfile_path=False) + assert "--username" in cmd + + +class TestUsernameInjectionIntoBruteForce: + """End-to-end: hcatBruteForce cmd contains --username when flag is set.""" + + def test_brute_force_contains_username_when_flag_set( + self, main_module, tmp_path + ): + hash_file = str(tmp_path / "hashes.txt") + mock_proc = _make_mock_proc() + + with patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "hcatUsernamePrefix", True), \ + patch.object(main_module, "generate_session_id", return_value="sess"), \ + patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mp, \ + patch.object(main_module, "lineCount", return_value=0): + main_module.hcatBruteForce("0", hash_file, 1, 7) + cmd = mp.call_args[0][0] + assert "--username" in cmd + + def test_brute_force_no_username_when_flag_unset( + self, main_module, tmp_path + ): + hash_file = str(tmp_path / "hashes.txt") + mock_proc = _make_mock_proc() + + with patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "hcatUsernamePrefix", False), \ + patch.object(main_module, "generate_session_id", return_value="sess"), \ + patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mp, \ + patch.object(main_module, "lineCount", return_value=0): + main_module.hcatBruteForce("0", hash_file, 1, 7) + cmd = mp.call_args[0][0] + assert "--username" not in cmd + + def test_brute_force_duplicate_guard_via_tuning( + self, main_module, tmp_path + ): + """hcatTuning='--username' with the flag set must not duplicate.""" + hash_file = str(tmp_path / "hashes.txt") + mock_proc = _make_mock_proc() + + with patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", "--username"), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "hcatUsernamePrefix", True), \ + patch.object(main_module, "generate_session_id", return_value="sess"), \ + patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mp, \ + patch.object(main_module, "lineCount", return_value=0): + main_module.hcatBruteForce("0", hash_file, 1, 7) + cmd = mp.call_args[0][0] + assert cmd.count("--username") == 1 + + +class TestUsernameInjectionIntoShow: + """_run_hashcat_show should also honor hcatUsernamePrefix so the initial + potfile check and combine_ntlm_output correctly parse user:hash files.""" + + def test_show_contains_username_when_flag_set(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + output_path = str(tmp_path / "hashes.out") + mock_result = MagicMock() + mock_result.stdout = b"" + + with patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "hcatUsernamePrefix", True), \ + patch("hate_crack.main.subprocess.run", return_value=mock_result) as mr: + main_module._run_hashcat_show("0", hash_file, output_path) + cmd = mr.call_args[0][0] + assert "--username" in cmd + + def test_show_no_username_when_flag_unset(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + output_path = str(tmp_path / "hashes.out") + mock_result = MagicMock() + mock_result.stdout = b"" + + with patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "hcatUsernamePrefix", False), \ + patch("hate_crack.main.subprocess.run", return_value=mock_result) as mr: + main_module._run_hashcat_show("0", hash_file, output_path) + cmd = mr.call_args[0][0] + assert "--username" not in cmd