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"),