#!/usr/bin/env python3 """ CVE-2026-25177 Detector - Safe for Production Use =================================================== Read-only scanner that detects signs of SPN Unicode collision attacks against Active Directory Domain Controllers. This script performs ONLY read operations (LDAP searches). It does NOT modify any AD objects, write any attributes, or change any configuration. Detects: 1. SPNs containing Unicode zero-width or homoglyph characters 2. Duplicate SPNs across multiple accounts (case-insensitive) 3. SPNs that visually match but differ at the byte level 4. Recent SPN modifications (via whenChanged attribute) Requirements: pip install ldap3 pycryptodome Usage: python detect_spn_abuse.py # auto-detect everything, prompt for password python detect_spn_abuse.py -dc 10.0.0.1 -d corp.local # prompt for user and password python detect_spn_abuse.py -dc 10.0.0.1 -d corp.local -u svc_scanner --csv report.csv --days 7 """ import argparse import csv import getpass import os import socket import ssl import subprocess import sys import unicodedata from collections import defaultdict from datetime import datetime, timedelta from ldap3 import Server, Connection, ALL, NTLM, SIMPLE, Tls, SUBTREE # Characters that should never appear in a legitimate SPN SUSPICIOUS_CODEPOINTS = { 0x200B: "Zero-Width Space", 0x200C: "Zero-Width Non-Joiner", 0x200D: "Zero-Width Joiner", 0x200E: "Left-to-Right Mark", 0x200F: "Right-to-Left Mark", 0x2060: "Word Joiner", 0x2061: "Function Application", 0x2062: "Invisible Times", 0x2063: "Invisible Separator", 0x2064: "Invisible Plus", 0xFEFF: "Byte Order Mark / ZWNBSP", 0xFFFE: "Reversed BOM", 0x00AD: "Soft Hyphen", 0x034F: "Combining Grapheme Joiner", 0x180E: "Mongolian Vowel Separator", 0x061C: "Arabic Letter Mark", 0x115F: "Hangul Choseong Filler", 0x1160: "Hangul Jungseong Filler", 0x17B4: "Khmer Vowel Inherent Aq", 0x17B5: "Khmer Vowel Inherent Aa", } # Homoglyph pairs: (unicode char, ascii char it mimics) HOMOGLYPH_MAP = { 0x2044: ("/", "Fraction Slash"), 0xFF0F: ("/", "Fullwidth Solidus"), 0x2215: ("/", "Division Slash"), 0xFF28: ("H", "Fullwidth H"), 0xFF34: ("T", "Fullwidth T"), 0xFF30: ("P", "Fullwidth P"), 0x0391: ("A", "Greek Alpha"), 0x0392: ("B", "Greek Beta"), 0x0395: ("E", "Greek Epsilon"), 0x0397: ("H", "Greek Eta"), 0x0399: ("I", "Greek Iota"), 0x039A: ("K", "Greek Kappa"), 0x039C: ("M", "Greek Mu"), 0x039D: ("N", "Greek Nu"), 0x039F: ("O", "Greek Omicron"), 0x03A1: ("P", "Greek Rho"), 0x03A4: ("T", "Greek Tau"), 0x03A5: ("Y", "Greek Upsilon"), 0x0410: ("A", "Cyrillic A"), 0x0412: ("B", "Cyrillic Ve"), 0x0415: ("E", "Cyrillic Ie"), 0x041D: ("H", "Cyrillic En"), 0x041E: ("O", "Cyrillic O"), 0x0420: ("P", "Cyrillic Er"), 0x0421: ("C", "Cyrillic Es"), 0x0422: ("T", "Cyrillic Te"), } def auto_discover(): """Auto-discover DC and domain from the current machine. Works on domain-joined Windows machines.""" dc = None domain = None # Method 1: Environment variables domain = os.environ.get("USERDNSDOMAIN") logon_server = os.environ.get("LOGONSERVER", "").strip("\\") if domain and logon_server: try: dc = socket.gethostbyname(logon_server) print(f"[+] Auto-discovered from environment:") print(f" Domain: {domain}") print(f" DC: {logon_server} ({dc})") return dc, domain except socket.gaierror: pass # Method 2: nltest try: result = subprocess.run( ["nltest", "/dsgetdc:"], capture_output=True, text=True, timeout=5 ) if result.returncode == 0: for line in result.stdout.splitlines(): line = line.strip() if line.startswith("DC:"): dc_name = line.split("DC:")[1].strip().strip("\\") try: dc = socket.gethostbyname(dc_name) except socket.gaierror: dc = dc_name elif "Dns Dom Name:" in line or "DNS Dom Name:" in line: domain = line.split(":")[1].strip() elif line.startswith("Dom Name:") and not domain: domain = line.split("Dom Name:")[1].strip() if dc and domain: print(f"[+] Auto-discovered via nltest:") print(f" Domain: {domain}") print(f" DC: {dc}") return dc, domain except (FileNotFoundError, subprocess.TimeoutExpired): pass # Method 3: DNS SRV lookup if domain: try: result = subprocess.run( ["nslookup", "-type=SRV", f"_ldap._tcp.dc._msdcs.{domain}"], capture_output=True, text=True, timeout=5 ) for line in result.stdout.splitlines(): if "svr hostname" in line.lower(): dc_name = line.split("=")[-1].strip().rstrip(".") try: dc = socket.gethostbyname(dc_name) except socket.gaierror: dc = dc_name print(f"[+] Auto-discovered via DNS SRV:") print(f" Domain: {domain}") print(f" DC: {dc_name} ({dc})") return dc, domain except (FileNotFoundError, subprocess.TimeoutExpired): pass return dc, domain def connect_readonly(dc_host, domain, username, password): """Establish a read-only LDAP connection.""" tls_config = Tls(validate=ssl.CERT_NONE) methods = [ ("NTLM:389", 389, False, NTLM, f"{domain}\\{username}"), ("NTLM:636", 636, True, NTLM, f"{domain}\\{username}"), ("SIMPLE:636", 636, True, SIMPLE, f"{username}@{domain}"), ("SIMPLE:389", 389, False, SIMPLE, f"{username}@{domain}"), ] for name, port, use_ssl, auth, user in methods: try: server = Server(dc_host, port=port, use_ssl=use_ssl, tls=tls_config if use_ssl else None, get_info=ALL) conn = Connection(server, user=user, password=password, authentication=auth, auto_bind=True, read_only=True) print(f"[+] Connected via {name} (read-only)") return conn except Exception: continue print("[-] All authentication methods failed") sys.exit(1) def normalize_spn(spn): """Normalize an SPN for comparison: strip non-ASCII, lowercase.""" cleaned = "" for ch in spn: cp = ord(ch) if cp in SUSPICIOUS_CODEPOINTS: continue if cp in HOMOGLYPH_MAP: cleaned += HOMOGLYPH_MAP[cp][0] continue if cp > 127: # Try NFKD normalization (decomposes fullwidth etc.) normalized = unicodedata.normalize("NFKD", ch) if all(ord(c) < 128 for c in normalized): cleaned += normalized continue cleaned += ch return cleaned.lower() def check_unicode_chars(spn): """Check an SPN for suspicious Unicode characters. Returns list of findings.""" findings = [] for i, ch in enumerate(spn): cp = ord(ch) # Check zero-width / invisible characters if cp in SUSPICIOUS_CODEPOINTS: findings.append({ "position": i, "char": ch, "codepoint": f"U+{cp:04X}", "type": "invisible", "name": SUSPICIOUS_CODEPOINTS[cp], }) # Check homoglyphs elif cp in HOMOGLYPH_MAP: ascii_equiv, name = HOMOGLYPH_MAP[cp] findings.append({ "position": i, "char": ch, "codepoint": f"U+{cp:04X}", "type": "homoglyph", "name": f"{name} (looks like '{ascii_equiv}')", }) # Check any other non-ASCII (SPNs should be ASCII-only) elif cp > 127: cat = unicodedata.category(ch) char_name = unicodedata.name(ch, "UNKNOWN") findings.append({ "position": i, "char": ch, "codepoint": f"U+{cp:04X}", "type": "non-ascii", "name": f"{char_name} (category: {cat})", }) return findings def scan_spns(conn, search_base, days_back=None): """Scan all SPNs in the directory. Returns (all_spns, alerts).""" print("[*] Scanning all accounts with SPNs...") attrs = ["sAMAccountName", "servicePrincipalName", "distinguishedName", "whenChanged", "objectClass"] conn.search(search_base, "(servicePrincipalName=*)", search_scope=SUBTREE, attributes=attrs, paged_size=500, paged_cookie=None) # Handle LDAP paging to get ALL results (default AD limit is 1000) all_entries = list(conn.entries) page_num = 1 print(f" Page {page_num}: retrieved {len(all_entries)} accounts...", flush=True) while conn.result.get("controls", {}).get("1.2.840.113556.1.4.319", {}).get("value", {}).get("cookie"): cookie = conn.result["controls"]["1.2.840.113556.1.4.319"]["value"]["cookie"] conn.search(search_base, "(servicePrincipalName=*)", search_scope=SUBTREE, attributes=attrs, paged_size=500, paged_cookie=cookie) all_entries.extend(conn.entries) page_num += 1 print(f" Page {page_num}: retrieved {len(all_entries)} accounts so far...", flush=True) print(f" Done. {len(all_entries)} total accounts to analyze.") all_spns = [] # list of (account, dn, spn, when_changed) alerts = [] # list of alert dicts spn_index = defaultdict(list) # normalized_spn -> [(account, spn, dn)] cutoff = None if days_back: from datetime import timezone cutoff = datetime.now(timezone.utc) - timedelta(days=days_back) total_accounts = len(all_entries) total_spns = 0 for entry in all_entries: account = str(entry.sAMAccountName) dn = str(entry.distinguishedName) spns = list(entry.servicePrincipalName) if entry.servicePrincipalName else [] when_changed = entry.whenChanged.value if hasattr(entry, "whenChanged") and entry.whenChanged.value else None obj_classes = list(entry.objectClass) if entry.objectClass else [] for spn in spns: total_spns += 1 all_spns.append((account, dn, spn, when_changed)) # Index by normalized form for duplicate detection norm = normalize_spn(spn) spn_index[norm].append((account, spn, dn)) # Check 1: Unicode characters in SPN unicode_findings = check_unicode_chars(spn) if unicode_findings: alerts.append({ "check": "unicode_chars", "severity": "CRITICAL", "account": account, "dn": dn, "spn": spn, "spn_hex": spn.encode("utf-8").hex(), "details": unicode_findings, "when_changed": when_changed, "message": f"SPN contains {len(unicode_findings)} suspicious Unicode character(s)", }) # Check 2: Recently modified SPNs (if days_back specified) if cutoff and when_changed: # Make comparison timezone-aware from datetime import timezone if when_changed.tzinfo is None: when_changed = when_changed.replace(tzinfo=timezone.utc) if when_changed > cutoff: # Only alert on user accounts, not computer accounts is_computer = "computer" in [c.lower() for c in obj_classes] if not is_computer: alerts.append({ "check": "recent_change", "severity": "INFO", "account": account, "dn": dn, "spn": spn, "when_changed": when_changed, "message": f"SPN on user account modified within last {days_back} days", }) # Check 3: Duplicate SPNs (different accounts, same normalized SPN) for norm_spn, entries in spn_index.items(): if len(entries) > 1: accounts_involved = [(a, s, d) for a, s, d in entries] # Check if the raw SPNs differ (Unicode collision indicator) raw_spns = set(s for _, s, _ in entries) if len(raw_spns) > 1: severity = "CRITICAL" msg = "Byte-level different SPNs resolve to same value (Unicode collision!)" else: severity = "HIGH" msg = "Exact duplicate SPN on multiple accounts" alerts.append({ "check": "duplicate_spn", "severity": severity, "normalized_spn": norm_spn, "accounts": accounts_involved, "message": msg, }) print(f"[+] Scanned {total_accounts} accounts, {total_spns} SPNs") return all_spns, alerts def print_report(alerts): """Print a formatted report of findings.""" if not alerts: print("\n[+] No suspicious SPN activity detected.") return # Sort by severity severity_order = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "INFO": 3} alerts.sort(key=lambda a: severity_order.get(a["severity"], 99)) critical = sum(1 for a in alerts if a["severity"] == "CRITICAL") high = sum(1 for a in alerts if a["severity"] == "HIGH") info = sum(1 for a in alerts if a["severity"] == "INFO") print(f"\n{'=' * 70}") print(f" SCAN RESULTS: {len(alerts)} finding(s)") print(f" CRITICAL: {critical} | HIGH: {high} | INFO: {info}") print(f"{'=' * 70}") for i, alert in enumerate(alerts): sev = alert["severity"] check = alert["check"] print(f"\n [{sev}] Finding {i + 1}: {alert['message']}") print(f" {'─' * 60}") if check == "unicode_chars": print(f" Account: {alert['account']}") print(f" DN: {alert['dn']}") print(f" SPN: {alert['spn']}") print(f" SPN Hex: {alert['spn_hex']}") if alert.get("when_changed"): print(f" Modified: {alert['when_changed']}") for finding in alert["details"]: print(f" Position {finding['position']}: " f"{finding['codepoint']} - {finding['name']} " f"[{finding['type']}]") elif check == "duplicate_spn": print(f" Normalized SPN: {alert['normalized_spn']}") print(f" Accounts with this SPN:") for account, raw_spn, dn in alert["accounts"]: print(f" - {account}") print(f" DN: {dn}") print(f" Raw: {raw_spn}") print(f" Hex: {raw_spn.encode('utf-8').hex()}") elif check == "recent_change": print(f" Account: {alert['account']}") print(f" DN: {alert['dn']}") print(f" SPN: {alert['spn']}") print(f" Modified: {alert['when_changed']}") print(f"\n{'=' * 70}") if critical > 0: print("\n RECOMMENDED ACTIONS:") print(" 1. Investigate accounts with CRITICAL findings immediately") print(" 2. Remove any SPNs containing Unicode characters") print(" 3. Check Event Log for SPN modification events (Event ID 4742)") print(" 4. Review delegation permissions (who can write SPNs)") print(" 5. Apply Microsoft patch for CVE-2026-25177") print() def write_csv(alerts, filename): """Write findings to a CSV file.""" with open(filename, "w", newline="", encoding="utf-8") as f: writer = csv.writer(f) writer.writerow(["Severity", "Check", "Account", "DN", "SPN", "SPN_Hex", "Message", "Modified", "Details"]) for alert in alerts: account = alert.get("account", "") dn = alert.get("dn", "") spn = alert.get("spn", "") spn_hex = alert.get("spn_hex", "") when = str(alert.get("when_changed", "")) if alert["check"] == "duplicate_spn": for a, s, d in alert["accounts"]: writer.writerow([ alert["severity"], alert["check"], a, d, s, s.encode("utf-8").hex(), alert["message"], "", "" ]) else: details = "" if alert.get("details"): details = "; ".join( f"pos {f['position']}: {f['codepoint']} {f['name']}" for f in alert["details"] ) writer.writerow([ alert["severity"], alert["check"], account, dn, spn, spn_hex, alert["message"], when, details ]) print(f"[+] CSV report written to: {filename}") def main(): parser = argparse.ArgumentParser( description="CVE-2026-25177 Detector - Production-safe SPN scanner", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" This scanner is READ-ONLY and safe for production use. It does not modify any Active Directory objects. Examples: # Auto-detect everything (domain-joined machine), prompt for password python detect_spn_abuse.py # Auto-detect DC and domain, specify user python detect_spn_abuse.py -u svc_scanner # Fully manual python detect_spn_abuse.py -dc dc01.corp.local -d corp.local -u scanner -p 'pass' # Scan with CSV output and recent change detection python detect_spn_abuse.py --csv report.csv --days 7 # Use LDAPS python detect_spn_abuse.py --use-ssl """, ) parser.add_argument("-dc", required=False, help="Domain Controller IP/hostname (auto-detected if omitted)") parser.add_argument("-d", "--domain", required=False, help="Domain name (auto-detected if omitted)") parser.add_argument("-u", "--username", required=False, help="Username (auto-detected if omitted)") parser.add_argument("-p", "--password", required=False, help="Password (prompted securely if omitted)") parser.add_argument("--use-ssl", action="store_true", help="Use LDAPS (port 636)") parser.add_argument("--csv", metavar="FILE", help="Write findings to CSV file") parser.add_argument("--days", type=int, metavar="N", help="Flag user-account SPNs modified within last N days") args = parser.parse_args() print(""" +==============================================================+ | CVE-2026-25177 - SPN Unicode Collision Detector | | Read-only scan - safe for production | +==============================================================+ """) # Auto-discover DC and domain if not provided if not args.dc or not args.domain: print("[*] Auto-discovering domain controller...") discovered_dc, discovered_domain = auto_discover() if not args.dc: if discovered_dc: args.dc = discovered_dc else: print("[-] Could not auto-discover DC. Use -dc ") sys.exit(1) if not args.domain: if discovered_domain: args.domain = discovered_domain else: print("[-] Could not auto-discover domain. Use -d ") sys.exit(1) # Auto-detect username if not provided if not args.username: args.username = os.environ.get("USERNAME") if args.username: print(f"[+] Using current user: {args.username}") else: print("[-] Could not detect username. Use -u ") sys.exit(1) # Prompt for password if not provided if not args.password: args.password = getpass.getpass( f"Password for {args.domain}\\{args.username}: " ) search_base = ",".join(f"DC={p}" for p in args.domain.split(".")) import time start_time = time.time() conn = connect_readonly(args.dc, args.domain, args.username, args.password) all_spns, alerts = scan_spns(conn, search_base, args.days) conn.unbind() print_report(alerts) if args.csv: write_csv(alerts, args.csv) elapsed = time.time() - start_time minutes, seconds = divmod(int(elapsed), 60) if minutes > 0: print(f"[*] Scan completed in {minutes}m {seconds}s") else: print(f"[*] Scan completed in {seconds}s") if __name__ == "__main__": main()