From dfe9e28e70e8059eb1c9d2be2699de680625f717 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 19 Mar 2026 12:13:38 -0400 Subject: [PATCH 1/2] 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 6032787fa0527a45dc079c91fbf2f77cd8030210 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 19 Mar 2026 15:42:18 -0400 Subject: [PATCH 2/2] 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