← All advisories
CVE-2026-55230High · CVSS 8.7· CWE-79

Stored XSS in Vvveb Post & Product Content via HTML Sanitizer Bypass

Vendor
givanz
Product
Vvveb
Status
Published · Jun 17 2026
Researchers
eo420
CVSS Vector
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N
Published
Jun 17 2026

Summary

Vvveb stores rich-text fields (post content, product content, excerpt, comment body, user bio, site settings) after passing them through one server-side filter, sanitizeHTML(). Its event-handler remover relies on a regex that stops scanning at a first > character. A crafted tag that carries a > inside a quoted attribute value hides an onerror handler from that regex, while a browser keeps parsing a same tag and runs a handler. Payload passes through sanitizeHTML() unchanged.

An authenticated content author (default role author or contributor) can store such a payload in post or product content, which a theme renders raw. Script then runs in a browser of every visitor and of any administrator who views or previews that content. JavaScript executes in a victim origin and session context, which opens a path toward admin account takeover through authenticated same-origin requests.

Technical Details: Root Cause

Regex event-handler remover stops at a quoted greater-than

sanitizeHTML() at system/functions.php:1436 removes on-handlers with a regex anchored to a first >:

// system/functions.php:1450
$string = preg_replace('#(<[^>]+?[\x00-\x20"\'/])(?:on|xmlns)[^>]*+>#iu', '$1>', $string);

Character class [^>] can't cross a >, so a whole match must terminate at a first one. Feed markup and two parsers disagree:

  • An HTML tokenizer enters an attribute value at src=", so a following > counts as a literal value character, and a tag stays open until a real closing >. A resulting element keeps src="x>" plus a live onerror handler.
  • This regex treats a first > inside quotes as a tag end. A substring it considers is <img src="x>, which holds no on-handler, so no replacement happens and onerror=... survives.

Same blind spot affects expression, behaviour, javascript-protocol, and tag-removal passes, since each anchors with [^>]*+>. Verified with a real shipped function:

input  : <img src="x>" onerror="alert(document.domain)">
output : <img src="x>" onerror="alert(document.domain)">   (unchanged)
control: <img src=x onerror=alert(1)>  becomes  <img src=x >   (plain handler stripped)

Content fields render raw, so a sanitizer is a only control

A template engine escapes a default variable binding (system/vtpl/vtpl.php:846 emits echo htmlspecialchars(...)), but rich content fields opt out with an explicit raw echo, since they exist as HTML by design:

// app/template/components/post.tpl:19
@post [data-v-post-content] = <?php if (isset($post['content'])) echo($post['content']); ?>
// app/template/components/product.tpl:24
@product [data-v-product-content] = <?php echo($product['content']); ?>

So sanitizeHTML() is all that stands between author input and a visitor browser, and a bypass becomes stored XSS.

Save handlers store a bypass intact

Save paths run a sanitizer once and persist a result:

// app/component/post.php:127
$post_content[$name] = sanitizeHTML($value);
// admin/controller/content/edit.php:404
$content['content'] = sanitizeHTML($content['content']);

A browser check confirms execution. A page that places these exact stored bytes into a content element, served over HTTP and loaded in Chromium, ran a handler: document.title became XSS:127.0.0.1, a counter window.__xss reached 1, and a parsed DOM showed , which proves a browser absorbed a > into src and kept a live handler.