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:
mukul975
2026-03-19 19:14:23 +01:00
parent d43cc7a766
commit d833f0eab9
125 changed files with 47874 additions and 334 deletions
@@ -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()