From 09ac410ace023f00de0f8a273eb46015660dd3cd Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 6 Feb 2026 14:36:15 -0500 Subject: [PATCH] updated loopback logic and added testing --- hate_crack.py | 1 + hate_crack/attacks.py | 97 +++++++++++++++++++ hate_crack/main.py | 84 ++++++++++++----- tests/test_hashcat_rules.py | 172 ++++++++++++++++++++++++++++++++++ tests/test_ui_menu_options.py | 1 + 5 files changed, 330 insertions(+), 25 deletions(-) create mode 100644 tests/test_hashcat_rules.py diff --git a/hate_crack.py b/hate_crack.py index bbd3a42..d76b49f 100755 --- a/hate_crack.py +++ b/hate_crack.py @@ -82,6 +82,7 @@ def get_main_menu_options(): "11": _attacks.middle_combinator, "12": _attacks.thorough_combinator, "13": _attacks.bandrel_method, + "14": _attacks.loopback_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 9baf43d..1f0bf1e 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -170,6 +170,103 @@ def quick_crack(ctx: Any) -> None: ) +def loopback_attack(ctx: Any) -> None: + empty_wordlist = os.path.join(ctx.hcatWordlists, "empty.txt") + os.makedirs(ctx.hcatWordlists, exist_ok=True) + if not os.path.exists(empty_wordlist): + with open(empty_wordlist, "w"): + pass + + print(f"\nUsing loopback attack with wordlist: {empty_wordlist}") + + rule_choice = None + selected_hcatRules = [] + + rule_files = sorted( + f for f in os.listdir(ctx.rulesDirectory) if f != ".DS_Store" + ) + if not rule_files: + download_rules = ( + input("\nNo rules found. Download rules from Hashmob now? (Y/n): ") + .strip() + .lower() + ) + if download_rules in ("", "y", "yes"): + download_hashmob_rules(print_fn=print) + rule_files = sorted(os.listdir(ctx.rulesDirectory)) + + if not rule_files: + print("No rules available. Proceeding without rules.") + rule_choice = ["0"] + else: + print("\nWhich rule(s) would you like to run?") + rule_entries = ["0. To run without any rules"] + rule_entries.extend( + [f"{i}. {file}" for i, file in enumerate(rule_files, start=1)] + ) + rule_entries.append("98. YOLO...run all of the rules") + rule_entries.append("99. Back to Main Menu") + max_rule_len = max((len(e) for e in rule_entries), default=26) + print_multicolumn_list( + "Available Rules", + rule_entries, + min_col_width=max_rule_len, + max_col_width=max_rule_len, + ) + + example_line = "" + if len(rule_files) >= 2: + example_line = f"For example 1+1 will run {rule_files[0]} chained twice and 1,2 would run {rule_files[0]} and then {rule_files[1]} sequentially.\n" + elif len(rule_files) == 1: + example_line = f"For example 1+1 will run {rule_files[0]} chained twice.\n" + + while rule_choice is None: + raw_choice = input( + "Enter Comma separated list of rules you would like to run. To run rules chained use the + symbol.\n" + f"{example_line}" + "Choose wisely: " + ) + if raw_choice.strip() == "99": + return + if raw_choice != "": + rule_choice = raw_choice.split(",") + + if "99" in rule_choice: + return + if "98" in rule_choice: + for rule in rule_files: + selected_hcatRules.append(f"-r {ctx.rulesDirectory}/{rule}") + elif "0" in rule_choice: + selected_hcatRules = [""] + else: + for choice in rule_choice: + if "+" in choice: + combined_choice = "" + choices = choice.split("+") + for rule in choices: + try: + combined_choice = f"{combined_choice} -r {ctx.rulesDirectory}/{rule_files[int(rule) - 1]}" + except Exception: + continue + selected_hcatRules.append(combined_choice) + else: + try: + selected_hcatRules.append( + f"-r {ctx.rulesDirectory}/{rule_files[int(choice) - 1]}" + ) + except IndexError: + continue + + for chain in selected_hcatRules: + ctx.hcatQuickDictionary( + ctx.hcatHashType, + ctx.hcatHashFile, + chain, + empty_wordlist, + loopback=True, + ) + + def extensive_crack(ctx: Any) -> None: ctx.hcatBruteForce(ctx.hcatHashType, ctx.hcatHashFile, "1", "7") ctx.hcatRecycle(ctx.hcatHashType, ctx.hcatHashFile, ctx.hcatBruteCount) diff --git a/hate_crack/main.py b/hate_crack/main.py index a594ab0..47faa9c 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -268,6 +268,23 @@ hcatWordlists = config_parser["hcatWordlists"] hcatOptimizedWordlists = config_parser["hcatOptimizedWordlists"] hcatRules: list[str] = [] + +# Optional: override hashcat's default potfile location. +# If unset/empty, we use hashcat's built-in default potfile behavior. +hcatPotfilePath = (config_parser.get("hcatPotfilePath") or "").strip() +if hcatPotfilePath: + hcatPotfilePath = os.path.expanduser(hcatPotfilePath) + if not os.path.isabs(hcatPotfilePath): + hcatPotfilePath = os.path.join(hate_path, hcatPotfilePath) + + +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: + cmd.append(f"--potfile-path={pot}") + try: rulesDirectory = config_parser["rules_directory"] except KeyError as e: @@ -834,7 +851,8 @@ def _run_hashcat_show(hash_type, hash_file, output_path): [ hcatBin, "--show", - f"--potfile-path={hate_path}/hashcat.pot", + # Use hashcat's built-in potfile unless configured otherwise. + *([f"--potfile-path={hcatPotfilePath}"] if hcatPotfilePath else []), "-m", str(hash_type), hash_file, @@ -865,7 +883,7 @@ def hcatBruteForce(hcatHashType, hcatHashFile, hcatMinLen, hcatMaxLen): "?a?a?a?a?a?a?a?a?a?a?a?a?a?a", ] cmd.extend(shlex.split(hcatTuning)) - cmd.append(f"--potfile-path={hate_path}/hashcat.pot") + _append_potfile_arg(cmd) hcatProcess = subprocess.Popen(cmd) try: hcatProcess.wait() @@ -897,7 +915,7 @@ def hcatDictionary(hcatHashType, hcatHashFile): cmd.extend(optimized_lists) cmd.extend(["-r", rule_best66]) cmd.extend(shlex.split(hcatTuning)) - cmd.append(f"--potfile-path={hate_path}/hashcat.pot") + _append_potfile_arg(cmd) hcatProcess = subprocess.Popen(cmd) try: hcatProcess.wait() @@ -921,7 +939,7 @@ def hcatDictionary(hcatHashType, hcatHashFile): rule_d3ad0ne, ] cmd.extend(shlex.split(hcatTuning)) - cmd.append(f"--potfile-path={hate_path}/hashcat.pot") + _append_potfile_arg(cmd) hcatProcess = subprocess.Popen(cmd) try: hcatProcess.wait() @@ -944,7 +962,7 @@ def hcatDictionary(hcatHashType, hcatHashFile): rule_toxic, ] cmd.extend(shlex.split(hcatTuning)) - cmd.append(f"--potfile-path={hate_path}/hashcat.pot") + _append_potfile_arg(cmd) hcatProcess = subprocess.Popen(cmd) try: hcatProcess.wait() @@ -956,7 +974,15 @@ def hcatDictionary(hcatHashType, hcatHashFile): # Quick Dictionary Attack (Optional Chained Rules) -def hcatQuickDictionary(hcatHashType, hcatHashFile, hcatChains, wordlists): +def hcatQuickDictionary( + hcatHashType, + hcatHashFile, + hcatChains, + wordlists, + loopback=False, + use_potfile_path=False, + potfile_path=None, +): global hcatProcess cmd = [ hcatBin, @@ -972,10 +998,12 @@ def hcatQuickDictionary(hcatHashType, hcatHashFile, hcatChains, wordlists): cmd.extend(wordlists) else: cmd.append(wordlists) + if loopback: + cmd.append("--loopback") if hcatChains: cmd.extend(shlex.split(hcatChains)) cmd.extend(shlex.split(hcatTuning)) - cmd.append(f"--potfile-path={hate_path}/hashcat.pot") + _append_potfile_arg(cmd, use_potfile_path=use_potfile_path, potfile_path=potfile_path) hcatProcess = subprocess.Popen(cmd) try: hcatProcess.wait() @@ -1040,7 +1068,7 @@ def hcatTopMask(hcatHashType, hcatHashFile, hcatTargetTime): f"{hcatHashFile}.hcmask", ] cmd.extend(shlex.split(hcatTuning)) - cmd.append(f"--potfile-path={hate_path}/hashcat.pot") + _append_potfile_arg(cmd) hcatProcess = subprocess.Popen(cmd) try: hcatProcess.wait() @@ -1094,7 +1122,7 @@ def hcatFingerprint(hcatHashType, hcatHashFile): f"{hcatHashFile}.expanded", f"{hcatHashFile}.expanded", *shlex.split(hcatTuning), - f"--potfile-path={hate_path}/hashcat.pot", + *([f"--potfile-path={hcatPotfilePath}"] if hcatPotfilePath else []), ] ) try: @@ -1125,7 +1153,7 @@ def hcatCombination(hcatHashType, hcatHashFile): hcatCombinationWordlist[1], ] cmd.extend(shlex.split(hcatTuning)) - cmd.append(f"--potfile-path={hate_path}/hashcat.pot") + _append_potfile_arg(cmd) hcatProcess = subprocess.Popen(cmd) try: hcatProcess.wait() @@ -1182,7 +1210,7 @@ def hcatHybrid(hcatHashType, hcatHashFile, wordlists=None): *args, ] cmd.extend(shlex.split(hcatTuning)) - cmd.append(f"--potfile-path={hate_path}/hashcat.pot") + _append_potfile_arg(cmd) hcatProcess = subprocess.Popen(cmd) try: hcatProcess.wait() @@ -1217,7 +1245,7 @@ def hcatYoloCombination(hcatHashType, hcatHashFile): right_path, ] cmd.extend(shlex.split(hcatTuning)) - cmd.append(f"--potfile-path={hate_path}/hashcat.pot") + _append_potfile_arg(cmd) hcatProcess = subprocess.Popen(cmd) try: hcatProcess.wait() @@ -1266,7 +1294,7 @@ def hcatBandrel(hcatHashType, hcatHashFile): mask2.strip(), ] cmd.extend(shlex.split(hcatTuning)) - cmd.append(f"--potfile-path={hate_path}/hashcat.pot") + _append_potfile_arg(cmd) hcatProcess = subprocess.Popen(cmd) try: hcatProcess.wait() @@ -1309,7 +1337,7 @@ def hcatBandrel(hcatHashType, hcatHashFile): mask2.strip(), ] cmd.extend(shlex.split(hcatTuning)) - cmd.append(f"--potfile-path={hate_path}/hashcat.pot") + _append_potfile_arg(cmd) hcatProcess = subprocess.Popen(cmd) try: hcatProcess.wait() @@ -1351,7 +1379,7 @@ def hcatMiddleCombinator(hcatHashType, hcatHashFile): masks[x], hcatMiddleBaseList, hcatMiddleBaseList, - f"--potfile-path={hate_path}/hashcat.pot", + *([f"--potfile-path={hcatPotfilePath}"] if hcatPotfilePath else []), ] hcatProcess = subprocess.Popen(cmd) try: @@ -1395,7 +1423,7 @@ def hcatThoroughCombinator(hcatHashType, hcatHashFile): hcatThoroughBaseList, ] cmd.extend(shlex.split(hcatTuning)) - cmd.append(f"--potfile-path={hate_path}/hashcat.pot") + _append_potfile_arg(cmd) hcatProcess = subprocess.Popen(cmd) try: hcatProcess.wait() @@ -1420,7 +1448,7 @@ def hcatThoroughCombinator(hcatHashType, hcatHashFile): masks[x], hcatThoroughBaseList, hcatThoroughBaseList, - f"--potfile-path={hate_path}/hashcat.pot", + *([f"--potfile-path={hcatPotfilePath}"] if hcatPotfilePath else []), ] hcatProcess = subprocess.Popen(cmd) try: @@ -1450,7 +1478,7 @@ def hcatThoroughCombinator(hcatHashType, hcatHashFile): hcatThoroughBaseList, ] cmd.extend(shlex.split(hcatTuning)) - cmd.append(f"--potfile-path={hate_path}/hashcat.pot") + _append_potfile_arg(cmd) hcatProcess = subprocess.Popen(cmd) try: hcatProcess.wait() @@ -1481,7 +1509,7 @@ def hcatThoroughCombinator(hcatHashType, hcatHashFile): hcatThoroughBaseList, ] cmd.extend(shlex.split(hcatTuning)) - cmd.append(f"--potfile-path={hate_path}/hashcat.pot") + _append_potfile_arg(cmd) hcatProcess = subprocess.Popen(cmd) hcatProcess.wait() except KeyboardInterrupt: @@ -1506,7 +1534,7 @@ def hcatPathwellBruteForce(hcatHashType, hcatHashFile): os.path.join(hate_path, "masks", "pathwell.hcmask"), ] cmd.extend(shlex.split(hcatTuning)) - cmd.append(f"--potfile-path={hate_path}/hashcat.pot") + _append_potfile_arg(cmd) hcatProcess = subprocess.Popen(cmd) try: hcatProcess.wait() @@ -1548,7 +1576,7 @@ def hcatPrince(hcatHashType, hcatHashFile): prince_rule, ] hashcat_cmd.extend(shlex.split(hcatTuning)) - hashcat_cmd.append(f"--potfile-path={hate_path}/hashcat.pot") + _append_potfile_arg(hashcat_cmd) with open(prince_base, "rb") as base: prince_proc = subprocess.Popen(prince_cmd, stdin=base, stdout=subprocess.PIPE) hcatProcess = subprocess.Popen(hashcat_cmd, stdin=prince_proc.stdout) @@ -1584,7 +1612,7 @@ def hcatGoodMeasure(hcatHashType, hcatHashFile): hcatGoodMeasureBaseList, ] cmd.extend(shlex.split(hcatTuning)) - cmd.append(f"--potfile-path={hate_path}/hashcat.pot") + _append_potfile_arg(cmd) hcatProcess = subprocess.Popen(cmd) try: hcatProcess.wait() @@ -1617,7 +1645,7 @@ def hcatLMtoNT(): "?1?1?1?1?1?1?1", ] cmd.extend(shlex.split(hcatTuning)) - cmd.append(f"--potfile-path={hate_path}/hashcat.pot") + _append_potfile_arg(cmd) hcatProcess = subprocess.Popen(cmd) try: hcatProcess.wait() @@ -1667,7 +1695,7 @@ def hcatLMtoNT(): ), ] cmd.extend(shlex.split(hcatTuning)) - cmd.append(f"--potfile-path={hate_path}/hashcat.pot") + _append_potfile_arg(cmd) hcatProcess = subprocess.Popen(cmd) try: hcatProcess.wait() @@ -1706,7 +1734,7 @@ def hcatRecycle(hcatHashType, hcatHashFile, hcatNewPasswords): rule_path, ] cmd.extend(shlex.split(hcatTuning)) - cmd.append(f"--potfile-path={hate_path}/hashcat.pot") + _append_potfile_arg(cmd) hcatProcess = subprocess.Popen(cmd) try: hcatProcess.wait() @@ -2412,6 +2440,10 @@ def bandrel_method(): return _attacks.bandrel_method(_attack_ctx()) +def loopback_attack(): + return _attacks.loopback_attack(_attack_ctx()) + + # convert hex words for recycling def convert_hex(working_file): processed_words = [] @@ -2605,6 +2637,7 @@ def get_main_menu_options(): "11": middle_combinator, "12": thorough_combinator, "13": bandrel_method, + "14": loopback_attack, "90": download_hashmob_rules, "91": weakpass_wordlist_menu, "92": download_hashmob_wordlists, @@ -3138,6 +3171,7 @@ def main(): print("\t(11) Middle Combinator Attack") print("\t(12) Thorough Combinator Attack") print("\t(13) Bandrel Methodology") + print("\t(14) Loopback Attack") print("\n\t(90) Download rules from Hashmob.net") print("\n\t(91) Download wordlists from Weakpass") print("\t(92) Download wordlists from Hashmob.net") diff --git a/tests/test_hashcat_rules.py b/tests/test_hashcat_rules.py new file mode 100644 index 0000000..170f257 --- /dev/null +++ b/tests/test_hashcat_rules.py @@ -0,0 +1,172 @@ +import os +import json +import shutil +import subprocess +import shlex +from pathlib import Path + +import pytest + + +_TEST_HASH = "994a24ad0d9ac6f1fd7d4d75adffeda2" + + +def _format_hashcat_cmd(cmd: list[str]) -> str: + # Mirror hate_crack's debug printing: safe shell-style quoting. + return " ".join(shlex.quote(part) for part in cmd) + + +def _get_hcat_tuning_args(repo_root: Path) -> list[str]: + config_path = repo_root / "config.json" + if not config_path.is_file(): + return [] + try: + config = json.loads(config_path.read_text()) + except Exception: + return [] + + tuning = (config.get("hcatTuning") or "").strip() + if not tuning: + return [] + return shlex.split(tuning) + + +def _hashcat_sessions_writable() -> bool: + """ + Hashcat writes session files under ~/.hashcat/sessions on macOS/homebrew builds. + If that location is not writable (sandbox/MDM), running hashcat will emit stderr. + """ + sessions_dir = Path.home() / ".hashcat" / "sessions" + try: + sessions_dir.mkdir(parents=True, exist_ok=True) + probe = sessions_dir / f"pytest_write_probe_{os.getpid()}" + probe.write_text("probe") + probe.unlink(missing_ok=True) + return True + except Exception: + return False + + +def _run_hashcat( + cmd: list[str], + cwd: Path, + *, + timeout_s: int = 60, + capsys=None, + show_output: bool = False, + show_cmd: bool = False, +) -> subprocess.CompletedProcess: + """ + Run hashcat and skip (not fail) on common local-environment issues. + + This repo's normal test suite is offline/mocked; this test is opt-in and + depends on a local hashcat installation that can write its session files. + """ + if show_cmd and capsys is not None: + with capsys.disabled(): + print("\n[DEBUG] hashcat cmd: " + _format_hashcat_cmd(cmd)) + + try: + result = subprocess.run( + cmd, + cwd=str(cwd), + capture_output=True, + text=True, + timeout=timeout_s, + ) + except FileNotFoundError: + pytest.skip("hashcat not available in PATH") + except subprocess.TimeoutExpired: + pytest.fail(f"hashcat timed out after {timeout_s}s: {cmd!r}") + + combined = (result.stdout or "") + (result.stderr or "") + + if show_output and capsys is not None: + with capsys.disabled(): + print("\n[hashcat stdout]\n" + (result.stdout or "")) + print("\n[hashcat stderr]\n" + (result.stderr or "")) + + # If hashcat crashed, subprocess uses a negative return code (signal). + if result.returncode < 0: + pytest.fail( + f"hashcat terminated by signal {-result.returncode}. stdout={result.stdout!r} stderr={result.stderr!r}" + ) + + # Per request: fail on any stderr output (warnings/errors are treated as failure). + if (result.stderr or "").strip(): + pytest.fail( + f"hashcat wrote to stderr (treated as failure). cmd={_format_hashcat_cmd(cmd)!r} stderr={result.stderr!r}" + ) + + assert "Segmentation fault" not in combined + assert "core dumped" not in combined.lower() + + return result + + +def test_toggle_rule_parses_with_and_without_loopback(tmp_path: Path, capsys): + """ + Execute the two hashcat command-lines requested (with an empty wordlist), + primarily to ensure hashcat does not crash while parsing/using the rule file. + """ + if shutil.which("hashcat") is None: + pytest.skip("hashcat not available in PATH") + if not _hashcat_sessions_writable(): + pytest.skip("hashcat session directory (~/.hashcat/sessions) is not writable") + + show_output = os.environ.get("HATE_CRACK_SHOW_HASHCAT_OUTPUT") == "1" + show_cmd = ( + os.environ.get("HATE_CRACK_SHOW_HASHCAT_CMD") == "1" + or os.environ.get("HATE_CRACK_SHOW_HASHCAT_OUTPUT") == "1" + ) + + repo_root = Path(__file__).resolve().parents[1] + tuning_args = _get_hcat_tuning_args(repo_root) + src_rule = repo_root / "rules" / "toggles-lm-ntlm.rule" + if not src_rule.is_file(): + pytest.skip("rules/toggles-lm-ntlm.rule not found") + + # Mirror the requested relative `rules/...` path in a temp working dir. + (tmp_path / "rules").mkdir(parents=True, exist_ok=True) + rule_path = tmp_path / "rules" / "toggles-lm-ntlm.rule" + shutil.copy2(src_rule, rule_path) + + # Equivalent to: `echo > empty.txt` + (tmp_path / "empty.txt").write_text("") + + cmd_with_loopback = [ + "hashcat", + *tuning_args, + "-m", + "1000", + "empty.txt", + "--loopback", + "-r", + "rules/toggles-lm-ntlm.rule", + _TEST_HASH, + ] + cmd_without_loopback = [ + "hashcat", + *tuning_args, + "-m", + "1000", + "empty.txt", + "-r", + "rules/toggles-lm-ntlm.rule", + _TEST_HASH, + ] + + _run_hashcat( + cmd_with_loopback, + cwd=tmp_path, + capsys=capsys, + show_output=show_output, + show_cmd=show_cmd, + ) + _run_hashcat( + cmd_without_loopback, + cwd=tmp_path, + capsys=capsys, + show_output=show_output, + show_cmd=show_cmd, + ) diff --git a/tests/test_ui_menu_options.py b/tests/test_ui_menu_options.py index 92d3399..798d5a1 100644 --- a/tests/test_ui_menu_options.py +++ b/tests/test_ui_menu_options.py @@ -24,6 +24,7 @@ MENU_OPTION_TEST_CASES = [ ("11", CLI_MODULE._attacks, "middle_combinator", "middle"), ("12", CLI_MODULE._attacks, "thorough_combinator", "thorough"), ("13", CLI_MODULE._attacks, "bandrel_method", "bandrel"), + ("14", CLI_MODULE._attacks, "loopback_attack", "loopback"), ("90", CLI_MODULE, "download_hashmob_rules", "hashmob-rules"), ("91", CLI_MODULE, "weakpass_wordlist_menu", "weakpass-menu"), ("92", CLI_MODULE, "download_hashmob_wordlists", "hashmob-wordlists"),