mirror of
https://github.com/mukul975/Anthropic-Cybersecurity-Skills.git
synced 2026-06-14 23:14:55 +03:00
378 lines
14 KiB
Python
378 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""NTLM Relay Detection Agent - Detects NTLM relay via Event 4624 correlation and signing audit."""
|
|
|
|
import json
|
|
import logging
|
|
import argparse
|
|
import csv
|
|
import os
|
|
import sys
|
|
import subprocess
|
|
from collections import defaultdict
|
|
from datetime import datetime, timedelta
|
|
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
|
logger = logging.getLogger(__name__)
|
|
|
|
EVTX_NS = "http://schemas.microsoft.com/win/2004/08/events/event"
|
|
RAPID_AUTH_WINDOW_DEFAULT = 120
|
|
RAPID_AUTH_THRESHOLD_DEFAULT = 3
|
|
SUBPROCESS_TIMEOUT = 30
|
|
|
|
|
|
def parse_security_evtx(evtx_path):
|
|
"""Parse Windows Security EVTX for Event 4624/4625/4776."""
|
|
try:
|
|
from Evtx.Evtx import FileHeader
|
|
from lxml import etree
|
|
except ImportError:
|
|
logger.error("Required packages missing. Install: pip install python-evtx lxml")
|
|
sys.exit(1)
|
|
|
|
events = []
|
|
target_ids = {"4624", "4625", "4776"}
|
|
ns = {"evt": EVTX_NS}
|
|
with open(evtx_path, "rb") as f:
|
|
fh = FileHeader(f)
|
|
for record in fh.records():
|
|
try:
|
|
xml = record.xml()
|
|
root = etree.fromstring(xml.encode("utf-8"))
|
|
eid_elem = root.find(".//evt:System/evt:EventID", ns)
|
|
if eid_elem is None or eid_elem.text not in target_ids:
|
|
continue
|
|
data = {}
|
|
for elem in root.findall(".//evt:EventData/evt:Data", ns):
|
|
data[elem.get("Name", "")] = elem.text or ""
|
|
time_elem = root.find(".//evt:System/evt:TimeCreated", ns)
|
|
data["TimeCreated"] = time_elem.get("SystemTime", "") if time_elem is not None else ""
|
|
comp_elem = root.find(".//evt:System/evt:Computer", ns)
|
|
data["Computer"] = comp_elem.text if comp_elem is not None else ""
|
|
data["EventID"] = eid_elem.text
|
|
events.append(data)
|
|
except Exception:
|
|
continue
|
|
logger.info("Parsed %d security events from %s", len(events), evtx_path)
|
|
return events
|
|
|
|
|
|
def load_inventory(csv_path):
|
|
"""Load hostname-to-IP inventory from CSV (columns: hostname, ip_address)."""
|
|
inventory = {}
|
|
try:
|
|
with open(csv_path, "r", newline="") as f:
|
|
reader = csv.DictReader(f)
|
|
for row in reader:
|
|
hostname = row.get("hostname", "").strip().upper()
|
|
ip = row.get("ip_address", "").strip()
|
|
if hostname and ip:
|
|
inventory[hostname] = ip
|
|
except Exception as e:
|
|
logger.error("Failed to load inventory: %s", e)
|
|
logger.info("Loaded %d hosts from inventory", len(inventory))
|
|
return inventory
|
|
|
|
|
|
def detect_ip_hostname_mismatch(events, inventory):
|
|
"""Detect NTLM relay via IP-hostname mismatch in Event 4624 LogonType 3."""
|
|
findings = []
|
|
for ev in events:
|
|
if ev.get("EventID") != "4624" or ev.get("LogonType") != "3":
|
|
continue
|
|
if ev.get("AuthenticationPackageName") != "NTLM":
|
|
continue
|
|
user = ev.get("TargetUserName", "")
|
|
if user.endswith("$") or user in ("ANONYMOUS LOGON", "-", ""):
|
|
continue
|
|
source_ip = ev.get("IpAddress", "")
|
|
if source_ip in ("-", "::1", "127.0.0.1", ""):
|
|
continue
|
|
workstation = ev.get("WorkstationName", "").strip().upper()
|
|
if workstation in inventory:
|
|
expected = inventory[workstation]
|
|
if source_ip != expected:
|
|
findings.append({
|
|
"detection": "IP-Hostname Mismatch (NTLM Relay Indicator)",
|
|
"severity": "CRITICAL",
|
|
"mitre": "T1557.001",
|
|
"timestamp": ev.get("TimeCreated"),
|
|
"target_host": ev.get("Computer"),
|
|
"target_user": user,
|
|
"workstation": workstation,
|
|
"actual_ip": source_ip,
|
|
"expected_ip": expected,
|
|
"lm_package": ev.get("LmPackageName"),
|
|
})
|
|
logger.info("IP-hostname mismatch findings: %d", len(findings))
|
|
return findings
|
|
|
|
|
|
def detect_rapid_auth(events, window=RAPID_AUTH_WINDOW_DEFAULT, threshold=RAPID_AUTH_THRESHOLD_DEFAULT):
|
|
"""Detect rapid NTLM authentication to multiple targets (relay spraying)."""
|
|
findings = []
|
|
auth_groups = defaultdict(list)
|
|
for ev in events:
|
|
if ev.get("EventID") != "4624" or ev.get("LogonType") != "3":
|
|
continue
|
|
if ev.get("AuthenticationPackageName") != "NTLM":
|
|
continue
|
|
user = ev.get("TargetUserName", "")
|
|
ip = ev.get("IpAddress", "")
|
|
if user.endswith("$") or user in ("ANONYMOUS LOGON", "-", ""):
|
|
continue
|
|
if ip in ("-", "::1", "127.0.0.1", ""):
|
|
continue
|
|
try:
|
|
ts = datetime.fromisoformat(ev["TimeCreated"].replace("Z", "+00:00"))
|
|
except (ValueError, KeyError):
|
|
continue
|
|
auth_groups[(ip, user)].append({"ts": ts, "target": ev.get("Computer", "")})
|
|
|
|
for (ip, user), auths in auth_groups.items():
|
|
auths.sort(key=lambda x: x["ts"])
|
|
for i in range(len(auths)):
|
|
start = auths[i]["ts"]
|
|
end = start + timedelta(seconds=window)
|
|
targets = set()
|
|
for j in range(i, len(auths)):
|
|
if auths[j]["ts"] <= end:
|
|
targets.add(auths[j]["target"])
|
|
else:
|
|
break
|
|
if len(targets) >= threshold:
|
|
findings.append({
|
|
"detection": "Rapid Multi-Host NTLM Auth (Relay Spraying)",
|
|
"severity": "HIGH",
|
|
"mitre": "T1557.001",
|
|
"timestamp": start.isoformat(),
|
|
"source_ip": ip,
|
|
"target_user": user,
|
|
"unique_targets": len(targets),
|
|
"targets": sorted(targets),
|
|
"window_seconds": window,
|
|
})
|
|
break
|
|
logger.info("Rapid auth findings: %d", len(findings))
|
|
return findings
|
|
|
|
|
|
def detect_ntlmv1_downgrade(events):
|
|
"""Detect NTLMv1 authentication events indicating downgrade attack."""
|
|
findings = []
|
|
v1_by_user = defaultdict(list)
|
|
for ev in events:
|
|
if ev.get("EventID") != "4624" or ev.get("LogonType") != "3":
|
|
continue
|
|
lm = ev.get("LmPackageName", "")
|
|
if "NTLM V1" not in lm:
|
|
continue
|
|
user = ev.get("TargetUserName", "")
|
|
if user.endswith("$") or user in ("ANONYMOUS LOGON", "-", ""):
|
|
continue
|
|
v1_by_user[user].append({
|
|
"ts": ev.get("TimeCreated"),
|
|
"target": ev.get("Computer"),
|
|
"ip": ev.get("IpAddress"),
|
|
})
|
|
|
|
for user, auths in v1_by_user.items():
|
|
findings.append({
|
|
"detection": "NTLMv1 Downgrade Detected",
|
|
"severity": "HIGH",
|
|
"mitre": "T1557.001",
|
|
"timestamp": auths[0]["ts"],
|
|
"target_user": user,
|
|
"ntlmv1_count": len(auths),
|
|
"source_ips": sorted(set(a["ip"] for a in auths)),
|
|
"targets": sorted(set(a["target"] for a in auths)),
|
|
})
|
|
logger.info("NTLMv1 downgrade findings: %d", len(findings))
|
|
return findings
|
|
|
|
|
|
def detect_machine_relay(events):
|
|
"""Detect machine account NTLM relay (PetitPotam, DFSCoerce, PrinterBug)."""
|
|
findings = []
|
|
machine_auths = defaultdict(list)
|
|
for ev in events:
|
|
if ev.get("EventID") != "4624" or ev.get("LogonType") != "3":
|
|
continue
|
|
if ev.get("AuthenticationPackageName") != "NTLM":
|
|
continue
|
|
user = ev.get("TargetUserName", "")
|
|
if not user.endswith("$"):
|
|
continue
|
|
ip = ev.get("IpAddress", "")
|
|
if ip in ("-", "::1", "127.0.0.1", ""):
|
|
continue
|
|
machine_auths[user].append({
|
|
"ts": ev.get("TimeCreated"),
|
|
"target": ev.get("Computer"),
|
|
"ip": ip,
|
|
})
|
|
|
|
for machine, auths in machine_auths.items():
|
|
ips = set(a["ip"] for a in auths)
|
|
if len(ips) > 1:
|
|
findings.append({
|
|
"detection": "Machine Account Relay (Coercion + NTLM Relay)",
|
|
"severity": "CRITICAL",
|
|
"mitre": "T1557.001",
|
|
"timestamp": auths[0]["ts"],
|
|
"machine_account": machine,
|
|
"source_ips": sorted(ips),
|
|
"targets": sorted(set(a["target"] for a in auths)),
|
|
"auth_count": len(auths),
|
|
})
|
|
logger.info("Machine account relay findings: %d", len(findings))
|
|
return findings
|
|
|
|
|
|
def audit_smb_signing_local():
|
|
"""Audit local SMB signing configuration (Windows only)."""
|
|
if sys.platform != "win32":
|
|
logger.info("SMB signing audit only available on Windows")
|
|
return {}
|
|
|
|
audit = {}
|
|
checks = {
|
|
"SMB_Server_RequireSign": (
|
|
r"HKLM\SYSTEM\CurrentControlSet\Services\LanManServer\Parameters",
|
|
"RequireSecuritySignature"
|
|
),
|
|
"SMB_Client_RequireSign": (
|
|
r"HKLM\SYSTEM\CurrentControlSet\Services\LanManWorkstation\Parameters",
|
|
"RequireSecuritySignature"
|
|
),
|
|
"LmCompatibilityLevel": (
|
|
r"HKLM\SYSTEM\CurrentControlSet\Control\Lsa",
|
|
"LmCompatibilityLevel"
|
|
),
|
|
"LLMNR_Disabled": (
|
|
r"HKLM\SOFTWARE\Policies\Microsoft\Windows NT\DNSClient",
|
|
"EnableMulticast"
|
|
),
|
|
}
|
|
|
|
for label, (key, value_name) in checks.items():
|
|
try:
|
|
result = subprocess.run(
|
|
["reg", "query", key, "/v", value_name],
|
|
capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT
|
|
)
|
|
if result.returncode == 0:
|
|
for line in result.stdout.splitlines():
|
|
if value_name in line:
|
|
parts = line.strip().split()
|
|
audit[label] = parts[-1] if parts else "UNKNOWN"
|
|
break
|
|
else:
|
|
audit[label] = "NOT_CONFIGURED"
|
|
except subprocess.TimeoutExpired:
|
|
audit[label] = "TIMEOUT"
|
|
except Exception as e:
|
|
audit[label] = f"ERROR: {e}"
|
|
|
|
# Evaluate risk
|
|
smb_server = audit.get("SMB_Server_RequireSign", "")
|
|
audit["SMB_Relay_Vulnerable"] = "YES" if smb_server != "0x1" else "NO"
|
|
|
|
lm_level = audit.get("LmCompatibilityLevel", "")
|
|
try:
|
|
lm_int = int(lm_level, 0)
|
|
audit["NTLMv1_Vulnerable"] = "YES" if lm_int < 3 else "NO"
|
|
except (ValueError, TypeError):
|
|
audit["NTLMv1_Vulnerable"] = "UNKNOWN"
|
|
|
|
llmnr = audit.get("LLMNR_Disabled", "")
|
|
audit["Responder_Vulnerable"] = "NO" if llmnr == "0x0" else "YES"
|
|
|
|
return audit
|
|
|
|
|
|
def generate_report(all_findings, smb_audit, output_path):
|
|
"""Generate JSON detection report."""
|
|
report = {
|
|
"scan_timestamp": datetime.utcnow().isoformat() + "Z",
|
|
"mitre_technique": "T1557.001",
|
|
"summary": {
|
|
"total_findings": len(all_findings),
|
|
"critical": len([f for f in all_findings if f.get("severity") == "CRITICAL"]),
|
|
"high": len([f for f in all_findings if f.get("severity") == "HIGH"]),
|
|
"medium": len([f for f in all_findings if f.get("severity") == "MEDIUM"]),
|
|
},
|
|
"findings": all_findings,
|
|
"smb_signing_audit": smb_audit,
|
|
}
|
|
|
|
with open(output_path, "w") as f:
|
|
json.dump(report, f, indent=2, default=str)
|
|
logger.info("Report saved to %s", output_path)
|
|
|
|
s = report["summary"]
|
|
print(f"\nNTLM RELAY DETECTION REPORT")
|
|
print(f" Total findings: {s['total_findings']}")
|
|
print(f" Critical: {s['critical']}, High: {s['high']}, Medium: {s['medium']}")
|
|
if s["critical"] > 0:
|
|
print(" [!!!] CRITICAL: IP-hostname mismatch or machine account relay detected")
|
|
if smb_audit.get("SMB_Relay_Vulnerable") == "YES":
|
|
print(" [!] WARNING: SMB signing NOT enforced on this host")
|
|
if smb_audit.get("Responder_Vulnerable") == "YES":
|
|
print(" [!] WARNING: LLMNR enabled - vulnerable to Responder poisoning")
|
|
return report
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="NTLM Relay Detection Agent (T1557.001)"
|
|
)
|
|
parser.add_argument("--evtx", required=True, help="Path to Windows Security .evtx file")
|
|
parser.add_argument("--inventory", help="CSV file with hostname,ip_address columns for mismatch detection")
|
|
parser.add_argument("--output", "-o", default="ntlm_relay_report.json",
|
|
help="Output JSON report path (default: ntlm_relay_report.json)")
|
|
parser.add_argument("--rapid-window", type=int, default=RAPID_AUTH_WINDOW_DEFAULT,
|
|
help=f"Rapid auth detection window in seconds (default: {RAPID_AUTH_WINDOW_DEFAULT})")
|
|
parser.add_argument("--rapid-threshold", type=int, default=RAPID_AUTH_THRESHOLD_DEFAULT,
|
|
help=f"Min unique targets for rapid auth alert (default: {RAPID_AUTH_THRESHOLD_DEFAULT})")
|
|
parser.add_argument("--audit-signing", action="store_true",
|
|
help="Audit local SMB/NTLM signing configuration (Windows only)")
|
|
parser.add_argument("--verbose", "-v", action="store_true", help="Enable debug logging")
|
|
args = parser.parse_args()
|
|
|
|
if args.verbose:
|
|
logging.getLogger().setLevel(logging.DEBUG)
|
|
|
|
if not os.path.isfile(args.evtx):
|
|
logger.error("EVTX file not found: %s", args.evtx)
|
|
sys.exit(1)
|
|
|
|
inventory = {}
|
|
if args.inventory:
|
|
if os.path.isfile(args.inventory):
|
|
inventory = load_inventory(args.inventory)
|
|
else:
|
|
logger.warning("Inventory file not found: %s", args.inventory)
|
|
|
|
logger.info("Parsing security events from: %s", args.evtx)
|
|
events = parse_security_evtx(args.evtx)
|
|
|
|
mismatch = detect_ip_hostname_mismatch(events, inventory) if inventory else []
|
|
rapid = detect_rapid_auth(events, args.rapid_window, args.rapid_threshold)
|
|
downgrade = detect_ntlmv1_downgrade(events)
|
|
machine = detect_machine_relay(events)
|
|
|
|
if not inventory:
|
|
logger.warning("No inventory provided (--inventory). IP-hostname mismatch detection disabled.")
|
|
|
|
all_findings = mismatch + machine + rapid + downgrade
|
|
all_findings.sort(key=lambda x: {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}.get(
|
|
x.get("severity", "LOW"), 4))
|
|
|
|
smb_audit = audit_smb_signing_local() if args.audit_signing else {}
|
|
|
|
generate_report(all_findings, smb_audit, args.output)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|