CVE-2026-24387

WP Quick Post Duplicator <= 2.1 - Missing Authorization

mediumMissing Authorization
4.3
CVSS Score
4.3
CVSS Score
medium
Severity
2.2
Patched in
27d
Time to patch

Description

The WP Quick Post Duplicator plugin for WordPress is vulnerable to unauthorized access due to a missing capability check on a function in all versions up to, and including, 2.1. This makes it possible for authenticated attackers, with Contributor-level access and above, to perform an unauthorized action.

CVSS Vector Breakdown

CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N
Attack Vector
Network
Attack Complexity
Low
Privileges Required
Low
User Interaction
None
Scope
Unchanged
None
Confidentiality
Low
Integrity
None
Availability

Technical Details

Affected versions<=2.1
PublishedJanuary 8, 2026
Last updatedFebruary 3, 2026

What Changed in the Fix

Changes introduced in v2.2

Loading patch diff...

Source Code

WordPress.org SVN
Research Plan
Unverified

# Exploitation Research Plan - CVE-2026-24387 ## 1. Vulnerability Summary The **WP Quick Post Duplicator** plugin (<= 2.1) is vulnerable to **Missing Authorization**. While the plugin provides a feature to duplicate posts and pages, the processing function `apj_duplicate_post_as_a_draft()` fails to…

Show full research plan

Exploitation Research Plan - CVE-2026-24387

1. Vulnerability Summary

The WP Quick Post Duplicator plugin (<= 2.1) is vulnerable to Missing Authorization. While the plugin provides a feature to duplicate posts and pages, the processing function apj_duplicate_post_as_a_draft() fails to perform a granular capability check (e.g., current_user_can('edit_post', $post_id)). It only verifies a global edit_posts capability and a standard WordPress nonce.

Crucially, the nonce used for the duplication action is not bound to a specific Post ID. This allows an authenticated user with "Contributor" permissions (who has the edit_posts capability) to obtain a valid nonce from one of their own posts and reuse it to duplicate any other post or page on the site, including private or draft content created by administrators.

2. Attack Vector Analysis

  • Endpoint: /wp-admin/admin.php
  • Action: apj_duplicate_post_as_a_draft
  • Parameters:
    • action: apj_duplicate_post_as_a_draft (Required)
    • post: The ID of the target post/page to duplicate (Required)
    • _wpnonce: A valid nonce for the action wqpd_clone_post_page_nonce (Required)
  • Authentication: Authenticated, Contributor-level access (edit_posts) or higher.
  • Preconditions: At least one post must be accessible/editable by the attacker to obtain the initial nonce.

3. Code Flow

  1. Entry Point: The function apj_duplicate_post_as_a_draft is registered to the admin_action_apj_duplicate_post_as_a_draft hook in wp-quick-post-duplicator.php.
  2. Authorization Check: Line 102 checks if(!current_user_can( 'edit_posts' )). This allows any Contributor+ to proceed.
  3. Nonce Verification: Line 106 calls check_admin_referer('wqpd_clone_post_page_nonce'). This verifies the _wpnonce parameter against the specific action string.
  4. Input Processing: Lines 113-117 retrieve the post ID from $_GET['post'] (or $_POST['post']).
  5. Retrieval: Line 119 calls get_post($post_id) without verifying if the current user has permission to view or edit this specific $post_id.
  6. Sinks:
    • wp_insert_post($args) (Line 146): Creates a new post with the original content and the attacker as the post_author.
    • $wpdb->query($main_sql_query) (Line 169): Duplicates all post metadata.

4. Nonce Acquisition Strategy

The nonce is generated in the apj_duplicate_post_link function and displayed in the "Row Actions" (the hover menu) on the wp-admin/edit.php page.

  1. Login: Authenticate as a Contributor.
  2. Navigate: Go to edit.php (Posts list).
  3. Locate Link: Find a post owned by the Contributor.
  4. Extract Nonce: The "Duplicate This Item" link contains the nonce in the _wpnonce query parameter.
  5. Execution Tool: Use browser_eval to extract the link attribute from the DOM.
    • Selector: a[href*="action=apj_duplicate_post_as_a_draft"]
    • JS Logic: document.querySelector('a[href*="action=apj_duplicate_post_as_a_draft"]').href

