145 lines
4.9 KiB
Python
145 lines
4.9 KiB
Python
"""Run bounded offline hashcat attacks against supported capture artifacts."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from typing import Callable, Optional, Sequence, Union
|
|
|
|
from ..artifacts import HashArtifact, identify_hash_mode
|
|
|
|
|
|
@dataclass
|
|
class HashCrack:
|
|
value: str
|
|
hashcat_mode: Optional[int] = None
|
|
hash_format: Optional[str] = None
|
|
cracked: bool = False
|
|
plaintext: Optional[str] = None
|
|
duration_seconds: Optional[float] = None
|
|
# pending | cracked | not_cracked | timeout | error | unsupported
|
|
state: str = "pending"
|
|
error: Optional[str] = None
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"value": self.value, "hashcat_mode": self.hashcat_mode,
|
|
"hash_format": self.hash_format, "cracked": self.cracked,
|
|
"plaintext": self.plaintext, "duration_seconds": self.duration_seconds,
|
|
"state": self.state, "error": self.error,
|
|
}
|
|
|
|
|
|
# Keep the bundled demo list first; authorized deployments may add a system list.
|
|
DEFAULT_WORDLISTS = (
|
|
str(Path(__file__).resolve().parents[3] / "demo_files" / "demo_wordlist.txt"),
|
|
"/usr/share/wordlists/rockyou.txt",
|
|
)
|
|
|
|
|
|
def build_crack_command(hashcat_path: str, mode: int, hash_file: str,
|
|
wordlists: Sequence[str], potfile: str) -> list[str]:
|
|
"""Build the hashcat dictionary-attack command."""
|
|
cmd = [hashcat_path, "-m", str(mode), "-a", "0", "-D", "1,2",
|
|
hash_file, *wordlists, "--potfile-path", potfile, "--force"]
|
|
return cmd
|
|
|
|
|
|
def build_show_command(hashcat_path: str, mode: int, hash_file: str,
|
|
potfile: str) -> list[str]:
|
|
"""Build the hashcat command used to read cracked results."""
|
|
return [hashcat_path, "-m", str(mode), "--show", "--potfile-path", potfile, hash_file]
|
|
|
|
|
|
def parse_show_output(stdout: str) -> Optional[str]:
|
|
"""Extract plaintext from ``hashcat --show`` output."""
|
|
if not stdout:
|
|
return None
|
|
for line in stdout.strip().splitlines():
|
|
if ":" in line:
|
|
return line.rsplit(":", 1)[1]
|
|
return None
|
|
|
|
|
|
def crack_hash(
|
|
artifact: Union[HashArtifact, str],
|
|
*,
|
|
wordlists: Sequence[str] = DEFAULT_WORDLISTS,
|
|
hashcat_path: str = "hashcat",
|
|
timeout: int = 300,
|
|
potfile: Optional[str] = None,
|
|
runner: Callable[..., subprocess.CompletedProcess] = subprocess.run,
|
|
which: Callable[[str], Optional[str]] = shutil.which,
|
|
) -> HashCrack:
|
|
"""Crack one artifact and convert runtime failures into a result state."""
|
|
if isinstance(artifact, HashArtifact):
|
|
value = artifact.value
|
|
mode = artifact.hashcat_mode
|
|
fmt = artifact.hash_format
|
|
else:
|
|
value = str(artifact)
|
|
mode = None
|
|
fmt = None
|
|
|
|
if mode is None:
|
|
mode, fmt = identify_hash_mode(value, hint_format=fmt)
|
|
|
|
result = HashCrack(value=value, hashcat_mode=mode, hash_format=fmt)
|
|
|
|
if mode is None:
|
|
result.state = "unsupported"
|
|
result.error = "No supported hashcat mode was detected"
|
|
return result
|
|
|
|
if which(hashcat_path) is None:
|
|
result.state = "error"
|
|
result.error = "hashcat is not installed or not available on PATH"
|
|
return result
|
|
|
|
available = [w for w in wordlists if os.path.exists(w)]
|
|
if not available:
|
|
result.state = "error"
|
|
result.error = "No wordlist is available"
|
|
return result
|
|
|
|
tmpdir = tempfile.mkdtemp(prefix="hashcrack_")
|
|
hash_file = os.path.join(tmpdir, "hash.txt")
|
|
pot = potfile or os.path.join(tmpdir, "hashcat.potfile")
|
|
try:
|
|
with open(hash_file, "w", encoding="utf-8") as fh:
|
|
fh.write(value.strip() + "\n")
|
|
|
|
start = time.time()
|
|
try:
|
|
runner(build_crack_command(hashcat_path, mode, hash_file, available, pot),
|
|
capture_output=True, text=True, timeout=timeout)
|
|
except subprocess.TimeoutExpired:
|
|
result.state = "timeout"
|
|
result.duration_seconds = round(time.time() - start, 2)
|
|
result.error = f"hashcat timed out after {timeout}s"
|
|
return result
|
|
|
|
show = runner(build_show_command(hashcat_path, mode, hash_file, pot),
|
|
capture_output=True, text=True, timeout=timeout)
|
|
result.duration_seconds = round(time.time() - start, 2)
|
|
|
|
plaintext = parse_show_output(getattr(show, "stdout", "") or "")
|
|
if plaintext is not None:
|
|
result.cracked = True
|
|
result.plaintext = plaintext
|
|
result.state = "cracked"
|
|
else:
|
|
result.state = "not_cracked"
|
|
return result
|
|
except Exception as exc: # Enrichment failures must not stop capture analysis.
|
|
result.state = "error"
|
|
result.error = str(exc)[:300]
|
|
return result
|
|
finally:
|
|
shutil.rmtree(tmpdir, ignore_errors=True)
|