diff --git a/README.md b/README.md index 0a69f8b..702cbb9 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 + (21) Combipow Passphrase Attack (90) Download rules from Hashmob.net (91) Analyze Hashcat Rules @@ -789,6 +790,15 @@ 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 +#### Combipow Passphrase Attack +Generates all unique non-empty subset combinations from a short wordlist using `combipow.bin` and pipes them into hashcat. Designed for passphrase cracking when you know the pool of words a password was built from. + +* Prompts for a wordlist file (max 63 lines - combipow generates up to 2^n-1 combinations) +* Optional space separator (`-s` flag) to insert spaces between words in each combination +* Warns if the wordlist exceeds 20 lines (output volume may be large) +* Aborts with a clear message if the wordlist exceeds 63 lines (hard limit) +* Candidates are piped directly to hashcat stdin + #### 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..e292a7b 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, + "21": _attacks.combipow_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..e8d70c4 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -620,6 +620,31 @@ def markov_brute_force(ctx: Any) -> None: ctx.hcatMarkovBruteForce(ctx.hcatHashType, ctx.hcatHashFile, hcatMinLen, hcatMaxLen) +def combipow_crack(ctx: Any) -> None: + wordlist = None + while wordlist is None: + path = input("\n[*] Enter path to wordlist (max 63 lines recommended): ").strip() + if not path: + continue + if not os.path.isfile(path): + print(f"[!] File not found: {path}") + continue + with open(path) as fh: + line_count = sum(1 for _ in fh) + if line_count > 63: + print( + f"[!] Wordlist has {line_count} lines (max 63). combipow generates 2^n-1 combinations." + ) + return + if line_count > 20: + print( + f"[*] Warning: {line_count} lines will generate a large number of combinations." + ) + wordlist = path + use_space_sep = input("[*] Add spaces between words? (Y/n): ").strip().lower() != "n" + ctx.hcatCombipow(ctx.hcatHashType, ctx.hcatHashFile, wordlist, use_space_sep) + + 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..6536a1d 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -2174,6 +2174,45 @@ def hcatMarkovBruteForce(hcatHashType, hcatHashFile, hcatMinLen, hcatMaxLen): hcatProcess.kill() +# Combipow Passphrase Attack +hcatCombipowCount = 0 + + +def hcatCombipow(hcatHashType, hcatHashFile, wordlist, use_space_sep=True): + global hcatProcess, hcatCombipowCount + hcatCombipowCount += 1 + combipow_bin = os.path.join(hate_path, "hashcat-utils/bin/combipow.bin") + generator_cmd = [combipow_bin] + if use_space_sep: + generator_cmd.append("-s") + generator_cmd.append(wordlist) + session_name = re.sub( + r"[^a-zA-Z0-9_-]", "_", os.path.splitext(os.path.basename(hcatHashFile))[0] + ) + hashcat_cmd = [ + hcatBin, + "--session", + session_name, + "-m", + hcatHashType, + hcatHashFile, + "-o", + f"{hcatHashFile}.out", + ] + hashcat_cmd.extend(shlex.split(hcatTuning)) + _append_potfile_arg(hashcat_cmd) + generator_proc = subprocess.Popen(generator_cmd, stdout=subprocess.PIPE) + 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() + + # PRINCE Attack def hcatPrince(hcatHashType, hcatHashFile): global hcatProcess @@ -3329,6 +3368,10 @@ def omen_attack(): return _attacks.omen_attack(_attack_ctx()) +def combipow_crack(): + return _attacks.combipow_crack(_attack_ctx()) + + # convert hex words for recycling def convert_hex(working_file): processed_words = [] @@ -3557,6 +3600,7 @@ def get_main_menu_items(): ("16", "OMEN Attack"), ("17", "Ad-hoc Mask Attack"), ("18", "Markov Brute Force Attack"), + ("21", "Combipow Passphrase Attack"), ("90", "Download rules from Hashmob.net"), ("91", "Analyze Hashcat Rules"), ("92", "Download wordlists from Hashmob.net"), @@ -3594,6 +3638,7 @@ def get_main_menu_options(): "16": omen_attack, "17": adhoc_mask_crack, "18": markov_brute_force, + "21": combipow_crack, "90": lambda: download_hashmob_rules(rules_dir=rulesDirectory), "91": analyze_rules, "92": download_hashmob_wordlists, diff --git a/tests/test_combipow_attack.py b/tests/test_combipow_attack.py new file mode 100644 index 0000000..d4bf54f --- /dev/null +++ b/tests/test_combipow_attack.py @@ -0,0 +1,231 @@ +import importlib +import importlib.util +import os +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +os.environ["HATE_CRACK_SKIP_INIT"] = "1" + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +def _load_attacks(): + """Import hate_crack.attacks with SKIP_INIT set.""" + for key in list(sys.modules.keys()): + if "hate_crack" in key: + del sys.modules[key] + if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + import hate_crack.attacks as attacks # noqa: PLC0415 + + return attacks + + +def _load_cli(): + for key in list(sys.modules.keys()): + if "hate_crack" in key: + del sys.modules[key] + spec = importlib.util.spec_from_file_location( + "hate_crack_cli", PROJECT_ROOT / "hate_crack.py" + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture +def cli(): + return _load_cli() + + +def _make_ctx(hash_type="1000", hash_file="/tmp/hashes.txt"): + ctx = MagicMock() + ctx.hcatHashType = hash_type + ctx.hcatHashFile = hash_file + return ctx + + +# --- Menu presence tests --- + +def test_combipow_crack_in_main_menu(cli): + options = cli.get_main_menu_options() + assert "21" in options + + +def test_combipow_crack_menu_item_label(): + cli = _load_cli() + items = cli.get_main_menu_items() + keys = [k for k, _ in items] + assert "21" in keys + labels = {k: label for k, label in items} + assert "passphrase" in labels["21"].lower() or "combipow" in labels["21"].lower() + + +# --- combipow_crack handler tests --- + +class TestCombipowCrack: + def test_calls_hcatCombipow_with_space_sep_by_default(self, tmp_path): + attacks = _load_attacks() + ctx = _make_ctx() + wl = tmp_path / "words.txt" + wl.write_text("correct\nhorse\nbattery\n") + with patch("builtins.input", side_effect=[str(wl), ""]): + attacks.combipow_crack(ctx) + ctx.hcatCombipow.assert_called_once() + call_args = ctx.hcatCombipow.call_args + use_space = ( + call_args[0][3] if len(call_args[0]) > 3 else call_args[1].get("use_space_sep") + ) + assert use_space is True + + def test_calls_hcatCombipow_without_space_sep(self, tmp_path): + attacks = _load_attacks() + ctx = _make_ctx() + wl = tmp_path / "words.txt" + wl.write_text("correct\nhorse\nbattery\n") + with patch("builtins.input", side_effect=[str(wl), "n"]): + attacks.combipow_crack(ctx) + ctx.hcatCombipow.assert_called_once() + call_args = ctx.hcatCombipow.call_args + use_space = ( + call_args[0][3] if len(call_args[0]) > 3 else call_args[1].get("use_space_sep") + ) + assert use_space is False + + def test_rejects_more_than_63_lines(self, tmp_path): + attacks = _load_attacks() + ctx = _make_ctx() + wl = tmp_path / "words.txt" + wl.write_text("\n".join(f"word{i}" for i in range(64)) + "\n") + with patch("builtins.input", return_value=str(wl)): + attacks.combipow_crack(ctx) + ctx.hcatCombipow.assert_not_called() + + def test_accepts_exactly_63_lines(self, tmp_path): + attacks = _load_attacks() + ctx = _make_ctx() + wl = tmp_path / "words.txt" + wl.write_text("\n".join(f"word{i}" for i in range(63)) + "\n") + with patch("builtins.input", side_effect=[str(wl), ""]): + attacks.combipow_crack(ctx) + ctx.hcatCombipow.assert_called_once() + + def test_rejects_nonexistent_file(self, tmp_path): + attacks = _load_attacks() + ctx = _make_ctx() + wl = tmp_path / "words.txt" + wl.write_text("test\n") + with patch("builtins.input", side_effect=["/nonexistent.txt", str(wl), ""]): + attacks.combipow_crack(ctx) + ctx.hcatCombipow.assert_called_once() + + def test_passes_correct_hash_type_and_file(self, tmp_path): + attacks = _load_attacks() + ctx = _make_ctx(hash_type="3200", hash_file="/tmp/bcrypt.txt") + wl = tmp_path / "words.txt" + wl.write_text("word1\nword2\n") + with patch("builtins.input", side_effect=[str(wl), ""]): + attacks.combipow_crack(ctx) + ctx.hcatCombipow.assert_called_once() + args = ctx.hcatCombipow.call_args[0] + assert args[0] == "3200" + assert args[1] == "/tmp/bcrypt.txt" + assert args[2] == str(wl) + + def test_warns_about_large_word_count(self, tmp_path, capsys): + attacks = _load_attacks() + ctx = _make_ctx() + wl = tmp_path / "words.txt" + wl.write_text("\n".join(f"word{i}" for i in range(31)) + "\n") + with patch("builtins.input", side_effect=[str(wl), ""]): + attacks.combipow_crack(ctx) + captured = capsys.readouterr() + assert "large" in captured.out.lower() or "warning" in captured.out.lower() + + +# --- hcatCombipow wrapper tests --- + +class TestHcatCombipow: + def _setup_module(self, tmp_path): + """Return main module with hate_path and hcatBin patched.""" + for key in list(sys.modules.keys()): + if "hate_crack" in key: + del sys.modules[key] + if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + import hate_crack.main as m # noqa: PLC0415 + + m.hate_path = str(tmp_path) + m.hcatBin = "hashcat" + m.hcatTuning = "" + m.hcatHashCracked = 0 + m.hcatHashFile = str(tmp_path / "hashes.txt") + + combipow_bin = tmp_path / "hashcat-utils" / "bin" / "combipow.bin" + combipow_bin.parent.mkdir(parents=True, exist_ok=True) + combipow_bin.touch() + + return m + + def test_includes_s_flag_when_use_space_sep_true(self, tmp_path): + m = self._setup_module(tmp_path) + wl = tmp_path / "words.txt" + wl.write_text("word1\nword2\n") + + fake_combipow = MagicMock() + fake_combipow.stdout = MagicMock() + fake_hashcat = MagicMock() + fake_hashcat.pid = 9999 + + out_file = tmp_path / "hashes.txt.out" + out_file.write_text("") + + with patch("subprocess.Popen", side_effect=[fake_combipow, fake_hashcat]) as mock_popen: + with patch.object(m, "lineCount", return_value=0): + m.hcatCombipow("1000", str(tmp_path / "hashes.txt"), str(wl), use_space_sep=True) + + first_call_args = mock_popen.call_args_list[0][0][0] + assert "-s" in first_call_args + + def test_omits_s_flag_when_use_space_sep_false(self, tmp_path): + m = self._setup_module(tmp_path) + wl = tmp_path / "words.txt" + wl.write_text("word1\nword2\n") + + fake_combipow = MagicMock() + fake_combipow.stdout = MagicMock() + fake_hashcat = MagicMock() + fake_hashcat.pid = 9999 + + out_file = tmp_path / "hashes.txt.out" + out_file.write_text("") + + with patch("subprocess.Popen", side_effect=[fake_combipow, fake_hashcat]) as mock_popen: + with patch.object(m, "lineCount", return_value=0): + m.hcatCombipow("1000", str(tmp_path / "hashes.txt"), str(wl), use_space_sep=False) + + first_call_args = mock_popen.call_args_list[0][0][0] + assert "-s" not in first_call_args + + def test_wordlist_passed_as_argument(self, tmp_path): + m = self._setup_module(tmp_path) + wl = tmp_path / "words.txt" + wl.write_text("word1\nword2\n") + + fake_combipow = MagicMock() + fake_combipow.stdout = MagicMock() + fake_hashcat = MagicMock() + fake_hashcat.pid = 9999 + + out_file = tmp_path / "hashes.txt.out" + out_file.write_text("") + + with patch("subprocess.Popen", side_effect=[fake_combipow, fake_hashcat]) as mock_popen: + with patch.object(m, "lineCount", return_value=0): + m.hcatCombipow("1000", str(tmp_path / "hashes.txt"), str(wl), use_space_sep=True) + + first_call_args = mock_popen.call_args_list[0][0][0] + assert str(wl) in first_call_args diff --git a/tests/test_ui_menu_options.py b/tests/test_ui_menu_options.py index fd3bfa6..f494f7c 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"), + ("21", CLI_MODULE._attacks, "combipow_crack", "combipow"), ("90", CLI_MODULE, "download_hashmob_rules", "hashmob-rules"), ("91", CLI_MODULE, "weakpass_wordlist_menu", "weakpass-menu"), ("92", CLI_MODULE, "download_hashmob_wordlists", "hashmob-wordlists"),