From 61ee3b50d75b6460d47c0a5c4ffdeff1901d70f7 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Mon, 4 May 2026 08:53:12 -0400 Subject: [PATCH] =?UTF-8?q?feat(pcfg):=20add=20hcatPCFG=20attack=20(mode?= =?UTF-8?q?=20A=20=E2=80=94=20pcfg=5Fguesser=20piped=20to=20hashcat)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- hate_crack/main.py | 47 ++++++++++++++++++++++++++++++-- tests/test_main_pcfg.py | 60 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 tests/test_main_pcfg.py diff --git a/hate_crack/main.py b/hate_crack/main.py index 91b9dd3..d493891 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -914,14 +914,17 @@ def _add_debug_mode_for_rules(cmd): # Sanitize filename for use as hashcat session name -def generate_session_id(): +def generate_session_id(hash_file=None): """Sanitize the hashfile name for use as a hashcat session name Hashcat session names can only contain alphanumeric characters, hyphens, and underscores. This function removes the file extension and replaces problematic characters. + + Args: + hash_file: Optional explicit path; falls back to the ``hcatHashFile`` global when omitted. """ # Get just the filename without path - filename = os.path.basename(hcatHashFile) + filename = os.path.basename(hash_file if hash_file is not None else hcatHashFile) # Remove extension name_without_ext = os.path.splitext(filename)[0] # Replace any non-alphanumeric chars (except - and _) with underscore @@ -2614,6 +2617,46 @@ def hcatPrince(hcatHashType, hcatHashFile): prince_proc.stdout.close() +def hcatPCFG(hcatHashType, hcatHashFile): + """Mode A: pipe pcfg_guesser.py output into hashcat in stdin mode.""" + pcfg_guesser_script = os.path.join(hate_path, "pcfg_cracker", "pcfg_guesser.py") + if not os.path.isfile(pcfg_guesser_script): + print(f"pcfg_guesser.py not found at {pcfg_guesser_script}") + return + pcfg_cmd = [ + "python3", + pcfg_guesser_script, + "--rule", + pcfgRuleset, + "--limit", + str(pcfgMaxCandidates), + ] + hashcat_cmd = [ + hcatBin, + "-m", + hcatHashType, + hcatHashFile, + "--session", + generate_session_id(hcatHashFile), + "-o", + f"{hcatHashFile}.out", + ] + if _should_use_optimized_kernel("hcatPCFG"): + _insert_optimized_flag(hashcat_cmd) + hashcat_cmd.extend(shlex.split(hcatTuning)) + _append_potfile_arg(hashcat_cmd) + pcfg_proc = subprocess.Popen(pcfg_cmd, stdout=subprocess.PIPE) + _run_hcat_cmd( + hashcat_cmd, + attack_name="PCFG", + hash_file=hcatHashFile, + stdin=pcfg_proc.stdout, + companion_procs=[pcfg_proc], + ) + if pcfg_proc.stdout: + pcfg_proc.stdout.close() + + def hcatPermute(hcatHashType, hcatHashFile, wordlist): global hcatProcess, hcatPermuteCount permute_path = os.path.join(hate_path, "hashcat-utils", "bin", "permute.bin") diff --git a/tests/test_main_pcfg.py b/tests/test_main_pcfg.py new file mode 100644 index 0000000..f27e999 --- /dev/null +++ b/tests/test_main_pcfg.py @@ -0,0 +1,60 @@ +"""Tests for PCFG attack subprocess construction in hate_crack.main.""" +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +@pytest.fixture +def hc_main(monkeypatch): + """Load hate_crack.main with SKIP_INIT and stub external bits.""" + monkeypatch.setenv("HATE_CRACK_SKIP_INIT", "1") + if "hate_crack.main" in sys.modules: + del sys.modules["hate_crack.main"] + import hate_crack.main as m + return m + + +class TestHcatPCFG: + def test_builds_expected_subprocess(self, hc_main, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + Path(hash_file).write_text("dummy") + + captured_calls = [] + + class FakeProc: + def __init__(self, *args, **kwargs): + captured_calls.append((args, kwargs)) + self.stdout = MagicMock() + self.stdout.close = MagicMock() + + with patch("hate_crack.main.subprocess.Popen", side_effect=FakeProc), \ + patch("hate_crack.main._run_hcat_cmd") as mock_run: + hc_main.hcatPCFG("0", hash_file) + + # First Popen call is the pcfg_guesser producer + producer_args, producer_kwargs = captured_calls[0] + producer_cmd = producer_args[0] + assert "python3" in producer_cmd[0] or producer_cmd[0].endswith("python3") + assert any("pcfg_guesser.py" in part for part in producer_cmd) + assert "--rule" in producer_cmd + assert producer_cmd[producer_cmd.index("--rule") + 1] == hc_main.pcfgRuleset + assert "--limit" in producer_cmd + assert producer_cmd[producer_cmd.index("--limit") + 1] == str(hc_main.pcfgMaxCandidates) + + # _run_hcat_cmd was called with attack_name='PCFG' and the hashcat command + assert mock_run.called + kwargs = mock_run.call_args.kwargs + hashcat_cmd = mock_run.call_args.args[0] + assert kwargs["attack_name"] == "PCFG" + assert kwargs["hash_file"] == hash_file + # Hashcat does NOT carry --limit (cap is producer-side) + assert "--limit" not in hashcat_cmd + # Hashcat is in stdin mode (no -a flag) + assert "-a" not in hashcat_cmd + assert "-m" in hashcat_cmd + assert hashcat_cmd[hashcat_cmd.index("-m") + 1] == "0"