674 lines
14 KiB
Python
674 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import subprocess
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Callable
|
|
|
|
|
|
class HashcatError(RuntimeError):
|
|
def __init__(
|
|
self,
|
|
message: str,
|
|
*,
|
|
exit_code: int | None = None,
|
|
) -> None:
|
|
super().__init__(message)
|
|
self.exit_code = exit_code
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class HashcatRunResult:
|
|
exit_code: int
|
|
restore_seen: bool
|
|
|
|
|
|
def restore_path(
|
|
job_dir: Path,
|
|
step: dict,
|
|
) -> Path:
|
|
return (
|
|
job_dir
|
|
/ "state"
|
|
/ f"step-{step['step_no']:03d}.restore"
|
|
)
|
|
|
|
|
|
def completed_marker(
|
|
job_dir: Path,
|
|
step: dict,
|
|
) -> Path:
|
|
return (
|
|
job_dir
|
|
/ "state"
|
|
/ f"step-{step['step_no']:03d}.completed"
|
|
)
|
|
|
|
|
|
def _session_name(step: dict) -> str:
|
|
session = step.get("session_name")
|
|
|
|
if not isinstance(session, str) or not session:
|
|
raise HashcatError(
|
|
f"Step {step.get('step_no')} has no session_name."
|
|
)
|
|
|
|
return session
|
|
|
|
def _resolve_wordlist(
|
|
config,
|
|
resource: str,
|
|
step_no,
|
|
) -> Path:
|
|
if not isinstance(resource, str) or not resource:
|
|
raise HashcatError(
|
|
f"Step {step_no} has invalid dictionary resource."
|
|
)
|
|
|
|
prefix = "wordlist:"
|
|
|
|
if not resource.startswith(prefix):
|
|
raise HashcatError(
|
|
f"Step {step_no} has unsupported dictionary resource: "
|
|
f"{resource}"
|
|
)
|
|
|
|
name = resource[len(prefix):]
|
|
|
|
if not name or "/" in name or "\\" in name or name in (".", ".."):
|
|
raise HashcatError(
|
|
f"Step {step_no} has invalid dictionary resource: "
|
|
f"{resource}"
|
|
)
|
|
|
|
dictionary = config.wordlists.get(name)
|
|
|
|
if dictionary is None:
|
|
raise HashcatError(
|
|
f"Wordlist resource {resource!r} is not configured."
|
|
)
|
|
|
|
dictionary = dictionary.resolve()
|
|
|
|
if not dictionary.is_file():
|
|
raise HashcatError(
|
|
f"Configured wordlist does not exist: {dictionary}"
|
|
)
|
|
|
|
return dictionary
|
|
|
|
def build_start_command(
|
|
config,
|
|
job_dir: Path,
|
|
job: dict,
|
|
step: dict,
|
|
) -> list[str]:
|
|
exe = config.hashcat_exe.resolve()
|
|
|
|
if not exe.is_file():
|
|
raise HashcatError(
|
|
f"Hashcat executable does not exist: {exe}"
|
|
)
|
|
|
|
hash_file = (
|
|
job_dir
|
|
/ "hashes"
|
|
/ job["hash_file_name"]
|
|
).resolve()
|
|
|
|
if not hash_file.is_file():
|
|
raise HashcatError(
|
|
f"Hash file does not exist: {hash_file}"
|
|
)
|
|
|
|
restore = restore_path(
|
|
job_dir,
|
|
step,
|
|
).resolve()
|
|
|
|
session = _session_name(step)
|
|
|
|
attack_mode = step.get("definition", {}).get(
|
|
"attack_mode"
|
|
)
|
|
|
|
if not isinstance(attack_mode, int):
|
|
raise HashcatError(
|
|
f"Step {step.get('step_no')} has invalid "
|
|
"attack_mode."
|
|
)
|
|
|
|
definition = step["definition"]
|
|
|
|
cmd = [
|
|
str(exe),
|
|
"-m",
|
|
str(job["hash_mode"]),
|
|
"-a",
|
|
str(attack_mode),
|
|
"--session",
|
|
session,
|
|
"--restore-file-path",
|
|
str(restore),
|
|
"--potfile-path",
|
|
str(config.potfile_path.resolve()),
|
|
"--logfile-disable",
|
|
"-w",
|
|
str(
|
|
definition.get(
|
|
"workload_profile",
|
|
1,
|
|
)
|
|
),
|
|
]
|
|
|
|
if attack_mode in (0, 1, 6, 7):
|
|
dictionary_definition = (
|
|
definition.get(
|
|
"dictionaries"
|
|
)
|
|
if attack_mode == 1
|
|
else definition.get(
|
|
"dictionary"
|
|
)
|
|
)
|
|
|
|
if attack_mode == 0:
|
|
if not isinstance(
|
|
dictionary_definition,
|
|
dict,
|
|
):
|
|
raise HashcatError(
|
|
f"Step {step.get('step_no')} "
|
|
"attack mode 0 has no dictionary."
|
|
)
|
|
|
|
resources = [
|
|
dictionary_definition.get(
|
|
"resource"
|
|
)
|
|
]
|
|
|
|
elif attack_mode == 1:
|
|
if (
|
|
not isinstance(
|
|
dictionary_definition,
|
|
list,
|
|
)
|
|
or len(dictionary_definition) != 2
|
|
):
|
|
raise HashcatError(
|
|
f"Step {step.get('step_no')} "
|
|
"attack mode 1 requires exactly "
|
|
"two dictionaries."
|
|
)
|
|
|
|
resources = [
|
|
item.get("resource")
|
|
if isinstance(item, dict)
|
|
else None
|
|
for item in dictionary_definition
|
|
]
|
|
|
|
else:
|
|
if not isinstance(
|
|
dictionary_definition,
|
|
dict,
|
|
):
|
|
raise HashcatError(
|
|
f"Step {step.get('step_no')} "
|
|
f"attack mode {attack_mode} "
|
|
"has no dictionary."
|
|
)
|
|
|
|
resources = [
|
|
dictionary_definition.get(
|
|
"resource"
|
|
)
|
|
]
|
|
|
|
dictionaries = [
|
|
_resolve_wordlist(
|
|
config,
|
|
resource,
|
|
step.get("step_no"),
|
|
)
|
|
for resource in resources
|
|
]
|
|
|
|
induction = (
|
|
job_dir
|
|
/ "state"
|
|
/ "induct"
|
|
).resolve()
|
|
|
|
outfile_check = (
|
|
job_dir
|
|
/ "state"
|
|
/ "outfiles"
|
|
).resolve()
|
|
|
|
induction.mkdir(
|
|
parents=True,
|
|
exist_ok=True,
|
|
)
|
|
|
|
outfile_check.mkdir(
|
|
parents=True,
|
|
exist_ok=True,
|
|
)
|
|
|
|
cmd.extend(
|
|
[
|
|
"--induction-dir",
|
|
str(induction),
|
|
"--outfile-check-dir",
|
|
str(outfile_check),
|
|
str(hash_file),
|
|
]
|
|
)
|
|
|
|
cmd.extend(
|
|
str(dictionary)
|
|
for dictionary in dictionaries
|
|
)
|
|
|
|
if attack_mode in (6, 7):
|
|
mask = definition.get("mask")
|
|
|
|
if not isinstance(mask, str) or not mask:
|
|
raise HashcatError(
|
|
f"Step {step.get('step_no')} "
|
|
f"attack mode {attack_mode} has no mask."
|
|
)
|
|
|
|
if attack_mode == 6:
|
|
cmd.append(mask)
|
|
else:
|
|
cmd.insert(
|
|
len(cmd) - len(dictionaries),
|
|
mask,
|
|
)
|
|
|
|
elif attack_mode == 3:
|
|
mask = definition.get("mask")
|
|
|
|
if not isinstance(mask, str) or not mask:
|
|
raise HashcatError(
|
|
f"Step {step.get('step_no')} "
|
|
"attack mode 3 has no mask."
|
|
)
|
|
|
|
cmd.extend(
|
|
[
|
|
str(hash_file),
|
|
mask,
|
|
]
|
|
)
|
|
|
|
else:
|
|
raise HashcatError(
|
|
f"Unsupported attack mode {attack_mode}. "
|
|
"Use a structured adapter for new modes."
|
|
)
|
|
|
|
return cmd
|
|
|
|
|
|
def build_restore_command(
|
|
config,
|
|
job_dir: Path,
|
|
step: dict,
|
|
) -> list[str]:
|
|
exe = config.hashcat_exe.resolve()
|
|
|
|
if not exe.is_file():
|
|
raise HashcatError(
|
|
f"Hashcat executable does not exist: {exe}"
|
|
)
|
|
|
|
restore = restore_path(
|
|
job_dir,
|
|
step,
|
|
).resolve()
|
|
|
|
return [
|
|
str(exe),
|
|
"--session",
|
|
_session_name(step),
|
|
"--restore-file-path",
|
|
str(restore),
|
|
"--restore",
|
|
]
|
|
|
|
|
|
def build_show_command(
|
|
config,
|
|
job_dir: Path,
|
|
job: dict,
|
|
) -> list[str]:
|
|
exe = config.hashcat_exe.resolve()
|
|
|
|
if not exe.is_file():
|
|
raise HashcatError(
|
|
f"Hashcat executable does not exist: {exe}"
|
|
)
|
|
|
|
hash_file = (
|
|
job_dir
|
|
/ "hashes"
|
|
/ job["hash_file_name"]
|
|
).resolve()
|
|
|
|
if not hash_file.is_file():
|
|
raise HashcatError(
|
|
f"Hash file does not exist: {hash_file}"
|
|
)
|
|
|
|
return [
|
|
str(exe),
|
|
"-m",
|
|
str(job["hash_mode"]),
|
|
"--potfile-path",
|
|
str(config.potfile_path.resolve()),
|
|
"--show",
|
|
str(hash_file),
|
|
]
|
|
|
|
|
|
def command_text(cmd: list[str]) -> str:
|
|
return " ".join(
|
|
f'"{value}"' if " " in value else value
|
|
for value in cmd
|
|
)
|
|
|
|
|
|
def run_hashcat(
|
|
cmd: list[str],
|
|
log_path: Path,
|
|
*,
|
|
on_output: Callable[[bytes], None] | None = None,
|
|
) -> int:
|
|
log_path.parent.mkdir(
|
|
parents=True,
|
|
exist_ok=True,
|
|
)
|
|
|
|
if not cmd:
|
|
raise HashcatError(
|
|
"Hashcat command is empty."
|
|
)
|
|
|
|
hashcat_dir = (
|
|
Path(cmd[0])
|
|
.resolve()
|
|
.parent
|
|
)
|
|
|
|
text = command_text(cmd)
|
|
|
|
with log_path.open(
|
|
"ab",
|
|
) as log:
|
|
log.write(
|
|
b"\n=== START ===\n"
|
|
)
|
|
log.write(
|
|
text.encode(
|
|
"utf-8",
|
|
errors="replace",
|
|
)
|
|
)
|
|
log.write(b"\n")
|
|
log.flush()
|
|
|
|
try:
|
|
process = subprocess.Popen(
|
|
cmd,
|
|
cwd=str(hashcat_dir),
|
|
stdin=None,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
bufsize=0,
|
|
creationflags=getattr(
|
|
subprocess,
|
|
"CREATE_NEW_PROCESS_GROUP",
|
|
0,
|
|
),
|
|
)
|
|
except OSError as exc:
|
|
log.write(
|
|
(
|
|
"\n=== PROCESS START ERROR ===\n"
|
|
f"{exc}\n"
|
|
).encode(
|
|
"utf-8",
|
|
errors="replace",
|
|
)
|
|
)
|
|
raise HashcatError(
|
|
f"Failed to start Hashcat: {exc}"
|
|
) from exc
|
|
|
|
try:
|
|
if process.stdout is None:
|
|
raise HashcatError(
|
|
"Hashcat stdout pipe was not created."
|
|
)
|
|
|
|
while True:
|
|
chunk = process.stdout.read(4096)
|
|
|
|
if not chunk:
|
|
break
|
|
|
|
log.write(chunk)
|
|
log.flush()
|
|
|
|
if on_output is not None:
|
|
on_output(chunk)
|
|
|
|
except BaseException:
|
|
try:
|
|
process.kill()
|
|
except OSError:
|
|
pass
|
|
|
|
raise
|
|
|
|
finally:
|
|
if process.stdout is not None:
|
|
process.stdout.close()
|
|
|
|
exit_code = process.wait()
|
|
|
|
log.write(
|
|
(
|
|
"\n=== END ===\n"
|
|
f"exit_code={exit_code}\n"
|
|
).encode(
|
|
"utf-8",
|
|
errors="replace",
|
|
)
|
|
)
|
|
log.flush()
|
|
|
|
return exit_code
|
|
|
|
|
|
def run_step(
|
|
config,
|
|
job_dir: Path,
|
|
job: dict,
|
|
step: dict,
|
|
log_path: Path,
|
|
*,
|
|
restore: bool = False,
|
|
) -> HashcatRunResult:
|
|
restore_file = restore_path(
|
|
job_dir,
|
|
step,
|
|
)
|
|
|
|
if restore:
|
|
if not restore_file.is_file():
|
|
raise HashcatError(
|
|
f"Restore requested but restore file "
|
|
f"does not exist: {restore_file}"
|
|
)
|
|
|
|
cmd = build_restore_command(
|
|
config,
|
|
job_dir,
|
|
step,
|
|
)
|
|
else:
|
|
cmd = build_start_command(
|
|
config,
|
|
job_dir,
|
|
job,
|
|
step,
|
|
)
|
|
|
|
exit_code = run_hashcat(
|
|
cmd,
|
|
log_path,
|
|
on_output=lambda chunk: print(
|
|
chunk.decode(
|
|
"utf-8",
|
|
errors="replace",
|
|
),
|
|
end="",
|
|
flush=True,
|
|
),
|
|
)
|
|
|
|
restore_seen = restore_file.is_file()
|
|
|
|
if exit_code not in (0, 1):
|
|
raise HashcatError(
|
|
f"Hashcat exited with unsupported "
|
|
f"code {exit_code}.",
|
|
exit_code=exit_code,
|
|
)
|
|
|
|
return HashcatRunResult(
|
|
exit_code=exit_code,
|
|
restore_seen=restore_seen,
|
|
)
|
|
|
|
|
|
def run_show(
|
|
config,
|
|
job_dir: Path,
|
|
job: dict,
|
|
output_path: Path,
|
|
log_path: Path,
|
|
) -> str:
|
|
cmd = build_show_command(
|
|
config,
|
|
job_dir,
|
|
job,
|
|
)
|
|
|
|
output_path.parent.mkdir(
|
|
parents=True,
|
|
exist_ok=True,
|
|
)
|
|
|
|
log_path.parent.mkdir(
|
|
parents=True,
|
|
exist_ok=True,
|
|
)
|
|
|
|
hashcat_dir = (
|
|
Path(cmd[0])
|
|
.resolve()
|
|
.parent
|
|
)
|
|
|
|
text = command_text(cmd)
|
|
|
|
with log_path.open(
|
|
"ab",
|
|
) as log:
|
|
log.write(
|
|
b"\n=== SHOW START ===\n"
|
|
)
|
|
log.write(
|
|
text.encode(
|
|
"utf-8",
|
|
errors="replace",
|
|
)
|
|
)
|
|
log.write(b"\n")
|
|
log.flush()
|
|
|
|
try:
|
|
process = subprocess.run(
|
|
cmd,
|
|
cwd=str(hashcat_dir),
|
|
stdin=None,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
check=False,
|
|
)
|
|
except OSError as exc:
|
|
log.write(
|
|
(
|
|
"\n=== SHOW START ERROR ===\n"
|
|
f"{exc}\n"
|
|
).encode(
|
|
"utf-8",
|
|
errors="replace",
|
|
)
|
|
)
|
|
raise HashcatError(
|
|
f"Failed to start Hashcat --show: {exc}"
|
|
) from exc
|
|
|
|
output = process.stdout or b""
|
|
|
|
output_path.write_bytes(
|
|
output
|
|
)
|
|
|
|
log.write(output)
|
|
log.write(
|
|
(
|
|
"\n=== SHOW END ===\n"
|
|
f"exit_code={process.returncode}\n"
|
|
).encode(
|
|
"utf-8",
|
|
errors="replace",
|
|
)
|
|
)
|
|
log.flush()
|
|
|
|
if process.returncode not in (0, 1):
|
|
raise HashcatError(
|
|
f"Hashcat --show exited with "
|
|
f"code {process.returncode}."
|
|
)
|
|
|
|
return output.decode(
|
|
"utf-8",
|
|
errors="replace",
|
|
)
|
|
|
|
|
|
def sha256_file(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
|
|
with path.open("rb") as handle:
|
|
while True:
|
|
chunk = handle.read(1024 * 1024)
|
|
|
|
if not chunk:
|
|
break
|
|
|
|
digest.update(chunk)
|
|
|
|
return digest.hexdigest()
|