CVE-2026-102394 The magic number in place of a flag name in Essential Addons
Intrudify's autonomous pentesting engine discovered a stored cross-site scripting vulnerability in Essential Addons for Elementor, affecting all versions up to and including 6.8.4. The EA Woo Product Grid builds its pagination element by string concatenation and places a Contributor-controlled value inside a single-quoted attribute through json_encode( $template_info, 1 ). The literal 1 is JSON_HEX_TAG, which escapes < and >. The attribute is wrapped in single quotes, and JSON_HEX_APOS is flag 4.
Summary
Helper::eael_pagination() assembles the pagination nav as one concatenated string. Six values are interpolated into it. Five of them are escaped: the ids go through sanitize_key, the numbers through intval, the query string through http_build_query. The sixth, data-template, is produced by json_encode with a bare 1 as the flags argument.
JSON_HEX_TAG is flag 1. It escapes < and > to \u003C and \u003E, which is the right defence for a value landing in HTML. It says nothing about quote characters. JSON_HEX_APOS is flag 4 and is the one that escapes the single quote. The attribute this value feeds is delimited with single quotes.
The value itself comes from a setting named eael_dynamic_template_Layout. That key is a registered control on a different widget, and the Woo Product Grid reads it without ever registering it. Elementor has no schema for it on this widget and persists whatever is supplied, and the save-time sanitiser kses_post_deep() finds nothing to remove because the payload carries no tags.
A Contributor stores a value whose first character after default is a single quote. That quote closes data-template, and everything after it becomes attributes on the nav.
Technical detail
The value
// includes/Classes/Helper.php:1377, inside eael_pagination()
$template_info = [
'dir' => 'free',
'file_name' => 'default',
'name' => $settings['eael_widget_name']
];
if ( ! empty( $settings['eael_dynamic_template_Layout'] ) ) {
$template_info['file_name'] = $settings['eael_dynamic_template_Layout'];
}file_name is taken verbatim. The default is the string default, which is what the attacker's payload is built to look like for its first seven characters.
The sink
// includes/Classes/Helper.php:1385, one concatenated string
$setPagination .= "<nav id='{$widget_id}-eael-pagination'
class='eael-woo-pagination'
data-plimit='$paginationLimit'
data-totalpage ='{$args['total_post']}'
data-widgetid='{$widget_id}'
data-pageid='$page_id'
data-args='".http_build_query( $args )."'
data-template='".json_encode( $template_info, 1 )."'>";
// The ids go through sanitize_key, the numbers through intval, and
// data-args through http_build_query. data-template is the only value
// in the tag with nothing applied to it.The author of this line was thinking about escaping. Five of the six interpolations prove it. The sixth got a number instead of a name.
What flag 1 covers
We ran json_encode against the payload and against a control, on the same PHP version the lab used:
// PHP 8.3. json_encode's second argument is a bitmask.
JSON_HEX_TAG is flag 1; JSON_HEX_APOS is flag 4
// The payload, carrying single quotes
json_encode($info, 1) : {"dir":"free","file_name":"default' onmouseover='alert(document.domain)' x='",...}
single quote survived : YES
with JSON_HEX_APOS : {"file_name":"default\u0027 onmouseover=\u0027alert(document.domain)\u0027 x=\u0027",...}
// Control, carrying angle brackets, to show flag 1 is doing something
json_encode($info, 1) : {"file_name":"default\u003Cscript\u003Ealert(1)\u003C\/script\u003E",...}
single quote survived : noThe control carries angle brackets and comes back with \u003C and \u003E, which is how we know flag 1 was applied. The payload carries single quotes and comes back with them intact. Adding JSON_HEX_APOS turns each one into \u0027 and the attribute holds.
Attacker control
// Where eael_dynamic_template_Layout is a registered control
includes/Elements/Content_Ticker.php:85 add_control
// Where the Woo Product Grid reads it
includes/Classes/Helper.php:1377 read, never registered
// includes/Elements/Post_Grid.php:98
// 'eael_dynamic_template_Layout', <- commented outeael_dynamic_template_Layout is declared as a control on the Content Ticker widget. The Woo Product Grid consumes it through the shared pagination helper and never declares it, and the Post Grid has its declaration commented out. Elementor stores widget settings as the editor sends them, so a key with no control behind it is kept as supplied.
Elementor\Utils::kses_post_deep() runs on data saved by any user without unfiltered_html. It strips disallowed HTML. The payload is a run of text with quotes, equals signs and semicolons in it, so there is nothing tag-shaped for that filter to act on.
The chain
- The attacker holds a Contributor account, creates a draft, and opens it in the Elementor editor, which a Contributor may do on their own post. The editor page carries the AJAX nonce.
- One
elementor_ajaxsave stores an EA Woo Product Grid widget witheael_dynamic_template_Layoutset to the payload. Reading_elementor_databack shows it stored byte for byte. - The Contributor submits the post for review. An Editor or Administrator publishes it.
- Any visitor opens the post and receives the pagination element:
// What an unauthenticated visitor receives
<nav id='ccc3333-eael-pagination' class='eael-woo-pagination' ...
data-template='{"dir":"free","file_name":"default'
onmouseover='alert(document.domain)'
style='position:fixed;top:0;left:0;width:100%;height:100%;z-index:2147483647'
x='","name":"eicon-woocommerce"}'>
// The quote after default closes data-template. onmouseover and style
// are attributes of the nav. The style stretches the nav over the whole
// viewport, so the handler fires on the first pointer movement.The style is what removes the need for a deliberate interaction. It stretches the nav to cover the viewport at the top of the stacking order, so the first pointer movement anywhere over the page is a pointer movement over the nav.
Verification
We reproduced this on a clean WordPress 7.1 installation running Essential Addons for Elementor Lite 6.8.4, Elementor 4.3.2 and WooCommerce 11.1.2 with the default theme, no other plugins and default settings. The attacker held a stock Contributor account with edit_posts and without unfiltered_html. Every step up to publication was driven over HTTP as that user.
The save returned {"success":true} and reading the post meta back confirmed the payload was stored unchanged, so kses_post_deep() removed nothing. After an administrator published the post, an anonymous request returned the nav with onmouseover and style as separate attributes.
An alert is weak evidence on its own, so execution was confirmed twice more without one. Replacing the handler with fetch('/EAEL-XSS-FIRED') produced a matching line in the web server access log, with Referer pointing at the published post, in both Chromium and Firefox from a browser carrying no cookies. A third variant set document.body.innerHTML and rewrote the visible page.
The escaping claim has its own control, in the section above. The same call that leaves the payload's single quotes intact converts the control's angle brackets to \u003C and \u003E. Flag 1 was applied and did exactly what flag 1 does.
Impact
Arbitrary JavaScript executes in the browser of every visitor to the published post, in the site's own origin. An administrator who opens the page runs it in their authenticated session, which puts the session cookie and the REST nonce within reach of the script. An administrator session can create another administrator and can install a plugin, and installing a plugin is arbitrary PHP on the host.
The publishing step is the attack. A Contributor cannot publish, so the payload sits in a pending post until an Editor or Administrator approves it, and approval is what puts it in front of every visitor. The widget renders ordinary-looking pagination, so the review gives no signal.
Essential Addons for Elementor is installed on more than a million sites. The affected path needs WooCommerce and a Woo Product Grid with pagination turned on, which is the shape of any store page built with this plugin.
Remediation
Update Essential Addons for Elementor to 6.8.5 or later.
WPDeveloper took both halves of the fix:
// 6.8.5, includes/Classes/Helper.php
// Template file names are simple slugs. Only accept slug-safe values so an
// untrusted saved setting cannot inject characters into the data-template attribute below.
+ $eael_template_slug = '/^[A-Za-z0-9_-]+$/';
+ if ( ! empty( $settings[...] ) && preg_match( $eael_template_slug, $settings[...] ) ) {
- data-template='".json_encode( $template_info, 1 )."'
+ data-template='".esc_attr( wp_json_encode( $template_info ) )."'wp_json_encode() followed by esc_attr() makes the attribute safe whichever quote delimits it, and the slug pattern stops a value with a quote in it from reaching any sink the plugin adds later. The sibling setting eael_product_grid_template, read in the same else if, got the same guard.
The same release rewrote a document-save guard, and the vendor's own docblock explains why:
// 6.8.4, includes/Classes/Bootstrap.php:371
if ( is_admin() ) {
...
if ( ! current_user_can( 'administrator' ) ) {
add_filter( 'elementor/document/save/data', function ( $data ) {
// clears eael_custom_js, register_user_role, ...
} );
}
}
// 6.8.5 registers it unconditionally and checks the user at save time
add_filter( 'elementor/document/save/data', [ $this, 'eael_restrict_document_save_data' ], 10, 2 );The guard clears settings a non-administrator must not save, including the Custom JS extension's stored code and the Login and Register widget's new-user role. In 6.8.4 it was attached only when is_admin() was true, decided once when the plugin loaded. The docblock records the consequence: *"Elementor applies this filter inside Document::save(), which runs for the editor's AJAX save and also outside wp-admin ... every one of those other paths skipped it."* 6.8.5 registers it on every request and checks the user when the document is actually saved.
That second change is a separate defect from this CVE, closed in the same release. Worth knowing on its own terms: the 6.8.5 changelog lists nine entries and not one of them mentions security. A site owner reading it has no reason to treat the update as urgent.
The wider lesson
A flag written as a number hides what it covers. json_encode( $template_info, 1 ) and json_encode( $template_info, JSON_HEX_TAG ) compile to the same thing, and only one of them lets a reader ask the next question. Spelled out, the name sits next to a single-quoted attribute and invites the comparison. Written as 1, it is a number that has been there a long time and reads as settled. Constants exist so that the code states its own assumptions, and a bitmask is the place where that matters most, because what is absent from it is invisible.
Escaping is a claim about a delimiter. JSON_HEX_TAG defends against HTML parsing and JSON_HEX_APOS defends a single-quoted attribute. The two are not ranked, they answer different questions, and the only way to pick is to look at the character that ends the value you are writing into. Five values on this line were escaped for the context they landed in. The sixth was escaped for a different one.
A setting with no control behind it is still an input. eael_dynamic_template_Layout exists as a declared control on another widget, which makes it feel like part of the plugin's own vocabulary. On this widget it is a key the editor can send and the server will keep. Anything read out of a settings array deserves the same treatment as a request parameter, because for a widget that never declared it, that is exactly what it is.
Disclosure timeline
- 2026-09-28 Vulnerability identified and full chain validated by Intrudify, reported via Patchstack
- 2026-09-30 Fixed release 6.8.5 published by WPDeveloper
- 2026-10-01 Public disclosure · CVE-2026-102394 assigned
Questions
What is CVE-2026-102394?
CVE-2026-102394 is a stored cross-site scripting vulnerability in the Essential Addons for Elementor WordPress plugin, versions 6.8.4 and earlier, reachable by any user holding a Contributor account. It was discovered autonomously by the Intrudify AI penetration testing engine. The EA Woo Product Grid builds its pagination markup by concatenation and places an unvalidated setting inside a single-quoted data-template attribute through json_encode with the JSON_HEX_TAG flag only, so a single quote in the value closes the attribute.
The value goes through json_encode. Why is that not enough?
The call passes the literal 1 as its flags argument, which is JSON_HEX_TAG. That flag escapes the less-than and greater-than characters and nothing else. The attribute it writes into is delimited with single quotes, and the flag that escapes a single quote is JSON_HEX_APOS, which is flag 4 and was not set. Every other value interpolated into the same tag is escaped for the context it lands in.
How do I fix CVE-2026-102394?
Update Essential Addons for Elementor to version 6.8.5 or later. WPDeveloper replaced the call with esc_attr( wp_json_encode( $template_info ) ) and added a slug pattern that rejects any template setting containing characters outside letters, digits, underscore and hyphen. The same release also rewrote a document-save guard that had been registered only inside wp-admin.