diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index d6ba8fd..420e23d 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -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) diff --git a/hate_crack/main.py b/hate_crack/main.py index 39af63f..a4a67e6 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -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+") diff --git a/tests/test_ntlm_preprocessing.py b/tests/test_ntlm_preprocessing.py new file mode 100644 index 0000000..9780b06 --- /dev/null +++ b/tests/test_ntlm_preprocessing.py @@ -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