mirror of
https://github.com/mukul975/Anthropic-Cybersecurity-Skills.git
synced 2026-07-17 05:05:16 +03:00
Add 30 new production-grade cybersecurity skills: AI security, supply chain, firmware, cloud-native, compliance, deception, crypto, threat hunting, purple team, OT, privacy
This commit is contained in:
@@ -0,0 +1,377 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,353 @@
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Audits SMB signing, LDAP signing, and NTLM configuration across Active Directory.
|
||||
|
||||
.DESCRIPTION
|
||||
This script performs a comprehensive audit of NTLM relay attack surface by checking:
|
||||
- SMB signing enforcement on all domain-joined Windows hosts
|
||||
- LDAP signing and channel binding on domain controllers
|
||||
- LmCompatibilityLevel (NTLMv1 vs NTLMv2 enforcement)
|
||||
- LLMNR and NBT-NS configuration
|
||||
- NTLM restriction policies
|
||||
Outputs results to CSV and provides a risk summary.
|
||||
|
||||
.PARAMETER OutputPath
|
||||
Directory to save audit results. Defaults to current directory.
|
||||
|
||||
.PARAMETER DomainControllerOnly
|
||||
Only audit domain controllers (faster for large environments).
|
||||
|
||||
.PARAMETER SkipConnectivity
|
||||
Skip remote connectivity checks (only check local configuration).
|
||||
|
||||
.EXAMPLE
|
||||
.\audit_smb_signing.ps1 -OutputPath C:\AuditResults
|
||||
.\audit_smb_signing.ps1 -DomainControllerOnly
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter()]
|
||||
[string]$OutputPath = ".",
|
||||
|
||||
[Parameter()]
|
||||
[switch]$DomainControllerOnly,
|
||||
|
||||
[Parameter()]
|
||||
[switch]$SkipConnectivity
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Continue"
|
||||
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
|
||||
|
||||
Write-Host @"
|
||||
==============================================================================
|
||||
NTLM Relay Attack Surface Audit
|
||||
Checks SMB Signing, LDAP Signing, NTLM Configuration
|
||||
MITRE ATT&CK: T1557.001
|
||||
Run Time: $(Get-Date -Format "yyyy-MM-dd HH:mm:ss")
|
||||
==============================================================================
|
||||
"@
|
||||
|
||||
# ============================================================================
|
||||
# Section 1: SMB Signing Audit
|
||||
# ============================================================================
|
||||
Write-Host "`n[*] Section 1: SMB Signing Audit" -ForegroundColor Cyan
|
||||
|
||||
$smbResults = @()
|
||||
|
||||
if ($DomainControllerOnly) {
|
||||
Write-Host "[*] Scanning domain controllers only..."
|
||||
$targets = Get-ADDomainController -Filter * | Select-Object -ExpandProperty HostName
|
||||
} else {
|
||||
Write-Host "[*] Scanning all domain computers..."
|
||||
$targets = Get-ADComputer -Filter { Enabled -eq $true -and OperatingSystem -like "*Windows*" } |
|
||||
Select-Object -ExpandProperty DNSHostName
|
||||
}
|
||||
|
||||
Write-Host "[*] Found $($targets.Count) targets to audit"
|
||||
|
||||
$counter = 0
|
||||
foreach ($target in $targets) {
|
||||
$counter++
|
||||
Write-Progress -Activity "Auditing SMB Signing" -Status "$target ($counter/$($targets.Count))" `
|
||||
-PercentComplete (($counter / $targets.Count) * 100)
|
||||
|
||||
$result = [PSCustomObject]@{
|
||||
Hostname = $target
|
||||
Reachable = $false
|
||||
SMBServerSignRequired = "Unknown"
|
||||
SMBServerSignEnabled = "Unknown"
|
||||
SMBClientSignRequired = "Unknown"
|
||||
SMBClientSignEnabled = "Unknown"
|
||||
RelayVulnerable = "Unknown"
|
||||
ErrorDetail = ""
|
||||
}
|
||||
|
||||
if (-not $SkipConnectivity) {
|
||||
try {
|
||||
$session = New-CimSession -ComputerName $target -OperationTimeoutSec 10 -ErrorAction Stop
|
||||
$result.Reachable = $true
|
||||
|
||||
$serverConfig = Get-SmbServerConfiguration -CimSession $session -ErrorAction Stop
|
||||
$result.SMBServerSignRequired = $serverConfig.RequireSecuritySignature
|
||||
$result.SMBServerSignEnabled = $serverConfig.EnableSecuritySignature
|
||||
|
||||
try {
|
||||
$clientConfig = Get-SmbClientConfiguration -CimSession $session -ErrorAction Stop
|
||||
$result.SMBClientSignRequired = $clientConfig.RequireSecuritySignature
|
||||
$result.SMBClientSignEnabled = $clientConfig.EnableSecuritySignature
|
||||
} catch {
|
||||
$result.SMBClientSignRequired = "Error"
|
||||
$result.SMBClientSignEnabled = "Error"
|
||||
}
|
||||
|
||||
# Determine relay vulnerability
|
||||
if ($serverConfig.RequireSecuritySignature -eq $true) {
|
||||
$result.RelayVulnerable = "No - SMB Signing Required"
|
||||
} elseif ($serverConfig.EnableSecuritySignature -eq $true) {
|
||||
$result.RelayVulnerable = "Partial - Signing Enabled but Not Required"
|
||||
} else {
|
||||
$result.RelayVulnerable = "YES - SMB Signing Not Enforced"
|
||||
}
|
||||
|
||||
Remove-CimSession $session
|
||||
} catch {
|
||||
$result.ErrorDetail = $_.Exception.Message
|
||||
$result.RelayVulnerable = "Unknown - Connection Failed"
|
||||
}
|
||||
}
|
||||
|
||||
$smbResults += $result
|
||||
}
|
||||
|
||||
Write-Progress -Activity "Auditing SMB Signing" -Completed
|
||||
|
||||
$smbCsvPath = Join-Path $OutputPath "smb_signing_audit_$timestamp.csv"
|
||||
$smbResults | Export-Csv -Path $smbCsvPath -NoTypeInformation
|
||||
Write-Host "[*] SMB signing results saved to: $smbCsvPath"
|
||||
|
||||
$vulnerable = @($smbResults | Where-Object { $_.RelayVulnerable -like "YES*" })
|
||||
$partial = @($smbResults | Where-Object { $_.RelayVulnerable -like "Partial*" })
|
||||
$secure = @($smbResults | Where-Object { $_.RelayVulnerable -like "No*" })
|
||||
|
||||
Write-Host "`n SMB Signing Summary:"
|
||||
Write-Host " Fully Protected (Signing Required): $($secure.Count)" -ForegroundColor Green
|
||||
Write-Host " Partially Protected (Signing Enabled): $($partial.Count)" -ForegroundColor Yellow
|
||||
Write-Host " VULNERABLE (Signing Not Enforced): $($vulnerable.Count)" -ForegroundColor Red
|
||||
|
||||
if ($vulnerable.Count -gt 0) {
|
||||
Write-Host "`n [!] Vulnerable hosts:" -ForegroundColor Red
|
||||
$vulnerable | Select-Object -First 10 | ForEach-Object {
|
||||
Write-Host " $($_.Hostname)" -ForegroundColor Red
|
||||
}
|
||||
if ($vulnerable.Count -gt 10) {
|
||||
Write-Host " ... and $($vulnerable.Count - 10) more (see CSV)" -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Section 2: LDAP Signing Audit (Domain Controllers)
|
||||
# ============================================================================
|
||||
Write-Host "`n[*] Section 2: LDAP Signing Audit (Domain Controllers)" -ForegroundColor Cyan
|
||||
|
||||
$ldapResults = @()
|
||||
$dcs = Get-ADDomainController -Filter * | Select-Object HostName, IPv4Address, OperatingSystem
|
||||
|
||||
foreach ($dc in $dcs) {
|
||||
$ldapResult = [PSCustomObject]@{
|
||||
DCHostname = $dc.HostName
|
||||
IPAddress = $dc.IPv4Address
|
||||
OS = $dc.OperatingSystem
|
||||
LDAPSigning = "Unknown"
|
||||
ChannelBinding = "Unknown"
|
||||
RelayToLDAP = "Unknown"
|
||||
ErrorDetail = ""
|
||||
}
|
||||
|
||||
try {
|
||||
$ldapSigning = Invoke-Command -ComputerName $dc.HostName -ScriptBlock {
|
||||
$regPath = "HKLM:\SYSTEM\CurrentControlSet\Services\NTDS\Parameters"
|
||||
$signing = (Get-ItemProperty -Path $regPath -Name "LDAPServerIntegrity" -ErrorAction SilentlyContinue).LDAPServerIntegrity
|
||||
$binding = (Get-ItemProperty -Path $regPath -Name "LdapEnforceChannelBinding" -ErrorAction SilentlyContinue).LdapEnforceChannelBinding
|
||||
return @{ Signing = $signing; Binding = $binding }
|
||||
} -ErrorAction Stop
|
||||
|
||||
$ldapResult.LDAPSigning = switch ($ldapSigning.Signing) {
|
||||
0 { "None (VULNERABLE)" }
|
||||
1 { "Negotiate (Default - VULNERABLE to relay)" }
|
||||
2 { "Required (Secure)" }
|
||||
default { "Not Configured (defaults to Negotiate - VULNERABLE)" }
|
||||
}
|
||||
|
||||
$ldapResult.ChannelBinding = switch ($ldapSigning.Binding) {
|
||||
0 { "Disabled (VULNERABLE)" }
|
||||
1 { "When Supported" }
|
||||
2 { "Always Required (Secure)" }
|
||||
default { "Not Configured (VULNERABLE)" }
|
||||
}
|
||||
|
||||
if ($ldapSigning.Signing -eq 2 -and $ldapSigning.Binding -eq 2) {
|
||||
$ldapResult.RelayToLDAP = "No - Signing and Channel Binding Required"
|
||||
} elseif ($ldapSigning.Signing -eq 2) {
|
||||
$ldapResult.RelayToLDAP = "Partial - Signing Required but Channel Binding Not Enforced"
|
||||
} else {
|
||||
$ldapResult.RelayToLDAP = "YES - LDAP Relay Possible"
|
||||
}
|
||||
} catch {
|
||||
$ldapResult.ErrorDetail = $_.Exception.Message
|
||||
}
|
||||
|
||||
$ldapResults += $ldapResult
|
||||
}
|
||||
|
||||
$ldapCsvPath = Join-Path $OutputPath "ldap_signing_audit_$timestamp.csv"
|
||||
$ldapResults | Export-Csv -Path $ldapCsvPath -NoTypeInformation
|
||||
Write-Host "[*] LDAP signing results saved to: $ldapCsvPath"
|
||||
|
||||
foreach ($r in $ldapResults) {
|
||||
$color = if ($r.RelayToLDAP -like "YES*") { "Red" } elseif ($r.RelayToLDAP -like "Partial*") { "Yellow" } else { "Green" }
|
||||
Write-Host " $($r.DCHostname): LDAP=$($r.LDAPSigning), ChannelBinding=$($r.ChannelBinding)" -ForegroundColor $color
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Section 3: NTLM Configuration Audit
|
||||
# ============================================================================
|
||||
Write-Host "`n[*] Section 3: NTLM Configuration Audit" -ForegroundColor Cyan
|
||||
|
||||
$ntlmResults = @()
|
||||
|
||||
foreach ($target in $targets | Select-Object -First 50) {
|
||||
$ntlmResult = [PSCustomObject]@{
|
||||
Hostname = $target
|
||||
LmCompatLevel = "Unknown"
|
||||
LmCompatDesc = "Unknown"
|
||||
NTLMRestriction = "Unknown"
|
||||
LLMNREnabled = "Unknown"
|
||||
NBTNSEnabled = "Unknown"
|
||||
NTLMv1Vulnerable = "Unknown"
|
||||
ErrorDetail = ""
|
||||
}
|
||||
|
||||
try {
|
||||
$config = Invoke-Command -ComputerName $target -ScriptBlock {
|
||||
$lmLevel = (Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\Lsa" `
|
||||
-Name "LmCompatibilityLevel" -ErrorAction SilentlyContinue).LmCompatibilityLevel
|
||||
|
||||
$llmnr = (Get-ItemProperty -Path "HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\DNSClient" `
|
||||
-Name "EnableMulticast" -ErrorAction SilentlyContinue).EnableMulticast
|
||||
|
||||
$ntlmRestrict = (Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0" `
|
||||
-Name "RestrictReceivingNTLMTraffic" -ErrorAction SilentlyContinue).RestrictReceivingNTLMTraffic
|
||||
|
||||
return @{
|
||||
LmLevel = $lmLevel
|
||||
LLMNR = $llmnr
|
||||
NTLMRestrict = $ntlmRestrict
|
||||
}
|
||||
} -ErrorAction Stop
|
||||
|
||||
$ntlmResult.LmCompatLevel = $config.LmLevel
|
||||
$ntlmResult.LmCompatDesc = switch ($config.LmLevel) {
|
||||
0 { "Send LM & NTLM (CRITICAL - NTLMv1 active)" }
|
||||
1 { "Send LM & NTLM, use NTLMv2 session if negotiated" }
|
||||
2 { "Send NTLM only (NTLMv1)" }
|
||||
3 { "Send NTLMv2 only (Recommended minimum)" }
|
||||
4 { "Send NTLMv2 only, refuse LM" }
|
||||
5 { "Send NTLMv2 only, refuse LM & NTLM (Most Secure)" }
|
||||
default { "Not configured (defaults to 3)" }
|
||||
}
|
||||
|
||||
$ntlmResult.NTLMv1Vulnerable = if ($config.LmLevel -lt 3 -and $null -ne $config.LmLevel) {
|
||||
"YES - NTLMv1 may be used"
|
||||
} else {
|
||||
"No - NTLMv2 enforced"
|
||||
}
|
||||
|
||||
$ntlmResult.LLMNREnabled = if ($config.LLMNR -eq 0) { "Disabled (Secure)" } else { "Enabled (VULNERABLE to Responder)" }
|
||||
|
||||
$ntlmResult.NTLMRestriction = switch ($config.NTLMRestrict) {
|
||||
0 { "Allow all" }
|
||||
1 { "Deny all domain accounts" }
|
||||
2 { "Deny all accounts" }
|
||||
default { "Not configured (Allow all)" }
|
||||
}
|
||||
} catch {
|
||||
$ntlmResult.ErrorDetail = $_.Exception.Message
|
||||
}
|
||||
|
||||
$ntlmResults += $ntlmResult
|
||||
}
|
||||
|
||||
$ntlmCsvPath = Join-Path $OutputPath "ntlm_config_audit_$timestamp.csv"
|
||||
$ntlmResults | Export-Csv -Path $ntlmCsvPath -NoTypeInformation
|
||||
Write-Host "[*] NTLM configuration results saved to: $ntlmCsvPath"
|
||||
|
||||
$ntlmv1Vuln = @($ntlmResults | Where-Object { $_.NTLMv1Vulnerable -like "YES*" })
|
||||
$llmnrVuln = @($ntlmResults | Where-Object { $_.LLMNREnabled -like "Enabled*" })
|
||||
|
||||
Write-Host "`n NTLM Configuration Summary:"
|
||||
Write-Host " Hosts vulnerable to NTLMv1 downgrade: $($ntlmv1Vuln.Count)" -ForegroundColor $(if ($ntlmv1Vuln.Count -gt 0) { "Red" } else { "Green" })
|
||||
Write-Host " Hosts with LLMNR enabled (Responder target): $($llmnrVuln.Count)" -ForegroundColor $(if ($llmnrVuln.Count -gt 0) { "Red" } else { "Green" })
|
||||
|
||||
# ============================================================================
|
||||
# Section 4: Overall Risk Assessment
|
||||
# ============================================================================
|
||||
Write-Host "`n" + ("=" * 78) -ForegroundColor Cyan
|
||||
Write-Host " OVERALL NTLM RELAY RISK ASSESSMENT" -ForegroundColor Cyan
|
||||
Write-Host ("=" * 78) -ForegroundColor Cyan
|
||||
|
||||
$riskScore = 0
|
||||
$recommendations = @()
|
||||
|
||||
if ($vulnerable.Count -gt 0) {
|
||||
$riskScore += 30
|
||||
$recommendations += "CRITICAL: Enforce SMB signing on $($vulnerable.Count) hosts via GPO"
|
||||
}
|
||||
|
||||
$ldapVuln = @($ldapResults | Where-Object { $_.RelayToLDAP -like "YES*" })
|
||||
if ($ldapVuln.Count -gt 0) {
|
||||
$riskScore += 30
|
||||
$recommendations += "CRITICAL: Enforce LDAP signing on $($ldapVuln.Count) domain controllers"
|
||||
}
|
||||
|
||||
if ($ntlmv1Vuln.Count -gt 0) {
|
||||
$riskScore += 20
|
||||
$recommendations += "HIGH: Set LmCompatibilityLevel >= 3 on $($ntlmv1Vuln.Count) hosts to prevent NTLMv1"
|
||||
}
|
||||
|
||||
if ($llmnrVuln.Count -gt 0) {
|
||||
$riskScore += 20
|
||||
$recommendations += "HIGH: Disable LLMNR via GPO on $($llmnrVuln.Count) hosts to prevent Responder poisoning"
|
||||
}
|
||||
|
||||
$riskLevel = switch {
|
||||
($riskScore -ge 60) { "CRITICAL" }
|
||||
($riskScore -ge 40) { "HIGH" }
|
||||
($riskScore -ge 20) { "MEDIUM" }
|
||||
default { "LOW" }
|
||||
}
|
||||
|
||||
$riskColor = switch ($riskLevel) {
|
||||
"CRITICAL" { "Red" }
|
||||
"HIGH" { "Red" }
|
||||
"MEDIUM" { "Yellow" }
|
||||
"LOW" { "Green" }
|
||||
}
|
||||
|
||||
Write-Host "`n Risk Level: $riskLevel (Score: $riskScore/100)" -ForegroundColor $riskColor
|
||||
Write-Host "`n Recommendations:" -ForegroundColor White
|
||||
foreach ($rec in $recommendations) {
|
||||
Write-Host " - $rec" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
if ($recommendations.Count -eq 0) {
|
||||
Write-Host " - No critical issues found. Continue monitoring NTLM usage via Event 8004." -ForegroundColor Green
|
||||
}
|
||||
|
||||
Write-Host "`n Output Files:"
|
||||
Write-Host " - $smbCsvPath"
|
||||
Write-Host " - $ldapCsvPath"
|
||||
Write-Host " - $ntlmCsvPath"
|
||||
Write-Host "`n" + ("=" * 78) -ForegroundColor Cyan
|
||||
@@ -0,0 +1,632 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
NTLM Relay Detection via Event Correlation Script
|
||||
Parses Windows Security event logs to detect NTLM relay attacks through
|
||||
IP-hostname mismatch analysis, NTLMv1 downgrade detection, rapid multi-host
|
||||
authentication patterns, and machine account relay indicators.
|
||||
|
||||
MITRE ATT&CK: T1557.001 (LLMNR/NBT-NS Poisoning and SMB Relay)
|
||||
|
||||
Usage:
|
||||
python detect_ntlm_relay.py --evtx <security.evtx>
|
||||
python detect_ntlm_relay.py --evtx <security.evtx> --inventory hosts.csv
|
||||
python detect_ntlm_relay.py --evtx <security.evtx> --json --output results.json
|
||||
|
||||
Requirements:
|
||||
pip install python-evtx lxml
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from collections import defaultdict
|
||||
|
||||
try:
|
||||
import Evtx.Evtx as evtx
|
||||
from lxml import etree
|
||||
except ImportError:
|
||||
print("[!] Required packages not found. Install with: pip install python-evtx lxml")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
EVENT_NS = "http://schemas.microsoft.com/win/2004/08/events/event"
|
||||
|
||||
# Default time window for rapid authentication detection (seconds)
|
||||
RAPID_AUTH_WINDOW = 120
|
||||
# Minimum number of unique targets to flag rapid authentication
|
||||
RAPID_AUTH_THRESHOLD = 3
|
||||
|
||||
|
||||
def parse_security_event(record_xml):
|
||||
"""Parse a Windows Security event record XML into a dictionary."""
|
||||
try:
|
||||
root = etree.fromstring(record_xml)
|
||||
except etree.XMLSyntaxError:
|
||||
return None
|
||||
|
||||
ns = {"e": EVENT_NS}
|
||||
event = {}
|
||||
|
||||
system = root.find(".//e:System", ns)
|
||||
if system is not None:
|
||||
event_id_elem = system.find("e:EventID", ns)
|
||||
event["EventID"] = int(event_id_elem.text) if event_id_elem is not None else 0
|
||||
time_elem = system.find("e:TimeCreated", ns)
|
||||
if time_elem is not None:
|
||||
event["TimeCreated"] = time_elem.get("SystemTime", "")
|
||||
computer_elem = system.find("e:Computer", ns)
|
||||
event["Computer"] = computer_elem.text if computer_elem is not None else ""
|
||||
|
||||
event_data = root.find(".//e:EventData", ns)
|
||||
if event_data is not None:
|
||||
for data in event_data.findall("e:Data", ns):
|
||||
name = data.get("Name", "")
|
||||
value = data.text or ""
|
||||
event[name] = value
|
||||
|
||||
return event
|
||||
|
||||
|
||||
def load_host_inventory(csv_path):
|
||||
"""
|
||||
Load hostname-to-IP mapping from CSV file.
|
||||
Expected 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:
|
||||
print(f"[!] Error loading inventory from {csv_path}: {e}")
|
||||
return inventory
|
||||
|
||||
|
||||
def is_internal_ip(ip):
|
||||
"""Check if an IP address is in RFC1918 private ranges."""
|
||||
if not ip or ip in ("-", "::1", "127.0.0.1"):
|
||||
return False
|
||||
parts = ip.split(".")
|
||||
if len(parts) != 4:
|
||||
return False
|
||||
try:
|
||||
first = int(parts[0])
|
||||
second = int(parts[1])
|
||||
if first == 10:
|
||||
return True
|
||||
if first == 172 and 16 <= second <= 31:
|
||||
return True
|
||||
if first == 192 and second == 168:
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def detect_ip_hostname_mismatch(events, inventory):
|
||||
"""
|
||||
Detect NTLM relay by finding Event 4624 LogonType 3 entries where
|
||||
the WorkstationName does not match the expected IP for that hostname.
|
||||
"""
|
||||
findings = []
|
||||
|
||||
for event in events:
|
||||
if event.get("EventID") != 4624:
|
||||
continue
|
||||
if event.get("LogonType") != "3":
|
||||
continue
|
||||
if event.get("AuthenticationPackageName") != "NTLM":
|
||||
continue
|
||||
|
||||
target_user = event.get("TargetUserName", "")
|
||||
workstation = event.get("WorkstationName", "").strip().upper()
|
||||
source_ip = event.get("IpAddress", "")
|
||||
computer = event.get("Computer", "")
|
||||
timestamp = event.get("TimeCreated", "")
|
||||
lm_package = event.get("LmPackageName", "")
|
||||
|
||||
# Skip machine accounts and anonymous logons
|
||||
if target_user.endswith("$") or target_user in ("ANONYMOUS LOGON", "-", ""):
|
||||
continue
|
||||
if source_ip in ("-", "::1", "127.0.0.1", ""):
|
||||
continue
|
||||
|
||||
# Check against inventory
|
||||
if workstation in inventory:
|
||||
expected_ip = inventory[workstation]
|
||||
if source_ip != expected_ip:
|
||||
findings.append({
|
||||
"timestamp": timestamp,
|
||||
"detection_type": "IP-Hostname Mismatch (NTLM Relay Indicator)",
|
||||
"severity": "CRITICAL",
|
||||
"mitre": "T1557.001",
|
||||
"target_host": computer,
|
||||
"target_user": target_user,
|
||||
"workstation_name": workstation,
|
||||
"actual_source_ip": source_ip,
|
||||
"expected_source_ip": expected_ip,
|
||||
"lm_package": lm_package,
|
||||
"explanation": (
|
||||
f"Event 4624 shows {target_user} authenticating from "
|
||||
f"workstation '{workstation}' but source IP is {source_ip} "
|
||||
f"(expected {expected_ip}). This IP mismatch is a primary "
|
||||
f"indicator of NTLM relay."
|
||||
),
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def detect_rapid_multi_host_auth(events, window_seconds=RAPID_AUTH_WINDOW,
|
||||
threshold=RAPID_AUTH_THRESHOLD):
|
||||
"""
|
||||
Detect rapid NTLM authentication to multiple targets from the same source,
|
||||
indicating relay spraying or credential relay.
|
||||
"""
|
||||
findings = []
|
||||
|
||||
# Group events by source IP and user
|
||||
auth_by_source = defaultdict(list)
|
||||
|
||||
for event in events:
|
||||
if event.get("EventID") != 4624:
|
||||
continue
|
||||
if event.get("LogonType") != "3":
|
||||
continue
|
||||
if event.get("AuthenticationPackageName") != "NTLM":
|
||||
continue
|
||||
|
||||
target_user = event.get("TargetUserName", "")
|
||||
source_ip = event.get("IpAddress", "")
|
||||
|
||||
if target_user.endswith("$") or target_user in ("ANONYMOUS LOGON", "-", ""):
|
||||
continue
|
||||
if source_ip in ("-", "::1", "127.0.0.1", ""):
|
||||
continue
|
||||
|
||||
try:
|
||||
ts = datetime.fromisoformat(event["TimeCreated"].replace("Z", "+00:00"))
|
||||
except (ValueError, KeyError):
|
||||
continue
|
||||
|
||||
key = (source_ip, target_user)
|
||||
auth_by_source[key].append({
|
||||
"timestamp": ts,
|
||||
"target_host": event.get("Computer", ""),
|
||||
"workstation": event.get("WorkstationName", ""),
|
||||
})
|
||||
|
||||
# Analyze each source for rapid multi-host authentication
|
||||
for (source_ip, target_user), auth_list in auth_by_source.items():
|
||||
auth_list.sort(key=lambda x: x["timestamp"])
|
||||
|
||||
# Sliding window analysis
|
||||
for i in range(len(auth_list)):
|
||||
window_start = auth_list[i]["timestamp"]
|
||||
window_end = window_start + timedelta(seconds=window_seconds)
|
||||
|
||||
targets_in_window = set()
|
||||
events_in_window = []
|
||||
|
||||
for j in range(i, len(auth_list)):
|
||||
if auth_list[j]["timestamp"] <= window_end:
|
||||
targets_in_window.add(auth_list[j]["target_host"])
|
||||
events_in_window.append(auth_list[j])
|
||||
else:
|
||||
break
|
||||
|
||||
if len(targets_in_window) >= threshold:
|
||||
findings.append({
|
||||
"timestamp": window_start.isoformat(),
|
||||
"detection_type": "Rapid Multi-Host NTLM Authentication (Relay Spraying)",
|
||||
"severity": "HIGH",
|
||||
"mitre": "T1557.001",
|
||||
"source_ip": source_ip,
|
||||
"target_user": target_user,
|
||||
"unique_targets": len(targets_in_window),
|
||||
"target_hosts": sorted(targets_in_window),
|
||||
"event_count": len(events_in_window),
|
||||
"window_seconds": window_seconds,
|
||||
"explanation": (
|
||||
f"User '{target_user}' authenticated via NTLM from {source_ip} "
|
||||
f"to {len(targets_in_window)} unique targets in {window_seconds}s. "
|
||||
f"Rapid multi-host authentication is consistent with ntlmrelayx spraying."
|
||||
),
|
||||
})
|
||||
break # One finding per source/user pair
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def detect_ntlmv1_downgrade(events):
|
||||
"""
|
||||
Detect NTLMv1 authentication which indicates a downgrade attack.
|
||||
NTLMv1 is weaker and should not be in use in modern environments.
|
||||
"""
|
||||
findings = []
|
||||
ntlmv1_by_user = defaultdict(list)
|
||||
|
||||
for event in events:
|
||||
if event.get("EventID") != 4624:
|
||||
continue
|
||||
if event.get("LogonType") != "3":
|
||||
continue
|
||||
|
||||
lm_package = event.get("LmPackageName", "")
|
||||
if "NTLM V1" not in lm_package:
|
||||
continue
|
||||
|
||||
target_user = event.get("TargetUserName", "")
|
||||
if target_user.endswith("$") or target_user in ("ANONYMOUS LOGON", "-", ""):
|
||||
continue
|
||||
|
||||
ntlmv1_by_user[target_user].append({
|
||||
"timestamp": event.get("TimeCreated", ""),
|
||||
"computer": event.get("Computer", ""),
|
||||
"source_ip": event.get("IpAddress", ""),
|
||||
"workstation": event.get("WorkstationName", ""),
|
||||
})
|
||||
|
||||
for user, auth_list in ntlmv1_by_user.items():
|
||||
targets = set(a["computer"] for a in auth_list)
|
||||
source_ips = set(a["source_ip"] for a in auth_list)
|
||||
findings.append({
|
||||
"timestamp": auth_list[0]["timestamp"],
|
||||
"detection_type": "NTLMv1 Authentication Detected (Downgrade Attack Indicator)",
|
||||
"severity": "HIGH",
|
||||
"mitre": "T1557.001",
|
||||
"target_user": user,
|
||||
"ntlmv1_event_count": len(auth_list),
|
||||
"source_ips": sorted(source_ips),
|
||||
"target_hosts": sorted(targets),
|
||||
"explanation": (
|
||||
f"User '{user}' authenticated {len(auth_list)} times using NTLMv1. "
|
||||
f"NTLMv1 is deprecated and should not be in use. This may indicate "
|
||||
f"a downgrade attack or misconfigured LmCompatibilityLevel."
|
||||
),
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def detect_machine_account_relay(events):
|
||||
"""
|
||||
Detect machine account NTLM authentication from unexpected IPs,
|
||||
indicating PetitPotam, DFSCoerce, or PrinterBug coercion + relay.
|
||||
"""
|
||||
findings = []
|
||||
machine_auths = defaultdict(list)
|
||||
|
||||
for event in events:
|
||||
if event.get("EventID") != 4624:
|
||||
continue
|
||||
if event.get("LogonType") != "3":
|
||||
continue
|
||||
if event.get("AuthenticationPackageName") != "NTLM":
|
||||
continue
|
||||
|
||||
target_user = event.get("TargetUserName", "")
|
||||
source_ip = event.get("IpAddress", "")
|
||||
|
||||
# Only machine accounts (ending in $)
|
||||
if not target_user.endswith("$"):
|
||||
continue
|
||||
if source_ip in ("-", "::1", "127.0.0.1", ""):
|
||||
continue
|
||||
|
||||
machine_auths[target_user].append({
|
||||
"timestamp": event.get("TimeCreated", ""),
|
||||
"target_host": event.get("Computer", ""),
|
||||
"source_ip": source_ip,
|
||||
"workstation": event.get("WorkstationName", ""),
|
||||
"lm_package": event.get("LmPackageName", ""),
|
||||
})
|
||||
|
||||
for machine_account, auth_list in machine_auths.items():
|
||||
source_ips = set(a["source_ip"] for a in auth_list)
|
||||
target_hosts = set(a["target_host"] for a in auth_list)
|
||||
|
||||
# Flag if machine account authenticates from multiple source IPs
|
||||
# or if source IP does not match expected machine IP
|
||||
if len(source_ips) > 1:
|
||||
findings.append({
|
||||
"timestamp": auth_list[0]["timestamp"],
|
||||
"detection_type": "Machine Account NTLM Auth from Multiple Sources (Coercion + Relay)",
|
||||
"severity": "CRITICAL",
|
||||
"mitre": "T1557.001",
|
||||
"machine_account": machine_account,
|
||||
"source_ips": sorted(source_ips),
|
||||
"target_hosts": sorted(target_hosts),
|
||||
"auth_count": len(auth_list),
|
||||
"explanation": (
|
||||
f"Machine account '{machine_account}' authenticated via NTLM from "
|
||||
f"{len(source_ips)} different source IPs: {', '.join(sorted(source_ips))}. "
|
||||
f"This indicates the machine's NTLM authentication was coerced "
|
||||
f"(PetitPotam/DFSCoerce/PrinterBug) and relayed to "
|
||||
f"{', '.join(sorted(target_hosts))}."
|
||||
),
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def detect_anonymous_ntlm_logons(events):
|
||||
"""
|
||||
Detect ANONYMOUS LOGON via NTLM which can indicate null session relay
|
||||
or Responder activity.
|
||||
"""
|
||||
findings = []
|
||||
anon_by_ip = defaultdict(list)
|
||||
|
||||
for event in events:
|
||||
if event.get("EventID") != 4624:
|
||||
continue
|
||||
if event.get("LogonType") != "3":
|
||||
continue
|
||||
if event.get("AuthenticationPackageName") != "NTLM":
|
||||
continue
|
||||
|
||||
target_user = event.get("TargetUserName", "")
|
||||
if target_user != "ANONYMOUS LOGON":
|
||||
continue
|
||||
|
||||
source_ip = event.get("IpAddress", "")
|
||||
if source_ip in ("-", "::1", "127.0.0.1", ""):
|
||||
continue
|
||||
|
||||
anon_by_ip[source_ip].append({
|
||||
"timestamp": event.get("TimeCreated", ""),
|
||||
"target_host": event.get("Computer", ""),
|
||||
})
|
||||
|
||||
for source_ip, auth_list in anon_by_ip.items():
|
||||
targets = set(a["target_host"] for a in auth_list)
|
||||
if len(auth_list) >= 3:
|
||||
findings.append({
|
||||
"timestamp": auth_list[0]["timestamp"],
|
||||
"detection_type": "Excessive ANONYMOUS NTLM Logons (Responder/Relay Probe)",
|
||||
"severity": "MEDIUM",
|
||||
"mitre": "T1557.001",
|
||||
"source_ip": source_ip,
|
||||
"anonymous_logon_count": len(auth_list),
|
||||
"target_hosts": sorted(targets),
|
||||
"explanation": (
|
||||
f"Source IP {source_ip} performed {len(auth_list)} anonymous NTLM "
|
||||
f"logons to {len(targets)} hosts. Excessive anonymous NTLM "
|
||||
f"authentication may indicate Responder probing or null session relay."
|
||||
),
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def parse_evtx_file(filepath):
|
||||
"""Parse a .evtx file and return list of parsed events."""
|
||||
events = []
|
||||
try:
|
||||
with evtx.Evtx(filepath) as log:
|
||||
for record in log.records():
|
||||
try:
|
||||
event = parse_security_event(record.xml())
|
||||
if event and event.get("EventID") in (4624, 4625, 4648, 4776):
|
||||
events.append(event)
|
||||
except Exception:
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f"[!] Error parsing {filepath}: {e}")
|
||||
return events
|
||||
|
||||
|
||||
def print_findings(findings, title):
|
||||
"""Print findings in a formatted table."""
|
||||
if not findings:
|
||||
print(f"\n[+] {title}: No findings")
|
||||
return
|
||||
|
||||
print(f"\n{'=' * 80}")
|
||||
print(f" {title} ({len(findings)} findings)")
|
||||
print(f"{'=' * 80}")
|
||||
|
||||
for i, finding in enumerate(findings, 1):
|
||||
severity = finding.get("severity", "N/A")
|
||||
severity_marker = {
|
||||
"CRITICAL": "[!!!]",
|
||||
"HIGH": "[!!]",
|
||||
"MEDIUM": "[!]",
|
||||
"LOW": "[.]",
|
||||
}.get(severity, "[?]")
|
||||
|
||||
print(f"\n {severity_marker} [{i}] {finding.get('detection_type', 'Unknown')}")
|
||||
print(f" Severity: {severity}")
|
||||
print(f" Time: {finding.get('timestamp', 'N/A')}")
|
||||
|
||||
if "target_user" in finding:
|
||||
print(f" User: {finding['target_user']}")
|
||||
if "machine_account" in finding:
|
||||
print(f" Machine: {finding['machine_account']}")
|
||||
if "source_ip" in finding:
|
||||
print(f" Source IP: {finding['source_ip']}")
|
||||
if "actual_source_ip" in finding:
|
||||
print(f" Actual Source IP: {finding['actual_source_ip']}")
|
||||
print(f" Expected Source IP: {finding.get('expected_source_ip', 'N/A')}")
|
||||
if "workstation_name" in finding:
|
||||
print(f" Workstation: {finding['workstation_name']}")
|
||||
if "target_hosts" in finding:
|
||||
hosts = finding["target_hosts"]
|
||||
if len(hosts) <= 5:
|
||||
print(f" Targets: {', '.join(hosts)}")
|
||||
else:
|
||||
print(f" Targets: {', '.join(hosts[:5])} ... (+{len(hosts)-5} more)")
|
||||
if "source_ips" in finding:
|
||||
print(f" Source IPs: {', '.join(finding['source_ips'])}")
|
||||
|
||||
print(f" Detail: {finding.get('explanation', 'N/A')}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Detect NTLM relay attacks via Windows Security event log correlation"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--evtx", required=True,
|
||||
help="Path to Windows Security .evtx log file"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--inventory",
|
||||
help="Path to CSV file with hostname,ip_address columns for mismatch detection"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json", action="store_true",
|
||||
help="Output results in JSON format"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output", "-o",
|
||||
help="Output file path (default: stdout)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--rapid-window", type=int, default=RAPID_AUTH_WINDOW,
|
||||
help=f"Time window for rapid auth detection in seconds (default: {RAPID_AUTH_WINDOW})"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--rapid-threshold", type=int, default=RAPID_AUTH_THRESHOLD,
|
||||
help=f"Min unique targets for rapid auth alert (default: {RAPID_AUTH_THRESHOLD})"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not os.path.exists(args.evtx):
|
||||
print(f"[!] File not found: {args.evtx}")
|
||||
sys.exit(1)
|
||||
|
||||
# Load host inventory if provided
|
||||
inventory = {}
|
||||
if args.inventory:
|
||||
if os.path.exists(args.inventory):
|
||||
inventory = load_host_inventory(args.inventory)
|
||||
print(f"[*] Loaded {len(inventory)} hosts from inventory")
|
||||
else:
|
||||
print(f"[!] Inventory file not found: {args.inventory}")
|
||||
|
||||
print(f"[*] Parsing Security events from: {args.evtx}")
|
||||
events = parse_evtx_file(args.evtx)
|
||||
print(f"[*] Parsed {len(events)} relevant Security events (4624, 4625, 4648, 4776)")
|
||||
|
||||
ntlm_4624 = [e for e in events if e.get("EventID") == 4624
|
||||
and e.get("AuthenticationPackageName") == "NTLM"]
|
||||
print(f"[*] Found {len(ntlm_4624)} NTLM LogonType 3 events for analysis")
|
||||
|
||||
print("[*] Running NTLM relay detection modules...")
|
||||
|
||||
# Run all detection modules
|
||||
mismatch_findings = detect_ip_hostname_mismatch(events, inventory) if inventory else []
|
||||
rapid_auth_findings = detect_rapid_multi_host_auth(
|
||||
events, args.rapid_window, args.rapid_threshold
|
||||
)
|
||||
ntlmv1_findings = detect_ntlmv1_downgrade(events)
|
||||
machine_relay_findings = detect_machine_account_relay(events)
|
||||
anon_findings = detect_anonymous_ntlm_logons(events)
|
||||
|
||||
all_findings = (
|
||||
mismatch_findings + rapid_auth_findings + ntlmv1_findings
|
||||
+ machine_relay_findings + anon_findings
|
||||
)
|
||||
|
||||
all_results = {
|
||||
"scan_time": datetime.utcnow().isoformat() + "Z",
|
||||
"security_log": args.evtx,
|
||||
"inventory_file": args.inventory or "Not provided",
|
||||
"inventory_hosts": len(inventory),
|
||||
"total_events_parsed": len(events),
|
||||
"ntlm_logon_events": len(ntlm_4624),
|
||||
"detection_modules": {
|
||||
"ip_hostname_mismatch": {
|
||||
"enabled": bool(inventory),
|
||||
"findings": mismatch_findings,
|
||||
"count": len(mismatch_findings),
|
||||
},
|
||||
"rapid_multi_host_auth": {
|
||||
"enabled": True,
|
||||
"findings": rapid_auth_findings,
|
||||
"count": len(rapid_auth_findings),
|
||||
"window_seconds": args.rapid_window,
|
||||
"threshold": args.rapid_threshold,
|
||||
},
|
||||
"ntlmv1_downgrade": {
|
||||
"enabled": True,
|
||||
"findings": ntlmv1_findings,
|
||||
"count": len(ntlmv1_findings),
|
||||
},
|
||||
"machine_account_relay": {
|
||||
"enabled": True,
|
||||
"findings": machine_relay_findings,
|
||||
"count": len(machine_relay_findings),
|
||||
},
|
||||
"anonymous_ntlm_logons": {
|
||||
"enabled": True,
|
||||
"findings": anon_findings,
|
||||
"count": len(anon_findings),
|
||||
},
|
||||
},
|
||||
"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"]),
|
||||
"low": len([f for f in all_findings if f.get("severity") == "LOW"]),
|
||||
},
|
||||
}
|
||||
|
||||
if args.json:
|
||||
output = json.dumps(all_results, indent=2, default=str)
|
||||
if args.output:
|
||||
with open(args.output, "w") as f:
|
||||
f.write(output)
|
||||
print(f"[*] JSON results written to: {args.output}")
|
||||
else:
|
||||
print(output)
|
||||
else:
|
||||
print(f"\n[*] NTLM Relay Detection Report")
|
||||
print(f"[*] Scan Time: {all_results['scan_time']}")
|
||||
print(f"[*] Events Analyzed: {all_results['total_events_parsed']}")
|
||||
print(f"[*] NTLM Network Logons: {all_results['ntlm_logon_events']}")
|
||||
|
||||
if not inventory:
|
||||
print("\n[!] WARNING: No host inventory provided (--inventory).")
|
||||
print(" IP-hostname mismatch detection is DISABLED.")
|
||||
print(" Provide a CSV with hostname,ip_address columns for full detection.")
|
||||
|
||||
print_findings(mismatch_findings, "IP-Hostname Mismatch Detection")
|
||||
print_findings(rapid_auth_findings, "Rapid Multi-Host Authentication")
|
||||
print_findings(ntlmv1_findings, "NTLMv1 Downgrade Detection")
|
||||
print_findings(machine_relay_findings, "Machine Account Relay (Coercion)")
|
||||
print_findings(anon_findings, "Anonymous NTLM Logon Analysis")
|
||||
|
||||
print(f"\n{'=' * 80}")
|
||||
print(f" SUMMARY")
|
||||
print(f"{'=' * 80}")
|
||||
s = all_results["summary"]
|
||||
print(f" Total Findings: {s['total_findings']}")
|
||||
print(f" Critical: {s['critical']}")
|
||||
print(f" High: {s['high']}")
|
||||
print(f" Medium: {s['medium']}")
|
||||
print(f" Low: {s['low']}")
|
||||
|
||||
if s["critical"] > 0:
|
||||
print(f"\n [!!!] CRITICAL findings detected -- NTLM relay attack likely in progress!")
|
||||
print(f" Recommended: Isolate source IPs, reset affected credentials,")
|
||||
print(f" enforce SMB/LDAP signing, disable LLMNR/NBT-NS.")
|
||||
|
||||
if args.output:
|
||||
with open(args.output, "w") as f:
|
||||
json.dump(all_results, f, indent=2, default=str)
|
||||
print(f"\n[*] Full results written to: {args.output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user