Merge pull request #101 from trustedsec/feat/combipow-attack

feat: Passphrase combination attack (issue #88)
This commit is contained in:
Justin Bollinger
2026-03-19 15:50:58 -04:00
committed by GitHub
6 changed files with 295 additions and 0 deletions
+10
View File
@@ -621,6 +621,7 @@ All tests use mocked API calls, so they can run without connectivity to a Hashvi
(19) N-gram Attack
(20) Permutation Attack
(21) Random Rules Attack
(22) Combipow Passphrase Attack
(90) Download rules from Hashmob.net
(91) Analyze Hashcat Rules
@@ -810,6 +811,15 @@ Generates a set of random hashcat mutation rules using `generate-rules.bin`, wri
* 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
#### Combipow Passphrase Attack
Generates all unique non-empty subset combinations from a short wordlist using `combipow.bin` and pipes them into hashcat. Designed for passphrase cracking when you know the pool of words a password was built from.
* Prompts for a wordlist file (max 63 lines - combipow generates up to 2^n-1 combinations)
* Optional space separator (`-s` flag) to insert spaces between words in each combination
* Warns if the wordlist exceeds 20 lines (output volume may be large)
* Aborts with a clear message if the wordlist exceeds 63 lines (hard limit)
* Candidates are piped directly to hashcat stdin
#### 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
@@ -91,6 +91,7 @@ def get_main_menu_options():
"19": _attacks.ngram_attack,
"20": _attacks.permute_crack,
"21": _attacks.generate_rules_crack,
"22": _attacks.combipow_crack,
"90": download_hashmob_rules,
"91": weakpass_wordlist_menu,
"92": download_hashmob_wordlists,
+26
View File
@@ -1,4 +1,5 @@
import glob
import gzip
import os
import readline
from typing import Any
@@ -637,6 +638,31 @@ def markov_brute_force(ctx: Any) -> None:
ctx.hcatMarkovBruteForce(ctx.hcatHashType, ctx.hcatHashFile, hcatMinLen, hcatMaxLen)
def combipow_crack(ctx: Any) -> None:
wordlist = None
while wordlist is None:
path = input("\n[*] Enter path to wordlist (max 63 lines recommended): ").strip()
if not path:
continue
if not os.path.isfile(path):
print(f"[!] File not found: {path}")
continue
with (gzip.open(path, "rb") if path.endswith(".gz") else open(path, "rb")) as fh:
line_count = sum(1 for _ in fh)
if line_count > 63:
print(
f"[!] Wordlist has {line_count} lines (max 63). combipow generates 2^n-1 combinations."
)
return
if line_count > 20:
print(
f"[*] Warning: {line_count} lines will generate a large number of combinations."
)
wordlist = path
use_space_sep = input("[*] Add spaces between words? (Y/n): ").strip().lower() != "n"
ctx.hcatCombipow(ctx.hcatHashType, ctx.hcatHashFile, wordlist, use_space_sep)
def generate_rules_crack(ctx: Any) -> None:
print("\n" + "=" * 60)
print("RANDOM RULES ATTACK")
+60
View File
@@ -2343,6 +2343,60 @@ def hcatMarkovBruteForce(hcatHashType, hcatHashFile, hcatMinLen, hcatMaxLen):
hcatProcess.kill()
# Combipow Passphrase Attack
hcatCombipowCount = 0
def hcatCombipow(hcatHashType, hcatHashFile, wordlist, use_space_sep=True):
global hcatProcess, hcatCombipowCount
hcatCombipowCount += 1
combipow_bin = os.path.join(hate_path, "hashcat-utils/bin/combipow.bin")
tmp_file = None
if wordlist.endswith(".gz"):
tmp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".txt")
with gzip.open(wordlist, "rb") as gz_in:
tmp_file.write(gz_in.read())
tmp_file.close()
wordlist_path = tmp_file.name
else:
wordlist_path = wordlist
generator_cmd = [combipow_bin]
if use_space_sep:
generator_cmd.append("-s")
generator_cmd.append(wordlist_path)
session_name = re.sub(
r"[^a-zA-Z0-9_-]", "_", os.path.splitext(os.path.basename(hcatHashFile))[0]
)
hashcat_cmd = [
hcatBin,
"--session",
session_name,
"-m",
hcatHashType,
hcatHashFile,
"-o",
f"{hcatHashFile}.out",
]
hashcat_cmd.extend(shlex.split(hcatTuning))
_append_potfile_arg(hashcat_cmd)
generator_proc = subprocess.Popen(generator_cmd, stdout=subprocess.PIPE)
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()
finally:
if tmp_file is not None:
with contextlib.suppress(OSError):
os.unlink(tmp_file.name)
# PRINCE Attack
def hcatPrince(hcatHashType, hcatHashFile):
global hcatProcess
@@ -3598,6 +3652,10 @@ def omen_attack():
return _attacks.omen_attack(_attack_ctx())
def combipow_crack():
return _attacks.combipow_crack(_attack_ctx())
def generate_rules_crack():
return _attacks.generate_rules_crack(_attack_ctx())
@@ -3837,6 +3895,7 @@ def get_main_menu_items():
("19", "N-gram Attack"),
("20", "Permutation Attack"),
("21", "Random Rules Attack"),
("22", "Combipow Passphrase Attack"),
("90", "Download rules from Hashmob.net"),
("91", "Analyze Hashcat Rules"),
("92", "Download wordlists from Hashmob.net"),
@@ -3877,6 +3936,7 @@ def get_main_menu_options():
"19": ngram_attack,
"20": permute_crack,
"21": generate_rules_crack,
"22": combipow_crack,
"90": lambda: download_hashmob_rules(rules_dir=rulesDirectory),
"91": analyze_rules,
"92": download_hashmob_wordlists,
+197
View File
@@ -0,0 +1,197 @@
import importlib
import importlib.util
import os
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
os.environ["HATE_CRACK_SKIP_INIT"] = "1"
PROJECT_ROOT = Path(__file__).resolve().parents[1]
def _load_attacks():
"""Import hate_crack.attacks with SKIP_INIT set."""
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
import hate_crack.attacks as attacks # noqa: PLC0415
return attacks
def _load_cli():
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()
def _make_ctx(hash_type="1000", hash_file="/tmp/hashes.txt"):
ctx = MagicMock()
ctx.hcatHashType = hash_type
ctx.hcatHashFile = hash_file
return ctx
# --- Menu presence tests ---
def test_combipow_crack_in_main_menu(cli):
options = cli.get_main_menu_options()
assert "22" in options
def test_combipow_crack_menu_item_label():
cli = _load_cli()
items = cli.get_main_menu_items()
keys = [k for k, _ in items]
assert "22" in keys
labels = {k: label for k, label in items}
assert "passphrase" in labels["22"].lower() or "combipow" in labels["22"].lower()
# --- combipow_crack handler tests ---
class TestCombipowCrack:
def test_calls_hcatCombipow_with_space_sep_by_default(self, tmp_path):
attacks = _load_attacks()
ctx = _make_ctx()
wl = tmp_path / "words.txt"
wl.write_text("correct\nhorse\nbattery\n")
with patch("builtins.input", side_effect=[str(wl), ""]):
attacks.combipow_crack(ctx)
ctx.hcatCombipow.assert_called_once()
call_args = ctx.hcatCombipow.call_args
use_space = (
call_args[0][3] if len(call_args[0]) > 3 else call_args[1].get("use_space_sep")
)
assert use_space is True
def test_calls_hcatCombipow_without_space_sep(self, tmp_path):
attacks = _load_attacks()
ctx = _make_ctx()
wl = tmp_path / "words.txt"
wl.write_text("correct\nhorse\nbattery\n")
with patch("builtins.input", side_effect=[str(wl), "n"]):
attacks.combipow_crack(ctx)
ctx.hcatCombipow.assert_called_once()
call_args = ctx.hcatCombipow.call_args
use_space = (
call_args[0][3] if len(call_args[0]) > 3 else call_args[1].get("use_space_sep")
)
assert use_space is False
def test_rejects_more_than_63_lines(self, tmp_path):
attacks = _load_attacks()
ctx = _make_ctx()
wl = tmp_path / "words.txt"
wl.write_text("\n".join(f"word{i}" for i in range(64)) + "\n")
with patch("builtins.input", return_value=str(wl)):
attacks.combipow_crack(ctx)
ctx.hcatCombipow.assert_not_called()
def test_accepts_exactly_63_lines(self, tmp_path):
attacks = _load_attacks()
ctx = _make_ctx()
wl = tmp_path / "words.txt"
wl.write_text("\n".join(f"word{i}" for i in range(63)) + "\n")
with patch("builtins.input", side_effect=[str(wl), ""]):
attacks.combipow_crack(ctx)
ctx.hcatCombipow.assert_called_once()
def test_rejects_nonexistent_file(self, tmp_path):
attacks = _load_attacks()
ctx = _make_ctx()
wl = tmp_path / "words.txt"
wl.write_text("test\n")
with patch("builtins.input", side_effect=["/nonexistent.txt", str(wl), ""]):
attacks.combipow_crack(ctx)
ctx.hcatCombipow.assert_called_once()
def test_passes_correct_hash_type_and_file(self, tmp_path):
attacks = _load_attacks()
ctx = _make_ctx(hash_type="3200", hash_file="/tmp/bcrypt.txt")
wl = tmp_path / "words.txt"
wl.write_text("word1\nword2\n")
with patch("builtins.input", side_effect=[str(wl), ""]):
attacks.combipow_crack(ctx)
ctx.hcatCombipow.assert_called_once()
args = ctx.hcatCombipow.call_args[0]
assert args[0] == "3200"
assert args[1] == "/tmp/bcrypt.txt"
assert args[2] == str(wl)
def test_warns_about_large_word_count(self, tmp_path, capsys):
attacks = _load_attacks()
ctx = _make_ctx()
wl = tmp_path / "words.txt"
wl.write_text("\n".join(f"word{i}" for i in range(31)) + "\n")
with patch("builtins.input", side_effect=[str(wl), ""]):
attacks.combipow_crack(ctx)
captured = capsys.readouterr()
assert "large" in captured.out.lower() or "warning" in captured.out.lower()
# --- hcatCombipow wrapper tests ---
def _get_main_module():
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
import hate_crack.main as m # noqa: PLC0415
return m
class TestHcatCombipow:
def _run(self, tmp_path, wl, use_space_sep):
"""Run hcatCombipow with module globals patched via context managers."""
m = _get_main_module()
hash_file = str(tmp_path / "hashes.txt")
combipow_bin = tmp_path / "hashcat-utils" / "bin" / "combipow.bin"
combipow_bin.parent.mkdir(parents=True, exist_ok=True)
combipow_bin.touch()
fake_combipow = MagicMock()
fake_combipow.stdout = MagicMock()
fake_hashcat = MagicMock()
fake_hashcat.pid = 9999
with (
patch.object(m, "hate_path", str(tmp_path)),
patch.object(m, "hcatBin", "hashcat"),
patch.object(m, "hcatTuning", ""),
patch("hate_crack.main.hcatHashFile", hash_file, create=True),
patch("hate_crack.main.subprocess.Popen", side_effect=[fake_combipow, fake_hashcat]) as mock_popen,
):
m.hcatCombipow("1000", hash_file, str(wl), use_space_sep=use_space_sep)
return mock_popen
def test_includes_s_flag_when_use_space_sep_true(self, tmp_path):
wl = tmp_path / "words.txt"
wl.write_text("word1\nword2\n")
mock_popen = self._run(tmp_path, wl, use_space_sep=True)
first_call_args = mock_popen.call_args_list[0][0][0]
assert "-s" in first_call_args
def test_omits_s_flag_when_use_space_sep_false(self, tmp_path):
wl = tmp_path / "words.txt"
wl.write_text("word1\nword2\n")
mock_popen = self._run(tmp_path, wl, use_space_sep=False)
first_call_args = mock_popen.call_args_list[0][0][0]
assert "-s" not in first_call_args
def test_wordlist_passed_as_argument(self, tmp_path):
wl = tmp_path / "words.txt"
wl.write_text("word1\nword2\n")
mock_popen = self._run(tmp_path, wl, use_space_sep=True)
first_call_args = mock_popen.call_args_list[0][0][0]
assert str(wl) in first_call_args
+1
View File
@@ -29,6 +29,7 @@ MENU_OPTION_TEST_CASES = [
("19", CLI_MODULE._attacks, "ngram_attack", "ngram"),
("20", CLI_MODULE._attacks, "permute_crack", "permute"),
("21", CLI_MODULE._attacks, "generate_rules_crack", "random-rules"),
("22", CLI_MODULE._attacks, "combipow_crack", "combipow"),
("90", CLI_MODULE, "download_hashmob_rules", "hashmob-rules"),
("91", CLI_MODULE, "weakpass_wordlist_menu", "weakpass-menu"),
("92", CLI_MODULE, "download_hashmob_wordlists", "hashmob-wordlists"),