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

Author-level user poisons _wp_attachment_metadata via /wp/v2/media/<id>/finalize, then deletes own attachment to unlink any file on disk, including wp-config.php.

- Payout: hidden
- Severity: critical
- Program: WordPress
- Found by: jakubk
- Disclosed: 2026-08-28
- CVE: CVE-2018-12895
- Write-up quality: 9/10
- Original: https://hackerone.com/reports/3931777
- Library page: https://bugbountylibrary.com/r/h1%3A3931777
- Tags: area:web domain:api domain:web impact:integrity impact:privesc impact:rce kind:bounty platform:hackerone sev:critical tech:php tech:rest technique:source-review vuln:access-control vuln:path-traversal

**Target:** REST endpoint POST /wp/v2/media/<id>/finalize and wp_delete_attachment_files()
**Root cause:** finalize_item() writes attacker-supplied sub_sizes[].file verbatim into _wp_attachment_metadata with no sanitize_callback or basename check; the $backup_sizes branch of wp_delete_attachment_files() derives both the deletion target and its confinement directory from the attacker-controlled $meta['file'], making wp_delete_file_from_directory() containment a no-op.
**Steps:**
1. Upload a JPEG as Author: `POST /wp/v2/media` with Content-Disposition filename=zap4.jpg -> id=8
2. Poison a sub-size: `POST /wp/v2/media/8/finalize` body `{"sub_sizes":[{"image_size":"thumbnail","file":"wp-config.php",...}]}`
3. Read image_editor-8 nonce from `/wp-admin/media-upload.php?tab=library` (imageEdit.open(8,"<nonce>"))
4. Launder into protected meta: `POST /wp-admin/admin-ajax.php` `action=image-editor&_ajax_nonce=<nonce>&postid=8&do=save&target=all&history=[{"r":90}]`
5. Re-poison top-level file: `POST /wp/v2/media/8/finalize` with `{"sub_sizes":[{"image_size":"original","file":"../../x.jpg"}]}` (or absolute path)
6. Delete own attachment: `DELETE /wp/v2/media/8?force=true` -> target unlinked
**How found:** Source review of finalize_item() found no sanitize_callback/pattern on sub_sizes[].file; then auditing every branch of wp_delete_attachment_files() revealed the $backup_sizes branch uniquely derives its confinement directory from $meta['file'] rather than get_attached_file(), so the containment check compares against an attacker-chosen directory.
**Exploitation:** Author uploads own attachment, poisons sizes[*].file via finalize, uses the image-editor AJAX (nonce printed to Author) to copy the poisoned entry into _wp_attachment_backup_sizes, re-poisons metadata['file'] to control the confinement dir, then deletes the attachment; wp_delete_attachment_files() unlinks the attacker-named file anywhere PHP can unlink. Deleting wp-config.php forces the installer redirect, enabling takeover.
**Escalation:** Delete wp-config.php -> site redirects to setup-config.php -> unauthenticated visitor completes install with attacker DB -> admin -> RCE via plugin editor. Also deletes .htaccess, mu-plugins, backups, and cross-tenant files on shared hosting.
**Insight:** When auditing file-deletion sinks, check whether the containment directory and the target are derived from the same attacker-controlled value — a guard comparing two attacker-influenced paths is a no-op. Also: REST schema 'type: string' without pattern/sanitize_callback on filename fields is a write primitive into post meta.
