Segurium Research ElementsKit Elementor Addons

ElementsKit Elementor Addons 4.0.6 closes a stored XSS in the Nav Menu logo

Segurium Research 7 min read

Plugin
ElementsKit Elementor Addons elementskit-lite
Affected versions
4.0.5 and earlier
Fixed in
4.0.6
Class
Cross-site scripting
Severity
medium Segurium Research assessment. No CVSS score published yet.
CVE
None assigned at the time of writing
Installs
1,000,000 active
Patch released
Sources
Advisory record for ElementsKit Elementor Addons 4.0.6 The same finding as a structured entry in the advisory directory.

Wpmet published ElementsKit Elementor Addons 4.0.6 on wordpress.org. Our record dates the fix to 2026-10-02. The plugin runs on ~1,000,000 sites, by the directory’s count.

The vendor’s changelog names the bug: “Fixed: A stored XSS vulnerability in the Nav Menu widget’s mobile logo image attributes.” A second changelog line reads: “Improved: Output escaping for media image attributes to enhance security.”

The diff between 4.0.5 and 4.0.6 supports both lines. Version 4.0.5 printed an image title and an image alt text into an <img> tag without escaping. Version 4.0.6 passes both values through esc_attr() first. A shared image helper in the plugin gets the same change.

What the release fixes

The Nav Menu widget builds the <img> tag for the mobile logo with sprintf(). In 4.0.5 the widget escaped the src value but printed the title and alt values raw. A stored title or alt text with a double quote in it closed the attribute early. The text after the quote then became new attributes on the tag, and an attribute can carry JavaScript.

  • Affected versions: 4.0.5 and earlier
  • Fixed in: 4.0.6
  • Class: stored cross-site scripting (XSS)
  • Installs: ~1,000,000 sites

How the bug works

The vulnerable code is in widgets/nav-menu/nav-menu.php, in the class ElementsKit_Widget_Nav_Menu. The diff shows the whole change:

 			if (!empty($ekit_nav_menu_logo['id']) && !empty($ekit_nav_menu_logo['url'])) :
 				// $nav_logo_html = \Elementskit_Lite\Utils::get_attachment_image_html($settings, 'elementskit_nav_menu_logo', 'full');
 
