diff --git a/README.md b/README.md index 45319f8..08e86d1 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/config.json.example b/config.json.example index 424e762..f5e1f09 100644 --- a/config.json.example +++ b/config.json.example @@ -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 } diff --git a/hate_crack.py b/hate_crack.py index a73b440..1d8ffbf 100755 --- a/hate_crack.py +++ b/hate_crack.py @@ -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, diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index b0c5c62..a414b80 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -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, + ) diff --git a/hate_crack/main.py b/hate_crack/main.py index cf23d04..5836bc8 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -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") diff --git a/hate_crack/passgpt_generate.py b/hate_crack/passgpt_generate.py new file mode 100644 index 0000000..16c39d1 --- /dev/null +++ b/hate_crack/passgpt_generate.py @@ -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("")[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() diff --git a/pyproject.toml b/pyproject.toml index d6530ee..780927b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/tests/test_passgpt_attack.py b/tests/test_passgpt_attack.py new file mode 100644 index 0000000..5bf7331 --- /dev/null +++ b/tests/test_passgpt_attack.py @@ -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() diff --git a/tests/test_ui_menu_options.py b/tests/test_ui_menu_options.py index 2df6ebd..98a20a4 100644 --- a/tests/test_ui_menu_options.py +++ b/tests/test_ui_menu_options.py @@ -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"),