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