-				$nav_logo_html = sprintf(
-					'<img src="%s" title="%s" alt="%s" decoding="async" />',
-					esc_url($ekit_nav_menu_logo['url']),
-					Control_Media::get_image_title($ekit_nav_menu_logo),
-					Control_Media::get_image_alt($ekit_nav_menu_logo)
-				);
-
+                $nav_logo_html = sprintf(
+                    '<img src="%s" title="%s" alt="%s" decoding="async" />',
+                    esc_url($ekit_nav_menu_logo['url']),
+                    esc_attr(Control_Media::get_image_title($ekit_nav_menu_logo)),
+                    esc_attr(Control_Media::get_image_alt($ekit_nav_menu_logo))
+                );
 				$markup .= sprintf(
 					'<a class="elementskit-nav-logo" href="%1$s" target="%2$s" rel="%3$s">%4$s</a>',
 					esc_url($link),

The if line runs the block when $ekit_nav_menu_logo['id'] and $ekit_nav_menu_logo['url'] both hold a value. The sprintf() template has three slots, and each slot sits between double quotes. The src slot gets esc_url($ekit_nav_menu_logo['url']). The title slot gets the return value of Control_Media::get_image_title($ekit_nav_menu_logo). The alt slot gets the return value of Control_Media::get_image_alt($ekit_nav_menu_logo). In 4.0.5 no escaping function touched those two return values before sprintf() printed them.

A browser reads title=" and takes every character up to the next double quote as the value. A title that holds a double quote ends the value at that quote. The rest of the title now sits outside the attribute. The browser reads that leftover text as more attributes on the same <img> tag. An attacker who controls the title can place an event handler attribute there. The browser runs the JavaScript in that handler when the event fires. The attacker needs no <script> tag and does not need to close the <img> tag. The alt slot fails the same way.

Version 4.0.6 wraps both return values in esc_attr(). The escaping function turns " into &quot; and ' into &#039;. The same function encodes <, > and &. A browser shows &quot; as a quote character inside the value. A browser does not treat &quot; as the end of the attribute. The whole title stays inside title="...", whatever characters the title holds.

The diff does not show where the title and the alt text come from. Control_Media is an Elementor class, and the Elementor code is not part of this diff. The helper names say what the helpers return: the title and the alt text of the image in $ekit_nav_menu_logo. The diff does not show which stored field each helper reads. The diff does not show which request writes that field, or which capability the request needs. So the route an attacker uses to store the value is not established here.

The commented-out line above the sprintf() call names a widget setting, elementskit_nav_menu_logo. The line that fills $ekit_nav_menu_logo is outside the diff.

The next statement appends an anchor with the class elementskit-nav-logo to $markup. The anchor template has a fourth slot, %4$s, for the content of the link. The diff ends at the first argument, esc_url($link). The diff does not show $nav_logo_html going into that fourth slot.

The same pattern exists in helpers/utils.php, in the static method get_attachment_image_html(). The method takes $settings, $image_key, $image_size_key and $image_attr.

-		if ( ! empty( $image['id'] ) && $image['id'] != '-1' && get_post($image['id'])) {
+
+		if ( ! empty( $image['id'] ) && '-1' !== (string) $image['id'] && get_post( $image['id'] ) ) {
 			$html .= wp_get_attachment_image( $image['id'], $size, false, $image_attr );
 		} else {
 			$html .= sprintf(
 				'<img src="%s" title="%s" alt="%s" class="%s" />',
-				esc_attr($image['url']),
-				\Elementor\Control_Media::get_image_title($image),
-				\Elementor\Control_Media::get_image_alt($image),
-				(isset($image_attr['class']) ? esc_attr($image_attr['class']) : '')
+				esc_url( $image['url'] ),
+				esc_attr( \Elementor\Control_Media::get_image_title( $image ) ),
+				esc_attr( \Elementor\Control_Media::get_image_alt( $image ) ),
+				isset( $image_attr['class'] ) ? esc_attr( $image_attr['class'] ) : ''
 			);
 		}

The method has two branches. The first branch runs when $image['id'] holds a value, is not -1, and get_post() finds that post. In that branch WordPress core builds the tag with wp_get_attachment_image(). The else branch builds the tag by hand with sprintf(). In 4.0.5 that hand-built tag printed the title and the alt text raw, like the Nav Menu widget. Version 4.0.6 adds esc_attr() to both.

The src slot changes too, from esc_attr() to esc_url(). esc_attr() already stopped a double quote from closing the src attribute. esc_url() also rejects a URL whose scheme is not on the WordPress allowed list.

The id comparison changes from $image['id'] != '-1' to '-1' !== (string) $image['id']. An id of -1 reaches the else branch in both releases. We see no security effect from that line.

The diff does not show the line that fills $image. The diff does not show which widgets call get_attachment_image_html(). The one call in the diff is the commented-out line in the Nav Menu widget.

The vendor calls the bug stored XSS, and the code agrees. The plugin prints the stored title and alt text each time a page renders the mobile logo. The attacker’s JavaScript runs in the browser of a visitor who opens that page. The script runs on the site’s own origin. The script can read the page and send requests as that visitor. When that visitor is a logged-in administrator, the script can act in wp-admin with the administrator’s session.

The rest of the diff is feature and rendering work. That work covers Mega Menu panel CSS in the Elementor editor and style loading for nested Elementor documents. The Blog Posts widget gets a responsive image position control. The settings page loses two Instagram token fields. The Nav Menu widget also gains an aria-label attribute, and the new code escapes the label with esc_attr().

Who is exposed

Sites that run ElementsKit Elementor Addons 4.0.5 or an earlier release are exposed. The diff compares 4.0.5 with 4.0.6. The diff does not show which older release first shipped the raw output.

The vulnerable block in the Nav Menu widget runs under these conditions:

  • The site renders the ElementsKit Nav Menu widget.
  • The widget has a mobile logo image with both an id and a URL.

A Nav Menu widget without a mobile logo skips the block.

The attacker also needs a way to store text in the title or the alt text of that logo image. The diff does not show that route or the role behind it. The diff shows no way for a visitor without an account to write either value. We rate the severity as medium for that reason.

On a single-site install, an administrator can already post unfiltered HTML. The bug matters most where a lower role can change the title or the alt text.

The stored script runs for every visitor who opens a page with the mobile logo. The worst case needs a logged-in administrator to open such a page.

The shared helper get_attachment_image_html() had the same gap in its else branch. That branch runs when the image id is empty, is -1, or matches no post. The diff does not show which widgets reach that branch, so exposure through the helper is not established here.

What to do

Update ElementsKit Elementor Addons to 4.0.6. Then check any site that ran 4.0.5 or earlier with a Nav Menu mobile logo.

  • Open the source of a page that shows the menu. Search for elementskit-nav-logo and read the <img> tag inside that link.
  • On 4.0.6, read the title and alt values. A value that holds &quot; followed by text that looks like an attribute points to an attack.
  • On 4.0.5, the template prints src, title, alt and decoding. Any other attribute on that tag came from a stored value.
  • If you find such a value, replace the title and the alt text of the logo image with clean text.
  • Review the administrator accounts for names you do not know. A script that ran in an administrator’s browser could have created one.

The diff names no AJAX action or REST route for this bug. Server logs give you no action name to search for.

Version 4.0.6 wraps the title and the alt text in esc_attr() in the Nav Menu widget and in get_attachment_image_html(). The helper also moves src to esc_url(). The diff has no code that cleans values already stored. A bad value stays stored after the update, and the plugin now prints that value as plain text. Confirm that the Plugins screen shows 4.0.6. A site that still runs 4.0.5 is still unpatched.

More research

Segurium is a WordPress malware scanner and cleanup tool on the official directory at wordpress.org/plugins/segurium/.