From 8ada9b069ad79c06f69ba8dbb1cdec10d4c13c5d Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 19 Mar 2026 12:14:05 -0400 Subject: [PATCH 01/10] 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 e2f25bfc706180f91a40b93954b7589e81881685 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 19 Mar 2026 12:16:04 -0400 Subject: [PATCH 02/10] 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 993bd518650479bdede143c7e0926f31c22b7721 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 19 Mar 2026 12:20:37 -0400 Subject: [PATCH 03/10] 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 20f9110fc1eb4e30ca7372ad72a21bc540d13f6c Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 19 Mar 2026 14:18:25 -0400 Subject: [PATCH 04/10] 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 f43bf2c982866e10aaa95e74ac445fbd377a2c82 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 19 Mar 2026 14:35:29 -0400 Subject: [PATCH 05/10] 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 4d0039264ab8cc950ac43f530f24fe14eb41689d Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 19 Mar 2026 15:02:42 -0400 Subject: [PATCH 06/10] 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 bf582c1143197fc7a2d9887507ede422b08770d3 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 19 Mar 2026 15:05:29 -0400 Subject: [PATCH 07/10] Revert "fix: ngramX generates n-grams per line, not across entire corpus" This reverts commit 4d0039264ab8cc950ac43f530f24fe14eb41689d. --- 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 3c8bc8eefddc9a033d20aca338a67706b0423a8f Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 19 Mar 2026 15:05:37 -0400 Subject: [PATCH 08/10] 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 e64d555cc6cd454d1d0e9ac968a6ca8d25001b88 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 19 Mar 2026 15:29:59 -0400 Subject: [PATCH 09/10] 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 9efba2ee5da5a109c62840199d58fd72e9f48fa3 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 19 Mar 2026 15:30:27 -0400 Subject: [PATCH 10/10] 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