merge: resolve conflicts with main - permute moves to key 20, ngram stays at 19

This commit is contained in:
Justin Bollinger
2026-03-19 15:33:48 -04:00
12 changed files with 918 additions and 90 deletions

View File

@@ -764,11 +764,13 @@ Uses the Ordered Markov ENumerator (OMEN) to train a statistical password model
* Model files and metadata are stored in `~/.hate_crack/omen/` for persistence across sessions
#### Combinator Attacks Submenu
Opens an interactive submenu with four combinator attack variants (formerly at menu keys 10-12). Consolidates related attacks for cleaner menu organization:
Opens an interactive submenu with six combinator attack variants (formerly at menu keys 10-12). Consolidates related attacks for cleaner menu organization:
- Combinator Attack - combines two wordlists
- YOLO Combinator Attack - combines all permutations of multiple wordlists
- Middle Combinator Attack - combines wordlists with an extra word in the middle
- Thorough Combinator Attack - comprehensive combination of wordlists with rules
- Combinator3 Attack - combines exactly 3 wordlists using `combinator3.bin`, generating all `word1+word2+word3` combinations piped to hashcat
- CombinatorX Attack - combines 2-8 wordlists using `combinatorX.bin` with optional `--sepFill` separator character between word segments
#### Ad-hoc Mask Attack
Runs hashcat mask attack (mode 3) with a user-specified custom mask string. Allows fine-grained control over character-set brute forcing.

View File

@@ -8,6 +8,8 @@
"rules_directory": "./hashcat/rules",
"hcatDictionaryWordlist": ["rockyou.txt"],
"hcatCombinationWordlist": ["rockyou.txt","rockyou.txt"],
"hcatCombinator3Wordlist": ["rockyou.txt","rockyou.txt","rockyou.txt"],
"hcatCombinatorXWordlist": ["rockyou.txt","rockyou.txt"],
"hcatHybridlist": ["rockyou.txt"],
"hcatMiddleCombinatorMasks": ["2","4"," ","-","_","+",",",".","&"],
"hcatMiddleBaseList": "rockyou.txt",

View File

@@ -88,7 +88,8 @@ def get_main_menu_options():
"16": _attacks.omen_attack,
"17": _attacks.adhoc_mask_crack,
"18": _attacks.markov_brute_force,
"19": _attacks.permute_crack,
"19": _attacks.ngram_attack,
"20": _attacks.permute_crack,
"90": download_hashmob_rules,
"91": weakpass_wordlist_menu,
"92": download_hashmob_wordlists,

View File

