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/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.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 d06e250..31d2de3 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): @@ -261,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 = ( @@ -270,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: @@ -427,6 +384,66 @@ def middle_combinator(ctx: Any) -> None: ctx.hcatMiddleCombinator(ctx.hcatHashType, ctx.hcatHashFile) +def _prompt_wordlist_paths(ctx, max_count: int) -> list[str]: + """Prompt for wordlist paths one at a time with tab-autocomplete. + + Stops when a blank line is entered or max_count paths have been collected. + Returns a list of resolved, valid file paths. + """ + + 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( + f"\nWordlist #{count} (tab to autocomplete, blank to finish): " + ).strip() + if not raw: + 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 + + +def combinator3_crack(ctx: Any) -> None: + """3-way combinator attack (delegates to unified combinator_crack).""" + combinator_crack(ctx) + + +def combinatorX_crack(ctx: Any) -> None: + """N-way combinator attack (delegates to unified combinator_crack).""" + combinator_crack(ctx) + + +def combinator_3plus_crack(ctx: Any) -> None: + """3+ wordlist combinator (delegates to unified combinator_crack).""" + combinator_crack(ctx) + + def bandrel_method(ctx: Any) -> None: ctx.hcatBandrel(ctx.hcatHashType, ctx.hcatHashFile) @@ -620,11 +637,35 @@ def markov_brute_force(ctx: Any) -> None: ctx.hcatMarkovBruteForce(ctx.hcatHashType, ctx.hcatHashFile, hcatMinLen, hcatMaxLen) -def combinator_submenu(ctx: Any) -> None: - from hate_crack.menu import interactive_menu +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"), + ("1", "Combinator Attack (2-8 wordlists)"), ("2", "YOLO Combinator Attack"), ("3", "Middle Combinator Attack"), ("4", "Thorough Combinator Attack"), diff --git a/hate_crack/main.py b/hate_crack/main.py index e36bc14..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 @@ -356,19 +359,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 +404,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 +563,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) @@ -674,6 +681,9 @@ hcatDictionaryCount = 0 hcatMaskCount = 0 hcatFingerprintCount = 0 hcatCombinationCount = 0 +hcatCombinator3Count = 0 +hcatCombinatorXCount = 0 +hcatNgramXCount = 0 hcatHybridCount = 0 hcatExtraCount = 0 hcatRecycleCount = 0 @@ -691,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. @@ -1415,6 +1456,125 @@ 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") + 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 + + +# 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") + 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 @@ -3301,6 +3461,22 @@ 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_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()) @@ -3557,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"), @@ -3594,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_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 new file mode 100644 index 0000000..25fd5b5 --- /dev/null +++ b/tests/test_combinator3_combinatorX.py @@ -0,0 +1,154 @@ +from unittest.mock import MagicMock, patch + +from hate_crack.attacks import combinator_crack, combinator_submenu + + +def _make_ctx(hash_type="1000", hash_file="/tmp/hashes.txt"): + ctx = MagicMock() + ctx.hcatHashType = hash_type + ctx.hcatHashFile = hash_file + return ctx + + +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 + 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_four_wordlists_calls_hcatCombinatorX(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 + 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_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 + 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_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 + 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() + 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 + 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_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", ""]): + 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 + 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") + assert positional_sep in (None, "") and keyword_sep in (None, "") + + +class TestCombinatorSubmenuUpdated: + def test_submenu_option1_dispatches_to_combinator_crack(self): + ctx = _make_ctx() + with patch("hate_crack.attacks.combinator_crack") as mock_c, patch( + "hate_crack.attacks.interactive_menu", side_effect=["1", "99"] + ): + combinator_submenu(ctx) + mock_c.assert_called_once_with(ctx) + + 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 = [] + + 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 "1" in keys + assert "5" not in keys + assert "6" not 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() 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}")