feat: add PassGPT attack (#17) - GPT-2 based ML password generator

Add PassGPT as attack mode 17, using a GPT-2 model trained on leaked
password datasets to generate candidate passwords. The generator pipes
candidates to hashcat via stdin, matching the existing OMEN pipe pattern.

- Add standalone generator module (python -m hate_crack.passgpt_generate)
- Add [ml] optional dependency group (torch, transformers)
- Add config keys: passgptModel, passgptMaxCandidates, passgptBatchSize
- Wire up menu entries in main.py, attacks.py, and hate_crack.py
- Auto-detect GPU (CUDA/MPS) with CPU fallback
- Add unit tests for pipe construction, handler, and ML deps check

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Justin Bollinger
2026-02-18 08:41:22 -05:00
co-authored by Claude Opus 4.6
parent 2446ef3ed1
commit 39970b41c4
9 changed files with 499 additions and 6 deletions
+29
View File
@@ -550,6 +550,7 @@ Tests automatically run on GitHub Actions for every push and pull request (Ubunt
(14) Loopback Attack
(15) LLM Attack
(16) OMEN Attack
(17) PassGPT Attack
(90) Download rules from Hashmob.net
(91) Analyze Hashcat Rules
@@ -694,6 +695,31 @@ Uses the Ordered Markov ENumerator (OMEN) to train a statistical password model
* Pipes generated candidates directly into hashcat for cracking
* Model files are stored in `~/.hate_crack/omen/` for persistence across sessions
#### PassGPT Attack
Uses PassGPT, a GPT-2 based password generator trained on leaked password datasets, to generate candidate passwords. PassGPT produces higher-quality candidates than traditional Markov models by leveraging transformer-based language modeling.
**Requirements:** ML dependencies must be installed separately:
```bash
uv pip install -e ".[ml]"
```
This installs PyTorch and HuggingFace Transformers. GPU acceleration (CUDA/MPS) is auto-detected but not required.
**Configuration keys:**
* `passgptModel` - HuggingFace model name (default: `javirandor/passgpt-10characters`)
* `passgptMaxCandidates` - Maximum candidates to generate (default: 1000000)
* `passgptBatchSize` - Generation batch size (default: 1024)
**Supported models:**
* `javirandor/passgpt-10characters` - Trained on passwords up to 10 characters (default)
* `javirandor/passgpt-16characters` - Trained on passwords up to 16 characters
* Any compatible GPT-2 model on HuggingFace
**Standalone usage:**
```bash
python -m hate_crack.passgpt_generate --num 1000 --model javirandor/passgpt-10characters
```
#### Download Rules from Hashmob.net
Downloads the latest rule files from Hashmob.net's rule repository. These rules are curated and optimized for password cracking and can be used with the Quick Crack and Loopback Attack modes.
@@ -727,6 +753,9 @@ Interactive menu for downloading and managing wordlists from Weakpass.com via Bi
-------------------------------------------------------------------
### Version History
Version 2.0+
Added PassGPT Attack (option 17) using GPT-2 based ML password generation
Added PassGPT configuration keys (passgptModel, passgptMaxCandidates, passgptBatchSize)
Added [ml] optional dependency group for PyTorch and Transformers
Added OMEN Attack (option 16) using statistical model-based password generation
Added OMEN configuration keys (omenTrainingList, omenMaxCandidates)
Added LLM Attack (option 15) using Ollama for AI-generated password candidates
+5 -1
View File
@@ -26,5 +26,9 @@
"ollamaModel": "mistral",
"ollamaNumCtx": 2048,
"omenTrainingList": "rockyou.txt",
"omenMaxCandidates": 1000000
"omenMaxCandidates": 1000000,
"passgptModel": "javirandor/passgpt-10characters",
"passgptMaxCandidates": 1000000,
"passgptBatchSize": 1024,
"check_for_updates": true
}
+1
View File
@@ -85,6 +85,7 @@ def get_main_menu_options():
"14": _attacks.loopback_attack,
"15": _attacks.ollama_attack,
"16": _attacks.omen_attack,
"17": _attacks.passgpt_attack,
"90": download_hashmob_rules,
"91": weakpass_wordlist_menu,
"92": download_hashmob_wordlists,
+25 -1
View File
@@ -510,7 +510,8 @@ def omen_attack(ctx: Any) -> None:
print("\n\tOMEN binaries not found. Build them with:")
print(f"\t cd {omen_dir} && make")
return
model_exists = os.path.isfile(os.path.join(omen_dir, "IP.level"))
model_dir = os.path.join(os.path.expanduser("~"), ".hate_crack", "omen")
model_exists = os.path.isfile(os.path.join(model_dir, "createConfig"))
if not model_exists:
print("\n\tNo OMEN model found. Training is required before generation.")
training_source = input(
@@ -525,3 +526,26 @@ def omen_attack(ctx: Any) -> None:
if not max_candidates:
max_candidates = str(ctx.omenMaxCandidates)
ctx.hcatOmen(ctx.hcatHashType, ctx.hcatHashFile, int(max_candidates))
def passgpt_attack(ctx: Any) -> None:
print("\n\tPassGPT Attack (ML Password Generator)")
if not ctx.HAS_ML_DEPS:
print("\n\tPassGPT requires ML dependencies. Install them with:")
print('\t uv pip install -e ".[ml]"')
return
max_candidates = input(
f"\n\tMax candidates to generate ({ctx.passgptMaxCandidates}): "
).strip()
if not max_candidates:
max_candidates = str(ctx.passgptMaxCandidates)
model_name = input(f"\n\tModel name ({ctx.passgptModel}): ").strip()
if not model_name:
model_name = ctx.passgptModel
ctx.hcatPassGPT(
ctx.hcatHashType,
ctx.hcatHashFile,
int(max_candidates),
model_name=model_name,
batch_size=ctx.passgptBatchSize,
)
+170 -4
View File
@@ -37,6 +37,15 @@ try:
except Exception:
pass
HAS_ML_DEPS = False
try:
import torch # noqa: F401
import transformers # noqa: F401
HAS_ML_DEPS = True
except Exception:
pass
# Ensure project root is on sys.path so package imports work when loaded via spec.
_root_dir = os.path.dirname(os.path.realpath(__file__))
if _root_dir not in sys.path:
@@ -486,6 +495,42 @@ except KeyError as e:
)
)
omenMaxCandidates = int(default_config.get("omenMaxCandidates", 1000000))
try:
passgptModel = config_parser["passgptModel"]
except KeyError as e:
print(
"{0} is not defined in config.json using defaults from config.json.example".format(
e
)
)
passgptModel = default_config.get("passgptModel", "javirandor/passgpt-10characters")
try:
passgptMaxCandidates = int(config_parser["passgptMaxCandidates"])
except KeyError as e:
print(
"{0} is not defined in config.json using defaults from config.json.example".format(
e
)
)
passgptMaxCandidates = int(default_config.get("passgptMaxCandidates", 1000000))
try:
passgptBatchSize = int(config_parser["passgptBatchSize"])
except KeyError as e:
print(
"{0} is not defined in config.json using defaults from config.json.example".format(
e
)
)
passgptBatchSize = int(default_config.get("passgptBatchSize", 1024))
try:
check_for_updates_enabled = config_parser["check_for_updates"]
except KeyError as e:
print(
"{0} is not defined in config.json using defaults from config.json.example".format(
e
)
)
check_for_updates_enabled = default_config.get("check_for_updates", True)
hcatExpanderBin = "expander.bin"
hcatCombinatorBin = "combinator.bin"
@@ -823,6 +868,35 @@ def ascii_art():
)
def check_for_updates():
"""Check GitHub for a newer release and print a notice if one exists."""
try:
from hate_crack import __version__
if not REQUESTS_AVAILABLE:
return
resp = requests.get(
"https://api.github.com/repos/trustedsec/hate_crack/releases/latest",
timeout=5,
)
resp.raise_for_status()
tag = resp.json().get("tag_name", "")
latest = tag.lstrip("v")
# Compare base version (before any +g... suffix) against remote tag
local_base = __version__.split("+")[0]
if not latest or not local_base:
return
from packaging.version import parse
if parse(latest) > parse(local_base):
print(
f"\n Update available: {latest} (current: {local_base})."
f"\n See https://github.com/trustedsec/hate_crack/releases\n"
)
except Exception:
pass
# File selector with tab autocomplete
def select_file_with_autocomplete(
prompt, default=None, allow_multiple=False, base_dir=None
@@ -2111,6 +2185,15 @@ def hcatPrince(hcatHashType, hcatHashFile):
prince_proc.kill()
# OMEN model directory - writable location for trained model files.
# The binaries live in {hate_path}/omen/ (possibly read-only after install),
# but model output (createConfig, *.level) goes to ~/.hate_crack/omen/.
def _omen_model_dir():
model_dir = os.path.join(os.path.expanduser("~"), ".hate_crack", "omen")
os.makedirs(model_dir, exist_ok=True)
return model_dir
# OMEN Attack - Train model
def hcatOmenTrain(training_file):
omen_dir = os.path.join(hate_path, "omen")
@@ -2118,13 +2201,30 @@ def hcatOmenTrain(training_file):
if not os.path.isfile(create_bin):
print(f"Error: OMEN createNG binary not found: {create_bin}")
return
training_file = os.path.abspath(training_file)
if not os.path.isfile(training_file):
print(f"Error: Training file not found: {training_file}")
return
model_dir = _omen_model_dir()
print(f"Training OMEN model with: {training_file}")
cmd = [create_bin, "--iPwdList", training_file]
print(f"Model output directory: {model_dir}")
cmd = [
create_bin,
"--iPwdList",
training_file,
"-C",
os.path.join(model_dir, "createConfig"),
"-c",
os.path.join(model_dir, "CP"),
"-i",
os.path.join(model_dir, "IP"),
"-e",
os.path.join(model_dir, "EP"),
"-l",
os.path.join(model_dir, "LN"),
]
print(f"[*] Running: {_format_cmd(cmd)}")
proc = subprocess.Popen(cmd, cwd=omen_dir)
proc = subprocess.Popen(cmd)
try:
proc.wait()
except KeyboardInterrupt:
@@ -2145,7 +2245,13 @@ def hcatOmen(hcatHashType, hcatHashFile, max_candidates):
if not os.path.isfile(enum_bin):
print(f"Error: OMEN enumNG binary not found: {enum_bin}")
return
enum_cmd = [enum_bin, "-p", "-m", str(max_candidates)]
model_dir = _omen_model_dir()
config_path = os.path.join(model_dir, "createConfig")
if not os.path.isfile(config_path):
print(f"Error: OMEN model not found at {config_path}")
print("Run training first (option 16).")
return
enum_cmd = [enum_bin, "-p", "-m", str(max_candidates), "-C", config_path]
hashcat_cmd = [
hcatBin,
"-m",
@@ -2160,7 +2266,7 @@ def hcatOmen(hcatHashType, hcatHashFile, max_candidates):
_append_potfile_arg(hashcat_cmd)
print(f"[*] Running: {_format_cmd(enum_cmd)} | {_format_cmd(hashcat_cmd)}")
_debug_cmd(hashcat_cmd)
enum_proc = subprocess.Popen(enum_cmd, cwd=omen_dir, stdout=subprocess.PIPE)
enum_proc = subprocess.Popen(enum_cmd, cwd=model_dir, stdout=subprocess.PIPE)
hcatProcess = subprocess.Popen(hashcat_cmd, stdin=enum_proc.stdout)
enum_proc.stdout.close()
try:
@@ -2172,6 +2278,56 @@ def hcatOmen(hcatHashType, hcatHashFile, max_candidates):
enum_proc.kill()
# PassGPT Attack - Generate candidates with ML model and pipe to hashcat
def hcatPassGPT(
hcatHashType,
hcatHashFile,
max_candidates,
model_name=None,
batch_size=None,
):
global hcatProcess
if model_name is None:
model_name = passgptModel
if batch_size is None:
batch_size = passgptBatchSize
gen_cmd = [
sys.executable,
"-m",
"hate_crack.passgpt_generate",
"--num",
str(max_candidates),
"--model",
model_name,
"--batch-size",
str(batch_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)
print(f"[*] Running: {_format_cmd(gen_cmd)} | {_format_cmd(hashcat_cmd)}")
_debug_cmd(hashcat_cmd)
gen_proc = subprocess.Popen(gen_cmd, stdout=subprocess.PIPE)
hcatProcess = subprocess.Popen(hashcat_cmd, stdin=gen_proc.stdout)
gen_proc.stdout.close()
try:
hcatProcess.wait()
gen_proc.wait()
except KeyboardInterrupt:
print("Killing PID {0}...".format(str(hcatProcess.pid)))
hcatProcess.kill()
gen_proc.kill()
# Extra - Good Measure
def hcatGoodMeasure(hcatHashType, hcatHashFile):
global hcatExtraCount
@@ -3091,6 +3247,10 @@ def omen_attack():
return _attacks.omen_attack(_attack_ctx())
def passgpt_attack():
return _attacks.passgpt_attack(_attack_ctx())
# convert hex words for recycling
def convert_hex(working_file):
processed_words = []
@@ -3320,6 +3480,7 @@ def get_main_menu_options():
"14": loopback_attack,
"15": ollama_attack,
"16": omen_attack,
"17": passgpt_attack,
"90": download_hashmob_rules,
"91": analyze_rules,
"92": download_hashmob_wordlists,
@@ -3730,6 +3891,8 @@ def main():
sys.exit(1)
else:
ascii_art()
if not SKIP_INIT and check_for_updates_enabled:
check_for_updates()
menu_loop = True
while menu_loop:
print("\n" + "=" * 60)
@@ -3786,6 +3949,8 @@ def main():
if not hcatHashFileOrig:
hcatHashFileOrig = hcatHashFile
ascii_art()
if not SKIP_INIT and check_for_updates_enabled:
check_for_updates()
# Get Initial Input Hash Count
# If LM or NT Mode Selected and pwdump Format Detected, Prompt For LM to NT Attack
@@ -3973,6 +4138,7 @@ def main():
print("\t(14) Loopback Attack")
print("\t(15) LLM Attack")
print("\t(16) OMEN Attack")
print("\t(17) PassGPT Attack")
print("\n\t(90) Download rules from Hashmob.net")
print("\n\t(91) Analyze Hashcat Rules")
print("\t(92) Download wordlists from Hashmob.net")
+125
View File
@@ -0,0 +1,125 @@
"""Standalone PassGPT password candidate generator.
Invokable as ``python -m hate_crack.passgpt_generate``. Outputs one
candidate password per line to stdout so it can be piped directly into
hashcat. Progress and diagnostic messages go to stderr.
"""
from __future__ import annotations
import argparse
import sys
def _detect_device() -> str:
import torch
if torch.cuda.is_available():
return "cuda"
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
return "mps"
return "cpu"
def generate(
num: int,
model_name: str,
batch_size: int,
max_length: int,
device: str | None,
) -> None:
import torch
from transformers import GPT2LMHeadModel, RobertaTokenizerFast
if device is None:
device = _detect_device()
print(f"[*] Loading model {model_name} on {device}", file=sys.stderr)
tokenizer = RobertaTokenizerFast.from_pretrained(model_name)
model = GPT2LMHeadModel.from_pretrained(model_name).to(device)
model.eval()
generated = 0
seen: set[str] = set()
print(f"[*] Generating {num} candidates (batch_size={batch_size})", file=sys.stderr)
with torch.no_grad():
while generated < num:
current_batch = min(batch_size, num - generated)
input_ids = torch.full(
(current_batch, 1),
tokenizer.bos_token_id,
dtype=torch.long,
device=device,
)
output = model.generate(
input_ids,
max_length=max_length,
do_sample=True,
top_k=0,
top_p=1.0,
num_return_sequences=current_batch,
pad_token_id=tokenizer.eos_token_id,
)
# Strip BOS token
output = output[:, 1:]
for seq in output:
decoded = tokenizer.decode(seq, skip_special_tokens=False)
password = decoded.split("</s>")[0]
if password and password not in seen:
seen.add(password)
sys.stdout.write(password + "\n")
generated += 1
if generated >= num:
break
sys.stdout.flush()
print(f"[*] Done. Generated {generated} unique candidates.", file=sys.stderr)
def main() -> None:
parser = argparse.ArgumentParser(
description="Generate password candidates using PassGPT"
)
parser.add_argument(
"--num",
type=int,
default=1000000,
help="Number of candidates to generate (default: 1000000)",
)
parser.add_argument(
"--model",
type=str,
default="javirandor/passgpt-10characters",
help="HuggingFace model name (default: javirandor/passgpt-10characters)",
)
parser.add_argument(
"--batch-size",
type=int,
default=1024,
help="Generation batch size (default: 1024)",
)
parser.add_argument(
"--max-length",
type=int,
default=12,
help="Max token length including special tokens (default: 12)",
)
parser.add_argument(
"--device",
type=str,
default=None,
help="Device: cuda, mps, or cpu (default: auto-detect)",
)
args = parser.parse_args()
generate(
num=args.num,
model_name=args.model,
batch_size=args.batch_size,
max_length=args.max_length,
device=args.device,
)
if __name__ == "__main__":
main()
+5
View File
@@ -12,12 +12,17 @@ dependencies = [
"requests>=2.31.0",
"beautifulsoup4>=4.12.0",
"openpyxl>=3.0.0",
"packaging>=21.0",
]
[project.scripts]
hate_crack = "hate_crack.__main__:main"
[project.optional-dependencies]
ml = [
"torch>=2.0.0",
"transformers>=4.30.0",
]
dev = [
"mypy>=1.8.0",
"ruff>=0.3.0",
+138
View File
@@ -0,0 +1,138 @@
import sys
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 TestHcatPassGPT:
def test_builds_correct_pipe_commands(self, main_module):
with patch.object(main_module, "hcatBin", "hashcat"), patch.object(
main_module, "hcatTuning", "--force"
), patch.object(main_module, "hcatPotfilePath", ""), patch.object(
main_module, "hcatHashFile", "/tmp/hashes.txt", create=True
), patch.object(
main_module, "passgptModel", "javirandor/passgpt-10characters"
), patch.object(main_module, "passgptBatchSize", 1024), patch(
"hate_crack.main.subprocess.Popen"
) as mock_popen:
mock_gen_proc = MagicMock()
mock_gen_proc.stdout = MagicMock()
mock_hashcat_proc = MagicMock()
mock_hashcat_proc.wait.return_value = None
mock_gen_proc.wait.return_value = None
mock_popen.side_effect = [mock_gen_proc, mock_hashcat_proc]
main_module.hcatPassGPT("1000", "/tmp/hashes.txt", 500000)
assert mock_popen.call_count == 2
# First call: passgpt generator
gen_cmd = mock_popen.call_args_list[0][0][0]
assert gen_cmd[0] == sys.executable
assert "-m" in gen_cmd
assert "hate_crack.passgpt_generate" in gen_cmd
assert "--num" in gen_cmd
assert "500000" in gen_cmd
assert "--model" in gen_cmd
assert "javirandor/passgpt-10characters" in gen_cmd
assert "--batch-size" in gen_cmd
assert "1024" in gen_cmd
# Second call: hashcat
hashcat_cmd = mock_popen.call_args_list[1][0][0]
assert hashcat_cmd[0] == "hashcat"
assert "1000" in hashcat_cmd
assert "/tmp/hashes.txt" in hashcat_cmd
def test_custom_model_and_batch_size(self, main_module):
with patch.object(main_module, "hcatBin", "hashcat"), patch.object(
main_module, "hcatTuning", "--force"
), patch.object(main_module, "hcatPotfilePath", ""), patch.object(
main_module, "hcatHashFile", "/tmp/hashes.txt", create=True
), patch.object(
main_module, "passgptModel", "javirandor/passgpt-10characters"
), patch.object(main_module, "passgptBatchSize", 1024), patch(
"hate_crack.main.subprocess.Popen"
) as mock_popen:
mock_gen_proc = MagicMock()
mock_gen_proc.stdout = MagicMock()
mock_hashcat_proc = MagicMock()
mock_hashcat_proc.wait.return_value = None
mock_gen_proc.wait.return_value = None
mock_popen.side_effect = [mock_gen_proc, mock_hashcat_proc]
main_module.hcatPassGPT(
"1000",
"/tmp/hashes.txt",
100000,
model_name="custom/model",
batch_size=512,
)
gen_cmd = mock_popen.call_args_list[0][0][0]
assert "custom/model" in gen_cmd
assert "512" in gen_cmd
class TestPassGPTAttackHandler:
def test_prompts_and_calls_hcatPassGPT(self):
ctx = MagicMock()
ctx.HAS_ML_DEPS = True
ctx.passgptMaxCandidates = 1000000
ctx.passgptModel = "javirandor/passgpt-10characters"
ctx.passgptBatchSize = 1024
ctx.hcatHashType = "1000"
ctx.hcatHashFile = "/tmp/hashes.txt"
with patch("builtins.input", return_value=""):
from hate_crack.attacks import passgpt_attack
passgpt_attack(ctx)
ctx.hcatPassGPT.assert_called_once_with(
"1000",
"/tmp/hashes.txt",
1000000,
model_name="javirandor/passgpt-10characters",
batch_size=1024,
)
def test_custom_values(self):
ctx = MagicMock()
ctx.HAS_ML_DEPS = True
ctx.passgptMaxCandidates = 1000000
ctx.passgptModel = "javirandor/passgpt-10characters"
ctx.passgptBatchSize = 1024
ctx.hcatHashType = "1000"
ctx.hcatHashFile = "/tmp/hashes.txt"
inputs = iter(["500000", "custom/model"])
with patch("builtins.input", side_effect=inputs):
from hate_crack.attacks import passgpt_attack
passgpt_attack(ctx)
ctx.hcatPassGPT.assert_called_once_with(
"1000",
"/tmp/hashes.txt",
500000,
model_name="custom/model",
batch_size=1024,
)
def test_ml_deps_missing(self, capsys):
ctx = MagicMock()
ctx.HAS_ML_DEPS = False
from hate_crack.attacks import passgpt_attack
passgpt_attack(ctx)
captured = capsys.readouterr()
assert "ML dependencies" in captured.out
assert "uv pip install" in captured.out
ctx.hcatPassGPT.assert_not_called()
+1
View File
@@ -27,6 +27,7 @@ MENU_OPTION_TEST_CASES = [
("14", CLI_MODULE._attacks, "loopback_attack", "loopback"),
("15", CLI_MODULE._attacks, "ollama_attack", "ollama"),
("16", CLI_MODULE._attacks, "omen_attack", "omen"),
("17", CLI_MODULE._attacks, "passgpt_attack", "passgpt"),
("90", CLI_MODULE, "download_hashmob_rules", "hashmob-rules"),
("91", CLI_MODULE, "weakpass_wordlist_menu", "weakpass-menu"),
("92", CLI_MODULE, "download_hashmob_wordlists", "hashmob-wordlists"),