#!/usr/bin/env python3 """ CVE-2025-59718 Fortinet Authentication Bypass Checker Detecta vulnerabilidad en FortiOS, FortiProxy, FortiSwitchManager y FortiWeb Autor: m10sec Versión: 1.2 """ import argparse import json import re import socket import ssl from dataclasses import dataclass, asdict from typing import Optional, Tuple, List, Dict from urllib.parse import urlparse from http.client import HTTPSConnection from datetime import datetime import paramiko from colorama import init, Fore, Style init() # ==================== BANNER Y UTILIDADES ==================== def banner(): print(Fore.GREEN + Style.BRIGHT + r""" ░▒▓████████▓▒░▒▓██████▓▒░░▒▓███████▓▒░▒▓████████▓▒░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓██████▓▒░░▒▓█▓▒░░▒▓█▓▒░▒▓███████▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓██████▓▒░░▒▓█▓▒░░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░ """ + Style.RESET_ALL) def print_banner(): print(" Fortinet Checker CVE-2025-59718 v1.3 ") print(" by m10sec (2025) m10sec@proton.me ") print(" CVEs: CVE-2025-59718 / CVE-2025-59719 ") print("=" * 55) print() def c_ok(msg): return f"{Fore.GREEN}[+]{Style.RESET_ALL} {msg}" def c_warn(msg): return f"{Fore.YELLOW}[!]{Style.RESET_ALL} {msg}" def c_err(msg): return f"{Fore.RED}[X]{Style.RESET_ALL} {msg}" def c_info(msg): return f"{Fore.CYAN}[*]{Style.RESET_ALL} {msg}" def c_crit(msg): return f"{Fore.RED + Style.BRIGHT}[!!!]{Style.RESET_ALL} {msg}" # ==================== VERSION HELPERS ==================== def parse_ver(v: str) -> Tuple[int, int, int]: """Parse version string to tuple (major, minor, patch)""" m = re.match(r"^\s*(\d+)\.(\d+)\.(\d+)", v) if not m: raise ValueError(f"Version no parseable: {v!r}") return tuple(map(int, m.groups())) def in_range(v: Tuple[int, int, int], lo: Tuple[int, int, int], hi: Tuple[int, int, int]) -> bool: """Check if version is within range (inclusive)""" return lo <= v <= hi # ==================== FINDING MODEL ==================== @dataclass class Finding: target: str mode: str # ssh|passive host: str port: int product: Optional[str] = None version: Optional[str] = None vulnerable_version: Optional[bool] = None forticloud_sso_enabled: Optional[bool] = None # Final verdict: EXISTS / NOT_FOUND / POTENTIAL / UNKNOWN verdict: str = "UNKNOWN" # Additional metadata confidence: str = "unknown" # high, medium, low indicators: List[str] = None notes: List[str] = None def __post_init__(self): if self.notes is None: self.notes = [] if self.indicators is None: self.indicators = [] # ==================== CVE DETECTION LOGIC ==================== def detect_product_and_version(status_output: str) -> Tuple[Optional[str], Optional[str]]: """Detect Fortinet product and version from system status output""" # FortiOS/FortiProxy: m = re.search(r"Version:\s*(FortiOS|FortiProxy)\s*v?(\d+\.\d+\.\d+)", status_output, re.IGNORECASE) if m: return m.group(1), m.group(2) # FortiSwitchManager: m2 = re.search(r"(FortiSwitchManager).*?v?(\d+\.\d+\.\d+)", status_output, re.IGNORECASE) if m2: return "FortiSwitchManager", m2.group(2) # FortiWeb: m3 = re.search(r"(FortiWeb).*?v?(\d+\.\d+\.\d+)", status_output, re.IGNORECASE) if m3: return "FortiWeb", m3.group(2) return None, None def is_vulnerable(product: Optional[str], version: Optional[str]) -> bool: """ Check if product version is vulnerable to CVE-2025-59718 Based on official Fortinet advisory FG-IR-25-647 """ if not product or not version: return False try: v = parse_ver(version) except ValueError: return False p = product.lower() # FortiOS vulnerable ranges if p == "fortios": return any([ in_range(v, parse_ver("7.6.0"), parse_ver("7.6.3")), in_range(v, parse_ver("7.4.0"), parse_ver("7.4.8")), in_range(v, parse_ver("7.2.0"), parse_ver("7.2.11")), in_range(v, parse_ver("7.0.0"), parse_ver("7.0.17")), ]) # FortiProxy vulnerable ranges if p == "fortiproxy": return any([ in_range(v, parse_ver("7.6.0"), parse_ver("7.6.3")), in_range(v, parse_ver("7.4.0"), parse_ver("7.4.10")), in_range(v, parse_ver("7.2.0"), parse_ver("7.2.14")), in_range(v, parse_ver("7.0.0"), parse_ver("7.0.21")), ]) # FortiSwitchManager vulnerable ranges if p == "fortiswitchmanager": return any([ in_range(v, parse_ver("7.2.0"), parse_ver("7.2.6")), in_range(v, parse_ver("7.0.0"), parse_ver("7.0.5")), ]) # FortiWeb vulnerable ranges if p == "fortiweb": return any([ in_range(v, parse_ver("8.0.0"), parse_ver("8.0.0")), in_range(v, parse_ver("7.6.0"), parse_ver("7.6.4")), in_range(v, parse_ver("7.4.0"), parse_ver("7.4.9")), # 7.2 and 7.0 NOT affected ]) return False def get_patch_version(product: Optional[str], version: Optional[str]) -> Optional[str]: """Get recommended patch version for vulnerable products""" if not product or not version: return None try: v = parse_ver(version) except ValueError: return None p = product.lower() if p == "fortios": if in_range(v, parse_ver("7.6.0"), parse_ver("7.6.3")): return "7.6.4" elif in_range(v, parse_ver("7.4.0"), parse_ver("7.4.8")): return "7.4.9" elif in_range(v, parse_ver("7.2.0"), parse_ver("7.2.11")): return "7.2.12" elif in_range(v, parse_ver("7.0.0"), parse_ver("7.0.17")): return "7.0.18" elif p == "fortiproxy": if in_range(v, parse_ver("7.6.0"), parse_ver("7.6.3")): return "7.6.4" elif in_range(v, parse_ver("7.4.0"), parse_ver("7.4.10")): return "7.4.11" elif in_range(v, parse_ver("7.2.0"), parse_ver("7.2.14")): return "7.2.15" elif in_range(v, parse_ver("7.0.0"), parse_ver("7.0.21")): return "7.0.22" elif p == "fortiswitchmanager": if in_range(v, parse_ver("7.2.0"), parse_ver("7.2.6")): return "7.2.7" elif in_range(v, parse_ver("7.0.0"), parse_ver("7.0.5")): return "7.0.6" elif p == "fortiweb": if in_range(v, parse_ver("8.0.0"), parse_ver("8.0.0")): return "8.0.1" elif in_range(v, parse_ver("7.6.0"), parse_ver("7.6.4")): return "7.6.5" elif in_range(v, parse_ver("7.4.0"), parse_ver("7.4.9")): return "7.4.10" return None def infer_forticloud_sso_enabled(global_output: str) -> Optional[bool]: """ Parse FortiCloud SSO login setting from system global output Returns: True if enabled, False if disabled, None if unknown """ m = re.search(r"set\s+admin-forticloud-sso-login\s+(enable|disable)", global_output, re.IGNORECASE) if not m: return None return m.group(1).lower() == "enable" # ==================== TARGET PARSING ==================== def normalize_target_to_host_port(t: str, default_port: int) -> Tuple[str, int, str]: """ Parse target string to (host, port, original_string) Accepts: IP, hostname, host:port, https://host[:port]/path, ssh://host[:port] """ t = t.strip() if "://" in t: u = urlparse(t) host = u.hostname or t port = u.port or (443 if u.scheme in ("https",) else default_port) return host, port, t # host:port format if ":" in t and not re.match(r"^\[.*\]$", t): host, p = t.rsplit(":", 1) if p.isdigit(): return host.strip(), int(p), t # host only return t, default_port, t def read_targets_file(path: str, default_port: int) -> List[Tuple[str, int, str]]: """Read targets from file (one per line, # for comments)""" out = [] with open(path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line or line.startswith("#"): continue host, port, raw = normalize_target_to_host_port(line, default_port) out.append((host, port, raw)) return out # ==================== SSH MODE ==================== def ssh_run(host: str, port: int, user: str, password: Optional[str], keyfile: Optional[str], cmd: str, timeout=10) -> str: """Execute SSH command and return output""" client = paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) try: if keyfile: pkey = paramiko.RSAKey.from_private_key_file(keyfile) client.connect(host, port=port, username=user, pkey=pkey, timeout=timeout, banner_timeout=timeout) else: client.connect(host, port=port, username=user, password=password, timeout=timeout, banner_timeout=timeout) _, stdout, stderr = client.exec_command(cmd, timeout=timeout) out = stdout.read().decode(errors="ignore") err = stderr.read().decode(errors="ignore") return out + ("\n" + err if err.strip() else "") finally: client.close() def check_ssh(host: str, port: int, raw: str, user: str, password: Optional[str], keyfile: Optional[str], timeout: int) -> Finding: """ SSH-based verification (most reliable) Requires valid credentials """ notes: List[str] = [] indicators: List[str] = [] f = Finding(target=raw, mode="ssh", host=host, port=port, notes=notes, indicators=indicators) try: print(c_info(f"Conectando vía SSH a {host}:{port}...")) # Get system status status = ssh_run(host, port, user, password, keyfile, "get system status", timeout=timeout) product, version = detect_product_and_version(status) f.product, f.version = product, version if not product or not version: notes.append("No se pudo inferir producto/versión desde 'get system status'") f.verdict = "UNKNOWN" f.confidence = "low" return f indicators.append(f"Producto detectado: {product} v{version}") # Check if version is vulnerable f.vulnerable_version = is_vulnerable(product, version) if f.vulnerable_version: indicators.append(f"⚠️ Versión {version} está en rango VULNERABLE") patch_version = get_patch_version(product, version) if patch_version: notes.append(f"Actualizar a versión {patch_version} o superior") else: indicators.append(f"✓ Versión {version} NO es vulnerable") # Check FortiCloud SSO setting (only for FortiOS/FortiProxy) if product and product.lower() in ("fortios", "fortiproxy"): try: glob = ssh_run(host, port, user, password, keyfile, "show system global | grep -i admin-forticloud-sso-login", timeout=timeout) f.forticloud_sso_enabled = infer_forticloud_sso_enabled(glob) if glob else None if f.forticloud_sso_enabled is True: indicators.append("⚠️ FortiCloud SSO login está HABILITADO") elif f.forticloud_sso_enabled is False: indicators.append("✓ FortiCloud SSO login está DESHABILITADO") else: indicators.append("? No se pudo determinar estado de FortiCloud SSO") except Exception as e: notes.append(f"Error al verificar FortiCloud SSO: {e}") # Final verdict logic if f.vulnerable_version is True: if f.forticloud_sso_enabled is True: f.verdict = "EXISTS" f.confidence = "high" notes.append("🚨 CRÍTICO: Sistema VULNERABLE y FortiCloud SSO HABILITADO") notes.append("🚨 Este sistema está siendo explotado activamente in-the-wild") notes.append("🚨 ACCIÓN INMEDIATA REQUERIDA") elif f.forticloud_sso_enabled is False: f.verdict = "NOT_FOUND" f.confidence = "high" notes.append("Versión vulnerable pero FortiCloud SSO está deshabilitado") notes.append("Sistema no explotable por esta vía (aún así, actualizar ASAP)") else: f.verdict = "POTENTIAL" f.confidence = "medium" notes.append("⚠️ Versión vulnerable, estado FortiCloud SSO desconocido") notes.append("Verificar manualmente: System > Settings > FortiCloud SSO") elif f.vulnerable_version is False: f.verdict = "NOT_FOUND" f.confidence = "high" notes.append("Sistema no vulnerable (versión parcheada)") else: f.verdict = "UNKNOWN" f.confidence = "low" return f except (paramiko.SSHException, socket.error, TimeoutError) as e: f.verdict = "UNKNOWN" f.confidence = "low" notes.append(f"Error SSH: {type(e).__name__}: {e}") return f # ==================== ACTIVE VULNERABILITY TESTING ==================== def test_forticloud_sso_endpoint(host: str, port: int, timeout: int) -> Dict: """ Test if FortiCloud SSO authentication endpoint exists and is vulnerable CVE-2025-59718: Improper Verification of Cryptographic Signature Returns dict with: - endpoint_exists: bool - endpoint_accessible: bool - response_code: int - vulnerable_behavior: bool (indica comportamiento sospechoso) """ result = { 'endpoint_exists': False, 'endpoint_accessible': False, 'response_code': None, 'vulnerable_behavior': False, 'details': [] } ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE # Endpoints conocidos de FortiCloud SSO test_endpoints = [ '/api/v2/authentication/forticloud', '/api/v2/cmdb/system/admin', '/remote/logincheck', '/remote/fgt_lang', ] for endpoint in test_endpoints: try: conn = HTTPSConnection(host, port=port, timeout=timeout, context=ctx) # Test básico GET conn.request("GET", endpoint) resp = conn.getresponse() body = resp.read().decode('utf-8', errors='ignore') if resp.status != 404: result['endpoint_exists'] = True result['response_code'] = resp.status result['details'].append(f"{endpoint}: HTTP {resp.status}") # Comportamientos que indican FortiCloud SSO activo if endpoint == '/api/v2/authentication/forticloud': if resp.status in [200, 401, 403]: result['endpoint_accessible'] = True result['details'].append("FortiCloud auth endpoint responde") # Si responde 200 sin autenticación válida = sospechoso if resp.status == 200: result['vulnerable_behavior'] = True result['details'].append("⚠️ Endpoint responde 200 sin auth") # Buscar indicadores en respuesta if 'forticloud' in body.lower() or 'sso' in body.lower(): result['details'].append("Respuesta contiene referencias SSO") # Otros endpoints que confirman FortiCloud habilitado if 'forticloud' in body.lower(): result['details'].append(f"Referencias FortiCloud en {endpoint}") conn.close() except Exception as e: result['details'].append(f"{endpoint}: {type(e).__name__}") return result def test_authentication_bypass(host: str, port: int, timeout: int) -> Dict: """ Prueba activa (no destructiva) de bypass de autenticación CVE-2025-59718: Authentication bypass via cryptographic signature flaw NOTA: Esta es una prueba SEGURA que solo verifica el comportamiento, NO intenta explotar el sistema. """ result = { 'bypass_possible': False, 'evidence': [], 'test_performed': False } try: ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE conn = HTTPSConnection(host, port=port, timeout=timeout, context=ctx) # Test 1: Intentar acceso a endpoint administrativo conn.request("GET", "/api/v2/monitor/system/status") resp = conn.getresponse() body = resp.read().decode('utf-8', errors='ignore') result['test_performed'] = True # Si el endpoint responde con información sin credenciales = problema if resp.status == 200: if any(keyword in body.lower() for keyword in ['version', 'hostname', 'serial']): result['bypass_possible'] = True result['evidence'].append("API status endpoint accesible sin auth") # Test 2: Verificar headers de respuesta if 'x-frame-options' not in [h.lower() for h in resp.getheaders()]: result['evidence'].append("Falta X-Frame-Options (configuración débil)") conn.close() except Exception as e: result['evidence'].append(f"Test error: {type(e).__name__}") return result # ==================== PASSIVE MODE ==================== def https_fetch_page(host: str, port: int, timeout: int) -> Tuple[dict, str, Optional[str]]: """ Fetch HTTPS page and return (headers, body, cert_subject) """ ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE conn = HTTPSConnection(host, port=port, timeout=timeout, context=ctx) conn.request("GET", "/") resp = conn.getresponse() headers = {k.lower(): v for k, v in resp.getheaders()} body = resp.read().decode('utf-8', errors='ignore') # Extract cert subject cert_subject = None try: sock = conn.sock if sock: cert = sock.getpeercert() if cert and "subject" in cert: cert_subject = str(cert["subject"]) except Exception: pass conn.close() return headers, body, cert_subject def analyze_fortinet_fingerprint(headers: dict, body: str, cert_subject: Optional[str]) -> Dict: """ Analyze page for Fortinet fingerprints Returns detection metadata """ indicators = [] product = None confidence = "none" # High confidence HTML fingerprints html_fingerprints = { "ftnt-fortinet-grid": "Fortinet icon class", "NEUTRINO_THEME": "Neutrino UI framework (Fortinet)", "fgt_lang": "FortiGate language variable", "/static/js/login.js": "Fortinet login script", "/favicon/fortinet": "Fortinet favicon" } for fp, desc in html_fingerprints.items(): if fp in body: indicators.append(desc) confidence = "high" # Detect specific product if "