diff --git a/README.md b/README.md index 048a541..9268ed7 100644 --- a/README.md +++ b/README.md @@ -621,6 +621,7 @@ All tests use mocked API calls, so they can run without connectivity to a Hashvi (19) N-gram Attack (20) Permutation Attack (21) Random Rules Attack + (22) Combipow Passphrase Attack (90) Download rules from Hashmob.net (91) Analyze Hashcat Rules @@ -810,6 +811,15 @@ Generates a set of random hashcat mutation rules using `generate-rules.bin`, wri * Temporary rules file is cleaned up after the run regardless of outcome * Useful when known rule sets are exhausted - explores random rule-space for additional cracks +#### 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 8ac3762..fbb78b6 100755 --- a/hate_crack.py +++ b/hate_crack.py @@ -91,6 +91,7 @@ def get_main_menu_options(): "19": _attacks.ngram_attack, "20": _attacks.permute_crack, "21": _attacks.generate_rules_crack, + "22": _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 d30f1d0..6b79f42 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -1,4 +1,5 @@ import glob +import gzip import os import readline from typing import Any @@ -637,6 +638,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 (gzip.open(path, "rb") if path.endswith(".gz") else open(path, "rb")) 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 generate_rules_crack(ctx: Any) -> None: print("\n" + "=" * 60) print("RANDOM RULES ATTACK") diff --git a/hate_crack/main.py b/hate_crack/main.py index 739b0f5..6897c69 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -2343,6 +2343,60 @@ 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") + + tmp_file = None + if wordlist.endswith(".gz"): + tmp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".txt") + with gzip.open(wordlist, "rb") as gz_in: + tmp_file.write(gz_in.read()) + tmp_file.close() + wordlist_path = tmp_file.name + else: + wordlist_path = wordlist + + generator_cmd = [combipow_bin] + if use_space_sep: + generator_cmd.append("-s") + generator_cmd.append(wordlist_path) + 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() + finally: + if tmp_file is not None: + with contextlib.suppress(OSError): + os.unlink(tmp_file.name) + + # PRINCE Attack def hcatPrince(hcatHashType, hcatHashFile): global hcatProcess @@ -3598,6 +3652,10 @@ def omen_attack(): return _attacks.omen_attack(_attack_ctx()) +def combipow_crack(): + return _attacks.combipow_crack(_attack_ctx()) + + def generate_rules_crack(): return _attacks.generate_rules_crack(_attack_ctx()) @@ -3837,6 +3895,7 @@ def get_main_menu_items(): ("19", "N-gram Attack"), ("20", "Permutation Attack"), ("21", "Random Rules Attack"), + ("22", "Combipow Passphrase Attack"), ("90", "Download rules from Hashmob.net"), ("91", "Analyze Hashcat Rules"), ("92", "Download wordlists from Hashmob.net"), @@ -3877,6 +3936,7 @@ def get_main_menu_options(): "19": ngram_attack, "20": permute_crack, "21": generate_rules_crack, + "22": 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..4952ef6 --- /dev/null +++ b/tests/test_combipow_attack.py @@ -0,0 +1,197 @@ +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.""" + 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(): + 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 "22" 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 "22" in keys + labels = {k: label for k, label in items} + assert "passphrase" in labels["22"].lower() or "combipow" in labels["22"].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 --- + +def _get_main_module(): + if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + import hate_crack.main as m # noqa: PLC0415 + + return m + + +class TestHcatCombipow: + def _run(self, tmp_path, wl, use_space_sep): + """Run hcatCombipow with module globals patched via context managers.""" + m = _get_main_module() + hash_file = 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() + + fake_combipow = MagicMock() + fake_combipow.stdout = MagicMock() + fake_hashcat = MagicMock() + fake_hashcat.pid = 9999 + + with ( + patch.object(m, "hate_path", str(tmp_path)), + patch.object(m, "hcatBin", "hashcat"), + patch.object(m, "hcatTuning", ""), + patch("hate_crack.main.hcatHashFile", hash_file, create=True), + patch("hate_crack.main.subprocess.Popen", side_effect=[fake_combipow, fake_hashcat]) as mock_popen, + ): + m.hcatCombipow("1000", hash_file, str(wl), use_space_sep=use_space_sep) + + return mock_popen + + def test_includes_s_flag_when_use_space_sep_true(self, tmp_path): + wl = tmp_path / "words.txt" + wl.write_text("word1\nword2\n") + mock_popen = self._run(tmp_path, 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): + wl = tmp_path / "words.txt" + wl.write_text("word1\nword2\n") + mock_popen = self._run(tmp_path, 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): + wl = tmp_path / "words.txt" + wl.write_text("word1\nword2\n") + mock_popen = self._run(tmp_path, 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 f99f69c..4815330 100644 --- a/tests/test_ui_menu_options.py +++ b/tests/test_ui_menu_options.py @@ -29,6 +29,7 @@ MENU_OPTION_TEST_CASES = [ ("19", CLI_MODULE._attacks, "ngram_attack", "ngram"), ("20", CLI_MODULE._attacks, "permute_crack", "permute"), ("21", CLI_MODULE._attacks, "generate_rules_crack", "random-rules"), + ("22", 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"),