Roundcube: exfiltrating emails with CVE-2021-44026
- Article tags
Have you ever asked yourself: what is the half-life of a disclosed vulnerability? When should we stop worrying about it?
As an internal research exercise, we took a look at CVE-2021-44026, an SQL injection vulnerability in the open-source mail client Roundcube. Although it’s already two years old, it was successfully used in an exploit this summer. On 20 June 2023, CERT-UA and Recorded Future published a report of an attack which compromised the email infrastructure of several state organizations.
The attackers sent an email that, when opened, could exploit a vulnerability chain in Roundcube: an XSS in rendering links in emails (CVE-2020-35730) chained with an authenticated SQL injection (CVE-2021-44026).
What is Roundcube and why do we care about it?
According to the official description, Roundcube is:
… a browser-based multilingual IMAP client with an application-like user interface. It provides full functionality you expect from an email client, including MIME support, address book, folder manipulation, message searching and spell checking.
Roundcube is used globally by NGOs and public institutions and is the default email client that comes bundled with cPanel. Given its popularity, we decided to replicate the SQL injection, as we found no public analysis about it. We did this as an internal research project and published the results so people responsible for email security could patch their Roundcube servers and prevent more data leaks.
Analyzing the patch for the SQLi
Because Roundcube is open source, we analyzed the vulnerability by reviewing the public patch on GitHub. We discovered that the release 1.4.12 addressed this flaw through two commits.
The first commit applies a regex to the variable _sort
, which the user can manipulate.
...
$dont_override = (array) $RCMAIL->config->get('dont_override');
// is there a sort type for this request?
- if ($sort = rcube_utils::get_input_value('_sort', rcube_utils::INPUT_GET)) {
+ $sort = rcube_utils::get_input_value('_sort', rcube_utils::INPUT_GET);
+ if ($sort && preg_match('/^[a-zA-Z_-]+$/', $sort)) {
// yes, so set the sort vars
list($sort_col, $sort_order) = explode('_', $sort);
...
The patch filters the input to allow only lowercase and uppercase English letters and the characters _, -
.
The second patch involved renaming the search
key in $_SESSION
in various places in the code that read data from it. Here’s an excerpt from one of the changed files, export.inc
:
...
$RCMAIL->request_security_check(rcube_utils::INPUT_GET);
// Use search result
- if (!empty($_REQUEST['_search']) && isset($_SESSION['search'][$_REQUEST['_search']])) {
+ if (!empty($_REQUEST['_search']) && isset($_SESSION['contact_search'][$_REQUEST['_search']])
+ && is_array($_SESSION['contact_search'][$_REQUEST['_search']])
+ ) {
$sort_col = $RCMAIL->config->get('addressbook_sort_col', 'name');
- $search = (array)$_SESSION['search'][$_REQUEST['_search']];
+ $search = $_SESSION['contact_search'][$_REQUEST['_search']];
$records = array();
...
The change applies to export.inc
and to four other files responsible for searching, displaying emails and displaying contacts for a user in Roundcube.
Additionally, the commit message seemed interesting:
Rename session items to fix potential conflict with the session items for email search.
Judging by this, and the change in the first commit, our first thought was that an attacker could pollute $_SESSION[‘search’]
, which was used unsanitized in a SQL query.
Looking for SQL Injection
Manipulating the session variables
We wanted to see if the value of _sort
lands inside $_SESSION
.
Starting from the file modified in the first patch, we see that the user controls the value in _sort
through a query parameter. When a request arrives, the value in _sort
will be split after the _
delimiter. The resulting elements will be saved in two variables: $sort_col
and $sort_order
. Then, both variables will be stored in $_SESSION
:
<?php
...
if ($sort = rcube_utils::get_input_value('_sort', rcube_utils::INPUT_GET)) {
// yes, so set the sort vars
list($sort_col, $sort_order) = explode('_', $sort);
// set session vars for sort (so next page and task switch know how to sort)
if (!in_array('message_sort_col', $dont_override)) {
$_SESSION['sort_col'] = $save_arr['message_sort_col'] = $sort_col;
}
if (!in_array('message_sort_order', $dont_override)) {
$_SESSION['sort_order'] = $save_arr['message_sort_order'] = $sort_order;
}
}
...
The functionality for setting this variable was on the Inbox page, designed to determine the preferred sorting method for a user's emails:
The first requests in our exploit chain will set the $_SESSION[‘sort_col’]
variable to a simple SQLi payload 1=1;--
, using the following request:
GET /?_task=mail&_action=list&_sort="1=1;--"&_layout=widescreen&_mbox=INBOX&_page=&_remote=1&_unlock=loading1698056472097&_=1698056454105 HTTP/1.1
Host: mail.pentest-ground.com:9009
Finding the vulnerable SQL query
The second part of the patch led us to think we should find a place where the application concatenates user-controlled input into a SQL query. Roundcube uses prepared statements, so we are looking for code where the app concatenates our input in the query before executing it.
Searching the code for places where this occurs led us to list_records
in rcube_contacts.php
, which is responsible for listing the contacts of the current user.
The function makes a call to $this->db->limitquery
, which executes the parameterized SQL query passed as the first parameter:
$sql_result = $this->db->limitquery(
"SELECT * FROM " . $this->db->table_name($this->db_name, true) . " AS c" .
$join .
" WHERE c.`del` <> 1" .
" AND c.`user_id` = ?" .
($this->group_id ? " AND m.`contactgroup_id` = ?" : "").
($this->filter ? " AND ".$this->filter : "") .
" ORDER BY ". $this->db->concat($order_cols) . " " . $this->sort_order,
$start_row,
$length,
$this->user_id,
$this->group_id
);
Three variables are concatenated directly into the query instead of being parametrized. Of them, only $this->filter
seems to be variable, making it a great candidate for the SQL Injection.
We saw it is manipulated using the set_search_set
method, defined in the same file.
This will become useful in a moment.
<?php
function set_search_set($filter)
{
$this->filter = $filter;
$this->cache = null;
}
When we examined the calls to the list_records
function, we stumbled upon export.inc
, a file modified in the patch.
This file allows a user to export his contacts as a vCard. It is found in the Contacts section of the Roundcube interface:
Examining the code in export.inc
, we notice something interesting: it contains a call to bot set_search_set
and list_records
:
<?php
if (!empty($_REQUEST['_search']) && isset($_SESSION['search'][$_REQUEST['_search']])) {
$sort_col = $RCMAIL->config->get('addressbook_sort_col', 'name');
$search = (array)$_SESSION['search'][$_REQUEST['_search']];
$records = array();
// Get records from all sources
foreach ($search as $s => $set) {
$source = $RCMAIL->get_address_book($s);
// reset page
$source->set_page(1);
$source->set_pagesize(99999);
$source->set_search_set($set);
// get records
$result = $source->list_records();
$set
comes from $_SESSION[‘search’]
so our goal is to find a way to get our values into $_SESSION[‘search’]
.
Finding a bridge between two puzzle pieces
The next logical step would be to look for assignments into $_SESSION[‘search’]
. So that’s what we did.
The assignment we sought is at line 151 of search.inc
, which deals with email search:
<?php
...
// save search results in session
if ($search_str) {
$_SESSION['search'] = $RCMAIL->storage->get_search_set();
}
$RCMAIL->storage->get_search_set()
is implemented in rcube_imap.php
and there is an equivalent setter defined there, set_search_set($set)
:
<?php
...
public function set_search_set($set)
{
$set = (array)$set;
...
$this->search_sort_field = $set[3];
...
}
There is only one call to this function, inside the same rcube_imap.php
, in the method search
.
<?php
...
$sorted = $this->threading || $this->search_sorted || $plugin['search_sorted'] ? true : false;
$this->set_search_set(array($search, $results, $charset, $sort_field, $sorted));
return $results;
}
Equivalently, search is called from search.inc
as $RCMAIL->storage->search
, at line 142:
<?php
...
$result = $RCMAIL->storage->search($mboxes, $search_str, $imap_charset, $sort_column);
Notice the name of the last parameter here, $sort_column
. Looking at its value, we see it comes from the assignment $sort_column = rcmail_sort_column()
from a few lines above.
After examining the implementation of rcmail_sort_column
, we found the jackpot!
<?php
function rcmail_sort_column()
{
...
if (isset($_SESSION['sort_col'])) {
$column = $_SESSION['sort_col'];
}
...
return $column;
}
To recap:
search.inc
handles email searches coming from the interface:
Inside it, the function call to
rcmail_sort_column
assigns the value from$_SESSION[‘sort_col’]
to$sort_column
, which is then used in a subsequent search, as$sort_field
, the value of the last parameter.
<?php
...
$sort_column = rcmail_sort_column();
...
$result = $RCMAIL->storage->search($mboxes, $search_str, $imap_charset, $sort_column);
Digging through the search implementation, we see that
$sort_field
is used as part of the call toset_search_set
.
<?php
public function search($folder = '', $search = 'ALL', $charset = null, $sort_field = null) {
...
$this->set_search_set(array($search, $results, $charset, $sort_field, $sorted));
return $results;
}
In
set_search_set
, the parameter$sort_field
- the fourth element in the array - will be assigned as the value of$this->search_sort_field
<?php
public function set_search_set($set)
{
$set = (array)$set;
...
$this->search_sort_field = $set[3];
...
}
The
search
call above returns, the execution context goes back tosearch.inc
, where a few lines below an assignment is made to$_SESSION[‘search’]
:
<?php
...
$result = $RCMAIL->storage->search($mboxes, $search_str, $imap_charset, $sort_column);
...
if ($search_str) {
$_SESSION['search'] = $RCMAIL->storage->get_search_set();
...
Connecting the dots
To successfully exploit the SQL injection, we need a sequence of three requests:
Polluting
$_SESSION[‘sort_col’]
with our input from the_sort
query parameter:
GET /?_task=mail&_action=list&_sort="1=1;--"&_layout=widescreen&_mbox=INBOX&_page=&_remote=1&_unlock=loading1698056472097&_=1698056454105 HTTP/1.1
Host: roundcube
Moving the value from
$_SESSION[‘sort_col’]
to$_SESSION[‘search]
.
GET /?_task=mail&_action=search&_interval=&_q=test&_filter=ALL&_scope=base&_mbox=INBOX&_remote=1 HTTP/1.1
Host: roundcube
Triggering the SQLi and having the result of the query returned as a VCARD. To achieve this, we set the
_search
parameter to3
, knowing$sort_col
is the fourth element in the$_SESSION[‘search’]
array:
GET /?_task=addressbook&_action=export&_search=3&_source=0&_cid=1&_token=cKGAH76jfaGjrXNCMIOGDkKWKR4CGXz8 HTTP/1.1
Host: roundcube
How we tackled the exploitation challenges
Since no vulnerability is truly critical until there's proof of impact, we decided to write a PoC exploit which could extract authenticated users’ emails.
To exploit CVE-2021-44026, an attacker needs to be authenticated. For our exploit, we used an existing XSS vulnerability, CVE-2020-35730, to bypass the authentication step.
We encountered a few challenges when writing it, which we’ll explain below.
1. Hijacking the session
Since Roundcube doesn’t store emails (they sit on the mail server instead), we had to find another way to exfiltrate them. We explored the option to hijack the users’ sessions and extract all emails through the functionality Roundcube provides.
When taking a look at the database schema, the session
table is in charge of keeping track of sessions. It has two columns of interest, sess_id
and vars
, which include each user's name, encrypted password and session secret, among others.
Extracting sess_id
was not straightforward, because we couldn’t use the character in our payload. If you remember the code handling the first request in the exploit, it splits the user input by the same _
character.
To bypass this, we used PostgreSQL's identifiers. They can include escaped Unicode characters using backslashes and hexadecimal codes. For example, you can write sess_id
as U&"sess\\005Fid"
.
2. Bypassing the timestamp check
The Roundcube session mechanism consists of two cookies:
roundcube_sessid
, which is the value of thesess_id
column;roundcube_sessauth
, which is built from two values: a session secret stored inside thevars
column and a timestamp, derived from the current time of the server (that we can approximate using theDate
header in a response).
If the server receives a request with an invalid timestamp, the session is instantly invalidated and deleted from the database, rendering the exploit useless. However, we can derive a correct value inside our exploit by reimplementing Roundcube’s internal logic to build it.
Is this vulnerability still significant in 2023?
This analysis raised the question of whether vulnerable versions were still widespread online, so we decided to analyze the Roundcube versions found on the Internet.
Here are the questions we used to map them out:
How many Roundcube servers can we find online?
Where are these servers located?
What is the distribution of versions?
How many of them are still vulnerable?
Here’s what we found.
Scraping Shodan in November 2023 we found a total of 74039 Roundcube servers.
Most of them are in the US and Germany, with Russia coming in third:
Of these 74039 targets, we managed to extract the version for 45238 of them. Version1.4, with it's various patch levels, is the most popular, having almost the same share as versions 1.5 and 1.6 combined:
According to the advisory the vulnerability affects versions before 1.3.17 and 1.4.x before 1.4.12. Extracting the instances that satisfy this criteria from the targets above, we are left with a sobering number: 21901 (29.6%) of them are still vulnerable two years later.
We decided to publicly release our exploit to urge system administrators to upgrade their Roundcube servers and prevent further exploitation from threat actors who already have working exploits.
And there’s an even bigger context behind this project - and others like it who will follow - which we outlined in our vulnerability research manifesto.