feat: add computer account filtering and NetNTLM dedup

- Detect and optionally filter Windows computer accounts (username
  ending with $) from pwdump-format NTLM hash files (type 1000)
- Detect and optionally deduplicate NetNTLMv1/v2 hashes (types
  5500/5600) by username, keeping first occurrence
- Add 10 tests covering both features

Fixes #27
Fixes #28

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Justin Bollinger
2026-02-17 12:43:22 -05:00
co-authored by Claude Opus 4.6
parent 43adce1f4f
commit ffbc41f3ca
3 changed files with 307 additions and 31 deletions
+1 -3
View File
@@ -498,6 +498,4 @@ def ollama_attack(ctx: Any) -> None:
"industry": industry,
"location": location,
}
ctx.hcatOllama(
ctx.hcatHashType, ctx.hcatHashFile, "target", target_info
)
ctx.hcatOllama(ctx.hcatHashType, ctx.hcatHashFile, "target", target_info)
+148 -28
View File
@@ -113,7 +113,6 @@ def _resolve_config_destination():
return os.getcwd()
def _ensure_hashfile_in_cwd(hashfile_path):
"""Ensure hashfile path points to cwd to keep output files in execution dir."""
if not hashfile_path:
@@ -155,9 +154,7 @@ else:
_config_path = _resolve_config_path()
if not _config_path:
print("Initializing config.json from config.json.example")
src_config = os.path.abspath(
os.path.join(_package_path, "config.json.example")
)
src_config = os.path.abspath(os.path.join(_package_path, "config.json.example"))
config_dir = _resolve_config_destination()
dst_config = os.path.abspath(os.path.join(config_dir, "config.json"))
shutil.copy(src_config, dst_config)
@@ -214,9 +211,7 @@ def ensure_binary(binary_path, build_dir=None, name=None):
print(
"\nRun 'make install' from the repository directory to install with assets:"
)
print(
" cd /path/to/hate_crack && make install"
)
print(" cd /path/to/hate_crack && make install")
quit(1)
# Binary missing - need to build
@@ -677,9 +672,7 @@ if not SKIP_INIT:
# Verify hcstat2gen binary (optional, for LLM attacks)
# Note: hcstat2gen is part of hashcat-utils, already in hate_crack repo
hcstat2gen_path = (
hate_path + "/hashcat-utils/bin/" + hcatHcstat2genBin
)
hcstat2gen_path = hate_path + "/hashcat-utils/bin/" + hcatHcstat2genBin
try:
ensure_binary(
hcstat2gen_path,
@@ -775,7 +768,8 @@ def usage():
def ascii_art():
from hate_crack import __version__
print(r"""
print(
r"""
___ ___ __ _________ __
/ | \_____ _/ |_ ____ \_ ___ \____________ ____ | | __
@@ -783,8 +777,11 @@ def ascii_art():
\ Y // __ \| | \ ___/ \ \____| | \// __ \\ \___| <
\___|_ /(____ /__| \___ >____\______ /|__| (____ /\___ >__|_ \
\/ \/ \/_____/ \/ \/ \/ \/
Version """ + __version__ + """
""")
Version """
+ __version__
+ """
"""
)
# File selector with tab autocomplete
@@ -919,6 +916,66 @@ def _write_field_sorted_unique(input_path, output_path, field_index, delimiter="
return False
def _filter_computer_accounts(input_path, output_path, delimiter=":"):
"""Filter out computer accounts (usernames ending with $) from a hash file.
Reads the input file, removes lines where the first field (username)
ends with '$', and writes the remaining lines to output_path.
Returns the number of computer accounts removed.
"""
removed = 0
kept_lines = []
try:
with open(input_path, "r", errors="replace") as src:
for line in src:
stripped = line.rstrip("\n")
if not stripped:
continue
username = stripped.split(delimiter, 1)[0]
if username.endswith("$"):
removed += 1
else:
kept_lines.append(stripped)
with open(output_path, "w") as dst:
for line in kept_lines:
dst.write(line + "\n")
except FileNotFoundError:
pass
return removed
def _dedup_netntlm_by_username(input_path, output_path, delimiter=":"):
"""Deduplicate NetNTLM hashes by username, keeping the first occurrence.
NetNTLM format: username::domain:challenge:response:blob
The username is the first field before the delimiter.
Returns a tuple of (total_lines, duplicates_removed).
"""
seen_usernames = set()
kept_lines = []
duplicates = 0
total = 0
try:
with open(input_path, "r", errors="replace") as src:
for line in src:
stripped = line.rstrip("\n")
if not stripped:
continue
total += 1
username = stripped.split(delimiter, 1)[0].lower()
if username in seen_usernames:
duplicates += 1
else:
seen_usernames.add(username)
kept_lines.append(stripped)
with open(output_path, "w") as dst:
for line in kept_lines:
dst.write(line + "\n")
except FileNotFoundError:
pass
return total, duplicates
def _run_hashcat_show(hash_type, hash_file, output_path):
result = subprocess.run(
[
@@ -1556,7 +1613,7 @@ def hcatOllama(hcatHashType, hcatHashFile, mode, context_data):
wordlist_sample = "\n".join(lines)
prompt = (
"Generate baseword to be used in a denylist for keeping users from setting their passwords with these basewords."
"Study the patterns, character choices, and structures. Focus on patterns like capitalization, leetspeak, suffixes, and common substitutions. Here are the sample passwords:\n"
"Study the patterns, character choices, and structures. Focus on patterns like capitalization, leetspeak, suffixes, and common substitutions. Here are the sample passwords:\n"
f"{wordlist_sample}"
)
elif mode == "target":
@@ -1579,12 +1636,14 @@ def hcatOllama(hcatHashType, hcatHashFile, mode, context_data):
# Step B: Call Ollama API to generate candidates
print(f"Generating password candidates via Ollama ({ollamaModel})...")
api_url = f"{ollamaUrl}/api/generate"
payload = json.dumps({
"model": ollamaModel,
"prompt": prompt,
"stream": False,
"options": {"num_ctx": ollamaNumCtx},
}).encode("utf-8")
payload = json.dumps(
{
"model": ollamaModel,
"prompt": prompt,
"stream": False,
"options": {"num_ctx": ollamaNumCtx},
}
).encode("utf-8")
if debug_mode:
print(f"[DEBUG] Ollama API URL: {api_url}")
@@ -1612,7 +1671,9 @@ def hcatOllama(hcatHashType, hcatHashFile, mode, context_data):
with urllib.request.urlopen(req, timeout=600) as resp:
result = json.loads(resp.read().decode("utf-8"))
if debug_mode:
print(f"[DEBUG] Ollama response (after pull): {json.dumps(result, indent=2)}")
print(
f"[DEBUG] Ollama response (after pull): {json.dumps(result, indent=2)}"
)
except Exception as retry_err:
print(f"Error calling Ollama API after pull: {retry_err}")
return
@@ -1633,7 +1694,9 @@ def hcatOllama(hcatHashType, hcatHashFile, mode, context_data):
response_text = result.get("response", "")
if "I'm sorry, but I can't help with that" in response_text:
print("Error: Ollama refused the request. Try a different model or adjust your prompt.")
print(
"Error: Ollama refused the request. Try a different model or adjust your prompt."
)
return
raw_lines = response_text.strip().split("\n")
# Filter out blank lines and lines that look like numbering/explanation
@@ -1664,7 +1727,9 @@ def hcatOllama(hcatHashType, hcatHashFile, mode, context_data):
print(f"Generated {len(candidates)} password candidates -> {candidates_path}")
if debug_mode:
filtered_count = len(raw_lines) - len(candidates)
print(f"[DEBUG] Filtered out {filtered_count} lines from Ollama response ({len(raw_lines)} raw -> {len(candidates)} candidates)")
print(
f"[DEBUG] Filtered out {filtered_count} lines from Ollama response ({len(raw_lines)} raw -> {len(candidates)} candidates)"
)
# Step C: Run hashcat wordlist attack with LLM-generated candidates (no rules)
print("Running wordlist attack with LLM-generated candidates...")
@@ -1690,14 +1755,14 @@ def hcatOllama(hcatHashType, hcatHashFile, mode, context_data):
return
# Step D: Run hashcat with LLM candidates against every rule in the rules directory
rule_files = sorted(
f for f in os.listdir(rulesDirectory) if f != ".DS_Store"
)
rule_files = sorted(f for f in os.listdir(rulesDirectory) if f != ".DS_Store")
if not rule_files:
print("No rule files found in rules directory. Skipping rule-based attacks.")
return
print(f"\nRunning LLM candidates with {len(rule_files)} rule file(s) from {rulesDirectory}...")
print(
f"\nRunning LLM candidates with {len(rule_files)} rule file(s) from {rulesDirectory}..."
)
for rule in rule_files:
rule_path = os.path.join(rulesDirectory, rule)
print(f"\n\tRunning with rule: {rule}")
@@ -1725,7 +1790,6 @@ def hcatOllama(hcatHashType, hcatHashFile, mode, context_data):
return
# Middle fast Combinator Attack
def hcatMiddleCombinator(hcatHashType, hcatHashFile):
global hcatProcess
@@ -3590,6 +3654,25 @@ def main():
if re.search(r"[a-f0-9A-F]{32}:[a-f0-9A-F]{32}:::", hcatHashFileLine):
pwdump_format = True
print("PWDUMP format detected...")
# Detect computer accounts (usernames ending with $)
computer_count = sum(
1
for line in open(hcatHashFile, "r", errors="replace")
if line.strip() and line.split(":", 1)[0].endswith("$")
)
if computer_count > 0:
print(
f"Detected {computer_count} computer account(s)"
" (usernames ending with $)."
)
filter_choice = (
input("Would you like to ignore computer accounts? (Y) ") or "Y"
)
if filter_choice.upper() == "Y":
filtered_path = f"{hcatHashFile}.filtered"
removed = _filter_computer_accounts(hcatHashFile, filtered_path)
print(f"Removed {removed} computer account(s).")
hcatHashFile = filtered_path
print("Parsing NT hashes...")
_write_field_sorted_unique(hcatHashFile, f"{hcatHashFile}.nt", 4)
print("Parsing LM hashes...")
@@ -3640,6 +3723,43 @@ def main():
else:
print("unknown format....does it have usernames?")
exit(1)
# Detect and optionally deduplicate NetNTLM hashes by username
if hcatHashType in ("5500", "5600"):
total, duplicates = _dedup_netntlm_by_username(
hcatHashFile, hcatHashFile + ".dedup"
)
if duplicates > 0:
print(
f"Detected {duplicates} duplicate account(s) out of"
f" {total} total NetNTLM hashes."
)
dedup_choice = (
input(
"Would you like to ignore duplicate accounts"
" (keep first occurrence only)? (Y) "
)
or "Y"
)
if dedup_choice.upper() == "Y":
hcatHashFileOrig = hcatHashFile
hcatHashFile = hcatHashFile + ".dedup"
print(
f"Using deduplicated hash file with"
f" {total - duplicates} unique accounts."
)
else:
# Remove the dedup file if user chose not to use it
try:
os.remove(hcatHashFile + ".dedup")
except OSError:
pass
else:
# No duplicates found, clean up
try:
os.remove(hcatHashFile + ".dedup")
except OSError:
pass
# Check POT File for Already Cracked Hashes
if not os.path.isfile(hcatHashFile + ".out"):
hcatOutput = open(hcatHashFile + ".out", "w+")
+158
View File
@@ -0,0 +1,158 @@
"""Tests for NTLM/NetNTLM hash preprocessing helpers (issues #27 and #28)."""
import os
import sys
import importlib
import pytest
@pytest.fixture
def main_module(monkeypatch):
"""Load hate_crack.main with SKIP_INIT to access helper functions."""
monkeypatch.setenv("HATE_CRACK_SKIP_INIT", "1")
if "hate_crack.main" in sys.modules:
mod = sys.modules["hate_crack.main"]
importlib.reload(mod)
return mod
import hate_crack.main as mod
return mod
class TestFilterComputerAccounts:
"""Issue #27 - filter accounts ending with $."""
def test_removes_computer_accounts(self, tmp_path, main_module):
hash_file = tmp_path / "hashes.txt"
hash_file.write_text(
"user1:1001:aad3b435b51404ee:hash1:::\n"
"COMPUTER1$:1002:aad3b435b51404ee:hash2:::\n"
"user2:1003:aad3b435b51404ee:hash3:::\n"
"WORKSTATION$:1004:aad3b435b51404ee:hash4:::\n"
)
output_file = tmp_path / "filtered.txt"
removed = main_module._filter_computer_accounts(
str(hash_file), str(output_file)
)
assert removed == 2
lines = output_file.read_text().strip().split("\n")
assert len(lines) == 2
assert all("$" not in line.split(":", 1)[0] for line in lines)
def test_no_computer_accounts(self, tmp_path, main_module):
hash_file = tmp_path / "hashes.txt"
hash_file.write_text(
"user1:1001:aad3b435b51404ee:hash1:::\n"
"user2:1002:aad3b435b51404ee:hash2:::\n"
)
output_file = tmp_path / "filtered.txt"
removed = main_module._filter_computer_accounts(
str(hash_file), str(output_file)
)
assert removed == 0
lines = output_file.read_text().strip().split("\n")
assert len(lines) == 2
def test_all_computer_accounts(self, tmp_path, main_module):
hash_file = tmp_path / "hashes.txt"
hash_file.write_text(
"COMP1$:1001:aad3b435b51404ee:hash1:::\n"
"COMP2$:1002:aad3b435b51404ee:hash2:::\n"
)
output_file = tmp_path / "filtered.txt"
removed = main_module._filter_computer_accounts(
str(hash_file), str(output_file)
)
assert removed == 2
content = output_file.read_text().strip()
assert content == ""
def test_missing_file(self, tmp_path, main_module):
removed = main_module._filter_computer_accounts(
str(tmp_path / "nonexistent.txt"),
str(tmp_path / "output.txt"),
)
assert removed == 0
def test_empty_file(self, tmp_path, main_module):
hash_file = tmp_path / "hashes.txt"
hash_file.write_text("")
output_file = tmp_path / "filtered.txt"
removed = main_module._filter_computer_accounts(
str(hash_file), str(output_file)
)
assert removed == 0
class TestDedupNetntlmByUsername:
"""Issue #28 - deduplicate NetNTLM hashes by username."""
def test_removes_duplicates(self, tmp_path, main_module):
hash_file = tmp_path / "netntlm.txt"
hash_file.write_text(
"user1::DOMAIN:challenge1:response1:blob1\n"
"user2::DOMAIN:challenge2:response2:blob2\n"
"user1::DOMAIN:challenge3:response3:blob3\n"
"user3::DOMAIN:challenge4:response4:blob4\n"
"user2::DOMAIN:challenge5:response5:blob5\n"
)
output_file = tmp_path / "dedup.txt"
total, duplicates = main_module._dedup_netntlm_by_username(
str(hash_file), str(output_file)
)
assert total == 5
assert duplicates == 2
lines = output_file.read_text().strip().split("\n")
assert len(lines) == 3
# First occurrences should be kept
assert "challenge1" in lines[0]
assert "challenge2" in lines[1]
assert "challenge4" in lines[2]
def test_case_insensitive_dedup(self, tmp_path, main_module):
hash_file = tmp_path / "netntlm.txt"
hash_file.write_text(
"User1::DOMAIN:challenge1:response1:blob1\n"
"USER1::DOMAIN:challenge2:response2:blob2\n"
"user1::DOMAIN:challenge3:response3:blob3\n"
)
output_file = tmp_path / "dedup.txt"
total, duplicates = main_module._dedup_netntlm_by_username(
str(hash_file), str(output_file)
)
assert total == 3
assert duplicates == 2
lines = output_file.read_text().strip().split("\n")
assert len(lines) == 1
def test_no_duplicates(self, tmp_path, main_module):
hash_file = tmp_path / "netntlm.txt"
hash_file.write_text(
"user1::DOMAIN:challenge1:response1:blob1\n"
"user2::DOMAIN:challenge2:response2:blob2\n"
)
output_file = tmp_path / "dedup.txt"
total, duplicates = main_module._dedup_netntlm_by_username(
str(hash_file), str(output_file)
)
assert total == 2
assert duplicates == 0
def test_missing_file(self, tmp_path, main_module):
total, duplicates = main_module._dedup_netntlm_by_username(
str(tmp_path / "nonexistent.txt"),
str(tmp_path / "output.txt"),
)
assert total == 0
assert duplicates == 0
def test_empty_file(self, tmp_path, main_module):
hash_file = tmp_path / "netntlm.txt"
hash_file.write_text("")
output_file = tmp_path / "dedup.txt"
total, duplicates = main_module._dedup_netntlm_by_username(
str(hash_file), str(output_file)
)
assert total == 0
assert duplicates == 0