mirror of
https://github.com/trustedsec/hate_crack.git
synced 2026-07-09 21:52:29 -07:00
Merge pull request #105 from trustedsec/fix/output-path-symlinks
Fix output file path and remove symlink creation
This commit is contained in:
+12
-25
@@ -196,27 +196,13 @@ def _resolve_config_destination():
|
||||
|
||||
|
||||
def _ensure_hashfile_in_cwd(hashfile_path):
|
||||
"""Ensure hashfile path points to cwd to keep output files in execution dir."""
|
||||
if not hashfile_path:
|
||||
return hashfile_path
|
||||
cwd = orig_cwd()
|
||||
if not os.path.isabs(hashfile_path):
|
||||
return hashfile_path
|
||||
if os.path.dirname(hashfile_path) == cwd:
|
||||
return hashfile_path
|
||||
basename = os.path.basename(hashfile_path)
|
||||
local_path = os.path.join(cwd, basename)
|
||||
if os.path.exists(local_path):
|
||||
return local_path
|
||||
try:
|
||||
os.symlink(hashfile_path, local_path)
|
||||
return local_path
|
||||
except Exception:
|
||||
try:
|
||||
shutil.copy2(hashfile_path, local_path)
|
||||
return local_path
|
||||
except Exception:
|
||||
return hashfile_path
|
||||
"""Return hashfile path as-is.
|
||||
|
||||
Output files (.out, .nt, etc.) are written next to the hashfile.
|
||||
``resolve_path()`` already resolves relative paths against
|
||||
``HATE_CRACK_ORIG_CWD`` so no relocation is needed.
|
||||
"""
|
||||
return hashfile_path
|
||||
|
||||
|
||||
# hate_path is where hate_crack assets live (hashcat-utils, princeprocessor, etc.)
|
||||
@@ -3002,10 +2988,11 @@ def cleanup():
|
||||
if hcatHashType == "1000" and pwdump_format:
|
||||
print("\nComparing cracked hashes to original file...")
|
||||
combine_ntlm_output()
|
||||
print(
|
||||
"\nCracked passwords combined with original hashes in %s"
|
||||
% (hcatHashFileOrig + ".out")
|
||||
)
|
||||
out_path = hcatHashFileOrig + ".out"
|
||||
if os.path.isfile(out_path):
|
||||
print(f"\nCracked passwords combined with original hashes in {out_path}")
|
||||
else:
|
||||
print(f"\nNo cracked hashes to combine. Raw output (if any): {hcatHashFile}.out")
|
||||
print("\nCleaning up temporary files...")
|
||||
if os.path.exists(hcatHashFile + ".masks"):
|
||||
os.remove(hcatHashFile + ".masks")
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
"""E2E test: output files land next to the hashfile, not in the package directory.
|
||||
|
||||
Requires: hashcat installed, HATE_CRACK_RUN_E2E=1
|
||||
|
||||
Creates a dummy pwdump file at /tmp/test_hashes.ntds with known weak
|
||||
NTLM hashes, runs a real hashcat dictionary attack, then verifies:
|
||||
- /tmp/test_hashes.ntds.nt (NT hash extraction)
|
||||
- /tmp/test_hashes.ntds.nt.out (hashcat cracked output)
|
||||
- /tmp/test_hashes.ntds.out (combined pwdump + password)
|
||||
- No symlinks or output files created in the project directory
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
# Known NTLM hashes for weak passwords
|
||||
PWDUMP_LINES = [
|
||||
"alice:500:aad3b435b51404eeaad3b435b51404ee:8846f7eaee8fb117ad06bdd830b7586c:::\n",
|
||||
"bob:501:aad3b435b51404eeaad3b435b51404ee:32ed87bdb5fdc5e9cba88547376818d4:::\n",
|
||||
]
|
||||
WORDLIST = ["password\n", "123456\n"]
|
||||
EXPECTED_CRACKS = {
|
||||
"8846f7eaee8fb117ad06bdd830b7586c": "password",
|
||||
"32ed87bdb5fdc5e9cba88547376818d4": "123456",
|
||||
}
|
||||
|
||||
HASH_BASE = Path("/tmp/test_hashes.ntds")
|
||||
TEST_FILES = [
|
||||
HASH_BASE,
|
||||
Path(f"{HASH_BASE}.nt"),
|
||||
Path(f"{HASH_BASE}.nt.out"),
|
||||
Path(f"{HASH_BASE}.out"),
|
||||
Path(f"{HASH_BASE}.lm"),
|
||||
Path(f"{HASH_BASE}.lm.cracked"),
|
||||
Path(f"{HASH_BASE}.working"),
|
||||
Path(f"{HASH_BASE}.masks"),
|
||||
Path(f"{HASH_BASE}.expanded"),
|
||||
Path(f"{HASH_BASE}.combined"),
|
||||
Path(f"{HASH_BASE}.passwords"),
|
||||
Path("/tmp/test_wordlist.txt"),
|
||||
Path("/tmp/test_potfile.pot"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_test_files():
|
||||
"""Remove all test artifacts before and after each test."""
|
||||
for f in TEST_FILES:
|
||||
f.unlink(missing_ok=True)
|
||||
yield
|
||||
for f in TEST_FILES:
|
||||
f.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _hashcat_available() -> bool:
|
||||
return shutil.which("hashcat") is not None
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.environ.get("HATE_CRACK_RUN_E2E") != "1",
|
||||
reason="Set HATE_CRACK_RUN_E2E=1 to run e2e tests.",
|
||||
)
|
||||
@pytest.mark.skipif(not _hashcat_available(), reason="hashcat not installed")
|
||||
class TestOutputPathE2E:
|
||||
"""Verify output files land next to the hashfile in /tmp/, not in the project dir."""
|
||||
|
||||
def _create_hash_file(self):
|
||||
HASH_BASE.write_text("".join(PWDUMP_LINES))
|
||||
|
||||
def _create_wordlist(self):
|
||||
Path("/tmp/test_wordlist.txt").write_text("".join(WORDLIST))
|
||||
|
||||
def _extract_nt_hashes(self):
|
||||
"""Extract NT hashes (field 4) from pwdump, sorted unique — mirrors main.py preprocessing."""
|
||||
nt_hashes = set()
|
||||
with open(HASH_BASE) as f:
|
||||
for line in f:
|
||||
parts = line.strip().split(":")
|
||||
if len(parts) >= 4:
|
||||
nt_hashes.add(parts[3])
|
||||
nt_path = Path(f"{HASH_BASE}.nt")
|
||||
nt_path.write_text("\n".join(sorted(nt_hashes)) + "\n")
|
||||
return str(nt_path)
|
||||
|
||||
def _run_hashcat_crack(self, nt_file: str):
|
||||
"""Run a real hashcat dictionary attack against extracted NT hashes."""
|
||||
potfile = "/tmp/test_potfile.pot"
|
||||
out_file = f"{nt_file}.out"
|
||||
cmd = [
|
||||
"hashcat",
|
||||
"-m", "1000",
|
||||
nt_file,
|
||||
"/tmp/test_wordlist.txt",
|
||||
"-a", "0",
|
||||
"-o", out_file,
|
||||
f"--potfile-path={potfile}",
|
||||
"--potfile-disable",
|
||||
"--quiet",
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
# hashcat returns 0 (cracked) or 1 (exhausted) on success
|
||||
assert result.returncode in (0, 1), (
|
||||
f"hashcat failed (rc={result.returncode}): {result.stderr}"
|
||||
)
|
||||
return out_file
|
||||
|
||||
def _combine_ntlm_output(self, nt_out_file: str):
|
||||
"""Combine cracked NT hashes back into pwdump format — mirrors combine_ntlm_output()."""
|
||||
hashes = {}
|
||||
if os.path.isfile(nt_out_file):
|
||||
with open(nt_out_file) as f:
|
||||
for line in f:
|
||||
parts = line.strip().split(":", 1)
|
||||
if len(parts) == 2:
|
||||
hashes[parts[0]] = parts[1]
|
||||
|
||||
if not hashes:
|
||||
return
|
||||
|
||||
combined_path = f"{HASH_BASE}.out"
|
||||
with open(combined_path, "w") as out, open(HASH_BASE) as orig:
|
||||
for line in orig:
|
||||
parts = line.split(":")
|
||||
if len(parts) >= 4 and parts[3] in hashes:
|
||||
out.write(line.strip() + hashes[parts[3]] + "\n")
|
||||
|
||||
def test_output_files_land_in_tmp(self):
|
||||
"""Full flow: pwdump in /tmp -> hashcat -> combined output in /tmp."""
|
||||
self._create_hash_file()
|
||||
self._create_wordlist()
|
||||
|
||||
# Step 1: Extract NT hashes (preprocessing)
|
||||
nt_file = self._extract_nt_hashes()
|
||||
assert os.path.isfile(nt_file), f"NT extraction failed: {nt_file}"
|
||||
|
||||
# Step 2: Crack with real hashcat
|
||||
nt_out = self._run_hashcat_crack(nt_file)
|
||||
assert os.path.isfile(nt_out), f"hashcat output missing: {nt_out}"
|
||||
|
||||
# Step 3: Combine back to pwdump format
|
||||
self._combine_ntlm_output(nt_out)
|
||||
|
||||
# Verify: combined output exists at /tmp/test_hashes.ntds.out
|
||||
combined = Path(f"{HASH_BASE}.out")
|
||||
assert combined.exists(), f"Combined output missing: {combined}"
|
||||
assert combined.stat().st_size > 0, "Combined output is empty"
|
||||
|
||||
# Verify: combined output has correct pwdump + password format
|
||||
with open(combined) as f:
|
||||
lines = f.readlines()
|
||||
assert len(lines) == 2, f"Expected 2 cracked lines, got {len(lines)}"
|
||||
for line in lines:
|
||||
parts = line.strip().split(":")
|
||||
assert len(parts) >= 4, f"Malformed combined line: {line}"
|
||||
nt_hash = parts[3]
|
||||
assert nt_hash in EXPECTED_CRACKS, f"Unexpected hash: {nt_hash}"
|
||||
|
||||
def test_no_files_created_in_project_dir(self):
|
||||
"""Verify no symlinks or output files leak into the project directory."""
|
||||
self._create_hash_file()
|
||||
self._create_wordlist()
|
||||
|
||||
nt_file = self._extract_nt_hashes()
|
||||
self._run_hashcat_crack(nt_file)
|
||||
self._combine_ntlm_output(f"{nt_file}.out")
|
||||
|
||||
# Check project root for any test_hashes artifacts
|
||||
for item in REPO_ROOT.iterdir():
|
||||
assert "test_hashes" not in item.name, (
|
||||
f"Test artifact leaked into project dir: {item}"
|
||||
)
|
||||
|
||||
def test_nt_extraction_output_path(self):
|
||||
"""NT hash extraction writes .nt file next to the original."""
|
||||
self._create_hash_file()
|
||||
nt_file = self._extract_nt_hashes()
|
||||
assert nt_file == f"{HASH_BASE}.nt"
|
||||
assert Path(nt_file).parent == Path("/tmp")
|
||||
|
||||
def test_hashcat_output_path(self):
|
||||
"""hashcat -o writes .nt.out next to the .nt file."""
|
||||
self._create_hash_file()
|
||||
self._create_wordlist()
|
||||
nt_file = self._extract_nt_hashes()
|
||||
nt_out = self._run_hashcat_crack(nt_file)
|
||||
assert nt_out == f"{HASH_BASE}.nt.out"
|
||||
assert Path(nt_out).parent == Path("/tmp")
|
||||
+2
-10
@@ -775,7 +775,7 @@ class TestHashviewAPI:
|
||||
assert content == b"hash1\nhash2\n"
|
||||
|
||||
def test_hashfile_orig_path_preservation(self, tmp_path, monkeypatch):
|
||||
"""Test that original hashfile path is preserved before _ensure_hashfile_in_cwd"""
|
||||
"""Test that _ensure_hashfile_in_cwd is a pass-through returning the input path."""
|
||||
from hate_crack.main import _ensure_hashfile_in_cwd
|
||||
|
||||
# Create a test hashfile in a different directory
|
||||
@@ -792,16 +792,8 @@ class TestHashviewAPI:
|
||||
# Call _ensure_hashfile_in_cwd
|
||||
result_path = _ensure_hashfile_in_cwd(original_path)
|
||||
|
||||
# The result should be different from original (in cwd now)
|
||||
# But original_path should still exist and be unchanged
|
||||
assert result_path == original_path, "Pass-through should return the input path"
|
||||
assert os.path.exists(original_path), "Original file should still exist"
|
||||
assert os.path.exists(result_path), "Result file should exist"
|
||||
|
||||
# If they're different, result should be in cwd
|
||||
if result_path != original_path:
|
||||
assert os.path.dirname(result_path) == str(tmp_path), (
|
||||
"Result should be in cwd"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+21
-28
@@ -100,25 +100,8 @@ class TestEnsureHashfileInCwd:
|
||||
result = main_module._ensure_hashfile_in_cwd(str(target))
|
||||
assert result == str(target)
|
||||
|
||||
def test_different_dir_existing_file_in_cwd(self, main_module, tmp_path, monkeypatch):
|
||||
# File exists in a different directory
|
||||
other_dir = tmp_path / "other"
|
||||
other_dir.mkdir()
|
||||
source_file = other_dir / "hashes.txt"
|
||||
source_file.write_text("hashes")
|
||||
|
||||
# A file with the same name already exists in cwd
|
||||
cwd_dir = tmp_path / "cwd"
|
||||
cwd_dir.mkdir()
|
||||
cwd_copy = cwd_dir / "hashes.txt"
|
||||
cwd_copy.write_text("cwd version")
|
||||
|
||||
monkeypatch.setenv("HATE_CRACK_ORIG_CWD", str(cwd_dir))
|
||||
result = main_module._ensure_hashfile_in_cwd(str(source_file))
|
||||
assert result == str(cwd_copy)
|
||||
|
||||
def test_different_dir_creates_symlink(self, main_module, tmp_path, monkeypatch):
|
||||
# Source file in a different directory, nothing in cwd
|
||||
def test_different_dir_returns_original_path(self, main_module, tmp_path, monkeypatch):
|
||||
"""File in different dir is returned as-is (no symlink/copy)."""
|
||||
other_dir = tmp_path / "other"
|
||||
other_dir.mkdir()
|
||||
source_file = other_dir / "hashes.txt"
|
||||
@@ -129,14 +112,25 @@ class TestEnsureHashfileInCwd:
|
||||
|
||||
monkeypatch.setenv("HATE_CRACK_ORIG_CWD", str(cwd_dir))
|
||||
result = main_module._ensure_hashfile_in_cwd(str(source_file))
|
||||
assert result == str(source_file)
|
||||
|
||||
expected = str(cwd_dir / "hashes.txt")
|
||||
assert result == expected
|
||||
assert os.path.exists(expected)
|
||||
def test_different_dir_no_symlink(self, main_module, tmp_path, monkeypatch):
|
||||
"""File in different dir does NOT create a symlink in cwd."""
|
||||
other_dir = tmp_path / "other"
|
||||
other_dir.mkdir()
|
||||
source_file = other_dir / "hashes.txt"
|
||||
source_file.write_text("hashes")
|
||||
|
||||
cwd_dir = tmp_path / "cwd"
|
||||
cwd_dir.mkdir()
|
||||
|
||||
monkeypatch.setenv("HATE_CRACK_ORIG_CWD", str(cwd_dir))
|
||||
result = main_module._ensure_hashfile_in_cwd(str(source_file))
|
||||
assert result == str(source_file)
|
||||
assert not (cwd_dir / "hashes.txt").exists()
|
||||
|
||||
def test_uses_orig_cwd_not_process_cwd(self, main_module, tmp_path, monkeypatch):
|
||||
"""Output files go to HATE_CRACK_ORIG_CWD, not the process CWD."""
|
||||
# Simulate: process CWD is the install dir, orig CWD is the user's dir
|
||||
"""Returns original path as-is; no files created in any cwd."""
|
||||
install_dir = tmp_path / "install"
|
||||
install_dir.mkdir()
|
||||
user_dir = tmp_path / "user"
|
||||
@@ -150,10 +144,9 @@ class TestEnsureHashfileInCwd:
|
||||
monkeypatch.setenv("HATE_CRACK_ORIG_CWD", str(user_dir))
|
||||
result = main_module._ensure_hashfile_in_cwd(str(source_file))
|
||||
|
||||
# Should land in user_dir, NOT install_dir
|
||||
assert result == str(user_dir / "hashes.txt")
|
||||
assert os.path.exists(user_dir / "hashes.txt")
|
||||
assert not os.path.exists(install_dir / "hashes.txt")
|
||||
assert result == str(source_file)
|
||||
assert not (user_dir / "hashes.txt").exists()
|
||||
assert not (install_dir / "hashes.txt").exists()
|
||||
|
||||
|
||||
class TestRunHashcatShow:
|
||||
|
||||
Reference in New Issue
Block a user