diff --git a/CHANGELOG.md b/CHANGELOG.md index 539f047..0c8f972 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Dates are omitted for releases predating this file; see the git tags for exact timing. +## [2.14.0] - 2026-07-24 + +### Added + +- **Non-interactive attack subcommands** for scripting (issue #17). Launch a + single attack without the menu: `quick` (wordlist + optional `--rules`), + `dict` (configured-wordlist methodology), `brute` (`--min`/`--max`), and + `topmask` (`--target-time`). Preprocessing prompts auto-accept their + defaults, and the process returns a clean exit code (0 on success, non-zero + on a bad hash file, hash type, wordlist, or rule name). + ## [2.13.1] - 2026-07-24 ### Fixed diff --git a/README.md b/README.md index 32e2738..792fa5f 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,30 @@ You can also use Python directly: python hate_crack.py ``` +### Non-interactive / scripted usage + +For automation you can launch a single attack directly, bypassing the menu. The attack name is the first argument, followed by the hash file and hashcat hash type. Preprocessing prompts (computer-account filtering, LM-first brute force, duplicate-account dedup) auto-accept their defaults in this mode. The process exits `0` on success and non-zero on error (missing hash file, non-numeric hash type, missing wordlist, or an unknown rule filename). + +```bash +# Quick crack: one wordlist + optional rule(s) from the rules directory +hate_crack quick hashes.txt 1000 --wordlist rockyou.txt --rules best64.rule + +# Chain two rules in a single run +hate_crack quick hashes.txt 1000 --wordlist rockyou.txt --rules best64.rule+d3ad0ne.rule + +# Run two rules as two separate passes +hate_crack quick hashes.txt 1000 --wordlist rockyou.txt --rules best64.rule d3ad0ne.rule + +# Canned dictionary methodology (uses your configured wordlists) +hate_crack dict hashes.txt 1000 + +# Brute force lengths 1-8 +hate_crack brute hashes.txt 1000 --min 1 --max 8 + +# Top-mask attack targeting ~4 hours +hate_crack topmask hashes.txt 1000 --target-time 4 +``` + ------------------------------------------------------------------- ## Troubleshooting diff --git a/hate_crack/main.py b/hate_crack/main.py index e13b237..6018b51 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -74,6 +74,7 @@ from hate_crack.cli import ( # noqa: E402 ) from hate_crack import attacks as _attacks # noqa: E402 from hate_crack import llm # noqa: E402 +from hate_crack import noninteractive as _noninteractive # noqa: E402 from hate_crack.progress import spinner # noqa: E402 from hate_crack.menu import interactive_menu # noqa: E402 from hate_crack.username_detect import detect_username_hash_format # noqa: E402 @@ -777,6 +778,7 @@ hcatGenerateRulesCount = 0 hcatPermuteCount = 0 hcatProcess: subprocess.Popen[Any] | None = None debug_mode = False +non_interactive = False hcatUsernamePrefix: bool = False @@ -4075,6 +4077,15 @@ def hashview_api(): print(f"\nError connecting to Hashview: {str(e)}") +def _auto_input(prompt, default=""): + """input() wrapper that returns the default without prompting when running + in non-interactive (scripted) mode. In interactive mode this is identical + to ``input(prompt) or default``.""" + if non_interactive: + return default + return input(prompt) or default + + def _attack_ctx(): ctx = sys.modules.get(__name__) if ctx is None: @@ -4746,6 +4757,7 @@ def main(): global hcatHashFileOrig global lmHashesFound global debug_mode + global non_interactive global hashview_url, hashview_api_key global hcatPath, hcatBin, hcatWordlists, hcatOptimizedWordlists, rulesDirectory global pipalPath, maxruntime, bandrelbasewords @@ -4755,6 +4767,7 @@ def main(): # Initialize global variables hcatHashFile = None + non_interactive = False hcatHashType = None hcatHashFileOrig = None @@ -4841,6 +4854,7 @@ def main(): return parser, hashview_parser subparsers = parser.add_subparsers(dest="command") + _noninteractive.add_attack_subparsers(subparsers) hashview_parser = subparsers.add_parser( "hashview", help="Hashview menu actions" @@ -4967,7 +4981,10 @@ def main(): else: argv = argv_temp # Fallback if subcommand not found - use_subcommand_parser = "hashview" in argv + has_attack_subcommand = any( + arg in _noninteractive.ATTACK_COMMANDS for arg in argv + ) + use_subcommand_parser = "hashview" in argv or has_attack_subcommand parser, hashview_parser = _build_parser( include_positional=not use_subcommand_parser, include_subcommands=use_subcommand_parser, @@ -4976,6 +4993,8 @@ def main(): global debug_mode debug_mode = args.debug + if getattr(args, "command", None) in _noninteractive.ATTACK_COMMANDS: + non_interactive = True # CLI flags override config file. if getattr(args, "no_potfile_path", False): @@ -5274,8 +5293,8 @@ def main(): f"Detected {computer_count} computer account(s)" " (usernames ending with $)." ) - filter_choice = ( - input("Would you like to ignore computer accounts? (Y) ") or "Y" + filter_choice = _auto_input( + "Would you like to ignore computer accounts? (Y) ", "Y" ) if filter_choice.upper() == "Y": filtered_path = f"{hcatHashFile}.filtered" @@ -5297,12 +5316,10 @@ def main(): ) ) or (lineCount(hcatHashFile + ".lm") > 1): lmHashesFound = True - lmChoice = ( - input( - "LM hashes identified. Would you like to brute force" - " the LM hashes first? (Y) " - ) - or "Y" + lmChoice = _auto_input( + "LM hashes identified. Would you like to brute force" + " the LM hashes first? (Y) ", + "Y", ) if lmChoice.upper() == "Y": hcatLMtoNT() @@ -5348,8 +5365,8 @@ def main(): f"Detected {computer_count} computer account(s)" " (usernames ending with $)." ) - filter_choice = ( - input("Would you like to ignore computer accounts? (Y) ") or "Y" + filter_choice = _auto_input( + "Would you like to ignore computer accounts? (Y) ", "Y" ) if filter_choice.upper() == "Y": filtered_path = f"{hcatHashFile}.filtered" @@ -5372,12 +5389,10 @@ def main(): f"Detected {duplicates} duplicate account(s) out of" f" {total} total NetNTLM hashes." ) - dedup_choice = ( - input( - "Would you like to ignore duplicate accounts" - " (keep first occurrence only)? (Y) " - ) - or "Y" + dedup_choice = _auto_input( + "Would you like to ignore duplicate accounts" + " (keep first occurrence only)? (Y) ", + "Y", ) if dedup_choice.upper() == "Y": hcatHashFileOrig = hcatHashFile @@ -5428,6 +5443,9 @@ def main(): else: print("No hashes found in POT file.") + if non_interactive: + sys.exit(_noninteractive.run_noninteractive(_attack_ctx(), args)) + # Display Options try: options = get_main_menu_options() diff --git a/hate_crack/noninteractive.py b/hate_crack/noninteractive.py new file mode 100644 index 0000000..18a23bb --- /dev/null +++ b/hate_crack/noninteractive.py @@ -0,0 +1,145 @@ +"""Non-interactive (scripted) attack entry points for hate_crack. + +These helpers translate parsed argparse namespaces into calls against the +existing ``hcat*`` attack functions on the main module (passed in as ``ctx``, +the same pattern ``attacks.py`` uses). See +``docs/superpowers/specs/2026-07-24-cli-noninteractive-design.md``. +""" + +import os +from typing import Any + +ATTACK_COMMANDS = ("quick", "dict", "brute", "topmask") + + +def build_rule_chains(ctx: Any, rule_tokens: list[str] | None) -> list[str]: + """Convert CLI ``--rules`` tokens into hashcat ``-r`` chain strings. + + Each token becomes one attack pass. A token may chain multiple rule files + with ``+`` (mirroring the interactive rule selector). Filenames resolve + against ``ctx.rulesDirectory``. Returns ``[""]`` when no rules are given + (equivalent to the interactive "run without rules" choice). + + Raises ``FileNotFoundError`` (with the offending filename as its argument) + if any named rule file is missing. + """ + if not rule_tokens: + return [""] + chains = [] + for token in rule_tokens: + chain = "" + for name in token.split("+"): + name = name.strip() + if not name: + continue + path = os.path.join(ctx.rulesDirectory, name) + if not os.path.isfile(path): + raise FileNotFoundError(name) + chain = f"{chain} -r {path}".strip() + if not chain: + raise ValueError(f"Rule token {token!r} resolved to no rule files") + chains.append(chain) + return chains + + +def run_noninteractive(ctx: Any, args: Any) -> int: + """Run a non-interactive attack. Returns a process exit code. + + ``ctx`` is the main module (live ``hcat*`` functions, ``rulesDirectory``, + ``resolve_path``, ``hcatHashType``/``hcatHashFile`` already set by the + preprocessing block in ``main()``). ``args`` is the parsed subparser + namespace whose ``command`` selects the attack. + """ + command = args.command + + if command == "quick": + wordlist = ctx.resolve_path(args.wordlist) + if not wordlist or not os.path.isfile(wordlist): + print(f"Error: wordlist not found: {args.wordlist}") + return 1 + try: + chains = build_rule_chains(ctx, args.rule_files) + except (FileNotFoundError, ValueError) as exc: + print(f"Error: invalid --rules value: {exc}") + return 1 + for chain in chains: + ctx.hcatQuickDictionary( + ctx.hcatHashType, + ctx.hcatHashFile, + chain, + wordlist, + attack_name="Quick Crack", + ) + return 0 + + if command == "dict": + ctx.hcatDictionary(ctx.hcatHashType, ctx.hcatHashFile) + return 0 + + if command == "brute": + ctx.hcatBruteForce( + ctx.hcatHashType, ctx.hcatHashFile, args.min_len, args.max_len + ) + return 0 + + if command == "topmask": + ctx.hcatTopMask(ctx.hcatHashType, ctx.hcatHashFile, args.target_time * 3600) + return 0 + + print(f"Error: unknown non-interactive command: {command}") + return 2 + + +def add_attack_subparsers(subparsers) -> None: + """Register the non-interactive attack subcommands on an argparse + subparsers object (the same one used for ``hashview``). + + Each subcommand carries its own required ``hashfile`` + ``hashtype`` + positionals plus attack-specific flags. + """ + + def _add_target(p): + p.add_argument("hashfile", help="Path to hash file to crack") + p.add_argument("hashtype", help="Hashcat hash type (e.g. 1000 for NTLM)") + + quick = subparsers.add_parser( + "quick", help="Non-interactive quick crack (single wordlist + optional rules)" + ) + _add_target(quick) + quick.add_argument("--wordlist", required=True, help="Path to wordlist file") + quick.add_argument( + "--rules", + nargs="*", + default=[], + dest="rule_files", + metavar="RULE", + help="Rule filename(s) from the rules directory. Chain with '+' " + "(e.g. best64.rule+d3ad0ne.rule). Omit to run without rules.", + ) + + dictp = subparsers.add_parser( + "dict", + help="Non-interactive dictionary methodology (uses configured wordlists)", + ) + _add_target(dictp) + + brute = subparsers.add_parser( + "brute", help="Non-interactive brute force (mask) attack" + ) + _add_target(brute) + brute.add_argument( + "--min", type=int, default=1, dest="min_len", help="Minimum length (default 1)" + ) + brute.add_argument( + "--max", type=int, default=7, dest="max_len", help="Maximum length (default 7)" + ) + + topmask = subparsers.add_parser("topmask", help="Non-interactive top-mask attack") + _add_target(topmask) + topmask.add_argument( + "--target-time", + type=int, + default=4, + dest="target_time", + help="Target completion time in hours (default 4)", + ) diff --git a/tests/test_noninteractive.py b/tests/test_noninteractive.py new file mode 100644 index 0000000..07aba49 --- /dev/null +++ b/tests/test_noninteractive.py @@ -0,0 +1,259 @@ +import os +import sys +from types import SimpleNamespace + +import pytest + +import hate_crack.main as hc_main +from hate_crack import noninteractive as ni + + +def _ctx_with_rules(tmp_path, *rule_names): + rules_dir = tmp_path / "rules" + rules_dir.mkdir() + for name in rule_names: + (rules_dir / name).write_text(":\n") + return SimpleNamespace(rulesDirectory=str(rules_dir)) + + +def test_build_rule_chains_no_rules_returns_empty_chain(tmp_path): + ctx = _ctx_with_rules(tmp_path) + assert ni.build_rule_chains(ctx, []) == [""] + assert ni.build_rule_chains(ctx, None) == [""] + + +def test_build_rule_chains_single_rule(tmp_path): + ctx = _ctx_with_rules(tmp_path, "best64.rule") + chains = ni.build_rule_chains(ctx, ["best64.rule"]) + expected = os.path.join(ctx.rulesDirectory, "best64.rule") + assert chains == [f"-r {expected}"] + + +def test_build_rule_chains_chained_token(tmp_path): + ctx = _ctx_with_rules(tmp_path, "best64.rule", "d3ad0ne.rule") + chains = ni.build_rule_chains(ctx, ["best64.rule+d3ad0ne.rule"]) + a = os.path.join(ctx.rulesDirectory, "best64.rule") + b = os.path.join(ctx.rulesDirectory, "d3ad0ne.rule") + assert chains == [f"-r {a} -r {b}"] + + +def test_build_rule_chains_multiple_tokens_are_separate_passes(tmp_path): + ctx = _ctx_with_rules(tmp_path, "best64.rule", "d3ad0ne.rule") + chains = ni.build_rule_chains(ctx, ["best64.rule", "d3ad0ne.rule"]) + a = os.path.join(ctx.rulesDirectory, "best64.rule") + b = os.path.join(ctx.rulesDirectory, "d3ad0ne.rule") + assert chains == [f"-r {a}", f"-r {b}"] + + +def test_build_rule_chains_missing_file_raises(tmp_path): + ctx = _ctx_with_rules(tmp_path) + with pytest.raises(FileNotFoundError, match="nope.rule"): + ni.build_rule_chains(ctx, ["nope.rule"]) + + +def test_build_rule_chains_empty_token_raises(tmp_path): + ctx = _ctx_with_rules(tmp_path, "best64.rule") + with pytest.raises(ValueError): + ni.build_rule_chains(ctx, ["+"]) + + +def _spy_ctx(tmp_path, **overrides): + calls = [] + + def rec(name): + def _fn(*a, **k): + calls.append((name, a, k)) + return _fn + + ctx = SimpleNamespace( + calls=calls, + hcatHashType="1000", + hcatHashFile=str(tmp_path / "hashes.txt"), + rulesDirectory=str(tmp_path / "rules"), + resolve_path=lambda p: os.path.abspath(os.path.expanduser(p)) if p else None, + hcatQuickDictionary=rec("hcatQuickDictionary"), + hcatDictionary=rec("hcatDictionary"), + hcatBruteForce=rec("hcatBruteForce"), + hcatTopMask=rec("hcatTopMask"), + ) + for k, v in overrides.items(): + setattr(ctx, k, v) + return ctx + + +def test_dispatch_quick_calls_quick_dictionary(tmp_path): + (tmp_path / "rules").mkdir() + (tmp_path / "rules" / "best64.rule").write_text(":\n") + wl = tmp_path / "rockyou.txt" + wl.write_text("password\n") + ctx = _spy_ctx(tmp_path) + args = SimpleNamespace(command="quick", wordlist=str(wl), rule_files=["best64.rule"]) + code = ni.run_noninteractive(ctx, args) + assert code == 0 + assert [c[0] for c in ctx.calls] == ["hcatQuickDictionary"] + name, a, k = ctx.calls[0] + assert a[0] == "1000" + assert a[1] == ctx.hcatHashFile + assert a[2] == f"-r {os.path.join(ctx.rulesDirectory, 'best64.rule')}" + assert a[3] == os.path.abspath(str(wl)) + + +def test_dispatch_quick_missing_wordlist_returns_1(tmp_path): + (tmp_path / "rules").mkdir() + ctx = _spy_ctx(tmp_path) + args = SimpleNamespace(command="quick", wordlist=str(tmp_path / "nope.txt"), rule_files=[]) + assert ni.run_noninteractive(ctx, args) == 1 + assert ctx.calls == [] + + +def test_dispatch_quick_unknown_rule_returns_1(tmp_path): + (tmp_path / "rules").mkdir() + wl = tmp_path / "rockyou.txt" + wl.write_text("password\n") + ctx = _spy_ctx(tmp_path) + args = SimpleNamespace(command="quick", wordlist=str(wl), rule_files=["ghost.rule"]) + assert ni.run_noninteractive(ctx, args) == 1 + assert ctx.calls == [] + + +def test_dispatch_dict_calls_dictionary(tmp_path): + ctx = _spy_ctx(tmp_path) + args = SimpleNamespace(command="dict") + assert ni.run_noninteractive(ctx, args) == 0 + assert ctx.calls[0][0] == "hcatDictionary" + assert ctx.calls[0][1] == ("1000", ctx.hcatHashFile) + + +def test_dispatch_brute_calls_bruteforce_with_lengths(tmp_path): + ctx = _spy_ctx(tmp_path) + args = SimpleNamespace(command="brute", min_len=2, max_len=6) + assert ni.run_noninteractive(ctx, args) == 0 + assert ctx.calls[0] == ("hcatBruteForce", ("1000", ctx.hcatHashFile, 2, 6), {}) + + +def test_dispatch_topmask_converts_hours_to_seconds(tmp_path): + ctx = _spy_ctx(tmp_path) + args = SimpleNamespace(command="topmask", target_time=4) + assert ni.run_noninteractive(ctx, args) == 0 + assert ctx.calls[0] == ("hcatTopMask", ("1000", ctx.hcatHashFile, 4 * 3600), {}) + + +def test_dispatch_unknown_command_returns_2(tmp_path): + ctx = _spy_ctx(tmp_path) + args = SimpleNamespace(command="bogus") + assert ni.run_noninteractive(ctx, args) == 2 + assert ctx.calls == [] + + +def test_dispatch_quick_multiple_rules_runs_each_as_separate_pass(tmp_path): + rules = tmp_path / "rules" + rules.mkdir() + (rules / "best64.rule").write_text(":\n") + (rules / "d3ad0ne.rule").write_text(":\n") + wl = tmp_path / "rockyou.txt" + wl.write_text("password\n") + ctx = _spy_ctx(tmp_path) + args = SimpleNamespace( + command="quick", wordlist=str(wl), rule_files=["best64.rule", "d3ad0ne.rule"] + ) + assert ni.run_noninteractive(ctx, args) == 0 + assert len(ctx.calls) == 2 + chains = [c[1][2] for c in ctx.calls] + assert chains == [ + f"-r {os.path.join(ctx.rulesDirectory, 'best64.rule')}", + f"-r {os.path.join(ctx.rulesDirectory, 'd3ad0ne.rule')}", + ] + + +def _run_main(monkeypatch, argv): + monkeypatch.setattr(sys, "argv", ["hate_crack.py"] + argv) + # main()'s pre-dispatch potfile-recovery step shells out to the real hashcat + # binary, which is absent in CI. Stub it — these tests exercise dispatch + # routing and prompt suppression, not potfile recovery. + monkeypatch.setattr(hc_main, "_run_hashcat_show", lambda *a, **k: None) + with pytest.raises(SystemExit) as excinfo: + hc_main.main() + return excinfo.value.code + + +def _make_ntlm_hashfile(tmp_path): + # Bare 32-hex NTLM hash: exercises the non-pwdump preprocessing branch. + hf = tmp_path / "hashes.txt" + hf.write_text("aad3b435b51404eeaad3b435b51404ee\n") + return hf + + +def test_main_quick_dispatches_without_prompting(monkeypatch, tmp_path): + hf = _make_ntlm_hashfile(tmp_path) + wl = tmp_path / "rockyou.txt" + wl.write_text("password\n") + calls = [] + monkeypatch.setattr( + hc_main, "hcatQuickDictionary", lambda *a, **k: calls.append((a, k)) + ) + monkeypatch.setattr( + "builtins.input", + lambda *a, **k: (_ for _ in ()).throw(AssertionError("prompted")), + ) + code = _run_main(monkeypatch, ["quick", str(hf), "1000", "--wordlist", str(wl)]) + assert code == 0 + assert len(calls) == 1 + + +def test_main_brute_dispatches(monkeypatch, tmp_path): + hf = _make_ntlm_hashfile(tmp_path) + calls = [] + monkeypatch.setattr(hc_main, "hcatBruteForce", lambda *a, **k: calls.append(a)) + code = _run_main( + monkeypatch, ["brute", str(hf), "1000", "--min", "1", "--max", "8"] + ) + assert code == 0 + assert calls[0][2] == 1 and calls[0][3] == 8 + + +def test_main_quick_bad_hashtype_errors(monkeypatch, tmp_path): + hf = _make_ntlm_hashfile(tmp_path) + wl = tmp_path / "w.txt" + wl.write_text("x\n") + code = _run_main( + monkeypatch, ["quick", str(hf), "notanumber", "--wordlist", str(wl)] + ) + assert code != 0 + + +def test_main_quick_with_rules_dispatches(monkeypatch, tmp_path): + hf = _make_ntlm_hashfile(tmp_path) + wl = tmp_path / "rockyou.txt" + wl.write_text("password\n") + rules_dir = tmp_path / "rules" + rules_dir.mkdir() + (rules_dir / "best64.rule").write_text(":\n") + monkeypatch.setattr(hc_main, "rulesDirectory", str(rules_dir)) + calls = [] + monkeypatch.setattr( + hc_main, "hcatQuickDictionary", lambda *a, **k: calls.append(a) + ) + code = _run_main( + monkeypatch, + ["quick", str(hf), "1000", "--wordlist", str(wl), "--rules", "best64.rule"], + ) + assert code == 0 + assert len(calls) == 1 + # the rule chain (4th positional arg) must reference best64.rule + assert "best64.rule" in calls[0][2] + + +def test_main_debug_flag_before_subcommand(monkeypatch, tmp_path): + hf = _make_ntlm_hashfile(tmp_path) + wl = tmp_path / "w.txt" + wl.write_text("x\n") + calls = [] + monkeypatch.setattr( + hc_main, "hcatQuickDictionary", lambda *a, **k: calls.append(a) + ) + code = _run_main( + monkeypatch, + ["--debug", "quick", str(hf), "1000", "--wordlist", str(wl)], + ) + assert code == 0 + assert len(calls) == 1