Merge feature/pcfg-integration: PCFG and PRINCE-LING attacks

This commit is contained in:
Justin Bollinger
2026-05-04 09:18:59 -04:00
10 changed files with 343 additions and 1 deletions
+4
View File
@@ -14,3 +14,7 @@
path = princeprocessor
url = https://github.com/hashcat/princeprocessor.git
ignore = dirty
[submodule "pcfg_cracker"]
path = pcfg_cracker
url = https://github.com/lakiw/pcfg_cracker.git
ignore = dirty
+1
View File
@@ -36,6 +36,7 @@ submodules-pre:
@test -d hashcat-utils || { echo "Error: missing required directory: hashcat-utils"; exit 1; }
@test -d princeprocessor || { echo "Error: missing required directory: princeprocessor"; exit 1; }
@test -d omen || { echo "Warning: missing directory: omen (OMEN attacks will not be available)"; }
@test -d pcfg_cracker || { echo "Warning: missing directory: pcfg_cracker (PCFG attacks will not be available)"; }
@# Generate per-length expander sources (expander8.c..expander36.c) and patch
@# hashcat-utils Makefiles to compile them. Skips if expander8.c already exists.
@for base in hashcat-utils; do \
+4 -1
View File
@@ -29,6 +29,9 @@
"ollamaNumCtx": 2048,
"omenTrainingList": "rockyou.txt",
"omenMaxCandidates": 50000000,
"pcfgRuleset": "DEFAULT",
"pcfgMaxCandidates": 50000000,
"pcfgPrinceLingMaxCandidates": 10000000,
"check_for_updates": true,
"optimizedKernelAttacks": [
"hcatDictionary", "hcatQuickDictionary", "hcatBandrel", "hcatGoodMeasure",
@@ -36,7 +39,7 @@
"hcatAdHocMask", "hcatMarkovBruteForce", "hcatFingerprint", "hcatCombination",
"hcatCombinator3", "hcatCombinatorX", "hcatHybrid", "hcatYoloCombination",
"hcatMiddleCombinator", "hcatThoroughCombinator", "hcatCombipow", "hcatPrince",
"hcatPermute"
"hcatPermute", "hcatPCFG", "hcatPrinceLing"
],
"notify_enabled": false,
"notify_pushover_token": "",
+2
View File
@@ -93,6 +93,8 @@ def get_main_menu_options():
"20": _attacks.permute_crack,
"21": _attacks.generate_rules_crack,
"22": _attacks.combipow_crack,
"23": _attacks.pcfg_attack,
"24": _attacks.prince_ling_attack,
"80": _attacks.wordlist_tools_submenu,
"81": _attacks.rule_tools_submenu,
"82": notifications_submenu,
+10
View File
@@ -400,6 +400,16 @@ def prince_attack(ctx: Any) -> None:
ctx.hcatPrince(ctx.hcatHashType, ctx.hcatHashFile)
def pcfg_attack(ctx: Any) -> None:
_notify.prompt_notify_for_attack("PCFG")
ctx.hcatPCFG(ctx.hcatHashType, ctx.hcatHashFile)
def prince_ling_attack(ctx: Any) -> None:
_notify.prompt_notify_for_attack("PRINCE-LING")
ctx.hcatPrinceLing(ctx.hcatHashType, ctx.hcatHashFile)
def yolo_combination(ctx: Any) -> None:
_notify.prompt_notify_for_attack("YOLO Combination")
ctx.hcatYoloCombination(ctx.hcatHashType, ctx.hcatHashFile)
+129
View File
@@ -450,6 +450,9 @@ ollamaNumCtx = int(config_parser.get("ollamaNumCtx", 2048))
omenTrainingList = config_parser.get("omenTrainingList", "rockyou.txt")
omenMaxCandidates = int(config_parser.get("omenMaxCandidates", 1000000))
pcfgRuleset = config_parser.get("pcfgRuleset", "DEFAULT")
pcfgMaxCandidates = int(config_parser.get("pcfgMaxCandidates", 50000000))
pcfgPrinceLingMaxCandidates = int(config_parser.get("pcfgPrinceLingMaxCandidates", 10000000))
try:
_cfg_optimized = config_parser["optimizedKernelAttacks"]
@@ -713,6 +716,16 @@ if not SKIP_INIT:
except SystemExit:
print("OMEN attacks will not be available.")
# Verify pcfg_cracker presence (optional, for PCFG attacks)
# pcfg_cracker is pure-Python; we just check the script files exist.
pcfg_guesser_script = os.path.join(hate_path, "pcfg_cracker", "pcfg_guesser.py")
pcfg_prince_ling_script = os.path.join(hate_path, "pcfg_cracker", "prince_ling.py")
if not os.path.isfile(pcfg_guesser_script) or not os.path.isfile(pcfg_prince_ling_script):
print("pcfg_cracker not found at " + os.path.join(hate_path, "pcfg_cracker"))
print("PCFG attacks will not be available. Run 'make' to fetch submodules.")
elif not shutil.which("python3"):
print("python3 not on PATH. PCFG attacks will not be available.")
except Exception as e:
print(f"Module initialization error: {e}")
if not shutil.which("hashcat") and not os.path.exists("/usr/bin/hashcat"):
@@ -2601,6 +2614,110 @@ 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(),
"-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 hcatPrinceLing(hcatHashType, hcatHashFile):
"""Mode B: prince_ling generates a wordlist (with cache+staleness check),
then we delegate to the existing hcatPrince attack with hcatPrinceBaseList
temporarily rebound to the cached wordlist.
"""
global hcatPrinceBaseList
pcfg_root = os.path.join(hate_path, "pcfg_cracker")
prince_ling_script = os.path.join(pcfg_root, "prince_ling.py")
ruleset_dir = os.path.join(pcfg_root, "Rules", pcfgRuleset)
if not os.path.isfile(prince_ling_script):
print(f"prince_ling.py not found at {prince_ling_script}")
return
if not os.path.isdir(ruleset_dir):
print(f"PCFG ruleset not found: {ruleset_dir}")
return
cache_dir = hcatOptimizedWordlists if isinstance(hcatOptimizedWordlists, str) \
else str(hcatOptimizedWordlists)
os.makedirs(cache_dir, exist_ok=True)
cache_path = os.path.join(cache_dir, f"pcfg_prince_ling_{pcfgRuleset}.txt")
tmp_path = cache_path + ".tmp"
# Staleness check: regenerate iff ruleset dir mtime > cache mtime (strict)
needs_regen = True
if os.path.isfile(cache_path):
ruleset_mtime = os.path.getmtime(ruleset_dir)
cache_mtime = os.path.getmtime(cache_path)
if ruleset_mtime <= cache_mtime:
needs_regen = False
if needs_regen:
print(f"[*] Generating prince_ling wordlist -> {cache_path}")
cmd = [
"python3",
prince_ling_script,
"--rule",
pcfgRuleset,
"--output",
tmp_path,
"--size",
str(pcfgPrinceLingMaxCandidates),
]
try:
subprocess.run(cmd, check=True)
os.replace(tmp_path, cache_path)
except (subprocess.CalledProcessError, KeyboardInterrupt, OSError) as e:
# Clean up partial tmp file
if os.path.isfile(tmp_path):
try:
os.remove(tmp_path)
except OSError:
pass
print(f"prince_ling generation failed: {e}")
return
# Delegate to existing PRINCE attack with rebound base list
original_base = hcatPrinceBaseList
hcatPrinceBaseList = [cache_path]
try:
hcatPrince(hcatHashType, hcatHashFile)
finally:
hcatPrinceBaseList = original_base
def hcatPermute(hcatHashType, hcatHashFile, wordlist):
global hcatProcess, hcatPermuteCount
permute_path = os.path.join(hate_path, "hashcat-utils", "bin", "permute.bin")
@@ -3816,6 +3933,14 @@ def permute_crack():
return _attacks.permute_crack(_attack_ctx())
def pcfg_attack():
return _attacks.pcfg_attack(_attack_ctx())
def prince_ling_attack():
return _attacks.prince_ling_attack(_attack_ctx())
def wordlist_filter_len(infile: str, outfile: str, min_len: int, max_len: int) -> bool:
"""Filter wordlist keeping only words between min_len and max_len (inclusive)."""
len_bin = os.path.join(hate_path, "hashcat-utils/bin/len.bin")
@@ -4250,6 +4375,8 @@ def get_main_menu_items():
("20", "Permutation Attack"),
("21", "Random Rules Attack"),
("22", "Combipow Passphrase Attack"),
("23", "PCFG Attack"),
("24", "PRINCE-LING Attack"),
("80", "Wordlist Tools"),
("81", "Rule File Tools"),
("82", "Notifications"),
@@ -4294,6 +4421,8 @@ def get_main_menu_options():
"20": permute_crack,
"21": generate_rules_crack,
"22": combipow_crack,
"23": pcfg_attack,
"24": prince_ling_attack,
"80": wordlist_tools_submenu,
"81": rule_tools_submenu,
"82": notifications_submenu,
Submodule
+1
Submodule pcfg_cracker added at b04bbdadfe
+22
View File
@@ -0,0 +1,22 @@
from unittest.mock import MagicMock
from hate_crack.attacks import pcfg_attack, prince_ling_attack
def _make_ctx(hash_type: str = "1000", hash_file: str = "/tmp/hashes.txt") -> MagicMock:
ctx = MagicMock()
ctx.hcatHashType = hash_type
ctx.hcatHashFile = hash_file
return ctx
def test_pcfg_attack_invokes_hcatPCFG():
ctx = _make_ctx()
pcfg_attack(ctx)
ctx.hcatPCFG.assert_called_once_with("1000", "/tmp/hashes.txt")
def test_prince_ling_attack_invokes_hcatPrinceLing():
ctx = _make_ctx()
prince_ling_attack(ctx)
ctx.hcatPrinceLing.assert_called_once_with("1000", "/tmp/hashes.txt")
+168
View File
@@ -0,0 +1,168 @@
"""Tests for PCFG attack subprocess construction in hate_crack.main."""
import os
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
@pytest.fixture
def main_module(hc_module):
"""Return the underlying hate_crack.main module for direct patching."""
return hc_module._main
class TestHcatPCFG:
def test_builds_expected_subprocess(self, main_module, 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, \
patch.object(main_module, "hcatBin", "hashcat"), \
patch.object(main_module, "hcatTuning", ""), \
patch.object(main_module, "hcatPotfilePath", ""), \
patch.object(main_module, "generate_session_id", return_value="test_session"):
main_module.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] == main_module.pcfgRuleset
assert "--limit" in producer_cmd
assert producer_cmd[producer_cmd.index("--limit") + 1] == str(main_module.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"
# Verify the producer is wired into hashcat's stdin via _run_hcat_cmd
assert kwargs["stdin"] is not None
assert kwargs["companion_procs"] is not None
assert len(kwargs["companion_procs"]) == 1
class TestHcatPrinceLing:
def _setup_pcfg_dirs(self, tmp_path, main_module, monkeypatch):
"""Lay out fake pcfg_cracker/Rules/<ruleset>/ and optimized_wordlists/."""
pcfg_root = tmp_path / "pcfg_cracker"
rules_dir = pcfg_root / "Rules" / "DEFAULT"
rules_dir.mkdir(parents=True)
(rules_dir / "config.txt").write_text("dummy")
# prince_ling script must "exist" for the function to proceed
(pcfg_root / "prince_ling.py").write_text("# stub")
opt_dir = tmp_path / "optimized_wordlists"
opt_dir.mkdir()
monkeypatch.setattr(main_module, "hate_path", str(tmp_path))
monkeypatch.setattr(main_module, "hcatOptimizedWordlists", str(opt_dir))
return rules_dir, opt_dir
def test_regenerates_when_cache_stale(self, main_module, tmp_path, monkeypatch):
rules_dir, opt_dir = self._setup_pcfg_dirs(tmp_path, main_module, monkeypatch)
cache = opt_dir / "pcfg_prince_ling_DEFAULT.txt"
# Cache exists but is older than ruleset
cache.write_text("stale")
old = (rules_dir.stat().st_mtime - 100)
os.utime(cache, (old, old))
run_calls = []
def fake_run(cmd, **kwargs):
run_calls.append(cmd)
# Simulate prince_ling writing the .tmp file
for i, part in enumerate(cmd):
if part == "--output":
Path(cmd[i + 1]).write_text("regenerated")
class R:
returncode = 0
return R()
with patch("hate_crack.main.subprocess.run", side_effect=fake_run), \
patch("hate_crack.main.hcatPrince") as mock_prince:
main_module.hcatPrinceLing("0", str(tmp_path / "hashes.txt"))
# prince_ling subprocess.run was invoked
assert len(run_calls) == 1
cmd = run_calls[0]
assert any("prince_ling.py" in p for p in cmd)
assert "--rule" in cmd
assert cmd[cmd.index("--rule") + 1] == "DEFAULT"
# Uses --size, NOT --limit
assert "--size" in cmd
assert "--limit" not in cmd
# hcatPrince delegated
assert mock_prince.called
def test_skips_regen_when_cache_fresh(self, main_module, tmp_path, monkeypatch):
rules_dir, opt_dir = self._setup_pcfg_dirs(tmp_path, main_module, monkeypatch)
cache = opt_dir / "pcfg_prince_ling_DEFAULT.txt"
cache.write_text("fresh")
# Cache is newer than ruleset
future = rules_dir.stat().st_mtime + 1000
os.utime(cache, (future, future))
with patch("hate_crack.main.subprocess.run") as mock_run, \
patch("hate_crack.main.hcatPrince"):
main_module.hcatPrinceLing("0", str(tmp_path / "hashes.txt"))
# subprocess.run was NOT called for prince_ling
assert not mock_run.called
def test_atomic_cache_write_cleans_tmp_on_failure(self, main_module, tmp_path, monkeypatch):
import subprocess as real_subprocess
rules_dir, opt_dir = self._setup_pcfg_dirs(tmp_path, main_module, monkeypatch)
def boom(cmd, **kwargs):
# Touch the .tmp file then fail (simulates partial write + crash)
for i, part in enumerate(cmd):
if part == "--output":
Path(cmd[i + 1]).write_text("partial")
raise real_subprocess.CalledProcessError(1, cmd)
with patch("hate_crack.main.subprocess.run", side_effect=boom), \
patch("hate_crack.main.hcatPrince"):
main_module.hcatPrinceLing("0", str(tmp_path / "hashes.txt"))
# No real cache file created; tmp file cleaned up
assert not (opt_dir / "pcfg_prince_ling_DEFAULT.txt").exists()
assert not (opt_dir / "pcfg_prince_ling_DEFAULT.txt.tmp").exists()
def test_restores_hcatPrinceBaseList_on_exception(self, main_module, tmp_path, monkeypatch):
rules_dir, opt_dir = self._setup_pcfg_dirs(tmp_path, main_module, monkeypatch)
cache = opt_dir / "pcfg_prince_ling_DEFAULT.txt"
cache.write_text("fresh")
future = rules_dir.stat().st_mtime + 1000
os.utime(cache, (future, future))
original = ["original_base.txt"]
monkeypatch.setattr(main_module, "hcatPrinceBaseList", original)
def boom(*a, **kw):
raise RuntimeError("hcatPrince exploded")
with patch("hate_crack.main.hcatPrince", side_effect=boom), \
pytest.raises(RuntimeError):
main_module.hcatPrinceLing("0", str(tmp_path / "hashes.txt"))
assert main_module.hcatPrinceBaseList == original
+2
View File
@@ -30,6 +30,8 @@ MENU_OPTION_TEST_CASES = [
("20", CLI_MODULE._attacks, "permute_crack", "permute"),
("21", CLI_MODULE._attacks, "generate_rules_crack", "random-rules"),
("22", CLI_MODULE._attacks, "combipow_crack", "combipow"),
("23", CLI_MODULE._attacks, "pcfg_attack", "pcfg"),
("24", CLI_MODULE._attacks, "prince_ling_attack", "prince-ling"),
("80", CLI_MODULE._attacks, "wordlist_tools_submenu", "wordlist-tools"),
("81", CLI_MODULE._attacks, "rule_tools_submenu", "rule-tools"),
("82", CLI_MODULE, "notifications_submenu", "notifications-submenu"),