CVE-2026-97262 Letting the client decide whether to check permissions

Intrudify's autonomous pentesting engine discovered a broken access control leading to stored cross-site scripting in Visual Composer Website Builder, affecting all versions up to and including 45.16.2. The setData AJAX handler takes both its target post id and a boolean deciding whether to run the capability check from the same attacker-supplied structure. A Contributor can overwrite any post on the site and store raw HTML and JavaScript.

Discovered by
Intrudify autonomous engine
Validated & reported by
Tudor Lasuschevici
Disclosure
30 September 2026
Severity
Medium · CVSS 6.5
Vulnerability class
Broken access control to stored XSS, CWE-79
Affected
Visual Composer Website Builder ≤ 45.16.2
Fixed in
45.16.3
Active installs
40,000+
Privilege required
Contributor
Write scope
Any post or page, including administrator-owned
Configuration
Default, no setting changed

Summary

Visual Composer Website Builder is a drag-and-drop page builder. Intrudify's autonomous testing engine found that its setData AJAX action is gated only by a nonce check requiring edit_posts, which is a default Contributor capability, and that the nonce is printed on a dashboard page Contributors can open.

Past that gate, the handler derives the target post from a client-supplied structure and honours a client-supplied flag that decides whether the capability check runs at all. The save path then removes WordPress's sanitisation filters before writing, so the acting user's lack of unfiltered_html has no effect.

Technical detail

Getting past the gate

The action is protected by an admin nonce verification that requires edit_posts. That capability belongs to Contributors by default, and the nonce value is rendered into the plugin's Getting Started dashboard page, which a Contributor can load. Obtaining a valid nonce is therefore a single GET request.

The flag the client supplies

In Modules/Editors/DataAjax/Controller.php:

// checkSourceId()
if (is_array($sourceId) && $sourceId['status'] === true) {
    if (isset($sourceId['accessCheck'])) {
        $accessCheck = $sourceId['accessCheck'];   // attacker controlled
    }
    $sourceId = $sourceId['sourceId'];              // attacker controlled
}

// setData()
list($accessCheck, $sourceId) = $this->checkSourceId($sourceId);
$hasAccess = $accessCheck ? $userCapabilitiesHelper->canEdit($sourceId) : true;

Both the post being written to and the decision about whether to verify permission on it come from the same request parameter. Sending {"status":true,"sourceId":<id>,"accessCheck":false} makes canEdit() unreachable and $hasAccess true for any post id on the site.

The array form travels in the plugin's own transport, a base64-encoded zlib-compressed JSON body, which preserves real boolean values. A plain form encoding would not.

And the sanitisation is removed on the way in

kses_remove_filters();
remove_filter('content_save_pre', 'balanceTags', 50);

The content is then written to post_content unfiltered. So the second half of the finding follows from the first: having reached a write the attacker should not have, the write itself stores markup the attacker's role is not permitted to store.

Why this is a privilege boundary violation

The Contributor role has none of the capabilities this outcome requires. We confirmed all three return false for the attacking account:

CapabilityResult for the attacker
edit_others_postsfalse
unfiltered_htmlfalse
edit_post (target post)false

The accessCheck=false shortcut was intended for an internal template-creation flow that performs its own capability check before calling through. The flow is reasonable; what is missing is anything preventing a client from sending the same structure directly.

Verification

We reproduced the entire chain over plain HTTP against a default installation of the latest release, with no WP-CLI, database or filesystem access, using a stock Contributor account.

The Contributor logged in, read the nonce from the plugin's dashboard page, and overwrote the administrator-authored post with a payload containing a script tag. Reading the post content back confirmed the write landed byte for byte, including the script tag that unfiltered_html should have stripped.

An administrator then opened the post for review, which is the ordinary workflow step, and the script executed in the site origin inside the authenticated administrator session.

Impact

Contributor is the lowest role that can submit content and it is routinely granted to guest authors. This gives that role two things it should not have.

First, the ability to overwrite the content of any post or page it does not own, including administrator-authored content. That is destructive on its own, and it is silent: there is no normal path by which a Contributor could have made that edit, so nothing in the interface flags it as unusual.

Second, the ability to store raw HTML and JavaScript despite lacking unfiltered_html. The stored script runs when an administrator or editor opens the post to review it, inside an authenticated session in the site origin, which is a path to full site compromise through same-origin requests.

This vector is distinct from the broken access control fixed in 45.16.0 and the Contributor XSS fixed in 45.16.2, and it remained exploitable on 45.16.2, the release that shipped the second of those fixes.

Remediation

Update Visual Composer Website Builder to 45.16.3 or later.

For maintainers, three changes are worth making together. Never accept an accessCheck flag from the request. Never derive the target post id from a client-supplied array. Always enforce the capability check for setData, and give the internal template-creation flow its exemption through a server-side token rather than a value that arrives with the request.

The wider lesson

The capability check exists and is correct. What makes it useless is that whether it runs is itself a value the caller provides. A conditional security check is only as trustworthy as whatever decides the condition, and here that decision was delegated to the untrusted side of the boundary.

The shape is worth recognising because it usually arrives the same way. An internal caller legitimately needs to skip a check, because it has already performed an equivalent one. The cheapest way to express that is a parameter. The parameter then travels the same path as everything else, and nothing distinguishes the internal caller from anyone else who can form the same request. Exemptions for trusted callers need to be carried by something the untrusted side cannot produce.

There is a second, quieter lesson in the nonce. A nonce proves a request came from a page the user was served; it says nothing about which user, and here the underlying capability was edit_posts, which almost every role has. Treating nonce verification as an authorisation check rather than an intent check is common, and the gap only becomes visible when the action behind it can touch objects the user does not own.

Worth noting for anyone auditing recently patched code: this plugin had shipped two security fixes in its three most recent releases, and this vector survived both. That makes three findings in this research set where a sink persisted through a neighbouring security patch.

Disclosure timeline

  • 2026-09-22 Vulnerability identified and full chain validated by Intrudify, reported via Patchstack
  • 2026-09-30 Public disclosure · CVE-2026-97262 assigned

Questions

What is CVE-2026-97262?

CVE-2026-97262 is a broken access control vulnerability leading to stored cross-site scripting in the Visual Composer Website Builder WordPress plugin, versions 45.16.2 and earlier. It was discovered autonomously by the Intrudify AI penetration testing engine. The setData AJAX handler derives its target post from a client-supplied structure and honours a client-supplied accessCheck flag, so a Contributor can overwrite any post on the site and store unfiltered HTML and JavaScript.

What can a Contributor do with CVE-2026-97262?

Overwrite the content of any post or page, including administrator-authored content, and store raw HTML and JavaScript despite lacking the unfiltered_html capability, because the save path removes the sanitisation filters before writing. The stored script then executes in the administrator session during the ordinary review step.

How do I fix CVE-2026-97262?

Update Visual Composer Website Builder to version 45.16.3 or later. The underlying fix is to never accept an accessCheck flag from the request, never derive the target post id from a client-supplied array, and always enforce the capability check for setData.

References

More advisories from Intrudify

Join the Future of
AI-Driven Pentesting