@@ -5,6 +5,7 @@ from typing import Any
from hate_crack.api import download_hashmob_rules
from hate_crack.formatting import print_multicolumn_list
from hate_crack.menu import interactive_menu
def _configure_readline(completer):
@@ -261,8 +262,7 @@ def combinator_crack(ctx: Any) -> None:
print("\n" + "=" * 60)
print("COMBINATOR ATTACK")
print("=" * 60)
print("This attack combines two wordlists to generate candidates.")
print("Example: wordlist1='password' + wordlist2='123' = 'password123'")
print("Combines 2-8 wordlists. 2 uses hashcat native mode; 3+ use external binaries.")
print("=" * 60)
use_default = (
@@ -270,73 +270,30 @@ def combinator_crack(ctx: Any) -> None:
)
if use_default != "n":
print("\nUsing default wordlist(s) from config:")
if isinstance(ctx.hcatCombinationWordlist, list):
for wl in ctx.hcatCombinationWordlist:
print(f" - {wl}")
wordlists = ctx.hcatCombinationWordlist
else:
print(f" - {ctx.hcatCombinationWordlist}")
wordlists = [ctx.hcatCombinationWordlist]
else:
print("\nSelect wordlists for combinator attack.")
print("You need to provide exactly 2 wordlists.")
print("You can enter:")
print(" - Two file paths separated by commas")
print(" - Press TAB to autocomplete file paths")
selection = ctx.select_file_with_autocomplete(
"Enter 2 wordlist files (comma-separated)",
allow_multiple=True,
base_dir=ctx.hcatWordlists,
)
if not selection:
print("No wordlists selected. Aborting combinator attack.")
base = ctx.hcatCombinationWordlist
wordlists = base if isinstance(base, list) else [base]
wordlists = [ctx._resolve_wordlist_path(wl, ctx.hcatWordlists) for wl in wordlists]
if len(wordlists) < 2:
print("\n[!] Config does not have at least 2 wordlists.")
print("Set hcatCombinationWordlist to a list of 2+ paths in config.json.")
print("Aborting combinator attack.")
return
if isinstance(selection, str):
wordlists = [selection]
else:
wordlists = selection
separator = ""
else:
print("\nEnter 2-8 wordlists. Enter a blank line when done.")
wordlists = _prompt_wordlist_paths(ctx, max_count=8)
if len(wordlists) < 2:
print("\n[!] Combinator attack requires at least 2 wordlists.")
print("Aborting combinator attack.")
return
separator = input("\nEnter separator between words (leave blank for none): ").strip()
valid_wordlists = []
for wl in wordlists[:2]: # Only use first 2
resolved = ctx._resolve_wordlist_path(wl, ctx.hcatWordlists)
if os.path.isfile(resolved):
valid_wordlists.append(resolved)
print(f"✓ Found: {resolved}")
else:
print(f"✗ Not found: {resolved}")
if len(valid_wordlists) < 2:
print("\nCould not find 2 valid wordlists. Aborting combinator attack.")
return
wordlists = valid_wordlists
wordlists = [
ctx._resolve_wordlist_path(wl, ctx.hcatWordlists) for wl in wordlists[:2]
]
if len(wordlists) < 2:
print("\n[!] Combinator attack requires 2 wordlists but only 1 is configured.")
print("Set hcatCombinationWordlist to a list of 2 paths in config.json.")
print("Aborting combinator attack.")
return
print("\nStarting combinator attack with 2 wordlists:")
print(f" Wordlist 1: {wordlists[0]}")
print(f" Wordlist 2: {wordlists[1]}")
print(f"Hash type: {ctx.hcatHashType}")
print(f"Hash file: {ctx.hcatHashFile}")
ctx.hcatCombination(ctx.hcatHashType, ctx.hcatHashFile, wordlists)
if len(wordlists) == 2 and not separator:
ctx.hcatCombination(ctx.hcatHashType, ctx.hcatHashFile, wordlists)
elif len(wordlists) == 3 and not separator:
ctx.hcatCombinator3(ctx.hcatHashType, ctx.hcatHashFile, wordlists)
else:
ctx.hcatCombinatorX(ctx.hcatHashType, ctx.hcatHashFile, wordlists, separator or None)
def hybrid_crack(ctx: Any) -> None:
@@ -427,6 +384,66 @@ def middle_combinator(ctx: Any) -> None:
ctx.hcatMiddleCombinator(ctx.hcatHashType, ctx.hcatHashFile)
def _prompt_wordlist_paths(ctx, max_count: int) -> list[str]:
"""Prompt for wordlist paths one at a time with tab-autocomplete.
Stops when a blank line is entered or max_count paths have been collected.
Returns a list of resolved, valid file paths.
"""
def path_completer(text, state):
base = ctx.hcatWordlists
if not text:
pattern = os.path.join(base, "*")
matches = glob.glob(pattern)
else:
expanded = os.path.expanduser(text)
if expanded.startswith(("/", "./", "../", "~")):
matches = glob.glob(expanded + "*")
else:
pattern = os.path.join(base, expanded + "*")
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)
collected: list[str] = []
count = 1
while len(collected) < max_count:
raw = input(
f"\nWordlist #{count} (tab to autocomplete, blank to finish): "
).strip()
if not raw:
break
resolved = ctx._resolve_wordlist_path(raw, ctx.hcatWordlists)
if os.path.isfile(resolved):
collected.append(resolved)
print(f"Added: {resolved}")
count += 1
else:
print(f"Not found: {resolved}")
return collected
def combinator3_crack(ctx: Any) -> None:
"""3-way combinator attack (delegates to unified combinator_crack)."""
combinator_crack(ctx)
def combinatorX_crack(ctx: Any) -> None:
"""N-way combinator attack (delegates to unified combinator_crack)."""
combinator_crack(ctx)
def combinator_3plus_crack(ctx: Any) -> None:
"""3+ wordlist combinator (delegates to unified combinator_crack)."""
combinator_crack(ctx)
def bandrel_method(ctx: Any) -> None:
ctx.hcatBandrel(ctx.hcatHashType, ctx.hcatHashFile)
@@ -620,6 +637,32 @@ def markov_brute_force(ctx: Any) -> None:
ctx.hcatMarkovBruteForce(ctx.hcatHashType, ctx.hcatHashFile, hcatMinLen, hcatMaxLen)
def ngram_attack(ctx: Any) -> None:
print("\n" + "=" * 60)
print("NGRAM ATTACK")
print("=" * 60)
print("Generates n-gram candidates from a corpus file via ngramX.bin.")
print("Gzip-compressed corpus files are auto-detected and decompressed.")
print("=" * 60)
corpus = ctx.select_file_with_autocomplete(
"Select corpus file (tab to autocomplete)",
base_dir=ctx.hcatWordlists,
)
if not corpus:
print("No corpus selected. Aborting ngram attack.")
return
group_size_raw = input("\nEnter n-gram group size (default 3): ").strip()
try:
group_size = int(group_size_raw) if group_size_raw else 3
except ValueError:
print("[!] Invalid group size. Using default of 3.")
group_size = 3
ctx.hcatNgramX(ctx.hcatHashType, ctx.hcatHashFile, corpus, group_size)
def permute_crack(ctx: Any) -> None:
print("\n" + "=" * 60)
print("PERMUTATION ATTACK")
@@ -668,10 +711,8 @@ def permute_crack(ctx: Any) -> None:
def combinator_submenu(ctx: Any) -> None:
from hate_crack.menu import interactive_menu
items = [
("1", "Combinator Attack"),
("1", "Combinator Attack (2-8 wordlists)"),
("2", "YOLO Combinator Attack"),
("3", "Middle Combinator Attack"),
("4", "Thorough Combinator Attack"),

View File

@@ -23,8 +23,10 @@ import time
import argparse
import urllib.request
import urllib.error
import contextlib
import gzip
import lzma
import tempfile
from types import SimpleNamespace
#!/usr/bin/env python3
@@ -357,19 +359,19 @@ else:
def _append_potfile_arg(cmd, *, use_potfile_path=True, potfile_path=None):
if not use_potfile_path:
return
pot = potfile_path or hcatPotfilePath
if pot:
try:
pot_dir = os.path.dirname(pot)
if pot_dir:
os.makedirs(pot_dir, exist_ok=True)
if not os.path.exists(pot):
open(pot, "a").close()
except OSError:
pass
cmd.append(f"--potfile-path={pot}")
if use_potfile_path:
pot = potfile_path or hcatPotfilePath
if pot:
try:
pot_dir = os.path.dirname(pot)
if pot_dir:
os.makedirs(pot_dir, exist_ok=True)
if not os.path.exists(pot):
open(pot, "a").close()
except OSError:
pass
cmd.append(f"--potfile-path={pot}")
_debug_cmd(cmd)
rulesDirectory = config_parser["rules_directory"]
@@ -402,6 +404,8 @@ pipalPath = config_parser["pipalPath"]
hcatDictionaryWordlist = config_parser["hcatDictionaryWordlist"]
hcatHybridlist = config_parser["hcatHybridlist"]
hcatCombinationWordlist = config_parser["hcatCombinationWordlist"]
hcatCombinator3Wordlist = config_parser.get("hcatCombinator3Wordlist", ["rockyou.txt", "rockyou.txt", "rockyou.txt"])
hcatCombinatorXWordlist = config_parser.get("hcatCombinatorXWordlist", ["rockyou.txt", "rockyou.txt"])
hcatMiddleCombinatorMasks = config_parser["hcatMiddleCombinatorMasks"]
hcatMiddleBaseList = config_parser["hcatMiddleBaseList"]
hcatThoroughCombinatorMasks = config_parser["hcatThoroughCombinatorMasks"]
@@ -559,6 +563,8 @@ hcatDictionaryWordlist = _normalize_wordlist_setting(
hcatCombinationWordlist = _normalize_wordlist_setting(
hcatCombinationWordlist, wordlists_dir
)
hcatCombinator3Wordlist = _normalize_wordlist_setting(hcatCombinator3Wordlist, wordlists_dir)
hcatCombinatorXWordlist = _normalize_wordlist_setting(hcatCombinatorXWordlist, wordlists_dir)
hcatHybridlist = _normalize_wordlist_setting(hcatHybridlist, wordlists_dir)
hcatMiddleBaseList = _normalize_wordlist_setting(hcatMiddleBaseList, wordlists_dir)
hcatThoroughBaseList = _normalize_wordlist_setting(hcatThoroughBaseList, wordlists_dir)
@@ -675,6 +681,9 @@ hcatDictionaryCount = 0
hcatMaskCount = 0
hcatFingerprintCount = 0
hcatCombinationCount = 0
hcatCombinator3Count = 0
hcatCombinatorXCount = 0
hcatNgramXCount = 0
hcatHybridCount = 0
hcatExtraCount = 0
hcatRecycleCount = 0
@@ -700,6 +709,37 @@ def _debug_cmd(cmd):
print(f"[DEBUG] hashcat cmd: {_format_cmd(cmd)}")
def _is_gzipped(path: str) -> bool:
try:
with open(path, "rb") as f:
return f.read(2) == b"\x1f\x8b"
except OSError:
return False
@contextlib.contextmanager
def _wordlist_path(path: str):
"""Yield an uncompressed path for path.
If the file is gzip-compressed, decompress to a temp file and clean up on
exit. Otherwise yield the original path unchanged.
"""
if _is_gzipped(path):
with tempfile.NamedTemporaryFile(delete=False, suffix=".txt") as tmp:
tmp_name = tmp.name
with gzip.open(path, "rb") as gz_in:
shutil.copyfileobj(gz_in, tmp)
try:
yield tmp_name
finally:
try:
os.unlink(tmp_name)
except OSError:
pass
else:
yield path
def _add_debug_mode_for_rules(cmd):
"""Add debug mode arguments to hashcat command if rules are being used.
@@ -1424,6 +1464,125 @@ def hcatCombination(hcatHashType, hcatHashFile, wordlists=None):
hcatCombinationCount = lineCount(hcatHashFile + ".out") - hcatHashCracked
# Combinator3 Attack - 3-way combination via combinator3.bin piped to hashcat
def hcatCombinator3(hcatHashType, hcatHashFile, wordlists):
global hcatCombinator3Count
global hcatProcess
if len(wordlists) < 3:
print("[!] Combinator3 attack requires exactly 3 wordlists.")
return
combinator3_bin = os.path.join(hate_path, "hashcat-utils/bin/combinator3.bin")
with contextlib.ExitStack() as stack:
resolved = [stack.enter_context(_wordlist_path(w)) for w in wordlists[:3]]
generator_cmd = [combinator3_bin] + resolved
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)
generator_proc = subprocess.Popen(generator_cmd, stdout=subprocess.PIPE)
assert generator_proc.stdout is not None
hcatProcess = subprocess.Popen(hashcat_cmd, stdin=generator_proc.stdout)
generator_proc.stdout.close()
try:
hcatProcess.wait()
generator_proc.wait()
except KeyboardInterrupt:
print("Killing PID {0}...".format(str(hcatProcess.pid)))
hcatProcess.kill()
generator_proc.kill()
hcatCombinator3Count = lineCount(hcatHashFile + ".out") - hcatHashCracked
# CombinatorX Attack - N-way combination (2-8 wordlists) via combinatorX.bin piped to hashcat
def hcatCombinatorX(hcatHashType, hcatHashFile, wordlists, separator=None):
global hcatCombinatorXCount
global hcatProcess
if len(wordlists) < 2:
print("[!] CombinatorX attack requires at least 2 wordlists.")
return
combinatorX_bin = os.path.join(hate_path, "hashcat-utils/bin/combinatorX.bin")
with contextlib.ExitStack() as stack:
resolved = [stack.enter_context(_wordlist_path(w)) for w in wordlists[:8]]
generator_cmd = [combinatorX_bin]
for i, f in enumerate(resolved, start=1):
generator_cmd += [f"--file{i}", f]
if separator:
generator_cmd += ["--sepFill", separator]
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)
generator_proc = subprocess.Popen(generator_cmd, stdout=subprocess.PIPE)
assert generator_proc.stdout is not None
hcatProcess = subprocess.Popen(hashcat_cmd, stdin=generator_proc.stdout)
generator_proc.stdout.close()
try:
hcatProcess.wait()
generator_proc.wait()
except KeyboardInterrupt:
print("Killing PID {0}...".format(str(hcatProcess.pid)))
hcatProcess.kill()
generator_proc.kill()
hcatCombinatorXCount = lineCount(hcatHashFile + ".out") - hcatHashCracked
# NgramX Attack - n-gram candidates from corpus file piped to hashcat
def hcatNgramX(hcatHashType, hcatHashFile, corpus, group_size=3):
global hcatNgramXCount
global hcatProcess
ngramX_bin = os.path.join(hate_path, "hashcat-utils/bin/ngramX.bin")
with _wordlist_path(corpus) as resolved_corpus:
generator_cmd = [ngramX_bin, resolved_corpus, str(group_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)
generator_proc = subprocess.Popen(generator_cmd, stdout=subprocess.PIPE)
assert generator_proc.stdout is not None
hcatProcess = subprocess.Popen(hashcat_cmd, stdin=generator_proc.stdout)
generator_proc.stdout.close()
try:
hcatProcess.wait()
generator_proc.wait()
except KeyboardInterrupt:
print("Killing PID {0}...".format(str(hcatProcess.pid)))
hcatProcess.kill()
generator_proc.kill()
hcatNgramXCount = lineCount(hcatHashFile + ".out") - hcatHashCracked
# Hybrid Attack
def hcatHybrid(hcatHashType, hcatHashFile, wordlists=None):
global hcatHybridCount
@@ -3349,6 +3508,22 @@ def middle_combinator():
return _attacks.middle_combinator(_attack_ctx())
def combinator3_crack():
return _attacks.combinator3_crack(_attack_ctx())
def combinatorX_crack():
return _attacks.combinatorX_crack(_attack_ctx())
def combinator_3plus_crack():
return _attacks.combinator_3plus_crack(_attack_ctx())
def ngram_attack():
return _attacks.ngram_attack(_attack_ctx())
def combinator_submenu():
return _attacks.combinator_submenu(_attack_ctx())
@@ -3609,7 +3784,8 @@ def get_main_menu_items():
("16", "OMEN Attack"),
("17", "Ad-hoc Mask Attack"),
("18", "Markov Brute Force Attack"),
("19", "Permutation Attack"),
("19", "N-gram Attack"),
("20", "Permutation Attack"),
("90", "Download rules from Hashmob.net"),
("91", "Analyze Hashcat Rules"),
("92", "Download wordlists from Hashmob.net"),
@@ -3647,7 +3823,8 @@ def get_main_menu_options():
"16": omen_attack,
"17": adhoc_mask_crack,
"18": markov_brute_force,
"19": permute_crack,
"19": ngram_attack,
"20": permute_crack,
"90": lambda: download_hashmob_rules(rules_dir=rulesDirectory),
"91": analyze_rules,
"92": download_hashmob_wordlists,

View File

@@ -216,7 +216,7 @@ class TestCombinatorCrack:
ctx._resolve_wordlist_path.assert_any_call("/wl/a.txt", ctx.hcatWordlists)
ctx._resolve_wordlist_path.assert_any_call("/wl/b.txt", ctx.hcatWordlists)
def test_uses_only_first_two_wordlists(self) -> None:
def test_three_wordlists_in_config_routes_to_combinator3(self) -> None:
ctx = _make_ctx()
ctx.hcatCombinationWordlist = ["/wl/a.txt", "/wl/b.txt", "/wl/c.txt"]
ctx._resolve_wordlist_path.side_effect = lambda wl, _: wl
@@ -224,9 +224,10 @@ class TestCombinatorCrack:
with patch("builtins.input", return_value=""):
combinator_crack(ctx)
call_wordlists = ctx.hcatCombination.call_args[0][2]
assert len(call_wordlists) == 2
assert "/wl/c.txt" not in call_wordlists
ctx.hcatCombinator3.assert_called_once()
ctx.hcatCombination.assert_not_called()
call_wordlists = ctx.hcatCombinator3.call_args[0][2]
assert len(call_wordlists) == 3
class TestHybridCrack:

View File

@@ -0,0 +1,154 @@
from unittest.mock import MagicMock, patch
from hate_crack.attacks import combinator_crack, combinator_submenu
def _make_ctx(hash_type="1000", hash_file="/tmp/hashes.txt"):
ctx = MagicMock()
ctx.hcatHashType = hash_type
ctx.hcatHashFile = hash_file
return ctx
class TestCombinatorCrackUnified:
def test_two_wordlists_calls_hcatCombination(self, tmp_path):
ctx = _make_ctx()
ctx.hcatWordlists = str(tmp_path)
for name in ["a.txt", "b.txt"]:
(tmp_path / name).write_text("word\n")
ctx._resolve_wordlist_path.side_effect = lambda p, base: p
inputs = ["n", f"{tmp_path}/a.txt", f"{tmp_path}/b.txt", "", ""]
with patch("builtins.input", side_effect=inputs):
combinator_crack(ctx)
ctx.hcatCombination.assert_called_once()
ctx.hcatCombinator3.assert_not_called()
ctx.hcatCombinatorX.assert_not_called()
def test_three_wordlists_calls_hcatCombinator3(self, tmp_path):
ctx = _make_ctx()
ctx.hcatWordlists = str(tmp_path)
for name in ["a.txt", "b.txt", "c.txt"]:
(tmp_path / name).write_text("word\n")
ctx._resolve_wordlist_path.side_effect = lambda p, base: p
inputs = ["n", f"{tmp_path}/a.txt", f"{tmp_path}/b.txt", f"{tmp_path}/c.txt", "", ""]
with patch("builtins.input", side_effect=inputs):
combinator_crack(ctx)
ctx.hcatCombinator3.assert_called_once()
ctx.hcatCombination.assert_not_called()
ctx.hcatCombinatorX.assert_not_called()
def test_four_wordlists_calls_hcatCombinatorX(self, tmp_path):
ctx = _make_ctx()
ctx.hcatWordlists = str(tmp_path)
for name in ["a.txt", "b.txt", "c.txt", "d.txt"]:
(tmp_path / name).write_text("word\n")
ctx._resolve_wordlist_path.side_effect = lambda p, base: p
inputs = [
"n",
f"{tmp_path}/a.txt",
f"{tmp_path}/b.txt",
f"{tmp_path}/c.txt",
f"{tmp_path}/d.txt",
"",
"",
]
with patch("builtins.input", side_effect=inputs):
combinator_crack(ctx)
ctx.hcatCombinatorX.assert_called_once()
ctx.hcatCombination.assert_not_called()
ctx.hcatCombinator3.assert_not_called()
def test_separator_forces_combinatorX_for_two_wordlists(self, tmp_path):
ctx = _make_ctx()
ctx.hcatWordlists = str(tmp_path)
for name in ["a.txt", "b.txt"]:
(tmp_path / name).write_text("word\n")
ctx._resolve_wordlist_path.side_effect = lambda p, base: p
inputs = ["n", f"{tmp_path}/a.txt", f"{tmp_path}/b.txt", "", "-"]
with patch("builtins.input", side_effect=inputs):
combinator_crack(ctx)
ctx.hcatCombinatorX.assert_called_once()
ctx.hcatCombination.assert_not_called()
def test_separator_forces_combinatorX_for_three_wordlists(self, tmp_path):
ctx = _make_ctx()
ctx.hcatWordlists = str(tmp_path)
for name in ["a.txt", "b.txt", "c.txt"]:
(tmp_path / name).write_text("word\n")
ctx._resolve_wordlist_path.side_effect = lambda p, base: p
inputs = ["n", f"{tmp_path}/a.txt", f"{tmp_path}/b.txt", f"{tmp_path}/c.txt", "", "-"]
with patch("builtins.input", side_effect=inputs):
combinator_crack(ctx)
ctx.hcatCombinatorX.assert_called_once()
ctx.hcatCombinator3.assert_not_called()
def test_aborts_with_fewer_than_2_wordlists(self, tmp_path):
ctx = _make_ctx()
ctx.hcatWordlists = str(tmp_path)
(tmp_path / "a.txt").write_text("word\n")
ctx._resolve_wordlist_path.side_effect = lambda p, base: p
inputs = ["n", f"{tmp_path}/a.txt", ""]
with patch("builtins.input", side_effect=inputs):
combinator_crack(ctx)
ctx.hcatCombination.assert_not_called()
ctx.hcatCombinator3.assert_not_called()
ctx.hcatCombinatorX.assert_not_called()
def test_aborts_when_no_wordlists_provided(self, tmp_path):
ctx = _make_ctx()
ctx.hcatWordlists = str(tmp_path)
ctx._resolve_wordlist_path.side_effect = lambda p, base: p
with patch("builtins.input", side_effect=["n", ""]):
combinator_crack(ctx)
ctx.hcatCombination.assert_not_called()
ctx.hcatCombinator3.assert_not_called()
ctx.hcatCombinatorX.assert_not_called()
def test_no_separator_passes_none_to_combinatorX(self, tmp_path):
ctx = _make_ctx()
ctx.hcatWordlists = str(tmp_path)
for name in ["a.txt", "b.txt", "c.txt", "d.txt"]:
(tmp_path / name).write_text("word\n")
ctx._resolve_wordlist_path.side_effect = lambda p, base: p
inputs = [
"n",
f"{tmp_path}/a.txt",
f"{tmp_path}/b.txt",
f"{tmp_path}/c.txt",
f"{tmp_path}/d.txt",
"",
"",
]
with patch("builtins.input", side_effect=inputs):
combinator_crack(ctx)
call_args = ctx.hcatCombinatorX.call_args
positional_sep = call_args[0][3] if len(call_args[0]) >= 4 else None
keyword_sep = call_args[1].get("separator")
assert positional_sep in (None, "") and keyword_sep in (None, "")
class TestCombinatorSubmenuUpdated:
def test_submenu_option1_dispatches_to_combinator_crack(self):
ctx = _make_ctx()
with patch("hate_crack.attacks.combinator_crack") as mock_c, patch(
"hate_crack.attacks.interactive_menu", side_effect=["1", "99"]
):
combinator_submenu(ctx)
mock_c.assert_called_once_with(ctx)
def test_submenu_has_no_separate_3plus_option(self):
"""Verify option 5 (3+) is removed - combinator is now unified under option 1."""
ctx = _make_ctx()
captured_items = []
def capture_menu(items, **kwargs):
captured_items.extend(items)
return "99"
with patch("hate_crack.attacks.interactive_menu", side_effect=capture_menu):
combinator_submenu(ctx)
keys = [item[0] for item in captured_items]
assert "1" in keys
assert "5" not in keys
assert "6" not in keys

View File

@@ -0,0 +1,300 @@
"""Tests for hcatCombinator3 and hcatCombinatorX hashcat wrapper functions."""
from unittest.mock import MagicMock, patch
import pytest
def _make_mock_proc(wait_side_effect=None):
proc = MagicMock()
proc.stdout = 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
@pytest.fixture
def main_module(hc_module):
return hc_module._main
class TestHcatCombinator3:
def test_calls_combinator3_bin_with_three_files(self, main_module, tmp_path):
hash_file = str(tmp_path / "hashes.txt")
wls = []
for i in range(3):
p = str(tmp_path / f"w{i}.txt")
open(p, "w").close()
wls.append(p)
combinator_proc = _make_mock_proc()
hashcat_proc = _make_mock_proc()
def popen_side_effect(cmd, **kwargs):
if "combinator3" in str(cmd[0]):
return combinator_proc
return hashcat_proc
with (
patch.object(main_module, "hcatBin", "hashcat"),
patch.object(main_module, "hcatTuning", ""),
patch.object(main_module, "hcatPotfilePath", ""),
patch.object(main_module, "hcatWordlists", str(tmp_path)),
patch.object(main_module, "generate_session_id", return_value="sess123"),
patch.object(main_module, "lineCount", return_value=0),
patch("hate_crack.main.subprocess.Popen", side_effect=popen_side_effect) as mock_popen,
):
main_module.hcatCombinator3("1000", hash_file, wls)
calls = mock_popen.call_args_list
assert len(calls) == 2
combinator_cmd = calls[0][0][0]
assert "combinator3" in combinator_cmd[0]
assert wls[0] in combinator_cmd
assert wls[1] in combinator_cmd
assert wls[2] in combinator_cmd
def test_pipes_stdout_to_hashcat_stdin(self, main_module, tmp_path):
hash_file = str(tmp_path / "hashes.txt")
wls = []
for i in range(3):
p = str(tmp_path / f"w{i}.txt")
open(p, "w").close()
wls.append(p)
combinator_proc = _make_mock_proc()
hashcat_proc = _make_mock_proc()
def popen_side_effect(cmd, **kwargs):
if "combinator3" in str(cmd[0]):
return combinator_proc
return hashcat_proc
with (
patch.object(main_module, "hcatBin", "hashcat"),
patch.object(main_module, "hcatTuning", ""),
patch.object(main_module, "hcatPotfilePath", ""),
patch.object(main_module, "hcatWordlists", str(tmp_path)),
patch.object(main_module, "generate_session_id", return_value="sess123"),
patch.object(main_module, "lineCount", return_value=0),
patch("hate_crack.main.subprocess.Popen", side_effect=popen_side_effect) as mock_popen,
):
main_module.hcatCombinator3("1000", hash_file, wls)
calls = mock_popen.call_args_list
hashcat_call_kwargs = calls[1][1]
assert hashcat_call_kwargs.get("stdin") == combinator_proc.stdout
def test_aborts_with_fewer_than_3_wordlists(self, main_module, tmp_path):
hash_file = str(tmp_path / "hashes.txt")
wl1 = str(tmp_path / "w1.txt")
wl2 = str(tmp_path / "w2.txt")
for p in [wl1, wl2]:
open(p, "w").close()
with patch("hate_crack.main.subprocess.Popen") as mock_popen:
main_module.hcatCombinator3("1000", hash_file, [wl1, wl2])
mock_popen.assert_not_called()
def test_keyboard_interrupt_kills_both_processes(self, main_module, tmp_path):
hash_file = str(tmp_path / "hashes.txt")
wls = []
for i in range(3):
p = str(tmp_path / f"w{i}.txt")
open(p, "w").close()
wls.append(p)
combinator_proc = _make_mock_proc()
hashcat_proc = _make_mock_proc(wait_side_effect=KeyboardInterrupt())
def popen_side_effect(cmd, **kwargs):
if "combinator3" in str(cmd[0]):
return combinator_proc
return hashcat_proc
with (
patch.object(main_module, "hcatBin", "hashcat"),
patch.object(main_module, "hcatTuning", ""),
patch.object(main_module, "hcatPotfilePath", ""),
patch.object(main_module, "hcatWordlists", str(tmp_path)),
patch.object(main_module, "generate_session_id", return_value="sess123"),
patch.object(main_module, "lineCount", return_value=0),
patch("hate_crack.main.subprocess.Popen", side_effect=popen_side_effect),
):
main_module.hcatCombinator3("1000", hash_file, wls)
hashcat_proc.kill.assert_called_once()
combinator_proc.kill.assert_called_once()
class TestHcatCombinatorX:
def test_calls_combinatorX_bin_with_file_flags(self, main_module, tmp_path):
hash_file = str(tmp_path / "hashes.txt")
wls = []
for i in range(2):
p = str(tmp_path / f"w{i}.txt")
open(p, "w").close()
wls.append(p)
combinator_proc = _make_mock_proc()
hashcat_proc = _make_mock_proc()
def popen_side_effect(cmd, **kwargs):
if "combinatorX" in str(cmd[0]):
return combinator_proc
return hashcat_proc
with (
patch.object(main_module, "hcatBin", "hashcat"),
patch.object(main_module, "hcatTuning", ""),
patch.object(main_module, "hcatPotfilePath", ""),
patch.object(main_module, "hcatWordlists", str(tmp_path)),
patch.object(main_module, "generate_session_id", return_value="sess123"),
patch.object(main_module, "lineCount", return_value=0),
patch("hate_crack.main.subprocess.Popen", side_effect=popen_side_effect) as mock_popen,
):
main_module.hcatCombinatorX("1000", hash_file, wls)
calls = mock_popen.call_args_list
assert len(calls) == 2
combinator_cmd = calls[0][0][0]
assert "combinatorX" in combinator_cmd[0]
assert "--file1" in combinator_cmd
assert "--file2" in combinator_cmd
def test_passes_sepfill_when_separator_given(self, main_module, tmp_path):
hash_file = str(tmp_path / "hashes.txt")
wls = []
for i in range(2):
p = str(tmp_path / f"w{i}.txt")
open(p, "w").close()
wls.append(p)
combinator_proc = _make_mock_proc()
hashcat_proc = _make_mock_proc()
def popen_side_effect(cmd, **kwargs):
if "combinatorX" in str(cmd[0]):
return combinator_proc
return hashcat_proc
with (
patch.object(main_module, "hcatBin", "hashcat"),
patch.object(main_module, "hcatTuning", ""),
patch.object(main_module, "hcatPotfilePath", ""),
patch.object(main_module, "hcatWordlists", str(tmp_path)),
patch.object(main_module, "generate_session_id", return_value="sess123"),
patch.object(main_module, "lineCount", return_value=0),
patch("hate_crack.main.subprocess.Popen", side_effect=popen_side_effect) as mock_popen,
):
main_module.hcatCombinatorX("1000", hash_file, wls, separator="-")
combinator_cmd = mock_popen.call_args_list[0][0][0]
assert "--sepFill" in combinator_cmd
sep_idx = combinator_cmd.index("--sepFill")
assert combinator_cmd[sep_idx + 1] == "-"
def test_no_sepfill_when_separator_is_none(self, main_module, tmp_path):
hash_file = str(tmp_path / "hashes.txt")
wls = []
for i in range(2):
p = str(tmp_path / f"w{i}.txt")
open(p, "w").close()
wls.append(p)
combinator_proc = _make_mock_proc()
hashcat_proc = _make_mock_proc()
def popen_side_effect(cmd, **kwargs):
if "combinatorX" in str(cmd[0]):
return combinator_proc
return hashcat_proc
with (
patch.object(main_module, "hcatBin", "hashcat"),
patch.object(main_module, "hcatTuning", ""),
patch.object(main_module, "hcatPotfilePath", ""),
patch.object(main_module, "hcatWordlists", str(tmp_path)),
patch.object(main_module, "generate_session_id", return_value="sess123"),
patch.object(main_module, "lineCount", return_value=0),
patch("hate_crack.main.subprocess.Popen", side_effect=popen_side_effect) as mock_popen,
):
main_module.hcatCombinatorX("1000", hash_file, wls, separator=None)
combinator_cmd = mock_popen.call_args_list[0][0][0]
assert "--sepFill" not in combinator_cmd
def test_aborts_with_fewer_than_2_wordlists(self, main_module, tmp_path):
hash_file = str(tmp_path / "hashes.txt")
wl1 = str(tmp_path / "w1.txt")
open(wl1, "w").close()
with patch("hate_crack.main.subprocess.Popen") as mock_popen:
main_module.hcatCombinatorX("1000", hash_file, [wl1])
mock_popen.assert_not_called()
def test_supports_up_to_8_wordlists(self, main_module, tmp_path):
hash_file = str(tmp_path / "hashes.txt")
wls = []
for i in range(8):
p = str(tmp_path / f"w{i}.txt")
open(p, "w").close()
wls.append(p)
combinator_proc = _make_mock_proc()
hashcat_proc = _make_mock_proc()
def popen_side_effect(cmd, **kwargs):
if "combinatorX" in str(cmd[0]):
return combinator_proc
return hashcat_proc
with (
patch.object(main_module, "hcatBin", "hashcat"),
patch.object(main_module, "hcatTuning", ""),
patch.object(main_module, "hcatPotfilePath", ""),
patch.object(main_module, "hcatWordlists", str(tmp_path)),
patch.object(main_module, "generate_session_id", return_value="sess123"),
patch.object(main_module, "lineCount", return_value=0),
patch("hate_crack.main.subprocess.Popen", side_effect=popen_side_effect) as mock_popen,
):
main_module.hcatCombinatorX("1000", hash_file, wls)
combinator_cmd = mock_popen.call_args_list[0][0][0]
for i in range(1, 9):
assert f"--file{i}" in combinator_cmd
def test_keyboard_interrupt_kills_both_processes(self, main_module, tmp_path):
hash_file = str(tmp_path / "hashes.txt")
wls = []
for i in range(2):
p = str(tmp_path / f"w{i}.txt")
open(p, "w").close()
wls.append(p)
combinator_proc = _make_mock_proc()
hashcat_proc = _make_mock_proc(wait_side_effect=KeyboardInterrupt())
def popen_side_effect(cmd, **kwargs):
if "combinatorX" in str(cmd[0]):
return combinator_proc
return hashcat_proc
with (
patch.object(main_module, "hcatBin", "hashcat"),
patch.object(main_module, "hcatTuning", ""),
patch.object(main_module, "hcatPotfilePath", ""),
patch.object(main_module, "hcatWordlists", str(tmp_path)),
patch.object(main_module, "generate_session_id", return_value="sess123"),
patch.object(main_module, "lineCount", return_value=0),
patch("hate_crack.main.subprocess.Popen", side_effect=popen_side_effect),
):
main_module.hcatCombinatorX("1000", hash_file, wls)
hashcat_proc.kill.assert_called_once()
combinator_proc.kill.assert_called_once()

149
tests/test_ngram_gzip.py Normal file
View File

@@ -0,0 +1,149 @@
import gzip
import os
from unittest.mock import MagicMock, patch
from hate_crack.attacks import ngram_attack
def _make_ctx(hash_type="1000", hash_file="/tmp/hashes.txt"):
ctx = MagicMock()
ctx.hcatHashType = hash_type
ctx.hcatHashFile = hash_file
return ctx
class TestNgramAttack:
def test_calls_hcatNgramX_with_corpus_and_group_size(self, tmp_path):
ctx = _make_ctx()
corpus = tmp_path / "corpus.txt"
corpus.write_text("password\nletmein\n")
ctx.select_file_with_autocomplete.return_value = str(corpus)
with patch("builtins.input", return_value="3"):
ngram_attack(ctx)
ctx.hcatNgramX.assert_called_once_with(
ctx.hcatHashType, ctx.hcatHashFile, str(corpus), 3
)
def test_default_group_size_is_3(self, tmp_path):
ctx = _make_ctx()
corpus = tmp_path / "corpus.txt"
corpus.write_text("password\n")
ctx.select_file_with_autocomplete.return_value = str(corpus)
with patch("builtins.input", return_value=""):
ngram_attack(ctx)
ctx.hcatNgramX.assert_called_once()
assert ctx.hcatNgramX.call_args[0][3] == 3
def test_invalid_group_size_defaults_to_3(self, tmp_path):
ctx = _make_ctx()
corpus = tmp_path / "corpus.txt"
corpus.write_text("password\n")
ctx.select_file_with_autocomplete.return_value = str(corpus)
with patch("builtins.input", return_value="abc"):
ngram_attack(ctx)
ctx.hcatNgramX.assert_called_once()
assert ctx.hcatNgramX.call_args[0][3] == 3
def test_aborts_when_no_corpus_selected(self):
ctx = _make_ctx()
ctx.select_file_with_autocomplete.return_value = None
ngram_attack(ctx)
ctx.hcatNgramX.assert_not_called()
def test_custom_group_size_passed_through(self, tmp_path):
ctx = _make_ctx()
corpus = tmp_path / "corpus.txt"
corpus.write_text("password\n")
ctx.select_file_with_autocomplete.return_value = str(corpus)
with patch("builtins.input", return_value="5"):
ngram_attack(ctx)
assert ctx.hcatNgramX.call_args[0][3] == 5
class TestIsGzipped:
def test_detects_gzip_file(self, tmp_path):
from hate_crack.main import _is_gzipped
gz_file = tmp_path / "test.txt.gz"
with gzip.open(str(gz_file), "wb") as f:
f.write(b"password\n")
assert _is_gzipped(str(gz_file)) is True
def test_plain_file_not_detected_as_gzip(self, tmp_path):
from hate_crack.main import _is_gzipped
plain = tmp_path / "test.txt"
plain.write_bytes(b"password\n")
assert _is_gzipped(str(plain)) is False
def test_missing_file_returns_false(self, tmp_path):
from hate_crack.main import _is_gzipped
assert _is_gzipped(str(tmp_path / "nonexistent.txt")) is False
def test_empty_file_returns_false(self, tmp_path):
from hate_crack.main import _is_gzipped
empty = tmp_path / "empty.txt"
empty.write_bytes(b"")
assert _is_gzipped(str(empty)) is False
class TestWordlistPath:
def test_plain_file_yields_original_path(self, tmp_path):
from hate_crack.main import _wordlist_path
plain = tmp_path / "words.txt"
plain.write_text("password\n")
with _wordlist_path(str(plain)) as result:
assert result == str(plain)
def test_gzip_file_yields_temp_file_with_content(self, tmp_path):
from hate_crack.main import _wordlist_path
gz_file = tmp_path / "words.txt.gz"
with gzip.open(str(gz_file), "wb") as f:
f.write(b"password\nletmein\n")
with _wordlist_path(str(gz_file)) as result:
assert result != str(gz_file)
assert os.path.isfile(result)
with open(result, "rb") as f:
assert f.read() == b"password\nletmein\n"
def test_gzip_temp_file_removed_after_context(self, tmp_path):
from hate_crack.main import _wordlist_path
gz_file = tmp_path / "words.txt.gz"
with gzip.open(str(gz_file), "wb") as f:
f.write(b"password\n")
with _wordlist_path(str(gz_file)) as result:
tmp_path_used = result
assert not os.path.exists(tmp_path_used)
def test_plain_file_not_deleted_after_context(self, tmp_path):
from hate_crack.main import _wordlist_path
plain = tmp_path / "words.txt"
plain.write_text("password\n")
with _wordlist_path(str(plain)) as result:
assert result == str(plain)
assert plain.exists()

View File

@@ -26,7 +26,8 @@ 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"),
("19", CLI_MODULE._attacks, "ngram_attack", "ngram"),
("20", 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"),

View File

@@ -40,7 +40,7 @@ def main():
# Resolve binary paths relative to script location
script_dir = os.path.dirname(os.path.realpath(__file__))
ext = ".app" if sys.platform == "darwin" else ".bin"
ext = ".bin" if sys.platform == "darwin" else ".bin"
splitlen_bin = os.path.join(script_dir, "hashcat-utils", "bin", f"splitlen{ext}")
rli_bin = os.path.join(script_dir, "hashcat-utils", "bin", f"rli{ext}")