720 lines
37 KiB
Python
720 lines
37 KiB
Python
from __future__ import annotations
|
|
|
|
import ipaddress
|
|
import json
|
|
import math
|
|
import os
|
|
import re
|
|
import shutil
|
|
import signal
|
|
import subprocess
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Callable
|
|
|
|
from .catalog import CheckDefinition
|
|
from .checks import CheckResult, classify_msf, classify_nxc, index_messages
|
|
from .longlogon import probe_longlogon
|
|
from .models import CheckAttempt, HostEvidence
|
|
from .netexec import (NxcCapabilities, host_failures, inspect_capabilities, invocation_failed,
|
|
plan_batches, skip_reason, validate_literal_credentials)
|
|
from .parsers import load_enum4linux, parse_enum4linux, parse_nxc, parse_rpcdump
|
|
from .progress import ProgressEvent
|
|
from .reporting import coverage
|
|
from .storage import atomic_text, prepare_output, redact_text, write_sanitized_json
|
|
from .targets import discover_services, expand_targets
|
|
|
|
|
|
@dataclass
|
|
class ScanConfig:
|
|
targets: list[str]
|
|
username: str = ""
|
|
password: str = field(default="", repr=False)
|
|
domain: str = ""
|
|
shares: bool = False
|
|
lhost: str | None = None
|
|
jobs: int = 8
|
|
discovery_workers: int = 256
|
|
timeout: int = 120
|
|
discovery_timeout: int = 600
|
|
tcp_discovery: bool = False
|
|
batch_checks: bool = False # CLI uses batches; preserve isolated library behavior.
|
|
connect_timeout: float = 1.0
|
|
max_hosts: int = 4096
|
|
use_enum4linux: bool = True
|
|
use_rpcdump: bool = True
|
|
use_msf: bool = False
|
|
checks: list[CheckDefinition] = field(default_factory=list)
|
|
dry_run: bool = False
|
|
out_dir: Path = Path("rpcscope-output")
|
|
commands: list[list[str]] = field(default_factory=list)
|
|
errors: list[str] = field(default_factory=list)
|
|
cancelled: bool = False
|
|
|
|
|
|
def _which(*names: str) -> str | None:
|
|
return next((found for name in names if (found := shutil.which(name))), None)
|
|
|
|
|
|
def doctor() -> dict[str, str | None]:
|
|
return {
|
|
"nxc": _which("nxc", "netexec"),
|
|
"enum4linux-ng": _which("enum4linux-ng", "enum4linux-ng.py"),
|
|
"rpcdump": _which("impacket-rpcdump", "rpcdump.py", "rpcdump"),
|
|
"msfconsole": _which("msfconsole"),
|
|
}
|
|
|
|
|
|
def _redacted(command: list[str], password: str) -> list[str]:
|
|
redacted = [redact_text(item, password) for item in command]
|
|
if "-x" in command:
|
|
index = command.index("-x") + 1
|
|
if index < len(redacted):
|
|
redacted[index] = "<redacted Metasploit resource script>"
|
|
return redacted
|
|
|
|
|
|
def _terminate(process: subprocess.Popen) -> None:
|
|
"""Stop the collector and its children, including inherited log handles."""
|
|
if os.name == "posix":
|
|
try:
|
|
os.killpg(process.pid, signal.SIGKILL)
|
|
except ProcessLookupError:
|
|
pass
|
|
elif process.poll() is None:
|
|
try:
|
|
subprocess.run(["taskkill", "/PID", str(process.pid), "/T", "/F"],
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
timeout=5, check=False)
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
process.kill()
|
|
try:
|
|
process.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
process.kill()
|
|
|
|
|
|
def _run(command: list[str], timeout: int, log: Path, password: str,
|
|
cancel: threading.Event | None = None) -> tuple[int, str]:
|
|
"""Spool output to disk; avoid pipe deadlocks and retain timed-out output."""
|
|
with tempfile.TemporaryFile() as capture:
|
|
limit = 16 * 1024 * 1024
|
|
kwargs = {"start_new_session": True} if os.name == "posix" else {
|
|
"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP,
|
|
}
|
|
try:
|
|
process = subprocess.Popen(command, stdout=capture, stderr=subprocess.STDOUT,
|
|
stdin=subprocess.DEVNULL,
|
|
env={**os.environ, "NO_COLOR": "1", "COLUMNS": "512", "PYTHONUNBUFFERED": "1"},
|
|
**kwargs)
|
|
except OSError as exc:
|
|
text = f"Could not start collector: {exc}\n"
|
|
text = redact_text(text, password)
|
|
atomic_text(log, text)
|
|
return 127, text
|
|
deadline = time.monotonic() + timeout
|
|
reason = ""
|
|
try:
|
|
while process.poll() is None:
|
|
if cancel and cancel.is_set():
|
|
reason = "RPCScope collection cancelled"
|
|
break
|
|
if os.fstat(capture.fileno()).st_size > limit:
|
|
reason = "RPCScope output limit (16 MiB) reached"
|
|
break
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
reason = f"RPCScope timeout after {timeout}s"
|
|
break
|
|
try:
|
|
process.wait(timeout=min(0.2, remaining))
|
|
except subprocess.TimeoutExpired:
|
|
pass
|
|
except KeyboardInterrupt:
|
|
if cancel:
|
|
cancel.set()
|
|
reason = "RPCScope collection cancelled"
|
|
except BaseException:
|
|
_terminate(process)
|
|
raise
|
|
if reason:
|
|
_terminate(process)
|
|
capture.seek(0)
|
|
data = capture.read(limit + 1)
|
|
rc = process.returncode or 0
|
|
if len(data) > limit:
|
|
data = data[:limit]
|
|
if "output limit" not in reason:
|
|
reason += "\nRPCScope output limit (16 MiB) reached"
|
|
rc = 125
|
|
if reason:
|
|
rc = 130 if cancel and cancel.is_set() else (rc if rc == 125 else 124)
|
|
text = data.decode("utf-8", errors="replace").replace("\r\n", "\n").replace("\r", "\n")
|
|
if reason:
|
|
text += "\n" + reason.strip() + "\n"
|
|
saved = redact_text(text, password)
|
|
atomic_text(log, saved)
|
|
return rc, text
|
|
|
|
|
|
def normalize_credentials(config: ScanConfig) -> None:
|
|
for separator in ("\\", "/"):
|
|
if separator in config.username:
|
|
domain, username = config.username.split(separator, 1)
|
|
if config.domain and config.domain.casefold() != domain.casefold():
|
|
raise ValueError("domain conflicts with the domain in the username")
|
|
config.domain, config.username = domain, username
|
|
break
|
|
if config.password and not config.username:
|
|
raise ValueError("a password requires a username")
|
|
if any(ord(char) < 32 for char in config.username + config.domain + config.password):
|
|
raise ValueError("credentials cannot contain control characters")
|
|
|
|
|
|
def _nxc_auth(config: ScanConfig) -> list[str]:
|
|
# Equals form preserves empty credentials and passwords beginning with '-'.
|
|
result = [f"-u={config.username}", f"-p={config.password}"]
|
|
if config.domain:
|
|
result.extend(["-d", config.domain])
|
|
return result
|
|
|
|
|
|
def _msf_command(tool: str, address: str, config: ScanConfig) -> list[str]:
|
|
def quote(value: str) -> str:
|
|
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
settings = ["use auxiliary/scanner/smb/smb_ms17_010", f"set RHOSTS {address}"]
|
|
if config.username:
|
|
settings.extend([f"set SMBUser {quote(config.username)}", f"set SMBPass {quote(config.password)}"])
|
|
if config.domain:
|
|
settings.append(f"set SMBDomain {quote(config.domain)}")
|
|
settings.extend(["run", "exit -y"])
|
|
return [tool, "-q", "-x", "; ".join(settings)]
|
|
|
|
|
|
def _record_check(host: HostEvidence, key: str, outcome: CheckResult, source: Path,
|
|
backend: str, password: str) -> None:
|
|
detail = redact_text(outcome.evidence, password)
|
|
saved = str(source) if source.exists() else ""
|
|
host.merge(HostEvidence(host.address, checks={key: outcome.status}, sources=[saved] if saved else [],
|
|
check_details={key: detail},
|
|
check_attempts=[CheckAttempt(key, backend, outcome.status, detail, saved)]))
|
|
|
|
|
|
def scan(config: ScanConfig, *, progress: Callable[[ProgressEvent], None] | None = None,
|
|
checkpoint: Callable[[dict[str, HostEvidence]], None] | None = None) -> dict[str, HostEvidence]:
|
|
for value in (config.jobs, config.discovery_workers, config.timeout, config.discovery_timeout, config.max_hosts):
|
|
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
raise ValueError("workers, jobs, execution timeouts and host limits must be positive integers")
|
|
if (isinstance(config.connect_timeout, bool) or not isinstance(config.connect_timeout, (int, float))
|
|
or not math.isfinite(config.connect_timeout) or config.connect_timeout <= 0):
|
|
raise ValueError("connection timeout must be positive and finite")
|
|
normalize_credentials(config)
|
|
# Public API callers may supply duplicate definitions; account/execute once.
|
|
unique_checks = {}
|
|
for definition in config.checks:
|
|
if definition.key in unique_checks and unique_checks[definition.key] != definition:
|
|
raise ValueError(f"conflicting definitions for check {definition.key}")
|
|
unique_checks[definition.key] = definition
|
|
config.checks = list(unique_checks.values())
|
|
tools = doctor()
|
|
nxc = str(tools["nxc"] or "nxc")
|
|
discovery = [nxc, "smb", *config.targets, *_nxc_auth(config), "-t", str(config.discovery_workers), "--no-progress"]
|
|
if config.shares:
|
|
discovery.extend(["--shares", "<version-specific-read-only-options>"])
|
|
if config.dry_run:
|
|
if config.tcp_discovery:
|
|
config.commands.append(_redacted(["<native:tcp-discovery>", *config.targets, "--ports", "135,445",
|
|
"--workers", str(config.discovery_workers), "--connect-timeout", str(config.connect_timeout),
|
|
"--max-hosts", str(config.max_hosts)], config.password))
|
|
discovery = [nxc, "smb", "<TCP/445-listeners>", *_nxc_auth(config), "-t", str(config.discovery_workers), "--no-progress",
|
|
*(["--shares", "<version-specific-read-only-options>"] if config.shares else [])]
|
|
config.commands.append(_redacted(discovery, config.password))
|
|
if config.dry_run:
|
|
address = "<discovered-host>"
|
|
if config.batch_checks:
|
|
dummy = HostEvidence("192.0.2.255", services={"135": "open", "445": "open"})
|
|
batches, _ = plan_batches(config.checks, {dummy.address: dummy}, jobs=config.jobs,
|
|
tcp_discovery=True, has_nxc=True, username=config.username)
|
|
for batch in batches:
|
|
config.commands.append(_redacted([nxc, "smb", "<live-host-batch>", *_nxc_auth(config),
|
|
"-t", str(config.jobs), "--no-progress", "-M", batch.module,
|
|
*(["-o", *batch.options] if batch.options else [])], config.password))
|
|
for definition in config.checks:
|
|
if definition.enabled and definition.backend == "native":
|
|
config.commands.append(["<native:longlogon>", address, "UDP/389", "EX-control+guarded-minimal-legacy"])
|
|
elif not config.batch_checks and definition.enabled and (not definition.requires_credentials or config.username):
|
|
config.commands.append(_redacted([nxc, "smb", address, *_nxc_auth(config),
|
|
"-M", definition.nxc_module,
|
|
*(["-o", *definition.nxc_options] if definition.nxc_options else [])], config.password))
|
|
if config.use_msf:
|
|
config.commands.append([str(tools["msfconsole"] or "msfconsole"), "-q", "-x",
|
|
"<MS17-010 scanner resource script; credentials redacted>"])
|
|
if config.use_rpcdump:
|
|
config.commands.append([str(tools["rpcdump"] or "impacket-rpcdump"), "-port", "135", address])
|
|
if config.use_enum4linux:
|
|
config.commands.append(_redacted([
|
|
str(tools["enum4linux-ng"] or "enum4linux-ng"), "-A", f"-u={config.username}",
|
|
f"-p={config.password}", *(["-w", config.domain] if config.domain else []),
|
|
"-oJ", "<private-temp-prefix>", address,
|
|
], config.password))
|
|
return {}
|
|
if not tools["nxc"] and not config.tcp_discovery:
|
|
raise RuntimeError("NetExec was not found (expected nxc or netexec in PATH)")
|
|
if tools["nxc"]:
|
|
validate_literal_credentials(config.username, config.password)
|
|
scope = expand_targets(config.targets, max_hosts=config.max_hosts) if config.tcp_discovery else []
|
|
prepare_output(config.out_dir)
|
|
raw = config.out_dir / "raw"
|
|
raw.mkdir(mode=0o700)
|
|
cancel = threading.Event()
|
|
lock = threading.Lock()
|
|
total = len(scope) + 1 if config.tcp_discovery else 1
|
|
completed = 0
|
|
|
|
def emit(kind: str, stage: str, host: str = "", status: str = "", detail: str = "", duration: float = 0.0) -> None:
|
|
nonlocal completed
|
|
with lock:
|
|
if kind in {"finish", "advance"}:
|
|
completed += 1
|
|
if progress:
|
|
progress(ProgressEvent(kind, stage, host, status, completed, total, detail, duration))
|
|
|
|
def error(message: str) -> None:
|
|
with lock:
|
|
message = redact_text(message, config.password)
|
|
if message not in config.errors:
|
|
config.errors.append(message)
|
|
|
|
capabilities: NxcCapabilities | None = None
|
|
|
|
def nxc_command(addresses, threads: int, *, shares: bool = False) -> list[str]:
|
|
nonlocal capabilities
|
|
if capabilities is None:
|
|
capabilities = inspect_capabilities(nxc) if tools["nxc"] else NxcCapabilities()
|
|
command = [nxc, "smb", *addresses, *_nxc_auth(config), "-t", str(threads), "--no-progress",
|
|
*capabilities.performance_options()]
|
|
if shares:
|
|
share_options = capabilities.readonly_shares()
|
|
if share_options is None:
|
|
error("Share enumeration skipped: installed NetExec help does not establish read-only share-check support")
|
|
else:
|
|
command.extend(share_options)
|
|
return command
|
|
|
|
def run(stage: str, address: str, command: list[str], path: Path, timeout: int) -> tuple[int, str]:
|
|
emit("start", stage, address)
|
|
with lock:
|
|
if command != discovery:
|
|
config.commands.append(_redacted(command, config.password))
|
|
code, text = _run(command, timeout, path, config.password, cancel=cancel)
|
|
if code:
|
|
error(f"{stage}{' for ' + address if address else ''} exited with status {code}; see {path.name}")
|
|
return code, text
|
|
|
|
discovery_start = time.monotonic()
|
|
if config.tcp_discovery:
|
|
hosts = {address: HostEvidence(address, services={"135": "unprobed", "445": "unprobed"})
|
|
for address in scope}
|
|
emit("start", "tcp-discovery", detail=f"{len(scope)} target(s); TCP/135 and TCP/445; {config.discovery_workers} workers")
|
|
|
|
def discovered(address: str, services: dict[int, str]) -> None:
|
|
hosts[address].services.update({str(port): state for port, state in services.items()})
|
|
if "open" in services.values():
|
|
emit("finish", "tcp-discovery", address, "alive",
|
|
detail=" ".join(f"{port}:{state}" for port, state in services.items()))
|
|
else:
|
|
emit("advance", "tcp-discovery")
|
|
|
|
discovery_failed = False
|
|
try:
|
|
discover_services(scope, jobs=config.discovery_workers, timeout=config.connect_timeout,
|
|
cancel=cancel, on_result=discovered,
|
|
deadline=discovery_start + config.discovery_timeout)
|
|
except KeyboardInterrupt:
|
|
cancel.set()
|
|
except (OSError, RuntimeError) as exc:
|
|
discovery_failed = True
|
|
error(f"TCP discovery failed locally: {exc}")
|
|
# NetExec discovery gets only SMB listeners. RPC-only hosts stay in scope.
|
|
smb_targets = [address for address, host in hosts.items() if host.services.get("445") == "open"]
|
|
rc = 1 if discovery_failed else 0
|
|
if (any("unprobed" in host.services.values() for host in hosts.values())
|
|
and not cancel.is_set() and not discovery_failed):
|
|
error("TCP discovery deadline reached; unprobed targets remain incomplete")
|
|
rc = 124
|
|
emit("summary", "tcp-discovery", status="complete" if rc == 0 else "incomplete",
|
|
detail=f"{sum('open' in host.services.values() for host in hosts.values())} alive; {len(smb_targets)} SMB listener(s)")
|
|
emit("start", "discovery", detail=f"{len(smb_targets)} SMB listener(s)")
|
|
if smb_targets and not tools["nxc"]:
|
|
error("NetExec was not found; SMB collection/checks are incomplete")
|
|
rc = 127
|
|
for address in smb_targets:
|
|
hosts[address].collection["discovery"] = "missing"
|
|
elif smb_targets and not cancel.is_set():
|
|
for index in range(0, len(smb_targets), 128):
|
|
batch = smb_targets[index:index + 128]
|
|
command = nxc_command(batch, config.discovery_workers, shares=config.shares)
|
|
remaining = config.discovery_timeout - int(time.monotonic() - discovery_start)
|
|
if remaining <= 0:
|
|
error("Discovery timeout reached; partial host inventory retained")
|
|
rc = 124
|
|
break
|
|
path = raw / f"nxc-{index // 128 + 1:03d}.txt"
|
|
# One discovery stage may have several command-line-sized batches.
|
|
config.commands.append(_redacted(command, config.password))
|
|
code, output = _run(command, remaining, path, config.password, cancel=cancel)
|
|
if not code and invocation_failed(output, tuple(batch)):
|
|
code = 1
|
|
error(f"SMB discovery invocation reported an error; see {path.name}")
|
|
rc = code or rc
|
|
failed_hosts = host_failures(output, tuple(batch))
|
|
for address in batch:
|
|
hosts[address].collection["discovery"] = "success" if code == 0 and address not in failed_hosts else "failed"
|
|
if address in failed_hosts:
|
|
error(f"NetExec caught an exception for {address} during discovery; see {path.name}")
|
|
rc = rc or 1
|
|
for address, evidence in parse_nxc(output, str(path)).items():
|
|
if address in batch:
|
|
hosts[address].merge(evidence)
|
|
if code:
|
|
error(f"SMB discovery batch exited with status {code}; see {path.name}")
|
|
if cancel.is_set():
|
|
break
|
|
for host in hosts.values():
|
|
host.collection["tcp-discovery"] = (
|
|
"success" if "unprobed" not in host.services.values()
|
|
else "incomplete"
|
|
)
|
|
if "open" in host.services.values():
|
|
host.collection.setdefault("discovery", "pending" if host.services.get("445") == "open"
|
|
else "skipped-service-unavailable")
|
|
else:
|
|
discovery = nxc_command(config.targets, config.discovery_workers, shares=config.shares)
|
|
config.commands.append(_redacted(discovery, config.password))
|
|
rc, output = run("discovery", "", discovery, raw / "nxc.txt", config.discovery_timeout)
|
|
parsed_discovery = parse_nxc(output, str(raw / "nxc.txt"))
|
|
if not rc and invocation_failed(output, tuple(parsed_discovery)):
|
|
rc = 1
|
|
error("SMB discovery invocation reported an error; see nxc.txt")
|
|
hosts = {address: host for address, host in parsed_discovery.items()
|
|
if "open" in host.services.values()}
|
|
failed_hosts = host_failures(output, tuple(hosts))
|
|
for address, host in hosts.items():
|
|
host.collection["discovery"] = "success" if rc == 0 and address not in failed_hosts else "failed"
|
|
if address in failed_hosts:
|
|
error(f"NetExec caught an exception for {address} during discovery; see nxc.txt")
|
|
active_hosts = {address: host for address, host in hosts.items()
|
|
if not config.tcp_discovery or "open" in host.services.values()}
|
|
for host in active_hosts.values():
|
|
if config.shares and capabilities is not None and capabilities.readonly_shares() is None:
|
|
host.collection["shares"] = "skipped-unsupported"
|
|
host.notes.append("NetExec share enumeration was skipped because a read-only mode could not be verified.")
|
|
if config.use_enum4linux:
|
|
host.collection["enum4linux"] = "pending"
|
|
if config.use_rpcdump:
|
|
host.collection["rpcdump"] = "pending"
|
|
if config.use_msf:
|
|
host.collection["msf-ms17-010"] = "pending"
|
|
host.checks.update({definition.key: "pending" for definition in config.checks})
|
|
if config.use_msf:
|
|
host.checks.setdefault("ms17-010", "pending")
|
|
per_host = int(config.use_enum4linux) + int(config.use_rpcdump) + len(config.checks) + int(config.use_msf)
|
|
total += len(active_hosts) * per_host
|
|
emit("finish", "discovery", status="success" if rc == 0 else "failed",
|
|
detail=f"{len(active_hosts)} alive; {len(hosts) - len(active_hosts)} unreachable omitted",
|
|
duration=time.monotonic() - discovery_start)
|
|
if checkpoint:
|
|
checkpoint(hosts)
|
|
if cancel.is_set():
|
|
config.cancelled = True
|
|
error("Scan interrupted during discovery; partial evidence was retained")
|
|
for host in hosts.values():
|
|
host.checks = {key: "skipped-cancelled" for key in host.checks}
|
|
host.collection = {key: "skipped-cancelled" if status == "pending" else status
|
|
for key, status in host.collection.items()}
|
|
if checkpoint:
|
|
checkpoint(hosts)
|
|
emit("summary", "scan", status="interrupted", detail=f"{len(hosts)} partial target(s)")
|
|
return hosts
|
|
if not active_hosts:
|
|
emit("summary", "scan", status="incomplete" if rc else "complete", detail="no reachable SMB/RPC hosts observed")
|
|
return hosts
|
|
smb_available = not config.tcp_discovery or any(host.services.get("445") == "open" for host in hosts.values())
|
|
rpc_available = not config.tcp_discovery or any(host.services.get("135") == "open" for host in hosts.values())
|
|
for tool, enabled in [("enum4linux-ng", config.use_enum4linux and smb_available),
|
|
("rpcdump", config.use_rpcdump and rpc_available),
|
|
("msfconsole", config.use_msf and smb_available)]:
|
|
if enabled and not tools[tool]:
|
|
error(f"{tool} was requested but not found")
|
|
|
|
if config.batch_checks:
|
|
batches, skipped = plan_batches(config.checks, active_hosts, jobs=config.jobs,
|
|
tcp_discovery=config.tcp_discovery, has_nxc=bool(tools["nxc"]), username=config.username)
|
|
for address, definition, outcome in skipped:
|
|
host = hosts[address]
|
|
_record_check(host, definition.key, outcome, raw / "not-run", "netexec", config.password)
|
|
host.collection[f"check:{definition.key}"] = outcome.status
|
|
emit("finish", definition.key, address, outcome.status)
|
|
try:
|
|
for number, batch in enumerate(batches, 1):
|
|
if cancel.is_set():
|
|
break
|
|
started = time.monotonic()
|
|
path = raw / f"batch-{number:04d}-{batch.module}.txt"
|
|
command = [*nxc_command(batch.targets, min(config.jobs, len(batch.targets))), "-M", batch.module]
|
|
if batch.options:
|
|
command.extend(["-o", *batch.options])
|
|
code, text = run(f"batch:{batch.module}", "", command, path, config.timeout * len(batch.checks))
|
|
if not code and invocation_failed(text, batch.targets):
|
|
code = 1
|
|
error(f"NetExec invocation reported an error in {batch.module}; see {path.name}")
|
|
indexed = index_messages(text)
|
|
failed_hosts = host_failures(text, batch.targets)
|
|
for address, evidence in parse_nxc(text, str(path)).items():
|
|
if address in batch.targets:
|
|
hosts[address].merge(evidence)
|
|
for address in batch.targets:
|
|
host = hosts[address]
|
|
effective_code = code or (1 if address in failed_hosts else 0)
|
|
if address in failed_hosts:
|
|
error(f"NetExec caught an exception for {address} in {batch.module}; see {path.name}")
|
|
for definition in batch.checks:
|
|
outcome = classify_nxc(definition.key, address, "", effective_code, index=indexed)
|
|
_record_check(host, definition.key, outcome, path, "netexec", config.password)
|
|
host.collection[f"check:{definition.key}"] = "failed" if effective_code else "success"
|
|
emit("finish", definition.key, address, outcome.status, duration=time.monotonic() - started)
|
|
emit("summary", "check-batch", status="failed" if code or failed_hosts else "complete",
|
|
detail=f"{batch.module}; {len(batch.targets)} alive host(s)")
|
|
if checkpoint:
|
|
checkpoint(hosts)
|
|
except KeyboardInterrupt:
|
|
cancel.set()
|
|
if cancel.is_set():
|
|
config.cancelled = True
|
|
error("Scan interrupted; completed batch evidence was retained")
|
|
for host in hosts.values():
|
|
host.checks = {key: "skipped-cancelled" if status == "pending" else status
|
|
for key, status in host.checks.items()}
|
|
host.collection = {key: "skipped-cancelled" if status == "pending" else status
|
|
for key, status in host.collection.items()}
|
|
if checkpoint:
|
|
checkpoint(hosts)
|
|
emit("summary", "scan", status="interrupted", detail="completed batch evidence retained")
|
|
return hosts
|
|
|
|
def collect(address: str) -> HostEvidence:
|
|
result = HostEvidence(address)
|
|
safe = address.replace(":", "_")
|
|
|
|
def unavailable(ports: tuple[int, ...]) -> bool:
|
|
return config.tcp_discovery and any(hosts[address].services.get(str(port)) != "open" for port in ports)
|
|
|
|
def collector(stage: str, tool: str, action: Callable[[], str], ports: tuple[int, ...]) -> None:
|
|
if cancel.is_set():
|
|
status = "skipped-cancelled"
|
|
elif unavailable(ports):
|
|
status = "skipped-service-unavailable"
|
|
elif not tools[tool]:
|
|
status = "missing"
|
|
else:
|
|
started = time.monotonic()
|
|
try:
|
|
status = action()
|
|
except (OSError, ValueError, TypeError) as exc:
|
|
status = "failed"
|
|
error(f"{stage} for {address}: {exc}")
|
|
result.collection[stage] = status
|
|
emit("finish", stage, address, status, duration=time.monotonic() - started)
|
|
return
|
|
result.collection[stage] = status
|
|
emit("finish", stage, address, status)
|
|
|
|
def enum_action() -> str:
|
|
destination = raw / f"enum4linux-{safe}.json"
|
|
with tempfile.TemporaryDirectory(prefix=".enum-", dir=raw) as temporary:
|
|
prefix = Path(temporary) / "result"
|
|
command = [str(tools["enum4linux-ng"]), "-A", f"-u={config.username}",
|
|
f"-p={config.password}", "-t", str(min(config.timeout, 60)),
|
|
"-oJ", str(prefix), address]
|
|
if config.domain:
|
|
command.extend(["-w", config.domain])
|
|
code, _ = run("enum4linux", address, command, raw / f"enum4linux-{safe}.log", config.timeout)
|
|
exported = Path(str(prefix) + ".json")
|
|
if not exported.exists():
|
|
if code == 0:
|
|
error(f"enum4linux for {address} produced no JSON export")
|
|
return "failed"
|
|
data = json.loads(exported.read_text(encoding="utf-8"))
|
|
write_sanitized_json(destination, data, config.password)
|
|
result.merge(parse_enum4linux(data, str(destination)))
|
|
return "success" if code == 0 else "failed"
|
|
|
|
def rpc_action() -> str:
|
|
path = raw / f"rpcdump-{safe}.txt"
|
|
# TCP endpoint mapper does not authenticate. Omitting credentials
|
|
# also prevents rpcdump from prompting for a legitimate blank password.
|
|
command = [str(tools["rpcdump"]), "-port", "135", address]
|
|
code, text = run("rpcdump", address, command, path, config.timeout)
|
|
result.merge(parse_rpcdump(text, address, str(path)))
|
|
return "success" if code == 0 else "failed"
|
|
|
|
def store_check(key: str, outcome: CheckResult, source: Path, backend: str = "netexec") -> None:
|
|
_record_check(result, key, outcome, source, backend, config.password)
|
|
|
|
for definition in config.checks:
|
|
if config.batch_checks and definition.backend == "netexec":
|
|
continue
|
|
started = time.monotonic()
|
|
path = raw / f"check-{definition.key}-{safe}.txt"
|
|
execution = "success"
|
|
if cancel.is_set():
|
|
outcome = CheckResult("skipped-cancelled", "scan interrupted")
|
|
elif (reason := skip_reason(definition, hosts[address], tcp_discovery=config.tcp_discovery,
|
|
has_nxc=bool(tools["nxc"]), username=config.username)):
|
|
outcome = reason
|
|
elif definition.backend == "native":
|
|
emit("start", definition.key, address)
|
|
with lock:
|
|
config.commands.append(["<native:longlogon>", address, "UDP/389", "EX-control+guarded-minimal-legacy"])
|
|
outcome = probe_longlogon(address, timeout=min(3.0, config.timeout / 2), cancel=cancel)
|
|
atomic_text(path, redact_text(f"{outcome.status}\n{outcome.evidence}\n", config.password))
|
|
else:
|
|
command = [*nxc_command([address], 1), "-M", definition.nxc_module]
|
|
if definition.nxc_options:
|
|
command.extend(["-o", *definition.nxc_options])
|
|
try:
|
|
code, text = run(definition.key, address, command, path, config.timeout)
|
|
if not code and (invocation_failed(text, (address,)) or host_failures(text, (address,))):
|
|
code = 1
|
|
error(f"NetExec reported an error for {address} in {definition.key}; see {path.name}")
|
|
outcome = classify_nxc(definition.key, address, text, code)
|
|
execution = "success" if code == 0 else "failed"
|
|
except (OSError, ValueError) as exc:
|
|
error(f"{definition.key} for {address}: {exc}")
|
|
outcome = CheckResult("inconclusive", "checker failed; inspect collection notes")
|
|
execution = "failed"
|
|
store_check(definition.key, outcome, path, backend=definition.backend)
|
|
result.collection[f"check:{definition.key}"] = outcome.status if outcome.status.startswith("skipped-") else execution
|
|
emit("finish", definition.key, address, outcome.status, duration=time.monotonic() - started)
|
|
|
|
if config.use_msf:
|
|
started = time.monotonic()
|
|
path = raw / f"msf-ms17-010-{safe}.txt"
|
|
execution = "success"
|
|
if cancel.is_set():
|
|
outcome = CheckResult("skipped-cancelled", "scan interrupted")
|
|
elif unavailable((445,)):
|
|
outcome = CheckResult("skipped-service-unavailable", "TCP/445 was not reachable")
|
|
elif not tools["msfconsole"]:
|
|
outcome = CheckResult("skipped-missing-tool", "msfconsole is not installed")
|
|
elif ";" in config.username + config.password + config.domain:
|
|
outcome = CheckResult("skipped-unsupported", "credentials contain a Metasploit command separator")
|
|
else:
|
|
try:
|
|
code, text = run("msf-ms17-010", address,
|
|
_msf_command(str(tools["msfconsole"]), address, config), path, config.timeout)
|
|
outcome = classify_msf(address, text, code)
|
|
execution = "success" if code == 0 else "failed"
|
|
except (OSError, ValueError) as exc:
|
|
error(f"msf-ms17-010 for {address}: {exc}")
|
|
outcome = CheckResult("inconclusive", "checker failed; inspect collection notes")
|
|
execution = "failed"
|
|
store_check("ms17-010", outcome, path, backend="metasploit")
|
|
result.collection["msf-ms17-010"] = outcome.status if outcome.status.startswith("skipped-") else execution
|
|
emit("finish", "msf-ms17-010", address, outcome.status, duration=time.monotonic() - started)
|
|
if config.use_rpcdump:
|
|
collector("rpcdump", "rpcdump", rpc_action, (135,))
|
|
if config.use_enum4linux:
|
|
collector("enum4linux", "enum4linux-ng", enum_action, (445,))
|
|
return result
|
|
|
|
pool = ThreadPoolExecutor(max_workers=config.jobs)
|
|
addresses = iter(active_hosts)
|
|
pending = {}
|
|
try:
|
|
for _ in range(min(config.jobs, len(active_hosts))):
|
|
address = next(addresses)
|
|
pending[pool.submit(collect, address)] = address
|
|
while pending:
|
|
done, _ = wait(pending, timeout=0.2, return_when=FIRST_COMPLETED)
|
|
for future in done:
|
|
address = pending.pop(future)
|
|
try:
|
|
hosts[address].merge(future.result())
|
|
except Exception as exc:
|
|
hosts[address].notes.append("host collection failed; remaining results are incomplete")
|
|
hosts[address].collection["host"] = "failed"
|
|
hosts[address].checks = {key: "inconclusive" if status == "pending" else status
|
|
for key, status in hosts[address].checks.items()}
|
|
error(f"collection error for {address}: {exc}")
|
|
if checkpoint:
|
|
checkpoint(hosts)
|
|
next_address = next(addresses, None)
|
|
if next_address is not None:
|
|
pending[pool.submit(collect, next_address)] = next_address
|
|
except KeyboardInterrupt:
|
|
config.cancelled = True
|
|
cancel.set()
|
|
error("Scan interrupted; completed evidence was retained")
|
|
for future in pending:
|
|
future.cancel()
|
|
except BaseException:
|
|
cancel.set()
|
|
for future in pending:
|
|
future.cancel()
|
|
raise
|
|
finally:
|
|
pool.shutdown(wait=True, cancel_futures=True)
|
|
if config.cancelled:
|
|
for future, address in pending.items():
|
|
if not future.cancelled() and future.done():
|
|
try:
|
|
hosts[address].merge(future.result())
|
|
except Exception:
|
|
pass
|
|
for host in hosts.values():
|
|
host.checks = {key: "skipped-cancelled" if status == "pending" else status
|
|
for key, status in host.checks.items()}
|
|
host.collection = {key: "skipped-cancelled" if status == "pending" else status
|
|
for key, status in host.collection.items()}
|
|
if checkpoint:
|
|
checkpoint(hosts)
|
|
incomplete = config.errors or any(coverage(host)[2] for host in hosts.values())
|
|
emit("summary", "scan", status="interrupted" if config.cancelled else ("incomplete" if incomplete else "complete"),
|
|
detail=f"{len(active_hosts)} alive; {len(hosts) - len(active_hosts)} unreachable omitted; {len(config.errors)} collection note(s)")
|
|
return hosts
|
|
|
|
|
|
def analyze_artifacts(nxc: Path | None, rpcdump_dir: Path | None,
|
|
enum4linux_dir: Path | None, errors: list[str] | None = None) -> dict[str, HostEvidence]:
|
|
hosts: dict[str, HostEvidence] = {}
|
|
notes = errors if errors is not None else []
|
|
if nxc:
|
|
hosts.update(parse_nxc(nxc.read_text(encoding="utf-8", errors="replace"), str(nxc)))
|
|
if enum4linux_dir:
|
|
for path in sorted(enum4linux_dir.glob("*.json")):
|
|
try:
|
|
evidence = load_enum4linux(path)
|
|
if evidence.address == "unknown":
|
|
raise ValueError("missing target identity")
|
|
hosts.setdefault(evidence.address, HostEvidence(evidence.address)).merge(evidence)
|
|
except (OSError, ValueError, TypeError) as exc:
|
|
notes.append(f"{path.name}: {exc}")
|
|
if rpcdump_dir:
|
|
for path in sorted(rpcdump_dir.glob("*.txt")):
|
|
match = re.search(r"(?<![\d.])(\d{1,3}(?:\.\d{1,3}){3})(?![\d.])", path.stem)
|
|
if not match:
|
|
notes.append(f"{path.name}: no target IP in filename")
|
|
continue
|
|
try:
|
|
address = str(ipaddress.ip_address(match.group(1)))
|
|
evidence = parse_rpcdump(path.read_text(encoding="utf-8", errors="replace"), address, str(path))
|
|
hosts.setdefault(address, HostEvidence(address)).merge(evidence)
|
|
except (OSError, ValueError) as exc:
|
|
notes.append(f"{path.name}: {exc}")
|
|
return hosts
|