5. Exploitation Strategy

  1. Preparation:
    • Identify the ID of a "Secret" page/post (e.g., ID 1) that the Contributor cannot normally edit.
    • Login as a Contributor and obtain a valid nonce from the URL of the "Duplicate" link on one of the Contributor's own posts.
  2. Execution: Send an authenticated GET request to admin.php targeting the secret post.
  3. HTTP Request (via http_request tool):
    GET /wp-admin/admin.php?action=apj_duplicate_post_as_a_draft&post=[TARGET_ID]&_wpnonce=[NONCE] HTTP/1.1
    Cookie: [Contributor_Cookies]
    
  4. Cleanup/Observation: The plugin will redirect to edit.php. A new draft will now exist with the content of the target post, but owned by the Contributor.

6. Test Data Setup

  1. Administrator Actions:
    • Create a Page titled "Admin Secret Page" with content "FLAG_SENSITIVE_DATA".
    • Note the ID of this page (e.g., 5).
  2. Contributor Actions:
    • Create a Post titled "Contributor Post".
    • Ensure the Contributor can see the "Duplicate This Item" link for their own post in wp-admin/edit.php.

7. Expected Results

  • The http_request should return a 302 Found redirecting back to edit.php.
  • A new post of type page or post (matching the target) will appear in the database.
  • The post_author of the new record will be the Contributor's ID.
  • The post_content will be "FLAG_SENSITIVE_DATA".

8. Verification Steps

  1. WP-CLI Check: List the posts owned by the contributor:
    wp post list --author=[CONTRIBUTOR_ID] --post_status=draft
  2. Content Check: Verify the content of the newly created post:
    wp post get [NEW_ID] --field=post_content
  3. Meta Check: Verify metadata was also duplicated:
    wp post meta list [NEW_ID]

9. Alternative Approaches

  • POST Method: The plugin code checks $_POST['post'] (Line 115). If a WAF blocks long query strings, the exploit can be converted to a POST request to admin.php, though check_admin_referer still expects the nonce in the request (usually _wpnonce).
  • Direct Meta Inspection: If the "Secret" post has sensitive custom fields, check if they were successfully copied to the new draft via wp post meta list.
Research Findings
Static analysis — not yet PoC-verified

Summary

The WP Quick Post Duplicator plugin (<= 2.1) is vulnerable to missing authorization because it fails to perform post-level capability checks and uses a generic nonce not bound to a specific post ID. This allows authenticated users with Contributor-level access to duplicate any post or page on the site, including private or restricted content, by reusing a nonce obtained from their own posts.

Vulnerable Code

// wp-quick-post-duplicator.php lines 68-107 in version 2.1

