From e2f25bfc706180f91a40b93954b7589e81881685 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 19 Mar 2026 12:16:04 -0400 Subject: [PATCH 1/3] 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 20f9110fc1eb4e30ca7372ad72a21bc540d13f6c Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 19 Mar 2026 14:18:25 -0400 Subject: [PATCH 2/3] 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 3/3] 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}")