feat: add random rules attack using generate-rules.bin (#87)

- Add hcatGenerateRules() in main.py: runs generate-rules.bin to
  produce N random rules, writes them to a temp file, runs hashcat
  with -r against a chosen wordlist, cleans up on exit
- Add generate_rules_crack() handler in attacks.py with count
  prompt (default 65536), wordlist picker with tab-completion,
  input validation, and abort on invalid input
- Add dispatcher generate_rules_crack() in main.py and key "20"
  in both main.py and hate_crack.py get_main_menu_options()
- Add ("20", "Random Rules Attack") to get_main_menu_items()
- Add tests: test_random_rules_attack.py (menu presence, handler
  wiring), test_random_rules_wrapper.py (subprocess behavior,
  cleanup, count passing, count tracking)
- Add key "20" to MENU_OPTION_TEST_CASES in test_ui_menu_options.py
- Update README: add option 20 to menu listing, add attack
  description, add version history entry
This commit is contained in:
Justin Bollinger
2026-03-19 12:13:38 -04:00
parent 53b99d4395
commit dfe9e28e70
7 changed files with 351 additions and 0 deletions
+10
View File
@@ -618,6 +618,7 @@ All tests use mocked API calls, so they can run without connectivity to a Hashvi
(16) OMEN Attack
(17) Ad-hoc Mask Attack
(18) Markov Brute Force Attack
(20) Random Rules Attack
(90) Download rules from Hashmob.net
(91) Analyze Hashcat Rules
@@ -789,6 +790,14 @@ Generates password candidates using Markov chain statistical models. Similar to
* Markov table persists with hash file (filename.out.hcstat2) for fast subsequent runs
* Faster than OMEN for general-purpose brute forcing
#### Random Rules Attack
Generates a set of random hashcat mutation rules using `generate-rules.bin`, writes them to a temporary file, then runs hashcat against a chosen wordlist with those rules.
* Prompts for rule count (default 65536)
* Prompts for wordlist path with tab-completion and numbered selection
* Temporary rules file is cleaned up after the run regardless of outcome
* Useful when known rule sets are exhausted - explores random rule-space for additional cracks
#### 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.
@@ -824,6 +833,7 @@ Interactive menu for downloading and managing wordlists from Weakpass.com via Bi
### Version History
Version 2.0+
- Added Random Rules Attack (option 20) using `generate-rules.bin` to generate random mutation rules (#87)
- Added Ad-hoc Mask Attack (option 17) for user-typed hashcat masks with optional custom character sets
- Added Markov Brute Force Attack (option 18) using `hcstat2` statistical tables for password generation
- Consolidated Combinator Attacks (formerly options 10/11/12) into interactive submenu under option 6
+1
View File
@@ -88,6 +88,7 @@ def get_main_menu_options():
"16": _attacks.omen_attack,
"17": _attacks.adhoc_mask_crack,
"18": _attacks.markov_brute_force,
"20": _attacks.generate_rules_crack,
"90": download_hashmob_rules,
"91": weakpass_wordlist_menu,
"92": download_hashmob_wordlists,
+78
View File
@@ -620,6 +620,84 @@ def markov_brute_force(ctx: Any) -> None:
ctx.hcatMarkovBruteForce(ctx.hcatHashType, ctx.hcatHashFile, hcatMinLen, hcatMaxLen)
def generate_rules_crack(ctx: Any) -> None:
print("\n" + "=" * 60)
print("RANDOM RULES ATTACK")
print("=" * 60)
print("Generates random hashcat mutation rules and applies them to a wordlist.")
print("Use when known rulesets are exhausted - a chaos mode for rule-space exploration.")
print("=" * 60)
raw_count = input("\nNumber of random rules to generate (65536): ").strip()
try:
rule_count = int(raw_count) if raw_count else 65536
if rule_count < 1:
print("[!] Rule count must be at least 1.")
return
except ValueError:
print("[!] Invalid rule count.")
return
wordlist_files = ctx.list_wordlist_files(ctx.hcatWordlists)
wordlist_entries = [
f"{i}. {file}" for i, file in enumerate(wordlist_files, start=1)
]
max_entry_len = max((len(e) for e in wordlist_entries), default=24)
print_multicolumn_list(
"Wordlists",
wordlist_entries,
min_col_width=max_entry_len,
max_col_width=max_entry_len,
)
def path_completer(text, state):
base = ctx.hcatWordlists
if not text:
pattern = os.path.join(base, "*")
matches = glob.glob(pattern)
else:
text = os.path.expanduser(text)
if text.startswith(("/", "./", "../", "~")):
matches = glob.glob(text + "*")
else:
pattern = os.path.join(base, text + "*")
matches = glob.glob(pattern)
matches = [m + "/" if os.path.isdir(m) else m for m in matches]
try:
return matches[state]
except IndexError:
return None
_configure_readline(path_completer)
wordlist_choice = None
while wordlist_choice is None:
try:
raw_choice = input(
"\nEnter path of wordlist (tab to autocomplete).\n"
f"Press Enter for default wordlist directory [{ctx.hcatWordlists}]: "
)
raw_choice = raw_choice.strip()
if raw_choice == "":
wordlist_choice = ctx.hcatWordlists
elif raw_choice.isdigit() and 1 <= int(raw_choice) <= len(wordlist_files):
chosen = os.path.join(
ctx.hcatWordlists, wordlist_files[int(raw_choice) - 1]
)
if os.path.exists(chosen):
wordlist_choice = chosen
print(wordlist_choice)
elif os.path.exists(raw_choice):
wordlist_choice = raw_choice
else:
print("[!] Wordlist not found. Please enter a valid path.")
return
except ValueError:
print("Please enter a valid number.")
ctx.hcatGenerateRules(ctx.hcatHashType, ctx.hcatHashFile, rule_count, wordlist_choice)
def combinator_submenu(ctx: Any) -> None:
from hate_crack.menu import interactive_menu
+53
View File
@@ -24,6 +24,7 @@ import argparse
import urllib.request
import urllib.error
import lzma
import tempfile
from types import SimpleNamespace
#!/usr/bin/env python3
@@ -677,6 +678,7 @@ hcatCombinationCount = 0
hcatHybridCount = 0
hcatExtraCount = 0
hcatRecycleCount = 0
hcatGenerateRulesCount = 0
hcatProcess: subprocess.Popen[Any] | None = None
debug_mode = False
@@ -2527,6 +2529,51 @@ def hcatRecycle(hcatHashType, hcatHashFile, hcatNewPasswords):
hcatProcess.kill()
def hcatGenerateRules(hcatHashType, hcatHashFile, rule_count, wordlist):
global hcatProcess, hcatGenerateRulesCount
generate_rules_path = os.path.join(
hate_path, "hashcat-utils", "bin", "generate-rules.bin"
)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".rule", prefix="hate_crack_random_", delete=False
) as rules_file:
rules_path = rules_file.name
try:
result = subprocess.run(
[generate_rules_path, str(rule_count)],
capture_output=True,
text=True,
check=True,
)
with open(rules_path, "w") as f:
f.write(result.stdout)
cmd = [
hcatBin,
"-m",
hcatHashType,
hcatHashFile,
"--session",
generate_session_id(),
"-o",
f"{hcatHashFile}.out",
"-r",
rules_path,
wordlist,
]
cmd.extend(shlex.split(hcatTuning))
_append_potfile_arg(cmd)
hcatProcess = subprocess.Popen(cmd)
try:
hcatProcess.wait()
except KeyboardInterrupt:
print(f"Killing PID {hcatProcess.pid}...")
hcatProcess.kill()
finally:
if os.path.exists(rules_path):
os.unlink(rules_path)
hcatGenerateRulesCount = lineCount(hcatHashFile + ".out") - hcatHashCracked
def check_potfile():
print("Checking POT file for already cracked hashes...")
_run_hashcat_show(hcatHashType, hcatHashFile, f"{hcatHashFile}.out")
@@ -3329,6 +3376,10 @@ def omen_attack():
return _attacks.omen_attack(_attack_ctx())
def generate_rules_crack():
return _attacks.generate_rules_crack(_attack_ctx())
# convert hex words for recycling
def convert_hex(working_file):
processed_words = []
@@ -3557,6 +3608,7 @@ def get_main_menu_items():
("16", "OMEN Attack"),
("17", "Ad-hoc Mask Attack"),
("18", "Markov Brute Force Attack"),
("20", "Random Rules Attack"),
("90", "Download rules from Hashmob.net"),
("91", "Analyze Hashcat Rules"),
("92", "Download wordlists from Hashmob.net"),
@@ -3594,6 +3646,7 @@ def get_main_menu_options():
"16": omen_attack,
"17": adhoc_mask_crack,
"18": markov_brute_force,
"20": generate_rules_crack,
"90": lambda: download_hashmob_rules(rules_dir=rulesDirectory),
"91": analyze_rules,
"92": download_hashmob_wordlists,
+45
View File
@@ -0,0 +1,45 @@
import importlib.util
import os
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[1]
def load_cli_module():
os.environ["HATE_CRACK_SKIP_INIT"] = "1"
for key in list(sys.modules.keys()):
if "hate_crack" in key:
del sys.modules[key]
spec = importlib.util.spec_from_file_location(
"hate_crack_cli", PROJECT_ROOT / "hate_crack.py"
)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
@pytest.fixture
def cli():
return load_cli_module()
def test_generate_rules_crack_in_main_menu(cli):
options = cli.get_main_menu_options()
assert "20" in options
def test_generate_rules_crack_handler_calls_main(cli, tmp_path):
ctx = MagicMock()
ctx.hcatHashType = "1000"
ctx.hcatHashFile = "/tmp/h.txt"
ctx.hcatWordlists = str(tmp_path)
ctx.list_wordlist_files.return_value = []
wl = tmp_path / "words.txt"
wl.write_text("password\n")
with patch("builtins.input", side_effect=["100", str(wl)]):
cli._attacks.generate_rules_crack(ctx)
ctx.hcatGenerateRules.assert_called_once_with("1000", "/tmp/h.txt", 100, str(wl))
+163
View File
@@ -0,0 +1,163 @@
from unittest.mock import MagicMock, patch
import pytest
@pytest.fixture
def main_module(hc_module):
return hc_module._main
def _make_mock_proc(wait_side_effect=None):
proc = MagicMock()
if wait_side_effect is not None:
proc.wait.side_effect = wait_side_effect
else:
proc.wait.return_value = None
proc.pid = 12345
return proc
class TestHcatGenerateRules:
def test_calls_generate_rules_bin(self, main_module, tmp_path):
wl = tmp_path / "words.txt"
wl.write_text("test\n")
hash_file = str(tmp_path / "hashes.txt")
mock_proc = _make_mock_proc()
mock_result = MagicMock()
mock_result.stdout = "l\nu\nc\n"
with 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"), \
patch.object(main_module, "lineCount", return_value=0), \
patch.object(main_module, "hcatHashCracked", 0), \
patch("hate_crack.main.subprocess.run", return_value=mock_result) as mock_run, \
patch("hate_crack.main.subprocess.Popen", return_value=mock_proc):
main_module.hcatGenerateRules("1000", hash_file, 100, str(wl))
run_calls = mock_run.call_args_list
assert any("generate-rules.bin" in str(c) for c in run_calls)
def test_calls_hashcat_with_rule_flag(self, main_module, tmp_path):
wl = tmp_path / "words.txt"
wl.write_text("test\n")
hash_file = str(tmp_path / "hashes.txt")
mock_proc = _make_mock_proc()
mock_result = MagicMock()
mock_result.stdout = "l\nu\n"
with 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"), \
patch.object(main_module, "lineCount", return_value=0), \
patch.object(main_module, "hcatHashCracked", 0), \
patch("hate_crack.main.subprocess.run", return_value=mock_result), \
patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen:
main_module.hcatGenerateRules("1000", hash_file, 100, str(wl))
popen_calls = mock_popen.call_args_list
assert any("-r" in str(c) for c in popen_calls)
def test_passes_rule_count_to_generate_rules_bin(self, main_module, tmp_path):
wl = tmp_path / "words.txt"
wl.write_text("test\n")
hash_file = str(tmp_path / "hashes.txt")
mock_proc = _make_mock_proc()
mock_result = MagicMock()
mock_result.stdout = "l\n"
with 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"), \
patch.object(main_module, "lineCount", return_value=0), \
patch.object(main_module, "hcatHashCracked", 0), \
patch("hate_crack.main.subprocess.run", return_value=mock_result) as mock_run, \
patch("hate_crack.main.subprocess.Popen", return_value=mock_proc):
main_module.hcatGenerateRules("1000", hash_file, 999, str(wl))
run_calls = mock_run.call_args_list
generate_call = next(
(c for c in run_calls if "generate-rules.bin" in str(c)), None
)
assert generate_call is not None
cmd_args = generate_call[0][0]
assert "999" in cmd_args
def test_cleans_up_temp_file(self, main_module, tmp_path):
wl = tmp_path / "words.txt"
wl.write_text("test\n")
hash_file = str(tmp_path / "hashes.txt")
mock_proc = _make_mock_proc()
mock_result = MagicMock()
mock_result.stdout = "l\nu\n"
captured_paths = []
import os as _os
original_unlink = _os.unlink
def capturing_unlink(path):
captured_paths.append(path)
original_unlink(path)
with 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"), \
patch.object(main_module, "lineCount", return_value=0), \
patch.object(main_module, "hcatHashCracked", 0), \
patch("hate_crack.main.subprocess.run", return_value=mock_result), \
patch("hate_crack.main.subprocess.Popen", return_value=mock_proc), \
patch("hate_crack.main.os.unlink", side_effect=capturing_unlink):
main_module.hcatGenerateRules("1000", hash_file, 50, str(wl))
assert any("hate_crack_random_" in p for p in captured_paths), \
f"Expected temp file cleanup, got: {captured_paths}"
def test_keyboard_interrupt_kills_process(self, main_module, tmp_path):
wl = tmp_path / "words.txt"
wl.write_text("test\n")
hash_file = str(tmp_path / "hashes.txt")
mock_proc = _make_mock_proc(wait_side_effect=KeyboardInterrupt())
mock_result = MagicMock()
mock_result.stdout = "l\n"
with 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"), \
patch.object(main_module, "lineCount", return_value=0), \
patch.object(main_module, "hcatHashCracked", 0), \
patch("hate_crack.main.subprocess.run", return_value=mock_result), \
patch("hate_crack.main.subprocess.Popen", return_value=mock_proc):
main_module.hcatGenerateRules("1000", hash_file, 10, str(wl))
mock_proc.kill.assert_called_once()
def test_sets_hcatGenerateRulesCount(self, main_module, tmp_path):
wl = tmp_path / "words.txt"
wl.write_text("test\n")
hash_file = str(tmp_path / "hashes.txt")
mock_proc = _make_mock_proc()
mock_result = MagicMock()
mock_result.stdout = "l\nu\n"
# patch.object won't patch reads of module-level globals; set directly
original_cracked = main_module.hcatHashCracked
main_module.hcatHashCracked = 2
try:
with 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"), \
patch.object(main_module, "lineCount", return_value=5), \
patch("hate_crack.main.subprocess.run", return_value=mock_result), \
patch("hate_crack.main.subprocess.Popen", return_value=mock_proc):
main_module.hcatGenerateRules("1000", hash_file, 10, str(wl))
finally:
main_module.hcatHashCracked = original_cracked
assert main_module.hcatGenerateRulesCount == 3 # 5 - 2
+1
View File
@@ -26,6 +26,7 @@ MENU_OPTION_TEST_CASES = [
("16", CLI_MODULE._attacks, "omen_attack", "omen"),
("17", CLI_MODULE._attacks, "adhoc_mask_crack", "adhoc-mask"),
("18", CLI_MODULE._attacks, "markov_brute_force", "markov-brute"),
("20", CLI_MODULE._attacks, "generate_rules_crack", "random-rules"),
("90", CLI_MODULE, "download_hashmob_rules", "hashmob-rules"),
("91", CLI_MODULE, "weakpass_wordlist_menu", "weakpass-menu"),
("92", CLI_MODULE, "download_hashmob_wordlists", "hashmob-wordlists"),