feat: add permutation attack using permute.bin (closes #86)

Adds Permutation Attack (menu option 19) that generates all character
permutations of each word in a targeted wordlist and pipes them to
hashcat via permute.bin from hashcat-utils.

- hcatPermute() in main.py: pipes permute.bin < wordlist | hashcat
- permute_crack() in attacks.py: prompts for single wordlist file with
  factorial-growth warning, tab-autocomplete support
- Menu option 19 wired in both main.py and hate_crack.py
- hcatPermuteCount tracking alongside other count globals
- Tests: test_permute_attack.py (handler behavior) and
  test_permute_wrapper.py (subprocess wiring)
- README: added entry in menu listing and attack descriptions
This commit is contained in:
Justin Bollinger
2026-03-19 12:14:05 -04:00
parent 53b99d4395
commit e41134eb1a
7 changed files with 333 additions and 0 deletions
+9
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
(19) Permutation 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
#### Permutation Attack
Generates all character permutations of each word in a targeted wordlist and pipes them to hashcat via `permute.bin` from hashcat-utils.
* Prompts for a single wordlist file (not a directory)
* Effective against short targeted wordlists where the character set is known but the order is not (company abbreviations, name fragments, known tokens)
* WARNING: Scales as N! per word - an 8-character word produces 40,320 permutations. Only practical for words up to ~8 characters.
* Uses `permute.bin < wordlist | hashcat` pipeline pattern
#### 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.
+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,
"19": _attacks.permute_crack,
"90": download_hashmob_rules,
"91": weakpass_wordlist_menu,
"92": download_hashmob_wordlists,
+47
View File
@@ -620,6 +620,53 @@ def markov_brute_force(ctx: Any) -> None:
ctx.hcatMarkovBruteForce(ctx.hcatHashType, ctx.hcatHashFile, hcatMinLen, hcatMaxLen)
def permute_crack(ctx: Any) -> None:
print("\n" + "=" * 60)
print("PERMUTATION ATTACK")
print("=" * 60)
print("Generates ALL character permutations of each word in a targeted wordlist.")
print("WARNING: Scales as N! per word. Only practical for words up to ~8 characters.")
print("Best for: short targeted wordlists (names, abbreviations, known fragments).")
print("=" * 60)
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_path = None
while wordlist_path is None:
raw = input(
"\nEnter path to a wordlist FILE (tab to autocomplete): "
).strip()
if not raw:
continue
if not os.path.exists(raw):
print(f"[!] Path not found: {raw}")
continue
if os.path.isdir(raw):
print("[!] A directory was provided. Please enter a single wordlist file.")
continue
wordlist_path = raw
ctx.hcatPermute(ctx.hcatHashType, ctx.hcatHashFile, wordlist_path)
def combinator_submenu(ctx: Any) -> None:
from hate_crack.menu import interactive_menu
+46
View File
@@ -677,6 +677,7 @@ hcatCombinationCount = 0
hcatHybridCount = 0
hcatExtraCount = 0
hcatRecycleCount = 0
hcatPermuteCount = 0
hcatProcess: subprocess.Popen[Any] | None = None
debug_mode = False
@@ -2222,6 +2223,45 @@ def hcatPrince(hcatHashType, hcatHashFile):
prince_proc.kill()
def hcatPermute(hcatHashType, hcatHashFile, wordlist):
global hcatProcess, hcatPermuteCount
permute_path = os.path.join(hate_path, "hashcat-utils", "bin", "permute.bin")
if not os.path.isfile(permute_path):
print(f"Error: permute.bin not found: {permute_path}")
return
if not os.path.isfile(wordlist):
print(f"Error: wordlist not found: {wordlist}")
return
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)
with open(wordlist, "rb") as wl_file:
permute_proc = subprocess.Popen(
[permute_path], stdin=wl_file, stdout=subprocess.PIPE
)
hcatProcess = subprocess.Popen(
hashcat_cmd, stdin=permute_proc.stdout
)
permute_proc.stdout.close()
try:
hcatProcess.wait()
permute_proc.wait()
except KeyboardInterrupt:
print(f"Killing PID {hcatProcess.pid}...")
hcatProcess.kill()
permute_proc.kill()
hcatPermuteCount = lineCount(f"{hcatHashFile}.out") - hcatHashCracked
# 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/.
@@ -3329,6 +3369,10 @@ def omen_attack():
return _attacks.omen_attack(_attack_ctx())
def permute_crack():
return _attacks.permute_crack(_attack_ctx())
# convert hex words for recycling
def convert_hex(working_file):
processed_words = []
@@ -3557,6 +3601,7 @@ def get_main_menu_items():
("16", "OMEN Attack"),
("17", "Ad-hoc Mask Attack"),
("18", "Markov Brute Force Attack"),
("19", "Permutation Attack"),
("90", "Download rules from Hashmob.net"),
("91", "Analyze Hashcat Rules"),
("92", "Download wordlists from Hashmob.net"),
@@ -3594,6 +3639,7 @@ def get_main_menu_options():
"16": omen_attack,
"17": adhoc_mask_crack,
"18": markov_brute_force,
"19": permute_crack,
"90": lambda: download_hashmob_rules(rules_dir=rulesDirectory),
"91": analyze_rules,
"92": download_hashmob_wordlists,
+67
View File
@@ -0,0 +1,67 @@
import os
from unittest.mock import MagicMock, patch
import pytest
from hate_crack.attacks import permute_crack
def _make_ctx(hash_type="1000", hash_file="/tmp/hashes.txt"):
ctx = MagicMock()
ctx.hcatHashType = hash_type
ctx.hcatHashFile = hash_file
ctx.hcatWordlists = "/tmp/wordlists"
return ctx
class TestPermuteCrack:
def test_calls_hcatPermute_with_valid_wordlist(self, tmp_path):
ctx = _make_ctx()
wl = tmp_path / "target.txt"
wl.write_text("abc\ndef\n")
with patch("builtins.input", return_value=str(wl)):
permute_crack(ctx)
ctx.hcatPermute.assert_called_once_with(
ctx.hcatHashType, ctx.hcatHashFile, str(wl)
)
def test_rejects_nonexistent_wordlist_then_accepts_valid(self, tmp_path):
ctx = _make_ctx()
wl = tmp_path / "real.txt"
wl.write_text("test\n")
with patch(
"builtins.input",
side_effect=["/nonexistent/path.txt", str(wl)],
):
permute_crack(ctx)
ctx.hcatPermute.assert_called_once_with(
ctx.hcatHashType, ctx.hcatHashFile, str(wl)
)
def test_rejects_directory_then_accepts_file(self, tmp_path):
ctx = _make_ctx()
wl = tmp_path / "words.txt"
wl.write_text("ab\n")
with patch("builtins.input", side_effect=[str(tmp_path), str(wl)]):
permute_crack(ctx)
ctx.hcatPermute.assert_called_once_with(
ctx.hcatHashType, ctx.hcatHashFile, str(wl)
)
def test_warns_about_factorial_scaling(self, tmp_path, capsys):
ctx = _make_ctx()
wl = tmp_path / "words.txt"
wl.write_text("abc\n")
with patch("builtins.input", return_value=str(wl)):
permute_crack(ctx)
captured = capsys.readouterr()
assert "WARNING" in captured.out or "factorial" in captured.out.lower() or "N!" in captured.out
def test_prints_header(self, tmp_path, capsys):
ctx = _make_ctx()
wl = tmp_path / "words.txt"
wl.write_text("abc\n")
with patch("builtins.input", return_value=str(wl)):
permute_crack(ctx)
captured = capsys.readouterr()
assert "PERMUTATION" in captured.out.upper()
+162
View File
@@ -0,0 +1,162 @@
import os
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 TestHcatPermute:
def test_uses_permute_bin(self, main_module, tmp_path):
wl = tmp_path / "words.txt"
wl.write_text("abc\n")
hash_file = str(tmp_path / "hashes.txt")
permute_bin_dir = tmp_path / "hashcat-utils" / "bin"
permute_bin_dir.mkdir(parents=True)
permute_bin = permute_bin_dir / "permute.bin"
permute_bin.touch()
mock_permute_proc = MagicMock()
mock_permute_proc.stdout = MagicMock()
mock_permute_proc.wait.return_value = None
mock_hashcat_proc = MagicMock()
mock_hashcat_proc.wait.return_value = None
mock_hashcat_proc.pid = 99
with patch.object(main_module, "hate_path", str(tmp_path)), \
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="sess1"), \
patch.object(main_module, "lineCount", return_value=0), \
patch.object(main_module, "hcatHashCracked", 0, create=True), \
patch("hate_crack.main.subprocess.Popen") as mock_popen:
mock_popen.side_effect = [mock_permute_proc, mock_hashcat_proc]
main_module.hcatPermute("1000", hash_file, str(wl))
assert mock_popen.call_count == 2
first_call_args = mock_popen.call_args_list[0][0][0]
assert "permute.bin" in str(first_call_args)
def test_pipes_permute_stdout_to_hashcat_stdin(self, main_module, tmp_path):
wl = tmp_path / "words.txt"
wl.write_text("abc\n")
hash_file = str(tmp_path / "hashes.txt")
permute_bin_dir = tmp_path / "hashcat-utils" / "bin"
permute_bin_dir.mkdir(parents=True)
(permute_bin_dir / "permute.bin").touch()
mock_permute_proc = MagicMock()
mock_permute_proc.stdout = MagicMock()
mock_permute_proc.wait.return_value = None
mock_hashcat_proc = MagicMock()
mock_hashcat_proc.wait.return_value = None
mock_hashcat_proc.pid = 99
with patch.object(main_module, "hate_path", str(tmp_path)), \
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="sess1"), \
patch.object(main_module, "lineCount", return_value=0), \
patch.object(main_module, "hcatHashCracked", 0, create=True), \
patch("hate_crack.main.subprocess.Popen") as mock_popen:
mock_popen.side_effect = [mock_permute_proc, mock_hashcat_proc]
main_module.hcatPermute("1000", hash_file, str(wl))
# Second call (hashcat) should use permute_proc.stdout as stdin
second_call_kwargs = mock_popen.call_args_list[1][1]
assert second_call_kwargs.get("stdin") == mock_permute_proc.stdout
def test_hashcat_cmd_includes_hash_type_and_file(self, main_module, tmp_path):
wl = tmp_path / "words.txt"
wl.write_text("abc\n")
hash_file = str(tmp_path / "hashes.txt")
permute_bin_dir = tmp_path / "hashcat-utils" / "bin"
permute_bin_dir.mkdir(parents=True)
(permute_bin_dir / "permute.bin").touch()
mock_permute_proc = MagicMock()
mock_permute_proc.stdout = MagicMock()
mock_permute_proc.wait.return_value = None
mock_hashcat_proc = MagicMock()
mock_hashcat_proc.wait.return_value = None
mock_hashcat_proc.pid = 99
with patch.object(main_module, "hate_path", str(tmp_path)), \
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="sess1"), \
patch.object(main_module, "lineCount", return_value=0), \
patch.object(main_module, "hcatHashCracked", 0, create=True), \
patch("hate_crack.main.subprocess.Popen") as mock_popen:
mock_popen.side_effect = [mock_permute_proc, mock_hashcat_proc]
main_module.hcatPermute("1000", hash_file, str(wl))
hashcat_cmd = mock_popen.call_args_list[1][0][0]
assert "hashcat" in hashcat_cmd
assert "-m" in hashcat_cmd
assert "1000" in hashcat_cmd
assert hash_file in hashcat_cmd
def test_keyboard_interrupt_kills_both_processes(self, main_module, tmp_path):
wl = tmp_path / "words.txt"
wl.write_text("abc\n")
hash_file = str(tmp_path / "hashes.txt")
permute_bin_dir = tmp_path / "hashcat-utils" / "bin"
permute_bin_dir.mkdir(parents=True)
(permute_bin_dir / "permute.bin").touch()
mock_permute_proc = MagicMock()
mock_permute_proc.stdout = MagicMock()
mock_permute_proc.wait.return_value = None
mock_hashcat_proc = MagicMock()
mock_hashcat_proc.wait.side_effect = KeyboardInterrupt()
mock_hashcat_proc.pid = 99
with patch.object(main_module, "hate_path", str(tmp_path)), \
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="sess1"), \
patch.object(main_module, "lineCount", return_value=0), \
patch.object(main_module, "hcatHashCracked", 0, create=True), \
patch("hate_crack.main.subprocess.Popen") as mock_popen:
mock_popen.side_effect = [mock_permute_proc, mock_hashcat_proc]
main_module.hcatPermute("1000", hash_file, str(wl))
mock_hashcat_proc.kill.assert_called_once()
mock_permute_proc.kill.assert_called_once()
def test_missing_permute_bin_prints_error(self, main_module, tmp_path, capsys):
wl = tmp_path / "words.txt"
wl.write_text("abc\n")
hash_file = str(tmp_path / "hashes.txt")
# No permute.bin created
with patch.object(main_module, "hate_path", str(tmp_path)):
main_module.hcatPermute("1000", hash_file, str(wl))
captured = capsys.readouterr()
assert "permute.bin" in captured.out
def test_missing_wordlist_prints_error(self, main_module, tmp_path, capsys):
hash_file = str(tmp_path / "hashes.txt")
permute_bin_dir = tmp_path / "hashcat-utils" / "bin"
permute_bin_dir.mkdir(parents=True)
(permute_bin_dir / "permute.bin").touch()
with patch.object(main_module, "hate_path", str(tmp_path)):
main_module.hcatPermute("1000", hash_file, "/nonexistent/words.txt")
captured = capsys.readouterr()
assert "not found" in captured.out.lower() or "error" in captured.out.lower()
+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"),
("19", CLI_MODULE._attacks, "permute_crack", "permute"),
("90", CLI_MODULE, "download_hashmob_rules", "hashmob-rules"),
("91", CLI_MODULE, "weakpass_wordlist_menu", "weakpass-menu"),
("92", CLI_MODULE, "download_hashmob_wordlists", "hashmob-wordlists"),