From e205e6e886bd4a3174e0bc022ef9751ff5d427f5 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 13 Feb 2026 13:13:39 -0500 Subject: [PATCH] feat: add LLM Markov Attack (menu option 15) Add a new attack mode that uses a local LLM via Ollama to generate password candidates, converts them into hashcat .hcstat2 Markov statistics via hcstat2gen, and runs a Markov-enhanced mask attack. Two generation sub-modes: - Wordlist-based: feeds sample from an existing wordlist to the LLM as pattern context (config-selectable default with Y/N override) - Target-based: prompts for company name, industry, and location for contextual password generation Pipeline: Ollama API -> candidate file -> hcstat2gen -> LZMA compress -> hashcat -a 3 --markov-hcstat2 Config additions: ollamaUrl, ollamaModel, markovCandidateCount, markovWordlist. No new pip dependencies (uses stdlib urllib/lzma). Co-Authored-By: Claude Opus 4.6 --- config.json.example | 6 +- hate_crack.py | 1 + hate_crack/attacks.py | 45 ++++++ hate_crack/main.py | 256 ++++++++++++++++++++++++++++++++++ tests/test_ui_menu_options.py | 1 + 5 files changed, 308 insertions(+), 1 deletion(-) diff --git a/config.json.example b/config.json.example index 5215c2d..fc954e4 100644 --- a/config.json.example +++ b/config.json.example @@ -22,5 +22,9 @@ "bandrel_common_basedwords": "welcome,password,p@ssword,p@$$word,changeme,letmein,summer,winter,spring,springtime,fall,autumn,monday,tuesday,wednesday,thursday,friday,saturday,sunday,january,february,march,april,may,june,july,august,september,october,november,december,christmas,easter,covid19", "hashview_url": "http://localhost:8443", "hashview_api_key": "", - "hashmob_api_key": "" + "hashmob_api_key": "", + "ollamaUrl": "http://localhost:11434", + "ollamaModel": "llama3.2", + "markovCandidateCount": 5000, + "markovWordlist": "rockyou.txt" } diff --git a/hate_crack.py b/hate_crack.py index ce16395..5b6bfd0 100755 --- a/hate_crack.py +++ b/hate_crack.py @@ -83,6 +83,7 @@ def get_main_menu_options(): "12": _attacks.thorough_combinator, "13": _attacks.bandrel_method, "14": _attacks.loopback_attack, + "15": _attacks.markov_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 1d3b5dc..74928ac 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -486,3 +486,48 @@ def middle_combinator(ctx: Any) -> None: def bandrel_method(ctx: Any) -> None: ctx.hcatBandrel(ctx.hcatHashType, ctx.hcatHashFile) + + +def markov_attack(ctx: Any) -> None: + print("\n\tLLM Markov Attack") + print("\t(1) Wordlist-based generation") + print("\t(2) Target-based generation") + choice = input("\nSelect generation mode: ").strip() + + if choice == "1": + default_wl = ctx.markovWordlist + if isinstance(default_wl, list): + default_wl = default_wl[0] if default_wl else "" + print(f"\nDefault wordlist: {default_wl}") + override = input("Use a different wordlist? [y/N]: ").strip().lower() + if override == "y": + wordlist = ctx.select_file_with_autocomplete("Enter wordlist path") + wordlist = ctx._resolve_wordlist_path(wordlist, ctx.hcatWordlists) + else: + wordlist = default_wl + count_input = input( + f"Number of candidates to generate [{ctx.markovCandidateCount}]: " + ).strip() + count = int(count_input) if count_input else ctx.markovCandidateCount + ctx.hcatMarkov( + ctx.hcatHashType, ctx.hcatHashFile, "wordlist", wordlist, count + ) + + elif choice == "2": + company = input("Company name: ").strip() + industry = input("Industry: ").strip() + location = input("Location: ").strip() + count_input = input( + f"Number of candidates to generate [{ctx.markovCandidateCount}]: " + ).strip() + count = int(count_input) if count_input else ctx.markovCandidateCount + target_info = { + "company": company, + "industry": industry, + "location": location, + } + ctx.hcatMarkov( + ctx.hcatHashType, ctx.hcatHashFile, "target", target_info, count + ) + else: + print("Invalid selection.") diff --git a/hate_crack/main.py b/hate_crack/main.py index 9d738e5..ed62f7d 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -9,6 +9,7 @@ import sys import os import json +import lzma import shutil import logging import binascii @@ -19,6 +20,8 @@ import readline import subprocess import shlex import argparse +import urllib.request +import urllib.error from types import SimpleNamespace #!/usr/bin/env python3 @@ -451,9 +454,47 @@ except KeyError as e: default_config.get("hcatDebugLogPath", "./hashcat_debug") ) +try: + ollamaUrl = config_parser["ollamaUrl"] +except KeyError as e: + print( + "{0} is not defined in config.json using defaults from config.json.example".format( + e + ) + ) + ollamaUrl = default_config.get("ollamaUrl", "http://localhost:11434") +try: + ollamaModel = config_parser["ollamaModel"] +except KeyError as e: + print( + "{0} is not defined in config.json using defaults from config.json.example".format( + e + ) + ) + ollamaModel = default_config.get("ollamaModel", "llama3.2") +try: + markovCandidateCount = int(config_parser["markovCandidateCount"]) +except KeyError as e: + print( + "{0} is not defined in config.json using defaults from config.json.example".format( + e + ) + ) + markovCandidateCount = int(default_config.get("markovCandidateCount", 5000)) +try: + markovWordlist = config_parser["markovWordlist"] +except KeyError as e: + print( + "{0} is not defined in config.json using defaults from config.json.example".format( + e + ) + ) + markovWordlist = default_config.get("markovWordlist", "rockyou.txt") + hcatExpanderBin = "expander.bin" hcatCombinatorBin = "combinator.bin" hcatPrinceBin = "pp64.bin" +hcatHcstat2genBin = "hcstat2gen.bin" def _resolve_wordlist_path(wordlist, base_dir): @@ -588,6 +629,7 @@ hcatGoodMeasureBaseList = _normalize_wordlist_setting( hcatGoodMeasureBaseList, wordlists_dir ) hcatPrinceBaseList = _normalize_wordlist_setting(hcatPrinceBaseList, wordlists_dir) +markovWordlist = _normalize_wordlist_setting(markovWordlist, wordlists_dir) if not SKIP_INIT: # Verify hashcat binary is available @@ -653,6 +695,20 @@ if not SKIP_INIT: except SystemExit: print("PRINCE attacks will not be available.") + # Verify hcstat2gen binary (optional, for Markov attacks) + # Note: hcstat2gen is part of hashcat-utils, already in hate_crack repo + hcstat2gen_path = ( + hate_path + "/hashcat-utils/bin/" + hcatHcstat2genBin + ) + try: + ensure_binary( + hcstat2gen_path, + build_dir=os.path.join(hate_path, "hashcat-utils"), + name="hcstat2gen", + ) + except SystemExit: + print("LLM Markov 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"): @@ -1446,6 +1502,200 @@ def hcatBandrel(hcatHashType, hcatHashFile): hcatProcess.kill() +# LLM Markov Attack +def hcatMarkov(hcatHashType, hcatHashFile, mode, context_data, candidate_count): + global hcatProcess + candidates_path = f"{hcatHashFile}.markov_candidates" + hcstat2_raw_path = f"{hcatHashFile}.hcstat2_raw" + hcstat2_path = f"{hcatHashFile}.hcstat2" + hcstat2gen_path = os.path.join( + hate_path, "hashcat-utils", "bin", hcatHcstat2genBin + ) + + if not os.path.isfile(hcstat2gen_path): + print( + f"Error: hcstat2gen not found at {hcstat2gen_path}. " + "LLM Markov attacks are not available." + ) + return + + # Step A: Build LLM prompt based on mode + if mode == "wordlist": + wordlist_path = context_data + if not os.path.isfile(wordlist_path): + print(f"Error: Wordlist not found: {wordlist_path}") + return + sample_lines = [] + try: + with open(wordlist_path, "r", errors="ignore") as f: + for i, line in enumerate(f): + if i >= 500: + break + stripped = line.strip() + if stripped: + sample_lines.append(stripped) + except Exception as e: + print(f"Error reading wordlist: {e}") + return + wordlist_sample = "\n".join(sample_lines) + prompt = ( + "You are a password generation expert. Below is a sample of real passwords. " + "Study the patterns, character choices, and structures. Generate exactly " + f"{candidate_count} new unique passwords that follow similar patterns but " + "are NOT copies of the input. Output ONLY passwords, one per line, no " + "numbering or explanation.\n\n" + f"Sample passwords:\n{wordlist_sample}" + ) + elif mode == "target": + company = context_data.get("company", "") + industry = context_data.get("industry", "") + location = context_data.get("location", "") + prompt = ( + f"Generate exactly {candidate_count} realistic passwords that employees at " + f"{company} in the {industry} industry located in {location} might use. " + "Include variations with:\n" + "- Company name variations and abbreviations\n" + "- Common password patterns (Season+Year, Name+Numbers)\n" + "- Keyboard walks and common substitutions (@ for a, 3 for e, etc.)\n" + "- Location-based words and local references\n" + "- Industry-specific terminology\n" + "Output ONLY the passwords, one per line, no numbering or explanation." + ) + else: + print(f"Error: Unknown Markov generation mode: {mode}") + return + + # Step B: Call Ollama API to generate candidates + print(f"Generating {candidate_count} password candidates via Ollama ({ollamaModel})...") + api_url = f"{ollamaUrl}/api/generate" + payload = json.dumps({ + "model": ollamaModel, + "prompt": prompt, + "stream": False, + }).encode("utf-8") + + try: + req = urllib.request.Request( + api_url, + data=payload, + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=600) as resp: + result = json.loads(resp.read().decode("utf-8")) + except urllib.error.URLError as e: + print(f"Error: Could not connect to Ollama at {ollamaUrl}: {e}") + print("Ensure Ollama is running (ollama serve) and try again.") + return + except Exception as e: + print(f"Error calling Ollama API: {e}") + return + + response_text = result.get("response", "") + raw_lines = response_text.strip().split("\n") + # Filter out blank lines and lines that look like numbering/explanation + candidates = [] + for line in raw_lines: + stripped = line.strip() + if not stripped: + continue + # Strip leading numbering like "1. " or "1) " or "- " + cleaned = re.sub(r"^\d+[.)]\s*", "", stripped) + cleaned = re.sub(r"^[-*]\s*", "", cleaned) + cleaned = cleaned.strip() + if cleaned and len(cleaned) <= 128: + candidates.append(cleaned) + + if not candidates: + print("Error: Ollama returned no usable password candidates.") + return + + try: + with open(candidates_path, "w") as f: + for candidate in candidates: + f.write(candidate + "\n") + except Exception as e: + print(f"Error writing candidates file: {e}") + return + + print(f"Generated {len(candidates)} password candidates -> {candidates_path}") + + # Step C: Run hcstat2gen to build Markov stats + print("Building Markov statistics with hcstat2gen...") + try: + with open(candidates_path, "rb") as candidates_file: + hcatProcess = subprocess.Popen( + [hcstat2gen_path, hcstat2_raw_path], + stdin=candidates_file, + ) + try: + hcatProcess.wait() + except KeyboardInterrupt: + print("Killing PID {0}...".format(str(hcatProcess.pid))) + hcatProcess.kill() + return + except Exception as e: + print(f"Error running hcstat2gen: {e}") + return + + if not os.path.isfile(hcstat2_raw_path): + print("Error: hcstat2gen did not produce output file.") + return + + # Step D: LZMA compress the raw hcstat2 file + print("Compressing hcstat2 file with LZMA...") + try: + with open(hcstat2_raw_path, "rb") as raw_file: + raw_data = raw_file.read() + compressed = lzma.compress( + raw_data, + format=lzma.FORMAT_RAW, + filters=[{"id": lzma.FILTER_LZMA1}], + ) + with open(hcstat2_path, "wb") as out_file: + out_file.write(compressed) + except Exception as e: + print(f"Error compressing hcstat2 file: {e}") + return + + print(f"Markov statistics file created -> {hcstat2_path}") + + # Step E: Run hashcat mask attack with custom Markov stats + print("Running Markov-enhanced mask attack...") + cmd = [ + hcatBin, + "-m", + hcatHashType, + hcatHashFile, + "--session", + generate_session_id(), + "-o", + f"{hcatHashFile}.out", + "-a", + "3", + f"--markov-hcstat2={hcstat2_path}", + "--increment", + "--increment-min=1", + "--increment-max=14", + "?a?a?a?a?a?a?a?a?a?a?a?a?a?a", + ] + cmd.extend(shlex.split(hcatTuning)) + _append_potfile_arg(cmd) + hcatProcess = subprocess.Popen(cmd) + try: + hcatProcess.wait() + except KeyboardInterrupt: + print("Killing PID {0}...".format(str(hcatProcess.pid))) + hcatProcess.kill() + + # Step F: Cleanup temp files + for temp_file in [candidates_path, hcstat2_raw_path]: + try: + if os.path.isfile(temp_file): + os.remove(temp_file) + except Exception: + pass + + # Middle fast Combinator Attack def hcatMiddleCombinator(hcatHashType, hcatHashFile): global hcatProcess @@ -2756,6 +3006,10 @@ def loopback_attack(): return _attacks.loopback_attack(_attack_ctx()) +def markov_attack(): + return _attacks.markov_attack(_attack_ctx()) + + # convert hex words for recycling def convert_hex(working_file): processed_words = [] @@ -2983,6 +3237,7 @@ def get_main_menu_options(): "12": thorough_combinator, "13": bandrel_method, "14": loopback_attack, + "15": markov_attack, "90": download_hashmob_rules, "91": analyze_rules, "92": download_hashmob_wordlists, @@ -3563,6 +3818,7 @@ def main(): print("\t(12) Thorough Combinator Attack") print("\t(13) Bandrel Methodology") print("\t(14) Loopback Attack") + print("\t(15) LLM Markov 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/tests/test_ui_menu_options.py b/tests/test_ui_menu_options.py index 5dbe771..7563092 100644 --- a/tests/test_ui_menu_options.py +++ b/tests/test_ui_menu_options.py @@ -25,6 +25,7 @@ MENU_OPTION_TEST_CASES = [ ("12", CLI_MODULE._attacks, "thorough_combinator", "thorough"), ("13", CLI_MODULE._attacks, "bandrel_method", "bandrel"), ("14", CLI_MODULE._attacks, "loopback_attack", "loopback"), + ("15", CLI_MODULE._attacks, "markov_attack", "markov"), ("90", CLI_MODULE, "download_hashmob_rules", "hashmob-rules"), ("91", CLI_MODULE, "weakpass_wordlist_menu", "weakpass-menu"), ("92", CLI_MODULE, "download_hashmob_wordlists", "hashmob-wordlists"),