function apj_duplicate_post_as_a_draft()
{
    global $wpdb;
    //check current user have a preveligies to edit post or page
    if(!current_user_can( 'edit_posts' )){
    wp_die("you don't have a permission");
    }
	// check user from right place
	check_admin_referer('wqpd_clone_post_page_nonce');

    if (!(isset($_GET['post']) || isset($_POST['post']) || (isset($_REQUEST['action']) && 'apj_duplicate_post_as_a_draft' == $_REQUEST['action'])))
    {
        wp_die('No post to duplicate has been supplied!');
    }

    $apjvalue1       = intval($_GET['post']);

    $apjvalue2       = intval($_POST['post']);

    $post_id         = (isset($apjvalue1) ? $apjvalue1 : $apjvalue2);

    $post            = get_post($post_id);

---

// wp-quick-post-duplicator.php lines 42-55 in version 2.1

function apj_duplicate_post_link($actions, $post)
{
    if (current_user_can('edit_posts'))
    {
        if ( $post->post_type == "post" || $post->post_type == "page" ) {
            $url = admin_url( 'admin.php' );
            if ( current_user_can( 'edit_post', $post->ID ) ) {
                // adding a nonce in this link
                    $copy_link = wp_nonce_url( add_query_arg( array( 'action' => 'apj_duplicate_post_as_a_draft','post'=>$post->ID ), $url ), 'wqpd_clone_post_page_nonce' );

                    $actions['duplicate'] = '<a href="' . $copy_link . '" rel="permalink">Duplicate This Item</a>';
            }
        }
    }
    return $actions;
}

Security Fix

diff -ru /home/deploy/wp-safety.org/data/plugin-versions/wp-quick-post-duplicator/2.1/readme.txt /home/deploy/wp-safety.org/data/plugin-versions/wp-quick-post-duplicator/2.2/readme.txt
--- /home/deploy/wp-safety.org/data/plugin-versions/wp-quick-post-duplicator/2.1/readme.txt	2025-04-25 13:29:04.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/wp-quick-post-duplicator/2.2/readme.txt	2025-12-25 16:27:54.000000000 +0000
@@ -5,7 +5,7 @@
 * Donate link:     https://paypal.me/arulprasadj?locale.x=en_GB
 * Requires at least: 3.0
 * Tested up to:      6.8
-* Stable tag:        2.1
+* Stable tag:        2.2
 * License:           GPLv2 or later
 * License URI:       http://www.gnu.org/licenses/gpl-2.0.html
 
@@ -43,6 +43,10 @@
 
 == Changelog ==
 
+= 2.2 =
+* Security fix: Prevent unauthorized users from duplicating private or restricted posts.
+* Security: Added post-level capability checks and improved nonce validation.
+
 = 2.1 =
 * Security improvements.
 
diff -ru /home/deploy/wp-safety.org/data/plugin-versions/wp-quick-post-duplicator/2.1/wp-quick-post-duplicator.php /home/deploy/wp-safety.org/data/plugin-versions/wp-quick-post-duplicator/2.2/wp-quick-post-duplicator.php
--- /home/deploy/wp-safety.org/data/plugin-versions/wp-quick-post-duplicator/2.1/wp-quick-post-duplicator.php	2024-01-16 12:21:24.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/wp-quick-post-duplicator/2.2/wp-quick-post-duplicator.php	2026-01-01 16:08:48.000000000 +0000
@@ -4,17 +4,16 @@
  *
  *
  * @package WP Quick Post Duplicator
- * @version 2.1
+ * @version 2.2
  * @since 1.0
  */
 /**
- /**
  * Plugin Name: WP Quick Post Duplicator
  * Plugin URI:  https://wordpress.org/plugins/wp-quick-post-duplicator/
  * Description: Copy or Duplicate any post types, including pages, taxonomies & custom fields with a single click.
  * Author:      Arul Prasad J
  * Author URI:  https://profiles.wordpress.org/arulprasadj/
- * Version:     2.1
+ * Version:     2.2
  * Text Domain: wp-quick-post-duplicator
  * Domain Path: /languages
  * License:     GPLv2 or later (license.txt)
@@ -36,110 +35,153 @@
 * - apj_duplicate_post_as_a_draft()
 * Classes list:
 */
-function apj_duplicate_post_link($actions, $post)
-{
-    if (current_user_can('edit_posts'))
-    {
-        if ( $post->post_type == "post" || $post->post_type == "page" ) {
-            $url = admin_url( 'admin.php' );
-            if ( current_user_can( 'edit_post', $post->ID ) ) {
-                // adding a nonce in this link
-                    $copy_link = wp_nonce_url( add_query_arg( array( 'action' => 'apj_duplicate_post_as_a_draft','post'=>$post->ID ), $url ), 'wqpd_clone_post_page_nonce' );
+/**
+ * Add duplicate link to post/page row actions
+ */
+function apj_duplicate_post_link( $actions, $post ) {
 
-                    $actions['duplicate'] = '<a href="' . $copy_link . '" rel="permalink">Duplicate This Item</a>';
-            }
-        }
+    if ( ! current_user_can( 'edit_posts' ) ) {
+        return $actions;
+    }
+
+    if ( $post->post_type !== 'post' && $post->post_type !== 'page' ) {
+        return $actions;
     }
+
+    // Only show link if user can edit THIS post
+    if ( ! current_user_can( 'edit_post', $post->ID ) ) {
+        return $actions;
+    }
+
+    $url = admin_url( 'admin.php' );
+
+    // 🔒 Nonce bound to post ID
+    $copy_link = wp_nonce_url(
+        add_query_arg(
+            array(
+                'action' => 'apj_duplicate_post_as_a_draft',
+                'post'   => $post->ID,
+            ),
+            $url
+        ),
+        'wqpd_clone_post_' . $post->ID
+    );
+
+    $actions['duplicate'] = '<a href="' . esc_url( $copy_link ) . '" rel="permalink">Duplicate This Item</a>';
+
     return $actions;
 }
 
-$post_types = get_post_types('', 'names');
-
-foreach ($post_types as $post_type)
-{
-    add_filter($post_type . '_row_actions', 'apj_duplicate_post_link', 10, 2);
+/**
+ * Attach duplicate link to all post types
+ */
+$post_types = get_post_types( array(), 'names' );
+foreach ( $post_types as $post_type ) {
+    add_filter( $post_type . '_row_actions', 'apj_duplicate_post_link', 10, 2 );
 }
 
-function apj_duplicate_post_as_a_draft()
-{
+/**
+ * Duplicate post handler (SECURE)
+ */
+function apj_duplicate_post_as_a_draft() {
     global $wpdb;
-    //check current user have a preveligies to edit post or page
-    if(!current_user_can( 'edit_posts' )){
-    wp_die("you don't have a permission");
-    }
-	// check user from right place
-	check_admin_referer('wqpd_clone_post_page_nonce');
-
-    if (!(isset($_GET['post']) || isset($_POST['post']) || (isset($_REQUEST['action']) && 'apj_duplicate_post_as_a_draft' == $_REQUEST['action'])))
-    {
-        wp_die('No post to duplicate has been supplied!');
-    }
-
-    $apjvalue1       = intval($_GET['post']);
-
-    $apjvalue2       = intval($_POST['post']);
-
-    $post_id         = (isset($apjvalue1) ? $apjvalue1 : $apjvalue2);
-
-    $post            = get_post($post_id);
-
-    $current_user    = wp_get_current_user();
-    $new_post_author = $current_user->ID;
-
-    if (isset($post) && $post != null)
-    {
-
-        $args            = array(
-            'comment_status' => $post->comment_status,
-            'ping_status'    => $post->ping_status,
-            'post_author'    => $new_post_author,
-            'post_content'   => $post->post_content,
-            'post_excerpt'   => $post->post_excerpt,
-            'post_name'      => $post->post_name,
-            'post_parent'    => $post->post_parent,
-            'post_password'  => $post->post_password,
-            'post_status'    => 'draft',
-            'post_title'     => $post->post_title,
-            'post_type'      => $post->post_type,
-            'to_ping'        => $post->to_ping,
-            'menu_order'     => $post->menu_order
-        );
-
-        $new_post_id     = wp_insert_post($args);
-
-        $taxonomies      = get_object_taxonomies($post->post_type);
-        foreach ($taxonomies as $taxonomy)
-        {
-            $post_terms      = wp_get_object_terms($post_id, $taxonomy, array(
-                'fields' => 'slugs'
-            ));
-            wp_set_object_terms($new_post_id, $post_terms, $taxonomy, false);
-        }
 
-        $post_meta_infos = $wpdb->get_results("SELECT meta_key, meta_value FROM $wpdb->postmeta WHERE post_id=$post_id");
-        if (count($post_meta_infos) != 0)
-        {
-            $main_sql_query  = "INSERT INTO $wpdb->postmeta (post_id, meta_key, meta_value) ";
-            foreach ($post_meta_infos as $meta_info)
-            {
-                $meta_key        = $meta_info->meta_key;
-                $meta_value      = addslashes($meta_info->meta_value);
-                $sql_query_select[]                 = "SELECT $new_post_id, '$meta_key', '$meta_value'";
-            }
-            $main_sql_query .= implode(" UNION ALL ", $sql_query_select);
-            $wpdb->query($main_sql_query);
-        }
+    // Must be logged in and able to edit posts
+    if ( ! current_user_can( 'edit_posts' ) ) {
+        wp_die( "You don't have permission." );
+    }
+
+    // Validate request
+    if (
+        ! isset( $_GET['post'], $_GET['_wpnonce'] ) ||
+        ! isset( $_REQUEST['action'] ) ||
+        $_REQUEST['action'] !== 'apj_duplicate_post_as_a_draft'
+    ) {
+        wp_die( 'Invalid request.' );
+    }
+
+    $post_id = absint( $_GET['post'] );
+    if ( ! $post_id ) {
+        wp_die( 'Invalid post ID.' );
+    }
+
+    // 🔒 Verify nonce bound to post
+    check_admin_referer( 'wqpd_clone_post_' . $post_id );
+
+    $post = get_post( $post_id );
+    if ( ! $post ) {
+        wp_die( 'Post not found.' );
+    }
+
+    /**
+     * 🔒 CRITICAL FIX
+     * Ensure user is allowed to READ this post
+     */
+    if ( ! current_user_can( 'read_post', $post_id ) ) {
+        wp_die( 'You are not allowed to duplicate this post.' );
+    }
 
-        wp_redirect(admin_url('edit.php?post_type=' . $post->post_type));
-        exit;
+    /**
+     * Extra protection for private posts
+     */
+    if ( $post->post_status === 'private' && ! current_user_can( 'edit_post', $post_id ) ) {
+        wp_die( 'You are not allowed to duplicate private posts.' );
     }
-    else
-    { 
-        wp_die('Post creation failed, could not find original post: ' . $post_id);
+
+    $current_user = wp_get_current_user();
+
+    $args = array(
+        'comment_status' => $post->comment_status,
+        'ping_status'    => $post->ping_status,
+        'post_author'    => $current_user->ID,
+        'post_content'   => $post->post_content,
+        'post_excerpt'   => $post->post_excerpt,
+        'post_name'      => $post->post_name,
+        'post_parent'    => $post->post_parent,
+        'post_password'  => $post->post_password,
+        'post_status'    => 'draft',
+        'post_title'     => $post->post_title,
+        'post_type'      => $post->post_type,
+        'to_ping'        => $post->to_ping,
+        'menu_order'     => $post->menu_order,
+    );
+
+    $new_post_id = wp_insert_post( $args );
+
+    if ( is_wp_error( $new_post_id ) ) {
+        wp_die( 'Failed to create duplicate post.' );
+    }
+
+    // Copy taxonomies
+    $taxonomies = get_object_taxonomies( $post->post_type );
+    foreach ( $taxonomies as $taxonomy ) {
+        $terms = wp_get_object_terms( $post_id, $taxonomy, array( 'fields' => 'slugs' ) );
+        wp_set_object_terms( $new_post_id, $terms, $taxonomy, false );
     }
+
+    // Copy post meta (safe method)
+    $post_meta_infos = $wpdb->get_results(
+        $wpdb->prepare(
+            "SELECT meta_key, meta_value FROM $wpdb->postmeta WHERE post_id = %d",
+            $post_id
+        )
+    );
+
+    if ( $post_meta_infos ) {
+        foreach ( $post_meta_infos as $meta_info ) {
+            add_post_meta(
+                $new_post_id,
+                $meta_info->meta_key,
+                maybe_unserialize( $meta_info->meta_value )
+            );
+        }
+    }
+
+    wp_redirect( admin_url( 'edit.php?post_type=' . $post->post_type ) );
+    exit;
 }
-add_action('admin_action_apj_duplicate_post_as_a_draft', 'apj_duplicate_post_as_a_draft');
 
+add_action( 'admin_action_apj_duplicate_post_as_a_draft', 'apj_duplicate_post_as_a_draft' );

Exploit Outline

The exploit involves an authenticated attacker with Contributor-level permissions obtaining a valid nonce from a post they already have permission to duplicate. 1. Log in to the WordPress dashboard as a Contributor. 2. Access the Posts or Pages list (`edit.php`) and locate a post owned by the current user. 3. Extract the `_wpnonce` value from the "Duplicate This Item" link in the row actions (this nonce is associated with the `wqpd_clone_post_page_nonce` action string). 4. Identify the ID of a target post or page (e.g., an administrator's private page) that the Contributor cannot normally edit or see. 5. Send a GET request to `/wp-admin/admin.php` with the following parameters: `action=apj_duplicate_post_as_a_draft`, `post=[TARGET_ID]`, and `_wpnonce=[EXTRACTED_NONCE]`. 6. The plugin will create a new draft containing the content and metadata of the target post, but with the Contributor as the owner, allowing them to view and further edit the stolen content.

Check if your site is affected.

Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.