From dfe9e28e70e8059eb1c9d2be2699de680625f717 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 19 Mar 2026 12:13:38 -0400 Subject: [PATCH 01/14] feat: add random rules attack using generate-rules.bin (#87) - Add hcatGenerateRules() in main.py: runs generate-rules.bin to produce N random rules, writes them to a temp file, runs hashcat with -r against a chosen wordlist, cleans up on exit - Add generate_rules_crack() handler in attacks.py with count prompt (default 65536), wordlist picker with tab-completion, input validation, and abort on invalid input - Add dispatcher generate_rules_crack() in main.py and key "20" in both main.py and hate_crack.py get_main_menu_options() - Add ("20", "Random Rules Attack") to get_main_menu_items() - Add tests: test_random_rules_attack.py (menu presence, handler wiring), test_random_rules_wrapper.py (subprocess behavior, cleanup, count passing, count tracking) - Add key "20" to MENU_OPTION_TEST_CASES in test_ui_menu_options.py - Update README: add option 20 to menu listing, add attack description, add version history entry --- README.md | 10 ++ hate_crack.py | 1 + hate_crack/attacks.py | 78 ++++++++++++++ hate_crack/main.py | 53 ++++++++++ tests/test_random_rules_attack.py | 45 ++++++++ tests/test_random_rules_wrapper.py | 163 +++++++++++++++++++++++++++++ tests/test_ui_menu_options.py | 1 + 7 files changed, 351 insertions(+) create mode 100644 tests/test_random_rules_attack.py create mode 100644 tests/test_random_rules_wrapper.py diff --git a/README.md b/README.md index 0a69f8b..c445bdd 100644 --- a/README.md +++ b/README.md @@ -618,6 +618,7 @@ All tests use mocked API calls, so they can run without connectivity to a Hashvi (16) OMEN Attack (17) Ad-hoc Mask Attack (18) Markov Brute Force Attack + (20) Random Rules Attack (90) Download rules from Hashmob.net (91) Analyze Hashcat Rules @@ -789,6 +790,14 @@ Generates password candidates using Markov chain statistical models. Similar to * Markov table persists with hash file (filename.out.hcstat2) for fast subsequent runs * Faster than OMEN for general-purpose brute forcing +#### Random Rules Attack +Generates a set of random hashcat mutation rules using `generate-rules.bin`, writes them to a temporary file, then runs hashcat against a chosen wordlist with those rules. + +* Prompts for rule count (default 65536) +* Prompts for wordlist path with tab-completion and numbered selection +* Temporary rules file is cleaned up after the run regardless of outcome +* Useful when known rule sets are exhausted - explores random rule-space for additional cracks + #### Download Rules from Hashmob.net Downloads the latest rule files from Hashmob.net's rule repository. These rules are curated and optimized for password cracking and can be used with the Quick Crack and Loopback Attack modes. @@ -824,6 +833,7 @@ Interactive menu for downloading and managing wordlists from Weakpass.com via Bi ### Version History Version 2.0+ + - Added Random Rules Attack (option 20) using `generate-rules.bin` to generate random mutation rules (#87) - Added Ad-hoc Mask Attack (option 17) for user-typed hashcat masks with optional custom character sets - Added Markov Brute Force Attack (option 18) using `hcstat2` statistical tables for password generation - Consolidated Combinator Attacks (formerly options 10/11/12) into interactive submenu under option 6 diff --git a/hate_crack.py b/hate_crack.py index de0124b..1db5d97 100755 --- a/hate_crack.py +++ b/hate_crack.py @@ -88,6 +88,7 @@ def get_main_menu_options(): "16": _attacks.omen_attack, "17": _attacks.adhoc_mask_crack, "18": _attacks.markov_brute_force, + "20": _attacks.generate_rules_crack, "90": download_hashmob_rules, "91": weakpass_wordlist_menu, "92": download_hashmob_wordlists, diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index d06e250..d1dfda6 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -620,6 +620,84 @@ def markov_brute_force(ctx: Any) -> None: ctx.hcatMarkovBruteForce(ctx.hcatHashType, ctx.hcatHashFile, hcatMinLen, hcatMaxLen) +def generate_rules_crack(ctx: Any) -> None: + print("\n" + "=" * 60) + print("RANDOM RULES ATTACK") + print("=" * 60) + print("Generates random hashcat mutation rules and applies them to a wordlist.") + print("Use when known rulesets are exhausted - a chaos mode for rule-space exploration.") + print("=" * 60) + + raw_count = input("\nNumber of random rules to generate (65536): ").strip() + try: + rule_count = int(raw_count) if raw_count else 65536 + if rule_count < 1: + print("[!] Rule count must be at least 1.") + return + except ValueError: + print("[!] Invalid rule count.") + return + + wordlist_files = ctx.list_wordlist_files(ctx.hcatWordlists) + wordlist_entries = [ + f"{i}. {file}" for i, file in enumerate(wordlist_files, start=1) + ] + max_entry_len = max((len(e) for e in wordlist_entries), default=24) + print_multicolumn_list( + "Wordlists", + wordlist_entries, + min_col_width=max_entry_len, + max_col_width=max_entry_len, + ) + + def path_completer(text, state): + base = ctx.hcatWordlists + if not text: + pattern = os.path.join(base, "*") + matches = glob.glob(pattern) + else: + text = os.path.expanduser(text) + if text.startswith(("/", "./", "../", "~")): + matches = glob.glob(text + "*") + else: + pattern = os.path.join(base, text + "*") + matches = glob.glob(pattern) + matches = [m + "/" if os.path.isdir(m) else m for m in matches] + try: + return matches[state] + except IndexError: + return None + + _configure_readline(path_completer) + + wordlist_choice = None + while wordlist_choice is None: + try: + raw_choice = input( + "\nEnter path of wordlist (tab to autocomplete).\n" + f"Press Enter for default wordlist directory [{ctx.hcatWordlists}]: " + ) + raw_choice = raw_choice.strip() + if raw_choice == "": + wordlist_choice = ctx.hcatWordlists + elif raw_choice.isdigit() and 1 <= int(raw_choice) <= len(wordlist_files): + chosen = os.path.join( + ctx.hcatWordlists, wordlist_files[int(raw_choice) - 1] + ) + if os.path.exists(chosen): + wordlist_choice = chosen + print(wordlist_choice) + elif os.path.exists(raw_choice): + wordlist_choice = raw_choice + else: + print("[!] Wordlist not found. Please enter a valid path.") + return + except ValueError: + print("Please enter a valid number.") + + ctx.hcatGenerateRules(ctx.hcatHashType, ctx.hcatHashFile, rule_count, wordlist_choice) + + def combinator_submenu(ctx: Any) -> None: from hate_crack.menu import interactive_menu diff --git a/hate_crack/main.py b/hate_crack/main.py index e36bc14..7b35749 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -24,6 +24,7 @@ import argparse import urllib.request import urllib.error import lzma +import tempfile from types import SimpleNamespace #!/usr/bin/env python3 @@ -677,6 +678,7 @@ hcatCombinationCount = 0 hcatHybridCount = 0 hcatExtraCount = 0 hcatRecycleCount = 0 +hcatGenerateRulesCount = 0 hcatProcess: subprocess.Popen[Any] | None = None debug_mode = False @@ -2527,6 +2529,51 @@ def hcatRecycle(hcatHashType, hcatHashFile, hcatNewPasswords): hcatProcess.kill() +def hcatGenerateRules(hcatHashType, hcatHashFile, rule_count, wordlist): + global hcatProcess, hcatGenerateRulesCount + generate_rules_path = os.path.join( + hate_path, "hashcat-utils", "bin", "generate-rules.bin" + ) + with tempfile.NamedTemporaryFile( + mode="w", suffix=".rule", prefix="hate_crack_random_", delete=False + ) as rules_file: + rules_path = rules_file.name + try: + result = subprocess.run( + [generate_rules_path, str(rule_count)], + capture_output=True, + text=True, + check=True, + ) + with open(rules_path, "w") as f: + f.write(result.stdout) + cmd = [ + hcatBin, + "-m", + hcatHashType, + hcatHashFile, + "--session", + generate_session_id(), + "-o", + f"{hcatHashFile}.out", + "-r", + rules_path, + wordlist, + ] + cmd.extend(shlex.split(hcatTuning)) + _append_potfile_arg(cmd) + hcatProcess = subprocess.Popen(cmd) + try: + hcatProcess.wait() + except KeyboardInterrupt: + print(f"Killing PID {hcatProcess.pid}...") + hcatProcess.kill() + finally: + if os.path.exists(rules_path): + os.unlink(rules_path) + hcatGenerateRulesCount = lineCount(hcatHashFile + ".out") - hcatHashCracked + + def check_potfile(): print("Checking POT file for already cracked hashes...") _run_hashcat_show(hcatHashType, hcatHashFile, f"{hcatHashFile}.out") @@ -3329,6 +3376,10 @@ def omen_attack(): return _attacks.omen_attack(_attack_ctx()) +def generate_rules_crack(): + return _attacks.generate_rules_crack(_attack_ctx()) + + # convert hex words for recycling def convert_hex(working_file): processed_words = [] @@ -3557,6 +3608,7 @@ def get_main_menu_items(): ("16", "OMEN Attack"), ("17", "Ad-hoc Mask Attack"), ("18", "Markov Brute Force Attack"), + ("20", "Random Rules Attack"), ("90", "Download rules from Hashmob.net"), ("91", "Analyze Hashcat Rules"), ("92", "Download wordlists from Hashmob.net"), @@ -3594,6 +3646,7 @@ def get_main_menu_options(): "16": omen_attack, "17": adhoc_mask_crack, "18": markov_brute_force, + "20": generate_rules_crack, "90": lambda: download_hashmob_rules(rules_dir=rulesDirectory), "91": analyze_rules, "92": download_hashmob_wordlists, diff --git a/tests/test_random_rules_attack.py b/tests/test_random_rules_attack.py new file mode 100644 index 0000000..d9d7fff --- /dev/null +++ b/tests/test_random_rules_attack.py @@ -0,0 +1,45 @@ +import importlib.util +import os +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +def load_cli_module(): + os.environ["HATE_CRACK_SKIP_INIT"] = "1" + for key in list(sys.modules.keys()): + if "hate_crack" in key: + del sys.modules[key] + spec = importlib.util.spec_from_file_location( + "hate_crack_cli", PROJECT_ROOT / "hate_crack.py" + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture +def cli(): + return load_cli_module() + + +def test_generate_rules_crack_in_main_menu(cli): + options = cli.get_main_menu_options() + assert "20" in options + + +def test_generate_rules_crack_handler_calls_main(cli, tmp_path): + ctx = MagicMock() + ctx.hcatHashType = "1000" + ctx.hcatHashFile = "/tmp/h.txt" + ctx.hcatWordlists = str(tmp_path) + ctx.list_wordlist_files.return_value = [] + wl = tmp_path / "words.txt" + wl.write_text("password\n") + with patch("builtins.input", side_effect=["100", str(wl)]): + cli._attacks.generate_rules_crack(ctx) + ctx.hcatGenerateRules.assert_called_once_with("1000", "/tmp/h.txt", 100, str(wl)) diff --git a/tests/test_random_rules_wrapper.py b/tests/test_random_rules_wrapper.py new file mode 100644 index 0000000..be6894f --- /dev/null +++ b/tests/test_random_rules_wrapper.py @@ -0,0 +1,163 @@ +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture +def main_module(hc_module): + return hc_module._main + + +def _make_mock_proc(wait_side_effect=None): + proc = MagicMock() + if wait_side_effect is not None: + proc.wait.side_effect = wait_side_effect + else: + proc.wait.return_value = None + proc.pid = 12345 + return proc + + +class TestHcatGenerateRules: + def test_calls_generate_rules_bin(self, main_module, tmp_path): + wl = tmp_path / "words.txt" + wl.write_text("test\n") + hash_file = str(tmp_path / "hashes.txt") + mock_proc = _make_mock_proc() + mock_result = MagicMock() + mock_result.stdout = "l\nu\nc\n" + + with patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "generate_session_id", return_value="test_session"), \ + patch.object(main_module, "lineCount", return_value=0), \ + patch.object(main_module, "hcatHashCracked", 0), \ + patch("hate_crack.main.subprocess.run", return_value=mock_result) as mock_run, \ + patch("hate_crack.main.subprocess.Popen", return_value=mock_proc): + main_module.hcatGenerateRules("1000", hash_file, 100, str(wl)) + + run_calls = mock_run.call_args_list + assert any("generate-rules.bin" in str(c) for c in run_calls) + + def test_calls_hashcat_with_rule_flag(self, main_module, tmp_path): + wl = tmp_path / "words.txt" + wl.write_text("test\n") + hash_file = str(tmp_path / "hashes.txt") + mock_proc = _make_mock_proc() + mock_result = MagicMock() + mock_result.stdout = "l\nu\n" + + with patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "generate_session_id", return_value="test_session"), \ + patch.object(main_module, "lineCount", return_value=0), \ + patch.object(main_module, "hcatHashCracked", 0), \ + patch("hate_crack.main.subprocess.run", return_value=mock_result), \ + patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen: + main_module.hcatGenerateRules("1000", hash_file, 100, str(wl)) + + popen_calls = mock_popen.call_args_list + assert any("-r" in str(c) for c in popen_calls) + + def test_passes_rule_count_to_generate_rules_bin(self, main_module, tmp_path): + wl = tmp_path / "words.txt" + wl.write_text("test\n") + hash_file = str(tmp_path / "hashes.txt") + mock_proc = _make_mock_proc() + mock_result = MagicMock() + mock_result.stdout = "l\n" + + with patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "generate_session_id", return_value="test_session"), \ + patch.object(main_module, "lineCount", return_value=0), \ + patch.object(main_module, "hcatHashCracked", 0), \ + patch("hate_crack.main.subprocess.run", return_value=mock_result) as mock_run, \ + patch("hate_crack.main.subprocess.Popen", return_value=mock_proc): + main_module.hcatGenerateRules("1000", hash_file, 999, str(wl)) + + run_calls = mock_run.call_args_list + generate_call = next( + (c for c in run_calls if "generate-rules.bin" in str(c)), None + ) + assert generate_call is not None + cmd_args = generate_call[0][0] + assert "999" in cmd_args + + def test_cleans_up_temp_file(self, main_module, tmp_path): + wl = tmp_path / "words.txt" + wl.write_text("test\n") + hash_file = str(tmp_path / "hashes.txt") + mock_proc = _make_mock_proc() + mock_result = MagicMock() + mock_result.stdout = "l\nu\n" + captured_paths = [] + + import os as _os + original_unlink = _os.unlink + + def capturing_unlink(path): + captured_paths.append(path) + original_unlink(path) + + with patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "generate_session_id", return_value="test_session"), \ + patch.object(main_module, "lineCount", return_value=0), \ + patch.object(main_module, "hcatHashCracked", 0), \ + patch("hate_crack.main.subprocess.run", return_value=mock_result), \ + patch("hate_crack.main.subprocess.Popen", return_value=mock_proc), \ + patch("hate_crack.main.os.unlink", side_effect=capturing_unlink): + main_module.hcatGenerateRules("1000", hash_file, 50, str(wl)) + + assert any("hate_crack_random_" in p for p in captured_paths), \ + f"Expected temp file cleanup, got: {captured_paths}" + + def test_keyboard_interrupt_kills_process(self, main_module, tmp_path): + wl = tmp_path / "words.txt" + wl.write_text("test\n") + hash_file = str(tmp_path / "hashes.txt") + mock_proc = _make_mock_proc(wait_side_effect=KeyboardInterrupt()) + mock_result = MagicMock() + mock_result.stdout = "l\n" + + with patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "generate_session_id", return_value="test_session"), \ + patch.object(main_module, "lineCount", return_value=0), \ + patch.object(main_module, "hcatHashCracked", 0), \ + patch("hate_crack.main.subprocess.run", return_value=mock_result), \ + patch("hate_crack.main.subprocess.Popen", return_value=mock_proc): + main_module.hcatGenerateRules("1000", hash_file, 10, str(wl)) + + mock_proc.kill.assert_called_once() + + def test_sets_hcatGenerateRulesCount(self, main_module, tmp_path): + wl = tmp_path / "words.txt" + wl.write_text("test\n") + hash_file = str(tmp_path / "hashes.txt") + mock_proc = _make_mock_proc() + mock_result = MagicMock() + mock_result.stdout = "l\nu\n" + + # patch.object won't patch reads of module-level globals; set directly + original_cracked = main_module.hcatHashCracked + main_module.hcatHashCracked = 2 + try: + with patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "generate_session_id", return_value="test_session"), \ + patch.object(main_module, "lineCount", return_value=5), \ + patch("hate_crack.main.subprocess.run", return_value=mock_result), \ + patch("hate_crack.main.subprocess.Popen", return_value=mock_proc): + main_module.hcatGenerateRules("1000", hash_file, 10, str(wl)) + finally: + main_module.hcatHashCracked = original_cracked + + assert main_module.hcatGenerateRulesCount == 3 # 5 - 2 diff --git a/tests/test_ui_menu_options.py b/tests/test_ui_menu_options.py index fd3bfa6..e8c8ba9 100644 --- a/tests/test_ui_menu_options.py +++ b/tests/test_ui_menu_options.py @@ -26,6 +26,7 @@ MENU_OPTION_TEST_CASES = [ ("16", CLI_MODULE._attacks, "omen_attack", "omen"), ("17", CLI_MODULE._attacks, "adhoc_mask_crack", "adhoc-mask"), ("18", CLI_MODULE._attacks, "markov_brute_force", "markov-brute"), + ("20", CLI_MODULE._attacks, "generate_rules_crack", "random-rules"), ("90", CLI_MODULE, "download_hashmob_rules", "hashmob-rules"), ("91", CLI_MODULE, "weakpass_wordlist_menu", "weakpass-menu"), ("92", CLI_MODULE, "download_hashmob_wordlists", "hashmob-wordlists"), From e41134eb1a9bd2bb3fe5f8eeb9d7eea245cd2a0b Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 19 Mar 2026 12:14:05 -0400 Subject: [PATCH 02/14] feat: add permutation attack using permute.bin (closes #86) Adds Permutation Attack (menu option 19) that generates all character permutations of each word in a targeted wordlist and pipes them to hashcat via permute.bin from hashcat-utils. - hcatPermute() in main.py: pipes permute.bin < wordlist | hashcat - permute_crack() in attacks.py: prompts for single wordlist file with factorial-growth warning, tab-autocomplete support - Menu option 19 wired in both main.py and hate_crack.py - hcatPermuteCount tracking alongside other count globals - Tests: test_permute_attack.py (handler behavior) and test_permute_wrapper.py (subprocess wiring) - README: added entry in menu listing and attack descriptions --- README.md | 9 ++ hate_crack.py | 1 + hate_crack/attacks.py | 47 ++++++++++ hate_crack/main.py | 46 ++++++++++ tests/test_permute_attack.py | 67 ++++++++++++++ tests/test_permute_wrapper.py | 162 ++++++++++++++++++++++++++++++++++ tests/test_ui_menu_options.py | 1 + 7 files changed, 333 insertions(+) create mode 100644 tests/test_permute_attack.py create mode 100644 tests/test_permute_wrapper.py diff --git a/README.md b/README.md index 0a69f8b..dc32bf7 100644 --- a/README.md +++ b/README.md @@ -618,6 +618,7 @@ All tests use mocked API calls, so they can run without connectivity to a Hashvi (16) OMEN Attack (17) Ad-hoc Mask Attack (18) Markov Brute Force Attack + (19) Permutation Attack (90) Download rules from Hashmob.net (91) Analyze Hashcat Rules @@ -789,6 +790,14 @@ Generates password candidates using Markov chain statistical models. Similar to * Markov table persists with hash file (filename.out.hcstat2) for fast subsequent runs * Faster than OMEN for general-purpose brute forcing +#### Permutation Attack +Generates all character permutations of each word in a targeted wordlist and pipes them to hashcat via `permute.bin` from hashcat-utils. + +* Prompts for a single wordlist file (not a directory) +* Effective against short targeted wordlists where the character set is known but the order is not (company abbreviations, name fragments, known tokens) +* WARNING: Scales as N! per word - an 8-character word produces 40,320 permutations. Only practical for words up to ~8 characters. +* Uses `permute.bin < wordlist | hashcat` pipeline pattern + #### Download Rules from Hashmob.net Downloads the latest rule files from Hashmob.net's rule repository. These rules are curated and optimized for password cracking and can be used with the Quick Crack and Loopback Attack modes. diff --git a/hate_crack.py b/hate_crack.py index de0124b..691b8ec 100755 --- a/hate_crack.py +++ b/hate_crack.py @@ -88,6 +88,7 @@ def get_main_menu_options(): "16": _attacks.omen_attack, "17": _attacks.adhoc_mask_crack, "18": _attacks.markov_brute_force, + "19": _attacks.permute_crack, "90": download_hashmob_rules, "91": weakpass_wordlist_menu, "92": download_hashmob_wordlists, diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index d06e250..b358789 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -620,6 +620,53 @@ def markov_brute_force(ctx: Any) -> None: ctx.hcatMarkovBruteForce(ctx.hcatHashType, ctx.hcatHashFile, hcatMinLen, hcatMaxLen) +def permute_crack(ctx: Any) -> None: + print("\n" + "=" * 60) + print("PERMUTATION ATTACK") + print("=" * 60) + print("Generates ALL character permutations of each word in a targeted wordlist.") + print("WARNING: Scales as N! per word. Only practical for words up to ~8 characters.") + print("Best for: short targeted wordlists (names, abbreviations, known fragments).") + print("=" * 60) + + def path_completer(text, state): + base = ctx.hcatWordlists + if not text: + pattern = os.path.join(base, "*") + matches = glob.glob(pattern) + else: + text = os.path.expanduser(text) + if text.startswith(("/", "./", "../", "~")): + matches = glob.glob(text + "*") + else: + pattern = os.path.join(base, text + "*") + matches = glob.glob(pattern) + matches = [m + "/" if os.path.isdir(m) else m for m in matches] + try: + return matches[state] + except IndexError: + return None + + _configure_readline(path_completer) + + wordlist_path = None + while wordlist_path is None: + raw = input( + "\nEnter path to a wordlist FILE (tab to autocomplete): " + ).strip() + if not raw: + continue + if not os.path.exists(raw): + print(f"[!] Path not found: {raw}") + continue + if os.path.isdir(raw): + print("[!] A directory was provided. Please enter a single wordlist file.") + continue + wordlist_path = raw + + ctx.hcatPermute(ctx.hcatHashType, ctx.hcatHashFile, wordlist_path) + + def combinator_submenu(ctx: Any) -> None: from hate_crack.menu import interactive_menu diff --git a/hate_crack/main.py b/hate_crack/main.py index e36bc14..cebc19c 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -677,6 +677,7 @@ hcatCombinationCount = 0 hcatHybridCount = 0 hcatExtraCount = 0 hcatRecycleCount = 0 +hcatPermuteCount = 0 hcatProcess: subprocess.Popen[Any] | None = None debug_mode = False @@ -2222,6 +2223,45 @@ def hcatPrince(hcatHashType, hcatHashFile): prince_proc.kill() +def hcatPermute(hcatHashType, hcatHashFile, wordlist): + global hcatProcess, hcatPermuteCount + permute_path = os.path.join(hate_path, "hashcat-utils", "bin", "permute.bin") + if not os.path.isfile(permute_path): + print(f"Error: permute.bin not found: {permute_path}") + return + if not os.path.isfile(wordlist): + print(f"Error: wordlist not found: {wordlist}") + return + hashcat_cmd = [ + hcatBin, + "-m", + hcatHashType, + hcatHashFile, + "--session", + generate_session_id(), + "-o", + f"{hcatHashFile}.out", + ] + hashcat_cmd.extend(shlex.split(hcatTuning)) + _append_potfile_arg(hashcat_cmd) + with open(wordlist, "rb") as wl_file: + permute_proc = subprocess.Popen( + [permute_path], stdin=wl_file, stdout=subprocess.PIPE + ) + hcatProcess = subprocess.Popen( + hashcat_cmd, stdin=permute_proc.stdout + ) + permute_proc.stdout.close() + try: + hcatProcess.wait() + permute_proc.wait() + except KeyboardInterrupt: + print(f"Killing PID {hcatProcess.pid}...") + hcatProcess.kill() + permute_proc.kill() + hcatPermuteCount = lineCount(f"{hcatHashFile}.out") - hcatHashCracked + + # OMEN model directory - writable location for trained model files. # The binaries live in {hate_path}/omen/ (possibly read-only after install), # but model output (createConfig, *.level) goes to ~/.hate_crack/omen/. @@ -3329,6 +3369,10 @@ def omen_attack(): return _attacks.omen_attack(_attack_ctx()) +def permute_crack(): + return _attacks.permute_crack(_attack_ctx()) + + # convert hex words for recycling def convert_hex(working_file): processed_words = [] @@ -3557,6 +3601,7 @@ def get_main_menu_items(): ("16", "OMEN Attack"), ("17", "Ad-hoc Mask Attack"), ("18", "Markov Brute Force Attack"), + ("19", "Permutation Attack"), ("90", "Download rules from Hashmob.net"), ("91", "Analyze Hashcat Rules"), ("92", "Download wordlists from Hashmob.net"), @@ -3594,6 +3639,7 @@ def get_main_menu_options(): "16": omen_attack, "17": adhoc_mask_crack, "18": markov_brute_force, + "19": permute_crack, "90": lambda: download_hashmob_rules(rules_dir=rulesDirectory), "91": analyze_rules, "92": download_hashmob_wordlists, diff --git a/tests/test_permute_attack.py b/tests/test_permute_attack.py new file mode 100644 index 0000000..d5bec44 --- /dev/null +++ b/tests/test_permute_attack.py @@ -0,0 +1,67 @@ +import os +from unittest.mock import MagicMock, patch + +import pytest + +from hate_crack.attacks import permute_crack + + +def _make_ctx(hash_type="1000", hash_file="/tmp/hashes.txt"): + ctx = MagicMock() + ctx.hcatHashType = hash_type + ctx.hcatHashFile = hash_file + ctx.hcatWordlists = "/tmp/wordlists" + return ctx + + +class TestPermuteCrack: + def test_calls_hcatPermute_with_valid_wordlist(self, tmp_path): + ctx = _make_ctx() + wl = tmp_path / "target.txt" + wl.write_text("abc\ndef\n") + with patch("builtins.input", return_value=str(wl)): + permute_crack(ctx) + ctx.hcatPermute.assert_called_once_with( + ctx.hcatHashType, ctx.hcatHashFile, str(wl) + ) + + def test_rejects_nonexistent_wordlist_then_accepts_valid(self, tmp_path): + ctx = _make_ctx() + wl = tmp_path / "real.txt" + wl.write_text("test\n") + with patch( + "builtins.input", + side_effect=["/nonexistent/path.txt", str(wl)], + ): + permute_crack(ctx) + ctx.hcatPermute.assert_called_once_with( + ctx.hcatHashType, ctx.hcatHashFile, str(wl) + ) + + def test_rejects_directory_then_accepts_file(self, tmp_path): + ctx = _make_ctx() + wl = tmp_path / "words.txt" + wl.write_text("ab\n") + with patch("builtins.input", side_effect=[str(tmp_path), str(wl)]): + permute_crack(ctx) + ctx.hcatPermute.assert_called_once_with( + ctx.hcatHashType, ctx.hcatHashFile, str(wl) + ) + + def test_warns_about_factorial_scaling(self, tmp_path, capsys): + ctx = _make_ctx() + wl = tmp_path / "words.txt" + wl.write_text("abc\n") + with patch("builtins.input", return_value=str(wl)): + permute_crack(ctx) + captured = capsys.readouterr() + assert "WARNING" in captured.out or "factorial" in captured.out.lower() or "N!" in captured.out + + def test_prints_header(self, tmp_path, capsys): + ctx = _make_ctx() + wl = tmp_path / "words.txt" + wl.write_text("abc\n") + with patch("builtins.input", return_value=str(wl)): + permute_crack(ctx) + captured = capsys.readouterr() + assert "PERMUTATION" in captured.out.upper() diff --git a/tests/test_permute_wrapper.py b/tests/test_permute_wrapper.py new file mode 100644 index 0000000..54865dd --- /dev/null +++ b/tests/test_permute_wrapper.py @@ -0,0 +1,162 @@ +import os +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture +def main_module(hc_module): + """Return the underlying hate_crack.main module for direct patching.""" + return hc_module._main + + +class TestHcatPermute: + def test_uses_permute_bin(self, main_module, tmp_path): + wl = tmp_path / "words.txt" + wl.write_text("abc\n") + hash_file = str(tmp_path / "hashes.txt") + permute_bin_dir = tmp_path / "hashcat-utils" / "bin" + permute_bin_dir.mkdir(parents=True) + permute_bin = permute_bin_dir / "permute.bin" + permute_bin.touch() + + mock_permute_proc = MagicMock() + mock_permute_proc.stdout = MagicMock() + mock_permute_proc.wait.return_value = None + + mock_hashcat_proc = MagicMock() + mock_hashcat_proc.wait.return_value = None + mock_hashcat_proc.pid = 99 + + with patch.object(main_module, "hate_path", str(tmp_path)), \ + patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "generate_session_id", return_value="sess1"), \ + patch.object(main_module, "lineCount", return_value=0), \ + patch.object(main_module, "hcatHashCracked", 0, create=True), \ + patch("hate_crack.main.subprocess.Popen") as mock_popen: + mock_popen.side_effect = [mock_permute_proc, mock_hashcat_proc] + main_module.hcatPermute("1000", hash_file, str(wl)) + + assert mock_popen.call_count == 2 + first_call_args = mock_popen.call_args_list[0][0][0] + assert "permute.bin" in str(first_call_args) + + def test_pipes_permute_stdout_to_hashcat_stdin(self, main_module, tmp_path): + wl = tmp_path / "words.txt" + wl.write_text("abc\n") + hash_file = str(tmp_path / "hashes.txt") + permute_bin_dir = tmp_path / "hashcat-utils" / "bin" + permute_bin_dir.mkdir(parents=True) + (permute_bin_dir / "permute.bin").touch() + + mock_permute_proc = MagicMock() + mock_permute_proc.stdout = MagicMock() + mock_permute_proc.wait.return_value = None + + mock_hashcat_proc = MagicMock() + mock_hashcat_proc.wait.return_value = None + mock_hashcat_proc.pid = 99 + + with patch.object(main_module, "hate_path", str(tmp_path)), \ + patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "generate_session_id", return_value="sess1"), \ + patch.object(main_module, "lineCount", return_value=0), \ + patch.object(main_module, "hcatHashCracked", 0, create=True), \ + patch("hate_crack.main.subprocess.Popen") as mock_popen: + mock_popen.side_effect = [mock_permute_proc, mock_hashcat_proc] + main_module.hcatPermute("1000", hash_file, str(wl)) + + # Second call (hashcat) should use permute_proc.stdout as stdin + second_call_kwargs = mock_popen.call_args_list[1][1] + assert second_call_kwargs.get("stdin") == mock_permute_proc.stdout + + def test_hashcat_cmd_includes_hash_type_and_file(self, main_module, tmp_path): + wl = tmp_path / "words.txt" + wl.write_text("abc\n") + hash_file = str(tmp_path / "hashes.txt") + permute_bin_dir = tmp_path / "hashcat-utils" / "bin" + permute_bin_dir.mkdir(parents=True) + (permute_bin_dir / "permute.bin").touch() + + mock_permute_proc = MagicMock() + mock_permute_proc.stdout = MagicMock() + mock_permute_proc.wait.return_value = None + + mock_hashcat_proc = MagicMock() + mock_hashcat_proc.wait.return_value = None + mock_hashcat_proc.pid = 99 + + with patch.object(main_module, "hate_path", str(tmp_path)), \ + patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "generate_session_id", return_value="sess1"), \ + patch.object(main_module, "lineCount", return_value=0), \ + patch.object(main_module, "hcatHashCracked", 0, create=True), \ + patch("hate_crack.main.subprocess.Popen") as mock_popen: + mock_popen.side_effect = [mock_permute_proc, mock_hashcat_proc] + main_module.hcatPermute("1000", hash_file, str(wl)) + + hashcat_cmd = mock_popen.call_args_list[1][0][0] + assert "hashcat" in hashcat_cmd + assert "-m" in hashcat_cmd + assert "1000" in hashcat_cmd + assert hash_file in hashcat_cmd + + def test_keyboard_interrupt_kills_both_processes(self, main_module, tmp_path): + wl = tmp_path / "words.txt" + wl.write_text("abc\n") + hash_file = str(tmp_path / "hashes.txt") + permute_bin_dir = tmp_path / "hashcat-utils" / "bin" + permute_bin_dir.mkdir(parents=True) + (permute_bin_dir / "permute.bin").touch() + + mock_permute_proc = MagicMock() + mock_permute_proc.stdout = MagicMock() + mock_permute_proc.wait.return_value = None + + mock_hashcat_proc = MagicMock() + mock_hashcat_proc.wait.side_effect = KeyboardInterrupt() + mock_hashcat_proc.pid = 99 + + with patch.object(main_module, "hate_path", str(tmp_path)), \ + patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "generate_session_id", return_value="sess1"), \ + patch.object(main_module, "lineCount", return_value=0), \ + patch.object(main_module, "hcatHashCracked", 0, create=True), \ + patch("hate_crack.main.subprocess.Popen") as mock_popen: + mock_popen.side_effect = [mock_permute_proc, mock_hashcat_proc] + main_module.hcatPermute("1000", hash_file, str(wl)) + + mock_hashcat_proc.kill.assert_called_once() + mock_permute_proc.kill.assert_called_once() + + def test_missing_permute_bin_prints_error(self, main_module, tmp_path, capsys): + wl = tmp_path / "words.txt" + wl.write_text("abc\n") + hash_file = str(tmp_path / "hashes.txt") + # No permute.bin created + + with patch.object(main_module, "hate_path", str(tmp_path)): + main_module.hcatPermute("1000", hash_file, str(wl)) + + captured = capsys.readouterr() + assert "permute.bin" in captured.out + + def test_missing_wordlist_prints_error(self, main_module, tmp_path, capsys): + hash_file = str(tmp_path / "hashes.txt") + permute_bin_dir = tmp_path / "hashcat-utils" / "bin" + permute_bin_dir.mkdir(parents=True) + (permute_bin_dir / "permute.bin").touch() + + with patch.object(main_module, "hate_path", str(tmp_path)): + main_module.hcatPermute("1000", hash_file, "/nonexistent/words.txt") + + captured = capsys.readouterr() + assert "not found" in captured.out.lower() or "error" in captured.out.lower() diff --git a/tests/test_ui_menu_options.py b/tests/test_ui_menu_options.py index fd3bfa6..f563773 100644 --- a/tests/test_ui_menu_options.py +++ b/tests/test_ui_menu_options.py @@ -26,6 +26,7 @@ MENU_OPTION_TEST_CASES = [ ("16", CLI_MODULE._attacks, "omen_attack", "omen"), ("17", CLI_MODULE._attacks, "adhoc_mask_crack", "adhoc-mask"), ("18", CLI_MODULE._attacks, "markov_brute_force", "markov-brute"), + ("19", CLI_MODULE._attacks, "permute_crack", "permute"), ("90", CLI_MODULE, "download_hashmob_rules", "hashmob-rules"), ("91", CLI_MODULE, "weakpass_wordlist_menu", "weakpass-menu"), ("92", CLI_MODULE, "download_hashmob_wordlists", "hashmob-wordlists"), From 69e6fb9cb31cc39f64828382c9b39a48b622e355 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 19 Mar 2026 12:16:04 -0400 Subject: [PATCH 03/14] feat: add combinator3 and combinatorX attacks to combinator submenu Extends the combinator submenu (option 6) with two new attacks using hashcat-utils binaries that were already compiled but unused. - hcatCombinator3: 3-way wordlist combination via combinator3.bin piped to hashcat stdin - hcatCombinatorX: 2-8 wordlist combination via combinatorX.bin with optional --sepFill separator, piped to hashcat stdin - combinator3_crack handler: prompts for 3 comma-separated wordlist paths - combinatorX_crack handler: prompts for 2-8 paths plus optional separator - combinator_submenu updated with options 5 and 6 Closes #84, closes #85 --- README.md | 4 +- hate_crack/attacks.py | 113 +++++++++- hate_crack/main.py | 91 ++++++++ tests/test_combinator3_combinatorX.py | 144 +++++++++++++ tests/test_combinator_wrappers.py | 300 ++++++++++++++++++++++++++ 5 files changed, 649 insertions(+), 3 deletions(-) create mode 100644 tests/test_combinator3_combinatorX.py create mode 100644 tests/test_combinator_wrappers.py diff --git a/README.md b/README.md index 0a69f8b..7c17bda 100644 --- a/README.md +++ b/README.md @@ -763,11 +763,13 @@ Uses the Ordered Markov ENumerator (OMEN) to train a statistical password model * Model files and metadata are stored in `~/.hate_crack/omen/` for persistence across sessions #### Combinator Attacks Submenu -Opens an interactive submenu with four combinator attack variants (formerly at menu keys 10-12). Consolidates related attacks for cleaner menu organization: +Opens an interactive submenu with six combinator attack variants (formerly at menu keys 10-12). Consolidates related attacks for cleaner menu organization: - Combinator Attack - combines two wordlists - YOLO Combinator Attack - combines all permutations of multiple wordlists - Middle Combinator Attack - combines wordlists with an extra word in the middle - Thorough Combinator Attack - comprehensive combination of wordlists with rules +- Combinator3 Attack - combines exactly 3 wordlists using `combinator3.bin`, generating all `word1+word2+word3` combinations piped to hashcat +- CombinatorX Attack - combines 2-8 wordlists using `combinatorX.bin` with optional `--sepFill` separator character between word segments #### Ad-hoc Mask Attack Runs hashcat mask attack (mode 3) with a user-specified custom mask string. Allows fine-grained control over character-set brute forcing. diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index d06e250..7c38c62 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -5,6 +5,7 @@ from typing import Any from hate_crack.api import download_hashmob_rules from hate_crack.formatting import print_multicolumn_list +from hate_crack.menu import interactive_menu def _configure_readline(completer): @@ -427,6 +428,110 @@ def middle_combinator(ctx: Any) -> None: ctx.hcatMiddleCombinator(ctx.hcatHashType, ctx.hcatHashFile) +def combinator3_crack(ctx: Any) -> None: + print("\n" + "=" * 60) + print("COMBINATOR3 ATTACK") + print("=" * 60) + print("This attack combines three wordlists to generate candidates.") + print("=" * 60) + + use_default = ( + input("\nUse default combinator wordlists from config? (Y/n): ").strip().lower() + ) + + if use_default != "n": + base = ctx.hcatCombinationWordlist + wordlists = base if isinstance(base, list) else [base] + if len(wordlists) < 3: + print("\n[!] Config does not have 3 wordlists for combinator3.") + print("Set hcatCombinationWordlist to a list of 3 paths in config.json.") + print("Aborting combinator3 attack.") + return + else: + raw = input( + "\nEnter 3 wordlist file paths (comma-separated): " + ).strip() + if not raw: + print("No wordlists provided. Aborting combinator3 attack.") + return + + entries = [p.strip() for p in raw.split(",") if p.strip()] + if len(entries) < 3: + print("\n[!] Combinator3 attack requires exactly 3 wordlists.") + print("Aborting combinator3 attack.") + return + + valid = [] + for p in entries[:3]: + resolved = ctx._resolve_wordlist_path(p, ctx.hcatWordlists) + if os.path.isfile(resolved): + valid.append(resolved) + print(f"Found: {resolved}") + else: + print(f"Not found: {resolved}") + + if len(valid) < 3: + print("\nCould not find 3 valid wordlists. Aborting combinator3 attack.") + return + + wordlists = valid + + ctx.hcatCombinator3(ctx.hcatHashType, ctx.hcatHashFile, wordlists) + + +def combinatorX_crack(ctx: Any) -> None: + print("\n" + "=" * 60) + print("COMBINATORX ATTACK") + print("=" * 60) + print("This attack combines 2-8 wordlists with an optional separator.") + print("=" * 60) + + use_default = ( + input("\nUse default combinator wordlists from config? (Y/n): ").strip().lower() + ) + + if use_default != "n": + base = ctx.hcatCombinationWordlist + wordlists = base if isinstance(base, list) else [base] + if len(wordlists) < 2: + print("\n[!] Config does not have at least 2 wordlists for combinatorX.") + print("Set hcatCombinationWordlist to a list of 2+ paths in config.json.") + print("Aborting combinatorX attack.") + return + separator = "" + else: + raw = input( + "\nEnter 2-8 wordlist file paths (comma-separated): " + ).strip() + if not raw: + print("No wordlists provided. Aborting combinatorX attack.") + return + + entries = [p.strip() for p in raw.split(",") if p.strip()] + if len(entries) < 2: + print("\n[!] CombinatorX attack requires at least 2 wordlists.") + print("Aborting combinatorX attack.") + return + + valid = [] + for p in entries[:8]: + resolved = ctx._resolve_wordlist_path(p, ctx.hcatWordlists) + if os.path.isfile(resolved): + valid.append(resolved) + print(f"Found: {resolved}") + else: + print(f"Not found: {resolved}") + + if len(valid) < 2: + print("\nCould not find 2 valid wordlists. Aborting combinatorX attack.") + return + + wordlists = valid + separator = input("\nEnter separator between words (leave blank for none): ").strip() + + ctx.hcatCombinatorX(ctx.hcatHashType, ctx.hcatHashFile, wordlists, separator or None) + + def bandrel_method(ctx: Any) -> None: ctx.hcatBandrel(ctx.hcatHashType, ctx.hcatHashFile) @@ -621,13 +726,13 @@ def markov_brute_force(ctx: Any) -> None: def combinator_submenu(ctx: Any) -> None: - from hate_crack.menu import interactive_menu - items = [ ("1", "Combinator Attack"), ("2", "YOLO Combinator Attack"), ("3", "Middle Combinator Attack"), ("4", "Thorough Combinator Attack"), + ("5", "Combinator3 Attack (3-way)"), + ("6", "CombinatorX Attack (N-way, 2-8 wordlists)"), ("99", "Back to Main Menu"), ] while True: @@ -642,3 +747,7 @@ def combinator_submenu(ctx: Any) -> None: middle_combinator(ctx) elif choice == "4": thorough_combinator(ctx) + elif choice == "5": + combinator3_crack(ctx) + elif choice == "6": + combinatorX_crack(ctx) diff --git a/hate_crack/main.py b/hate_crack/main.py index e36bc14..951a397 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -674,6 +674,8 @@ hcatDictionaryCount = 0 hcatMaskCount = 0 hcatFingerprintCount = 0 hcatCombinationCount = 0 +hcatCombinator3Count = 0 +hcatCombinatorXCount = 0 hcatHybridCount = 0 hcatExtraCount = 0 hcatRecycleCount = 0 @@ -1415,6 +1417,86 @@ def hcatCombination(hcatHashType, hcatHashFile, wordlists=None): hcatCombinationCount = lineCount(hcatHashFile + ".out") - hcatHashCracked +# Combinator3 Attack - 3-way combination via combinator3.bin piped to hashcat +def hcatCombinator3(hcatHashType, hcatHashFile, wordlists): + global hcatCombinator3Count + global hcatProcess + + if len(wordlists) < 3: + print("[!] Combinator3 attack requires exactly 3 wordlists.") + return + + combinator3_bin = os.path.join(hate_path, "hashcat-utils/bin/combinator3.bin") + generator_cmd = [combinator3_bin] + list(wordlists[:3]) + hashcat_cmd = [ + hcatBin, + "-m", + hcatHashType, + hcatHashFile, + "--session", + generate_session_id(), + "-o", + f"{hcatHashFile}.out", + ] + hashcat_cmd.extend(shlex.split(hcatTuning)) + _append_potfile_arg(hashcat_cmd) + generator_proc = subprocess.Popen(generator_cmd, stdout=subprocess.PIPE) + assert generator_proc.stdout is not None + hcatProcess = subprocess.Popen(hashcat_cmd, stdin=generator_proc.stdout) + generator_proc.stdout.close() + try: + hcatProcess.wait() + generator_proc.wait() + except KeyboardInterrupt: + print("Killing PID {0}...".format(str(hcatProcess.pid))) + hcatProcess.kill() + generator_proc.kill() + + hcatCombinator3Count = lineCount(hcatHashFile + ".out") - hcatHashCracked + + +# CombinatorX Attack - N-way combination (2-8 wordlists) via combinatorX.bin piped to hashcat +def hcatCombinatorX(hcatHashType, hcatHashFile, wordlists, separator=None): + global hcatCombinatorXCount + global hcatProcess + + if len(wordlists) < 2: + print("[!] CombinatorX attack requires at least 2 wordlists.") + return + + combinatorX_bin = os.path.join(hate_path, "hashcat-utils/bin/combinatorX.bin") + generator_cmd = [combinatorX_bin] + for i, f in enumerate(wordlists[:8], start=1): + generator_cmd += [f"--file{i}", f] + if separator: + generator_cmd += ["--sepFill", separator] + hashcat_cmd = [ + hcatBin, + "-m", + hcatHashType, + hcatHashFile, + "--session", + generate_session_id(), + "-o", + f"{hcatHashFile}.out", + ] + hashcat_cmd.extend(shlex.split(hcatTuning)) + _append_potfile_arg(hashcat_cmd) + generator_proc = subprocess.Popen(generator_cmd, stdout=subprocess.PIPE) + assert generator_proc.stdout is not None + hcatProcess = subprocess.Popen(hashcat_cmd, stdin=generator_proc.stdout) + generator_proc.stdout.close() + try: + hcatProcess.wait() + generator_proc.wait() + except KeyboardInterrupt: + print("Killing PID {0}...".format(str(hcatProcess.pid))) + hcatProcess.kill() + generator_proc.kill() + + hcatCombinatorXCount = lineCount(hcatHashFile + ".out") - hcatHashCracked + + # Hybrid Attack def hcatHybrid(hcatHashType, hcatHashFile, wordlists=None): global hcatHybridCount @@ -3301,6 +3383,15 @@ def middle_combinator(): return _attacks.middle_combinator(_attack_ctx()) +def combinator3_crack(): + return _attacks.combinator3_crack(_attack_ctx()) + + +def combinatorX_crack(): + return _attacks.combinatorX_crack(_attack_ctx()) + + + def combinator_submenu(): return _attacks.combinator_submenu(_attack_ctx()) diff --git a/tests/test_combinator3_combinatorX.py b/tests/test_combinator3_combinatorX.py new file mode 100644 index 0000000..267215e --- /dev/null +++ b/tests/test_combinator3_combinatorX.py @@ -0,0 +1,144 @@ +import os +from unittest.mock import MagicMock, patch + +import pytest + +from hate_crack.attacks import combinator3_crack, combinator_submenu, combinatorX_crack + + +def _make_ctx(hash_type="1000", hash_file="/tmp/hashes.txt"): + ctx = MagicMock() + ctx.hcatHashType = hash_type + ctx.hcatHashFile = hash_file + return ctx + + +class TestCombinator3Crack: + def test_calls_hcatCombinator3_with_three_wordlists(self, tmp_path): + ctx = _make_ctx() + ctx.hcatWordlists = str(tmp_path) + for name in ["a.txt", "b.txt", "c.txt"]: + (tmp_path / name).write_text("word\n") + ctx._resolve_wordlist_path.side_effect = lambda p, base: p + wl_arg = f"{tmp_path}/a.txt,{tmp_path}/b.txt,{tmp_path}/c.txt" + with patch("builtins.input", side_effect=["n", wl_arg]): + combinator3_crack(ctx) + ctx.hcatCombinator3.assert_called_once() + + def test_aborts_with_fewer_than_3_wordlists(self, tmp_path): + ctx = _make_ctx() + ctx.hcatWordlists = str(tmp_path) + (tmp_path / "a.txt").write_text("word\n") + ctx._resolve_wordlist_path.side_effect = lambda p, base: p + wl_arg = f"{tmp_path}/a.txt,{tmp_path}/a.txt" + with patch("builtins.input", side_effect=["n", wl_arg]): + combinator3_crack(ctx) + ctx.hcatCombinator3.assert_not_called() + + def test_aborts_when_no_wordlists_provided(self, tmp_path): + ctx = _make_ctx() + ctx.hcatWordlists = str(tmp_path) + ctx._resolve_wordlist_path.side_effect = lambda p, base: p + with patch("builtins.input", side_effect=["n", ""]): + combinator3_crack(ctx) + ctx.hcatCombinator3.assert_not_called() + + def test_passes_exactly_3_wordlists(self, tmp_path): + ctx = _make_ctx() + ctx.hcatWordlists = str(tmp_path) + for name in ["a.txt", "b.txt", "c.txt"]: + (tmp_path / name).write_text("word\n") + ctx._resolve_wordlist_path.side_effect = lambda p, base: p + wl_arg = f"{tmp_path}/a.txt,{tmp_path}/b.txt,{tmp_path}/c.txt" + with patch("builtins.input", side_effect=["n", wl_arg]): + combinator3_crack(ctx) + call_args = ctx.hcatCombinator3.call_args + wordlists = call_args[0][2] if len(call_args[0]) >= 3 else call_args[1].get("wordlists") + assert len(wordlists) == 3 + + +class TestCombinatorXCrack: + def test_calls_hcatCombinatorX_with_wordlists(self, tmp_path): + ctx = _make_ctx() + ctx.hcatWordlists = str(tmp_path) + for name in ["a.txt", "b.txt"]: + (tmp_path / name).write_text("word\n") + ctx._resolve_wordlist_path.side_effect = lambda p, base: p + wl_arg = f"{tmp_path}/a.txt,{tmp_path}/b.txt" + with patch("builtins.input", side_effect=["n", wl_arg, ""]): + combinatorX_crack(ctx) + ctx.hcatCombinatorX.assert_called_once() + + def test_passes_separator_to_hcatCombinatorX(self, tmp_path): + ctx = _make_ctx() + ctx.hcatWordlists = str(tmp_path) + for name in ["a.txt", "b.txt"]: + (tmp_path / name).write_text("word\n") + ctx._resolve_wordlist_path.side_effect = lambda p, base: p + wl_arg = f"{tmp_path}/a.txt,{tmp_path}/b.txt" + with patch("builtins.input", side_effect=["n", wl_arg, "-"]): + combinatorX_crack(ctx) + call_args = ctx.hcatCombinatorX.call_args + # separator may be positional or keyword + positional_has_sep = len(call_args[0]) >= 4 and call_args[0][3] == "-" + keyword_has_sep = call_args[1].get("separator") == "-" + assert positional_has_sep or keyword_has_sep + + def test_aborts_with_fewer_than_2_wordlists(self, tmp_path): + ctx = _make_ctx() + ctx.hcatWordlists = str(tmp_path) + (tmp_path / "a.txt").write_text("word\n") + ctx._resolve_wordlist_path.side_effect = lambda p, base: p + wl_arg = f"{tmp_path}/a.txt" + with patch("builtins.input", side_effect=["n", wl_arg, ""]): + combinatorX_crack(ctx) + ctx.hcatCombinatorX.assert_not_called() + + def test_no_separator_when_empty_input(self, tmp_path): + ctx = _make_ctx() + ctx.hcatWordlists = str(tmp_path) + for name in ["a.txt", "b.txt"]: + (tmp_path / name).write_text("word\n") + ctx._resolve_wordlist_path.side_effect = lambda p, base: p + wl_arg = f"{tmp_path}/a.txt,{tmp_path}/b.txt" + with patch("builtins.input", side_effect=["n", wl_arg, ""]): + combinatorX_crack(ctx) + call_args = ctx.hcatCombinatorX.call_args + positional_sep = call_args[0][3] if len(call_args[0]) >= 4 else None + keyword_sep = call_args[1].get("separator") + # separator should be None or empty string when nothing entered + assert positional_sep in (None, "") and keyword_sep in (None, "") + + +class TestCombinatorSubmenuUpdated: + def test_submenu_has_combinator3_option(self): + ctx = _make_ctx() + with patch("hate_crack.attacks.combinator3_crack") as mock_c3, patch( + "hate_crack.attacks.interactive_menu", side_effect=["5", "99"] + ): + combinator_submenu(ctx) + mock_c3.assert_called_once_with(ctx) + + def test_submenu_has_combinatorX_option(self): + ctx = _make_ctx() + with patch("hate_crack.attacks.combinatorX_crack") as mock_cx, patch( + "hate_crack.attacks.interactive_menu", side_effect=["6", "99"] + ): + combinator_submenu(ctx) + mock_cx.assert_called_once_with(ctx) + + def test_submenu_items_include_new_attacks(self): + """Verify the submenu item list advertises options 5 and 6.""" + ctx = _make_ctx() + captured_items = [] + + def capture_menu(items, **kwargs): + captured_items.extend(items) + return "99" + + with patch("hate_crack.attacks.interactive_menu", side_effect=capture_menu): + combinator_submenu(ctx) + + keys = [item[0] for item in captured_items] + assert "5" in keys + assert "6" in keys diff --git a/tests/test_combinator_wrappers.py b/tests/test_combinator_wrappers.py new file mode 100644 index 0000000..3ace009 --- /dev/null +++ b/tests/test_combinator_wrappers.py @@ -0,0 +1,300 @@ +"""Tests for hcatCombinator3 and hcatCombinatorX hashcat wrapper functions.""" + +from unittest.mock import MagicMock, patch + +import pytest + + +def _make_mock_proc(wait_side_effect=None): + proc = MagicMock() + proc.stdout = MagicMock() + if wait_side_effect is not None: + proc.wait.side_effect = wait_side_effect + else: + proc.wait.return_value = None + proc.pid = 12345 + return proc + + +@pytest.fixture +def main_module(hc_module): + return hc_module._main + + +class TestHcatCombinator3: + def test_calls_combinator3_bin_with_three_files(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + wls = [] + for i in range(3): + p = str(tmp_path / f"w{i}.txt") + open(p, "w").close() + wls.append(p) + + combinator_proc = _make_mock_proc() + hashcat_proc = _make_mock_proc() + + def popen_side_effect(cmd, **kwargs): + if "combinator3" in str(cmd[0]): + return combinator_proc + return hashcat_proc + + with ( + patch.object(main_module, "hcatBin", "hashcat"), + patch.object(main_module, "hcatTuning", ""), + patch.object(main_module, "hcatPotfilePath", ""), + patch.object(main_module, "hcatWordlists", str(tmp_path)), + patch.object(main_module, "generate_session_id", return_value="sess123"), + patch.object(main_module, "lineCount", return_value=0), + patch("hate_crack.main.subprocess.Popen", side_effect=popen_side_effect) as mock_popen, + ): + main_module.hcatCombinator3("1000", hash_file, wls) + + calls = mock_popen.call_args_list + assert len(calls) == 2 + combinator_cmd = calls[0][0][0] + assert "combinator3" in combinator_cmd[0] + assert wls[0] in combinator_cmd + assert wls[1] in combinator_cmd + assert wls[2] in combinator_cmd + + def test_pipes_stdout_to_hashcat_stdin(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + wls = [] + for i in range(3): + p = str(tmp_path / f"w{i}.txt") + open(p, "w").close() + wls.append(p) + + combinator_proc = _make_mock_proc() + hashcat_proc = _make_mock_proc() + + def popen_side_effect(cmd, **kwargs): + if "combinator3" in str(cmd[0]): + return combinator_proc + return hashcat_proc + + with ( + patch.object(main_module, "hcatBin", "hashcat"), + patch.object(main_module, "hcatTuning", ""), + patch.object(main_module, "hcatPotfilePath", ""), + patch.object(main_module, "hcatWordlists", str(tmp_path)), + patch.object(main_module, "generate_session_id", return_value="sess123"), + patch.object(main_module, "lineCount", return_value=0), + patch("hate_crack.main.subprocess.Popen", side_effect=popen_side_effect) as mock_popen, + ): + main_module.hcatCombinator3("1000", hash_file, wls) + + calls = mock_popen.call_args_list + hashcat_call_kwargs = calls[1][1] + assert hashcat_call_kwargs.get("stdin") == combinator_proc.stdout + + def test_aborts_with_fewer_than_3_wordlists(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + wl1 = str(tmp_path / "w1.txt") + wl2 = str(tmp_path / "w2.txt") + for p in [wl1, wl2]: + open(p, "w").close() + + with patch("hate_crack.main.subprocess.Popen") as mock_popen: + main_module.hcatCombinator3("1000", hash_file, [wl1, wl2]) + + mock_popen.assert_not_called() + + def test_keyboard_interrupt_kills_both_processes(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + wls = [] + for i in range(3): + p = str(tmp_path / f"w{i}.txt") + open(p, "w").close() + wls.append(p) + + combinator_proc = _make_mock_proc() + hashcat_proc = _make_mock_proc(wait_side_effect=KeyboardInterrupt()) + + def popen_side_effect(cmd, **kwargs): + if "combinator3" in str(cmd[0]): + return combinator_proc + return hashcat_proc + + with ( + patch.object(main_module, "hcatBin", "hashcat"), + patch.object(main_module, "hcatTuning", ""), + patch.object(main_module, "hcatPotfilePath", ""), + patch.object(main_module, "hcatWordlists", str(tmp_path)), + patch.object(main_module, "generate_session_id", return_value="sess123"), + patch.object(main_module, "lineCount", return_value=0), + patch("hate_crack.main.subprocess.Popen", side_effect=popen_side_effect), + ): + main_module.hcatCombinator3("1000", hash_file, wls) + + hashcat_proc.kill.assert_called_once() + combinator_proc.kill.assert_called_once() + + +class TestHcatCombinatorX: + def test_calls_combinatorX_bin_with_file_flags(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + wls = [] + for i in range(2): + p = str(tmp_path / f"w{i}.txt") + open(p, "w").close() + wls.append(p) + + combinator_proc = _make_mock_proc() + hashcat_proc = _make_mock_proc() + + def popen_side_effect(cmd, **kwargs): + if "combinatorX" in str(cmd[0]): + return combinator_proc + return hashcat_proc + + with ( + patch.object(main_module, "hcatBin", "hashcat"), + patch.object(main_module, "hcatTuning", ""), + patch.object(main_module, "hcatPotfilePath", ""), + patch.object(main_module, "hcatWordlists", str(tmp_path)), + patch.object(main_module, "generate_session_id", return_value="sess123"), + patch.object(main_module, "lineCount", return_value=0), + patch("hate_crack.main.subprocess.Popen", side_effect=popen_side_effect) as mock_popen, + ): + main_module.hcatCombinatorX("1000", hash_file, wls) + + calls = mock_popen.call_args_list + assert len(calls) == 2 + combinator_cmd = calls[0][0][0] + assert "combinatorX" in combinator_cmd[0] + assert "--file1" in combinator_cmd + assert "--file2" in combinator_cmd + + def test_passes_sepfill_when_separator_given(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + wls = [] + for i in range(2): + p = str(tmp_path / f"w{i}.txt") + open(p, "w").close() + wls.append(p) + + combinator_proc = _make_mock_proc() + hashcat_proc = _make_mock_proc() + + def popen_side_effect(cmd, **kwargs): + if "combinatorX" in str(cmd[0]): + return combinator_proc + return hashcat_proc + + with ( + patch.object(main_module, "hcatBin", "hashcat"), + patch.object(main_module, "hcatTuning", ""), + patch.object(main_module, "hcatPotfilePath", ""), + patch.object(main_module, "hcatWordlists", str(tmp_path)), + patch.object(main_module, "generate_session_id", return_value="sess123"), + patch.object(main_module, "lineCount", return_value=0), + patch("hate_crack.main.subprocess.Popen", side_effect=popen_side_effect) as mock_popen, + ): + main_module.hcatCombinatorX("1000", hash_file, wls, separator="-") + + combinator_cmd = mock_popen.call_args_list[0][0][0] + assert "--sepFill" in combinator_cmd + sep_idx = combinator_cmd.index("--sepFill") + assert combinator_cmd[sep_idx + 1] == "-" + + def test_no_sepfill_when_separator_is_none(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + wls = [] + for i in range(2): + p = str(tmp_path / f"w{i}.txt") + open(p, "w").close() + wls.append(p) + + combinator_proc = _make_mock_proc() + hashcat_proc = _make_mock_proc() + + def popen_side_effect(cmd, **kwargs): + if "combinatorX" in str(cmd[0]): + return combinator_proc + return hashcat_proc + + with ( + patch.object(main_module, "hcatBin", "hashcat"), + patch.object(main_module, "hcatTuning", ""), + patch.object(main_module, "hcatPotfilePath", ""), + patch.object(main_module, "hcatWordlists", str(tmp_path)), + patch.object(main_module, "generate_session_id", return_value="sess123"), + patch.object(main_module, "lineCount", return_value=0), + patch("hate_crack.main.subprocess.Popen", side_effect=popen_side_effect) as mock_popen, + ): + main_module.hcatCombinatorX("1000", hash_file, wls, separator=None) + + combinator_cmd = mock_popen.call_args_list[0][0][0] + assert "--sepFill" not in combinator_cmd + + def test_aborts_with_fewer_than_2_wordlists(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + wl1 = str(tmp_path / "w1.txt") + open(wl1, "w").close() + + with patch("hate_crack.main.subprocess.Popen") as mock_popen: + main_module.hcatCombinatorX("1000", hash_file, [wl1]) + + mock_popen.assert_not_called() + + def test_supports_up_to_8_wordlists(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + wls = [] + for i in range(8): + p = str(tmp_path / f"w{i}.txt") + open(p, "w").close() + wls.append(p) + + combinator_proc = _make_mock_proc() + hashcat_proc = _make_mock_proc() + + def popen_side_effect(cmd, **kwargs): + if "combinatorX" in str(cmd[0]): + return combinator_proc + return hashcat_proc + + with ( + patch.object(main_module, "hcatBin", "hashcat"), + patch.object(main_module, "hcatTuning", ""), + patch.object(main_module, "hcatPotfilePath", ""), + patch.object(main_module, "hcatWordlists", str(tmp_path)), + patch.object(main_module, "generate_session_id", return_value="sess123"), + patch.object(main_module, "lineCount", return_value=0), + patch("hate_crack.main.subprocess.Popen", side_effect=popen_side_effect) as mock_popen, + ): + main_module.hcatCombinatorX("1000", hash_file, wls) + + combinator_cmd = mock_popen.call_args_list[0][0][0] + for i in range(1, 9): + assert f"--file{i}" in combinator_cmd + + def test_keyboard_interrupt_kills_both_processes(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + wls = [] + for i in range(2): + p = str(tmp_path / f"w{i}.txt") + open(p, "w").close() + wls.append(p) + + combinator_proc = _make_mock_proc() + hashcat_proc = _make_mock_proc(wait_side_effect=KeyboardInterrupt()) + + def popen_side_effect(cmd, **kwargs): + if "combinatorX" in str(cmd[0]): + return combinator_proc + return hashcat_proc + + with ( + patch.object(main_module, "hcatBin", "hashcat"), + patch.object(main_module, "hcatTuning", ""), + patch.object(main_module, "hcatPotfilePath", ""), + patch.object(main_module, "hcatWordlists", str(tmp_path)), + patch.object(main_module, "generate_session_id", return_value="sess123"), + patch.object(main_module, "lineCount", return_value=0), + patch("hate_crack.main.subprocess.Popen", side_effect=popen_side_effect), + ): + main_module.hcatCombinatorX("1000", hash_file, wls) + + hashcat_proc.kill.assert_called_once() + combinator_proc.kill.assert_called_once() From 2d77d8e5828fcd91a7acc32711b319aa04598603 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 19 Mar 2026 12:17:26 -0400 Subject: [PATCH 04/14] feat: add combipow passphrase attack (#88) Add menu option 21 (Combipow Passphrase Attack) using combipow.bin from hashcat-utils. Generates all unique non-empty subset combinations from a short wordlist and pipes them into hashcat stdin. - hcatCombipow(hash_type, hash_file, wordlist, use_space_sep) in main.py - combipow_crack handler in attacks.py validates file, enforces 63-line limit, prompts for space separator, then delegates to hcatCombipow - Wired into get_main_menu_items() and get_main_menu_options() in both main.py and hate_crack.py - 12 tests covering menu presence, handler delegation, line-count enforcement, file validation, and subprocess flag construction --- README.md | 10 ++ hate_crack.py | 1 + hate_crack/attacks.py | 25 ++++ hate_crack/main.py | 45 +++++++ tests/test_combipow_attack.py | 231 ++++++++++++++++++++++++++++++++++ tests/test_ui_menu_options.py | 1 + 6 files changed, 313 insertions(+) create mode 100644 tests/test_combipow_attack.py diff --git a/README.md b/README.md index 0a69f8b..702cbb9 100644 --- a/README.md +++ b/README.md @@ -618,6 +618,7 @@ All tests use mocked API calls, so they can run without connectivity to a Hashvi (16) OMEN Attack (17) Ad-hoc Mask Attack (18) Markov Brute Force Attack + (21) Combipow Passphrase Attack (90) Download rules from Hashmob.net (91) Analyze Hashcat Rules @@ -789,6 +790,15 @@ Generates password candidates using Markov chain statistical models. Similar to * Markov table persists with hash file (filename.out.hcstat2) for fast subsequent runs * Faster than OMEN for general-purpose brute forcing +#### Combipow Passphrase Attack +Generates all unique non-empty subset combinations from a short wordlist using `combipow.bin` and pipes them into hashcat. Designed for passphrase cracking when you know the pool of words a password was built from. + +* Prompts for a wordlist file (max 63 lines - combipow generates up to 2^n-1 combinations) +* Optional space separator (`-s` flag) to insert spaces between words in each combination +* Warns if the wordlist exceeds 20 lines (output volume may be large) +* Aborts with a clear message if the wordlist exceeds 63 lines (hard limit) +* Candidates are piped directly to hashcat stdin + #### Download Rules from Hashmob.net Downloads the latest rule files from Hashmob.net's rule repository. These rules are curated and optimized for password cracking and can be used with the Quick Crack and Loopback Attack modes. diff --git a/hate_crack.py b/hate_crack.py index de0124b..e292a7b 100755 --- a/hate_crack.py +++ b/hate_crack.py @@ -88,6 +88,7 @@ def get_main_menu_options(): "16": _attacks.omen_attack, "17": _attacks.adhoc_mask_crack, "18": _attacks.markov_brute_force, + "21": _attacks.combipow_crack, "90": download_hashmob_rules, "91": weakpass_wordlist_menu, "92": download_hashmob_wordlists, diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index d06e250..e8d70c4 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -620,6 +620,31 @@ def markov_brute_force(ctx: Any) -> None: ctx.hcatMarkovBruteForce(ctx.hcatHashType, ctx.hcatHashFile, hcatMinLen, hcatMaxLen) +def combipow_crack(ctx: Any) -> None: + wordlist = None + while wordlist is None: + path = input("\n[*] Enter path to wordlist (max 63 lines recommended): ").strip() + if not path: + continue + if not os.path.isfile(path): + print(f"[!] File not found: {path}") + continue + with open(path) as fh: + line_count = sum(1 for _ in fh) + if line_count > 63: + print( + f"[!] Wordlist has {line_count} lines (max 63). combipow generates 2^n-1 combinations." + ) + return + if line_count > 20: + print( + f"[*] Warning: {line_count} lines will generate a large number of combinations." + ) + wordlist = path + use_space_sep = input("[*] Add spaces between words? (Y/n): ").strip().lower() != "n" + ctx.hcatCombipow(ctx.hcatHashType, ctx.hcatHashFile, wordlist, use_space_sep) + + def combinator_submenu(ctx: Any) -> None: from hate_crack.menu import interactive_menu diff --git a/hate_crack/main.py b/hate_crack/main.py index e36bc14..6536a1d 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -2174,6 +2174,45 @@ def hcatMarkovBruteForce(hcatHashType, hcatHashFile, hcatMinLen, hcatMaxLen): hcatProcess.kill() +# Combipow Passphrase Attack +hcatCombipowCount = 0 + + +def hcatCombipow(hcatHashType, hcatHashFile, wordlist, use_space_sep=True): + global hcatProcess, hcatCombipowCount + hcatCombipowCount += 1 + combipow_bin = os.path.join(hate_path, "hashcat-utils/bin/combipow.bin") + generator_cmd = [combipow_bin] + if use_space_sep: + generator_cmd.append("-s") + generator_cmd.append(wordlist) + session_name = re.sub( + r"[^a-zA-Z0-9_-]", "_", os.path.splitext(os.path.basename(hcatHashFile))[0] + ) + hashcat_cmd = [ + hcatBin, + "--session", + session_name, + "-m", + hcatHashType, + hcatHashFile, + "-o", + f"{hcatHashFile}.out", + ] + hashcat_cmd.extend(shlex.split(hcatTuning)) + _append_potfile_arg(hashcat_cmd) + generator_proc = subprocess.Popen(generator_cmd, stdout=subprocess.PIPE) + hcatProcess = subprocess.Popen(hashcat_cmd, stdin=generator_proc.stdout) + generator_proc.stdout.close() + try: + hcatProcess.wait() + generator_proc.wait() + except KeyboardInterrupt: + print("Killing PID {0}...".format(str(hcatProcess.pid))) + hcatProcess.kill() + generator_proc.kill() + + # PRINCE Attack def hcatPrince(hcatHashType, hcatHashFile): global hcatProcess @@ -3329,6 +3368,10 @@ def omen_attack(): return _attacks.omen_attack(_attack_ctx()) +def combipow_crack(): + return _attacks.combipow_crack(_attack_ctx()) + + # convert hex words for recycling def convert_hex(working_file): processed_words = [] @@ -3557,6 +3600,7 @@ def get_main_menu_items(): ("16", "OMEN Attack"), ("17", "Ad-hoc Mask Attack"), ("18", "Markov Brute Force Attack"), + ("21", "Combipow Passphrase Attack"), ("90", "Download rules from Hashmob.net"), ("91", "Analyze Hashcat Rules"), ("92", "Download wordlists from Hashmob.net"), @@ -3594,6 +3638,7 @@ def get_main_menu_options(): "16": omen_attack, "17": adhoc_mask_crack, "18": markov_brute_force, + "21": combipow_crack, "90": lambda: download_hashmob_rules(rules_dir=rulesDirectory), "91": analyze_rules, "92": download_hashmob_wordlists, diff --git a/tests/test_combipow_attack.py b/tests/test_combipow_attack.py new file mode 100644 index 0000000..d4bf54f --- /dev/null +++ b/tests/test_combipow_attack.py @@ -0,0 +1,231 @@ +import importlib +import importlib.util +import os +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +os.environ["HATE_CRACK_SKIP_INIT"] = "1" + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +def _load_attacks(): + """Import hate_crack.attacks with SKIP_INIT set.""" + for key in list(sys.modules.keys()): + if "hate_crack" in key: + del sys.modules[key] + if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + import hate_crack.attacks as attacks # noqa: PLC0415 + + return attacks + + +def _load_cli(): + for key in list(sys.modules.keys()): + if "hate_crack" in key: + del sys.modules[key] + spec = importlib.util.spec_from_file_location( + "hate_crack_cli", PROJECT_ROOT / "hate_crack.py" + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture +def cli(): + return _load_cli() + + +def _make_ctx(hash_type="1000", hash_file="/tmp/hashes.txt"): + ctx = MagicMock() + ctx.hcatHashType = hash_type + ctx.hcatHashFile = hash_file + return ctx + + +# --- Menu presence tests --- + +def test_combipow_crack_in_main_menu(cli): + options = cli.get_main_menu_options() + assert "21" in options + + +def test_combipow_crack_menu_item_label(): + cli = _load_cli() + items = cli.get_main_menu_items() + keys = [k for k, _ in items] + assert "21" in keys + labels = {k: label for k, label in items} + assert "passphrase" in labels["21"].lower() or "combipow" in labels["21"].lower() + + +# --- combipow_crack handler tests --- + +class TestCombipowCrack: + def test_calls_hcatCombipow_with_space_sep_by_default(self, tmp_path): + attacks = _load_attacks() + ctx = _make_ctx() + wl = tmp_path / "words.txt" + wl.write_text("correct\nhorse\nbattery\n") + with patch("builtins.input", side_effect=[str(wl), ""]): + attacks.combipow_crack(ctx) + ctx.hcatCombipow.assert_called_once() + call_args = ctx.hcatCombipow.call_args + use_space = ( + call_args[0][3] if len(call_args[0]) > 3 else call_args[1].get("use_space_sep") + ) + assert use_space is True + + def test_calls_hcatCombipow_without_space_sep(self, tmp_path): + attacks = _load_attacks() + ctx = _make_ctx() + wl = tmp_path / "words.txt" + wl.write_text("correct\nhorse\nbattery\n") + with patch("builtins.input", side_effect=[str(wl), "n"]): + attacks.combipow_crack(ctx) + ctx.hcatCombipow.assert_called_once() + call_args = ctx.hcatCombipow.call_args + use_space = ( + call_args[0][3] if len(call_args[0]) > 3 else call_args[1].get("use_space_sep") + ) + assert use_space is False + + def test_rejects_more_than_63_lines(self, tmp_path): + attacks = _load_attacks() + ctx = _make_ctx() + wl = tmp_path / "words.txt" + wl.write_text("\n".join(f"word{i}" for i in range(64)) + "\n") + with patch("builtins.input", return_value=str(wl)): + attacks.combipow_crack(ctx) + ctx.hcatCombipow.assert_not_called() + + def test_accepts_exactly_63_lines(self, tmp_path): + attacks = _load_attacks() + ctx = _make_ctx() + wl = tmp_path / "words.txt" + wl.write_text("\n".join(f"word{i}" for i in range(63)) + "\n") + with patch("builtins.input", side_effect=[str(wl), ""]): + attacks.combipow_crack(ctx) + ctx.hcatCombipow.assert_called_once() + + def test_rejects_nonexistent_file(self, tmp_path): + attacks = _load_attacks() + ctx = _make_ctx() + wl = tmp_path / "words.txt" + wl.write_text("test\n") + with patch("builtins.input", side_effect=["/nonexistent.txt", str(wl), ""]): + attacks.combipow_crack(ctx) + ctx.hcatCombipow.assert_called_once() + + def test_passes_correct_hash_type_and_file(self, tmp_path): + attacks = _load_attacks() + ctx = _make_ctx(hash_type="3200", hash_file="/tmp/bcrypt.txt") + wl = tmp_path / "words.txt" + wl.write_text("word1\nword2\n") + with patch("builtins.input", side_effect=[str(wl), ""]): + attacks.combipow_crack(ctx) + ctx.hcatCombipow.assert_called_once() + args = ctx.hcatCombipow.call_args[0] + assert args[0] == "3200" + assert args[1] == "/tmp/bcrypt.txt" + assert args[2] == str(wl) + + def test_warns_about_large_word_count(self, tmp_path, capsys): + attacks = _load_attacks() + ctx = _make_ctx() + wl = tmp_path / "words.txt" + wl.write_text("\n".join(f"word{i}" for i in range(31)) + "\n") + with patch("builtins.input", side_effect=[str(wl), ""]): + attacks.combipow_crack(ctx) + captured = capsys.readouterr() + assert "large" in captured.out.lower() or "warning" in captured.out.lower() + + +# --- hcatCombipow wrapper tests --- + +class TestHcatCombipow: + def _setup_module(self, tmp_path): + """Return main module with hate_path and hcatBin patched.""" + for key in list(sys.modules.keys()): + if "hate_crack" in key: + del sys.modules[key] + if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + import hate_crack.main as m # noqa: PLC0415 + + m.hate_path = str(tmp_path) + m.hcatBin = "hashcat" + m.hcatTuning = "" + m.hcatHashCracked = 0 + m.hcatHashFile = str(tmp_path / "hashes.txt") + + combipow_bin = tmp_path / "hashcat-utils" / "bin" / "combipow.bin" + combipow_bin.parent.mkdir(parents=True, exist_ok=True) + combipow_bin.touch() + + return m + + def test_includes_s_flag_when_use_space_sep_true(self, tmp_path): + m = self._setup_module(tmp_path) + wl = tmp_path / "words.txt" + wl.write_text("word1\nword2\n") + + fake_combipow = MagicMock() + fake_combipow.stdout = MagicMock() + fake_hashcat = MagicMock() + fake_hashcat.pid = 9999 + + out_file = tmp_path / "hashes.txt.out" + out_file.write_text("") + + with patch("subprocess.Popen", side_effect=[fake_combipow, fake_hashcat]) as mock_popen: + with patch.object(m, "lineCount", return_value=0): + m.hcatCombipow("1000", str(tmp_path / "hashes.txt"), str(wl), use_space_sep=True) + + first_call_args = mock_popen.call_args_list[0][0][0] + assert "-s" in first_call_args + + def test_omits_s_flag_when_use_space_sep_false(self, tmp_path): + m = self._setup_module(tmp_path) + wl = tmp_path / "words.txt" + wl.write_text("word1\nword2\n") + + fake_combipow = MagicMock() + fake_combipow.stdout = MagicMock() + fake_hashcat = MagicMock() + fake_hashcat.pid = 9999 + + out_file = tmp_path / "hashes.txt.out" + out_file.write_text("") + + with patch("subprocess.Popen", side_effect=[fake_combipow, fake_hashcat]) as mock_popen: + with patch.object(m, "lineCount", return_value=0): + m.hcatCombipow("1000", str(tmp_path / "hashes.txt"), str(wl), use_space_sep=False) + + first_call_args = mock_popen.call_args_list[0][0][0] + assert "-s" not in first_call_args + + def test_wordlist_passed_as_argument(self, tmp_path): + m = self._setup_module(tmp_path) + wl = tmp_path / "words.txt" + wl.write_text("word1\nword2\n") + + fake_combipow = MagicMock() + fake_combipow.stdout = MagicMock() + fake_hashcat = MagicMock() + fake_hashcat.pid = 9999 + + out_file = tmp_path / "hashes.txt.out" + out_file.write_text("") + + with patch("subprocess.Popen", side_effect=[fake_combipow, fake_hashcat]) as mock_popen: + with patch.object(m, "lineCount", return_value=0): + m.hcatCombipow("1000", str(tmp_path / "hashes.txt"), str(wl), use_space_sep=True) + + first_call_args = mock_popen.call_args_list[0][0][0] + assert str(wl) in first_call_args diff --git a/tests/test_ui_menu_options.py b/tests/test_ui_menu_options.py index fd3bfa6..f494f7c 100644 --- a/tests/test_ui_menu_options.py +++ b/tests/test_ui_menu_options.py @@ -26,6 +26,7 @@ MENU_OPTION_TEST_CASES = [ ("16", CLI_MODULE._attacks, "omen_attack", "omen"), ("17", CLI_MODULE._attacks, "adhoc_mask_crack", "adhoc-mask"), ("18", CLI_MODULE._attacks, "markov_brute_force", "markov-brute"), + ("21", CLI_MODULE._attacks, "combipow_crack", "combipow"), ("90", CLI_MODULE, "download_hashmob_rules", "hashmob-rules"), ("91", CLI_MODULE, "weakpass_wordlist_menu", "weakpass-menu"), ("92", CLI_MODULE, "download_hashmob_wordlists", "hashmob-wordlists"), From 9345e69bf4b8328422dff1d763fed9b6147682a0 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 19 Mar 2026 12:20:37 -0400 Subject: [PATCH 05/14] fix: ensure hashcat.induct exists before loopback test Hashcat renames ~/.hashcat/sessions/hashcat.induct after each session. When the directory is absent the loopback test fails with "No such file or directory". Recreate the directory in the test setup so it always exists when the loopback command runs. --- tests/test_hashcat_rules.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_hashcat_rules.py b/tests/test_hashcat_rules.py index 1a2636c..ebaa3a2 100644 --- a/tests/test_hashcat_rules.py +++ b/tests/test_hashcat_rules.py @@ -122,6 +122,10 @@ def test_toggle_rule_parses_with_and_without_loopback(tmp_path: Path, capsys): pytest.skip("hashcat not available in PATH") if not _hashcat_sessions_writable(): pytest.skip("hashcat session directory (~/.hashcat/sessions) is not writable") + # Hashcat renames hashcat.induct after each run; recreate so loopback can write. + (Path.home() / ".hashcat" / "sessions" / "hashcat.induct").mkdir( + parents=True, exist_ok=True + ) show_output = os.environ.get("HATE_CRACK_SHOW_HASHCAT_OUTPUT") == "1" show_cmd = ( From a9a533e41ec689adca3898d473eed5001d786b76 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 19 Mar 2026 12:22:29 -0400 Subject: [PATCH 06/14] refactor: improve TestHcatCombipow to use patch.object context managers Replace direct module attribute mutation with patch.object for cleaner test isolation. Use hate_crack.main.subprocess.Popen for targeted mocking instead of the global subprocess.Popen. --- tests/test_combipow_attack.py | 88 +++++++++++------------------------ 1 file changed, 27 insertions(+), 61 deletions(-) diff --git a/tests/test_combipow_attack.py b/tests/test_combipow_attack.py index d4bf54f..8700f98 100644 --- a/tests/test_combipow_attack.py +++ b/tests/test_combipow_attack.py @@ -14,9 +14,6 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1] def _load_attacks(): """Import hate_crack.attacks with SKIP_INIT set.""" - for key in list(sys.modules.keys()): - if "hate_crack" in key: - del sys.modules[key] if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) import hate_crack.attacks as attacks # noqa: PLC0415 @@ -25,9 +22,6 @@ def _load_attacks(): def _load_cli(): - for key in list(sys.modules.keys()): - if "hate_crack" in key: - del sys.modules[key] spec = importlib.util.spec_from_file_location( "hate_crack_cli", PROJECT_ROOT / "hate_crack.py" ) @@ -148,84 +142,56 @@ class TestCombipowCrack: # --- hcatCombipow wrapper tests --- +def _get_main_module(): + if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + import hate_crack.main as m # noqa: PLC0415 + + return m + + class TestHcatCombipow: - def _setup_module(self, tmp_path): - """Return main module with hate_path and hcatBin patched.""" - for key in list(sys.modules.keys()): - if "hate_crack" in key: - del sys.modules[key] - if str(PROJECT_ROOT) not in sys.path: - sys.path.insert(0, str(PROJECT_ROOT)) - import hate_crack.main as m # noqa: PLC0415 - - m.hate_path = str(tmp_path) - m.hcatBin = "hashcat" - m.hcatTuning = "" - m.hcatHashCracked = 0 - m.hcatHashFile = str(tmp_path / "hashes.txt") - + def _run(self, tmp_path, wl, use_space_sep): + """Run hcatCombipow with module globals patched via context managers.""" + m = _get_main_module() + hash_file = str(tmp_path / "hashes.txt") combipow_bin = tmp_path / "hashcat-utils" / "bin" / "combipow.bin" combipow_bin.parent.mkdir(parents=True, exist_ok=True) combipow_bin.touch() - return m - - def test_includes_s_flag_when_use_space_sep_true(self, tmp_path): - m = self._setup_module(tmp_path) - wl = tmp_path / "words.txt" - wl.write_text("word1\nword2\n") - fake_combipow = MagicMock() fake_combipow.stdout = MagicMock() fake_hashcat = MagicMock() fake_hashcat.pid = 9999 - out_file = tmp_path / "hashes.txt.out" - out_file.write_text("") + with ( + patch.object(m, "hate_path", str(tmp_path)), + patch.object(m, "hcatBin", "hashcat"), + patch.object(m, "hcatTuning", ""), + patch("hate_crack.main.hcatHashFile", hash_file, create=True), + patch("hate_crack.main.subprocess.Popen", side_effect=[fake_combipow, fake_hashcat]) as mock_popen, + ): + m.hcatCombipow("1000", hash_file, str(wl), use_space_sep=use_space_sep) - with patch("subprocess.Popen", side_effect=[fake_combipow, fake_hashcat]) as mock_popen: - with patch.object(m, "lineCount", return_value=0): - m.hcatCombipow("1000", str(tmp_path / "hashes.txt"), str(wl), use_space_sep=True) + return mock_popen + def test_includes_s_flag_when_use_space_sep_true(self, tmp_path): + wl = tmp_path / "words.txt" + wl.write_text("word1\nword2\n") + mock_popen = self._run(tmp_path, wl, use_space_sep=True) first_call_args = mock_popen.call_args_list[0][0][0] assert "-s" in first_call_args def test_omits_s_flag_when_use_space_sep_false(self, tmp_path): - m = self._setup_module(tmp_path) wl = tmp_path / "words.txt" wl.write_text("word1\nword2\n") - - fake_combipow = MagicMock() - fake_combipow.stdout = MagicMock() - fake_hashcat = MagicMock() - fake_hashcat.pid = 9999 - - out_file = tmp_path / "hashes.txt.out" - out_file.write_text("") - - with patch("subprocess.Popen", side_effect=[fake_combipow, fake_hashcat]) as mock_popen: - with patch.object(m, "lineCount", return_value=0): - m.hcatCombipow("1000", str(tmp_path / "hashes.txt"), str(wl), use_space_sep=False) - + mock_popen = self._run(tmp_path, wl, use_space_sep=False) first_call_args = mock_popen.call_args_list[0][0][0] assert "-s" not in first_call_args def test_wordlist_passed_as_argument(self, tmp_path): - m = self._setup_module(tmp_path) wl = tmp_path / "words.txt" wl.write_text("word1\nword2\n") - - fake_combipow = MagicMock() - fake_combipow.stdout = MagicMock() - fake_hashcat = MagicMock() - fake_hashcat.pid = 9999 - - out_file = tmp_path / "hashes.txt.out" - out_file.write_text("") - - with patch("subprocess.Popen", side_effect=[fake_combipow, fake_hashcat]) as mock_popen: - with patch.object(m, "lineCount", return_value=0): - m.hcatCombipow("1000", str(tmp_path / "hashes.txt"), str(wl), use_space_sep=True) - + mock_popen = self._run(tmp_path, wl, use_space_sep=True) first_call_args = mock_popen.call_args_list[0][0][0] assert str(wl) in first_call_args From 7a7401266b5812a44ce567adb1d2e4d16dbd337b Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 19 Mar 2026 14:18:25 -0400 Subject: [PATCH 07/14] feat: unify combinator attacks into single 2-8 wordlist handler - Merge combinator, combinator3, and combinatorX into one unified combinator_crack function that routes by wordlist count: 2 (no sep) -> hcatCombination, 3 (no sep) -> hcatCombinator3, 4+ or any separator -> hcatCombinatorX - Replace comma-separated wordlist input with one-at-a-time tab-autocomplete prompts (blank line to finish) - Add _prompt_wordlist_paths helper using existing readline infrastructure - Add hcatCombinator3Wordlist and hcatCombinatorXWordlist config vars with rockyou.txt defaults - Print full hashcat command to stdout in --debug mode by calling _debug_cmd at the end of _append_potfile_arg (covers all 27 invocations) - Collapse combinator submenu from 6 options to 4; keep combinator3_crack, combinatorX_crack, and combinator_3plus_crack as delegation shims - Update tests to cover unified routing and new prompt interface --- config.json.example | 2 + hate_crack/attacks.py | 226 ++++++++------------------ hate_crack/main.py | 34 ++-- tests/test_attacks_behavior.py | 9 +- tests/test_combinator3_combinatorX.py | 164 ++++++++++--------- 5 files changed, 181 insertions(+), 254 deletions(-) diff --git a/config.json.example b/config.json.example index a2804ca..5431016 100644 --- a/config.json.example +++ b/config.json.example @@ -8,6 +8,8 @@ "rules_directory": "./hashcat/rules", "hcatDictionaryWordlist": ["rockyou.txt"], "hcatCombinationWordlist": ["rockyou.txt","rockyou.txt"], + "hcatCombinator3Wordlist": ["rockyou.txt","rockyou.txt","rockyou.txt"], + "hcatCombinatorXWordlist": ["rockyou.txt","rockyou.txt"], "hcatHybridlist": ["rockyou.txt"], "hcatMiddleCombinatorMasks": ["2","4"," ","-","_","+",",",".","&"], "hcatMiddleBaseList": "rockyou.txt", diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index 7c38c62..bbd40d4 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -262,8 +262,7 @@ def combinator_crack(ctx: Any) -> None: print("\n" + "=" * 60) print("COMBINATOR ATTACK") print("=" * 60) - print("This attack combines two wordlists to generate candidates.") - print("Example: wordlist1='password' + wordlist2='123' = 'password123'") + print("Combines 2-8 wordlists. 2 uses hashcat native mode; 3+ use external binaries.") print("=" * 60) use_default = ( @@ -271,73 +270,30 @@ def combinator_crack(ctx: Any) -> None: ) if use_default != "n": - print("\nUsing default wordlist(s) from config:") - if isinstance(ctx.hcatCombinationWordlist, list): - for wl in ctx.hcatCombinationWordlist: - print(f" - {wl}") - wordlists = ctx.hcatCombinationWordlist - else: - print(f" - {ctx.hcatCombinationWordlist}") - wordlists = [ctx.hcatCombinationWordlist] - else: - print("\nSelect wordlists for combinator attack.") - print("You need to provide exactly 2 wordlists.") - print("You can enter:") - print(" - Two file paths separated by commas") - print(" - Press TAB to autocomplete file paths") - - selection = ctx.select_file_with_autocomplete( - "Enter 2 wordlist files (comma-separated)", - allow_multiple=True, - base_dir=ctx.hcatWordlists, - ) - - if not selection: - print("No wordlists selected. Aborting combinator attack.") + base = ctx.hcatCombinationWordlist + wordlists = base if isinstance(base, list) else [base] + wordlists = [ctx._resolve_wordlist_path(wl, ctx.hcatWordlists) for wl in wordlists] + if len(wordlists) < 2: + print("\n[!] Config does not have at least 2 wordlists.") + print("Set hcatCombinationWordlist to a list of 2+ paths in config.json.") + print("Aborting combinator attack.") return - - if isinstance(selection, str): - wordlists = [selection] - else: - wordlists = selection - + separator = "" + else: + print("\nEnter 2-8 wordlists. Enter a blank line when done.") + wordlists = _prompt_wordlist_paths(ctx, max_count=8) if len(wordlists) < 2: print("\n[!] Combinator attack requires at least 2 wordlists.") print("Aborting combinator attack.") return + separator = input("\nEnter separator between words (leave blank for none): ").strip() - valid_wordlists = [] - for wl in wordlists[:2]: # Only use first 2 - resolved = ctx._resolve_wordlist_path(wl, ctx.hcatWordlists) - if os.path.isfile(resolved): - valid_wordlists.append(resolved) - print(f"✓ Found: {resolved}") - else: - print(f"✗ Not found: {resolved}") - - if len(valid_wordlists) < 2: - print("\nCould not find 2 valid wordlists. Aborting combinator attack.") - return - - wordlists = valid_wordlists - - wordlists = [ - ctx._resolve_wordlist_path(wl, ctx.hcatWordlists) for wl in wordlists[:2] - ] - - if len(wordlists) < 2: - print("\n[!] Combinator attack requires 2 wordlists but only 1 is configured.") - print("Set hcatCombinationWordlist to a list of 2 paths in config.json.") - print("Aborting combinator attack.") - return - - print("\nStarting combinator attack with 2 wordlists:") - print(f" Wordlist 1: {wordlists[0]}") - print(f" Wordlist 2: {wordlists[1]}") - print(f"Hash type: {ctx.hcatHashType}") - print(f"Hash file: {ctx.hcatHashFile}") - - ctx.hcatCombination(ctx.hcatHashType, ctx.hcatHashFile, wordlists) + if len(wordlists) == 2 and not separator: + ctx.hcatCombination(ctx.hcatHashType, ctx.hcatHashFile, wordlists) + elif len(wordlists) == 3 and not separator: + ctx.hcatCombinator3(ctx.hcatHashType, ctx.hcatHashFile, wordlists) + else: + ctx.hcatCombinatorX(ctx.hcatHashType, ctx.hcatHashFile, wordlists, separator or None) def hybrid_crack(ctx: Any) -> None: @@ -428,108 +384,64 @@ def middle_combinator(ctx: Any) -> None: ctx.hcatMiddleCombinator(ctx.hcatHashType, ctx.hcatHashFile) -def combinator3_crack(ctx: Any) -> None: - print("\n" + "=" * 60) - print("COMBINATOR3 ATTACK") - print("=" * 60) - print("This attack combines three wordlists to generate candidates.") - print("=" * 60) +def _prompt_wordlist_paths(ctx, max_count: int) -> list[str]: + """Prompt for wordlist paths one at a time with tab-autocomplete. - use_default = ( - input("\nUse default combinator wordlists from config? (Y/n): ").strip().lower() - ) + Stops when a blank line is entered or max_count paths have been collected. + Returns a list of resolved, valid file paths. + """ - if use_default != "n": - base = ctx.hcatCombinationWordlist - wordlists = base if isinstance(base, list) else [base] - if len(wordlists) < 3: - print("\n[!] Config does not have 3 wordlists for combinator3.") - print("Set hcatCombinationWordlist to a list of 3 paths in config.json.") - print("Aborting combinator3 attack.") - return - else: + def path_completer(text, state): + base = ctx.hcatWordlists + if not text: + pattern = os.path.join(base, "*") + matches = glob.glob(pattern) + else: + expanded = os.path.expanduser(text) + if expanded.startswith(("/", "./", "../", "~")): + matches = glob.glob(expanded + "*") + else: + pattern = os.path.join(base, expanded + "*") + matches = glob.glob(pattern) + matches = [m + "/" if os.path.isdir(m) else m for m in matches] + try: + return matches[state] + except IndexError: + return None + + _configure_readline(path_completer) + + collected: list[str] = [] + count = 1 + while len(collected) < max_count: raw = input( - "\nEnter 3 wordlist file paths (comma-separated): " + f"\nWordlist #{count} (tab to autocomplete, blank to finish): " ).strip() if not raw: - print("No wordlists provided. Aborting combinator3 attack.") - return + break + resolved = ctx._resolve_wordlist_path(raw, ctx.hcatWordlists) + if os.path.isfile(resolved): + collected.append(resolved) + print(f"Added: {resolved}") + count += 1 + else: + print(f"Not found: {resolved}") + return collected - entries = [p.strip() for p in raw.split(",") if p.strip()] - if len(entries) < 3: - print("\n[!] Combinator3 attack requires exactly 3 wordlists.") - print("Aborting combinator3 attack.") - return - valid = [] - for p in entries[:3]: - resolved = ctx._resolve_wordlist_path(p, ctx.hcatWordlists) - if os.path.isfile(resolved): - valid.append(resolved) - print(f"Found: {resolved}") - else: - print(f"Not found: {resolved}") - - if len(valid) < 3: - print("\nCould not find 3 valid wordlists. Aborting combinator3 attack.") - return - - wordlists = valid - - ctx.hcatCombinator3(ctx.hcatHashType, ctx.hcatHashFile, wordlists) +def combinator3_crack(ctx: Any) -> None: + """3-way combinator attack (delegates to unified combinator_crack).""" + combinator_crack(ctx) def combinatorX_crack(ctx: Any) -> None: - print("\n" + "=" * 60) - print("COMBINATORX ATTACK") - print("=" * 60) - print("This attack combines 2-8 wordlists with an optional separator.") - print("=" * 60) + """N-way combinator attack (delegates to unified combinator_crack).""" + combinator_crack(ctx) - use_default = ( - input("\nUse default combinator wordlists from config? (Y/n): ").strip().lower() - ) - if use_default != "n": - base = ctx.hcatCombinationWordlist - wordlists = base if isinstance(base, list) else [base] - if len(wordlists) < 2: - print("\n[!] Config does not have at least 2 wordlists for combinatorX.") - print("Set hcatCombinationWordlist to a list of 2+ paths in config.json.") - print("Aborting combinatorX attack.") - return - separator = "" - else: - raw = input( - "\nEnter 2-8 wordlist file paths (comma-separated): " - ).strip() - if not raw: - print("No wordlists provided. Aborting combinatorX attack.") - return - - entries = [p.strip() for p in raw.split(",") if p.strip()] - if len(entries) < 2: - print("\n[!] CombinatorX attack requires at least 2 wordlists.") - print("Aborting combinatorX attack.") - return - - valid = [] - for p in entries[:8]: - resolved = ctx._resolve_wordlist_path(p, ctx.hcatWordlists) - if os.path.isfile(resolved): - valid.append(resolved) - print(f"Found: {resolved}") - else: - print(f"Not found: {resolved}") - - if len(valid) < 2: - print("\nCould not find 2 valid wordlists. Aborting combinatorX attack.") - return - - wordlists = valid - separator = input("\nEnter separator between words (leave blank for none): ").strip() - - ctx.hcatCombinatorX(ctx.hcatHashType, ctx.hcatHashFile, wordlists, separator or None) +def combinator_3plus_crack(ctx: Any) -> None: + """3+ wordlist combinator (delegates to unified combinator_crack).""" + combinator_crack(ctx) def bandrel_method(ctx: Any) -> None: @@ -727,12 +639,10 @@ def markov_brute_force(ctx: Any) -> None: def combinator_submenu(ctx: Any) -> None: items = [ - ("1", "Combinator Attack"), + ("1", "Combinator Attack (2-8 wordlists)"), ("2", "YOLO Combinator Attack"), ("3", "Middle Combinator Attack"), ("4", "Thorough Combinator Attack"), - ("5", "Combinator3 Attack (3-way)"), - ("6", "CombinatorX Attack (N-way, 2-8 wordlists)"), ("99", "Back to Main Menu"), ] while True: @@ -747,7 +657,3 @@ def combinator_submenu(ctx: Any) -> None: middle_combinator(ctx) elif choice == "4": thorough_combinator(ctx) - elif choice == "5": - combinator3_crack(ctx) - elif choice == "6": - combinatorX_crack(ctx) diff --git a/hate_crack/main.py b/hate_crack/main.py index 951a397..7f93bf7 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -356,19 +356,19 @@ else: def _append_potfile_arg(cmd, *, use_potfile_path=True, potfile_path=None): - if not use_potfile_path: - return - pot = potfile_path or hcatPotfilePath - if pot: - try: - pot_dir = os.path.dirname(pot) - if pot_dir: - os.makedirs(pot_dir, exist_ok=True) - if not os.path.exists(pot): - open(pot, "a").close() - except OSError: - pass - cmd.append(f"--potfile-path={pot}") + if use_potfile_path: + pot = potfile_path or hcatPotfilePath + if pot: + try: + pot_dir = os.path.dirname(pot) + if pot_dir: + os.makedirs(pot_dir, exist_ok=True) + if not os.path.exists(pot): + open(pot, "a").close() + except OSError: + pass + cmd.append(f"--potfile-path={pot}") + _debug_cmd(cmd) rulesDirectory = config_parser["rules_directory"] @@ -401,6 +401,8 @@ pipalPath = config_parser["pipalPath"] hcatDictionaryWordlist = config_parser["hcatDictionaryWordlist"] hcatHybridlist = config_parser["hcatHybridlist"] hcatCombinationWordlist = config_parser["hcatCombinationWordlist"] +hcatCombinator3Wordlist = config_parser.get("hcatCombinator3Wordlist", ["rockyou.txt", "rockyou.txt", "rockyou.txt"]) +hcatCombinatorXWordlist = config_parser.get("hcatCombinatorXWordlist", ["rockyou.txt", "rockyou.txt"]) hcatMiddleCombinatorMasks = config_parser["hcatMiddleCombinatorMasks"] hcatMiddleBaseList = config_parser["hcatMiddleBaseList"] hcatThoroughCombinatorMasks = config_parser["hcatThoroughCombinatorMasks"] @@ -558,6 +560,8 @@ hcatDictionaryWordlist = _normalize_wordlist_setting( hcatCombinationWordlist = _normalize_wordlist_setting( hcatCombinationWordlist, wordlists_dir ) +hcatCombinator3Wordlist = _normalize_wordlist_setting(hcatCombinator3Wordlist, wordlists_dir) +hcatCombinatorXWordlist = _normalize_wordlist_setting(hcatCombinatorXWordlist, wordlists_dir) hcatHybridlist = _normalize_wordlist_setting(hcatHybridlist, wordlists_dir) hcatMiddleBaseList = _normalize_wordlist_setting(hcatMiddleBaseList, wordlists_dir) hcatThoroughBaseList = _normalize_wordlist_setting(hcatThoroughBaseList, wordlists_dir) @@ -3391,6 +3395,10 @@ def combinatorX_crack(): return _attacks.combinatorX_crack(_attack_ctx()) +def combinator_3plus_crack(): + return _attacks.combinator_3plus_crack(_attack_ctx()) + + def combinator_submenu(): return _attacks.combinator_submenu(_attack_ctx()) diff --git a/tests/test_attacks_behavior.py b/tests/test_attacks_behavior.py index f574997..4096819 100644 --- a/tests/test_attacks_behavior.py +++ b/tests/test_attacks_behavior.py @@ -216,7 +216,7 @@ class TestCombinatorCrack: ctx._resolve_wordlist_path.assert_any_call("/wl/a.txt", ctx.hcatWordlists) ctx._resolve_wordlist_path.assert_any_call("/wl/b.txt", ctx.hcatWordlists) - def test_uses_only_first_two_wordlists(self) -> None: + def test_three_wordlists_in_config_routes_to_combinator3(self) -> None: ctx = _make_ctx() ctx.hcatCombinationWordlist = ["/wl/a.txt", "/wl/b.txt", "/wl/c.txt"] ctx._resolve_wordlist_path.side_effect = lambda wl, _: wl @@ -224,9 +224,10 @@ class TestCombinatorCrack: with patch("builtins.input", return_value=""): combinator_crack(ctx) - call_wordlists = ctx.hcatCombination.call_args[0][2] - assert len(call_wordlists) == 2 - assert "/wl/c.txt" not in call_wordlists + ctx.hcatCombinator3.assert_called_once() + ctx.hcatCombination.assert_not_called() + call_wordlists = ctx.hcatCombinator3.call_args[0][2] + assert len(call_wordlists) == 3 class TestHybridCrack: diff --git a/tests/test_combinator3_combinatorX.py b/tests/test_combinator3_combinatorX.py index 267215e..25fd5b5 100644 --- a/tests/test_combinator3_combinatorX.py +++ b/tests/test_combinator3_combinatorX.py @@ -1,9 +1,6 @@ -import os from unittest.mock import MagicMock, patch -import pytest - -from hate_crack.attacks import combinator3_crack, combinator_submenu, combinatorX_crack +from hate_crack.attacks import combinator_crack, combinator_submenu def _make_ctx(hash_type="1000", hash_file="/tmp/hashes.txt"): @@ -13,122 +10,134 @@ def _make_ctx(hash_type="1000", hash_file="/tmp/hashes.txt"): return ctx -class TestCombinator3Crack: - def test_calls_hcatCombinator3_with_three_wordlists(self, tmp_path): +class TestCombinatorCrackUnified: + def test_two_wordlists_calls_hcatCombination(self, tmp_path): + ctx = _make_ctx() + ctx.hcatWordlists = str(tmp_path) + for name in ["a.txt", "b.txt"]: + (tmp_path / name).write_text("word\n") + ctx._resolve_wordlist_path.side_effect = lambda p, base: p + inputs = ["n", f"{tmp_path}/a.txt", f"{tmp_path}/b.txt", "", ""] + with patch("builtins.input", side_effect=inputs): + combinator_crack(ctx) + ctx.hcatCombination.assert_called_once() + ctx.hcatCombinator3.assert_not_called() + ctx.hcatCombinatorX.assert_not_called() + + def test_three_wordlists_calls_hcatCombinator3(self, tmp_path): ctx = _make_ctx() ctx.hcatWordlists = str(tmp_path) for name in ["a.txt", "b.txt", "c.txt"]: (tmp_path / name).write_text("word\n") ctx._resolve_wordlist_path.side_effect = lambda p, base: p - wl_arg = f"{tmp_path}/a.txt,{tmp_path}/b.txt,{tmp_path}/c.txt" - with patch("builtins.input", side_effect=["n", wl_arg]): - combinator3_crack(ctx) + inputs = ["n", f"{tmp_path}/a.txt", f"{tmp_path}/b.txt", f"{tmp_path}/c.txt", "", ""] + with patch("builtins.input", side_effect=inputs): + combinator_crack(ctx) ctx.hcatCombinator3.assert_called_once() + ctx.hcatCombination.assert_not_called() + ctx.hcatCombinatorX.assert_not_called() - def test_aborts_with_fewer_than_3_wordlists(self, tmp_path): + def test_four_wordlists_calls_hcatCombinatorX(self, tmp_path): ctx = _make_ctx() ctx.hcatWordlists = str(tmp_path) - (tmp_path / "a.txt").write_text("word\n") + for name in ["a.txt", "b.txt", "c.txt", "d.txt"]: + (tmp_path / name).write_text("word\n") ctx._resolve_wordlist_path.side_effect = lambda p, base: p - wl_arg = f"{tmp_path}/a.txt,{tmp_path}/a.txt" - with patch("builtins.input", side_effect=["n", wl_arg]): - combinator3_crack(ctx) + inputs = [ + "n", + f"{tmp_path}/a.txt", + f"{tmp_path}/b.txt", + f"{tmp_path}/c.txt", + f"{tmp_path}/d.txt", + "", + "", + ] + with patch("builtins.input", side_effect=inputs): + combinator_crack(ctx) + ctx.hcatCombinatorX.assert_called_once() + ctx.hcatCombination.assert_not_called() ctx.hcatCombinator3.assert_not_called() - def test_aborts_when_no_wordlists_provided(self, tmp_path): + def test_separator_forces_combinatorX_for_two_wordlists(self, tmp_path): ctx = _make_ctx() ctx.hcatWordlists = str(tmp_path) + for name in ["a.txt", "b.txt"]: + (tmp_path / name).write_text("word\n") ctx._resolve_wordlist_path.side_effect = lambda p, base: p - with patch("builtins.input", side_effect=["n", ""]): - combinator3_crack(ctx) - ctx.hcatCombinator3.assert_not_called() + inputs = ["n", f"{tmp_path}/a.txt", f"{tmp_path}/b.txt", "", "-"] + with patch("builtins.input", side_effect=inputs): + combinator_crack(ctx) + ctx.hcatCombinatorX.assert_called_once() + ctx.hcatCombination.assert_not_called() - def test_passes_exactly_3_wordlists(self, tmp_path): + def test_separator_forces_combinatorX_for_three_wordlists(self, tmp_path): ctx = _make_ctx() ctx.hcatWordlists = str(tmp_path) for name in ["a.txt", "b.txt", "c.txt"]: (tmp_path / name).write_text("word\n") ctx._resolve_wordlist_path.side_effect = lambda p, base: p - wl_arg = f"{tmp_path}/a.txt,{tmp_path}/b.txt,{tmp_path}/c.txt" - with patch("builtins.input", side_effect=["n", wl_arg]): - combinator3_crack(ctx) - call_args = ctx.hcatCombinator3.call_args - wordlists = call_args[0][2] if len(call_args[0]) >= 3 else call_args[1].get("wordlists") - assert len(wordlists) == 3 - - -class TestCombinatorXCrack: - def test_calls_hcatCombinatorX_with_wordlists(self, tmp_path): - ctx = _make_ctx() - ctx.hcatWordlists = str(tmp_path) - for name in ["a.txt", "b.txt"]: - (tmp_path / name).write_text("word\n") - ctx._resolve_wordlist_path.side_effect = lambda p, base: p - wl_arg = f"{tmp_path}/a.txt,{tmp_path}/b.txt" - with patch("builtins.input", side_effect=["n", wl_arg, ""]): - combinatorX_crack(ctx) + inputs = ["n", f"{tmp_path}/a.txt", f"{tmp_path}/b.txt", f"{tmp_path}/c.txt", "", "-"] + with patch("builtins.input", side_effect=inputs): + combinator_crack(ctx) ctx.hcatCombinatorX.assert_called_once() - - def test_passes_separator_to_hcatCombinatorX(self, tmp_path): - ctx = _make_ctx() - ctx.hcatWordlists = str(tmp_path) - for name in ["a.txt", "b.txt"]: - (tmp_path / name).write_text("word\n") - ctx._resolve_wordlist_path.side_effect = lambda p, base: p - wl_arg = f"{tmp_path}/a.txt,{tmp_path}/b.txt" - with patch("builtins.input", side_effect=["n", wl_arg, "-"]): - combinatorX_crack(ctx) - call_args = ctx.hcatCombinatorX.call_args - # separator may be positional or keyword - positional_has_sep = len(call_args[0]) >= 4 and call_args[0][3] == "-" - keyword_has_sep = call_args[1].get("separator") == "-" - assert positional_has_sep or keyword_has_sep + ctx.hcatCombinator3.assert_not_called() def test_aborts_with_fewer_than_2_wordlists(self, tmp_path): ctx = _make_ctx() ctx.hcatWordlists = str(tmp_path) (tmp_path / "a.txt").write_text("word\n") ctx._resolve_wordlist_path.side_effect = lambda p, base: p - wl_arg = f"{tmp_path}/a.txt" - with patch("builtins.input", side_effect=["n", wl_arg, ""]): - combinatorX_crack(ctx) + inputs = ["n", f"{tmp_path}/a.txt", ""] + with patch("builtins.input", side_effect=inputs): + combinator_crack(ctx) + ctx.hcatCombination.assert_not_called() + ctx.hcatCombinator3.assert_not_called() ctx.hcatCombinatorX.assert_not_called() - def test_no_separator_when_empty_input(self, tmp_path): + def test_aborts_when_no_wordlists_provided(self, tmp_path): ctx = _make_ctx() ctx.hcatWordlists = str(tmp_path) - for name in ["a.txt", "b.txt"]: + ctx._resolve_wordlist_path.side_effect = lambda p, base: p + with patch("builtins.input", side_effect=["n", ""]): + combinator_crack(ctx) + ctx.hcatCombination.assert_not_called() + ctx.hcatCombinator3.assert_not_called() + ctx.hcatCombinatorX.assert_not_called() + + def test_no_separator_passes_none_to_combinatorX(self, tmp_path): + ctx = _make_ctx() + ctx.hcatWordlists = str(tmp_path) + for name in ["a.txt", "b.txt", "c.txt", "d.txt"]: (tmp_path / name).write_text("word\n") ctx._resolve_wordlist_path.side_effect = lambda p, base: p - wl_arg = f"{tmp_path}/a.txt,{tmp_path}/b.txt" - with patch("builtins.input", side_effect=["n", wl_arg, ""]): - combinatorX_crack(ctx) + inputs = [ + "n", + f"{tmp_path}/a.txt", + f"{tmp_path}/b.txt", + f"{tmp_path}/c.txt", + f"{tmp_path}/d.txt", + "", + "", + ] + with patch("builtins.input", side_effect=inputs): + combinator_crack(ctx) call_args = ctx.hcatCombinatorX.call_args positional_sep = call_args[0][3] if len(call_args[0]) >= 4 else None keyword_sep = call_args[1].get("separator") - # separator should be None or empty string when nothing entered assert positional_sep in (None, "") and keyword_sep in (None, "") class TestCombinatorSubmenuUpdated: - def test_submenu_has_combinator3_option(self): + def test_submenu_option1_dispatches_to_combinator_crack(self): ctx = _make_ctx() - with patch("hate_crack.attacks.combinator3_crack") as mock_c3, patch( - "hate_crack.attacks.interactive_menu", side_effect=["5", "99"] + with patch("hate_crack.attacks.combinator_crack") as mock_c, patch( + "hate_crack.attacks.interactive_menu", side_effect=["1", "99"] ): combinator_submenu(ctx) - mock_c3.assert_called_once_with(ctx) + mock_c.assert_called_once_with(ctx) - def test_submenu_has_combinatorX_option(self): - ctx = _make_ctx() - with patch("hate_crack.attacks.combinatorX_crack") as mock_cx, patch( - "hate_crack.attacks.interactive_menu", side_effect=["6", "99"] - ): - combinator_submenu(ctx) - mock_cx.assert_called_once_with(ctx) - - def test_submenu_items_include_new_attacks(self): - """Verify the submenu item list advertises options 5 and 6.""" + def test_submenu_has_no_separate_3plus_option(self): + """Verify option 5 (3+) is removed - combinator is now unified under option 1.""" ctx = _make_ctx() captured_items = [] @@ -140,5 +149,6 @@ class TestCombinatorSubmenuUpdated: combinator_submenu(ctx) keys = [item[0] for item in captured_items] - assert "5" in keys - assert "6" in keys + assert "1" in keys + assert "5" not in keys + assert "6" not in keys From 5fe6e2140575c2e601a7da5d55b46895faaf3239 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 19 Mar 2026 14:35:29 -0400 Subject: [PATCH 08/14] feat: add ngramX attack and gzip auto-detection for external binaries - Add _is_gzipped() magic-byte detector and _wordlist_path() context manager that transparently decompresses gzip files to a temp path - Apply gzip handling to hcatCombinator3 and hcatCombinatorX via contextlib.ExitStack so compressed wordlists work without manual prep - Add hcatNgramX() wrapper using ngramX.bin piped to hashcat, with gzip auto-detection on the corpus file - Add ngram_attack() handler in attacks.py with tab-autocomplete corpus selection and configurable group size (default 3) - Register attack as menu option 19 in both main.py and hate_crack.py - Fix wordlist_optimizer.py: .app extension on macOS was wrong, use .bin - Add tests/test_ngram_gzip.py covering ngram_attack handler, _is_gzipped, and _wordlist_path context manager (temp file cleanup, plain passthrough) --- hate_crack.py | 1 + hate_crack/attacks.py | 26 ++++++ hate_crack/main.py | 183 ++++++++++++++++++++++++++++----------- tests/test_ngram_gzip.py | 149 +++++++++++++++++++++++++++++++ wordlist_optimizer.py | 2 +- 5 files changed, 308 insertions(+), 53 deletions(-) create mode 100644 tests/test_ngram_gzip.py diff --git a/hate_crack.py b/hate_crack.py index de0124b..835cb76 100755 --- a/hate_crack.py +++ b/hate_crack.py @@ -88,6 +88,7 @@ def get_main_menu_options(): "16": _attacks.omen_attack, "17": _attacks.adhoc_mask_crack, "18": _attacks.markov_brute_force, + "19": _attacks.ngram_attack, "90": download_hashmob_rules, "91": weakpass_wordlist_menu, "92": download_hashmob_wordlists, diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index bbd40d4..31d2de3 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -637,6 +637,32 @@ def markov_brute_force(ctx: Any) -> None: ctx.hcatMarkovBruteForce(ctx.hcatHashType, ctx.hcatHashFile, hcatMinLen, hcatMaxLen) +def ngram_attack(ctx: Any) -> None: + print("\n" + "=" * 60) + print("NGRAM ATTACK") + print("=" * 60) + print("Generates n-gram candidates from a corpus file via ngramX.bin.") + print("Gzip-compressed corpus files are auto-detected and decompressed.") + print("=" * 60) + + corpus = ctx.select_file_with_autocomplete( + "Select corpus file (tab to autocomplete)", + base_dir=ctx.hcatWordlists, + ) + if not corpus: + print("No corpus selected. Aborting ngram attack.") + return + + group_size_raw = input("\nEnter n-gram group size (default 3): ").strip() + try: + group_size = int(group_size_raw) if group_size_raw else 3 + except ValueError: + print("[!] Invalid group size. Using default of 3.") + group_size = 3 + + ctx.hcatNgramX(ctx.hcatHashType, ctx.hcatHashFile, corpus, group_size) + + def combinator_submenu(ctx: Any) -> None: items = [ ("1", "Combinator Attack (2-8 wordlists)"), diff --git a/hate_crack/main.py b/hate_crack/main.py index 7f93bf7..fa566bf 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -23,7 +23,10 @@ import time import argparse import urllib.request import urllib.error +import contextlib +import gzip import lzma +import tempfile from types import SimpleNamespace #!/usr/bin/env python3 @@ -680,6 +683,7 @@ hcatFingerprintCount = 0 hcatCombinationCount = 0 hcatCombinator3Count = 0 hcatCombinatorXCount = 0 +hcatNgramXCount = 0 hcatHybridCount = 0 hcatExtraCount = 0 hcatRecycleCount = 0 @@ -697,6 +701,37 @@ def _debug_cmd(cmd): print(f"[DEBUG] hashcat cmd: {_format_cmd(cmd)}") +def _is_gzipped(path: str) -> bool: + try: + with open(path, "rb") as f: + return f.read(2) == b"\x1f\x8b" + except OSError: + return False + + +@contextlib.contextmanager +def _wordlist_path(path: str): + """Yield an uncompressed path for path. + + If the file is gzip-compressed, decompress to a temp file and clean up on + exit. Otherwise yield the original path unchanged. + """ + if _is_gzipped(path): + with tempfile.NamedTemporaryFile(delete=False, suffix=".txt") as tmp: + tmp_name = tmp.name + with gzip.open(path, "rb") as gz_in: + shutil.copyfileobj(gz_in, tmp) + try: + yield tmp_name + finally: + try: + os.unlink(tmp_name) + except OSError: + pass + else: + yield path + + def _add_debug_mode_for_rules(cmd): """Add debug mode arguments to hashcat command if rules are being used. @@ -1431,30 +1466,32 @@ def hcatCombinator3(hcatHashType, hcatHashFile, wordlists): return combinator3_bin = os.path.join(hate_path, "hashcat-utils/bin/combinator3.bin") - generator_cmd = [combinator3_bin] + list(wordlists[:3]) - hashcat_cmd = [ - hcatBin, - "-m", - hcatHashType, - hcatHashFile, - "--session", - generate_session_id(), - "-o", - f"{hcatHashFile}.out", - ] - hashcat_cmd.extend(shlex.split(hcatTuning)) - _append_potfile_arg(hashcat_cmd) - generator_proc = subprocess.Popen(generator_cmd, stdout=subprocess.PIPE) - assert generator_proc.stdout is not None - hcatProcess = subprocess.Popen(hashcat_cmd, stdin=generator_proc.stdout) - generator_proc.stdout.close() - try: - hcatProcess.wait() - generator_proc.wait() - except KeyboardInterrupt: - print("Killing PID {0}...".format(str(hcatProcess.pid))) - hcatProcess.kill() - generator_proc.kill() + with contextlib.ExitStack() as stack: + resolved = [stack.enter_context(_wordlist_path(w)) for w in wordlists[:3]] + generator_cmd = [combinator3_bin] + resolved + hashcat_cmd = [ + hcatBin, + "-m", + hcatHashType, + hcatHashFile, + "--session", + generate_session_id(), + "-o", + f"{hcatHashFile}.out", + ] + hashcat_cmd.extend(shlex.split(hcatTuning)) + _append_potfile_arg(hashcat_cmd) + generator_proc = subprocess.Popen(generator_cmd, stdout=subprocess.PIPE) + assert generator_proc.stdout is not None + hcatProcess = subprocess.Popen(hashcat_cmd, stdin=generator_proc.stdout) + generator_proc.stdout.close() + try: + hcatProcess.wait() + generator_proc.wait() + except KeyboardInterrupt: + print("Killing PID {0}...".format(str(hcatProcess.pid))) + hcatProcess.kill() + generator_proc.kill() hcatCombinator3Count = lineCount(hcatHashFile + ".out") - hcatHashCracked @@ -1469,38 +1506,75 @@ def hcatCombinatorX(hcatHashType, hcatHashFile, wordlists, separator=None): return combinatorX_bin = os.path.join(hate_path, "hashcat-utils/bin/combinatorX.bin") - generator_cmd = [combinatorX_bin] - for i, f in enumerate(wordlists[:8], start=1): - generator_cmd += [f"--file{i}", f] - if separator: - generator_cmd += ["--sepFill", separator] - hashcat_cmd = [ - hcatBin, - "-m", - hcatHashType, - hcatHashFile, - "--session", - generate_session_id(), - "-o", - f"{hcatHashFile}.out", - ] - hashcat_cmd.extend(shlex.split(hcatTuning)) - _append_potfile_arg(hashcat_cmd) - generator_proc = subprocess.Popen(generator_cmd, stdout=subprocess.PIPE) - assert generator_proc.stdout is not None - hcatProcess = subprocess.Popen(hashcat_cmd, stdin=generator_proc.stdout) - generator_proc.stdout.close() - try: - hcatProcess.wait() - generator_proc.wait() - except KeyboardInterrupt: - print("Killing PID {0}...".format(str(hcatProcess.pid))) - hcatProcess.kill() - generator_proc.kill() + with contextlib.ExitStack() as stack: + resolved = [stack.enter_context(_wordlist_path(w)) for w in wordlists[:8]] + generator_cmd = [combinatorX_bin] + for i, f in enumerate(resolved, start=1): + generator_cmd += [f"--file{i}", f] + if separator: + generator_cmd += ["--sepFill", separator] + hashcat_cmd = [ + hcatBin, + "-m", + hcatHashType, + hcatHashFile, + "--session", + generate_session_id(), + "-o", + f"{hcatHashFile}.out", + ] + hashcat_cmd.extend(shlex.split(hcatTuning)) + _append_potfile_arg(hashcat_cmd) + generator_proc = subprocess.Popen(generator_cmd, stdout=subprocess.PIPE) + assert generator_proc.stdout is not None + hcatProcess = subprocess.Popen(hashcat_cmd, stdin=generator_proc.stdout) + generator_proc.stdout.close() + try: + hcatProcess.wait() + generator_proc.wait() + except KeyboardInterrupt: + print("Killing PID {0}...".format(str(hcatProcess.pid))) + hcatProcess.kill() + generator_proc.kill() hcatCombinatorXCount = lineCount(hcatHashFile + ".out") - hcatHashCracked +# NgramX Attack - n-gram candidates from corpus file piped to hashcat +def hcatNgramX(hcatHashType, hcatHashFile, corpus, group_size=3): + global hcatNgramXCount + global hcatProcess + + ngramX_bin = os.path.join(hate_path, "hashcat-utils/bin/ngramX.bin") + with _wordlist_path(corpus) as resolved_corpus: + generator_cmd = [ngramX_bin, resolved_corpus, str(group_size)] + hashcat_cmd = [ + hcatBin, + "-m", + hcatHashType, + hcatHashFile, + "--session", + generate_session_id(), + "-o", + f"{hcatHashFile}.out", + ] + hashcat_cmd.extend(shlex.split(hcatTuning)) + _append_potfile_arg(hashcat_cmd) + generator_proc = subprocess.Popen(generator_cmd, stdout=subprocess.PIPE) + assert generator_proc.stdout is not None + hcatProcess = subprocess.Popen(hashcat_cmd, stdin=generator_proc.stdout) + generator_proc.stdout.close() + try: + hcatProcess.wait() + generator_proc.wait() + except KeyboardInterrupt: + print("Killing PID {0}...".format(str(hcatProcess.pid))) + hcatProcess.kill() + generator_proc.kill() + + hcatNgramXCount = lineCount(hcatHashFile + ".out") - hcatHashCracked + + # Hybrid Attack def hcatHybrid(hcatHashType, hcatHashFile, wordlists=None): global hcatHybridCount @@ -3399,6 +3473,9 @@ def combinator_3plus_crack(): return _attacks.combinator_3plus_crack(_attack_ctx()) +def ngram_attack(): + return _attacks.ngram_attack(_attack_ctx()) + def combinator_submenu(): return _attacks.combinator_submenu(_attack_ctx()) @@ -3656,6 +3733,7 @@ def get_main_menu_items(): ("16", "OMEN Attack"), ("17", "Ad-hoc Mask Attack"), ("18", "Markov Brute Force Attack"), + ("19", "N-gram Attack"), ("90", "Download rules from Hashmob.net"), ("91", "Analyze Hashcat Rules"), ("92", "Download wordlists from Hashmob.net"), @@ -3693,6 +3771,7 @@ def get_main_menu_options(): "16": omen_attack, "17": adhoc_mask_crack, "18": markov_brute_force, + "19": ngram_attack, "90": lambda: download_hashmob_rules(rules_dir=rulesDirectory), "91": analyze_rules, "92": download_hashmob_wordlists, diff --git a/tests/test_ngram_gzip.py b/tests/test_ngram_gzip.py new file mode 100644 index 0000000..3c43360 --- /dev/null +++ b/tests/test_ngram_gzip.py @@ -0,0 +1,149 @@ +import gzip +import os +from unittest.mock import MagicMock, patch + +from hate_crack.attacks import ngram_attack + + +def _make_ctx(hash_type="1000", hash_file="/tmp/hashes.txt"): + ctx = MagicMock() + ctx.hcatHashType = hash_type + ctx.hcatHashFile = hash_file + return ctx + + +class TestNgramAttack: + def test_calls_hcatNgramX_with_corpus_and_group_size(self, tmp_path): + ctx = _make_ctx() + corpus = tmp_path / "corpus.txt" + corpus.write_text("password\nletmein\n") + ctx.select_file_with_autocomplete.return_value = str(corpus) + + with patch("builtins.input", return_value="3"): + ngram_attack(ctx) + + ctx.hcatNgramX.assert_called_once_with( + ctx.hcatHashType, ctx.hcatHashFile, str(corpus), 3 + ) + + def test_default_group_size_is_3(self, tmp_path): + ctx = _make_ctx() + corpus = tmp_path / "corpus.txt" + corpus.write_text("password\n") + ctx.select_file_with_autocomplete.return_value = str(corpus) + + with patch("builtins.input", return_value=""): + ngram_attack(ctx) + + ctx.hcatNgramX.assert_called_once() + assert ctx.hcatNgramX.call_args[0][3] == 3 + + def test_invalid_group_size_defaults_to_3(self, tmp_path): + ctx = _make_ctx() + corpus = tmp_path / "corpus.txt" + corpus.write_text("password\n") + ctx.select_file_with_autocomplete.return_value = str(corpus) + + with patch("builtins.input", return_value="abc"): + ngram_attack(ctx) + + ctx.hcatNgramX.assert_called_once() + assert ctx.hcatNgramX.call_args[0][3] == 3 + + def test_aborts_when_no_corpus_selected(self): + ctx = _make_ctx() + ctx.select_file_with_autocomplete.return_value = None + + ngram_attack(ctx) + + ctx.hcatNgramX.assert_not_called() + + def test_custom_group_size_passed_through(self, tmp_path): + ctx = _make_ctx() + corpus = tmp_path / "corpus.txt" + corpus.write_text("password\n") + ctx.select_file_with_autocomplete.return_value = str(corpus) + + with patch("builtins.input", return_value="5"): + ngram_attack(ctx) + + assert ctx.hcatNgramX.call_args[0][3] == 5 + + +class TestIsGzipped: + def test_detects_gzip_file(self, tmp_path): + from hate_crack.main import _is_gzipped + + gz_file = tmp_path / "test.txt.gz" + with gzip.open(str(gz_file), "wb") as f: + f.write(b"password\n") + + assert _is_gzipped(str(gz_file)) is True + + def test_plain_file_not_detected_as_gzip(self, tmp_path): + from hate_crack.main import _is_gzipped + + plain = tmp_path / "test.txt" + plain.write_bytes(b"password\n") + + assert _is_gzipped(str(plain)) is False + + def test_missing_file_returns_false(self, tmp_path): + from hate_crack.main import _is_gzipped + + assert _is_gzipped(str(tmp_path / "nonexistent.txt")) is False + + def test_empty_file_returns_false(self, tmp_path): + from hate_crack.main import _is_gzipped + + empty = tmp_path / "empty.txt" + empty.write_bytes(b"") + + assert _is_gzipped(str(empty)) is False + + +class TestWordlistPath: + def test_plain_file_yields_original_path(self, tmp_path): + from hate_crack.main import _wordlist_path + + plain = tmp_path / "words.txt" + plain.write_text("password\n") + + with _wordlist_path(str(plain)) as result: + assert result == str(plain) + + def test_gzip_file_yields_temp_file_with_content(self, tmp_path): + from hate_crack.main import _wordlist_path + + gz_file = tmp_path / "words.txt.gz" + with gzip.open(str(gz_file), "wb") as f: + f.write(b"password\nletmein\n") + + with _wordlist_path(str(gz_file)) as result: + assert result != str(gz_file) + assert os.path.isfile(result) + with open(result, "rb") as f: + assert f.read() == b"password\nletmein\n" + + def test_gzip_temp_file_removed_after_context(self, tmp_path): + from hate_crack.main import _wordlist_path + + gz_file = tmp_path / "words.txt.gz" + with gzip.open(str(gz_file), "wb") as f: + f.write(b"password\n") + + with _wordlist_path(str(gz_file)) as result: + tmp_path_used = result + + assert not os.path.exists(tmp_path_used) + + def test_plain_file_not_deleted_after_context(self, tmp_path): + from hate_crack.main import _wordlist_path + + plain = tmp_path / "words.txt" + plain.write_text("password\n") + + with _wordlist_path(str(plain)) as result: + assert result == str(plain) + + assert plain.exists() diff --git a/wordlist_optimizer.py b/wordlist_optimizer.py index b231973..e32d207 100644 --- a/wordlist_optimizer.py +++ b/wordlist_optimizer.py @@ -40,7 +40,7 @@ def main(): # Resolve binary paths relative to script location script_dir = os.path.dirname(os.path.realpath(__file__)) - ext = ".app" if sys.platform == "darwin" else ".bin" + ext = ".bin" if sys.platform == "darwin" else ".bin" splitlen_bin = os.path.join(script_dir, "hashcat-utils", "bin", f"splitlen{ext}") rli_bin = os.path.join(script_dir, "hashcat-utils", "bin", f"rli{ext}") From e354f3e57e2b25836850e7c11a5e761db7528974 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 19 Mar 2026 15:02:42 -0400 Subject: [PATCH 09/14] fix: ngramX generates n-grams per line, not across entire corpus Previously ngramX.bin accumulated all words from the whole file into a flat array, producing candidates that crossed line boundaries. With a corpus of "roses are red\nviolets are blue", it generated "red violets" and "are red violets" - mixing lines that represent separate phrases. Fixes ngramX.c to process each line independently so n-grams stay within a single line, matching the expected behavior for song lyrics, book passages, and similar per-line corpora. --- hashcat-utils | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hashcat-utils b/hashcat-utils index 8bbf2ba..ee6833b 160000 --- a/hashcat-utils +++ b/hashcat-utils @@ -1 +1 @@ -Subproject commit 8bbf2baf7b341c8ec23ca91e44e0ac7d7fcc0355 +Subproject commit ee6833b34ef402705b85c818822c4598e2e8575f From 724e089d6325b14b11ba9c2ed01ace0070a17d77 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 19 Mar 2026 15:05:29 -0400 Subject: [PATCH 10/14] Revert "fix: ngramX generates n-grams per line, not across entire corpus" This reverts commit e354f3e57e2b25836850e7c11a5e761db7528974. --- hashcat-utils | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hashcat-utils b/hashcat-utils index ee6833b..8bbf2ba 160000 --- a/hashcat-utils +++ b/hashcat-utils @@ -1 +1 @@ -Subproject commit ee6833b34ef402705b85c818822c4598e2e8575f +Subproject commit 8bbf2baf7b341c8ec23ca91e44e0ac7d7fcc0355 From 78b51f9b10e01d1f6f0eae91a4f46c99c61f8c59 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 19 Mar 2026 15:05:37 -0400 Subject: [PATCH 11/14] revert: update hashcat-utils submodule to reverted ngramX --- hashcat-utils | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hashcat-utils b/hashcat-utils index 8bbf2ba..64074c5 160000 --- a/hashcat-utils +++ b/hashcat-utils @@ -1 +1 @@ -Subproject commit 8bbf2baf7b341c8ec23ca91e44e0ac7d7fcc0355 +Subproject commit 64074c5d5428c9591cf9a4ab9a2b20a26510efbe From 142ee4b245b8de862ce7ab27d7d81c8d7ee1ebc2 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 19 Mar 2026 15:29:59 -0400 Subject: [PATCH 12/14] fix: transparently decompress gzip wordlists for external tool attacks Add _open_wordlist() helper that returns gzip.open() or open() based on the .gz extension. Apply it to the three functions that open wordlist files themselves before piping to an external binary: - hcatPrince: prince base list fed to pp64.bin - hcatPermute: wordlist fed to permute.bin - hcatMarkovTrain: source file fed to hcstat2gen.bin Attacks that pass wordlist paths directly to hashcat are unaffected - hashcat already handles .gz natively. --- hate_crack/main.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/hate_crack/main.py b/hate_crack/main.py index cebc19c..3bc7ba7 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -23,7 +23,9 @@ import time import argparse import urllib.request import urllib.error +import gzip import lzma +import tempfile from types import SimpleNamespace #!/usr/bin/env python3 @@ -682,6 +684,13 @@ hcatProcess: subprocess.Popen[Any] | None = None debug_mode = False +def _open_wordlist(path): + """Open a wordlist file, transparently decompressing gzip if the path ends with .gz.""" + if path.endswith(".gz"): + return gzip.open(path, "rb") + return open(path, "rb") + + def _format_cmd(cmd): # Shell-style quoting to mirror what a user could run in a terminal. return " ".join(shlex.quote(str(part)) for part in cmd) @@ -2092,7 +2101,7 @@ def hcatMarkovTrain(source_file, hcatHashFile): return False try: - with open(source_file, "rb") as stdin_f: + with _open_wordlist(source_file) as stdin_f: hcatProcess = subprocess.Popen( [hcstat2gen_bin, hcstat2_path], stdin=stdin_f, stderr=subprocess.PIPE ) @@ -2210,7 +2219,7 @@ def hcatPrince(hcatHashType, hcatHashFile): hashcat_cmd.extend(shlex.split(hcatTuning)) _append_potfile_arg(hashcat_cmd) hashcat_cmd = _add_debug_mode_for_rules(hashcat_cmd) - with open(prince_base, "rb") as base: + with _open_wordlist(prince_base) as base: prince_proc = subprocess.Popen(prince_cmd, stdin=base, stdout=subprocess.PIPE) hcatProcess = subprocess.Popen(hashcat_cmd, stdin=prince_proc.stdout) prince_proc.stdout.close() @@ -2244,7 +2253,7 @@ def hcatPermute(hcatHashType, hcatHashFile, wordlist): ] hashcat_cmd.extend(shlex.split(hcatTuning)) _append_potfile_arg(hashcat_cmd) - with open(wordlist, "rb") as wl_file: + with _open_wordlist(wordlist) as wl_file: permute_proc = subprocess.Popen( [permute_path], stdin=wl_file, stdout=subprocess.PIPE ) From ffce245f789ec7b3d1784423ff59764a0607ee38 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 19 Mar 2026 15:30:27 -0400 Subject: [PATCH 13/14] fix: remove unused tempfile import --- hate_crack/main.py | 1 - 1 file changed, 1 deletion(-) diff --git a/hate_crack/main.py b/hate_crack/main.py index 3bc7ba7..ae6092c 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -25,7 +25,6 @@ import urllib.request import urllib.error import gzip import lzma -import tempfile from types import SimpleNamespace #!/usr/bin/env python3 From 6032787fa0527a45dc079c91fbf2f77cd8030210 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 19 Mar 2026 15:42:18 -0400 Subject: [PATCH 14/14] chore: ignore large local wordlist files --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 9869655..9efc18a 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,7 @@ hate_crack/princeprocessor/ *.ollama_candidates *.filtered research/ +--help +4_char_all +all_hashes.enabled +some_histories