The Exploit
A Contributor-level user can duplicate any post (including published posts they don't own) by sending a direct AJAX request, completely bypassing the UI permission checks.
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.wordpress.local
Content-Type: application/x-www-form-urlencoded
Cookie: wordpress_logged_in=<contributor_session>
action=rael_duplicate_post&post=123&_wpnonce=<valid_nonce_from_page>
The attacker observes a 200 OK response with {"id": 124} — the newly duplicated post ID. In the WordPress backend, a complete copy of the target post now exists under the attacker's authorship, including all custom fields, Elementor template data, and ACF values. The original post author and editor never receive notification.
What the Patch Did
Before
public function rae_get_duplicate_link( $post_id ) {
$url = wp_nonce_url(
admin_url( 'admin.php?action=rael_duplicate_post&post=' . $post_id ),
'rael_duplicate_post_' . $post_id
);
// No capability check
return $url;
}
foreach ( $post_ids as $post_id ) {
$this->rae_duplicate( $post_id ); // Duplicates without per-post authorization
}
if ( ! isset( $_REQUEST['post'] ) ) {
wp_die( 'Invalid request' );
}
$post_id = intval( $_REQUEST['post'] );
$new_id = $this->rae_duplicate( $post_id ); // No capability check before duplication
After
public function rae_get_duplicate_link( $post_id ) {
if ( ! $this->rae_user_can_duplicate_post( $post_id ) ) {
return '';
}
$url = wp_nonce_url(
admin_url( 'admin.php?action=rael_duplicate_post&post=' . $post_id ),
'rael_duplicate_post_' . $post_id
);
return $url;
}
foreach ( $post_ids as $post_id ) {
if ( ! $this->rae_user_can_duplicate_post( $post_id ) ) {
continue; // Skip posts user cannot edit
}
$this->rae_duplicate( $post_id );
}
if ( ! $this->rae_user_can_duplicate_post( $post_id ) ) {
wp_die( esc_html__( 'You are not allowed to duplicate this post.', 'responsive-addons-for-elementor' ) );
}
$new_id = $this->rae_duplicate( $post_id );
private function rae_user_can_duplicate_post( $post_id ) {
$post = get_post( $post_id );
if ( ! $post ) {
return false;
}
if ( ! current_user_can( 'edit_post', $post_id ) ) { // Core capability check
return false;
}
// Enforce contributor restrictions on password-protected and draft posts
if ( current_user_can( 'contributor' ) && ! current_user_can( 'edit_others_posts' ) ) {
if ( $post->post_status !== 'publish' ) {
return false;
}
if ( ! empty( $post->post_password ) ) {
return false;
}
}
return true;
}
The patch introduced a centralized authorization guard rae_user_can_duplicate_post() that wraps the WordPress core capability check current_user_can( 'edit_post', $post_id ) — validating that the user holds explicit edit permission for the target post before any duplication logic executes. This check is enforced at three critical junctures: link generation, bulk action registration, and the primary AJAX handler. The method also applies stricter role-based rules for Contributors, preventing them from duplicating password-protected or non-published posts regardless of formal edit capability.
Root Cause
CWE-862: Missing Authorization — The vulnerability stems from the duplication handler accepting a post ID parameter ($_REQUEST['post']) and passing it directly to rae_duplicate() without validating that the requesting user holds edit capability for that post. The AJAX action rael_duplicate_post trusts the nonce (which prevents CSRF but does not verify authorization) and the user's authentication status alone. No per-post capability check occurs between the request entry point and the actual duplication logic, allowing any authenticated Contributor to duplicate posts authored by others, published by Editors, or restricted by post type permissions. The nonce validates request origin; the missing current_user_can( 'edit_post', $post_id ) call would have validated request authority.
Why It Works
The load-bearing line is if ( ! current_user_can( 'edit_post', $post_id ) ) { return false; } inside rae_user_can_duplicate_post(). Removing this single check resurrects the exploit — a Contributor could call rae_duplicate() on any post ID. The surrounding checks (role-based restrictions on drafts and password-protected posts, post existence validation) are defence-in-depth: they prevent edge cases like duplication of revisions, enforce policy (Contributors shouldn't duplicate sensitive content), and fail safely if the post is deleted between authorization and execution. The centralized method exists to avoid duplicating this authorization logic across three separate code paths; without the method, the maintainers would likely miss authorizing one or more call sites (as happened in the original code). The ternary return statements in link generation and bulk actions are defensive — they ensure the UI never renders a "Duplicate" option for unauthorized users, preventing user confusion and reducing attack surface.
Hardening Checklist
-
Every AJAX action and admin menu page handler must call
current_user_can()with the appropriate capability (e.g.,edit_post,delete_post,manage_options) before reading or modifying data. Use a centralized authorization function if the same capability applies to multiple entry points, as the patch did. -
Use
wp_verify_nonce()and capability checks in tandem. A nonce guards against CSRF; it does not guard against horizontal privilege escalation (a Contributor using their valid nonce to modify another user's post). -
For object-level operations (posts, users, custom post types), always pass the object ID to
current_user_can(). Callcurrent_user_can( 'edit_post', $post_id )rather thancurrent_user_can( 'edit_posts' )— the former binds the check to a specific post and respects post-level permission overrides. -
Enumerate all code paths that mutate state (AJAX handlers, admin pages, bulk actions, REST endpoints) and audit each one for authorization checks. Use
grepor a static analyzer to find calls towp_insert_post(),update_post(),wp_delete_post()and verify that each is guarded by a preceding capability check on the same post ID. -
Validate input types early. Even with authorization checks in place, call
intval()orabsint()on post IDs received from user input to prevent type confusion and ensure consistent comparison inget_post()andcurrent_user_can().
References
- https://nvd.nist.gov/vuln/detail/CVE-2025-69363